Every security risk flagged on agentdev, by code scanning (CodeQL and its AI findings), independent review agents, or the owner, with its current status. Update this file in the PR that changes a risk's status.
Status: fixed (code removes the risk), mitigated (reduced, residual risk stated), accepted (inherent or by design, reason stated), open (known gap, tracked issue).
IDs are unique and never reused. A new risk takes the next free number on main, checked right before the PR
merges (rebase if another PR took it). scripts/check-risk-ids.sh fails on a duplicate ID and on a reference
to an ID that this file does not define (#126).
The security model these entries refer to is described in concepts.md and, for the planned target, security-model.md.
| ID | Risk | Source | Status | Where / how |
|---|---|---|---|---|
| R1 | Forge token in git remote URL (visible in process list, errors, .git/config) |
CodeQL #59 | fixed | Token never in a URL; credential helper (#59) |
| R2 | Forge token stored on disk (/state/git-credentials) readable through cat |
CodeQL #59 | fixed | Helper reads token from the loop's environment; old file deleted on start (49c5671) |
| R3 | Forge, wake and register tokens in Claude's environment | CodeQL #59 | fixed | Removed from Claude's environment (env -u) (0ff6073) |
| R4 | Claude can read the loop's environment through /proc/<pid>/environ with any allowed runner (same UID, same container) |
CodeQL #59 on 4d6026f | fixed (pending e2e) (e2e-security.md) | Forge sidecar (security-model.md phase 1, #64): FORGE_TOKEN, WAKE_TOKEN and REGISTER_TOKEN are only in the forge container (own UID, own process namespace); the agent container's environment and processes hold none. Sidecar push policy: agent/<name>/* only, no force, protected paths, size limit. Until the e2e pass on a cluster, keep issue and review writers trusted |
| R5 | --dangerously-skip-permissions (no tool policy) |
CodeQL #59 | fixed | --permission-mode dontAsk with allow and deny lists (0ff6073) |
| R6 | Build and test runners can start any program, bypassing the tool lists | CodeQL #59 | accepted | Required for an autonomous developer. Boundary is the pod, not the tool list (concepts.md). Residual risk depends on R10 (R4 fixed by #64) |
| R7 | Git hooks or config-defined programs written by Claude run in the loop's git (with the token) | CodeQL #59, review | fixed (unit-tested; live e2e: e2e-security.md section git) |
lgit (no hooks, fsmonitor, global or system config), scrub_git_config (4d6026f); worktree .git and commondir pinned before every loop git use, admin dir must be a single real directory, no writes through symlinks (cdef2b4, and the follow-up in this PR) |
| R8 | Credential helper answered any host (redirect or rewritten remote leaks the token) | CodeQL #59 | fixed | Answers only the forge's scheme://host; push to the forge URL, not origin (4d6026f) |
| R9 | Claude's stderr written into the worktree and committed | CodeQL #59 | fixed | Written under /state/jobs and deleted (4d6026f) |
| R10 | Open egress: anything Claude can read can be sent anywhere | review, #6 | mitigated (#65), pending GKE e2e (e2e-security.md) | Default-deny agent-egress NetworkPolicy plus the egress-proxy allow-list (security-model.md phase 2); refusals are security events. GitHub (FORGE_EGRESS_HOSTS) only with the forge sidecar's proxy credential; the Claude API only through claude-proxy (R64). Residual: an allowed host (for example a package registry) can still receive data; the NetworkPolicy needs kind v0.24 or later (R30), without it the proxy is advisory. DNS (port 53 only) goes to kube-dns and the NodeLocal DNSCache (node-local-dns pods, 169.254.20.10/32, #163); DNS itself remains a low-bandwidth channel to the cluster resolver. GKE: the metadata check of the e2e list (security-model.md phase 2) must pass before this counts as mitigated there |
| R11 | Claude login (subscription or API key) is in the agent pod and readable by Claude | design review | fixed (pending e2e) (e2e-security.md) | Credential proxy (security-model.md phase 3, #66): only claude-proxy mounts the Claude Secrets; the agent container gets ANTHROPIC_BASE_URL and a per-agent proxy key (agent-<name>-proxy). Interactive sessions (scripts/agent-chat.sh) also: no claude-* Secret in the session pod, the proxy key of one agent of the project (budget and usage count for that agent), agent-egress applies (#118). Residual: R60 (the key), R61 (budget reset on proxy restart), R62 (/status). CLAUDE_PROXY=false / claude_proxy: false restores the old mount (deprecated) |
| R60 | The model can read its agent's proxy key and call the proxy from a tool | design #66 | accepted | The key spends only that agent's budget, through the proxy (paths limited to /v1/messages), and is useless outside the cluster (NetworkPolicy claude-proxy: agent pods and the API only). It cannot name another agent (HMAC with a signing key only in the proxy). Same reach as Claude Code itself in that pod |
| R61 | A new proxy pod starts the 24 h budget at zero | design #66 | accepted | Spend is kept on an emptyDir (survives a container restart, not a new pod). A model cannot restart the proxy; an admin redeploy grants at most one more budget. One replica with Recreate: a restart is a short outage for all agents (Claude Code retries). A PVC was not used: it would pin the pod to one zone on GKE for little gain, since the subscription limits (#93) still apply. The subscription's own limits (#93) still apply |
| R63 | The proxy can reach any public address on 443 | review #119 | mitigated | Only the proxy binary connects out, to a fixed UPSTREAM (https only). On GKE with FQDNNetworkPolicy served, the playbook narrows egress to api.anthropic.com; otherwise, and on kind, the plain NetworkPolicy allows any public 443 |
| R64 | Agents can reach api.anthropic.com directly around the proxy |
review #119 | mitigated (#98), pending GKE e2e (e2e-security.md) | With CLAUDE_PROXY on, api.anthropic.com is not in the agents' egress allow-list (only claude-proxy reaches it, FQDN policy of R63); agents reach claude-proxy directly in-cluster (NO_PROXY, NetworkPolicy). Agent pods and chat session pods (#118, same NetworkPolicy) also hold no Claude credential. With the proxy off (deprecated) the host stays on the list |
| R65 | Parallel requests of one agent overshoot its budget | review #119 | accepted | The budget is checked before each request; requests already running finish. Overshoot is at most the agent's concurrent responses (MAX_CONCURRENT runs) |
| R96 | A person in a chat session can read the proxy key of the session's agent and spend that agent's budget | #118 | accepted | The key is useful only in the cluster (R60) and only through claude-proxy, which keeps the agent's budget. The session pod has no Claude credential. A person who can start a session already has cluster access. The usage of the session shows as usage of the agent (CHAT_AGENT). The session pod carries agentdev/chat-agent, not agent, so it is not in the agent Service and its security events are not attributed to the agent (no pause of the agent) |
| R66 | Public entry point exposes internal services or router endpoints | #120 | mitigated | One GKE Gateway with two routes only: PathPrefix /hook on the webhook host to webhook-router (HMAC X-Hub-Signature-256, 5 MB read limit) and the UI host to agentdev-ui (the API is reached only through the UI's /api/ proxy and checks the OIDC token). Router /status, /register, /idle, the API, ContextForge and Postgres have no route (404 at the load balancer). Port 80 only redirects. tests/ingress_render.yml checks the routes |
| R67 | Unauthenticated floods or large bodies on the public URL | #120 | mitigated (optional control) | Router reads at most 5 MB and checks the signature before any work; UI nginx client_max_body_size 1m. Cloud Armor (cloud_armor = true) denies bodies over public_max_request_bytes by Content-Length and throttles each IP (429). Off by default for cost: without it only the app limits and Google's L7 DDoS protection apply. Cloud Armor inspects at most 8 KB of a body; the Content-Length rule covers the rest |
| R68 | TLS downgrade or plain HTTP to the UI | #120 | mitigated | Google-managed certificate, SSL policy MODERN with TLS 1.2+, HTTP 301 to HTTPS, HSTS max-age=31536000; includeSubDomains on every HTTPS response. Not preloaded (the owner decides about the domain). The includeSubDomains choice assumes the instance owns the subdomain tree; set ingress.hsts_max_age lower to test. On kind no TLS: port-forward only |
| R84 | The company SSO sync removes a legitimate administrator, or a blueprint resets the members of agentdev-admins |
#103, #122 | mitigated | The sync writes only the child group agentdev-admins-sso; it removes a person only when the directory no longer lists them in the one configured group; hand-added members of agentdev-admins are never touched; no blueprint has users:. Checked by ./run.sh 09 (sign-in twice, and a re-apply of every agentdev blueprint after a hand-added member) and infra/ansible/tests/authentik_sso.yml |
| R85 | A person sets their own forge login (and acts as another forge user) through the SSO | #88, #122 | mitigated | forge_login, forge_login_github and forge_login_gitea come only from Workspace custom attributes that only directory administrators can edit; the API uses only the claim of the agent's forge or the forge_login fallback, never another forge's claim; the mapping accepts ^[a-z0-9][a-z0-9._-]{0,38}$ and clears it otherwise (read-only); the returning-user flow overwrites it at each sign-in. Open: a directory administrator can still set any value |
| R86 | Instance values injected into authentik expressions (code execution in authentik) | #122 | mitigated | admin_group, allowed_domains and forge_attribute, forge_attribute_github, forge_attribute_gitea are checked against strict patterns (no quotes, backslashes, new lines) in 09-authentik.sh and the role authentik_sso; tests/authentik_sso.yml checks that a quote is refused |
| R87 | A removed Workspace member keeps admin rights until the next sign-in | #122 | accepted | Sign-in time sync only (no directory polling). Docs: remove from the group and deactivate the authentik user for an immediate removal |
| R88 | The audit event of the sync leaks the IdP token | #122 | mitigated | The mapping writes its own event (custom_agentdev_admin_sync) with user name, change and group only; ak_create_event (which copies the whole context) is not used |
| R89 | An account outside the company signs in through the SSO source (another tenant, a personal Google account, any account of an OIDC provider) | review #134 | mitigated | Policy agentdev-sso-allowed-domains on the authentication and own enrollment flow (policy_engine_mode: all): the e-mail domain (SAML: Name ID; OIDC: email) must be in allowed_domains, and Google's hd, when present, too; no domains = nobody. Pattern-checked in the role and 09. ./run.sh 09 checks that a foreign e-mail and a foreign hd are refused without creating a user. Open: the domain check trusts the IdP for the e-mail address |
| R62 | Proxy /status lists agents and their token spend |
design #66 | accepted | No credentials or keys; reachable only from agent pods and the API (NetworkPolicy). Token spend is not secret within the instance |
| R12 | Prompt injection through issue and review text steers edits | CodeQL #59 | mitigated | Fenced as untrusted, fixed system rule, agent pushes only to its own branch, owner review before merge |
| R13 | Loop pushes without an approval gate | CodeQL #59 | mitigated | Push only to agent/<name>/*; merge to main needs the owner's approval (branch protection) |
| R14 | Issue and repo text sent to the model provider | CodeQL #59 | accepted | Inherent to the product; documented. No automatic redaction (would give false assurance) |
| R15 | Write/MultiEdit not covered by path deny rules |
CodeQL #59 | mitigated | Claude Code applies Edit(...) rules to all writes; real risk was R7 |
| R16 | A forged worktree admin dir makes pinning fail closed, so that branch stops working until fixed by hand (denial of service, no code runs) | review #59 | open | Self-heal by re-creating the worktree; with #64 |
| R17 | WORK/.git itself is not checked for being a symlink |
review #59 | open | Same class as R4 (Claude controls the agent's state volume); closed by #64 |
| R48 | GitHub App private key in each agent's forge sidecar: anyone who reads it can mint tokens for every repository the App is installed on | design #2 | mitigated | Only in the forge container (own UID, own process namespace, R4), mounted as a file (Secret volume, forge container only), never in a process environment; installation tokens narrowed to the agent's repos and the loop's permissions, in memory, ≤ 1 h; the App has no workflows or administration permission; install the App only on agent repositories. Residual: a sidecar compromise exposes the key; a token broker outside the pod (like #66) would remove it |
| R49 | On GitHub all agents act as one App bot: one agent could act on another's issue or PR | design #2 | mitigated | Push and PR head only to agent/<own name>/* (sidecar), own PRs found by author and branch prefix, work only from the label agent:<name>, and only when the person who added it last has write access, checked live before each start (label-not-writer, label-unverified events). Residual: comments of one agent could be posted on another agent's PR (same bot, allowed call) |
| R50 | Model output decides a security thread outcome ("fixed") | design #2, #110 | mitigated | The loop answers only thread ids it read from GitHub, accepts only three outcome words, writes "Fixed in <sha>" only when the pushed diff touches the finding's file, marks accepted risk / false positive as proposals, and never resolves a thread (GraphQL refused). The reviewer verifies |
| ID | Risk | Source | Status | Where / how |
|---|---|---|---|---|
| R20 | Agent spec can choose any container image | CodeQL #59 | fixed | No image field; image set by the operator (#59) |
| R21 | Agent spec can mount any Secret (forge or Claude credentials of another agent) | CodeQL #59 | fixed | CEL rules bind secrets to the agent name; reconciler fails closed with phase invalid |
| R22 | spec.repo points the agent at an attacker-controlled repository |
CodeQL #59 | mitigated | Pattern-checked; Agents are created by cluster admins, or through the API (#57) only by agentdev-admins or a user with admin on that repository, checked live on the forge (fail closed); every attempt is logged as AGENTDEV_AUDIT. An admin of a repository can point an agent only at that repository |
| R23 | Creating an Agent gives a pod forge write access | CodeQL #59 | accepted | That is the product. The API Role may create/patch/delete Agents only (no Secrets): the forge token must already exist as agent-<name> (CEL, R21), so the API cannot mint credentials. Creation needs repo admin or agentdev-admins (#57); forge user = claim forge_login (forgeUserOf, #88) |
| R24 | Write credentials to infrastructure (for example cloud admin for terraform) held by an AI agent | design #69 | open | Phase 5 (#69): the agent keeps read-only credentials; a deterministic executor with scoped write credentials applies only an approved plan of the merge commit |
| R25 | The plan executor runs code of an unreviewed PR (providers, modules) | review #85 | mitigated (#69, #135) | Sandboxed like an agent, read-only cloud credentials, own plan volume, egress allow-list per executor (R81) |
| ID | Risk | Source | Status | Where / how |
|---|---|---|---|---|
| R30 | Postgres and ContextForge admin reachable from agent pods | review #50 | mitigated | NetworkPolicies (f57e518). Enforced on GKE by Dataplane V2 (datapath_provider = ADVANCED_DATAPATH in infra/terraform, check in admin.md, #51) and on kind v0.24 or later (kindnet network policies; 00-prereqs.sh refuses older kind). Checked on kind v0.33 (#65): a pod in default no longer reaches ContextForge |
| R31 | MCP token scope | review #50 | fixed | Per-agent token scoped to the project's virtual server; revoke and renewal (#50) |
| R32 | In-cluster OIDC key fetch over plain http | review #53 | open | TLS inside the cluster, #51 |
| R33 | UI security headers dropped on some routes; no CSP | review #56 | fixed | Shared header snippet, strict CSP, frame-ancestors 'none' (4e1f9be) |
| R34 | A bypass attempt is blocked but nobody sees it, so a prompt injection can keep trying | owner, #64 | mitigated | #70: every refusal becomes a SecurityEvent and a row in the append-only security_events table; high severity or > 5 events per job pause the agent; Security page, per-agent counter, signed webhook to a SIEM. Agents have no RBAC on SecurityEvents. Source from the container (agent-container events untrusted), per-container rate limit with a rate-limited event, per-agent webhook queues, API login without DELETE/TRUNCATE (owner role, SECURITY DEFINER prune). Forge comments and notifications to project writers: #67, #57. #71: per-agent hash chain (prev, chain) for tamper evidence, syslog over TLS and OTLP logs outputs (TLS verified, no plain-text fallback) |
| R36 | A compromised agent forges security events or floods the collector | review of #82 | mitigated | source from the container, agent from the pod label, agent-container events marked untrusted; token bucket per pod container (10/min, burst 20), first overflow is a rate-limited high event that pauses only that agent; webhook queues per agent. Forged events can only pause the agent's own pod (accepted) |
| R37 | The API's database login deletes or rewrites the event history | review of #82 | mitigated | owner role agentdev_owner; agentdev_api has INSERT/SELECT and EXECUTE on the SECURITY DEFINER security_events_prune() only; retention in security_settings, owner-only. Open: a Postgres superuser can still change history; tamper evidence (hash chain, external copy) is #71. REVOKE CREATE ON SCHEMA public FROM PUBLIC (PG < 15) so the definer's search_path = pg_catalog, public cannot be shadowed. Before release: run TestPgRoleSeparation (images/api, skipped without TEST_DATABASE_URL) once against a real Postgres as a superuser, e.g. TEST_DATABASE_URL=postgres://postgres:...@localhost/postgres go test -run TestPgRoleSeparation ./... |
| R35 | Every signed-in user can read every agent's security events (the action text can quote issue content), and the subscription usage (GET /api/usage: Claude Secret names, windows, pauses, per-project limits, #93) |
#70, review #102 | open | Restrict GET /api/security-events and GET /api/usage to projects with write access (#57) |
| R70 | An agent (or prompt injection) approves its own permission request | design #67, review of #107 | mitigated | Decisions are read by the API from the forge, never from the pod; ignored: the requesting agent's own account id (spec.requesterId from GET /user with its token, independent of its login), every other agent account, the API's token account, APPROVAL_DENY_ACCOUNTS, bot accounts; on GitHub (#87) the App bot <app-slug>[bot] (all agents write as it) is always denied by login and every non-User author is refused; the sidecar refuses to post decision commands (approval-self-decision, high); commenter's write access checked live; first valid decision wins (resourceVersion); every decision and refused attempt audited. Agents have no RBAC on Approvals |
| R71 | Approval request text misleads the approver (prompt injection in the tool input) | design #67 | accepted | Text shown as untrusted, plain text, in a code block; user guide WARNING; --disallowedTools is never asked about |
| R72 | Approval decision applied to different content than the person read | design #67 | mitigated | Content hash computed by the sidecar, re-checked by the operator, copied into the decision; the sidecar acts only on its own hash; spec immutable (CEL) |
| R73 | Another agent pod reads a decision from the internal endpoint | #67, review of #107 | fixed | Per-request secret held by the sidecar, its SHA-256 in spec.secretHash; the endpoint answers only with the secret; NetworkPolicy admits agent pods only (not enforced on kind, R30); answer has no request text |
| R74 | /approve-job grants more than the approver read |
review of #107 | fixed | The job grant covers the same tool with the same input only (or the same commit); other input asks again; ends with the job and the pod |
| R75 | The agent files a request (and its comment) on an issue or PR that is not its job | review of #107 | fixed | Sidecar checks the job on the forge: issue assigned to the agent (on GitHub: labelled agent:<name>, #87) or PR from its branch; else approval-foreign-job (high) |
| R76 | Request text leaks a secret into a forge comment, log, CRD or UI | GHAS review of #107 | mitigated | Agent container holds no forge credential (#64); sidecar redacts its token and common credential shapes; pattern filter, not a guarantee (user guide NOTE) |
| R77 | Approval records pile up (one per retry) or stay decidable after they are no longer needed | owner on #107 | fixed | One record per fingerprint, reused on retry; no expiry; void on job end, supersede, closed issue/PR or gone pod; later decisions audited and ignored |
| R78 | A decision command quoted in tool or plan output (for example an executor's comment) decides a request | #69 coordination | mitigated | Commands count only on lines outside code fences, indented code and quotes; executor and service accounts belong in APPROVAL_DENY_ACCOUNTS |
| R38 | Sign-out left the authentik session alive | review #56 | fixed | signoutRedirect (4e1f9be) |
| R39 | GitHub App manifest built by string templating (injection) | review #61 | fixed | Built with jq --arg, strict input validation (db8be52) |
| R45 | Agents keep starting runs until the Claude subscription is exhausted for everyone, also for people (incident #92) | owner, #92 | mitigated | #93: per-subscription usage tracking from the CLI's rate-limit data; new runs pause at 80 % 5-hour / 90 % weekly (admin-set global, lower per project, audited set-limits); a rate-limited run pauses at once; automatic resume at the window reset. Residual: utilization is only known after a run, so the last run can pass the limit |
| R46 | Claude prints a forged AGENTDEV_USAGE line to pause or unpause its subscription |
review #93, #102 | mitigated | Usage is accepted only from the forge sidecar's structured channel (POST /usage, schema and range checked, at most 6 per minute), never from the agent container log (a usage line there is dropped and raises usage-line-forged). A report can only raise or extend a pause: within a window utilization only rises and the reset only moves later; a new window starts only after the current one has really reset. One report pauses for at most 5 h (operator) and the local pause of the loop is capped the same way. Reset times come only from the CLI's rate_limit_event, never from the model's result text. Anomalies (a jump of more than 0.5, a reset beyond the window, a far rate-limit reset) raise usage-anomaly. Values from JSON or files are validated as integers before any bash arithmetic (review #102, command execution through a[$(cmd)]). Residual: R47 |
| R47 | The model runs tools in the same pod and can call the sidecar's POST /usage itself, so a forged usage report can still pause the agents of its own subscription (at most 5 h per report) |
review #102 | fixed with the proxy (pending e2e) | With the Claude credential proxy on (#66, the default), usage comes only from claude-proxy pods, which read the response headers and token counts themselves; the model cannot write that log. The operator then ignores usage lines from agent pods (usage-line-forged), the sidecar refuses POST /usage (USAGE_REPORTS=off, event usage-report-refused) and the loop posts nothing. Still open only with CLAUDE_PROXY=false / claude_proxy: false (deprecated), where the R46 bounds apply |
| R42 | API maps an identity to a forge login: a chosen or case-variant name ("Adam") could act as another forge user | review #86, #88 | mitigated | The API reads only the claim of the agent's forge, forge_login_github or forge_login_gitea, else forge_login (scope forge, #122), emitted from the authentik user attributes of the same names, which users cannot edit in the default flows (configuration only, not enforced); set by the company SSO source property mapping or an administrator (admin.md, "Map people to forge users"). preferred_username is never used. Fail closed: no claim, or a value that is not a lowercase login matching ^[a-z0-9][a-z0-9._-]*$ (max. 40), means no forge user (read-only). Residual: an admin or a wrong source mapping that assigns someone else's login (the email local-part example only with one verified company domain); any flow/prompt with field key attributes.forge_login (user settings, enrollment, source flow) lets users pick a forge user: none may exist, recheck after each authentik upgrade; keep enrollment closed |
| R51 | Prompt injection through MCP servers: tool names, descriptions and outputs of a project's MCP servers reach the model as trusted tools | GHAS on #99 | mitigated | scripts/mcp-sync.sh registers only servers whose host is in the instance allow-list (MCP_ALLOWED_HOSTS in config.env on kind, mcp_allowed_hosts in the GKE instance.yml); an unlisted host, a bad name or a URL with credentials stops the sync before any change. Empty list: no server is registered. A listed server is trusted: its metadata and outputs are not filtered. Limits on what it can reach: R30 (NetworkPolicies), R31 (per-project token scope), ContextForge only calls the cluster Service range; the agent keeps its sandbox and forge limits |
| R52 | mcp-sync output in playbook or CI logs leaks ContextForge tokens | GHAS on #99 | mitigated | The script never prints a token (errors name the step; the Secret apply hides its output); the playbook tasks that generate, read or use secrets, and the sync task, have no_log |
| R53 | An automated process changes the members of agentdev-admins and locks out a legitimate admin, or adds one silently |
review #103 | mitigated | No automated process sets or removes members: the blueprints only create the group (attrs: {name}), and authentik re-applies them without touching members. Only on kind, ./run.sh 09 adds akadmin once with POST /api/v3/core/groups/{pk}/add_user/ (additive, idempotent), and checks that a member added by hand survives a blueprint re-apply. Members are managed by people in authentik only (docs/admin.md). GKE (#114): the role authentik makes the same single add-only call for akadmin and never removes a member; tests/authentik_render.yml checks that no blueprint has users: |
| R43 | API forge token user must be repo or site admin to read permissions | review #86 | accepted | Read-only scopes (read:repository, read:user); dedicated service account, admin on agent repositories only |
| R44 | The audit of agent changes is lost with the pod log, or the API rewrites it to hide a change | #91 | mitigated | Every AGENTDEV_AUDIT line is also in the append-only api_audit table: agentdev_api has INSERT/SELECT and EXECUTE on the SECURITY DEFINER api_audit_prune() only, a trigger refuses UPDATE/DELETE inside the security_settings retention (TestPgAudit). GET /api/audit is agentdev-admins only. Open: a failed insert is only logged (the stdout line stays); audit rows are not forwarded to the SIEM outputs yet; superuser tampering as R37 (#71) |
| R80 | An AI agent holds write credentials to infrastructure, or its output decides what is applied | #69 | mitigated | Deterministic executors: write credentials only in the apply Job of an Executor (own namespace agentdev-executors, no agent RBAC; EXEC_NS must differ from AGENTS_NS). Image (digest), commands, branch and path from the Executor only; the commit is checked ^[0-9a-f]{40}$ and passed as env; merge and apply runs fetch the protected branch only. Plan hash computed by the operator image after the tool exited, verified before the apply (TestExecutorMergeApproveApply) |
| R81 | Plan of an unreviewed PR runs attacker code (providers, modules) with the plan credentials | #69, #135 | mitigated | PR plans are sandboxed (gVisor, no API token, backoff 0), use the read-only credentials and an emptyDir, and can never be approved (only the merge plan is). Only same-repository PR heads by default; forks need spec.allowForkPRs (TestExecutorForkPRs). Egress (#135): default-deny NetworkPolicy executor-egress (DNS, the executor proxy, the in-cluster forge, the GKE metadata server only); the executor egress proxy allows each executor only EXECUTOR_EGRESS_HOSTS, its forge's git host and its spec.egressHosts, bound to a per-executor proxy credential the operator generates (TestExecutorEgressSecret, TestExecutorEgressManifest); on GKE an FQDNNetworkPolicy also limits the proxy; every refusal is a SecurityEvent of the executor (TestExecutorEgressRefusalEvent). Residual: the code can still send data to the allowed hosts (for example a bucket on an allowed storage API) and read what the read-only role can read; keep both lists short (R97) |
| R82 | Approval reused, replayed or forged to run an apply | #69 | mitigated | Approval (#67) bound to executor, environment, merge commit, plan hash, image digest, apply argv and the Executor spec hash (content hash re-checked; a changed Executor refuses the merge run and the apply, TestExecutorApprovalBindsSpec, TestExecutorApplyRefusedWhenSpecChangesAfterDecision); allowed only with status.contentHash equal to the spec, decided before spec.expires, apply started within EXECUTOR_APPLY_START_MINUTES of the decision (TestExecutorApplyStartWindow); void closes the run; one executor per environment (TestExecutorOnePerEnvironment, Ansible assert); single use by ExecutorRun name, and the merge run is closed after one apply; mismatches are executor.refused high events (TestExecutorApprovalRefusals, TestExecutorTamperedApprovalSpec). Operator has no approvals/status |
| R90 | An agent or a stolen agent credential decides an approval through the web UI | #67 (UI decisions) | mitigated | POST /api/approvals/{name}/decision accepts only an OIDC bearer token of the UI client (auth); the approval secret and forge tokens are refused (401). A session whose forge login or user name is an agent, the requester or an APPROVAL_DENY_ACCOUNTS account is refused (403) before the role check. Role: admin group or write access on the forge. Every attempt is audited in Postgres (api_audit) with the user |
| R91 | A UI decision applies to content the person did not see, or races a forge decision | #67 (UI decisions) | mitigated | The request carries the contentHash shown in the UI; a different hash is refused (409). The status is written with a compare-and-set on resourceVersion: the first decision wins, the same decision again is idempotent (200, no change), a different one is refused (409) |
| R83 | Tool output in the executor's forge comments decides an approval (for example /approve <id> printed by a PR) |
#69 | mitigated | Output is shown in a fence with every line prefixed and backticks replaced; the executor account is the requester of its own approvals (refused by the decider) and must be in APPROVAL_DENY_ACCOUNTS (admin.md) |
| R92 | GKE playbook applies a manifest with a value it does not fill (kind/GKE drift): a literal ${VAR} reaches the cluster, for example an empty approval deny list or a broken image name |
#121 | mitigated | templates/render.j2 stops the run before the apply and names the missing placeholders (never values); tests/render_values.yml in CI; the playbook waits for every Deployment to be ready (admin.md, run.sh steps and the GKE playbook) |
| R93 | The API pod holds the GitHub App private key, which can mint write tokens for every installed repository | #87 | mitigated | The API mints only installation tokens with metadata, issues and pull_requests read (TestGitHubPermissionMapping checks the request); the key is a file from the optional Secret agentdev-api-github (mode 0440, fsGroup), never an environment variable; the API's Role cannot read Secrets; the platform namespace is deny-by-default for ingress and only the UI and agent sidecars reach the API. Residual: code execution in the API pod could mint a write token with the key. Alternative without the key: a read-only fine-grained GITHUB_API_TOKEN (admin.md, "GitHub repositories") |
| R94 | The authentik login on GKE is public: password guessing on akadmin, or a spoofed client address in X-Forwarded-For |
#114 | mitigated | Only https://<auth_host>/ routes to authentik-server (http 9000), with HSTS, the Terraform SSL policy and Cloud Armor when set; a NetworkPolicy lets only the Google Front End ranges reach the pods. authentik trusts forwarded headers only from the GFE and private ranges (AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS), so its reputation policy sees the real client. akadmin has a 96-character random first password from Secret Manager and must get MFA at the first login (admin.md). Residual: brute force is limited by authentik's reputation policy and Cloud Armor only |
| R95 | Lockout: no person can log in as an authentik administrator after a failed Workspace source or a blueprint re-apply | #114 | mitigated | akadmin (password login, break-glass) is made by authentik's bootstrap blueprint with state: created and is never changed by agentdev; the playbook only adds it to agentdev-admins (add only) and fails when authentik has no active superuser; the first password stays in Secret Manager platform-authentik-admin. Procedure: admin.md "Keep a break-glass admin" |
| R97 | An executor's egress allow-list opens an exfiltration channel: a broad entry (.googleapis.com, a storage API) lets PR code write to an attacker-owned resource on an allowed host, or the proxy credential of one executor is used for another |
#135 | mitigated | Lists are per executor and written only by cluster admins (Executor, Ansible egress_hosts); pattern-checked DNS names only (CRD, operator egressHostRE, Ansible assert; IP addresses and Squid syntax refused, TestExecutorEgressHosts); the proxy credential of an executor opens only that executor's hosts, and invalid or duplicate executors get no credential (TestExecutorEgressSecret); the proxy refuses private and link-local addresses. Residual: an allowed multi-tenant API (for example storage.googleapis.com) can carry data to another project; prefer narrow names and VPC Service Controls (admin.md, "Add a host for an executor") |
| R98 | The router's wake poll credential (#181) leaks or is misused: the router pod is reachable from the public URL | #181 | mitigated | Read only: on GitHub the router mints installation tokens narrowed to one repository with issues, pull_requests and metadata read; on Gitea a token with read:repository,read:issue of the account agentdev-router (read collaborator). Secret webhook-router-poll, mounted only in the router pod (file, mode 0440, not an environment variable); the router reads only counts of changed items and starts agents through the same path as a webhook (blocked agents stay stopped). Residual: on GitHub the router holds the App private key, so a compromise of the router pod could mint write tokens; same key as the agents' forge sidecars (R21 scope); turn the poll off with wake_poll_seconds: 0 when webhooks are set up |
| ID | Risk | Source | Status | Where / how |
|---|---|---|---|---|
| R40 | Assistant committed under the owner's name without permission | owner | fixed | CLAUDE.md rule (#60): per-ticket explicit permission only |
| R41 | Workflow changes need a token the bot does not have | process | mitigated | Diff posted on the ticket, pushed under the owner's name only after a per-ticket yes |