diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 2deece2..9cd3e97 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -113,8 +113,8 @@ private function registerAbilities(): void { ), 'type' => array( 'type' => 'string', - 'enum' => array( 'primary', 'worktree' ), - 'description' => 'Optional checkout type filter. Use "primary" for base checkouts or "worktree" for branch worktrees.', + 'enum' => array( 'primary', 'worktree', 'context' ), + 'description' => 'Optional checkout type filter. Use "primary" for base checkouts, "worktree" for branch worktrees, or "context" for read-only context repositories.', ), ), ), diff --git a/inc/Tools/WorkspaceTools.php b/inc/Tools/WorkspaceTools.php index fb9fe7e..31bf15c 100644 --- a/inc/Tools/WorkspaceTools.php +++ b/inc/Tools/WorkspaceTools.php @@ -1035,8 +1035,8 @@ public function getListDefinition(): array ), 'type' => array( 'type' => 'string', - 'enum' => array( 'primary', 'worktree' ), - 'description' => 'Optional checkout type filter. Use "primary" for base checkouts or "worktree" for branch worktrees.', + 'enum' => array( 'primary', 'worktree', 'context' ), + 'description' => 'Optional checkout type filter. Use "primary" for base checkouts, "worktree" for branch worktrees, or "context" for read-only context repositories.', ), ), 'required' => array(), diff --git a/inc/Workspace/RemoteWorkspaceBackend.php b/inc/Workspace/RemoteWorkspaceBackend.php index 7bdc025..f966e1e 100644 --- a/inc/Workspace/RemoteWorkspaceBackend.php +++ b/inc/Workspace/RemoteWorkspaceBackend.php @@ -198,6 +198,11 @@ public function worktree_prune(): array { * @return array|\WP_Error */ public function read_file( string $handle, string $path, int $max_size, ?int $offset = null, ?int $limit = null ): array|\WP_Error { + $policy_error = WorkspaceAliasResolver::read_error_if_disallowed($handle, $path); + if ( null !== $policy_error ) { + return $policy_error; + } + $context = $this->resolve_handle($handle); if ( is_wp_error($context) ) { return $context; @@ -234,13 +239,18 @@ public function read_file( string $handle, string $path, int $max_size, ?int $of $result_content = implode("\n", $lines); } - return array( + $result = array( 'success' => true, 'backend' => 'github_api', 'content' => $result_content, 'path' => $path, 'size' => $size, ); + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + $result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($handle); + } + + return $result; } /** @@ -249,6 +259,11 @@ public function read_file( string $handle, string $path, int $max_size, ?int $of * @return array|\WP_Error */ public function list_directory( string $handle, ?string $path = null ): array|\WP_Error { + $policy_error = WorkspaceAliasResolver::read_error_if_disallowed($handle, $path ?? ''); + if ( null !== $policy_error ) { + return $policy_error; + } + $context = $this->resolve_handle($handle); if ( is_wp_error($context) ) { return $context; @@ -287,13 +302,20 @@ public function list_directory( string $handle, ?string $path = null ): array|\W ); } - return array( + $entries = WorkspaceAliasResolver::filter_context_entries($handle, '' === $prefix ? '/' : $prefix, $entries); + + $result = array( 'success' => true, 'backend' => 'github_api', 'repo' => $handle, 'path' => '' === $prefix ? '/' : $prefix, 'entries' => $entries, ); + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + $result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($handle); + } + + return $result; } /** @@ -302,6 +324,11 @@ public function list_directory( string $handle, ?string $path = null ): array|\W * @return array|\WP_Error */ public function grep( string $handle, string $pattern, ?string $path = null, ?string $include_pattern = null, int $max_results = 100, int $context_lines = 0 ): array|\WP_Error { + $policy_error = WorkspaceAliasResolver::read_error_if_disallowed($handle, $path ?? ''); + if ( null !== $policy_error ) { + return $policy_error; + } + $context = $this->resolve_handle($handle); if ( is_wp_error($context) ) { return $context; @@ -353,7 +380,11 @@ public function grep( string $handle, string $pattern, ?string $path = null, ?st } foreach ( $files as $file ) { - $file_path = (string) ( $file['path'] ?? '' ); + $file_path = (string) ( $file['path'] ?? '' ); + $context_policy = WorkspaceAliasResolver::context_policy_for($handle); + if ( null !== $context_policy && ! WorkspaceAliasResolver::path_allowed_by_policy($file_path, $context_policy) ) { + continue; + } if ( '' === $file_path || isset($seen[ $file_path ]) || ! $this->path_matches_include($file_path, $include_pattern) ) { continue; } @@ -380,7 +411,7 @@ public function grep( string $handle, string $pattern, ?string $path = null, ?st } } - return array( + $result = array( 'success' => true, 'backend' => 'github_api', 'repo' => $handle, @@ -390,6 +421,11 @@ public function grep( string $handle, string $pattern, ?string $path = null, ?st 'count' => count($matches), 'truncated' => count($matches) >= $max_results, ); + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + $result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($handle); + } + + return $result; } /** @@ -398,6 +434,10 @@ public function grep( string $handle, string $pattern, ?string $path = null, ?st * @return array|\WP_Error */ public function write_file( string $handle, string $path, string $content ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + return WorkspaceAliasResolver::mutation_error($handle, 'write'); + } + $context = $this->resolve_handle($handle); if ( is_wp_error($context) ) { return $context; @@ -428,6 +468,10 @@ public function write_file( string $handle, string $path, string $content ): arr * @return array|\WP_Error */ public function edit_file( string $handle, string $path, string $old_string, string $new_string, bool $replace_all = false ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + return WorkspaceAliasResolver::mutation_error($handle, 'edit'); + } + $context = $this->resolve_handle($handle); if ( is_wp_error($context) ) { return $context; @@ -490,12 +534,13 @@ public function show( string $handle ): array|\WP_Error { $files = array_values(array_unique(array_values( (array) $context['changed_files']))); - return array( + $result = array( 'success' => true, 'backend' => 'github_api', 'name' => $handle, 'repo' => $context['repo_name'], - 'is_worktree' => isset($context['branch']) && '' !== (string) $context['branch'], + 'is_worktree' => empty($context['read_only_context']) && isset($context['branch']) && '' !== (string) $context['branch'], + 'is_context' => ! empty($context['read_only_context']), 'path' => 'github://' . $context['repo'] . ( '' !== (string) $context['branch'] ? '#' . $context['branch'] : '' ), @@ -505,6 +550,11 @@ public function show( string $handle ): array|\WP_Error { 'dirty' => count($files), 'files' => $files, ); + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + $result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($handle); + } + + return $result; } /** @@ -514,6 +564,10 @@ public function show( string $handle ): array|\WP_Error { */ public function git_diff( string $handle, ?string $from = null, ?string $to = null, bool $staged = false, ?string $path = null ): array|\WP_Error { unset($staged); + $policy_error = WorkspaceAliasResolver::read_error_if_disallowed($handle, $path ?? ''); + if ( null !== $policy_error ) { + return $policy_error; + } $context = $this->resolve_handle($handle); if ( is_wp_error($context) ) { @@ -549,13 +603,18 @@ public function git_diff( string $handle, ?string $from = null, ?string $to = nu $diff .= $this->build_unified_file_diff($changed_path, $old_content, (string) $new_content); } - return array( + $result = array( 'success' => true, 'backend' => 'github_api', 'name' => $handle, 'repo' => $context['repo_name'], 'diff' => $diff, ); + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + $result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($handle); + } + + return $result; } /** @@ -569,13 +628,14 @@ public function git_status( string $handle ): array|\WP_Error { return $context; } - $files = array_values(array_unique(array_values( (array) $context['changed_files']))); - return array( + $files = array_values(array_unique(array_values( (array) $context['changed_files']))); + $result = array( 'success' => true, 'backend' => 'github_api', 'name' => $handle, 'repo' => $context['repo_name'], - 'is_worktree' => true, + 'is_worktree' => empty($context['read_only_context']), + 'is_context' => ! empty($context['read_only_context']), 'path' => 'github://' . $context['repo'] . '#' . $context['branch'], 'branch' => $context['branch'], 'remote' => 'https://github.com/' . $context['repo'] . '.git', @@ -583,6 +643,11 @@ public function git_status( string $handle ): array|\WP_Error { 'dirty' => count($files), 'files' => $files, ); + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + $result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($handle); + } + + return $result; } /** @@ -591,6 +656,10 @@ public function git_status( string $handle ): array|\WP_Error { * @return array|\WP_Error */ public function git_add( string $handle, array $paths ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + return WorkspaceAliasResolver::mutation_error($handle, 'git add'); + } + $context = $this->resolve_handle($handle); if ( is_wp_error($context) ) { return $context; @@ -611,6 +680,10 @@ public function git_add( string $handle, array $paths ): array|\WP_Error { * @return array|\WP_Error */ public function git_commit( string $handle, string $message ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + return WorkspaceAliasResolver::mutation_error($handle, 'git commit'); + } + $context = $this->resolve_handle($handle); if ( is_wp_error($context) ) { return $context; @@ -1103,6 +1176,10 @@ private function group_diff_hunks( array $ops, int $context_lines ): array { * @return array|\WP_Error */ public function git_push( string $handle, string $remote = 'origin', ?string $branch = null ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + return WorkspaceAliasResolver::mutation_error($handle, 'git push'); + } + $context = $this->resolve_handle($handle); if ( is_wp_error($context) ) { return $context; @@ -1131,6 +1208,26 @@ public function git_push( string $handle, string $remote = 'origin', ?string $br * @return array */ private function resolve_handle( string $handle ): array|\WP_Error { + $context_policy = WorkspaceAliasResolver::context_policy_for($handle); + if ( null !== $context_policy ) { + $repo = (string) ( $context_policy['repo'] ?? '' ); + if ( '' === $repo ) { + $repo = (string) ( $context_policy['target'] ?? $handle ); + } + + return array( + 'handle' => (string) $context_policy['alias'], + 'repo_name' => (string) $context_policy['alias'], + 'repo' => $repo, + 'branch' => (string) ( $context_policy['ref'] ?? '' ), + 'read_ref' => (string) ( $context_policy['ref'] ?? '' ), + 'pending_files' => array(), + 'changed_files' => array(), + 'last_commit_sha' => '', + 'read_only_context' => true, + ); + } + $handle = $this->resolve_alias($handle); $state = $this->state(); if ( isset($state['worktrees'][ $handle ]) ) { diff --git a/inc/Workspace/WorkspaceAliasResolver.php b/inc/Workspace/WorkspaceAliasResolver.php index ea7dc30..190a52b 100644 --- a/inc/Workspace/WorkspaceAliasResolver.php +++ b/inc/Workspace/WorkspaceAliasResolver.php @@ -13,7 +13,8 @@ class WorkspaceAliasResolver { - private const OPTION = 'datamachine_code_workspace_aliases'; + private const OPTION = 'datamachine_code_workspace_aliases'; + private const CONTEXT_OPTION = 'datamachine_code_context_repositories'; /** * Return normalized alias specs keyed by agent-facing alias. @@ -21,7 +22,7 @@ class WorkspaceAliasResolver { * Runners can persist aliases in the option, or inject them just-in-time via * the filter when a single conversation should see an opaque project name. * - * @return array + * @return array}> */ public static function aliases(): array { $aliases = function_exists('get_option') ? get_option(self::OPTION, array()) : array(); @@ -33,6 +34,17 @@ public static function aliases(): array { $aliases = apply_filters('datamachine_code_workspace_aliases', $aliases); } + foreach ( self::context_repositories() as $alias => $context ) { + $aliases[ $alias ] = array_merge( + $context, + array( + 'target' => (string) ( $context['target'] ?? $alias ), + 'access' => 'read_only', + 'is_context' => true, + ) + ); + } + $normalized = array(); foreach ( $aliases as $alias => $spec ) { $alias = trim( (string) $alias); @@ -48,8 +60,13 @@ public static function aliases(): array { continue; } $normalized[ $alias ] = array( - 'target' => $handle, - 'root' => $root, + 'target' => $handle, + 'root' => $root, + 'access' => is_array($spec) ? (string) ( $spec['access'] ?? 'read_write' ) : 'read_write', + 'is_context' => is_array($spec) && ! empty($spec['is_context']), + 'repo' => is_array($spec) ? (string) ( $spec['repo'] ?? '' ) : '', + 'ref' => is_array($spec) ? (string) ( $spec['ref'] ?? '' ) : '', + 'paths' => is_array($spec) ? self::normalize_paths($spec['paths'] ?? array()) : array(), ); } @@ -64,7 +81,7 @@ public static function resolve( string $handle ): string { /** * Return the normalized alias spec for an agent-facing handle. * - * @return array{target:string,root:string}|null + * @return array{target:string,root:string,access:string,is_context:bool,repo:string,ref:string,paths:array}|null */ public static function spec( string $handle ): ?array { return self::aliases()[ $handle ] ?? null; @@ -88,6 +105,177 @@ public static function root_for( string $handle ): string { return self::aliases()[ $handle ]['root'] ?? ''; } + /** + * Return normalized read-only context repository specs keyed by alias. + * + * Runners can persist the list in `datamachine_code_context_repositories` or + * inject it per run via the `datamachine_code_context_repositories` filter. + * + * @return array> + */ + public static function context_repositories(): array { + $contexts = function_exists('get_option') ? get_option(self::CONTEXT_OPTION, array()) : array(); + if ( ! is_array($contexts) ) { + $contexts = array(); + } + + if ( function_exists('apply_filters') ) { + $contexts = apply_filters('datamachine_code_context_repositories', $contexts); + } + + $normalized = array(); + foreach ( $contexts as $key => $spec ) { + if ( ! is_array($spec) ) { + continue; + } + + $alias = trim( (string) ( $spec['alias'] ?? $key )); + if ( '' === $alias ) { + continue; + } + + $repo = trim( (string) ( $spec['repo'] ?? '' )); + $target = trim( (string) ( $spec['target'] ?? $spec['handle'] ?? $spec['name'] ?? $alias )); + $root = self::normalize_root( (string) ( $spec['root'] ?? $spec['agent_root'] ?? $spec['path_prefix'] ?? '' )); + $paths = self::normalize_paths($spec['paths'] ?? array()); + + $normalized[ $alias ] = array( + 'alias' => $alias, + 'repo' => $repo, + 'ref' => trim( (string) ( $spec['ref'] ?? '' )), + 'target' => $target, + 'root' => $root, + 'paths' => $paths, + ); + } + + return $normalized; + } + + /** + * Return context policy for either the alias or its resolved target handle. + * + * @return array|null + */ + public static function context_policy_for( string $handle ): ?array { + foreach ( self::aliases() as $alias => $spec ) { + if ( empty($spec['is_context']) ) { + continue; + } + + if ( $handle === $alias || $handle === (string) $spec['target'] ) { + return array_merge($spec, array( 'alias' => $alias )); + } + } + + return null; + } + + public static function is_context_repository( string $handle ): bool { + return null !== self::context_policy_for($handle); + } + + public static function mutation_error( string $handle, string $operation = 'mutation' ): \WP_Error { + $policy = self::context_policy_for($handle); + $alias = (string) ( $policy['alias'] ?? $handle ); + + return new \WP_Error( + 'context_repository_read_only', + sprintf( + 'Context repository "%s" is read-only for this run; %s operations must target the writable workspace repository instead.', + $alias, + $operation + ), + array( + 'status' => 403, + 'workspace_policy' => self::policy_attestation($handle), + ) + ); + } + + public static function read_error_if_disallowed( string $handle, ?string $path ): ?\WP_Error { + $policy = self::context_policy_for($handle); + if ( null === $policy ) { + return null; + } + + $normalized_path = self::normalize_path( (string) ( $path ?? '' ) ); + if ( self::path_allowed_by_policy($normalized_path, $policy, true) ) { + return null; + } + + return new \WP_Error( + 'context_repository_path_not_allowed', + sprintf('Path "%s" is outside the read allowlist for context repository "%s".', '' === $normalized_path ? '/' : $normalized_path, (string) $policy['alias']), + array( + 'status' => 403, + 'workspace_policy' => self::policy_attestation($handle), + ) + ); + } + + public static function filter_context_entries( string $handle, string $listed_path, array $entries ): array { + $policy = self::context_policy_for($handle); + if ( null === $policy || empty($policy['paths']) ) { + return $entries; + } + + $base = self::normalize_path($listed_path); + return array_values( + array_filter( + $entries, + static function ( array $entry ) use ( $base, $policy ): bool { + $name = (string) ( $entry['name'] ?? '' ); + $path = '' === $base || '/' === $base ? $name : $base . '/' . $name; + return self::path_allowed_by_policy($path, $policy, true); + } + ) + ); + } + + public static function policy_attestation( string $handle ): array { + $policy = self::context_policy_for($handle); + if ( null === $policy ) { + return array( + 'access' => 'read_write', + 'writable' => true, + 'read_only' => false, + ); + } + + $attestation = array( + 'access' => 'read_only', + 'writable' => false, + 'read_only' => true, + 'alias' => (string) $policy['alias'], + 'target' => (string) $policy['target'], + 'repo' => (string) ( $policy['repo'] ?? '' ), + 'ref' => (string) ( $policy['ref'] ?? '' ), + 'allowed_paths' => array_values( (array) ( $policy['paths'] ?? array() ) ), + ); + + $policy_json = function_exists('wp_json_encode') ? wp_json_encode($attestation) : http_build_query($attestation, '', '&', PHP_QUERY_RFC3986); + $attestation['policy_hash'] = hash('sha256', (string) $policy_json); + + return $attestation; + } + + public static function path_allowed_by_policy( string $path, array $policy, bool $allow_ancestors = false ): bool { + $paths = self::normalize_paths($policy['paths'] ?? array()); + if ( empty($paths) ) { + return true; + } + + $path = self::normalize_path($path); + foreach ( $paths as $allowed ) { + if ( self::path_matches($path, $allowed, $allow_ancestors) ) { + return true; + } + } + + return false; + } + public static function scope_path( string $path, string $root ): string|false { $root = self::normalize_root($root); if ( '' === $root ) { @@ -182,6 +370,64 @@ private static function normalize_root( string $root ): string { return implode('/', $segments); } + private static function normalize_paths( mixed $paths ): array { + if ( ! is_array($paths) ) { + return array(); + } + + $normalized = array(); + foreach ( $paths as $path ) { + $path = self::normalize_path( (string) $path ); + if ( '' !== $path ) { + $normalized[] = $path; + } + } + + return array_values(array_unique($normalized)); + } + + private static function normalize_path( string $path ): string { + $path = trim(str_replace('\\', '/', $path), '/'); + $segments = array(); + foreach ( explode('/', $path) as $segment ) { + if ( '' === $segment || '.' === $segment || '..' === $segment || str_contains($segment, "\0") ) { + continue; + } + $segments[] = $segment; + } + + return implode('/', $segments); + } + + private static function path_matches( string $path, string $allowed, bool $allow_ancestors ): bool { + $path = self::normalize_path($path); + $allowed = self::normalize_path($allowed); + if ( '' === $allowed ) { + return true; + } + + if ( $path === $allowed || fnmatch($allowed, $path) ) { + return true; + } + + if ( str_ends_with($allowed, '/**') ) { + $prefix = substr($allowed, 0, -3); + if ( $path === $prefix || str_starts_with($path, $prefix . '/') ) { + return true; + } + } + + if ( $allow_ancestors && '' === $path ) { + return true; + } + + if ( $allow_ancestors ) { + return str_starts_with($allowed, $path . '/'); + } + + return false; + } + private static function sanitize_scoped_string( string $value, string $root ): string { $root = self::normalize_root($root); if ( '' === $root ) { diff --git a/inc/Workspace/WorkspaceGitOperations.php b/inc/Workspace/WorkspaceGitOperations.php index 0463b54..1577940 100644 --- a/inc/Workspace/WorkspaceGitOperations.php +++ b/inc/Workspace/WorkspaceGitOperations.php @@ -62,6 +62,9 @@ public function git_status( string $handle ): array|\WP_Error { if ( null !== $policy_attestation ) { $response['workspace_policy'] = $policy_attestation; } + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + $response['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($handle); + } return $response; } @@ -74,6 +77,10 @@ public function git_status( string $handle ): array|\WP_Error { * @return array */ public function git_pull( string $handle, bool $allow_dirty = false, bool $allow_primary_mutation = false ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + return WorkspaceAliasResolver::mutation_error($handle, 'git pull'); + } + $parsed = $this->parse_handle($handle); $repo_path = $this->resolve_repo_path($handle); if ( is_wp_error($repo_path) ) { @@ -120,6 +127,10 @@ public function git_pull( string $handle, bool $allow_dirty = false, bool $allow * @return array */ public function git_add( string $handle, array $paths, bool $allow_primary_mutation = false ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + return WorkspaceAliasResolver::mutation_error($handle, 'git add'); + } + $parsed = $this->parse_handle($handle); $repo_name = $parsed['repo']; $repo_path = $this->resolve_repo_path($handle); @@ -217,6 +228,10 @@ public function git_add( string $handle, array $paths, bool $allow_primary_mutat * @return array{success: bool, name: string, repo: string, path: string, deleted: array, was_tracked: bool}|\WP_Error */ public function delete_path( string $handle, string $path, bool $recursive = false, bool $allow_primary_mutation = false ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + return WorkspaceAliasResolver::mutation_error($handle, 'delete'); + } + $parsed = $this->parse_handle($handle); $repo_name = $parsed['repo']; $repo_path = $this->resolve_repo_path($handle); @@ -360,6 +375,10 @@ private function remove_directory_recursive( string $absolute, string $repo_path * @return array */ public function git_commit( string $handle, string $message, bool $allow_primary_mutation = false ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + return WorkspaceAliasResolver::mutation_error($handle, 'git commit'); + } + $parsed = $this->parse_handle($handle); $repo_name = $parsed['repo']; $repo_path = $this->resolve_repo_path($handle); @@ -438,6 +457,10 @@ public function git_commit( string $handle, string $message, bool $allow_primary * @return array */ public function git_push( string $handle, string $remote = 'origin', ?string $branch = null, bool $allow_primary_mutation = false, bool $force_with_lease = false, ?string $expected_sha = null ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + return WorkspaceAliasResolver::mutation_error($handle, 'git push'); + } + $parsed = $this->parse_handle($handle); $repo_name = $parsed['repo']; $repo_path = $this->resolve_repo_path($handle); @@ -557,6 +580,10 @@ public function git_push( string $handle, string $remote = 'origin', ?string $br * @return array|\WP_Error */ public function git_rebase( string $handle, ?string $onto = null, ?string $strategy_option = null, bool $continue_rebase = false, bool $allow_primary_mutation = false ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + return WorkspaceAliasResolver::mutation_error($handle, 'git rebase'); + } + $parsed = $this->parse_handle($handle); $repo_name = $parsed['repo']; $repo_path = $this->resolve_repo_path($handle); @@ -627,6 +654,10 @@ public function git_rebase( string $handle, ?string $onto = null, ?string $strat * @return array|\WP_Error */ public function git_reset( string $handle, string $mode = 'mixed', ?string $target = null, bool $allow_destructive = false, bool $allow_primary_mutation = false ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($handle) ) { + return WorkspaceAliasResolver::mutation_error($handle, 'git reset'); + } + $parsed = $this->parse_handle($handle); $repo_name = $parsed['repo']; $repo_path = $this->resolve_repo_path($handle); @@ -890,12 +921,17 @@ public function git_log( string $name, int $limit = 20 ): array|\WP_Error { } $parsed = $this->parse_handle($name); - return array( + $result = array( 'success' => true, 'name' => $parsed['dir_name'], 'repo' => $parsed['repo'], 'entries' => $entries, ); + if ( WorkspaceAliasResolver::is_context_repository($name) ) { + $result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($name); + } + + return $result; } /** @@ -909,6 +945,11 @@ public function git_log( string $name, int $limit = 20 ): array|\WP_Error { * @return array */ public function git_diff( string $name, ?string $from = null, ?string $to = null, bool $staged = false, ?string $path = null ): array|\WP_Error { + $policy_error = WorkspaceAliasResolver::read_error_if_disallowed($name, $path ?? ''); + if ( null !== $policy_error ) { + return $policy_error; + } + $repo_path = $this->resolve_repo_path($name); if ( is_wp_error($repo_path) ) { return $repo_path; @@ -943,12 +984,17 @@ public function git_diff( string $name, ?string $from = null, ?string $to = null } $parsed = $this->parse_handle($name); - return array( + $result = array( 'success' => true, 'name' => $parsed['dir_name'], 'repo' => $parsed['repo'], 'diff' => $diff['output'] ?? '', ); + if ( WorkspaceAliasResolver::is_context_repository($name) ) { + $result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($name); + } + + return $result; } /** diff --git a/inc/Workspace/WorkspaceReader.php b/inc/Workspace/WorkspaceReader.php index 2b9b37f..a454588 100644 --- a/inc/Workspace/WorkspaceReader.php +++ b/inc/Workspace/WorkspaceReader.php @@ -40,6 +40,11 @@ public function __construct( Workspace $workspace ) { * @return array{success: bool, content?: string, path?: string, size?: int, lines_read?: int, offset?: int}|\WP_Error */ public function read_file( string $name, string $path, int $max_size = Workspace::MAX_READ_SIZE, ?int $offset = null, ?int $limit = null ): array|\WP_Error { + $policy_error = WorkspaceAliasResolver::read_error_if_disallowed($name, $path); + if ( null !== $policy_error ) { + return $policy_error; + } + $repo_path = $this->workspace->get_repo_path($name); $path = ltrim($path, '/'); @@ -51,10 +56,10 @@ public function read_file( string $name, string $path, int $max_size = Workspace $validation = $this->workspace->validate_containment($file_path, $repo_path); if ( ! $validation['valid'] ) { - return new \WP_Error('path_traversal', $validation['message'], array( 'status' => 403 )); + return new \WP_Error('path_traversal', (string) ( $validation['message'] ?? 'Path traversal detected. Access denied.' ), array( 'status' => 403 )); } - $real_path = $validation['real_path']; + $real_path = (string) ( $validation['real_path'] ?? '' ); if ( ! is_file($real_path) ) { return new \WP_Error('file_not_found', sprintf('File not found: %s', $path), array( 'status' => 404 )); @@ -65,6 +70,9 @@ public function read_file( string $name, string $path, int $max_size = Workspace } $size = filesize($real_path); + if ( false === $size ) { + return new \WP_Error('file_size_failed', sprintf('Failed to determine file size: %s', $path), array( 'status' => 500 )); + } if ( $size > $max_size ) { return new \WP_Error( @@ -117,6 +125,10 @@ public function read_file( string $name, string $path, int $max_size = Workspace 'size' => $size, ); + if ( WorkspaceAliasResolver::is_context_repository($name) ) { + $result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($name); + } + if ( null !== $offset || null !== $limit ) { $result['lines_read'] = $lines_read; $result['offset'] = $start_line; @@ -133,6 +145,11 @@ public function read_file( string $name, string $path, int $max_size = Workspace * @return array{success: bool, repo?: string, path?: string, entries?: array}|\WP_Error */ public function list_directory( string $name, ?string $path = null ): array|\WP_Error { + $policy_error = WorkspaceAliasResolver::read_error_if_disallowed($name, $path ?? ''); + if ( null !== $policy_error ) { + return $policy_error; + } + $repo_path = $this->workspace->get_repo_path($name); if ( ! is_dir($repo_path) ) { @@ -148,10 +165,10 @@ public function list_directory( string $name, ?string $path = null ): array|\WP_ $validation = $this->workspace->validate_containment($target_path, $repo_path); if ( ! $validation['valid'] ) { - return new \WP_Error('path_traversal', $validation['message'], array( 'status' => 403 )); + return new \WP_Error('path_traversal', (string) ( $validation['message'] ?? 'Path traversal detected. Access denied.' ), array( 'status' => 403 )); } - $target_path = $validation['real_path']; + $target_path = (string) ( $validation['real_path'] ?? '' ); } if ( ! is_dir($target_path) ) { @@ -195,12 +212,20 @@ function ( $a, $b ) { } ); - return array( + $items = WorkspaceAliasResolver::filter_context_entries($name, $path ?? '/', $items); + + $result = array( 'success' => true, 'repo' => $name, 'path' => $path ?? '/', 'entries' => $items, ); + + if ( WorkspaceAliasResolver::is_context_repository($name) ) { + $result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($name); + } + + return $result; } /** @@ -215,6 +240,11 @@ function ( $a, $b ) { * @return array{success: bool, repo?: string, path?: string, pattern?: string, matches?: array, count?: int, truncated?: bool}|\WP_Error */ public function grep( string $name, string $pattern, ?string $path = null, ?string $include_pattern = null, int $max_results = 100, int $context_lines = 0 ): array|\WP_Error { + $policy_error = WorkspaceAliasResolver::read_error_if_disallowed($name, $path ?? ''); + if ( null !== $policy_error ) { + return $policy_error; + } + $repo_path = $this->workspace->get_repo_path($name); if ( ! is_dir($repo_path) ) { return new \WP_Error('repo_not_found', sprintf('Repository "%s" not found in workspace.', $name), array( 'status' => 404 )); @@ -232,9 +262,9 @@ public function grep( string $name, string $pattern, ?string $path = null, ?stri $target_path = $repo_real . '/' . $path; $validation = $this->workspace->validate_containment($target_path, $repo_real); if ( ! $validation['valid'] ) { - return new \WP_Error('path_traversal', $validation['message'], array( 'status' => 403 )); + return new \WP_Error('path_traversal', (string) ( $validation['message'] ?? 'Path traversal detected. Access denied.' ), array( 'status' => 403 )); } - $target_path = $validation['real_path']; + $target_path = (string) ( $validation['real_path'] ?? '' ); $search_path = $path; } @@ -253,7 +283,11 @@ public function grep( string $name, string $pattern, ?string $path = null, ?stri $files = is_file($target_path) ? array( $target_path ) : $this->iterable_files($target_path); foreach ( $files as $file_path ) { - $relative_path = ltrim(substr($file_path, strlen($repo_real)), '/'); + $relative_path = ltrim(substr($file_path, strlen($repo_real)), '/'); + $context_policy = WorkspaceAliasResolver::context_policy_for($name); + if ( null !== $context_policy && ! WorkspaceAliasResolver::path_allowed_by_policy($relative_path, $context_policy) ) { + continue; + } if ( str_starts_with($relative_path, '.git/') || ! $this->path_matches_include($relative_path, $include_pattern) ) { continue; } @@ -269,7 +303,7 @@ public function grep( string $name, string $pattern, ?string $path = null, ?stri } } - return array( + $result = array( 'success' => true, 'repo' => $name, 'path' => $search_path, @@ -278,6 +312,12 @@ public function grep( string $name, string $pattern, ?string $path = null, ?stri 'count' => count($matches), 'truncated' => count($matches) >= $max_results, ); + + if ( WorkspaceAliasResolver::is_context_repository($name) ) { + $result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($name); + } + + return $result; } private function compile_search_pattern( string $pattern ): string|\WP_Error { @@ -331,6 +371,10 @@ private function path_matches_include( string $path, ?string $include_pattern ): * @return array>|\WP_Error */ private function grep_file( string $repo, string $file_path, string $relative_path, string $regex, int $context_lines, int $limit ): array|\WP_Error { + if ( $limit < 1 ) { + return new \WP_Error('grep_limit_exhausted', 'Search result limit exhausted.', array( 'status' => 400 )); + } + $size = filesize($file_path); if ( false === $size || $size > Workspace::MAX_READ_SIZE ) { return array(); diff --git a/inc/Workspace/WorkspaceRepositoryLifecycle.php b/inc/Workspace/WorkspaceRepositoryLifecycle.php index 88bc64a..b0036c5 100644 --- a/inc/Workspace/WorkspaceRepositoryLifecycle.php +++ b/inc/Workspace/WorkspaceRepositoryLifecycle.php @@ -36,8 +36,8 @@ public function list_repos( ?string $repo = null, ?string $type = null ): array| $repo_filter = null !== $repo && '' !== trim($repo) ? $this->parse_handle($repo)['repo'] : null; $type_filter = null !== $type && '' !== trim($type) ? strtolower(trim($type)) : null; - if ( null !== $type_filter && ! in_array($type_filter, array( 'primary', 'worktree' ), true) ) { - return new \WP_Error('invalid_workspace_type', 'Workspace list type must be "primary" or "worktree".', array( 'status' => 400 )); + if ( null !== $type_filter && ! in_array($type_filter, array( 'primary', 'worktree', 'context' ), true) ) { + return new \WP_Error('invalid_workspace_type', 'Workspace list type must be "primary", "worktree", or "context".', array( 'status' => 400 )); } if ( ! is_dir($path) ) { @@ -51,63 +51,86 @@ public function list_repos( ?string $repo = null, ?string $type = null ): array| $repos = array(); $entries = scandir($path); - foreach ( $entries as $entry ) { - if ( '.' === $entry || '..' === $entry ) { - continue; - } - - $entry_path = $path . '/' . $entry; - if ( ! is_dir($entry_path) ) { - continue; - } + if ( 'context' !== $type_filter ) { + foreach ( $entries as $entry ) { + if ( '.' === $entry || '..' === $entry ) { + continue; + } - $git_path = $entry_path . '/.git'; - $is_git = is_dir($git_path) || is_file($git_path); - $is_wt = is_file($git_path); - $parsed = $this->parse_handle($entry); + $entry_path = $path . '/' . $entry; + if ( ! is_dir($entry_path) ) { + continue; + } - if ( null !== $repo_filter && $parsed['repo'] !== $repo_filter ) { - continue; - } + $git_path = $entry_path . '/.git'; + $is_git = is_dir($git_path) || is_file($git_path); + $is_wt = is_file($git_path); + $parsed = $this->parse_handle($entry); - $is_worktree = $is_wt || $parsed['is_worktree']; - if ( 'primary' === $type_filter && $is_worktree ) { - continue; - } - if ( 'worktree' === $type_filter && ! $is_worktree ) { - continue; - } + if ( null !== $repo_filter && $parsed['repo'] !== $repo_filter ) { + continue; + } - $repo_info = array( - 'name' => $entry, - 'path' => $entry_path, - 'git' => $is_git, - 'is_worktree' => $is_worktree, - 'repo' => $parsed['repo'], - ); + $is_worktree = $is_wt || $parsed['is_worktree']; + if ( 'primary' === $type_filter && $is_worktree ) { + continue; + } + if ( 'worktree' === $type_filter && ! $is_worktree ) { + continue; + } - if ( $parsed['is_worktree'] ) { - $repo_info['branch_slug'] = $parsed['branch_slug']; - } + $repo_info = array( + 'name' => $entry, + 'path' => $entry_path, + 'git' => $is_git, + 'is_worktree' => $is_worktree, + 'repo' => $parsed['repo'], + ); - // Get git remote if available. - if ( $is_git ) { - $remote = $this->git_get_remote($entry_path); - if ( null !== $remote ) { - $repo_info['remote'] = $remote; + if ( $parsed['is_worktree'] ) { + $repo_info['branch_slug'] = $parsed['branch_slug']; } - $branch = $this->git_get_branch($entry_path); - if ( null !== $branch ) { - $repo_info['branch'] = $branch; + // Get git remote if available. + if ( $is_git ) { + $remote = $this->git_get_remote($entry_path); + if ( null !== $remote ) { + $repo_info['remote'] = $remote; + } + + $branch = $this->git_get_branch($entry_path); + if ( null !== $branch ) { + $repo_info['branch'] = $branch; + } + + if ( ! $is_worktree ) { + $repo_info['primary_freshness'] = $this->build_primary_freshness_report($entry_path, $entry); + } } - if ( ! $is_worktree ) { - $repo_info['primary_freshness'] = $this->build_primary_freshness_report($entry_path, $entry); - } + $repos[] = $repo_info; } + } + + if ( null === $type_filter || 'context' === $type_filter ) { + foreach ( WorkspaceAliasResolver::context_repositories() as $alias => $context ) { + if ( null !== $repo_filter && $this->parse_handle( (string) ( $context['target'] ?? $alias ) )['repo'] !== $repo_filter && $alias !== $repo_filter ) { + continue; + } - $repos[] = $repo_info; + $target = (string) ( $context['target'] ?? $alias ); + $path = $this->workspace_path . '/' . $this->parse_handle($target)['dir_name']; + $repos[] = array( + 'name' => $alias, + 'path' => is_dir($path) ? $path : null, + 'git' => is_dir($path . '/.git') || is_file($path . '/.git'), + 'is_worktree' => false, + 'is_context' => true, + 'repo' => (string) ( $context['repo'] ?? $target ), + 'ref' => (string) ( $context['ref'] ?? '' ), + 'workspace_policy' => WorkspaceAliasResolver::policy_attestation($alias), + ); + } } return array( @@ -149,8 +172,8 @@ public function clone_repo( string $url, ?string $name = null, array $options = return new \WP_Error('invalid_clone_name', 'Repository names cannot contain "@". The "@" suffix is reserved for worktrees (use "workspace worktree add" instead).', array( 'status' => 400 )); } - $name = $this->sanitize_name($name); - $repo_path = $this->workspace_path . '/' . $name; + $name = $this->sanitize_name($name); + $repo_path = $this->workspace_path . '/' . $name; $allow_duplicate_remote = ! empty($options['allow_duplicate_remote']); // Check if already exists. @@ -428,10 +451,10 @@ private function clone_remote_exists_error( string $url, string $name, array $ex 'repo_remote_exists', sprintf('A primary checkout for %s already exists as "%s" at %s. Do not clone the same remote as "%s"; refresh/reuse the existing primary instead. Next steps: %s', $url, $existing_name, (string) ( $existing['path'] ?? '' ), $name, implode(' ', $next_steps)), array( - 'status' => 409, - 'url' => $url, - 'name' => $name, - 'existing' => $existing, + 'status' => 409, + 'url' => $url, + 'name' => $name, + 'existing' => $existing, 'next_steps' => $next_steps, ) ); @@ -620,6 +643,30 @@ public function remove_repo( string $handle ): array|\WP_Error { * @return array{success: bool, name?: string, path?: string, branch?: string, remote?: string, commit?: string, dirty?: int}|\WP_Error */ public function show_repo( string $handle ): array|\WP_Error { + $context_policy = WorkspaceAliasResolver::context_policy_for($handle); + if ( null !== $context_policy ) { + $target = (string) ( $context_policy['target'] ?? $handle ); + $parsed = $this->parse_handle($target); + $repo_path = $this->workspace_path . '/' . $parsed['dir_name']; + $ref = (string) ( $context_policy['ref'] ?? '' ); + if ( ! is_dir($repo_path) ) { + return array( + 'success' => true, + 'name' => (string) $context_policy['alias'], + 'repo' => (string) ( $context_policy['repo'] ?? $target ), + 'is_worktree' => false, + 'is_context' => true, + 'path' => null, + 'branch' => '' !== $ref ? $ref : null, + 'remote' => '' !== (string) ( $context_policy['repo'] ?? '' ) ? 'https://github.com/' . (string) $context_policy['repo'] . '.git' : null, + 'commit' => null, + 'dirty' => 0, + 'workspace_policy' => WorkspaceAliasResolver::policy_attestation($handle), + ); + } + $handle = $target; + } + $parsed = $this->parse_handle($handle); $repo_path = $this->workspace_path . '/' . $parsed['dir_name']; @@ -636,17 +683,23 @@ public function show_repo( string $handle ): array|\WP_Error { $status = trim( (string) exec(sprintf('git -C %s status --porcelain 2>/dev/null | wc -l', $escaped))); // phpcs:enable - return array( - 'success' => true, - 'name' => $parsed['dir_name'], - 'repo' => $parsed['repo'], - 'is_worktree' => $parsed['is_worktree'], - 'path' => $repo_path, - 'branch' => $branch ? $branch : null, - 'remote' => $remote ? $remote : null, - 'commit' => $commit ? $commit : null, - 'dirty' => (int) $status, + $result = array( + 'success' => true, + 'name' => null !== $context_policy ? (string) $context_policy['alias'] : $parsed['dir_name'], + 'repo' => $parsed['repo'], + 'is_worktree' => $parsed['is_worktree'], + 'is_context' => null !== $context_policy, + 'path' => $repo_path, + 'branch' => $branch ? $branch : null, + 'remote' => $remote ? $remote : null, + 'commit' => $commit ? $commit : null, + 'dirty' => (int) $status, 'primary_freshness' => ! $parsed['is_worktree'] ? $this->build_primary_freshness_report($repo_path, $parsed['dir_name']) : null, ); + if ( null !== $context_policy ) { + $result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($handle); + } + + return $result; } } diff --git a/inc/Workspace/WorkspaceWriter.php b/inc/Workspace/WorkspaceWriter.php index 49b9499..7e2b5cc 100644 --- a/inc/Workspace/WorkspaceWriter.php +++ b/inc/Workspace/WorkspaceWriter.php @@ -15,7 +15,6 @@ namespace DataMachineCode\Workspace; -use DataMachine\Core\FilesRepository\FilesystemHelper; use DataMachineCode\Support\GitRunner; use DataMachineCode\Support\PathSecurity; @@ -50,6 +49,10 @@ public function __construct( Workspace $workspace ) { * @return array{success: bool, path?: string, size?: int, created?: bool}|\WP_Error */ public function write_file( string $name, string $path, string $content ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($name) ) { + return WorkspaceAliasResolver::mutation_error($name, 'write'); + } + $repo_path = $this->workspace->get_repo_path($name); $path = ltrim($path, '/'); @@ -123,7 +126,10 @@ public function write_file( string $name, string $path, string $content ): array * @return array{success: bool, path?: string, replacements?: int}|\WP_Error */ public function edit_file( string $name, string $path, string $old_string, string $new_string, bool $replace_all = false ): array|\WP_Error { - $fs = FilesystemHelper::get(); + if ( WorkspaceAliasResolver::is_context_repository($name) ) { + return WorkspaceAliasResolver::mutation_error($name, 'edit'); + } + $repo_path = $this->workspace->get_repo_path($name); $path = ltrim($path, '/'); @@ -135,16 +141,16 @@ public function edit_file( string $name, string $path, string $old_string, strin $validation = $this->workspace->validate_containment($file_path, $repo_path); if ( ! $validation['valid'] ) { - return new \WP_Error('path_traversal', $validation['message'], array( 'status' => 403 )); + return new \WP_Error('path_traversal', (string) ( $validation['message'] ?? 'Path traversal detected. Access denied.' ), array( 'status' => 403 )); } - $real_path = $validation['real_path']; + $real_path = (string) ( $validation['real_path'] ?? '' ); if ( ! is_file($real_path) ) { return new \WP_Error('file_not_found', sprintf('File not found: %s', $path), array( 'status' => 404 )); } - if ( ! is_readable($real_path) || ! $fs->is_writable($real_path) ) { + if ( ! is_readable($real_path) || ! is_writable($real_path) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable return new \WP_Error('file_not_writable', sprintf('File not readable/writable: %s', $path), array( 'status' => 403 )); } @@ -185,7 +191,10 @@ public function edit_file( string $name, string $path, string $old_string, strin if ( $replace_all ) { $new_content = str_replace($old_string, $new_string, $content); } else { - $pos = strpos($content, $old_string); + $pos = strpos($content, $old_string); + if ( false === $pos ) { + return new \WP_Error('string_not_found', 'old_string not found in file content.', array( 'status' => 400 )); + } $new_content = substr_replace($content, $new_string, $pos, strlen($old_string)); } @@ -215,6 +224,10 @@ public function edit_file( string $name, string $path, string $old_string, strin * @return array{success: bool, name: string, path: string, changed_files: string[], diff: string, status: string, check_output: string, apply_output: string}|\WP_Error */ public function apply_patch( string $name, string $patch, bool $allow_primary_mutation = false ): array|\WP_Error { + if ( WorkspaceAliasResolver::is_context_repository($name) ) { + return WorkspaceAliasResolver::mutation_error($name, 'apply-patch'); + } + $repo_path = $this->workspace->get_repo_path($name); $parsed = $this->workspace->parse_handle($name); diff --git a/tests/smoke-remote-workspace-backend.php b/tests/smoke-remote-workspace-backend.php index f6c837d..a0c8c29 100644 --- a/tests/smoke-remote-workspace-backend.php +++ b/tests/smoke-remote-workspace-backend.php @@ -15,6 +15,10 @@ class GitHubAbilities 'content' => " 'file-sha-main-example', ), + 'Automattic/studio:trunk:README.md' => array( + 'content' => "Studio context\n", + 'sha' => 'file-sha-studio-readme', + ), ); public static array $commits = array(); @@ -61,6 +65,7 @@ public static function getRepoTree( array $input ): array|\WP_Error 'success' => true, 'files' => array( array( 'path' => 'src/example.php', 'type' => 'file', 'size' => 20 ), + array( 'path' => 'README.md', 'type' => 'file', 'size' => 15 ), ), ); } @@ -226,6 +231,28 @@ function update_option( string $key, mixed $value, bool $autoload = true ): bool $assert('push is successful compatibility no-op', ! is_wp_error($push) && 'fix/example' === $push['branch']); $assert('push backend result omits model-facing guidance', ! is_wp_error($push) && ! array_key_exists('next_required_tool', $push) && ! array_key_exists('next_required_args', $push)); + update_option( + 'datamachine_code_context_repositories', + array( + array( + 'repo' => 'Automattic/studio', + 'ref' => 'trunk', + 'alias' => 'studio', + 'paths' => array( 'README.md' ), + ), + ) + ); + + $context_read = $backend->read_file('studio', 'README.md', 1000000); + $assert('remote context alias reads configured repo ref', ! is_wp_error($context_read) && str_contains($context_read['content'], 'Studio context')); + $assert('remote context read includes policy attestation', ! is_wp_error($context_read) && true === ( $context_read['workspace_policy']['read_only'] ?? false )); + + $context_write = $backend->write_file('studio', 'README.md', "changed\n"); + $assert('remote context write is rejected', is_wp_error($context_write) && 'context_repository_read_only' === $context_write->get_error_code()); + + $context_push = $backend->git_push('studio'); + $assert('remote context push is rejected', is_wp_error($context_push) && 'context_repository_read_only' === $context_push->get_error_code()); + $second_worktree = $backend->worktree_add('example', 'fix/remove-me'); $assert('second worktree add succeeds', ! is_wp_error($second_worktree) && 'example@fix-remove-me' === $second_worktree['handle']); diff --git a/tests/smoke-workspace-context-repositories.php b/tests/smoke-workspace-context-repositories.php new file mode 100644 index 0000000..440f648 --- /dev/null +++ b/tests/smoke-workspace-context-repositories.php @@ -0,0 +1,193 @@ +code; + } + + public function get_error_message(): string { + return $this->message; + } + + public function get_error_data(): array { + return $this->data; + } + } +} + +if ( ! function_exists('is_wp_error') ) { + function is_wp_error( mixed $value ): bool { + return $value instanceof WP_Error; + } +} + +if ( ! function_exists('get_option') ) { + function get_option( string $name, mixed $default = false ): mixed { + return $GLOBALS['dmc_workspace_context_options'][ $name ] ?? $default; + } +} + +if ( ! function_exists('apply_filters') ) { + function apply_filters( string $tag, mixed $value ): mixed { + return $value; + } +} + +if ( ! function_exists('size_format') ) { + function size_format( int|float $bytes ): string { + return (string) $bytes . ' B'; + } +} + +require __DIR__ . '/../vendor/autoload.php'; +require __DIR__ . '/../inc/Workspace/Workspace.php'; +require __DIR__ . '/../inc/Workspace/WorkspaceAliasResolver.php'; +require __DIR__ . '/../inc/Workspace/WorkspaceReader.php'; +require __DIR__ . '/../inc/Workspace/WorkspaceWriter.php'; + +use DataMachineCode\Workspace\Workspace; +use DataMachineCode\Workspace\WorkspaceReader; +use DataMachineCode\Workspace\WorkspaceWriter; + +$failures = array(); +$total = 0; +$assert = function ( string $label, bool $condition ) use ( &$failures, &$total ): void { + ++$total; + if ( $condition ) { + echo " ok {$label}\n"; + return; + } + + $failures[] = $label; + echo " fail {$label}\n"; +}; + +$run = static function ( string $command, string $cwd ): string { + $output = array(); + $code = 0; + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + exec(sprintf('cd %s && %s 2>&1', escapeshellarg($cwd), $command), $output, $code); + if ( 0 !== $code ) { + throw new RuntimeException(sprintf("Command failed (%d): %s\n%s", $code, $command, implode("\n", $output))); + } + return implode("\n", $output); +}; + +$write = static function ( string $path, string $content ): void { + $dir = dirname($path); + if ( ! is_dir($dir) ) { + mkdir($dir, 0777, true); + } + file_put_contents($path, $content); +}; + +echo "Workspace context repositories - smoke\n"; + +$tmp = sys_get_temp_dir() . '/dmc-workspace-context-' . bin2hex(random_bytes(4)); +mkdir($tmp, 0777, true); +if ( ! defined('DATAMACHINE_WORKSPACE_PATH') ) { + define('DATAMACHINE_WORKSPACE_PATH', $tmp); +} + +$context_repo = $tmp . '/studio'; +mkdir($context_repo, 0777, true); +$run('git init -q', $context_repo); +$run('git config user.email tester@example.com', $context_repo); +$run('git config user.name Tester', $context_repo); +$write($context_repo . '/README.md', "Studio contract\n"); +$write($context_repo . '/docs/contract.md', "Tool contract\n"); +$write($context_repo . '/src/private.php', "private contract\n"); +$run('git add README.md docs/contract.md src/private.php && git commit -q -m initial', $context_repo); + +$target = $tmp . '/target@feature'; +mkdir($target, 0777, true); +$run('git init -q', $target); +$run('git config user.email tester@example.com', $target); +$run('git config user.name Tester', $target); +$write($target . '/README.md', "target\n"); +$run('git add README.md && git commit -q -m initial', $target); + +$GLOBALS['dmc_workspace_context_options'] = array( + 'datamachine_code_context_repositories' => array( + array( + 'repo' => 'Automattic/studio', + 'ref' => 'trunk', + 'alias' => 'studio', + 'paths' => array( 'README.md', 'docs/**' ), + ), + ), +); + +$workspace = new Workspace(); +$reader = new WorkspaceReader($workspace); +$writer = new WorkspaceWriter($workspace); + +$list = $workspace->list_repos(null, 'context'); +$assert('workspace_list exposes context repository row', ! is_wp_error($list) && 'studio' === ( $list['repos'][0]['name'] ?? '' )); +$assert('context list row is read-only', ! is_wp_error($list) && false === ( $list['repos'][0]['workspace_policy']['writable'] ?? true )); + +$show = $workspace->show_repo('studio'); +$assert('workspace_show emits context attestation', ! is_wp_error($show) && true === ( $show['workspace_policy']['read_only'] ?? false )); + +$read = $reader->read_file('studio', 'README.md'); +$assert('allowed context file can be read', ! is_wp_error($read) && str_contains((string) ( $read['content'] ?? '' ), 'Studio contract')); +$assert('read result includes context policy', ! is_wp_error($read) && false === ( $read['workspace_policy']['writable'] ?? true )); + +$blocked_read = $reader->read_file('studio', 'src/private.php'); +$assert('disallowed context file is rejected', is_wp_error($blocked_read) && 'context_repository_path_not_allowed' === $blocked_read->get_error_code()); + +$root_list = $reader->list_directory('studio'); +$root_names = ! is_wp_error($root_list) ? array_column($root_list['entries'] ?? array(), 'name') : array(); +$assert('context directory listing includes allowed root entry', in_array('docs', $root_names, true) && in_array('README.md', $root_names, true)); +$assert('context directory listing hides disallowed root entry', ! in_array('src', $root_names, true)); + +$grep = $reader->grep('studio', 'contract'); +$grep_paths = ! is_wp_error($grep) ? array_column($grep['matches'] ?? array(), 'path') : array(); +$assert('context grep searches allowed paths', in_array('README.md', $grep_paths, true) && in_array('docs/contract.md', $grep_paths, true)); +$assert('context grep skips disallowed paths', ! in_array('src/private.php', $grep_paths, true)); + +$write_blocked = $writer->write_file('studio', 'docs/new.md', "nope\n"); +$assert('context write is rejected', is_wp_error($write_blocked) && 'context_repository_read_only' === $write_blocked->get_error_code()); + +$edit_blocked = $writer->edit_file('studio', 'README.md', 'Studio', 'Edited'); +$assert('context edit is rejected', is_wp_error($edit_blocked) && 'context_repository_read_only' === $edit_blocked->get_error_code()); + +$patch_blocked = $writer->apply_patch('studio', "diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-Studio contract\n+Edited contract\n"); +$assert('context apply-patch is rejected', is_wp_error($patch_blocked) && 'context_repository_read_only' === $patch_blocked->get_error_code()); + +$git_add_blocked = $workspace->git_add('studio', array( 'README.md' ), true); +$assert('context git add is rejected', is_wp_error($git_add_blocked) && 'context_repository_read_only' === $git_add_blocked->get_error_code()); + +$git_commit_blocked = $workspace->git_commit('studio', 'Update context', true); +$assert('context git commit is rejected', is_wp_error($git_commit_blocked) && 'context_repository_read_only' === $git_commit_blocked->get_error_code()); + +$git_push_blocked = $workspace->git_push('studio', 'origin', 'trunk', true); +$assert('context git push is rejected', is_wp_error($git_push_blocked) && 'context_repository_read_only' === $git_push_blocked->get_error_code()); + +$target_write = $writer->write_file('target@feature', 'changed.txt', "target remains writable\n"); +$assert('target worktree remains writable', ! is_wp_error($target_write) && true === ( $target_write['success'] ?? false )); + +if ( ! empty($failures) ) { + echo "\nFAIL: " . count($failures) . " assertion(s) failed out of {$total}\n"; + foreach ( $failures as $failure ) { + echo " - {$failure}\n"; + } + exit(1); +} + +echo "\nOK ({$total} assertions)\n"; +exit(0);