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
165 changes: 163 additions & 2 deletions plugins/data-machine/inc/Abilities/Content/UpsertPostAbility.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ private function register_ability(): void {
),
),
'execute_callback' => array( self::class, 'execute' ),
'permission_callback' => fn() => PermissionHelper::can( 'chat' ),
'permission_callback' => array( self::class, 'check_permission' ),
'meta' => array(
'show_in_rest' => true,
'mcp' => array( 'public' => true ),
Expand Down Expand Up @@ -337,6 +337,13 @@ public static function execute( array $input ): array|\WP_Error {
return new \WP_Error( 'invalid_upsert_input', 'post_type and title are required.', array( 'status' => 400 ) );
}

// Authorize the requested operation before anything is written: parent
// stub creation and identity reservation both allocate posts.
$authorized = self::authorize_request( $input );
if ( is_wp_error( $authorized ) ) {
return $authorized;
}

if ( $add_source_attribution && '' !== $source_url ) {
$content = WordPressPublishHelper::applySourceAttribution(
(string) $content,
Expand Down Expand Up @@ -446,6 +453,144 @@ public static function execute( array $input ): array|\WP_Error {
return self::callback_result( self::execute_resolved_write( $write_context, (int) $existing_id ) );
}

/**
* Ability permission callback.
*
* Requires the chat action plus WordPress object capabilities for the
* requested write. Targets resolved later (slug, identity meta) are
* re-checked at write time by execute().
*
* @param mixed $input Ability input.
* @return bool|\WP_Error
*/
public static function check_permission( $input = null ): bool|\WP_Error {
if ( ! PermissionHelper::can( 'chat' ) ) {
return false;
}

$input = is_array( $input ) ? $input : array();
if ( '' === sanitize_key( (string) ( $input['post_type'] ?? '' ) ) ) {
// Let input validation report the missing post type.
return true;
}

$authorized = self::authorize_request( $input );
if ( is_wp_error( $authorized ) ) {
return $authorized;
}

$post_id = absint( $input['post_id'] ?? 0 );
if ( $post_id > 0 && get_post( $post_id ) instanceof \WP_Post ) {
return self::authorize_existing_post( $post_id, sanitize_key( (string) ( $input['post_status'] ?? 'publish' ) ) );
}

return true;
}

/**
* Authorize the post-type level capabilities a request needs.
*
* - create_posts for the post type (an upsert may always create).
* - publish_posts when the requested status is publish, future, or
* private, or when missing parent stubs would be created (stubs publish).
* - edit_others_posts when assigning a different author or setting
* protected meta (meta_input keys or the identity_meta key).
* - assign_terms for each requested taxonomy.
*
* @param array $input Raw ability input.
* @return true|\WP_Error
*/
private static function authorize_request( array $input ): bool|\WP_Error {
$post_type = sanitize_key( (string) ( $input['post_type'] ?? '' ) );
$post_status = sanitize_key( (string) ( $input['post_status'] ?? 'publish' ) );
$post_author = absint( $input['post_author'] ?? 0 );
$taxonomies = $input['taxonomies'] ?? array();

$type_object = get_post_type_object( $post_type );
if ( ! $type_object instanceof \WP_Post_Type ) {
// Unregistered post types have no capability mapping; only fully
// trusted contexts and site administrators may write them.
if ( PermissionHelper::can_wp_capability( 'manage_options' ) ) {
return true;
}
return self::forbidden( sprintf( 'Post type "%s" is not registered.', $post_type ) );
}

$caps = $type_object->cap;

if ( ! PermissionHelper::can_wp_capability( $caps->create_posts ) ) {
return self::forbidden( sprintf( 'You are not allowed to create %s posts.', $post_type ) );
}

$needs_publish = in_array( $post_status, array( 'publish', 'future', 'private' ), true )
|| ( ! empty( $input['create_stubs'] ) && '' !== trim( (string) ( $input['parent_path'] ?? '' ) ) );
if ( $needs_publish && ! PermissionHelper::can_wp_capability( $caps->publish_posts ) ) {
return self::forbidden( sprintf( 'You are not allowed to publish %s posts.', $post_type ) );
}

if ( $post_author > 0 && PermissionHelper::acting_user_id() !== $post_author
&& ! PermissionHelper::can_wp_capability( $caps->edit_others_posts ) ) {
return self::forbidden( 'You are not allowed to create posts as another user.' );
}

// Protected (underscore-prefixed) meta is internal plugin/core state.
// Only principals trusted to edit any post of this type may set it.
$meta_keys = is_array( $input['meta_input'] ?? null ) ? array_map( 'strval', array_keys( $input['meta_input'] ) ) : array();
if ( self::has_usable_identity_meta( $input['identity_meta'] ?? null ) ) {
$meta_keys[] = sanitize_key( (string) $input['identity_meta']['key'] );
}
foreach ( $meta_keys as $meta_key ) {
if ( is_protected_meta( $meta_key, 'post' ) && ! PermissionHelper::can_wp_capability( $caps->edit_others_posts ) ) {
return self::forbidden( sprintf( 'You are not allowed to set protected meta "%s".', $meta_key ) );
}
}

if ( is_array( $taxonomies ) ) {
foreach ( array_keys( $taxonomies ) as $taxonomy ) {
$tax_object = get_taxonomy( (string) $taxonomy );
if ( ! $tax_object instanceof \WP_Taxonomy ) {
continue;
}
if ( ! PermissionHelper::can_wp_capability( $tax_object->cap->assign_terms ) ) {
return self::forbidden( sprintf( 'You are not allowed to assign %s terms.', $taxonomy ) );
}
}
}

return true;
}

/**
* Authorize an update of an existing post.
*
* @param int $post_id Existing post ID.
* @param string $post_status Requested status.
* @return true|\WP_Error
*/
private static function authorize_existing_post( int $post_id, string $post_status ): bool|\WP_Error {
if ( ! PermissionHelper::can_wp_capability( 'edit_post', $post_id ) ) {
return self::forbidden( sprintf( 'You are not allowed to edit post #%d.', $post_id ) );
}

if ( 'trash' === $post_status && 'trash' !== get_post_status( $post_id )
&& ! PermissionHelper::can_wp_capability( 'delete_post', $post_id ) ) {
return self::forbidden( sprintf( 'You are not allowed to trash post #%d.', $post_id ) );
}

return true;
}

/** Whether the acting principal may create new terms in a taxonomy. */
private static function can_create_terms( string $taxonomy ): bool {
$tax_object = get_taxonomy( $taxonomy );
return $tax_object instanceof \WP_Taxonomy && PermissionHelper::can_wp_capability( $tax_object->cap->edit_terms );
}

/** Build a 403 authorization error. */
private static function forbidden( string $message ): \WP_Error {
return new \WP_Error( 'upsert_post_forbidden', $message, array( 'status' => 403 ) );
}

/**
* Execute an identity-backed write while holding its advisory fence.
*
Expand Down Expand Up @@ -618,6 +763,22 @@ private static function execute_resolved_write(
$has_reservation = null !== $reservation && $reservations instanceof PostIdentityReservations;
$post = $existing_id > 0 ? get_post( $existing_id ) : null;

// A shell allocated by this request's own reservation is a create, which
// authorize_request() already covered. Every other resolved post is an
// update and must be editable by the acting principal.
$is_own_allocation = $has_reservation && ! empty( $reservation['allocated'] );
if ( $existing_id > 0 && ! $is_own_allocation ) {
$authorized = self::authorize_existing_post( $existing_id, $post_status );
if ( is_wp_error( $authorized ) ) {
return array(
'success' => false,
'error' => $authorized->get_error_message(),
'error_code' => $authorized->get_error_code(),
'error_data' => $authorized->get_error_data(),
);
}
}

// Idempotency check. Parent/slug/status changes still need a write even
// when the content hash is unchanged.
if ( $existing_id > 0 && '' !== $content_hash ) {
Expand Down Expand Up @@ -790,7 +951,7 @@ private static function execute_resolved_write(
}
if ( $existing instanceof \WP_Term ) {
$term_ids[] = (int) $existing->term_id;
} else {
} elseif ( self::can_create_terms( $taxonomy ) ) {
// Create term if not found.
$result = wp_insert_term( $term, $taxonomy );
if ( is_array( $result ) ) {
Expand Down
101 changes: 101 additions & 0 deletions plugins/data-machine/inc/Abilities/PermissionHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,107 @@ public static function can( string $action ): bool {
return self::user_can( get_current_user_id(), $action );
}

/**
* Check a concrete WordPress capability (primitive or meta) for the acting principal.
*
* Use this for object-level authorization (edit_post, publish_posts,
* assign_terms, ...) that the Data Machine action map cannot express. The
* trusted-context rules mirror can():
*
* - WP-CLI (subject to datamachine_cli_bypass_permissions) and Action
* Scheduler queue runs are trusted.
* - Agent token context checks the agent owner's capabilities, bounded by
* the token's capability ceiling: every primitive capability the meta
* capability maps to must be allowed by the ceiling.
* - Pre-authenticated context checks the acting user; without an acting
* user (system context) it is trusted.
* - Otherwise the logged-in user must hold the capability.
*
* Unlike user_can() via can(), manage_options is NOT treated as a blanket
* grant here; WordPress capability mapping already decides what
* administrators may do with an object.
*
* @param string $capability WordPress capability or meta capability.
* @param mixed ...$args Optional object arguments (e.g. post ID).
* @return bool
*/
public static function can_wp_capability( string $capability, ...$args ): bool {
$capability = trim( $capability );
if ( '' === $capability ) {
return false;
}

if ( defined( 'WP_CLI' ) && WP_CLI && apply_filters( 'datamachine_cli_bypass_permissions', true ) ) {
return true;
}

if ( doing_action( 'action_scheduler_run_queue' ) ) {
return true;
}

if ( null !== self::$acting_agent_id ) {
if ( self::$agent_owner_id <= 0 ) {
return false;
}

return self::ceiling_allows_mapped_capability( self::$agent_owner_id, $capability, $args )
&& user_can( self::$agent_owner_id, $capability, ...$args );
}

if ( self::$authenticated_context ) {
if ( self::$authenticated_user_id > 0 ) {
return self::ceiling_allows_mapped_capability( self::$authenticated_user_id, $capability, $args )
&& user_can( self::$authenticated_user_id, $capability, ...$args );
}

return true;
}

if ( ! is_user_logged_in() ) {
return false;
}

$user_id = get_current_user_id();
return self::ceiling_allows_mapped_capability( $user_id, $capability, $args )
&& user_can( $user_id, $capability, ...$args );
}

/**
* Whether the active capability ceiling (if any) allows a capability.
*
* Meta capabilities are resolved through map_meta_cap() for the given
* user so a ceiling expressed in primitive capabilities (edit_posts,
* publish_posts, edit_others_posts, ...) bounds object-level checks too.
*
* @param int $user_id User whose capabilities are being mapped.
* @param string $capability Requested capability.
* @param array $args Object arguments.
* @return bool
*/
private static function ceiling_allows_mapped_capability( int $user_id, string $capability, array $args ): bool {
$ceiling = self::$capability_ceiling;
if ( ! $ceiling instanceof \WP_Agent_Capability_Ceiling ) {
return true;
}

if ( $ceiling->allows_capability( $capability ) ) {
return true;
}

$primitives = map_meta_cap( $capability, $user_id, ...$args );
if ( empty( $primitives ) ) {
return false;
}

foreach ( $primitives as $primitive ) {
if ( 'do_not_allow' === $primitive || ! $ceiling->allows_capability( (string) $primitive ) ) {
return false;
}
}

return true;
}

/**
* Get current acting user ID for permission-bounded execution.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ public static function execute( array $input ): array {
);
}

// A signed URL carries the resolution authority on its own, so only a
// caller already allowed to resolve this action may mint one. Managers
// keep operator-wide reach, matching the resolve surface.
$scope_input = PermissionHelper::can_manage() ? array( 'operator_wide' => true ) : array();
if ( ! PendingActionScope::can_access_payload( $action, $scope_input ) ) {
return array(
'success' => false,
'error' => 'Pending action not found or already resolved.',
'action_id' => $action_id,
);
}

$lifetime = self::normalize_lifetime( $input['lifetime'] ?? self::DEFAULT_LIFETIME );
$expires_at = time() + $lifetime;
$resolver = isset( $input['resolver'] ) ? sanitize_text_field( (string) $input['resolver'] ) : self::DEFAULT_RESOLVER;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use DataMachine\Abilities\PermissionHelper;
use DataMachine\Core\FilesRepository\AgentMemory;
use DataMachine\Engine\AI\DataMachineAgentConsentPolicy;
use DataMachine\Engine\AI\MemoryFileRegistry;

defined( 'ABSPATH' ) || exit;

Expand Down Expand Up @@ -67,7 +68,7 @@ public static function execute( array $input ): array {
return self::deny( sprintf( 'Section type "%s" is not allowed for self-memory writes.', $section_type ), 'section_type_denied' );
}

$file = (string) ( $input['file'] ?? 'MEMORY.md' );
$file = self::normalize_filename( (string) ( $input['file'] ?? 'MEMORY.md' ) );
$section = trim( (string) ( $input['section'] ?? '' ) );
$content = (string) ( $input['content'] ?? '' );
$mode = 'set' === ( $input['mode'] ?? 'append' ) ? 'set' : 'append';
Expand All @@ -76,6 +77,16 @@ public static function execute( array $input ): array {
return self::deny( 'section and content are required.', 'missing_required_input' );
}

// Self-memory must honor the same editability contract as the
// regular memory write surface: registered read-only, composable,
// or capability-gated files (e.g. site-wide rules) stay protected.
if ( '' === $file ) {
return self::deny( 'A memory file is required.', 'missing_required_input' );
}
if ( 'MEMORY.md' !== $file && ! MemoryFileRegistry::is_editable( $file, PermissionHelper::acting_user_id() ) ) {
return self::deny( sprintf( 'File %s is not editable through self-memory writes.', $file ), 'memory_file_not_editable' );
}

$artifact = self::memory_section_artifact( $target_agent_id, $file, $section, $section_type, $input );
if ( $artifact && $artifact->is_bundle_owned() ) {
return MemorySectionPendingAction::stage( self::stage_args( $input, $target_agent_id, $file, $section, $section_type, $content, $mode, 'bundle_upgrade' ) );
Expand Down Expand Up @@ -140,6 +151,19 @@ private static function stage_args( array $input, int $agent_id, string $file, s
);
}

/**
* Normalize a requested memory filename exactly like AgentMemory does,
* so the editability check runs against the file that will be written.
*/
private static function normalize_filename( string $filename ): string {
$segments = array_filter( explode( '/', $filename ), static fn( string $segment ): bool => '' !== $segment );
$clean = array();
foreach ( $segments as $segment ) {
$clean[] = preg_replace( '/[^a-zA-Z0-9._-]/', '', basename( $segment ) );
}
return implode( '/', $clean );
}

private static function deny( string $message, string $code ): array {
return array(
'success' => false,
Expand Down
Loading
Loading