Skip to content

feat(security): external-agent consent loop backend (Trust & Comms Phase 1) - #719

Merged
jaylfc merged 4 commits into
devfrom
feat/consent-loop-backend
Jun 9, 2026
Merged

feat(security): external-agent consent loop backend (Trust & Comms Phase 1)#719
jaylfc merged 4 commits into
devfrom
feat/consent-loop-backend

Conversation

@jaylfc

@jaylfc jaylfc commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Phase 1 of the Trust & Comms Layer (spec: docs/superpowers/specs/2026-06-09-trust-comms-layer-design.md) — the external-agent consent loop backend.

What

  • auth_requests_store.py — pending/accepted/refused requests; atomic set_decision (UPDATE…WHERE status='pending').
  • agent_grants_store.py — minimal per-scope grants (canonical_id, scope, tier, expires_at); list_active_grants() for the feed @taOSmd polls.
  • routes/agent_auth_requests.pyPOST /api/agents/auth-requests (public — external agent's front door) + GET .../{id} status poll (public; token returned only on accepted) + POST .../{id}/approve|deny + GET list (admin-only). Approve flow: registry.register (mint canonical_id) → set_status active → mint EdDSA-JWT → grants → atomic accept.
  • auth_middleware.py — method-sensitive _is_exempt: exempts only POST create + single-segment GET status; approve/deny/list require admin auth.

Contract (confirmed w/ @taOSmd)

Token = identity-only (sub=canonical_id, user_id, iss=taos-registry); all tiering in the grant store/feed; bus gate = valid token AND active grant.

Security review notes

  • Exemption is method+path scoped — verified approve/deny/list are NOT exempt (subactions have / in tail; list has no trailing slash).
  • Token only surfaced on accepted status; request_id is an opaque uuid capability.
  • Fix included: consent-approve transitions the agent pending→active (else an approved agent would sit in the inactive feed and be rejected) — regression test added.

65 tests pass (20 new + registry + auth_context). Desktop notification UI (per-scope toggles, impeccable skill) is the next build.

Summary by CodeRabbit

  • New Features

    • External-agent consent workflow: submit auth requests, poll status, and admin approve/deny endpoints.
    • Persistent per-agent grants store and admin-visible active grants feed (with optional agent filtering).
    • Updated auth exemption rules so specific consent endpoints behave correctly for unauthenticated callers.
  • Tests

    • Expanded end-to-end tests covering store behavior, HTTP routes, access control (403 for non-admin grants), grants-list filtering, rate limits, idempotency/conflict handling, and concurrent approvals.

jaylfc added 2 commits June 9, 2026 17:31
Add the request→notify→accept→mint→grant backend for external agents
to self-onboard via a human-approved handshake rather than a config file.

New modules:
- tinyagentos/auth_requests_store.py: AuthRequestsStore (aiosqlite) with
  create / get / list_pending / count_pending_for and atomic set_decision
  (conditional UPDATE WHERE status='pending' — returns None on conflict)
- tinyagentos/agent_grants_store.py: AgentGrantsStore with add_grant /
  list_grants / list_active_grants; tier='once' for Phase 1
- tinyagentos/routes/agent_auth_requests.py: five endpoints —
    POST   /api/agents/auth-requests          (exempt, no auth)
    GET    /api/agents/auth-requests/{id}     (exempt, opaque-id cap)
    POST   /api/agents/auth-requests/{id}/approve (admin only)
    POST   /api/agents/auth-requests/{id}/deny    (admin only)
    GET    /api/agents/auth-requests          (admin only, list pending)

Wiring:
- app.py: AuthRequestsStore + AgentGrantsStore created, init'd in lifespan,
  closed on shutdown, and set eagerly on app.state
- routes/__init__.py: router registered before /api/agents/{name} to avoid
  path-param capture
- auth_middleware.py: method-sensitive _is_exempt() replaces the simple
  path-in-set check — exempts POST /api/agents/auth-requests (create) and
  GET /api/agents/auth-requests/{id} (status poll) while keeping approve /
  deny / list admin-gated; token only returned on status==accepted

Approve flow: registry.register(origin='external-selfjoin') → mint_registry_token
→ AgentGrantsStore.add_grant per scope + RelationshipManager.set_permission edge
→ AuthRequestsStore.set_decision(accepted, canonical_id, token, granted_scopes)

Tests (tests/test_auth_requests.py): 20 cases covering store atomicity,
route auth gates, approve/deny/409/429 paths, token visibility, and grants.

Signed-off-by: jaylfc <jaylfc25@gmail.com>
External-selfjoin agents are born 'pending' (governance lifecycle); the
consent-loop approve now transitions the minted agent to 'active' after
register+grant, so an approved agent is not left in the bus inactive/revocation
feed (which would make @taOSmd's gate reject it). Adds a regression assertion.

Signed-off-by: jaylfc <jaylfc25@gmail.com>
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Implements a Phase‑1 external-agent consent workflow: adds persistent AuthRequestsStore and AgentGrantsStore, wires them into app state, adds path-aware auth exemptions, registers a consent router, implements create/poll/approve/deny/list endpoints with per-request locks and atomic decisions, and adds end-to-end async tests.

Changes

External Agent Consent Loop

Layer / File(s) Summary
Auth Request and Agent Grant Data Stores
tinyagentos/auth_requests_store.py, tinyagentos/agent_grants_store.py
Defines AuthRequestsStore (pending→accepted/refused atomic transitions, create/get/list/count) and AgentGrantsStore (per-agent scope grants with add/list/list_active).
Application Store Initialization and Lifecycle
tinyagentos/app.py
Instantiates and initializes AuthRequestsStore and AgentGrantsStore in create_app, assigns them to app.state, eagerly exposes them pre-lifespan, and closes them on shutdown.
Middleware Auth Exemptions for Consent Loop
tinyagentos/auth_middleware.py
Adds _is_exempt(method, path) to allow unauthenticated POST create and single-segment GET status polling on /api/agents/auth-requests, while requiring auth for approve/deny/list.
Consent Loop Router Registration
tinyagentos/routes/__init__.py
Registers the agent-auth-requests router before the generic /api/agents/{name} route to avoid path capture.
Consent Loop HTTP Endpoints and Admin Operations
tinyagentos/routes/agent_auth_requests.py
Adds endpoints: unauthenticated POST create (pending-cap rate limit) and GET status (token only on accepted); admin-only POST approve (registry registration, activation, token minting, grant writes, atomic accepted decision), POST deny (atomic refused decision), and GET list pending. Includes per-request approve locks and state accessors.
Registry Grants Feed
tinyagentos/routes/agent_registry.py
Adds admin-only GET /api/agents/registry/grants returning active grants (optionally filtered by canonical_id) from AgentGrantsStore and enforces admin authorization.
Store and Route Integration Tests
tests/test_auth_requests.py
Adds async tests and fixtures validating AuthRequestsStore lifecycle, route behaviors (create/poll/approve/deny/list), concurrency/conflict (409), rate limits (429), access control (403/404), token/grant issuance, grants feed population and canonical_id filtering.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • jaylfc/taOS#710: Related changes touching auth middleware exemption/dispatch used by the consent-loop and admin endpoints.
  • jaylfc/taOS#705: Token minting / registry identity logic used by the approval flow.
  • jaylfc/taOS#708: Registry canonical_id and token mechanics referenced by the consent approval flow.

"I hop with joy, a small dev's cheer,
Requests arrive, then admins steer,
Pending turns accepted, grants accrue,
Tokens signed, agents wake anew,
A rabbit applauds the code review crew 🐇"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change—implementing external-agent consent loop backend as Phase 1 of the Trust & Comms layer—and directly aligns with the substantial scope of the changeset across multiple store and route modules.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/consent-loop-backend

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
tinyagentos/auth_requests_store.py (3)

25-43: 💤 Low value

Consider adding indexes for query performance.

The list_pending method queries WHERE status='pending' ORDER BY created_ts, and count_pending_for queries WHERE identity_claim=? AND framework=? AND status='pending'. For deployments with many pending requests, these queries could benefit from composite indexes:

CREATE INDEX IF NOT EXISTS idx_auth_requests_status_created 
  ON auth_requests(status, created_ts);

CREATE INDEX IF NOT EXISTS idx_auth_requests_identity_framework_status 
  ON auth_requests(identity_claim, framework, status);

For Phase 1 with small tables this is not critical, but worth considering for scale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/auth_requests_store.py` around lines 25 - 43, The schema lacks
indexes for queries used by list_pending and count_pending_for which can slow
queries at scale; update SCHEMA in auth_requests_store.py to include the two
recommended composite indexes (one on status, created_ts and one on
identity_claim, framework, status) so the DB creates them if missing, ensuring
they align with the WHERE/ORDER BY patterns used by list_pending and
count_pending_for; add the CREATE INDEX IF NOT EXISTS statements immediately
after the CREATE TABLE statement or in the DB initialization routine that uses
SCHEMA to guarantee the indexes exist at startup.

76-114: ⚡ Quick win

Remove type ignore and handle potential None from get().

Line 114 suppresses a type error: get() returns Optional[dict] but create() is declared to return dict. If get() returns None after insertion (e.g., due to disk full, database corruption), this would break the contract.

🛡️ Proposed fix
-        return await self.get(request_id)  # type: ignore[return-value]
+        record = await self.get(request_id)
+        if record is None:
+            raise RuntimeError(f"Failed to retrieve auth request {request_id} after insert")
+        return record
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/auth_requests_store.py` around lines 76 - 114, create() currently
returns await self.get(request_id) with a type ignore while get() is
Optional[dict]; instead call self.get(request_id) into a variable (e.g., record
= await self.get(request_id)), check if record is None and raise an explicit
RuntimeError (including request_id/context) if so, otherwise return record so
the function's dict return type is satisfied and the type ignore can be removed;
update any relevant docstring/comments in create() to reflect the explicit error
path.

48-59: 💤 Low value

Consider logging JSON parsing errors.

Lines 54-56 catch broad exceptions (ValueError, TypeError) and silently fall back to []. If the database contains corrupted JSON, this would hide the issue. Consider logging the error or being more selective about which exceptions to catch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/auth_requests_store.py` around lines 48 - 59, In _row_to_dict,
replace the broad except handling for json parsing with catching
json.JSONDecodeError (and TypeError if needed) and log the parsing failure so
corrupted DB values aren’t silently ignored; use the module logger (e.g.,
logging.getLogger(__name__)) to record the exception, include the field name
(requested_scopes/requested_skills/granted_scopes), the raw value, and any
identifying key from the row (e.g., row.get("id")) in the log, then fall back to
[] (or None for granted_scopes) as currently implemented.
tinyagentos/agent_grants_store.py (1)

22-33: 💤 Low value

Consider adding indexes for grant queries.

list_grants queries by canonical_id and list_active_grants filters by expires_at. For larger deployments, consider:

CREATE INDEX IF NOT EXISTS idx_agent_grants_canonical_id 
  ON agent_grants(canonical_id);

CREATE INDEX IF NOT EXISTS idx_agent_grants_expires_at 
  ON agent_grants(expires_at);

Phase 1 tables will be small, so this is a future optimization.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/agent_grants_store.py` around lines 22 - 33, Add indexes to the
agent_grants schema to speed queries by canonical_id and expires_at: update the
SCHEMA string (where agent_grants table is defined) to include "CREATE INDEX IF
NOT EXISTS idx_agent_grants_canonical_id ON agent_grants(canonical_id);" and
"CREATE INDEX IF NOT EXISTS idx_agent_grants_expires_at ON
agent_grants(expires_at);", so list_grants (which queries by canonical_id) and
list_active_grants (which filters by expires_at) will use the indexes for larger
datasets.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tinyagentos/routes/agent_auth_requests.py`:
- Around line 192-243: The race creates orphaned registry/grant/relationship
records because side-effects (registry.register, mint_registry_token,
grants_store.add_grant, rel_mgr.set_permission) run before the atomic
auth_store.set_decision; fix by ensuring only the winner performs side-effects:
acquire a mutual exclusion keyed on request_id (e.g., advisory DB lock or
in-memory lock) at the start of the approve path and hold it until after
set_decision and side-effects complete, OR flip the flow so you call
auth_store.set_decision(...) first to claim the request (transition to
"accepted" or a provisional state) and only if that succeeds perform
registry.register, mint_registry_token, grants_store.add_grant and
rel_mgr.set_permission, and finally update the decision with canonical_id/token
if needed; if you choose to keep current ordering, add compensating cleanup in
the 409 branch to remove the created registry entry, grants, and relationship
edges (use registry.unregister/delete, grants_store.remove_grant,
rel_mgr.delete_permission) to avoid orphans.
- Around line 285-302: The endpoint list_auth_requests accepts a status query
but ignores it; add validation at the start of list_auth_requests to only allow
"pending" (Phase 1) and reject any other value by raising an HTTPException (400
Bad Request) with a clear message that only status=pending is supported, so
callers passing "all" or "accepted" are not silently ignored; keep the existing
call to store.list_pending() when status=="pending".

---

Nitpick comments:
In `@tinyagentos/agent_grants_store.py`:
- Around line 22-33: Add indexes to the agent_grants schema to speed queries by
canonical_id and expires_at: update the SCHEMA string (where agent_grants table
is defined) to include "CREATE INDEX IF NOT EXISTS idx_agent_grants_canonical_id
ON agent_grants(canonical_id);" and "CREATE INDEX IF NOT EXISTS
idx_agent_grants_expires_at ON agent_grants(expires_at);", so list_grants (which
queries by canonical_id) and list_active_grants (which filters by expires_at)
will use the indexes for larger datasets.

In `@tinyagentos/auth_requests_store.py`:
- Around line 25-43: The schema lacks indexes for queries used by list_pending
and count_pending_for which can slow queries at scale; update SCHEMA in
auth_requests_store.py to include the two recommended composite indexes (one on
status, created_ts and one on identity_claim, framework, status) so the DB
creates them if missing, ensuring they align with the WHERE/ORDER BY patterns
used by list_pending and count_pending_for; add the CREATE INDEX IF NOT EXISTS
statements immediately after the CREATE TABLE statement or in the DB
initialization routine that uses SCHEMA to guarantee the indexes exist at
startup.
- Around line 76-114: create() currently returns await self.get(request_id) with
a type ignore while get() is Optional[dict]; instead call self.get(request_id)
into a variable (e.g., record = await self.get(request_id)), check if record is
None and raise an explicit RuntimeError (including request_id/context) if so,
otherwise return record so the function's dict return type is satisfied and the
type ignore can be removed; update any relevant docstring/comments in create()
to reflect the explicit error path.
- Around line 48-59: In _row_to_dict, replace the broad except handling for json
parsing with catching json.JSONDecodeError (and TypeError if needed) and log the
parsing failure so corrupted DB values aren’t silently ignored; use the module
logger (e.g., logging.getLogger(__name__)) to record the exception, include the
field name (requested_scopes/requested_skills/granted_scopes), the raw value,
and any identifying key from the row (e.g., row.get("id")) in the log, then fall
back to [] (or None for granted_scopes) as currently implemented.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 116f3ccb-348f-482d-8e7c-1fcd83fc112a

📥 Commits

Reviewing files that changed from the base of the PR and between f96542a and 6f38736.

📒 Files selected for processing (7)
  • tests/test_auth_requests.py
  • tinyagentos/agent_grants_store.py
  • tinyagentos/app.py
  • tinyagentos/auth_middleware.py
  • tinyagentos/auth_requests_store.py
  • tinyagentos/routes/__init__.py
  • tinyagentos/routes/agent_auth_requests.py

Comment thread tinyagentos/routes/agent_auth_requests.py
Comment thread tinyagentos/routes/agent_auth_requests.py
jaylfc added 2 commits June 9, 2026 19:59
…ted ?status

Two concurrent approvals of the same auth-request could both pass the
pending-status check, each register a separate registry entry and write
duplicate grants, then one would win set_decision and the other return
409 with orphaned side-effects. Fix: serialise approve calls per
request_id with an asyncio.Lock stored on app.state._approve_locks.

Also: list endpoint now returns 400 for any status value other than
'pending' (Phase 1 only supports the pending feed), and removes the
misleading 'pass status=all' hint from the docstring.

auth_requests_store.py: remove # type: ignore on create(); raise
RuntimeError if get() returns None immediately after insert (invariant
violation rather than silent type lie).

Tests: +2 (concurrent approve, unsupported status=400).
…nforcement

Adds the active grant feed @taOSmd polls to enforce bus access. Admin-only,
mirrors the /revoked and /inactive patterns. Optional ?canonical_id= filter
for per-agent queries. Returns {grants: [{canonical_id, scope, tier,
project_id, granted_at, expires_at}]}. Phase 1: all grants are non-expiring
so the full list is always active.

Tests: +3 (feed populated after approve, 403 for non-admin, canonical_id filter).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tinyagentos/routes/agent_registry.py`:
- Around line 227-230: The route currently calls
grants_store.list_grants(canonical_id) which returns all grants for a
canonical_id without filtering expired entries; change the code so the
canonical_id path only returns active grants by either (A) calling
grants_store.list_active_grants() and then filtering by canonical_id (preserving
ORDER BY granted_at), or (B) updating AgentGrantsStore.list_grants(canonical_id)
to include the same expiry predicate (expires_at IS NULL OR expires_at > now())
in its query; ensure the chosen approach applies the active-filter and keeps the
granted_at ordering for GET /api/agents/registry/grants.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ea3603be-cfcd-456b-9498-4f39dee67273

📥 Commits

Reviewing files that changed from the base of the PR and between e33caba and 16b615b.

📒 Files selected for processing (2)
  • tests/test_auth_requests.py
  • tinyagentos/routes/agent_registry.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_auth_requests.py

Comment on lines +227 to +230
if canonical_id:
grants = await grants_store.list_grants(canonical_id)
else:
grants = await grants_store.list_active_grants()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if list_grants method filters by expiry like list_active_grants does

# Search for the list_grants method implementation in AgentGrantsStore
ast-grep --pattern $'class AgentGrantsStore {
  $$$
  async def list_grants($$$) {
    $$$
  }
  $$$
}'

# Also search with ripgrep to find the method and see surrounding context
rg -n -A 20 'async def list_grants' --type py

Repository: jaylfc/taOS

Length of output: 1794


Fix canonical_id grants path to apply “active” (expires_at) filtering

GET /api/agents/registry/grants is documented as an “active grant feed” (active iff expires_at IS NULL OR expires_at > now), but when ?canonical_id= is provided it calls AgentGrantsStore.list_grants(canonical_id). That method selects grants by canonical_id only (SELECT * FROM agent_grants WHERE canonical_id = ? ORDER BY granted_at) and has no expires_at predicate, so expired grants can be returned on the canonical-id path. Update the route/store to use expiry-filtered results for the canonical_id path (e.g., reuse list_active_grants() and filter by canonical_id, or add the same expires_at condition to list_grants).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/agent_registry.py` around lines 227 - 230, The route
currently calls grants_store.list_grants(canonical_id) which returns all grants
for a canonical_id without filtering expired entries; change the code so the
canonical_id path only returns active grants by either (A) calling
grants_store.list_active_grants() and then filtering by canonical_id (preserving
ORDER BY granted_at), or (B) updating AgentGrantsStore.list_grants(canonical_id)
to include the same expiry predicate (expires_at IS NULL OR expires_at > now())
in its query; ensure the chosen approach applies the active-filter and keeps the
granted_at ordering for GET /api/agents/registry/grants.

@jaylfc
jaylfc merged commit 456cc1c into dev Jun 9, 2026
5 of 6 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in TinyAgentOS Roadmap Jun 9, 2026
@jaylfc
jaylfc deleted the feat/consent-loop-backend branch June 11, 2026 23:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

1 participant