From dfc48f280fb7c445fdde2da5a3bfb705993b5761 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 21 Apr 2026 00:49:22 +0000 Subject: [PATCH] fix(workspace): permissive-by-default git mutation gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `datamachine_workspace_git_policies` option gates git mutation ops (pull / add / commit / push) and git_add's path allowlist. Before this change, unconfigured repos (the default state — no option row, no CLI to populate it, no docs describing it) would fail every git mutation with `git_write_disabled` / `no_allowed_paths`, making the workspace wrapper's git-family subcommands unusable out of the box and forcing callers back to plain `git -C ` shells. Root cause: the policy check treated "no entry" the same as "explicitly disabled". Combined with the lack of any way to configure the option (no CLI subcommand, no admin UI, no docs), this was effectively a hidden hard gate. Change: - `ensure_git_mutation_allowed()`: null entry → permissive (true). Entry present → honor explicit flags, with `write_enabled` / `push_enabled` defaulting to true when the flag itself is missing (so a partial config e.g. only `allowed_paths` doesn't accidentally lock out writes). - `git_add()`: empty allowlist → no restriction (sensitive-path and traversal checks still enforced unconditionally). - Error messages clarified to point at the policy option. - New `datamachine_workspace_git_policies` filter for runtime policy injection alongside the existing option. - `get_workspace_git_policies()` docblock documents schema + defaults. The primary-vs-worktree gate (`ensure_primary_mutation_allowed` + `--allow-primary-mutation` override) is the documented default safety mechanism (per AGENTS.md) and is unchanged. That's the intended protection layer. To restrict a repo explicitly: add an entry under `datamachine_workspace_git_policies.repos[]` with `write_enabled: false` / `push_enabled: false` / an `allowed_paths` list / a `fixed_branch` constraint. --- data-machine-code.php | 4 +- docs/CHANGELOG.md | 10 ++++ inc/Workspace/Workspace.php | 98 +++++++++++++++++++++++++++++++++---- 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/data-machine-code.php b/data-machine-code.php index 1bc36b7..d0cded6 100644 --- a/data-machine-code.php +++ b/data-machine-code.php @@ -3,7 +3,7 @@ * Plugin Name: Data Machine Code * Plugin URI: https://github.com/Extra-Chill/data-machine-code * Description: Developer tools extension for Data Machine. GitHub integration, workspace management, git operations, and code tools for WordPress AI agents. - * Version: 0.6.2 + * Version: 0.6.3 * Requires at least: 6.9 * Requires PHP: 8.2 * Requires Plugins: data-machine @@ -18,7 +18,7 @@ die; } -define( 'DATAMACHINE_CODE_VERSION', '0.6.2' ); +define( 'DATAMACHINE_CODE_VERSION', '0.6.3' ); define( 'DATAMACHINE_CODE_PATH', plugin_dir_path( __FILE__ ) ); define( 'DATAMACHINE_CODE_URL', plugin_dir_url( __FILE__ ) ); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3f9ba04..97349d5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to Data Machine Code will be documented in this file. +## [0.6.3] - 2026-04-21 + +### Fixed +- workspace git wrapper: `git pull/add/commit/push` no longer fail with `git_write_disabled` on repos that have no entry in `datamachine_workspace_git_policies`. Unconfigured repos now default to permissive (primary-vs-worktree protection remains via `--allow-primary-mutation`). Explicit `write_enabled: false` / `push_enabled: false` still deny. Error messages clarified to reference the policy option. +- `workspace git add`: `no_allowed_paths` no longer blocks unconfigured repos. The `allowed_paths` list is now opt-in — when configured, it restricts; when absent, any relative path is accepted (sensitive-path + traversal checks still enforced). + +### Added +- `datamachine_workspace_git_policies` filter for runtime-injected policy (alongside the existing option). Allows site config to declare per-repo policy without persisting to the option. +- Documentation in `get_workspace_git_policies()` describing the policy schema and defaults. + ## [0.6.2] - 2026-04-19 ### Changed diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index fd76a9d..657fa82 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -568,10 +568,11 @@ public function git_add( string $handle, array $paths, bool $allow_primary_mutat return new \WP_Error( 'missing_paths', 'At least one path is required for git add.', array( 'status' => 400 ) ); } + // Allowed paths are opt-in: when configured, they restrict which relative + // paths may be staged; when absent, any path within the repo is allowed. + // This mirrors ensure_git_mutation_allowed's permissive-by-default model. + // Sensitive-path + traversal checks still apply unconditionally. $allowed_roots = $this->get_repo_allowed_paths( $repo_name ); - if ( empty( $allowed_roots ) ) { - return new \WP_Error( 'no_allowed_paths', sprintf( 'No allowed paths configured for repo "%s".', $repo_name ), array( 'status' => 403 ) ); - } $clean_paths = array(); foreach ( $paths as $path ) { @@ -588,7 +589,8 @@ public function git_add( string $handle, array $paths, bool $allow_primary_mutat return new \WP_Error( 'sensitive_path', sprintf( 'Refusing to stage sensitive path: %s', $relative ), array( 'status' => 403 ) ); } - if ( ! $this->is_path_allowed( $relative, $allowed_roots ) ) { + // Only enforce the allowlist when one has been configured. + if ( ! empty( $allowed_roots ) && ! $this->is_path_allowed( $relative, $allowed_roots ) ) { return new \WP_Error( 'path_not_allowed', sprintf( 'Path "%s" is outside configured allowlist.', $relative ), array( 'status' => 403 ) ); } @@ -1650,6 +1652,19 @@ private function ensure_primary_mutation_allowed( array $parsed, bool $allow ): /** * Check if repo has git mutation permissions enabled. * + * Unconfigured repos are permissive by default: `datamachine_workspace_git_policies` + * is an opt-in restriction layer. When no entry exists for $repo_name, both + * write and push are allowed. When an entry exists, its flags (`write_enabled`, + * `push_enabled`) are honored; missing flags default to `true` so a partial + * config doesn't accidentally lock out ops that weren't explicitly restricted. + * + * The primary-vs-worktree gate (see ensure_primary_mutation_allowed) remains + * the documented default safety mechanism for protecting tracked branches. + * + * To explicitly deny a repo, add an entry with `write_enabled: false` and/or + * `push_enabled: false`. The `datamachine_workspace_git_policies` filter also + * lets plugins inject policy at runtime. + * * @param string $repo_name Repository name. * @param bool $require_push Whether push must also be enabled. * @return true|\WP_Error @@ -1658,12 +1673,43 @@ private function ensure_git_mutation_allowed( string $repo_name, bool $require_p $policies = $this->get_workspace_git_policies(); $repo = $policies['repos'][ $repo_name ] ?? null; - if ( ! is_array( $repo ) || empty( $repo['write_enabled'] ) ) { - return new \WP_Error( 'git_write_disabled', sprintf( 'Git write operations are disabled for repo "%s".', $repo_name ), array( 'status' => 403 ) ); + // No entry = permissive default. Callers should still respect + // primary-vs-worktree separation via ensure_primary_mutation_allowed. + if ( null === $repo ) { + return true; + } + + if ( ! is_array( $repo ) ) { + return true; } - if ( $require_push && empty( $repo['push_enabled'] ) ) { - return new \WP_Error( 'git_push_disabled', sprintf( 'Git push is disabled for repo "%s".', $repo_name ), array( 'status' => 403 ) ); + // Entry exists — honor explicit flags. Missing flags default to true + // so a partial config (e.g. only setting allowed_paths) doesn't + // accidentally disable write/push. + $write_enabled = array_key_exists( 'write_enabled', $repo ) ? (bool) $repo['write_enabled'] : true; + if ( ! $write_enabled ) { + return new \WP_Error( + 'git_write_disabled', + sprintf( + 'Git write operations are explicitly disabled for repo "%s" via datamachine_workspace_git_policies.', + $repo_name + ), + array( 'status' => 403 ) + ); + } + + if ( $require_push ) { + $push_enabled = array_key_exists( 'push_enabled', $repo ) ? (bool) $repo['push_enabled'] : true; + if ( ! $push_enabled ) { + return new \WP_Error( + 'git_push_disabled', + sprintf( + 'Git push is explicitly disabled for repo "%s" via datamachine_workspace_git_policies.', + $repo_name + ), + array( 'status' => 403 ) + ); + } } return true; @@ -1760,7 +1806,21 @@ private function has_traversal( string $path ): bool { /** * Read workspace git policy settings. * - * @return array + * The option defaults to an empty array, which is treated as "no + * restrictions" by the mutation gates (see ensure_git_mutation_allowed + * and git_add's allowed_paths check). To restrict a repo, configure an + * entry under `repos[]` with any combination of: + * + * - write_enabled (bool, default true) + * - push_enabled (bool, default true) + * - allowed_paths (string[], optional — if set, restricts git_add) + * - fixed_branch (string, optional — constrains push on primary) + * + * The `datamachine_workspace_git_policies` filter allows plugins and + * site configuration to inject or override policy at runtime without + * touching the stored option. + * + * @return array{repos: array>} */ private function get_workspace_git_policies(): array { $defaults = array( @@ -1769,13 +1829,31 @@ private function get_workspace_git_policies(): array { $settings = get_option( 'datamachine_workspace_git_policies', $defaults ); if ( ! is_array( $settings ) ) { - return $defaults; + $settings = $defaults; } if ( ! isset( $settings['repos'] ) || ! is_array( $settings['repos'] ) ) { $settings['repos'] = array(); } + /** + * Filter the workspace git policy map. + * + * Allows plugins to inject or override per-repo git mutation policy + * without persisting changes to the `datamachine_workspace_git_policies` + * option. Useful for environment-specific policy (dev vs prod) or for + * tying policy to site configuration managed elsewhere. + * + * @since 0.x.0 + * + * @param array $settings Current policy array, shape { repos: { : {...} } }. + */ + $settings = apply_filters( 'datamachine_workspace_git_policies', $settings ); + + if ( ! is_array( $settings ) || ! isset( $settings['repos'] ) || ! is_array( $settings['repos'] ) ) { + return $defaults; + } + return $settings; }