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/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 new file mode 100644 index 0000000..6383f19 --- /dev/null +++ b/src/Workspace/class-wp-agent-safe-execution-workspace.php @@ -0,0 +1,352 @@ + + */ + 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..cf7beb3 --- /dev/null +++ b/src/Workspace/register-safe-execution-workspace.php @@ -0,0 +1,181 @@ + 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, + 'idempotent' => false, + ), + ), + '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, + 'idempotent' => false, + ), + ), + ); + + 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_safe_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..93c9a44 --- /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_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' ); + +$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_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 ); + +$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 );