Skip to content

SECURITY: agent scope grants can be granted but never revoked (and assign-agent falsely reports revocation) #2148

Description

@jaylfc

SECURITY: agent scope grants can be created but never revoked

Found 2026-07-26 while trying to honour a routine revocation request: @taOS-website-dev
finished a cross-project review, asked for their files_read grant on the taOSc project
to be removed, and there is no way to do it.

The gap

tinyagentos/agent_grants_store.py exposes exactly three operations:

  • add_grant(...)
  • list_grants(canonical_id)
  • list_active_grants()

There is no revoke, no delete, no expire, at the store layer or as a route. Once a
scope is granted to an agent identity through the scope-request flow, it is permanent for
the life of that identity.

Consequences:

  1. Least privilege is unachievable. Grants only accumulate. An agent that needed read
    access to one project for one review keeps it forever.
  2. The only removal is nuclear. agent_registry_store.revoke(canonical_id) kills the
    whole identity - every scope on every project - and forces a re-mint. That is not a
    proportionate response to "this review is finished".
  3. expires_at exists in the schema and nothing ever sets it. Time-boxed grants are
    half-built: the column is there, add_grant always leaves it null.

Worse: a path that LOOKS like revocation and is not

POST /api/projects/{id}/members/assign-agent with "scopes": [] returns
{"granted_scopes": []} - a success response that reads exactly like a revocation.
It is not. Verified today: after that call the registry grant (id 199, files_read,
prj-b6ao4t) was still present and still active. Project member scopes and registry
grants are two different stores, and the admin surface silently reconciles only one.

An operator following the obvious path believes they removed access. They did not.
That is worse than having no revoke path at all.

What is needed

  1. revoke_grant(canonical_id, scope, project_id) on the grants store, plus
    revoke_all_for_project(canonical_id, project_id) for the common case.
  2. An owner/admin route to call it. Deny by default, audit-logged like the approve path.
  3. Make assign-agent honest: reducing or clearing scopes must reconcile the registry
    grants too, or must fail loudly rather than report a success it did not perform.
  4. Populate expires_at and honour it at check time, so a grant can be time-boxed to the
    job it was requested for.
  5. Surface active grants per agent in the Agents app with a Revoke action - an operator
    should not need the API to undo a grant they made in the UI.

Acceptance

  • A granted scope can be revoked individually, and the agent's next request for that
    scope is refused.
  • Revoking one scope on one project leaves the identity's other scopes and projects intact.
  • assign-agent with reduced scopes either reconciles registry grants or errors; a test
    asserts it can no longer report success while access persists.
  • An expired grant is refused at check time.
  • Revocation and expiry are audit-logged.
  • Existing grant/approve flows unchanged.

Metadata

Metadata

Assignees

No one assigned

    Labels

    securitySecurity improvements

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions