From 0475ecc70e888a0a89babeda2a69df40c624b319 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 6 Jun 2026 17:24:19 -0400 Subject: [PATCH 1/3] Add safe execution workspace primitive --- agents-api.php | 2 + ...lass-wp-agent-safe-execution-workspace.php | 348 ++++++++++++++++++ .../register-safe-execution-workspace.php | 175 +++++++++ tests/safe-execution-workspace-smoke.php | 142 +++++++ 4 files changed, 667 insertions(+) create mode 100644 src/Workspace/class-wp-agent-safe-execution-workspace.php create mode 100644 src/Workspace/register-safe-execution-workspace.php create mode 100644 tests/safe-execution-workspace-smoke.php diff --git a/agents-api.php b/agents-api.php index 6dc9388..7d30364 100644 --- a/agents-api.php +++ b/agents-api.php @@ -83,6 +83,8 @@ require_once AGENTS_API_PATH . 'src/Registry/register-agent-runtime-bundle-importer.php'; require_once AGENTS_API_PATH . 'src/Packages/register-agent-package-artifacts.php'; require_once AGENTS_API_PATH . 'src/Workspace/class-wp-agent-workspace-scope.php'; +require_once AGENTS_API_PATH . 'src/Workspace/class-wp-agent-safe-execution-workspace.php'; +require_once AGENTS_API_PATH . 'src/Workspace/register-safe-execution-workspace.php'; require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-identity-scope.php'; require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-materialized-identity.php'; require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-identity-store.php'; diff --git a/src/Workspace/class-wp-agent-safe-execution-workspace.php b/src/Workspace/class-wp-agent-safe-execution-workspace.php new file mode 100644 index 0000000..8c9f5bd --- /dev/null +++ b/src/Workspace/class-wp-agent-safe-execution-workspace.php @@ -0,0 +1,348 @@ + + */ + public static function target_metadata(): array { + return array( + 'id' => self::TARGET_ID, + 'label' => 'Safe execution workspace', + 'kind' => 'workspace', + 'description' => 'Host-approved filesystem workspace for isolated agent code execution.', + 'capabilities' => array( + 'workspace.files.read', + 'workspace.files.write', + 'code.execution.safe-root', + ), + 'resource_classes' => array( 'workspace' ), + 'metadata' => array( + 'schema' => 'agents-api/safe-execution-workspace-target/v1', + 'experimental' => true, + 'isolated_from_site' => true, + 'mutation_boundary' => 'workspace-root', + ), + ); + } + + /** + * Prepare a named workspace directory. + * + * @param array $input Ability input. + * @return array|\WP_Error + */ + public static function prepare( array $input ): array|\WP_Error { + $handle = self::handle( $input['handle'] ?? '' ); + if ( is_wp_error( $handle ) ) { + return $handle; + } + + $root = self::root_realpath(); + if ( is_wp_error( $root ) ) { + return $root; + } + + $path = $root . DIRECTORY_SEPARATOR . $handle; + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- This module's primitive is a local filesystem workspace root. + if ( ! is_dir( $path ) && ! mkdir( $path, 0755, true ) ) { + return new \WP_Error( 'agents_workspace_prepare_failed', 'Safe execution workspace directory could not be created.' ); + } + + $workspace = self::workspace_path( $handle ); + if ( is_wp_error( $workspace ) ) { + return $workspace; + } + + return array( + 'success' => true, + 'handle' => $handle, + 'path' => $workspace, + 'target' => self::TARGET_ID, + ); + } + + /** + * List prepared workspaces. + * + * @return array|\WP_Error + */ + public static function list_workspaces(): array|\WP_Error { + $root = self::root_realpath(); + if ( is_wp_error( $root ) ) { + return $root; + } + + $workspaces = array(); + $entries = scandir( $root ); + foreach ( false === $entries ? array() : $entries as $entry ) { + if ( '.' === $entry || '..' === $entry ) { + continue; + } + + $path = $root . DIRECTORY_SEPARATOR . $entry; + if ( is_dir( $path ) ) { + $workspaces[] = array( + 'handle' => $entry, + 'path' => $path, + ); + } + } + + return array( + 'success' => true, + 'root' => $root, + 'workspaces' => $workspaces, + ); + } + + /** + * Read a file from a named workspace. + * + * @param array $input Ability input. + * @return array|\WP_Error + */ + public static function read_file( array $input ): array|\WP_Error { + $handle = self::handle( $input['handle'] ?? '' ); + $relative = self::relative_path( $input['path'] ?? '' ); + $path = self::contained_path( $input, true ); + if ( is_wp_error( $handle ) ) { + return $handle; + } + if ( is_wp_error( $relative ) ) { + return $relative; + } + if ( is_wp_error( $path ) ) { + return $path; + } + + if ( ! is_file( $path ) || ! is_readable( $path ) ) { + return new \WP_Error( 'agents_workspace_file_not_readable', 'Safe execution workspace file is not readable.' ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- This reads a validated local workspace file, not a remote URL. + $content = file_get_contents( $path ); + if ( false === $content ) { + return new \WP_Error( 'agents_workspace_file_read_failed', 'Safe execution workspace file could not be read.' ); + } + + return array( + 'success' => true, + 'handle' => $handle, + 'path' => $relative, + 'content' => $content, + 'bytes' => strlen( $content ), + ); + } + + /** + * Write a file within a named workspace. + * + * @param array $input Ability input. + * @return array|\WP_Error + */ + public static function write_file( array $input ): array|\WP_Error { + $content = is_scalar( $input['content'] ?? null ) ? (string) $input['content'] : ''; + $handle = self::handle( $input['handle'] ?? '' ); + $relative = self::relative_path( $input['path'] ?? '' ); + $path = self::contained_path( $input, false ); + if ( is_wp_error( $handle ) ) { + return $handle; + } + if ( is_wp_error( $relative ) ) { + return $relative; + } + if ( is_wp_error( $path ) ) { + return $path; + } + + $parent = dirname( $path ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- This module's primitive is a local filesystem workspace root. + if ( ! is_dir( $parent ) && ! mkdir( $parent, 0755, true ) ) { + return new \WP_Error( 'agents_workspace_directory_create_failed', 'Safe execution workspace directory could not be created.' ); + } + + $parent_real = realpath( $parent ); + $workspace = self::workspace_path( $handle ); + if ( is_wp_error( $workspace ) || false === $parent_real || ! self::is_inside( $parent_real, $workspace ) ) { + return new \WP_Error( 'agents_workspace_path_escape', 'Safe execution workspace write path escapes the workspace root.' ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- This writes a validated local workspace file. + if ( false === file_put_contents( $path, $content ) ) { + return new \WP_Error( 'agents_workspace_file_write_failed', 'Safe execution workspace file could not be written.' ); + } + + return array( + 'success' => true, + 'handle' => $handle, + 'path' => $relative, + 'bytes' => strlen( $content ), + ); + } + + /** + * @return string|\WP_Error + */ + private static function root_realpath(): string|\WP_Error { + if ( ! self::enabled() ) { + return new \WP_Error( 'agents_workspace_disabled', 'Safe execution workspace is disabled.' ); + } + + $root = self::root(); + if ( '' === $root || ! is_dir( $root ) ) { + return new \WP_Error( 'agents_workspace_root_unavailable', 'Safe execution workspace root is not configured or does not exist.' ); + } + + $real = realpath( $root ); + if ( false === $real ) { + return new \WP_Error( 'agents_workspace_root_unavailable', 'Safe execution workspace root could not be resolved.' ); + } + + $site_root = self::site_root_realpath(); + if ( is_string( $site_root ) && ( self::is_inside( $real, $site_root ) || self::is_inside( $site_root, $real ) ) ) { + return new \WP_Error( 'agents_workspace_root_not_isolated', 'Safe execution workspace root must be isolated from the WordPress site root.' ); + } + + return $real; + } + + /** + * @return string|false + */ + private static function site_root_realpath(): string|false { + return defined( 'ABSPATH' ) ? realpath( (string) ABSPATH ) : false; + } + + /** + * @return string|\WP_Error + */ + private static function workspace_path( string|\WP_Error $handle ): string|\WP_Error { + if ( is_wp_error( $handle ) ) { + return $handle; + } + + $root = self::root_realpath(); + if ( is_wp_error( $root ) ) { + return $root; + } + + $path = realpath( $root . DIRECTORY_SEPARATOR . $handle ); + if ( false === $path || ! is_dir( $path ) || ! self::is_inside( $path, $root ) ) { + return new \WP_Error( 'agents_workspace_not_found', 'Safe execution workspace was not prepared.' ); + } + + return $path; + } + + /** + * @param array $input Ability input. + * @return string|\WP_Error + */ + private static function contained_path( array $input, bool $must_exist ): string|\WP_Error { + $handle = self::handle( $input['handle'] ?? '' ); + if ( is_wp_error( $handle ) ) { + return $handle; + } + + $workspace = self::workspace_path( $handle ); + if ( is_wp_error( $workspace ) ) { + return $workspace; + } + + $relative = self::relative_path( $input['path'] ?? '' ); + if ( is_wp_error( $relative ) ) { + return $relative; + } + + $candidate = $workspace . DIRECTORY_SEPARATOR . $relative; + $real = realpath( $candidate ); + if ( false !== $real ) { + return self::is_inside( $real, $workspace ) ? $real : new \WP_Error( 'agents_workspace_path_escape', 'Safe execution workspace path escapes the workspace root.' ); + } + + if ( $must_exist ) { + return new \WP_Error( 'agents_workspace_path_not_found', 'Safe execution workspace path does not exist.' ); + } + + return self::is_inside( dirname( $candidate ), $workspace ) ? $candidate : new \WP_Error( 'agents_workspace_path_escape', 'Safe execution workspace path escapes the workspace root.' ); + } + + /** + * @return string|\WP_Error + */ + private static function handle( mixed $value ): string|\WP_Error { + $handle = is_scalar( $value ) ? trim( (string) $value ) : ''; + if ( '' === $handle || 1 !== preg_match( '/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/', $handle ) ) { + return new \WP_Error( 'agents_workspace_invalid_handle', 'Safe execution workspace handle must be a simple name.' ); + } + + return $handle; + } + + /** + * @return string|\WP_Error + */ + private static function relative_path( mixed $value ): string|\WP_Error { + $path = is_scalar( $value ) ? str_replace( '\\', '/', trim( (string) $value ) ) : ''; + if ( '' === $path || str_starts_with( $path, '/' ) || str_contains( $path, "\0" ) ) { + return new \WP_Error( 'agents_workspace_invalid_path', 'Safe execution workspace path must be relative.' ); + } + + $parts = array_filter( explode( '/', $path ), static fn( string $part ): bool => '' !== $part && '.' !== $part ); + foreach ( $parts as $part ) { + if ( '..' === $part ) { + return new \WP_Error( 'agents_workspace_invalid_path', 'Safe execution workspace path cannot contain parent traversal.' ); + } + } + + return implode( '/', $parts ); + } + + private static function is_inside( string $path, string $root ): bool { + $root = rtrim( $root, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR; + $path = rtrim( $path, DIRECTORY_SEPARATOR ) . ( is_dir( $path ) ? DIRECTORY_SEPARATOR : '' ); + return str_starts_with( $path, $root ); + } +} diff --git a/src/Workspace/register-safe-execution-workspace.php b/src/Workspace/register-safe-execution-workspace.php new file mode 100644 index 0000000..b9ee090 --- /dev/null +++ b/src/Workspace/register-safe-execution-workspace.php @@ -0,0 +1,175 @@ + array( + 'label' => 'Prepare Safe Execution Workspace', + 'description' => 'Prepare a named host-approved workspace directory for isolated agent code execution.', + 'input' => agents_workspace_handle_schema(), + 'output' => agents_workspace_prepare_output_schema(), + 'callback' => array( WP_Agent_Safe_Execution_Workspace::class, 'prepare' ), + 'annotations' => array( 'destructive' => true ), + ), + 'agents/workspace-list' => array( + 'label' => 'List Safe Execution Workspaces', + 'description' => 'List prepared safe execution workspaces under the configured root.', + 'input' => array( 'type' => 'object' ), + 'output' => agents_workspace_list_output_schema(), + 'callback' => array( WP_Agent_Safe_Execution_Workspace::class, 'list_workspaces' ), + 'annotations' => array( 'idempotent' => true ), + ), + 'agents/workspace-read-file' => array( + 'label' => 'Read Safe Workspace File', + 'description' => 'Read a file contained inside a prepared safe execution workspace.', + 'input' => agents_workspace_file_schema( false ), + 'output' => agents_workspace_read_output_schema(), + 'callback' => array( WP_Agent_Safe_Execution_Workspace::class, 'read_file' ), + 'annotations' => array( 'idempotent' => true ), + ), + 'agents/workspace-write-file' => array( + 'label' => 'Write Safe Workspace File', + 'description' => 'Write a file contained inside a prepared safe execution workspace.', + 'input' => agents_workspace_file_schema( true ), + 'output' => agents_workspace_write_output_schema(), + 'callback' => array( WP_Agent_Safe_Execution_Workspace::class, 'write_file' ), + 'annotations' => array( 'destructive' => true ), + ), + ); + + foreach ( $abilities as $name => $ability ) { + if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $name ) ) { + continue; + } + + wp_register_ability( + $name, + array( + 'label' => $ability['label'], + 'description' => $ability['description'], + 'category' => 'agents-api', + 'input_schema' => $ability['input'], + 'output_schema' => $ability['output'], + 'execute_callback' => $ability['callback'], + 'permission_callback' => __NAMESPACE__ . '\\agents_workspace_permission', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => $ability['annotations'], + ), + ) + ); + } + } +); + +function agents_workspace_permission(): bool { + $allowed = function_exists( 'current_user_can' ) ? current_user_can( 'manage_options' ) : false; + return (bool) apply_filters( 'agents_api_blessed_workspace_permission', $allowed ); +} + +/** @return array */ +function agents_workspace_handle_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'handle' ), + 'properties' => array( + 'handle' => array( 'type' => 'string' ), + ), + ); +} + +/** @return array */ +function agents_workspace_file_schema( bool $include_content ): array { + $properties = array( + 'handle' => array( 'type' => 'string' ), + 'path' => array( 'type' => 'string' ), + ); + $required = array( 'handle', 'path' ); + if ( $include_content ) { + $properties['content'] = array( 'type' => 'string' ); + $required[] = 'content'; + } + + return array( + 'type' => 'object', + 'required' => $required, + 'properties' => $properties, + ); +} + +/** @return array */ +function agents_workspace_prepare_output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'handle' => array( 'type' => 'string' ), + 'path' => array( 'type' => 'string' ), + 'target' => array( 'type' => 'string' ), + ), + ); +} + +/** @return array */ +function agents_workspace_list_output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'root' => array( 'type' => 'string' ), + 'workspaces' => array( 'type' => 'array' ), + ), + ); +} + +/** @return array */ +function agents_workspace_read_output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'handle' => array( 'type' => 'string' ), + 'path' => array( 'type' => 'string' ), + 'content' => array( 'type' => 'string' ), + 'bytes' => array( 'type' => 'integer' ), + ), + ); +} + +/** @return array */ +function agents_workspace_write_output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'handle' => array( 'type' => 'string' ), + 'path' => array( 'type' => 'string' ), + 'bytes' => array( 'type' => 'integer' ), + ), + ); +} diff --git a/tests/safe-execution-workspace-smoke.php b/tests/safe-execution-workspace-smoke.php new file mode 100644 index 0000000..91ce764 --- /dev/null +++ b/tests/safe-execution-workspace-smoke.php @@ -0,0 +1,142 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): array { return $this->data; } +} + +function current_user_can( string $capability ): bool { + unset( $capability ); + return true; +} + +function wp_has_ability_category( string $category ): bool { + return isset( $GLOBALS['__agents_api_smoke_categories'][ $category ] ); +} + +function wp_register_ability_category( string $category, array $args ): void { + $GLOBALS['__agents_api_smoke_categories'][ $category ] = $args; +} + +function wp_has_ability( string $ability ): bool { + return isset( $GLOBALS['__agents_api_smoke_abilities'][ $ability ] ); +} + +function wp_register_ability( string $ability, array $args ): void { + $GLOBALS['__agents_api_smoke_abilities'][ $ability ] = $args; +} + +function agents_api_workspace_smoke_rm( string $path ): void { + if ( ! is_dir( $path ) ) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $iterator as $entry ) { + $entry->isDir() ? rmdir( $entry->getPathname() ) : unlink( $entry->getPathname() ); + } + rmdir( $path ); +} + +agents_api_smoke_require_module(); + +$disabled_targets = AgentsAPI\AI\Tasks\agents_execution_targets(); +agents_api_smoke_assert_equals( array(), $disabled_targets, 'workspace target is disabled by default', $failures, $passes ); +agents_api_smoke_assert_equals( false, isset( $GLOBALS['__agents_api_smoke_abilities']['agents/workspace-prepare'] ), 'workspace abilities are not registered before opt-in', $failures, $passes ); + +$root = sys_get_temp_dir() . '/agents-api-safe-workspace-' . bin2hex( random_bytes( 4 ) ); +mkdir( $root, 0755, true ); + +add_filter( 'agents_api_enable_blessed_workspace', static fn(): bool => true ); +add_filter( 'agents_api_blessed_workspace_root', static fn(): string => $root ); + +do_action( 'wp_abilities_api_categories_init' ); +do_action( 'wp_abilities_api_init' ); + +$targets = AgentsAPI\AI\Tasks\agents_execution_targets(); +agents_api_smoke_assert_equals( 'agents-api/safe-execution-workspace', $targets[0]['id'] ?? '', 'workspace target registers when opted in', $failures, $passes ); +agents_api_smoke_assert_equals( true, in_array( 'code.execution.safe-root', $targets[0]['capabilities'] ?? array(), true ), 'workspace target declares safe-root capability', $failures, $passes ); +agents_api_smoke_assert_equals( true, $targets[0]['metadata']['isolated_from_site'] ?? false, 'workspace target declares site isolation', $failures, $passes ); +agents_api_smoke_assert_equals( true, isset( $GLOBALS['__agents_api_smoke_abilities']['agents/workspace-prepare'] ), 'workspace prepare ability registers when opted in', $failures, $passes ); +agents_api_smoke_assert_equals( true, isset( $GLOBALS['__agents_api_smoke_abilities']['agents/workspace-write-file'] ), 'workspace write ability registers when opted in', $failures, $passes ); + +$prepare = $GLOBALS['__agents_api_smoke_abilities']['agents/workspace-prepare']['execute_callback']; +$write = $GLOBALS['__agents_api_smoke_abilities']['agents/workspace-write-file']['execute_callback']; +$read = $GLOBALS['__agents_api_smoke_abilities']['agents/workspace-read-file']['execute_callback']; +$list = $GLOBALS['__agents_api_smoke_abilities']['agents/workspace-list']['execute_callback']; + +$prepared = call_user_func( $prepare, array( 'handle' => 'site-generation' ) ); +agents_api_smoke_assert_equals( true, $prepared['success'] ?? false, 'prepare creates named workspace', $failures, $passes ); +agents_api_smoke_assert_equals( true, is_dir( $root . '/site-generation' ), 'workspace directory exists under configured root', $failures, $passes ); + +$written = call_user_func( + $write, + array( + 'handle' => 'site-generation', + 'path' => 'artifacts/index.html', + 'content' => '
Hello
', + ) +); +agents_api_smoke_assert_equals( true, $written['success'] ?? false, 'write stores file inside workspace', $failures, $passes ); + +$read_result = call_user_func( + $read, + array( + 'handle' => 'site-generation', + 'path' => 'artifacts/index.html', + ) +); +agents_api_smoke_assert_equals( '
Hello
', $read_result['content'] ?? '', 'read returns written workspace file', $failures, $passes ); + +$listed = call_user_func( $list, array() ); +agents_api_smoke_assert_equals( 'site-generation', $listed['workspaces'][0]['handle'] ?? '', 'list returns prepared workspace', $failures, $passes ); + +$traversal = call_user_func( + $write, + array( + 'handle' => 'site-generation', + 'path' => '../escape.txt', + 'content' => 'nope', + ) +); +agents_api_smoke_assert_equals( true, $traversal instanceof WP_Error, 'write rejects parent traversal', $failures, $passes ); +agents_api_smoke_assert_equals( false, file_exists( $root . '/escape.txt' ), 'traversal does not write outside workspace', $failures, $passes ); + +add_filter( 'agents_api_blessed_workspace_root', static fn(): string => dirname( __DIR__ ), 20 ); + +$blocked_targets = AgentsAPI\AI\Tasks\agents_execution_targets(); +agents_api_smoke_assert_equals( array(), $blocked_targets, 'workspace target rejects site-containing root', $failures, $passes ); + +$blocked_prepare = call_user_func( $prepare, array( 'handle' => 'site-generation' ) ); +agents_api_smoke_assert_equals( true, $blocked_prepare instanceof WP_Error, 'prepare rejects site-containing root', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_workspace_root_not_isolated', $blocked_prepare->get_error_code(), 'prepare returns isolated root error code', $failures, $passes ); + +agents_api_workspace_smoke_rm( $root ); + +agents_api_smoke_finish( 'safe execution workspace', $failures, $passes ); From c1be8e9daa0e3ae1025983047048ac2c492edbc2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 6 Jun 2026 17:49:08 -0400 Subject: [PATCH 2/3] Rename safe workspace configuration --- .../class-wp-agent-safe-execution-workspace.php | 8 ++++---- src/Workspace/register-safe-execution-workspace.php | 12 +++++++++--- tests/safe-execution-workspace-smoke.php | 6 +++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Workspace/class-wp-agent-safe-execution-workspace.php b/src/Workspace/class-wp-agent-safe-execution-workspace.php index 8c9f5bd..c5f3bdd 100644 --- a/src/Workspace/class-wp-agent-safe-execution-workspace.php +++ b/src/Workspace/class-wp-agent-safe-execution-workspace.php @@ -20,17 +20,17 @@ final class WP_Agent_Safe_Execution_Workspace { * Whether the optional module is enabled. */ public static function enabled(): bool { - $enabled = defined( 'AGENTS_API_ENABLE_BLESSED_WORKSPACE' ) && (bool) AGENTS_API_ENABLE_BLESSED_WORKSPACE; - return (bool) apply_filters( 'agents_api_enable_blessed_workspace', $enabled ); + $enabled = defined( 'AGENTS_API_ENABLE_SAFE_WORKSPACE' ) && (bool) AGENTS_API_ENABLE_SAFE_WORKSPACE; + return (bool) apply_filters( 'agents_api_enable_safe_workspace', $enabled ); } /** * Configured root path for all safe execution workspaces. */ public static function root(): string { - $constant = defined( 'AGENTS_API_BLESSED_WORKSPACE_ROOT' ) ? constant( 'AGENTS_API_BLESSED_WORKSPACE_ROOT' ) : ''; + $constant = defined( 'AGENTS_API_SAFE_WORKSPACE_ROOT' ) ? constant( 'AGENTS_API_SAFE_WORKSPACE_ROOT' ) : ''; $root = is_scalar( $constant ) ? (string) $constant : ''; - $root = apply_filters( 'agents_api_blessed_workspace_root', $root ); + $root = apply_filters( 'agents_api_safe_workspace_root', $root ); return is_string( $root ) ? rtrim( $root, '/\\' ) : ''; } diff --git a/src/Workspace/register-safe-execution-workspace.php b/src/Workspace/register-safe-execution-workspace.php index b9ee090..cf7beb3 100644 --- a/src/Workspace/register-safe-execution-workspace.php +++ b/src/Workspace/register-safe-execution-workspace.php @@ -34,7 +34,10 @@ static function (): void { 'input' => agents_workspace_handle_schema(), 'output' => agents_workspace_prepare_output_schema(), 'callback' => array( WP_Agent_Safe_Execution_Workspace::class, 'prepare' ), - 'annotations' => array( 'destructive' => true ), + 'annotations' => array( + 'destructive' => true, + 'idempotent' => false, + ), ), 'agents/workspace-list' => array( 'label' => 'List Safe Execution Workspaces', @@ -58,7 +61,10 @@ static function (): void { 'input' => agents_workspace_file_schema( true ), 'output' => agents_workspace_write_output_schema(), 'callback' => array( WP_Agent_Safe_Execution_Workspace::class, 'write_file' ), - 'annotations' => array( 'destructive' => true ), + 'annotations' => array( + 'destructive' => true, + 'idempotent' => false, + ), ), ); @@ -89,7 +95,7 @@ static function (): void { function agents_workspace_permission(): bool { $allowed = function_exists( 'current_user_can' ) ? current_user_can( 'manage_options' ) : false; - return (bool) apply_filters( 'agents_api_blessed_workspace_permission', $allowed ); + return (bool) apply_filters( 'agents_api_safe_workspace_permission', $allowed ); } /** @return array */ diff --git a/tests/safe-execution-workspace-smoke.php b/tests/safe-execution-workspace-smoke.php index 91ce764..93c9a44 100644 --- a/tests/safe-execution-workspace-smoke.php +++ b/tests/safe-execution-workspace-smoke.php @@ -73,8 +73,8 @@ function agents_api_workspace_smoke_rm( string $path ): void { $root = sys_get_temp_dir() . '/agents-api-safe-workspace-' . bin2hex( random_bytes( 4 ) ); mkdir( $root, 0755, true ); -add_filter( 'agents_api_enable_blessed_workspace', static fn(): bool => true ); -add_filter( 'agents_api_blessed_workspace_root', static fn(): string => $root ); +add_filter( 'agents_api_enable_safe_workspace', static fn(): bool => true ); +add_filter( 'agents_api_safe_workspace_root', static fn(): string => $root ); do_action( 'wp_abilities_api_categories_init' ); do_action( 'wp_abilities_api_init' ); @@ -128,7 +128,7 @@ function agents_api_workspace_smoke_rm( string $path ): void { agents_api_smoke_assert_equals( true, $traversal instanceof WP_Error, 'write rejects parent traversal', $failures, $passes ); agents_api_smoke_assert_equals( false, file_exists( $root . '/escape.txt' ), 'traversal does not write outside workspace', $failures, $passes ); -add_filter( 'agents_api_blessed_workspace_root', static fn(): string => dirname( __DIR__ ), 20 ); +add_filter( 'agents_api_safe_workspace_root', static fn(): string => dirname( __DIR__ ), 20 ); $blocked_targets = AgentsAPI\AI\Tasks\agents_execution_targets(); agents_api_smoke_assert_equals( array(), $blocked_targets, 'workspace target rejects site-containing root', $failures, $passes ); From 97c47c506eb91a059b2ff27f10728db33a4dc546 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 6 Jun 2026 17:58:09 -0400 Subject: [PATCH 3/3] Fix disabled safe workspace availability --- composer.json | 1 + src/Workspace/class-wp-agent-safe-execution-workspace.php | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/composer.json b/composer.json index 4f81c7e..32505c0 100644 --- a/composer.json +++ b/composer.json @@ -58,6 +58,7 @@ "php tests/pre-execute-approval-smoke.php", "php tests/approval-action-value-shape-smoke.php", "php tests/workspace-scope-smoke.php", + "php tests/safe-execution-workspace-smoke.php", "php tests/compaction-item-smoke.php", "php tests/compaction-conservation-smoke.php", "php tests/conversation-runner-contracts-smoke.php", diff --git a/src/Workspace/class-wp-agent-safe-execution-workspace.php b/src/Workspace/class-wp-agent-safe-execution-workspace.php index c5f3bdd..6383f19 100644 --- a/src/Workspace/class-wp-agent-safe-execution-workspace.php +++ b/src/Workspace/class-wp-agent-safe-execution-workspace.php @@ -38,6 +38,10 @@ public static function root(): string { * Whether the module can operate with the current configuration. */ public static function available(): bool { + if ( ! self::enabled() ) { + return false; + } + return is_string( self::root_realpath() ); }