Repository navigation
feat: enforced action policy (Phase B, scoped to GitHub) - #20
Conversation
The trust moat for the Code Review Engineer. Until now every gateway
call authenticated the *user* via X-Api-Key and did whatever was asked.
Now the GitHub agent endpoints authenticate the calling *agent* via its
own bearer token and refuse any action the role template does not list
(denied-by-default), so the Code Review Engineer is provably unable to
merge/close/push.
Identity (agent_auth.get_current_agent):
- Orchestrator persists the per-agent bearer token on the agents row
when the container starts and clears it on stop/error.
- New get_by_token() lookup; new agents.agent_token column +
unique index (migration 004 + schema.sql snapshot).
- get_current_agent dependency resolves Authorization: Bearer <token>
to the full agent row.
Enforcement (services.policy.require_action):
- Denied-by-default check against the template's allowed_actions.
- 403 + audit log line on violation (Phase D promotes to a persisted
agent_action_log row).
Wiring:
- The four GitHub gateway endpoints (pr.list, pr.read, review.submit,
pr.comment) swap get_current_user -> get_current_agent + require_action,
using agent["user_id"] for credential lookup.
- The two GitHub skills send Authorization: Bearer ${AGENT_TOKEN}.
- USER_API_KEY stays in the container for now so Secretary and
Customer-Support email/Slack skills keep working; full removal is a
follow-up.
Tests: 113/113 pass. 12 new — allow, deny-by-default (github.pr.merge),
wrong-role denial (secretary token on GitHub -> 403), missing/invalid
token -> 401, token persistence matching the container env.
Closes #7.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
@claude review |
|
@claude review |
|
@claude review |
|
@claude review |
| @@ -111,7 +117,9 @@ def get_agent_status(self, agent_id: str) -> dict | None: | |||
| AgentModel.update(agent_id, status=new_status) | |||
There was a problem hiding this comment.
Bug: agent_token is not cleared when get_agent_status detects an exited container.
This PR establishes the invariant that a stopped agent must not authenticate (see the comment in stop_agent and the NotFound handler below). Both of those paths pass agent_token=None to AgentModel.update. This branch — which fires when Docker reports the container as "exited" or "stopped" but the container record still exists — only updates status, leaving the agent_token intact in the database row.
Because get_current_agent checks only that the token resolves to a row (no status check), any caller holding the old token continues to authenticate and pass require_action policy checks even after the container has exited.
Fix: mirror the NotFound handler and clear the token here too.
| AgentModel.update(agent_id, status=new_status) | |
| AgentModel.update(agent_id, status=new_status, agent_token=None) |
| @@ -0,0 +1,12 @@ | |||
| -- AgentOS — Code Review Engineer migration. | |||
There was a problem hiding this comment.
CLAUDE.md docs rule: LOCAL_SETUP.md was not updated for this migration.
CLAUDE.md requires: "When behavior or setup changes, update the relevant md (README.md, LOCAL_SETUP.md, ROADMAP.md, this file) in the same change."
This migration is a required manual setup step — the PR description itself says "Migration 004_code_review_engineer.sql must be applied to Supabase before hiring new agents." However, LOCAL_SETUP.md (the authoritative setup guide) was not updated and still only instructs users to apply 001_initial_schema.sql. An operator following LOCAL_SETUP.md on an existing database will hit a missing-column error at runtime when the orchestrator attempts to persist agent_token.
LOCAL_SETUP.md should be updated to include the step to apply 004_code_review_engineer.sql (or note that schema.sql can be re-run as it is now equivalent to migrations 001–004).
The two sibling paths in this file (stop_agent and the NotFound branch in get_agent_status) clear the token, but the status-transition branch in get_agent_status only updated status — leaving a valid token on the agents row of a no-longer-running agent. Because get_current_agent does no status re-check after token lookup, an attacker (or a stale client) holding the old token kept passing the policy gate until the agent was explicitly stopped. Mirrors the NotFound handler. Adds a regression test that mocks Docker reporting "exited" and asserts the update call clears agent_token. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B — Enforced action policy (trust moat)
Until now every gateway call authenticated the user via
X-Api-Keyand did whatever was asked, so an "employee" was just a label. The four GitHub agent endpoints now authenticate the calling agent via its own bearer token and refuse any action the role template does not list (denied-by-default), making the Code Review Engineer provably unable to merge/close/push.Identity (
agent_auth.get_current_agent)agentsrow when the container starts and clears it on stop/error.agents.agent_tokencolumn + unique index (migration004+schema.sqlsnapshot).AgentModel.get_by_token()reverse lookup.get_current_agentdependency resolvesAuthorization: Bearer <token>to the full agent row.Enforcement (
services.policy.require_action)allowed_actions.agent_action_logrow).Wiring
pr.list,pr.read,review.submit,pr.comment) swapget_current_user→get_current_agent+require_action, usingagent["user_id"]for credential lookup.SKILL.mdfiles sendAuthorization: Bearer ${AGENT_TOKEN}.USER_API_KEYstays in the container for now so Secretary/Customer-Support email & Slack skills keep working; full removal is a follow-up (Phase D / a later issue).Tests
113/113 pass. 12 new:
require_action— allow, deny-by-default (github.pr.merge), unknown role denial, cross-role denial.get_current_agent— missing header → 401, malformed scheme → 401, unknown token → 401, valid token resolves.Operator note
Migration
004_code_review_engineer.sqlmust be applied to Supabase before hiring a new agent (this PR's author has already applied it locally).Closes #7.
🤖 Generated with Claude Code