diff --git a/CLAUDE.md b/CLAUDE.md index fb86040..cbe5d8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,7 @@ enum values used by the `repo_has_sdk_in_code` predicate: | `vercel_ai` | Vercel AI SDK | | `autogen` | AutoGen / AG2 | | `raw_llm_sdk` | Bare `anthropic` / `openai` client usage (no agent framework); category `raw_llm_sdk` loads unconditionally, the rule's own predicate gates | +| `github_actions` | Coding agents in GitHub Actions workflows (repo-root `.github/workflows/*.yml`); category `ci` loads unconditionally, the rule's own `repo_ci_agent_*` predicate gates. One finding per step that runs an offending agent (CI-003: per checkout step), carrying that step's file and line | | `agent_config` | Pre-trust harness config committed to the repo (`.claude/settings.json`, `.mcp.json`, `.gemini/.env`, `.vscode/settings.json`, `.codex/config.toml`); category `agent_config` loads unconditionally, the rule's own `repo_*` predicate gates. Findings carry the offending file and line | Repo-scope rules typically combine `applies_to` with a `repo_has_sdk_in_code` diff --git a/README.md b/README.md index 1a668dd..0297d2d 100644 --- a/README.md +++ b/README.md @@ -158,10 +158,14 @@ agent_config/ Pre-trust harness config rules (AGCFG-NNN, ├── harness_guidance.yaml AGCFG-010 ├── mcp_client_config.yaml MCPCFG-002 └── preauth_execution.yaml AGCFG-004..007 + +ci/ Coding agents in GitHub Actions workflows (CI-NNN; repo scope, no SDK gate) +├── untrusted_triggers.yaml CI-001, CI-002 +└── agent_hardening.yaml CI-003..005 ``` -The `agent_config` families are cross-harness config rules, so they sit outside the -SDK-prefix numbering below (AGCFG-001..003 and MCPCFG-001 are unused). +The `agent_config` and `ci` families are cross-harness config rules, so they sit outside the +SDK-prefix numbering below (AGCFG-001..003 and MCPCFG-001 are unused; CI rules number by topic). ID prefix denotes SDK; `NNN` tool scope, `1NN` agent / subagent scope, `2NN` repo scope. Two packs predate that convention: `autogen/` numbers its agent diff --git a/ci/agent_hardening.yaml b/ci/agent_hardening.yaml new file mode 100644 index 0000000..2161679 --- /dev/null +++ b/ci/agent_hardening.yaml @@ -0,0 +1,93 @@ +policy: + id: ci_agent_hardening + name: Hardening of coding-agent jobs on untrusted GitHub Actions triggers + category: ci + description: > + Repo-scoped rules for coding-agent jobs in GitHub Actions workflows that + run on an event any GitHub user can cause, with or without an actor gate: + the content of the event is untrusted input either way. Covers persisted + checkout credentials, unrestricted shell and missing egress control. The + pack loads on each scan and each rule's predicate gates it. One finding + is reported for each step that runs an offending agent; for CI-003, for + each actions/checkout step in an offending agent job. + +rules: + - id: CI-003 + title: Checkout persists credentials in an agent job + severity: high + confidence: 0.7 + applies_to: + - github_actions + scope: repo + match: + repo_ci_agent_checkout_persists_credentials: true + explanation: > + A job that runs a coding agent on an untrusted trigger checks out the + repository with actions/checkout and does not set persist-credentials: + false. The actions/checkout documentation describes persist-credentials + as whether to configure the token or SSH key with the local git config, + with a default of true. Commands run by an agent steered by the event's + content run on a runner where that configuration was applied. + Limits of this check: it reads persist-credentials on actions/checkout + steps in the same job as the agent step. A false value (boolean or the + string) is the sole value treated as safe, so an expression counts as + persisting. Credentials set up another way, such as git config in a run + step or a token passed to an action, are not seen. + fix: > + Set persist-credentials: false on each actions/checkout step in the agent + job. If the agent must push, give it a narrowly scoped token through the + action's own input instead of the checkout's stored credential. + + - id: CI-004 + title: Agent step grants unrestricted shell + severity: high + confidence: 0.8 + applies_to: + - github_actions + scope: repo + match: + repo_ci_agent_unrestricted_shell: true + explanation: > + A coding agent on an untrusted trigger may run any shell command. Its + tool allow-list grants Bash with no pattern, Bash(*), or a pattern that + starts with a shell, interpreter or download tool (bash, sh, zsh, cmd, + powershell, pwsh, python, python3, node, curl, wget, nc, ncat); or the + step skips approvals or the sandbox (--dangerously-skip-permissions, + --permission-mode bypassPermissions, Gemini --yolo or a bare + run_shell_command, Codex --dangerously-bypass-approvals-and-sandbox, + sandbox: danger-full-access or safety-strategy: unsafe). Text from the + triggering event can then make the agent run arbitrary commands on the + runner with the job's token and secrets. + fix: > + Allow the exact commands the task needs, for example Bash(npm test) or + run_shell_command(git diff), and remove any flag that skips approvals or + the sandbox. Keep Codex on its default safety strategy with a workspace-write + sandbox. + + - id: CI-005 + title: Agent job with shell or web access has no egress restriction + severity: medium + confidence: 0.5 + applies_to: + - github_actions + scope: repo + match: + repo_ci_agent_no_egress_restriction: true + explanation: > + A coding agent on an untrusted trigger can run shell commands or fetch + URLs (a Bash, WebFetch or WebSearch grant, a Gemini shell or web tool, or + a Codex full-access sandbox), and no step-security/harden-runner step with + egress-policy: block runs before it in the job. If the event's content + gets the agent to read a token or secret, nothing in the workflow stops + it sending the value to an outside host. This is an inference from the + workflow file alone. + Limits of this check: network controls outside the workflow file, such as + a self-hosted runner's network policy or organization settings, are not + visible. The harden-runner step is recognised by its action name and + egress-policy: block, and must come before the agent step in the same + job. An agent step with no tool configuration is counted as having + neither shell nor fetch access. + fix: > + Add step-security/harden-runner as the first step of the agent job with + egress-policy: block and allowed-endpoints limited to the model API and + GitHub, or run the agent on a runner whose network is restricted. diff --git a/ci/untrusted_triggers.yaml b/ci/untrusted_triggers.yaml new file mode 100644 index 0000000..782c29c --- /dev/null +++ b/ci/untrusted_triggers.yaml @@ -0,0 +1,78 @@ +policy: + id: ci_untrusted_triggers + name: Coding agents on untrusted GitHub Actions triggers + category: ci + description: > + Repo-scoped rules for GitHub Actions workflows in the repo-root + .github/workflows directory that run a coding agent on an event any GitHub + user can cause: issues, issue comments, pull request review comments, + discussions, discussion comments and pull_request_target. The pack loads + on each scan and each rule's predicate gates it. One finding is reported + for each step that runs an offending agent. + +rules: + - id: CI-001 + title: Agent step runs on an untrusted trigger without an actor gate + severity: critical + confidence: 0.7 + applies_to: + - github_actions + scope: repo + match: + repo_ci_agent_untrusted_trigger_ungated: true + explanation: > + A workflow runs a coding agent (claude-code-action, claude-code-base-action, + run-gemini-cli, codex-action, or the claude, gemini, codex, cline or aider + CLI) on an event that any GitHub user can cause, such as opening an issue + or posting a comment, and nothing limits who that user is. No if: on the + job, the step or a job it needs restricts author_association to OWNER, + MEMBER or COLLABORATOR or names the allowed actors, and the action's own + write-access check is absent or switched off. claude-code-action and + codex-action check for write access themselves; that check is treated as + switched off when allowed_non_write_users or allowed_bots is set to '*' + or when allow-users is set to '*'. The event's title, body and comments + reach the agent as input, so any user who can cause the event can give the + agent instructions. + Limits of this check: an if: expression is matched as text and is not + evaluated, so a condition that joins an allow-list with another branch + (A || allow-list) counts as gated. An actor check made in an earlier step, + an environment with required reviewers, and a label gate are not + recognised. A reusable workflow is followed one hop when it is a local + file; remote reusable workflows and composite actions are not followed. + fix: > + Add a job-level if: that admits trusted actors, for example + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), + github.event.comment.author_association). For claude-code-action and + codex-action, keep the built-in write-access check by removing '*' from + allowed_non_write_users, allowed_bots and allow-users. + + - id: CI-002 + title: Ungated agent job holds a write token or extra secrets + severity: critical + confidence: 0.75 + applies_to: + - github_actions + scope: repo + match: + repo_ci_agent_untrusted_trigger_privileged: true + explanation: > + An agent job that any GitHub user can trigger, with no actor gate (the + CI-001 condition), also holds authority beyond the model key: its + GITHUB_TOKEN has write-all or write access to contents, pull-requests, + actions or packages; it references a secret outside the agent's + model-key input; or it passes secrets: inherit to a reusable workflow. + Instructions from the triggering event can then act with that token or + read that secret. + Limits of this check: permissions are read from the job, then the + workflow, then the calling job; when none of them declares a permissions + block nothing is flagged, because the repository default is not visible. + A secret counts as the model key by where it is referenced (the agent + step's model-key input, or a model-key environment variable such as + ANTHROPIC_API_KEY), not by its name. Secrets passed to a remote reusable + workflow are not seen. The write scopes counted are contents, + pull-requests, actions and packages; other scopes such as id-token are + not counted. + fix: > + Give the agent job a read token (permissions: contents: read) and pass it the + model key alone. Move any step that needs write access or another secret + into a separate job that runs after a trusted-actor check. diff --git a/manifest.yaml b/manifest.yaml index 232ca8d..c40ae3e 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -11,4 +11,4 @@ # # This file is metadata, not a rule: the engine's loader skips manifest.yaml # when walking the pack for policy files. -schema_version: 27 +schema_version: 28