Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<owner user id>`
- `_wp_guideline_workspace_id=<workspace id>`

Workspace-shared guidance is identified with `_wp_guideline_scope=workspace_shared_guidance` and `_wp_guideline_workspace_id=<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.
Expand Down
64 changes: 52 additions & 12 deletions src/Guidelines/class-wp-guidelines-substrate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

/**
Expand Down Expand Up @@ -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,
Expand Down
139 changes: 139 additions & 0 deletions src/Guidelines/guidelines.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions tests/agents-api-smoke-helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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 ] );
}
Expand Down
74 changes: 74 additions & 0 deletions tests/guidelines-substrate-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand All @@ -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 );
Loading