Skip to content

feat: enforced action policy (Phase B, scoped to GitHub) - #20

Merged
michaelzwang13 merged 2 commits into
mainfrom
feat/enforced-action-policy
May 23, 2026
Merged

michaelzwang13 merged 2 commits into
mainfrom
feat/enforced-action-policy

Conversation

@michaelzwang13

Copy link
Copy Markdown
Owner

Phase B — Enforced action policy (trust moat)

Until now every gateway call authenticated the user via X-Api-Key and 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)

  • Orchestrator persists the per-agent bearer token on the agents row when the container starts and clears it on stop/error.
  • New agents.agent_token column + unique index (migration 004 + schema.sql snapshot).
  • AgentModel.get_by_token() reverse lookup.
  • get_current_agent dependency resolves Authorization: Bearer <token> to the full agent row.

Enforcement (services.policy.require_action)

  • Denied-by-default check against the role template's allowed_actions.
  • 403 + audit log line on violation (Phase D promotes the stub 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 SKILL.md files send Authorization: Bearer ${AGENT_TOKEN}.
  • USER_API_KEY stays 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.
  • GitHub endpoints — wrong-role token → 403, missing token → 401, invalid token → 401, token persistence matches container env.

Operator note

Migration 004_code_review_engineer.sql must be applied to Supabase before hiring a new agent (this PR's author has already applied it locally).

Closes #7.

🤖 Generated with Claude Code

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>
@michaelzwang13

Copy link
Copy Markdown
Owner Author

@claude review

@michaelzwang13

Copy link
Copy Markdown
Owner Author

@claude review

@michaelzwang13

Copy link
Copy Markdown
Owner Author

@claude review

@michaelzwang13

Copy link
Copy Markdown
Owner Author

@claude review

Comment thread backend/app/services/orchestrator.py Outdated
@@ -111,7 +117,9 @@ def get_agent_status(self, agent_id: str) -> dict | None:
AgentModel.update(agent_id, status=new_status)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase B — Enforced action policy (trust moat)

1 participant