diff --git a/.changeset/27900-identity-actions-ai-exposure.md b/.changeset/27900-identity-actions-ai-exposure.md new file mode 100644 index 00000000000..a911146a55b --- /dev/null +++ b/.changeset/27900-identity-actions-ai-exposure.md @@ -0,0 +1,16 @@ +--- +'@objectstack/platform-objects': patch +--- + +feat(platform-objects): the platform identity actions an agent may reach declare it — additive acts exposed, destructive lifecycle acts exposed behind confirmation, credential and ownership acts human-only + +Clause-②: no +Metadata-protocol: none + +The `sys_*` identity objects' actions now carry the ADR-0011 `ai` block by risk, as ADR-0011's 2026-10-30 addendum records (58 actions assessed, 38 declared): + +- **Exposed (21).** Additive or self-scoped acts declare `ai: { exposed: true, description }`: the four `invite_user` mirrors, resending and accepting an invitation, adding a member to an organization or a team, creating, editing and renaming a team or an organization, switching the active organization, re-enabling an OAuth application, enabling two-factor authentication, updating your own profile, resending the verification email, and requesting SSO domain verification. +- **Exposed, confirmation required (17).** Destructive or privilege-changing lifecycle acts also declare `requiresConfirmation: true`, so MCP `run_action` refuses them `428 ACTION_CONFIRMATION_REQUIRED`, with nothing run, until the call carries `confirm: true`: banning, unbanning and unlocking a user, setting a user's manager, revoking an API key or a session, signing out other devices, cancelling or declining an invitation, removing a member or a team member, deleting a team, an OAuth application or an SSO provider, disabling an OAuth application, leaving an organization, and unlinking a sign-in provider. +- **Human-only (20).** Acts that mint a credential or a session, change ownership, a role or a sign-in factor, or destroy an account or an organization (impersonating a user, setting a password, creating a user or an API key, transferring ownership, changing a member's role, deleting an organization, and the rest the addendum names) declare no `ai` block and are not listed over MCP. `sys_invitation.copy_invitation_link` declares none either; it was not part of the assessment. + +Over MCP these actions are listed and run behind every existing gate: the capability gate, the installation's action switch, the param contract, the confirmation gate and the identity route's own authorization. They are `type: 'api'` actions forwarded to identity routes that accept a signed-in session only, so an agent connected over OAuth sees them listed and is refused at the route when it runs one. diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx index 6f465cf02aa..e12c9f05921 100644 --- a/content/docs/ai/connect-mcp.mdx +++ b/content/docs/ai/connect-mcp.mdx @@ -261,6 +261,20 @@ can reach is the **default exposure policy (v1)**: The stored-metadata tables (`sys_metadata`, `sys_metadata_history`) stay refused even when flagged. Each action `list_actions` returns carries `aiExposed: true`, the server's attestation of that opt-in. +- **The platform's own identity actions declare it by risk.** Additive acts + (inviting someone, adding a member to a team, creating or renaming a team or + an organization, re-enabling an OAuth application) are exposed. Destructive + lifecycle acts (banning a user, revoking a session or an API key, removing a + member, deleting a team, leaving an organization) are exposed with + `ai.requiresConfirmation: true`, so `run_action` refuses them until the call + carries `confirm: true`. Acts that mint a credential or a session, change + ownership or a sign-in factor, or destroy an account or an organization + (impersonating a user, setting a password, creating an API key, transferring + ownership, deleting an organization) declare nothing and are not listed. The + full list is ADR-0011's 2026-10-30 addendum. These actions forward to + identity routes that accept a signed-in session only, so an agent connected + over OAuth sees them listed, but a call that reaches the identity route is + refused there (`401`) and changes nothing. This is the default policy, not a ceiling. A finer, **metadata-declared** exposure diff --git a/docs/adr/0011-actions-as-ai-tools.md b/docs/adr/0011-actions-as-ai-tools.md index a7c6e781c88..e88e2387167 100644 --- a/docs/adr/0011-actions-as-ai-tools.md +++ b/docs/adr/0011-actions-as-ai-tools.md @@ -305,6 +305,86 @@ ships next. --- +## Addendum (2026-10-30): the platform identity actions an agent may reach + +> Status of this addendum: **ruled** (ruling of record `6523087116` on #27900, letter A+), executing +> parameter 2 of ruling `6513869839` on #27681. The maintainer, verbatim: +> 「所以我说评估标准对象的意思,就是允许放开的动作,是不是就应该把申明加上」. +> It changes no rule of this ADR: it records how the platform's own `sys_*` identity actions apply the +> opt-in, and the declarations that follow from it. + +The opt-in binds the platform's `sys_*` actions exactly as it binds an app's: an action reaches an +agent only when it declares `ai: { exposed: true, description }`. The named actions on the twelve +identity objects in `packages/platform-objects/src/identity/` were assessed once, as one set, into +three buckets, and the declarations were written in the same change as this addendum. + +| Bucket | Test | What the action declares | +|:--|:--|:--| +| **H** human-only | it mints a credential or a session, changes who owns or who can sign in, or destroys an account or an organization | no `ai` block. ⛔ No `ai.exposed` on one of these without a maintainer ruling that names the action. | +| **C** exposed, confirmed | a destructive or privilege-changing lifecycle act that is reversible or bounded | `ai: { exposed: true, description, requiresConfirmation: true }`. ⛔ The confirmation is never relaxed. | +| **E** exposed | an additive or self-scoped act with no credential and no privilege escalation | `ai: { exposed: true, description }`; confirmation is the author's choice (none is declared today). | + +**H — human-only (20):** `sys_user.impersonate_user` · `sys_user.set_user_password` · +`sys_user.create_user` · `sys_user.delete_my_account` · `sys_user.change_my_password` · +`sys_user.change_my_email` · `sys_user.disable_two_factor` · `sys_user.generate_backup_codes` · +`sys_two_factor.disable_two_factor` · `sys_two_factor.regenerate_backup_codes` · +`sys_api_key.create_api_key` · `sys_api_key.restore_api_key` · +`sys_oauth_application.create_oauth_application` · `sys_oauth_application.rotate_client_secret` · +`sys_sso_provider.register_sso_provider` · `sys_sso_provider.register_saml_provider` · +`sys_sso_provider.verify_domain` · `sys_member.transfer_ownership` · `sys_member.update_member_role` · +`sys_organization.delete_organization`. + +**C — exposed, confirmed (17):** `sys_user.ban_user` · `sys_user.unban_user` · `sys_user.unlock_user` · +`sys_user.set_user_manager` · `sys_api_key.revoke_api_key` · `sys_session.revoke_session` · +`sys_session.revoke_my_other_sessions` · `sys_invitation.cancel_invitation` · +`sys_invitation.reject_invitation` · `sys_member.remove_member` · `sys_team_member.remove_team_member` · +`sys_team.remove_team` · `sys_oauth_application.disable_oauth_application` · +`sys_oauth_application.delete_oauth_application` · `sys_sso_provider.delete_sso_provider` · +`sys_organization.leave_organization` · `sys_account.unlink_account`. + +**E — exposed (21):** `sys_user.invite_user` · `sys_invitation.invite_user` · +`sys_organization.invite_user` · `sys_member.invite_user` · `sys_invitation.resend_invitation` · +`sys_invitation.accept_invitation` · `sys_member.add_member` · `sys_team_member.add_team_member` · +`sys_team.add_team_member` · `sys_team.create_team` · `sys_team.update_team` · +`sys_organization.create_organization` · `sys_organization.update_organization` · +`sys_organization.rename_organization` · `sys_organization.set_active_organization` · +`sys_oauth_application.enable_oauth_application` · `sys_user.enable_two_factor` · +`sys_two_factor.enable_two_factor` · `sys_user.update_my_profile` · +`sys_user.resend_verification_email` · `sys_sso_provider.request_domain_verification`. + +That is 58 actions and 38 `ai` blocks. The card's bucket headers read H 19 and E 22; its name lists, +which the ruling adopted, hold 20 and 21, and the names are the record. + +**Not assessed.** `sys_invitation.copy_invitation_link` was added after the assessment (the link-only +invitation delivery). It declares no `ai` block, which is this ADR's default, and it is not assessed +until a maintainer ruling names it. + +**No automatic check.** The maintainer struck the pin test the assessment proposed: +「自动检查 有必要吗?每一个元数据应用都有类似的问题,后续都要检查吗」. This table lives only here; a reviewer +of `packages/platform-objects/src/identity/**` reads it. A customer app governs its own actions with +the same key and the platform's defaults: the opt-in, the confirmation default, and the ADR-0126 +activation switch. + +**How the declarations reach an agent.** Over MCP, a `sys_*` action is listed and run only when the +action itself declares `ai.exposed: true` — ADR-0066 D4's 2026-10-30 note records that mechanism +(`packages/runtime/src/action-execution.ts#systemObjectActionExposureError`). As of this addendum the +38 E and C actions above are the platform `sys_*` actions that declare it. Every gate after admission +still runs: ADR-0066 D4's capability gate, the ADR-0126 §8 activation switch, the +`ai.requiresConfirmation` gate (a C action is refused `428 ACTION_CONFIRMATION_REQUIRED`, nothing run, +until the call carries `confirm: true`; `packages/runtime/src/action-execution.ts#actionConfirmationRefusal`), +and the target's own authorization. + +**Declared is not yet runnable for an OAuth-connected agent.** All 38 are `type: 'api'` actions that +forward to an identity route: 37 to the better-auth routes under `/api/v1/auth/`, and +`sys_api_key.revoke_api_key` to the REST data route. Those targets accept a signed-in session, and the +forward of an OAuth-connected agent's call is answered `401` there. Which credentials a forward +target accepts is not decided here; it is the open decision on #28178. Until that lands, an +OAuth-connected agent lists these actions, a C action is still refused `428` until the call is +confirmed, and a call that reaches the identity route is answered `401` there, with nothing changed. +A signed-in session's same call is served. + +--- + ## References * ADR-0003 — Package as first-class citizen (where actions live) diff --git a/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts b/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts index bd70f3c4316..32eb06fede9 100644 --- a/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts +++ b/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts @@ -105,9 +105,13 @@ describe('#7309 — an action that collects params opens ONE dialog', () => { // `ai.description` is the LLM-facing contract (≥40 chars, required when // `ai.exposed`) — the same word one level down. Putting the question there // would arm a tool description while the dialog fell back to its generic line. + // An action ADR-0011's 2026-10-30 addendum exposes carries its own + // `ai.description`, written for the model — never the dialog question. for (const [name, obj] of OBJECTS) { for (const action of CONVERTED[name] ?? []) { - expect(byName(obj, action).ai?.description, `${name}.${action}.ai.description`).toBeUndefined(); + const a = byName(obj, action); + expect(a.description, `${name}.${action}.description`).toBeTruthy(); + expect(a.ai?.description, `${name}.${action}.ai.description`).not.toBe(a.description); } } }); diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index 587b431d219..33b605b1468 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -74,6 +74,14 @@ export const SysAccount = ObjectSchema.create({ params: [ { name: 'accountId', field: 'id', defaultFromRow: true, required: true }, ], + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Unlink an external sign-in provider from the user\'s account (the record; param: its accountId): they can no longer sign in with that provider until they link it again. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, ], diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index cdc3ea64dc7..8378f017b4e 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -160,6 +160,14 @@ export const SysApiKey = ObjectSchema.create({ confirmText: 'Revoke this API key? Any clients using it will immediately lose access.', successMessage: 'API key "{{ record.name }}" revoked', refreshAfter: true, + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Revoke an API key (the record): every client using it loses access immediately. It can be restored later. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, { name: 'restore_api_key', diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index 83fbfe2986c..90c10b6ea6b 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -106,6 +106,12 @@ export const SysInvitation = ObjectSchema.create({ // field, so the gate narrows this dialog and no other picker. { field: 'role', required: true, options: [...INVITATION_ROLE_OPTIONS], helpText: MEMBERSHIP_ROLE_PARAM_HELP }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Invite a person by email address to join the current organization with a role (params: email, role). They join when they accept the invitation. Use when the user asks to invite someone who is not a member yet; the role options are only the roles the caller may grant.', + }, }, { name: 'cancel_invitation', @@ -122,6 +128,14 @@ export const SysInvitation = ObjectSchema.create({ confirmText: 'Cancel this invitation? The recipient will no longer be able to accept it.', successMessage: 'Invitation canceled', refreshAfter: true, + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Cancel a pending invitation (the record), so its recipient can no longer accept it. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, { name: 'resend_invitation', @@ -152,6 +166,12 @@ export const SysInvitation = ObjectSchema.create({ { field: 'email', required: true, defaultFromRow: true, carryOver: true }, { field: 'role', required: true, defaultFromRow: true, carryOver: true }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Send a pending invitation (the record) again (params: its email and role, copied from the invitation; the stored role is kept). Its expiry date starts over. Use when the invitee lost the invitation or never received it.', + }, }, { // [#27843, ruling E on #27797] The pending invitation's accept link, for @@ -216,6 +236,12 @@ export const SysInvitation = ObjectSchema.create({ visible: "has(record.email) && record.email == ctx.user.email && has(record.status) && record.status == 'pending'", successMessage: 'Invitation accepted', refreshAfter: true, + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Accept an invitation addressed to the current user (the record), joining its organization with the invited role. Works only on a pending invitation sent to the caller\'s own email address.', + }, }, { name: 'reject_invitation', @@ -230,6 +256,14 @@ export const SysInvitation = ObjectSchema.create({ confirmText: 'Decline this invitation? The inviter will be notified and you will need a new invitation to join.', successMessage: 'Invitation declined', refreshAfter: true, + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Decline an invitation addressed to the current user (the record). The inviter is notified, and joining later needs a new invitation. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, ], diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index fa5730dddd9..10eb07e23b9 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -130,6 +130,12 @@ export const SysMember = ObjectSchema.create({ // field, so the gate narrows this dialog and no other picker. { field: 'role', required: true, options: [...INVITATION_ROLE_OPTIONS], helpText: MEMBERSHIP_ROLE_PARAM_HELP }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Invite a person by email address to join the current organization with a role (params: email, role). They join when they accept the invitation. Use when the user asks to invite someone who is not a member yet; the role options are only the roles the caller may grant.', + }, }, { // Admin-only: directly attach an existing user to the active org, @@ -200,6 +206,12 @@ export const SysMember = ObjectSchema.create({ { field: 'role', required: true, helpText: MEMBERSHIP_ROLE_PARAM_HELP }, { name: 'organizationId', field: 'organization_id' }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Add an existing user straight into an organization with a role, without an invitation (params: userId, role, and optionally organizationId, which defaults to the active organization). Platform administrators only.', + }, }, { name: 'update_member_role', @@ -245,6 +257,14 @@ export const SysMember = ObjectSchema.create({ confirmText: 'Remove this member from the organization? They will lose access to all org resources.', successMessage: 'Member removed', refreshAfter: true, + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Remove a member (the record) from the organization: they lose access to all of its resources. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, // Make Owner is `update-member-role` with role=owner, and better-auth's // `updateMemberRole` writes the TARGET's role and nothing else: the caller diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index 2f185ac1644..57e34de59fb 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -113,6 +113,14 @@ export const SysOauthApplication = ObjectSchema.create({ params: [ { name: 'client_id', field: 'client_id', defaultFromRow: true, required: true }, ], + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Disable an OAuth application (the record; param: its client_id): its tokens are rejected and integrations using it stop working until it is re-enabled. Platform administrators only. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, { name: 'enable_oauth_application', @@ -134,6 +142,12 @@ export const SysOauthApplication = ObjectSchema.create({ params: [ { name: 'client_id', field: 'client_id', defaultFromRow: true, required: true }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Re-enable a disabled OAuth application (the record; param: its client_id), so token issuance, authorization and introspection resume for that client. Platform administrators only.', + }, }, { name: 'create_oauth_application', @@ -246,6 +260,14 @@ export const SysOauthApplication = ObjectSchema.create({ params: [ { name: 'client_id', field: 'client_id', defaultFromRow: true, required: true }, ], + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Permanently delete an OAuth application (the record; param: its client_id): all tokens and consents issued to it are invalidated and its integrations stop working. This cannot be undone. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, ], diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index e97a16bb9a3..541b3c2b7d2 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -85,6 +85,12 @@ export const SysOrganization = ObjectSchema.create({ { field: 'slug', required: true }, { field: 'logo' }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Create a new organization (params: name, slug, optionally logo); the caller becomes its owner. Available only where the deployment runs several organizations.', + }, }, { name: 'update_organization', @@ -110,6 +116,12 @@ export const SysOrganization = ObjectSchema.create({ { field: 'slug', required: true, defaultFromRow: true }, { field: 'logo', defaultFromRow: true }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Edit this organization\'s name, slug and logo (the record). Changing the slug changes the addresses that name the organization; use rename_organization to change only the name or logo.', + }, }, { name: 'delete_organization', @@ -145,6 +157,12 @@ export const SysOrganization = ObjectSchema.create({ requiresFeature: 'multiOrgEnabled', successMessage: 'Active organization switched', refreshAfter: true, + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Make this organization (the record) the caller\'s active organization, so the caller\'s later requests run in its context. Use when the user asks to switch to another organization they belong to.', + }, }, { // FOURTH mirror of `invite_user` (sys_user, sys_invitation, sys_member @@ -197,6 +215,12 @@ export const SysOrganization = ObjectSchema.create({ { field: 'email', objectOverride: 'sys_invitation', required: true }, { field: 'role', objectOverride: 'sys_invitation', required: true, options: [...INVITATION_ROLE_OPTIONS], helpText: MEMBERSHIP_ROLE_PARAM_HELP }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Invite a person by email address to join this organization (the record) with a role (params: email, role). They join when they accept the invitation. The role options are only the roles the caller may grant.', + }, }, { // Current user leaves the org. Distinct from `delete_organization` @@ -224,6 +248,14 @@ export const SysOrganization = ObjectSchema.create({ confirmText: 'Leave this organization? You will lose access to its environments and members.', successMessage: 'You have left the organization', refreshAfter: true, + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Remove the caller from this organization (the record): they lose access to its environments and members. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, { // Rename the organization: its `name` and `logo`, never its `slug`. @@ -266,6 +298,12 @@ export const SysOrganization = ObjectSchema.create({ { field: 'name', required: true, defaultFromRow: true }, { field: 'logo', defaultFromRow: true }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Rename this organization or change its logo (the record; params: name, optionally logo). The slug stays as it is.', + }, }, ], diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index 49bf758d0df..705ab266175 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -83,6 +83,14 @@ export const SysSession = ObjectSchema.create({ confirmText: 'Sign out of every other device where you\'re currently logged in? Your current session will remain active.', successMessage: 'All other sessions revoked', refreshAfter: true, + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Sign the caller out on every other device; the current session stays signed in. Use when the user suspects their account is in use elsewhere. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, { name: 'revoke_session', @@ -104,6 +112,14 @@ export const SysSession = ObjectSchema.create({ confirmText: 'Revoke this session? The user will be signed out from that device.', successMessage: 'Session revoked', refreshAfter: true, + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Revoke a sign-in session (the record), signing its user out on that device. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, ], diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index e73b7e33e26..88bb9c49264 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -193,6 +193,12 @@ export const SysSsoProvider = ObjectSchema.create({ { path: 'dnsRecordValue', label: 'Value', format: 'secret' }, ], }, + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Start domain verification for an SSO provider (the record; params: its providerId, optionally domain): answers the DNS TXT record to publish at the domain\'s DNS provider. Verifying the domain afterwards is a separate step for a human. Platform administrators only.', + }, }, { name: 'verify_domain', @@ -233,6 +239,14 @@ export const SysSsoProvider = ObjectSchema.create({ params: [ { name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true }, ], + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Delete an SSO provider (the record; param: its providerId): users from its email domain can no longer sign in through it. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, ], diff --git a/packages/platform-objects/src/identity/sys-team-member.object.ts b/packages/platform-objects/src/identity/sys-team-member.object.ts index ca5ce983f67..6309ed8bc46 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -66,6 +66,12 @@ export const SysTeamMember = ObjectSchema.create({ { name: 'teamId', field: 'team_id', required: true }, { name: 'userId', field: 'user_id', required: true }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Add a user to a team (params: teamId, userId). The user must already be a member of the team\'s organization.', + }, }, { // Better-auth: `organization/remove-team-member { teamId, userId }`. @@ -92,6 +98,14 @@ export const SysTeamMember = ObjectSchema.create({ { name: 'teamId', field: 'team_id', required: true, defaultFromRow: true }, { name: 'userId', field: 'user_id', required: true, defaultFromRow: true }, ], + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Remove a user from a team (params: teamId, userId): they lose the team-scoped access. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, ], diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index 24b6d67fdd6..5f86d6baa4c 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -56,6 +56,12 @@ export const SysTeam = ObjectSchema.create({ { field: 'name', required: true }, { name: 'organizationId', field: 'organization_id' }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Create a team in an organization (param: name; optionally organizationId, which defaults to the active organization). Teams group members for team-scoped access.', + }, }, { // Better-auth: `organization/update-team { teamId, data: { name } }`. @@ -77,6 +83,12 @@ export const SysTeam = ObjectSchema.create({ params: [ { field: 'name', required: true, defaultFromRow: true }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Rename this team (the record; param: name). Membership and team-scoped access are unchanged.', + }, }, { // Better-auth: `organization/add-team-member { teamId, userId }` — the @@ -102,6 +114,12 @@ export const SysTeam = ObjectSchema.create({ // through `objectOverride` — the sys_user lookup on the membership row. { name: 'userId', field: 'user_id', objectOverride: 'sys_team_member', required: true }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Add a user to this team (the record; param: userId). The user must already be a member of the team\'s organization.', + }, }, { // Better-auth: `organization/remove-team { teamId, organizationId? }`. @@ -120,6 +138,14 @@ export const SysTeam = ObjectSchema.create({ confirmText: 'Delete this team? Members will lose any team-scoped access. This cannot be undone.', successMessage: 'Team deleted', refreshAfter: true, + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Delete a team (the record): its members lose the team-scoped access. This cannot be undone. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, ], diff --git a/packages/platform-objects/src/identity/sys-two-factor.object.ts b/packages/platform-objects/src/identity/sys-two-factor.object.ts index 54370cc5d07..48a034f1828 100644 --- a/packages/platform-objects/src/identity/sys-two-factor.object.ts +++ b/packages/platform-objects/src/identity/sys-two-factor.object.ts @@ -93,6 +93,12 @@ export const SysTwoFactor = ObjectSchema.create({ { path: 'backupCodes', label: 'Backup Codes', format: 'code-list' }, ], }, + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Turn on two-factor authentication for the caller\'s own account (param: the current password). Answers the authenticator URI and one-time backup codes; the user must save the codes, which are shown only once.', + }, }, { name: 'disable_two_factor', diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 2f2f8b00a46..43fcb3adf7d 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -141,6 +141,12 @@ export const SysUser = ObjectSchema.create({ // field, so the gate narrows this dialog and no other picker. { field: 'role', objectOverride: 'sys_member', required: true, options: [...INVITATION_ROLE_OPTIONS], helpText: MEMBERSHIP_ROLE_PARAM_HELP }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Invite a person by email address to join the current organization with a role (params: email, role). They join when they accept the invitation. Use when the user asks to invite someone who is not a member yet; the role options are only the roles the caller may grant.', + }, }, // ── Platform admin operations (require better-auth `admin` plugin) ─ @@ -206,6 +212,14 @@ export const SysUser = ObjectSchema.create({ params: [ { name: 'banReason', label: 'Ban Reason', type: 'text', required: false }, ], + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Ban a user (the record; optionally banReason): they are signed out and cannot sign in until unbanned. Platform administrators only. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, { name: 'unban_user', @@ -222,6 +236,14 @@ export const SysUser = ObjectSchema.create({ recordIdParam: 'userId', successMessage: 'User unbanned', refreshAfter: true, + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Lift the ban on a banned user (the record), so they can sign in again. Platform administrators only. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, { // ADR-0069 D2 — clear a brute-force lockout early (locked_until auto- @@ -249,6 +271,14 @@ export const SysUser = ObjectSchema.create({ recordIdParam: 'userId', successMessage: 'Account unlocked', refreshAfter: true, + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Release a user (the record) from a sign-in lockout before it expires, at the password and the second-factor stage, and reset the failed-attempt count. Platform administrators only. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, { // #2766 V1 — a platform admin can add a login-capable teammate without @@ -524,6 +554,14 @@ export const SysUser = ObjectSchema.create({ helpText: 'The user this person reports to. The server refuses a manager outside their organization, a self-assignment, and a link that would close or over-deepen the reporting chain.', }, ], + // ADR-0011 identity-actions note, bucket C: exposed, and the + // confirmation is never relaxed. + ai: { + exposed: true, + description: + 'Set the manager a user (the record) reports to (param: managerId, a user in the same organization). The reporting chain drives approval routing and the own_and_reports record scope. Ask the human to confirm before calling.', + requiresConfirmation: true, + }, }, // ── Self-service actions (the row owner only) ───────────────────── @@ -564,6 +602,12 @@ export const SysUser = ObjectSchema.create({ { field: 'name', required: false, defaultFromRow: true }, { field: 'image', required: false, defaultFromRow: true }, ], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Update the caller\'s own display name and profile image (the record must be the caller\'s own user). Use when the user asks to change how their name or picture appears.', + }, }, { name: 'change_my_password', @@ -617,6 +661,12 @@ export const SysUser = ObjectSchema.create({ successMessage: 'Verification email sent — check your inbox.', refreshAfter: false, params: [], + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Send the caller another email-address verification message, for their own account whose email address is not verified yet.', + }, }, { name: 'delete_my_account', @@ -683,6 +733,12 @@ export const SysUser = ObjectSchema.create({ { path: 'backupCodes', label: 'Backup Codes', format: 'code-list' }, ], }, + // ADR-0011 identity-actions note, bucket E: exposed by plain declaration. + ai: { + exposed: true, + description: + 'Turn on two-factor authentication for the caller\'s own account (param: the current password). Answers the authenticator URI and one-time backup codes; the user must save the codes, which are shown only once.', + }, }, { name: 'disable_two_factor', diff --git a/packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts b/packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts index 49f502ad7c6..42d248166b9 100644 --- a/packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts +++ b/packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts @@ -1,48 +1,58 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#27681] An action on a `sys_*` object is reachable over MCP only when the -// action itself declares `ai: { exposed: true, description }` (ADR-0011) — -// pinned on a `sys_user` identity action, through the live MCP transport of a -// booted showcase with better-auth's admin plugin on and the real -// `/admin/ban-user` target. +// [#27681, #27900] An action on a `sys_*` object is reachable over MCP only +// when the action itself declares `ai: { exposed: true, description }` +// (ADR-0011) — pinned on the platform's OWN identity declarations, through the +// live MCP transport of a booted showcase with better-auth's admin plugin on +// and the real identity targets. // -// ## The ruling this file encodes +// ## The rulings this file encodes // -// Maintainer ruling 6513869839 (letter B). Before it, the runtime refused every -// action on a `sys_*` object by name, ahead of the exposure check, and the MCP -// tool layer hid and refused them again — while `POST /actions/sys_user/…` -// served the same action to the same administrator. Now the name refusal -// yields to the action's own opt-in at both MCP sites (`list_actions`, +// Maintainer ruling 6513869839 on #27681 (letter B): the runtime's `sys_*` name +// refusal yields to the action's own opt-in at both MCP sites (`list_actions`, // `run_action`), and every gate behind it runs unchanged. // -// ## The declaration here is TEST-ONLY +// Maintainer ruling 6523087116 on #27900 (letter A+): the platform identity +// actions are assessed in three buckets, recorded in ADR-0011's 2026-10-30 +// addendum — E exposed by plain declaration, C exposed with +// `requiresConfirmation: true`, H with no `ai` block. This file drives ONE +// action per bucket. It is not a pin over the table: the ruling struck any +// automatic check of it, and the table lives in the ADR only. // -// No platform `sys_*` action declares an `ai` block (ruling parameter 2, as -// amended: the declarations follow their own decision card). `ban_user` is -// given one for THIS boot only — set on the imported `SysUser` before the boot -// registers it, removed in `afterAll` — with `requiresConfirmation: true`, the -// shape the ruling requires for a destructive identity act. Its sibling -// `unban_user` keeps no `ai` block and is the unflagged control on the SAME -// object. +// ## Two callers // -// ## What each case pins +// - The administrator's SESSION bearer pins the #27681 mechanism on +// `ban_user` (C): listed and attested, refused 428 unconfirmed, the +// ADR-0126 §8 switch, the target's own authorization, and the confirmed +// call that bans. The human-only `impersonate_user` (H) on the SAME object +// is the control: never listed, refused by name. +// - An OAUTH-connected agent — the caller the #27900 ruling exposes these +// actions to — drives the three bucket cases: `update_my_profile` (E) +// listed and run, `ban_user` (C) refused until confirmed, and +// `impersonate_user` (H) absent. Its token is minted by the embedded +// authorization server the way an MCP client connects: dynamic client +// registration, authorize with the MCP resource and PKCE, consent, token. // -// - `list_actions` lists `ban_user` (attested `aiExposed`) and not -// `unban_user`: the agent that lists sees exactly what it may run; -// - the unflagged `unban_user` is refused by name over MCP, with and -// without `objectName`; -// - HITL: an unconfirmed `ban_user` is refused 428 and the user stays -// unbanned; -// - the ADR-0126 §8 switch: off ⇒ 409 ACTION_DISABLED over MCP even when -// confirmed, user unbanned; -// - the target still authorizes: a plain member's confirmed call is refused -// and the user stays unbanned; -// - and the confirmed administrator call bans the user. +// ## ⚠️ The E run does not succeed for the OAuth agent today — and says so +// +// Every one of these actions is `type: 'api'`: the door forwards the call to +// its identity route as the caller, and an identity route accepts a signed-in +// session only. So the agent's forward is answered 401 at the target and +// nothing changes, while the administrator's session making the same call is +// served (pinned beside it as the contrast — it is not the ruled case, and it +// does not stand in for it). The run case pins TODAY's answer; the ruled +// outcome (the agent's E call served) waits on +// the open decision on #28178, which governs which credentials a forward +// target accepts. ⛔ Do not make it pass by switching the caller to a session +// bearer, by minting a session for the agent, or by widening what an identity +// route or the REST face accepts — when #28178 lands, flip the expectation. // // `OS_SCIM_ENABLED=true` precedes the boot because `/admin/*` 501s without -// better-auth's admin plugin (see `api-action-door-forward.dogfood.test.ts`). -// The file is ISOLATED (an env toggle + a mutated platform declaration). +// better-auth's admin plugin (see `api-action-door-forward.dogfood.test.ts`), +// and `OS_OIDC_PROVIDER_ENABLED=true` mounts the embedded authorization server. +// The file is ISOLATED (env toggles). +import { createHash, randomBytes } from 'node:crypto'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { MCPServerPlugin } from '@objectstack/mcp'; @@ -52,30 +62,31 @@ import { bootShowcase } from './showcase-boot.js'; const SYS = { isSystem: true } as const; const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); +const ADMIN_EMAIL = 'admin@objectos.ai'; +const ADMIN_PASSWORD = 'admin123'; const TARGET_EMAIL = 'mcp.sys.exposure.target@example.com'; +const AGENT_TARGET_EMAIL = 'mcp.sys.exposure.agent-target@example.com'; const MEMBER_EMAIL = 'mcp.sys.exposure.member@example.com'; +const REDIRECT_URI = 'http://127.0.0.1:33419/callback'; +const AGENT_SCOPE = 'openid profile email offline_access data:read data:write actions:execute'; -/** The test-only opt-in, the shape the ruling requires on a destructive identity act. */ -const TEST_ONLY_AI = { - exposed: true, - description: 'Ban a user so they cannot sign in (dogfood test-only declaration).', - requiresConfirmation: true, -}; - -const banUserDeclaration = (): any => { - const found = ((SysUser as any).actions ?? []).find((a: any) => a?.name === 'ban_user'); - if (!found) throw new Error("sys_user no longer declares 'ban_user' — the fixture moved"); +const declarationOf = (name: string): any => { + const found = ((SysUser as any).actions ?? []).find((a: any) => a?.name === name); + if (!found) throw new Error(`sys_user no longer declares '${name}' — the fixture moved`); return found; }; -describe('[#27681] a sys_user action the author exposed to AI is listed and run over MCP, behind every gate', () => { +describe('[#27681, #27900] sys_user identity actions over MCP: what each bucket\'s declaration lets an agent reach', () => { let stack: VerifyStack; let ql: any; let admin: string; let member: string; + let agent: string; + let adminUserId: string; let targetUserId: string; + let agentTargetUserId: string; let priorScim: string | undefined; - let priorAi: unknown; + let priorOidc: string | undefined; /** One `tools/call` over the live MCP transport; the tool's text content, parsed. */ const tool = async (token: string, name: string, args: Record) => { @@ -97,54 +108,142 @@ describe('[#27681] a sys_user action the author exposed to AI is listed and run return { isError: rpc.result?.isError === true, payload, text }; }; const messageOf = (r: { payload: any; text: string }) => String(r.payload?.error?.message ?? r.payload?.message ?? r.text); - const isBanned = async () => { - const [row] = rowsOf(await ql.find('sys_user', { where: { id: targetUserId }, limit: 1 }, { context: SYS })); + const userRow = async (id: string) => + rowsOf(await ql.find('sys_user', { where: { id }, limit: 1 }, { context: SYS }))[0]; + const isBanned = async (id: string) => { + const row = await userRow(id); return row?.banned === true || row?.banned === 1; }; + const listedOnUser = async (token: string) => { + const listed = await tool(token, 'list_actions', {}); + expect(listed.isError, listed.text).toBe(false); + return (listed.payload.actions as any[]).filter((a) => a.objectName === 'sys_user'); + }; const flip = async (enabled: boolean) => { const res = await stack.apiAs(admin, 'POST', '/actions/_activation/sys_user/ban_user', { enabled }); expect(res.status, await res.clone().text()).toBe(200); }; + /** + * An access token for the MCP resource, minted by the embedded authorization + * server for the administrator, the way an MCP client connects (a public + * client, PKCE S256, `resource` = the MCP URL). + */ + const mintAgentToken = async (): Promise => { + const authSvc: any = await stack.kernel.getServiceAsync('auth'); + const resource = authSvc.getMcpResourceUrl(); + const reg = await stack.api('/auth/oauth2/register', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: 'sys-action exposure agent', + redirect_uris: [REDIRECT_URI], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + application_type: 'native', + scope: AGENT_SCOPE, + }), + }); + const client: any = await reg.json(); + expect(reg.status, JSON.stringify(client)).toBe(201); + const signIn = await stack.api('/auth/sign-in/email', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + }); + const cookie = ((signIn.headers as any).getSetCookie() as string[]).map((c) => c.split(';')[0]).join('; '); + const verifier = randomBytes(32).toString('base64url'); + const query = new URLSearchParams({ + client_id: client.client_id, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: AGENT_SCOPE, + state: 'st', + code_challenge: createHash('sha256').update(verifier).digest('base64url'), + code_challenge_method: 'S256', + resource, + }); + const authorize = await stack.api(`/auth/oauth2/authorize?${query}`, { headers: { cookie }, redirect: 'manual' }); + const location = authorize.headers.get('location') ?? ''; + let code = new URL(location, 'http://localhost:3000').searchParams.get('code'); + if (!code) { + const consent = await stack.api('/auth/oauth2/consent', { + method: 'POST', + headers: { 'content-type': 'application/json', cookie, origin: 'http://localhost:3000' }, + body: JSON.stringify({ accept: true, oauth_query: location.slice(location.indexOf('?')) }), + }); + const answer: any = await consent.json(); + expect(consent.status, JSON.stringify(answer)).toBe(200); + code = new URL(answer?.redirect_uri ?? answer?.url).searchParams.get('code'); + } + expect(code, `authorize answered ${authorize.status} ${location}`).toBeTruthy(); + const token = await stack.api('/auth/oauth2/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: code!, + redirect_uri: REDIRECT_URI, + client_id: client.client_id, + code_verifier: verifier, + resource, + }).toString(), + }); + const issued: any = await token.json(); + expect(token.status, JSON.stringify(issued)).toBe(200); + return String(issued.access_token); + }; + beforeAll(async () => { priorScim = process.env.OS_SCIM_ENABLED; + priorOidc = process.env.OS_OIDC_PROVIDER_ENABLED; process.env.OS_SCIM_ENABLED = 'true'; - const ban = banUserDeclaration(); - priorAi = ban.ai; - expect(priorAi, 'the platform now declares an ai block on ban_user — this fixture would mask it').toBeUndefined(); - ban.ai = TEST_ONLY_AI; + process.env.OS_OIDC_PROVIDER_ENABLED = 'true'; + // The premise, read off the platform's own declarations (ADR-0011's + // 2026-10-30 addendum): one action per bucket, nothing mutated here. + expect(declarationOf('ban_user').ai).toMatchObject({ exposed: true, requiresConfirmation: true }); + expect(declarationOf('update_my_profile').ai).toMatchObject({ exposed: true }); + expect(declarationOf('update_my_profile').ai.requiresConfirmation).toBeUndefined(); + expect(declarationOf('impersonate_user').ai).toBeUndefined(); stack = await bootShowcase({ extraPlugins: [new MCPServerPlugin()] }); admin = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); await stack.signUp(TARGET_EMAIL, 'Target-Pass-123'); + await stack.signUp(AGENT_TARGET_EMAIL, 'Target-Pass-123'); member = await stack.signUp(MEMBER_EMAIL, 'Member-Pass-123'); - const [target] = rowsOf(await ql.find('sys_user', { where: { email: TARGET_EMAIL }, limit: 1 }, { context: SYS })); - targetUserId = String(target.id); + const idOf = async (email: string) => + String(rowsOf(await ql.find('sys_user', { where: { email }, limit: 1 }, { context: SYS }))[0]?.id); + adminUserId = await idOf(ADMIN_EMAIL); + targetUserId = await idOf(TARGET_EMAIL); + agentTargetUserId = await idOf(AGENT_TARGET_EMAIL); + agent = await mintAgentToken(); }, 300_000); afterAll(async () => { await stack?.stop?.(); - const ban = banUserDeclaration(); - if (priorAi === undefined) delete ban.ai; - else ban.ai = priorAi; if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED; else process.env.OS_SCIM_ENABLED = priorScim; + if (priorOidc === undefined) delete process.env.OS_OIDC_PROVIDER_ENABLED; + else process.env.OS_OIDC_PROVIDER_ENABLED = priorOidc; }); - it('list_actions lists the flagged ban_user (attested, confirmation-gated) and not the unflagged unban_user', async () => { - const listed = await tool(admin, 'list_actions', {}); - expect(listed.isError, listed.text).toBe(false); - const onUser = (listed.payload.actions as any[]).filter((a) => a.objectName === 'sys_user'); - expect(onUser.map((a) => a.name)).toEqual(['ban_user']); - expect(onUser[0]).toMatchObject({ aiExposed: true, requiresConfirmation: true, requiresRecord: true }); + // ── The administrator's session: the #27681 mechanism on a C action ────── + + it('list_actions lists the declared ban_user (attested, confirmation-gated) and not the human-only impersonate_user', async () => { + const onUser = await listedOnUser(admin); + const names = onUser.map((a) => a.name); + expect(names).toContain('ban_user'); + expect(names).not.toContain('impersonate_user'); + expect(onUser.find((a) => a.name === 'ban_user')).toMatchObject({ aiExposed: true, requiresConfirmation: true, requiresRecord: true }); }, 120_000); - it('the unflagged unban_user stays refused by name, with and without objectName', async () => { - const scoped = await tool(admin, 'run_action', { actionName: 'unban_user', objectName: 'sys_user', recordId: targetUserId }); + it('the human-only impersonate_user is refused by name, with and without objectName', async () => { + const scoped = await tool(admin, 'run_action', { actionName: 'impersonate_user', objectName: 'sys_user', recordId: targetUserId }); expect(scoped.isError).toBe(true); expect(messageOf(scoped)).toContain('system object "sys_user" is not exposed via MCP'); - const byName = await tool(admin, 'run_action', { actionName: 'unban_user', recordId: targetUserId }); + const byName = await tool(admin, 'run_action', { actionName: 'impersonate_user', recordId: targetUserId }); expect(byName.isError).toBe(true); expect(messageOf(byName)).toContain("is on a system object ('sys_user') and is not exposed via MCP"); }, 120_000); @@ -155,7 +254,7 @@ describe('[#27681] a sys_user action the author exposed to AI is listed and run }); expect(refused.isError).toBe(true); expect(refused.payload?.error).toMatchObject({ code: 'ACTION_CONFIRMATION_REQUIRED', status: 428 }); - expect(await isBanned(), 'an unconfirmed agent call banned the user').toBe(false); + expect(await isBanned(targetUserId), 'an unconfirmed agent call banned the user').toBe(false); }, 120_000); it('the packaged-action switch holds over MCP: ban_user OFF answers 409 ACTION_DISABLED even confirmed', async () => { @@ -166,7 +265,7 @@ describe('[#27681] a sys_user action the author exposed to AI is listed and run }); expect(refused.isError).toBe(true); expect(refused.payload?.error).toMatchObject({ code: 'ACTION_DISABLED', status: 409 }); - expect(await isBanned()).toBe(false); + expect(await isBanned(targetUserId)).toBe(false); } finally { await flip(true); } @@ -177,7 +276,7 @@ describe('[#27681] a sys_user action the author exposed to AI is listed and run actionName: 'ban_user', objectName: 'sys_user', recordId: targetUserId, confirm: true, }); expect(refused.isError).toBe(true); - expect(await isBanned()).toBe(false); + expect(await isBanned(targetUserId)).toBe(false); }, 120_000); it('the confirmed administrator call runs through every gate and bans the user', async () => { @@ -186,6 +285,58 @@ describe('[#27681] a sys_user action the author exposed to AI is listed and run }); expect(ran.isError, ran.text).toBe(false); expect(ran.payload).toMatchObject({ ok: true, action: 'ban_user', objectName: 'sys_user', recordId: targetUserId }); - expect(await isBanned()).toBe(true); + expect(await isBanned(targetUserId)).toBe(true); + }, 120_000); + + // ── The OAuth-connected agent: one case per bucket ─────────────────────── + + it('E: the agent lists update_my_profile, attested and not confirmation-gated', async () => { + const onUser = await listedOnUser(agent); + expect(onUser.find((a) => a.name === 'update_my_profile')).toMatchObject({ + aiExposed: true, requiresConfirmation: false, requiresRecord: true, + }); + }, 120_000); + + it('E, today (waits on #28178): the agent\'s update_my_profile run is answered 401 at its identity route, and the name is unchanged', async () => { + const before = (await userRow(adminUserId))?.name; + const ran = await tool(agent, 'run_action', { + actionName: 'update_my_profile', objectName: 'sys_user', recordId: adminUserId, params: { name: 'Renamed By Agent' }, + }); + expect(ran.isError, ran.text).toBe(true); + expect(ran.payload?.error).toMatchObject({ status: 401 }); + expect((await userRow(adminUserId))?.name).toBe(before); + }, 120_000); + + it('E, the contrast: the same update_my_profile run from the administrator\'s session is served — the declaration is live, and the agent\'s 401 is the identity route\'s credential rule', async () => { + const ran = await tool(admin, 'run_action', { + actionName: 'update_my_profile', objectName: 'sys_user', recordId: adminUserId, params: { name: 'Renamed By Session' }, + }); + expect(ran.isError, ran.text).toBe(false); + expect(ran.payload).toMatchObject({ ok: true, action: 'update_my_profile', objectName: 'sys_user', recordId: adminUserId }); + expect((await userRow(adminUserId))?.name).toBe('Renamed By Session'); + }, 120_000); + + it('C: the agent\'s unconfirmed ban_user is refused 428 before any forward; confirmed, today it is answered 401 at its identity route (waits on #28178); nobody is banned', async () => { + const unconfirmed = await tool(agent, 'run_action', { + actionName: 'ban_user', objectName: 'sys_user', recordId: agentTargetUserId, params: { banReason: 'agent' }, + }); + expect(unconfirmed.isError).toBe(true); + expect(unconfirmed.payload?.error).toMatchObject({ code: 'ACTION_CONFIRMATION_REQUIRED', status: 428 }); + expect(await isBanned(agentTargetUserId)).toBe(false); + + const confirmed = await tool(agent, 'run_action', { + actionName: 'ban_user', objectName: 'sys_user', recordId: agentTargetUserId, params: { banReason: 'agent' }, confirm: true, + }); + expect(confirmed.isError, confirmed.text).toBe(true); + expect(confirmed.payload?.error).toMatchObject({ status: 401 }); + expect(await isBanned(agentTargetUserId)).toBe(false); + }, 120_000); + + it('H: impersonate_user is absent from the agent\'s listing and refused by name', async () => { + const onUser = await listedOnUser(agent); + expect(onUser.map((a) => a.name)).not.toContain('impersonate_user'); + const refused = await tool(agent, 'run_action', { actionName: 'impersonate_user', objectName: 'sys_user', recordId: agentTargetUserId }); + expect(refused.isError).toBe(true); + expect(messageOf(refused)).toContain('system object "sys_user" is not exposed via MCP'); }, 120_000); }); diff --git a/packages/runtime/src/action-execution-destructive.test.ts b/packages/runtime/src/action-execution-destructive.test.ts index db73edc77d3..1217d82716d 100644 --- a/packages/runtime/src/action-execution-destructive.test.ts +++ b/packages/runtime/src/action-execution-destructive.test.ts @@ -38,8 +38,10 @@ * actions, which is pinned below so the count of grounds is a measurement, not * a memory). Since #27681 gates 1 and 3 read ONE fact — the action's own * `ai.exposed` — so the exclusion is one ground asserted at both gates, not two - * independent ones — this fix has zero observable effect on any request a - * caller can make today. + * independent ones — this fix had zero observable effect on any request a + * caller could make when it landed. Since ADR-0011's 2026-10-30 addendum seven + * of the 13 declare `ai.exposed` and reach the classifier; the last `describe` + * below pins which. * * That is a fact about *today's platform objects*, not about the heuristic — * a future `ai.exposed`, non-`sys_*`, `script`/`flow` action carrying only @@ -147,7 +149,7 @@ describe("actionLooksDestructive: mode:'delete' and variant:'danger' still class // composition that mounts it declares it), so it is pinned absent instead. // --------------------------------------------------------------------------- -describe('the 5 identity actions #7309 flipped that the platform still declares read not-destructive, off their real declarations', () => { +describe('the 5 identity actions #7309 flipped that the platform still declares, off their real declarations', () => { const flipped: Array<[string, any, any]> = [ ['sys_user.generate_backup_codes', SysUser, 'generate_backup_codes'], ['sys_two_factor.regenerate_backup_codes', SysTwoFactor, 'regenerate_backup_codes'], @@ -156,7 +158,22 @@ describe('the 5 identity actions #7309 flipped that the platform still declares ['sys_oauth_application.rotate_client_secret', SysOauthApplication, 'rotate_client_secret'], ]; - it.each(flipped)('%s: no confirmText, no mode:delete/variant:danger, no ai override → not destructive', (_label, obj, name) => { + // ADR-0011's 2026-10-30 addendum (ruling 6523087116 on #27900) exposes + // `disable_oauth_application` in bucket C, with `ai.requiresConfirmation: + // true`: the declared override now decides its classification — the + // precedence pinned above — so the heuristic's flip is read on the other + // four, which declare no override. + const heuristicOnly = flipped.filter(([, obj, name]) => actionByName(obj, name).ai?.requiresConfirmation === undefined); + + it('one of the five now declares an override, and it wins: disable_oauth_application (bucket C) → destructive', () => { + expect(flipped.filter((row) => !heuristicOnly.includes(row)).map(([label]) => label)) + .toEqual(['sys_oauth_application.disable_oauth_application']); + const action = actionByName(SysOauthApplication, 'disable_oauth_application'); + expect(action.ai?.requiresConfirmation).toBe(true); + expect(actionLooksDestructive(deps, action)).toBe(true); + }); + + it.each(heuristicOnly)('%s: no confirmText, no mode:delete/variant:danger, no ai override → not destructive', (_label, obj, name) => { const action = actionByName(obj, name); // Guard the fixture's own premise: it must actually be the shape // #7828 describes, or this pin proves nothing. @@ -197,6 +214,12 @@ describe('sibling identity actions with a declared destructive signal still read const action = actionByName(SysOrganization, 'leave_organization'); expect(action.variant).toBe('danger'); expect(action.mode).not.toBe('delete'); + // ADR-0011's 2026-10-30 addendum exposes it in bucket C, and its declared + // `ai.requiresConfirmation: true` is read before the variant — so the + // variant leg is read with that override set aside, and the declared + // flag is asserted on its own. + expect(action.ai?.requiresConfirmation).toBe(true); + expect(actionLooksDestructive(deps, { ...action, ai: undefined })).toBe(true); expect(actionLooksDestructive(deps, action)).toBe(true); }); }); @@ -287,9 +310,14 @@ describe('the classification IS live-reachable for a shape gate 1/2/3 all pass // holds: #27506 made a same-origin api action server-dispatched, so gate 2 // ADMITS all 13 and gates 1 and 3 carry the exclusion alone. // #7309 converted 14; `sys_organization.change_slug` left the platform object. +// +// Since ADR-0011's 2026-10-30 addendum (ruling 6523087116 on #27900) seven of +// the 13 declare `ai.exposed` (buckets E and C), so gates 1 and 3 ADMIT them +// and the classifier is reached on a real identity action; the six human-only +// ones (bucket H) declare no `ai` block and stay excluded at both gates. // --------------------------------------------------------------------------- -describe('today, all 13 #7309 identity actions the platform still declares are excluded before actionLooksDestructive ever runs on them', () => { +describe('the 13 #7309 identity actions the platform still declares: the human-only six are excluded before actionLooksDestructive runs, the exposed seven reach it', () => { // The 13 of the 14 sites #7309 converted that the platform still // declares — same set pinned by // `platform-objects/src/identity/action-confirm-one-dialog.test.ts`'s @@ -315,13 +343,49 @@ describe('today, all 13 #7309 identity actions the platform still declares are e expect(allThirteen).toHaveLength(13); }); - it.each(allThirteen)('%s is excluded by systemObjectActionExposureError (gate 1: sys_* fail-closed unless ai.exposed)', (_label, objName, obj, name) => { + const exposed = new Set([ + 'sys_user.ban_user', + 'sys_oauth_application.enable_oauth_application', + 'sys_oauth_application.disable_oauth_application', + 'sys_oauth_application.delete_oauth_application', + 'sys_account.unlink_account', + 'sys_sso_provider.delete_sso_provider', + 'sys_team_member.remove_team_member', + ]); + const humanOnly = allThirteen.filter(([label]) => !exposed.has(label)); + const exposedRows = allThirteen.filter(([label]) => exposed.has(label)); + + it('seven are exposed and six human-only', () => { + expect(exposedRows).toHaveLength(7); + expect(humanOnly.map(([label]) => label)).toEqual([ + 'sys_user.delete_my_account', + 'sys_user.disable_two_factor', + 'sys_user.generate_backup_codes', + 'sys_oauth_application.rotate_client_secret', + 'sys_two_factor.disable_two_factor', + 'sys_two_factor.regenerate_backup_codes', + ]); + }); + + it.each(humanOnly)('%s is excluded by systemObjectActionExposureError (gate 1: sys_* fail-closed unless ai.exposed)', (_label, objName, obj, name) => { expect(isSystemObjectName(objName)).toBe(true); // Since #27681 the name alone no longer decides: the action's own - // `ai.exposed` does, and none of the 13 declares it. + // `ai.exposed` does, and a human-only action declares none. expect(systemObjectActionExposureError(deps, actionByName(obj, name), objName)).toMatch(/system object/); }); + it.each(exposedRows)('%s is admitted at gates 1 and 3 (its own ai.exposed), and its listing classification is the declared one', (_label, objName, obj, name) => { + const action = actionByName(obj, name); + expect(action.ai?.exposed).toBe(true); + expect(systemObjectActionExposureError(deps, action, objName)).toBeNull(); + expect(actionAiExposureError(deps, action)).toBeNull(); + // Bucket C declares `requiresConfirmation: true`; the one bucket E row + // (`enable_oauth_application`) declares none and carries no + // mode/variant signal, so it reads not destructive. + expect(summarizeAction(deps, action, { fields: {} }, objName).requiresConfirmation) + .toBe(action.ai?.requiresConfirmation === true); + }); + it.each(allThirteen)('%s is ADMITTED by isHeadlessInvokableAction since #27506 (gate 2: a same-origin api action is server-dispatched)', (_label, _objName, obj, name) => { const action = actionByName(obj, name); expect(action.type).toBe('api'); @@ -329,7 +393,7 @@ describe('today, all 13 #7309 identity actions the platform still declares are e expect(isHeadlessInvokableAction(deps, action, true)).toBe(true); }); - it.each(allThirteen)('%s is excluded by actionAiExposureError (gate 3: ai.exposed not set)', (_label, _objName, obj, name) => { + it.each(humanOnly)('%s is excluded by actionAiExposureError (gate 3: ai.exposed not set)', (_label, _objName, obj, name) => { const action = actionByName(obj, name); expect(action.ai?.exposed).not.toBe(true); // Matches the real call site (domains/mcp.ts listActions): 2-arg, no