Skip to content

Commit a898fce

Browse files
fix: require workspace handles for file abilities (#759)
Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent f8683b7 commit a898fce

6 files changed

Lines changed: 289 additions & 47 deletions

File tree

inc/Abilities/WorkspaceAbilities.php

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ private function registerAbilities(): void {
263263
'description' => 'Explicitly allow reading from a stale, diverged, detached, or otherwise unsafe primary checkout. Worktree reads are unaffected.',
264264
),
265265
),
266-
'required' => array( 'path' ),
266+
'required' => array( 'repo', 'path' ),
267267
),
268268
'output_schema' => array(
269269
'type' => 'object',
@@ -304,7 +304,7 @@ private function registerAbilities(): void {
304304
'description' => 'Explicitly allow listing a stale, diverged, detached, or otherwise unsafe primary checkout. Worktree reads are unaffected.',
305305
),
306306
),
307-
'required' => array(),
307+
'required' => array( 'repo' ),
308308
),
309309
'output_schema' => array(
310310
'type' => 'object',
@@ -369,7 +369,7 @@ private function registerAbilities(): void {
369369
'description' => 'Explicitly allow grepping a stale, diverged, detached, or otherwise unsafe primary checkout. Worktree reads are unaffected.',
370370
),
371371
),
372-
'required' => array( 'pattern' ),
372+
'required' => array( 'repo', 'pattern' ),
373373
),
374374
'output_schema' => array(
375375
'type' => 'object',
@@ -575,8 +575,12 @@ private function registerAbilities(): void {
575575
'type' => 'string',
576576
'description' => 'File content to write.',
577577
),
578+
'allow_primary_mutation' => array(
579+
'type' => 'boolean',
580+
'description' => 'Permit mutation on the primary checkout (default false). Worktrees are always allowed.',
581+
),
578582
),
579-
'required' => array( 'path', 'content' ),
583+
'required' => array( 'repo', 'path', 'content' ),
580584
),
581585
'output_schema' => array(
582586
'type' => 'object',
@@ -638,8 +642,12 @@ private function registerAbilities(): void {
638642
'type' => 'boolean',
639643
'description' => 'Replace all occurrences (default false).',
640644
),
645+
'allow_primary_mutation' => array(
646+
'type' => 'boolean',
647+
'description' => 'Permit mutation on the primary checkout (default false). Worktrees are always allowed.',
648+
),
641649
),
642-
'required' => array( 'path' ),
650+
'required' => array( 'repo', 'path' ),
643651
),
644652
'output_schema' => array(
645653
'type' => 'object',
@@ -951,7 +959,7 @@ private function registerAbilities(): void {
951959
'description' => 'Permit mutation on the primary checkout (default false). Worktrees are always allowed.',
952960
),
953961
),
954-
'required' => array( 'path' ),
962+
'required' => array( 'repo', 'path' ),
955963
),
956964
'output_schema' => array(
957965
'type' => 'object',
@@ -2587,9 +2595,13 @@ public static function showRepo( array $input ): array|\WP_Error {
25872595
* @return array Result.
25882596
*/
25892597
public static function readFile( array $input ): array|\WP_Error {
2590-
$input = self::normalize_mounted_workspace_path_input($input, array( 'repo' ));
2591-
$workspace = new Workspace();
2592-
$reader = new WorkspaceReader($workspace);
2598+
$input = self::normalize_mounted_workspace_path_input($input, array( 'repo' ));
2599+
$workspace = new Workspace();
2600+
$handle_check = $workspace->require_explicit_workspace_handle($input['repo'] ?? '');
2601+
if ( is_wp_error($handle_check) ) {
2602+
return $handle_check;
2603+
}
2604+
$reader = new WorkspaceReader($workspace);
25932605
if ( RemoteWorkspaceBackend::should_handle() && null !== self::showLocalWorkspaceHandleIfPresent($workspace, (string) ( $input['repo'] ?? '' )) ) {
25942606
return $reader->read_file(
25952607
$input['repo'] ?? '',
@@ -2631,9 +2643,13 @@ public static function readFile( array $input ): array|\WP_Error {
26312643
* @return array Result.
26322644
*/
26332645
public static function listDirectory( array $input ): array|\WP_Error {
2634-
$input = self::normalize_mounted_workspace_path_input($input, array( 'repo' ));
2635-
$workspace = new Workspace();
2636-
$reader = new WorkspaceReader($workspace);
2646+
$input = self::normalize_mounted_workspace_path_input($input, array( 'repo' ));
2647+
$workspace = new Workspace();
2648+
$handle_check = $workspace->require_explicit_workspace_handle($input['repo'] ?? '');
2649+
if ( is_wp_error($handle_check) ) {
2650+
return $handle_check;
2651+
}
2652+
$reader = new WorkspaceReader($workspace);
26372653
if ( RemoteWorkspaceBackend::should_handle() && null !== self::showLocalWorkspaceHandleIfPresent($workspace, (string) ( $input['repo'] ?? '' )) ) {
26382654
return $reader->list_directory(
26392655
$input['repo'] ?? '',
@@ -2666,9 +2682,13 @@ public static function listDirectory( array $input ): array|\WP_Error {
26662682
* @return array Result.
26672683
*/
26682684
public static function grepFiles( array $input ): array|\WP_Error {
2669-
$input = self::normalize_mounted_workspace_path_input($input, array( 'repo' ));
2670-
$workspace = new Workspace();
2671-
$reader = new WorkspaceReader($workspace);
2685+
$input = self::normalize_mounted_workspace_path_input($input, array( 'repo' ));
2686+
$workspace = new Workspace();
2687+
$handle_check = $workspace->require_explicit_workspace_handle($input['repo'] ?? '');
2688+
if ( is_wp_error($handle_check) ) {
2689+
return $handle_check;
2690+
}
2691+
$reader = new WorkspaceReader($workspace);
26722692
if ( RemoteWorkspaceBackend::should_handle() && null !== self::showLocalWorkspaceHandleIfPresent($workspace, (string) ( $input['repo'] ?? '' )) ) {
26732693
return $reader->grep(
26742694
$input['repo'] ?? '',
@@ -2958,7 +2978,13 @@ public static function removeRepo( array $input ): array|\WP_Error {
29582978
* @return array Result.
29592979
*/
29602980
public static function writeFile( array $input ): array|\WP_Error {
2961-
$input = self::normalize_mounted_workspace_path_input($input, array( 'repo' ));
2981+
$input = self::normalize_mounted_workspace_path_input($input, array( 'repo' ));
2982+
$workspace = new Workspace();
2983+
$handle_check = $workspace->ensure_workspace_mutation_allowed($input['repo'] ?? '', ! empty($input['allow_primary_mutation']));
2984+
if ( is_wp_error($handle_check) ) {
2985+
return $handle_check;
2986+
}
2987+
29622988
if ( RemoteWorkspaceBackend::should_handle() ) {
29632989
$result = ( new RemoteWorkspaceBackend() )->write_file(
29642990
$input['repo'] ?? '',
@@ -2968,13 +2994,13 @@ public static function writeFile( array $input ): array|\WP_Error {
29682994
return self::decorate_remote_workspace_result('write_file', $result);
29692995
}
29702996

2971-
$workspace = new Workspace();
2972-
$writer = new WorkspaceWriter($workspace);
2997+
$writer = new WorkspaceWriter($workspace);
29732998

29742999
return $writer->write_file(
29753000
$input['repo'] ?? '',
29763001
$input['path'] ?? '',
2977-
$input['content'] ?? ''
3002+
$input['content'] ?? '',
3003+
! empty($input['allow_primary_mutation'])
29783004
);
29793005
}
29803006

@@ -2985,7 +3011,12 @@ public static function writeFile( array $input ): array|\WP_Error {
29853011
* @return array Result.
29863012
*/
29873013
public static function editFile( array $input ): array|\WP_Error {
2988-
$input = self::normalize_mounted_workspace_path_input($input, array( 'repo' ));
3014+
$input = self::normalize_mounted_workspace_path_input($input, array( 'repo' ));
3015+
$workspace = new Workspace();
3016+
$handle_check = $workspace->ensure_workspace_mutation_allowed($input['repo'] ?? '', ! empty($input['allow_primary_mutation']));
3017+
if ( is_wp_error($handle_check) ) {
3018+
return $handle_check;
3019+
}
29893020
$old_string = (string) ( $input['old_string'] ?? $input['search'] ?? $input['old'] ?? '' );
29903021
$new_string = (string) ( $input['new_string'] ?? $input['replace'] ?? $input['new'] ?? '' );
29913022

@@ -3008,15 +3039,15 @@ public static function editFile( array $input ): array|\WP_Error {
30083039
return self::decorate_remote_workspace_result('edit_file', $result);
30093040
}
30103041

3011-
$workspace = new Workspace();
3012-
$writer = new WorkspaceWriter($workspace);
3042+
$writer = new WorkspaceWriter($workspace);
30133043

30143044
return $writer->edit_file(
30153045
$input['repo'] ?? '',
30163046
$input['path'] ?? '',
30173047
$old_string,
30183048
$new_string,
3019-
! empty($input['replace_all'])
3049+
! empty($input['replace_all']),
3050+
! empty($input['allow_primary_mutation'])
30203051
);
30213052
}
30223053

inc/Workspace/WorkspaceCoreUtilities.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,63 @@ public function parse_handle( string $handle ): array {
252252
);
253253
}
254254

255+
/**
256+
* Require file-operation callers to name a workspace handle explicitly.
257+
*
258+
* @param string $handle Workspace handle from ability input.
259+
* @return array{repo: string, branch_slug: string|null, is_worktree: bool, dir_name: string}|\WP_Error
260+
*/
261+
public function require_explicit_workspace_handle( string $handle ): array|\WP_Error {
262+
$handle = trim($handle);
263+
if ( '' === $handle ) {
264+
return new \WP_Error(
265+
'missing_workspace_handle',
266+
'Workspace file operations require an explicit repo/worktree handle; workspace-root access is not allowed.',
267+
array( 'status' => 400 )
268+
);
269+
}
270+
271+
$parsed = $this->parse_handle($handle);
272+
if ( '' === $parsed['dir_name'] || '' === $parsed['repo'] ) {
273+
return new \WP_Error(
274+
'invalid_workspace_handle',
275+
'Workspace file operations require a valid repo/worktree handle; workspace-root access is not allowed.',
276+
array( 'status' => 400 )
277+
);
278+
}
279+
280+
return $parsed;
281+
}
282+
283+
/**
284+
* Enforce the default read-only policy for primary checkout mutations.
285+
*
286+
* @param string $handle Workspace handle.
287+
* @param bool $allow Whether primary checkout mutation is explicitly allowed.
288+
* @return array{repo: string, branch_slug: string|null, is_worktree: bool, dir_name: string}|\WP_Error
289+
*/
290+
public function ensure_workspace_mutation_allowed( string $handle, bool $allow = false, string $allow_guidance = 'Pass allow_primary_mutation=true to operate on it' ): array|\WP_Error {
291+
$parsed = $this->require_explicit_workspace_handle($handle);
292+
if ( is_wp_error($parsed) ) {
293+
return $parsed;
294+
}
295+
296+
if ( $parsed['is_worktree'] || $allow ) {
297+
return $parsed;
298+
}
299+
300+
return new \WP_Error(
301+
'primary_mutation_blocked',
302+
sprintf(
303+
'Primary checkout "%s" is read-only by default. %s, or use a worktree handle (e.g. %s@<branch-slug>).',
304+
$parsed['repo'],
305+
$allow_guidance,
306+
$parsed['repo']
307+
),
308+
array( 'status' => 403 )
309+
);
310+
}
311+
255312
/**
256313
* Convert a branch name to a filesystem-safe slug.
257314
*

inc/Workspace/WorkspaceGitOperations.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,11 @@ public function delete_path( string $handle, string $path, bool $recursive = fal
354354
return WorkspaceAliasResolver::mutation_error($handle, 'delete');
355355
}
356356

357-
$parsed = $this->parse_handle($handle);
357+
$parsed = $this->require_explicit_workspace_handle($handle);
358+
if ( is_wp_error($parsed) ) {
359+
return $parsed;
360+
}
361+
358362
$repo_name = $parsed['repo'];
359363
$repo_path = $this->resolve_repo_path($handle);
360364
if ( is_wp_error($repo_path) ) {

inc/Workspace/WorkspaceReader.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ public function __construct( Workspace $workspace ) {
4040
* @return array{success: bool, content?: string, path?: string, size?: int, lines_read?: int, offset?: int}|\WP_Error
4141
*/
4242
public function read_file( string $name, string $path, int $max_size = Workspace::MAX_READ_SIZE, ?int $offset = null, ?int $limit = null, bool $allow_stale_primary = false ): array|\WP_Error {
43+
$handle_check = $this->workspace->require_explicit_workspace_handle($name);
44+
if ( is_wp_error($handle_check) ) {
45+
return $handle_check;
46+
}
47+
4348
$policy_error = WorkspaceAliasResolver::read_error_if_disallowed($name, $path);
4449
if ( null !== $policy_error ) {
4550
return $policy_error;
@@ -150,6 +155,11 @@ public function read_file( string $name, string $path, int $max_size = Workspace
150155
* @return array{success: bool, repo?: string, path?: string, entries?: array}|\WP_Error
151156
*/
152157
public function list_directory( string $name, ?string $path = null, bool $allow_stale_primary = false ): array|\WP_Error {
158+
$handle_check = $this->workspace->require_explicit_workspace_handle($name);
159+
if ( is_wp_error($handle_check) ) {
160+
return $handle_check;
161+
}
162+
153163
$policy_error = WorkspaceAliasResolver::read_error_if_disallowed($name, $path ?? '');
154164
if ( null !== $policy_error ) {
155165
return $policy_error;
@@ -250,6 +260,11 @@ function ( $a, $b ) {
250260
* @return array{success: bool, repo?: string, path?: string, pattern?: string, matches?: array, count?: int, truncated?: bool}|\WP_Error
251261
*/
252262
public function grep( string $name, string $pattern, ?string $path = null, ?string $include_pattern = null, int $max_results = 100, int $context_lines = 0, bool $allow_stale_primary = false ): array|\WP_Error {
263+
$handle_check = $this->workspace->require_explicit_workspace_handle($name);
264+
if ( is_wp_error($handle_check) ) {
265+
return $handle_check;
266+
}
267+
253268
$policy_error = WorkspaceAliasResolver::read_error_if_disallowed($name, $path ?? '');
254269
if ( null !== $policy_error ) {
255270
return $policy_error;

inc/Workspace/WorkspaceWriter.php

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,14 @@ public function __construct( Workspace $workspace ) {
4545
*
4646
* @param string $name Repository directory name.
4747
* @param string $path Relative file path within the repo.
48-
* @param string $content File content to write.
48+
* @param string $content File content to write.
49+
* @param bool $allow_primary_mutation Permit mutation on a primary checkout.
4950
* @return array{success: bool, path?: string, size?: int, created?: bool}|\WP_Error
5051
*/
51-
public function write_file( string $name, string $path, string $content ): array|\WP_Error {
52-
if ( WorkspaceAliasResolver::is_context_repository($name) ) {
53-
return WorkspaceAliasResolver::mutation_error($name, 'write');
52+
public function write_file( string $name, string $path, string $content, bool $allow_primary_mutation = false ): array|\WP_Error {
53+
$mutation_check = $this->ensure_workspace_mutation_allowed($name, 'write', $allow_primary_mutation);
54+
if ( is_wp_error($mutation_check) ) {
55+
return $mutation_check;
5456
}
5557

5658
$repo_path = $this->workspace->get_repo_path($name);
@@ -127,12 +129,14 @@ public function write_file( string $name, string $path, string $content ): array
127129
* @param string $path Relative file path within the repo.
128130
* @param string $old_string Text to find.
129131
* @param string $new_string Replacement text.
130-
* @param bool $replace_all Replace all occurrences (default false).
132+
* @param bool $replace_all Replace all occurrences (default false).
133+
* @param bool $allow_primary_mutation Permit mutation on a primary checkout.
131134
* @return array{success: bool, path?: string, replacements?: int}|\WP_Error
132135
*/
133-
public function edit_file( string $name, string $path, string $old_string, string $new_string, bool $replace_all = false ): array|\WP_Error {
134-
if ( WorkspaceAliasResolver::is_context_repository($name) ) {
135-
return WorkspaceAliasResolver::mutation_error($name, 'edit');
136+
public function edit_file( string $name, string $path, string $old_string, string $new_string, bool $replace_all = false, bool $allow_primary_mutation = false ): array|\WP_Error {
137+
$mutation_check = $this->ensure_workspace_mutation_allowed($name, 'edit', $allow_primary_mutation);
138+
if ( is_wp_error($mutation_check) ) {
139+
return $mutation_check;
136140
}
137141

138142
$repo_path = $this->workspace->get_repo_path($name);
@@ -234,29 +238,17 @@ public function edit_file( string $name, string $path, string $old_string, strin
234238
* @return array{success: bool, name: string, path: string, changed_files: string[], diff: string, status: string, check_output: string, apply_output: string}|\WP_Error
235239
*/
236240
public function apply_patch( string $name, string $patch, bool $allow_primary_mutation = false ): array|\WP_Error {
237-
if ( WorkspaceAliasResolver::is_context_repository($name) ) {
238-
return WorkspaceAliasResolver::mutation_error($name, 'apply-patch');
241+
$mutation_check = $this->ensure_workspace_mutation_allowed($name, 'apply-patch', $allow_primary_mutation);
242+
if ( is_wp_error($mutation_check) ) {
243+
return $mutation_check;
239244
}
240245

241246
$repo_path = $this->workspace->get_repo_path($name);
242-
$parsed = $this->workspace->parse_handle($name);
243247

244248
if ( ! is_dir($repo_path) ) {
245249
return new \WP_Error('repo_not_found', sprintf('Repository "%s" not found in workspace.', $name), array( 'status' => 404 ));
246250
}
247251

248-
if ( empty($parsed['is_worktree']) && ! $allow_primary_mutation ) {
249-
return new \WP_Error(
250-
'primary_mutation_blocked',
251-
sprintf(
252-
'Primary checkout "%s" is read-only by default. Pass allow_primary_mutation=true to operate on it, or use a worktree handle (e.g. %s@<branch-slug>).',
253-
$parsed['repo'],
254-
$parsed['repo']
255-
),
256-
array( 'status' => 403 )
257-
);
258-
}
259-
260252
$patch = str_replace("\r\n", "\n", $patch);
261253
if ( '' === trim($patch) ) {
262254
return new \WP_Error('empty_patch', 'Patch content is required.', array( 'status' => 400 ));
@@ -348,6 +340,19 @@ public function apply_patch( string $name, string $patch, bool $allow_primary_mu
348340
}
349341
}
350342

343+
private function ensure_workspace_mutation_allowed( string $name, string $operation, bool $allow_primary_mutation ): true|\WP_Error {
344+
if ( WorkspaceAliasResolver::is_context_repository($name) ) {
345+
return WorkspaceAliasResolver::mutation_error($name, $operation);
346+
}
347+
348+
$allowed = $this->workspace->ensure_workspace_mutation_allowed($name, $allow_primary_mutation);
349+
if ( is_wp_error($allowed) ) {
350+
return $allowed;
351+
}
352+
353+
return true;
354+
}
355+
351356
/**
352357
* @return array<int,array<string,mixed>>
353358
*/

0 commit comments

Comments
 (0)