diff --git a/README.md b/README.md index d1bb815..b134ef4 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,30 @@ $scope = new AgentsAPI\Core\FilesRepository\AgentMemoryScope( Transcript sessions are also workspace-stamped. `ConversationTranscriptStoreInterface::create_session()` and `::get_recent_pending_session()` both receive an `AgentWorkspaceScope`, and `AgentConversationRequest` can carry a workspace so runtime persisters can stamp the session they materialize. +## Guideline Capabilities + +When Agents API provides the `wp_guideline` polyfill, guideline access is scoped by explicit capabilities instead of ordinary post/private-post semantics: + +- `read_agent_memory` +- `edit_agent_memory` +- `read_private_agent_memory` +- `edit_private_agent_memory` +- `read_workspace_guidelines` +- `edit_workspace_guidelines` +- `promote_agent_memory` + +Private user-workspace memory is identified with guideline metadata, not by `post_status=private` alone: + +- `_wp_guideline_scope=private_user_workspace_memory` +- `_wp_guideline_user_id=` +- `_wp_guideline_workspace_id=` + +Workspace-shared guidance is identified with `_wp_guideline_scope=workspace_shared_guidance` and `_wp_guideline_workspace_id=`. + +The substrate maps private memory reads/edits through the explicit owner metadata, so editors and administrators do not gain access merely because they can read private posts. Workspace-shared guidance reads map to the editorial threshold (`edit_posts`), edits map to the publishing threshold (`publish_posts`), and promotion from private memory to shared guidance requires the owner plus the explicit `promote_agent_memory` capability. + +Hosts that provide their own guideline substrate can disable the polyfill with the `wp_guidelines_substrate_enabled` filter or register `wp_guideline` before Agents API does. + ## Execution Principals `AgentsAPI\AI\AgentExecutionPrincipal` represents the actor and agent context for one runtime request. It records the acting WordPress user ID, effective agent ID/slug, auth source, request context, optional token ID, workspace ID, client ID, capability ceiling, and JSON-friendly request metadata. diff --git a/src/Guidelines/class-wp-guidelines-substrate.php b/src/Guidelines/class-wp-guidelines-substrate.php index 1a27c62..c5aa1db 100644 --- a/src/Guidelines/class-wp-guidelines-substrate.php +++ b/src/Guidelines/class-wp-guidelines-substrate.php @@ -26,15 +26,55 @@ class WP_Guidelines_Substrate { */ const TAXONOMY = 'wp_guideline_type'; + /** + * Meta key describing the guideline access scope. + * + * @var string + */ + const META_SCOPE = '_wp_guideline_scope'; + + /** + * Meta key holding the owning user for private user-workspace memory. + * + * @var string + */ + const META_USER_ID = '_wp_guideline_user_id'; + + /** + * Meta key holding the workspace identifier for scoped memory/guidance. + * + * @var string + */ + const META_WORKSPACE_ID = '_wp_guideline_workspace_id'; + + /** + * Scope value for private memory owned by one user within one workspace. + * + * @var string + */ + const SCOPE_PRIVATE_MEMORY = 'private_user_workspace_memory'; + + /** + * Scope value for guidance shared across a workspace. + * + * @var string + */ + const SCOPE_WORKSPACE_GUIDANCE = 'workspace_shared_guidance'; + /** * Register the shared guideline substrate. */ public static function register(): void { + if ( ! apply_filters( 'wp_guidelines_substrate_enabled', true ) ) { + return; + } + self::register_post_type(); self::register_taxonomy(); add_action( 'save_post_' . self::POST_TYPE, '_wp_guidelines_ensure_default_type_term' ); add_filter( 'wp_insert_term_data', '_wp_guidelines_maybe_map_term_label', 10, 2 ); + add_filter( 'map_meta_cap', '_wp_guidelines_map_meta_cap', 10, 4 ); } /** @@ -79,18 +119,18 @@ private static function register_post_type(): void { 'capability_type' => 'guideline', 'map_meta_cap' => true, 'capabilities' => array( - 'read' => 'edit_posts', - 'create_posts' => 'publish_posts', - 'edit_posts' => 'edit_posts', - 'publish_posts' => 'publish_posts', - 'read_private_posts' => 'read_private_posts', - 'edit_private_posts' => 'edit_private_posts', - 'edit_published_posts' => 'edit_published_posts', - 'delete_private_posts' => 'delete_private_posts', - 'delete_published_posts' => 'delete_published_posts', - 'delete_posts' => 'delete_posts', - 'edit_others_posts' => 'edit_others_posts', - 'delete_others_posts' => 'delete_others_posts', + 'read' => 'read_workspace_guidelines', + 'create_posts' => 'edit_workspace_guidelines', + 'edit_posts' => 'edit_workspace_guidelines', + 'publish_posts' => 'edit_workspace_guidelines', + 'read_private_posts' => 'read_workspace_guidelines', + 'edit_private_posts' => 'edit_workspace_guidelines', + 'edit_published_posts' => 'edit_workspace_guidelines', + 'delete_private_posts' => 'edit_workspace_guidelines', + 'delete_published_posts' => 'edit_workspace_guidelines', + 'delete_posts' => 'edit_workspace_guidelines', + 'edit_others_posts' => 'edit_workspace_guidelines', + 'delete_others_posts' => 'edit_workspace_guidelines', ), 'supports' => array( 'title', 'editor', 'excerpt', 'author', 'revisions' ), 'hierarchical' => false, diff --git a/src/Guidelines/guidelines.php b/src/Guidelines/guidelines.php index 6aa0334..7474409 100644 --- a/src/Guidelines/guidelines.php +++ b/src/Guidelines/guidelines.php @@ -33,6 +33,145 @@ function wp_guideline_types(): array { } } +if ( ! function_exists( '_wp_guidelines_map_meta_cap' ) ) { + /** + * Maps guideline capabilities to explicit memory and workspace-guidance policy. + * + * Private user-workspace memory is author-only by metadata, not by core private + * post status. Workspace-shared guidance uses an editorial/admin threshold. + * + * @access private + * + * @param string[] $caps Primitive capabilities required by WordPress so far. + * @param string $cap Requested capability. + * @param int $user_id User ID being checked. + * @param mixed[] $args Additional capability arguments, usually post ID first. + * @return string[] Required primitive capabilities. + */ + function _wp_guidelines_map_meta_cap( array $caps, string $cap, int $user_id, array $args ): array { + if ( in_array( $cap, array( 'read_post', 'edit_post', 'delete_post' ), true ) ) { + $post_id = isset( $args[0] ) ? (int) $args[0] : 0; + if ( $post_id <= 0 || ! _wp_guidelines_is_guideline_post( $post_id ) ) { + return $caps; + } + + if ( _wp_guidelines_is_private_memory( $post_id ) ) { + return _wp_guidelines_map_private_memory_cap( 'read_post' === $cap ? 'read_private_agent_memory' : 'edit_private_agent_memory', $user_id, $post_id ); + } + + return 'read_post' === $cap ? array( 'read_workspace_guidelines' ) : array( 'edit_workspace_guidelines' ); + } + + if ( in_array( $cap, array( 'read_agent_memory', 'edit_agent_memory', 'read_workspace_guidelines', 'edit_workspace_guidelines' ), true ) ) { + return _wp_guidelines_map_guideline_primitive_cap( $cap ); + } + + if ( in_array( $cap, array( 'read_private_agent_memory', 'edit_private_agent_memory', 'promote_agent_memory' ), true ) ) { + $post_id = isset( $args[0] ) ? (int) $args[0] : 0; + if ( $post_id <= 0 || ! _wp_guidelines_is_private_memory( $post_id ) ) { + return array( 'do_not_allow' ); + } + + if ( 'promote_agent_memory' === $cap ) { + return _wp_guidelines_private_memory_owner_id( $post_id ) === $user_id ? array( 'promote_agent_memory' ) : array( 'do_not_allow' ); + } + + return _wp_guidelines_map_private_memory_cap( $cap, $user_id, $post_id ); + } + + return $caps; + } +} + +if ( ! function_exists( '_wp_guidelines_map_guideline_primitive_cap' ) ) { + /** + * Maps explicit guideline meta capabilities to host role thresholds. + * + * @access private + * + * @param string $cap Requested capability. + * @return string[] Required primitive capabilities. + */ + function _wp_guidelines_map_guideline_primitive_cap( string $cap ): array { + switch ( $cap ) { + case 'read_agent_memory': + return array( 'read' ); + + case 'edit_agent_memory': + case 'read_workspace_guidelines': + return array( 'edit_posts' ); + + case 'edit_workspace_guidelines': + return array( 'publish_posts' ); + } + + return array( 'do_not_allow' ); + } +} + +if ( ! function_exists( '_wp_guidelines_map_private_memory_cap' ) ) { + /** + * Maps a private memory capability for one guideline post. + * + * @access private + * + * @param string $cap Requested private-memory capability. + * @param int $user_id User ID being checked. + * @param int $post_id Guideline post ID. + * @return string[] Required primitive capabilities. + */ + function _wp_guidelines_map_private_memory_cap( string $cap, int $user_id, int $post_id ): array { + if ( _wp_guidelines_private_memory_owner_id( $post_id ) !== $user_id ) { + return array( 'do_not_allow' ); + } + + return _wp_guidelines_map_guideline_primitive_cap( 'read_private_agent_memory' === $cap ? 'read_agent_memory' : 'edit_agent_memory' ); + } +} + +if ( ! function_exists( '_wp_guidelines_is_guideline_post' ) ) { + /** + * Checks whether a post ID points at a guideline post. + * + * @access private + * + * @param int $post_id Post ID. + * @return bool Whether the post is a guideline. + */ + function _wp_guidelines_is_guideline_post( int $post_id ): bool { + $post = get_post( $post_id ); + return is_object( $post ) && WP_Guidelines_Substrate::POST_TYPE === $post->post_type; + } +} + +if ( ! function_exists( '_wp_guidelines_is_private_memory' ) ) { + /** + * Checks whether a guideline post is private user-workspace memory. + * + * @access private + * + * @param int $post_id Guideline post ID. + * @return bool Whether the guideline is private memory. + */ + function _wp_guidelines_is_private_memory( int $post_id ): bool { + return WP_Guidelines_Substrate::SCOPE_PRIVATE_MEMORY === get_post_meta( $post_id, WP_Guidelines_Substrate::META_SCOPE, true ); + } +} + +if ( ! function_exists( '_wp_guidelines_private_memory_owner_id' ) ) { + /** + * Returns the explicit owner for private user-workspace memory. + * + * @access private + * + * @param int $post_id Guideline post ID. + * @return int Owning user ID. + */ + function _wp_guidelines_private_memory_owner_id( int $post_id ): int { + return (int) get_post_meta( $post_id, WP_Guidelines_Substrate::META_USER_ID, true ); + } +} + if ( ! function_exists( '_wp_guidelines_ensure_default_type_term' ) ) { /** * Assigns the artifact type to guideline posts saved without a type. diff --git a/tests/agents-api-smoke-helpers.php b/tests/agents-api-smoke-helpers.php index a86f5ec..c7bc74e 100644 --- a/tests/agents-api-smoke-helpers.php +++ b/tests/agents-api-smoke-helpers.php @@ -16,6 +16,8 @@ $GLOBALS['__agents_api_smoke_post_types'] = array(); $GLOBALS['__agents_api_smoke_taxonomies'] = array(); $GLOBALS['__agents_api_smoke_terms'] = array(); +$GLOBALS['__agents_api_smoke_posts'] = array(); +$GLOBALS['__agents_api_smoke_post_meta'] = array(); function __( string $text, string $domain = 'default' ): string { unset( $domain ); @@ -114,6 +116,21 @@ function register_post_type( string $post_type, array $args = array() ) { ); } +function get_post( int $post_id ) { + return $GLOBALS['__agents_api_smoke_posts'][ $post_id ] ?? null; +} + +function get_post_meta( int $post_id, string $key = '', bool $single = false ) { + $meta = $GLOBALS['__agents_api_smoke_post_meta'][ $post_id ] ?? array(); + + if ( '' === $key ) { + return $meta; + } + + $value = $meta[ $key ] ?? ( $single ? '' : array() ); + return $single && is_array( $value ) ? reset( $value ) : $value; +} + function taxonomy_exists( string $taxonomy ): bool { return isset( $GLOBALS['__agents_api_smoke_taxonomies'][ $taxonomy ] ); } diff --git a/tests/guidelines-substrate-smoke.php b/tests/guidelines-substrate-smoke.php index 535cd79..437bd94 100644 --- a/tests/guidelines-substrate-smoke.php +++ b/tests/guidelines-substrate-smoke.php @@ -31,6 +31,9 @@ agents_api_smoke_assert_equals( true, $post_type_args['show_in_rest'] ?? null, 'wp_guideline is REST-visible', $failures, $passes ); agents_api_smoke_assert_equals( 'guidelines', $post_type_args['rest_base'] ?? null, 'wp_guideline uses shared REST base', $failures, $passes ); agents_api_smoke_assert_equals( 'guideline', $post_type_args['capability_type'] ?? null, 'wp_guideline uses guideline capability type', $failures, $passes ); +agents_api_smoke_assert_equals( 'read_workspace_guidelines', $post_type_args['capabilities']['read'] ?? null, 'guidelines use explicit read capability', $failures, $passes ); +agents_api_smoke_assert_equals( 'edit_workspace_guidelines', $post_type_args['capabilities']['edit_posts'] ?? null, 'guidelines use explicit edit capability', $failures, $passes ); +agents_api_smoke_assert_equals( 'read_workspace_guidelines', $post_type_args['capabilities']['read_private_posts'] ?? null, 'private core post reads do not grant guideline reads', $failures, $passes ); agents_api_smoke_assert_equals( true, taxonomy_exists( 'wp_guideline_type' ), 'wp_guideline_type taxonomy exists', $failures, $passes ); agents_api_smoke_assert_equals( 'wp_guideline', $taxonomy_entry['object_type'] ?? null, 'taxonomy is attached to wp_guideline', $failures, $passes ); agents_api_smoke_assert_equals( true, $taxonomy_args['hierarchical'] ?? null, 'guideline type taxonomy is hierarchical', $failures, $passes ); @@ -54,4 +57,75 @@ do_action( 'save_post_wp_guideline', 123 ); agents_api_smoke_assert_equals( true, (bool) term_exists( 'artifact', 'wp_guideline_type' ), 'saving an untyped guideline creates artifact term', $failures, $passes ); +echo "\n[3] Guideline meta capabilities enforce memory and guidance boundaries:\n"; + +$GLOBALS['__agents_api_smoke_posts'][200] = (object) array( + 'ID' => 200, + 'post_type' => 'wp_guideline', +); +$GLOBALS['__agents_api_smoke_post_meta'][200] = array( + '_wp_guideline_scope' => 'private_user_workspace_memory', + '_wp_guideline_user_id' => '7', + '_wp_guideline_workspace_id' => 'workspace-a', +); + +$GLOBALS['__agents_api_smoke_posts'][201] = (object) array( + 'ID' => 201, + 'post_type' => 'wp_guideline', +); +$GLOBALS['__agents_api_smoke_post_meta'][201] = array( + '_wp_guideline_scope' => 'workspace_shared_guidance', + '_wp_guideline_workspace_id' => 'workspace-a', +); + +agents_api_smoke_assert_equals( + array( 'read' ), + apply_filters( 'map_meta_cap', array( 'read_private_posts' ), 'read_post', 7, array( 200 ) ), + 'private memory owner can read via explicit owner metadata', + $failures, + $passes +); +agents_api_smoke_assert_equals( + array( 'do_not_allow' ), + apply_filters( 'map_meta_cap', array( 'read_private_posts' ), 'read_post', 8, array( 200 ) ), + 'private memory non-owner is denied despite core private-post capability input', + $failures, + $passes +); +agents_api_smoke_assert_equals( + array( 'edit_posts' ), + apply_filters( 'map_meta_cap', array(), 'read_workspace_guidelines', 8, array() ), + 'workspace-shared guidance reads require editorial threshold', + $failures, + $passes +); +agents_api_smoke_assert_equals( + array( 'publish_posts' ), + apply_filters( 'map_meta_cap', array(), 'edit_workspace_guidelines', 8, array() ), + 'workspace-shared guidance edits require publishing threshold', + $failures, + $passes +); +agents_api_smoke_assert_equals( + array( 'read_workspace_guidelines' ), + apply_filters( 'map_meta_cap', array(), 'read_post', 8, array( 201 ) ), + 'workspace-shared guideline post reads use explicit guidance capability', + $failures, + $passes +); +agents_api_smoke_assert_equals( + array( 'promote_agent_memory' ), + apply_filters( 'map_meta_cap', array(), 'promote_agent_memory', 7, array( 200 ) ), + 'private memory owner still needs explicit promotion capability', + $failures, + $passes +); +agents_api_smoke_assert_equals( + array( 'do_not_allow' ), + apply_filters( 'map_meta_cap', array(), 'promote_agent_memory', 8, array( 200 ) ), + 'private memory non-owner cannot promote memory', + $failures, + $passes +); + agents_api_smoke_finish( 'Agents API guideline substrate', $failures, $passes );