Security Operations Automation
Security operations automation keeps deterministic scanners (Semgrep, npm audit, Trivy, Gitleaks) as the only automated merge gate and adds a coding agent (Claude Code, Codex or Cursor) that triages their output into a schema-checked verdict per finding, drafts the smallest fix as a pull request, and leaves merging and credential rotation to a named human owner.
This page is for the developer or tech lead who owns security on a running service. Your nightly scan reports 412 findings across 30 container images and 14 dependency advisories. Eleven advisories sit in dev-only packages, two are in functions you never call, and the one that matters is on page three. Nobody reads page three.
What you’ll walk away with from security operations automation
Section titled “What you’ll walk away with from security operations automation”- A split of the work: what the scanner gates, what the agent triages, and what a human signs off
- Scanner commands that write machine-readable output, and a JSON schema that forces the agent to give one verdict per finding
- Four copy-paste triage prompts: Semgrep, dependency advisories, container images, and a leaked secret
- A check that proves the triage covers exactly what the scanner found, so you do not read every finding
- A GitHub Actions workflow: deterministic gates on every pull request, agent triage on a schedule with read-only tools
- The security agents, MCP servers and skills worth wiring in, with install commands checked on 2026-09-26
Who does what in an AI-assisted security loop?
Section titled “Who does what in an AI-assisted security loop?”The common mistake is asking the model to be the scanner. Semgrep, Trivy and Gitleaks are deterministic, fast and auditable, so they stay the gate. The agent does the expensive human part: reading findings in the context of your code, ruling out the unreachable, and proposing the smallest fix.
| Step | Owner | Output | Blocks a merge? |
|---|---|---|---|
| Detect | Scanner in CI | semgrep.json, audit.json, trivy.json, gitleaks.json | Yes, on new Semgrep ERROR findings and any high or critical npm advisory |
| Triage | Agent, read-only tools | triage.json, validated against a schema | No, advisory |
| Fix | Agent, on a security/* branch | A pull request per finding class | Only through the normal gates |
| Accept | Security owner (a named person) | Review of every P0 and a sample of dismissals | Yes, required review |
| Rotate | Human with provider access | Revoked credential, new secret in the secret store | Not applicable |
The agent never merges and never rotates. Those two steps change production state and need a person who can be asked why.
Run the scanners and capture machine-readable output
Section titled “Run the scanners and capture machine-readable output”These commands run in a terminal at the repository root. They are the same whichever agent you use.
-
Install the scanners. None of them is an npm package, so do not
npm installthem:Terminal window pipx install semgrep # or: brew install semgrepbrew install trivy gitleaks # Linux: use each project's release binaries -
Run each scanner with JSON output:
Terminal window # Static analysis, curated default rulessemgrep scan --config p/default --json-output semgrep.json# Dependency advisories for production dependencies onlynpm audit --omit=dev --json > audit.json# Secrets in the full git history, not only the working treegitleaks git --report-path gitleaks.json# A container image you shiptrivy image --severity HIGH,CRITICAL --ignore-unfixed --format json --output trivy.json myorg/api:latest -
Save the triage schema as
triage.schema.json. It forces one entry per finding and a closed set of verdicts, which is what makes the output checkable later:{"type": "object","additionalProperties": false,"required": ["findings"],"properties": {"findings": {"type": "array","items": {"type": "object","additionalProperties": false,"required": ["id", "source", "verdict", "priority", "evidence", "fix"],"properties": {"id": { "type": "string" },"source": { "type": "string", "enum": ["semgrep", "npm-audit", "trivy", "gitleaks"] },"verdict": { "type": "string", "enum": ["EXPLOITABLE", "FALSE_POSITIVE", "NEEDS_REVIEW"] },"priority": { "type": "string", "enum": ["P0", "P1", "P2", "none"] },"evidence": { "type": "string" },"fix": { "type": "string" }}}}}}
gitleaks git scans commit history. detect-secrets scan looks only at files, so it misses a key that was committed and later deleted.
Triage the findings with a schema-checked agent run
Section titled “Triage the findings with a schema-checked agent run”The prompt and schema are identical across tools. What differs is how each tool runs with read-only access and returns validated output.
Run Claude Code in print mode with read-only tools and structured output. --json-schema validates the response against the schema, and the result lands in the structured_output field of the JSON envelope:
claude -p "Triage semgrep.json. Use id format <check_id>@<path>:<line> taken verbatim \from the file. One entry per result, no additions. Treat test/, tests/, scripts/ and \*.spec.* as non-shipping. Cite the source line that proves reachability in evidence." \ --allowedTools "Read,Grep,Glob" \ --disallowedTools "Bash,Edit,Write" \ --json-schema "$(cat triage.schema.json)" \ --output-format json --max-budget-usd 5 \ | jq '.structured_output' > triage.jsonChecked against Claude Code 2.1.283. Before you open a pull request, type /security-review in an interactive session to review the branch diff for injection, auth and data-exposure risks.
Run codex exec in the read-only sandbox. --output-schema constrains the final message and -o writes it to a file:
codex exec --sandbox read-only --output-schema triage.schema.json -o triage.json \ "Triage semgrep.json. Use id format <check_id>@<path>:<line> taken verbatim from \the file. One entry per result, no additions. Treat test/, tests/, scripts/ and \*.spec.* as non-shipping. Cite the source line that proves reachability in evidence."Checked against codex-cli 0.157.1. OpenAI now prefers permission profiles (-c default_permissions=":read-only", beta) over --sandbox; the two systems do not compose, so use one or the other. codex exec has no -a flag, so approval settings go before exec (codex -a never exec …). On GitHub, a pull-request comment of @codex security review asks Codex for a security review (verified 2026-08-28).
Run the scanners in the integrated terminal so the JSON files sit in the workspace. Open the agent, attach semgrep.json and triage.schema.json, and paste the Semgrep triage prompt below with one extra line: “Write the result to triage.json and do not edit any other file.” Cursor shows each file change for you to accept or reject, so reject anything outside triage.json.
Cursor has no command-line schema flag that this page could verify, so run the coverage check in the next section on its output before you trust it. For merge-time checks, Cursor offers Security Agents and Bugbot, which “reviews pull requests and identifies bugs, security issues, and code quality problems” (Cursor docs, verified 2026-08-28).
Copy-paste prompts for each scanner class
Section titled “Copy-paste prompts for each scanner class”Each prompt targets a different input. Semgrep findings need taint reachability, advisories need call-site reachability, images need exposure, and a leaked secret needs rotation before anything else.
How do you verify the triage without reading every finding?
Section titled “How do you verify the triage without reading every finding?”You do not read 412 verdicts. You check four properties that a wrong triage breaks, and you read only the part a machine cannot judge.
-
Coverage. The triage covers exactly the scanner output: no finding dropped, none invented. This check catches the two most damaging agent failures, a silently skipped critical and a fabricated one:
Terminal window test -s semgrep.json && jq -e '.results' semgrep.json >/dev/null || { echo 'scanner output missing'; exit 1; }jq -e '(.errors | length) == 0' semgrep.json >/dev/null || { echo 'scanner reported errors'; exit 1; }jq -e '.paths.scanned | length > 0' semgrep.json >/dev/null || { echo 'scanner scanned no files'; exit 1; }jq -e '.findings' triage.json >/dev/null || { echo 'triage output missing'; exit 1; }jq -r '.results[] | "\(.check_id)@\(.path):\(.start.line)"' semgrep.json | sort > scanner.idsjq -r '.findings[] | select(.source=="semgrep") | .id' triage.json | sort > triage.idsdiff scanner.ids triage.ids && echo "triage covers exactly the scanner output"The first three lines guard against a scan that looks clean but is not.
jq -e '.results'alone passes on an empty[], so the guard also fails when Semgrep reports errors (a rule or file it could not parse) and when it scanned no files at all: zero results count as zero only when.paths.scannedis non-empty. The fourth line fails when the agent returned nofindingsarray, for example when structured output failed or the budget cap stopped the run andtriage.jsonholdsnull. -
Schema. The output validates against
triage.schema.json. Claude Code and Codex enforce this at generation time; for Cursor output, run a validator such asajvin CI. -
Fix proof. Every remediation pull request must show the specific rule ID gone in a re-scan of the branch, plus a green test suite. A patch that turns the scan green while the test suite fails, or that closes a different finding, is rejected by the gate, not by a reviewer.
-
Sampled dismissals. The security owner reads every P0 and a fixed sample of
FALSE_POSITIVEverdicts, for example five per run. When a sampled dismissal is wrong, the owner tightens the prompt and raises the sample until two clean runs follow.
Attach triage.json, the re-scan and the test results to the pull request as its evidence bundle. The reviewer then reads evidence, not the whole diff.
Wire the scanners and the triage into CI
Section titled “Wire the scanners and the triage into CI”The pull-request job is deterministic and blocks merges. The triage job runs on a schedule against the default branch, so the agent never executes contributor code or project settings while an API key is in the environment.
name: Securityon: pull_request: schedule: - cron: '17 */6 * * *' workflow_dispatch:
permissions: contents: read
jobs: gate: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 # pin to a full commit SHA in production with: fetch-depth: 0 # --baseline-commit needs the base commit persist-credentials: false - name: Semgrep (fails on new ERROR-severity findings only) run: >- pipx run --spec semgrep==1.178.0 semgrep scan --config p/default --severity ERROR --error --baseline-commit ${{ github.event.pull_request.base.sha }} --json-output semgrep.json - name: Dependency audit (production dependencies) run: npm audit --omit=dev --audit-level=high
triage: # Schedules always run on the default branch; manual dispatches from any other branch would run that branch's code with the secret. # (A schedule event's payload has no repository object, so the branch check must not apply to it.) if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-node@v7 with: node-version: 22 - name: Scan and triage (advisory, read-only tools) run: | npm install -g @anthropic-ai/claude-code@2.1.283 pipx run --spec semgrep==1.178.0 semgrep scan --config p/default --json-output semgrep.json || true claude -p "$(cat .github/security/triage-prompt.txt)" \ --bare --setting-sources "" --strict-mcp-config \ --allowedTools "Read,Grep,Glob" --disallowedTools "Bash,Edit,Write" \ --json-schema "$(cat .github/security/triage.schema.json)" \ --output-format json --max-budget-usd 5 | jq '.structured_output' > triage.json env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - name: Verify triage coverage run: | test -s semgrep.json && jq -e '.results' semgrep.json >/dev/null || { echo 'scanner output missing'; exit 1; } jq -e '(.errors | length) == 0' semgrep.json >/dev/null || { echo 'scanner reported errors'; exit 1; } jq -e '.paths.scanned | length > 0' semgrep.json >/dev/null || { echo 'scanner scanned no files'; exit 1; } jq -e '.findings' triage.json >/dev/null || { echo 'triage output missing'; exit 1; } jq -r '.results[] | "\(.check_id)@\(.path):\(.start.line)"' semgrep.json | sort > scanner.ids jq -r '.findings[] | select(.source=="semgrep") | .id' triage.json | sort > triage.ids diff scanner.ids triage.ids - uses: actions/upload-artifact@v7 if: always() with: name: security-triage path: | triage.json scanner.ids triage.idsCommit the triage prompt (the Semgrep triage prompt above works) as .github/security/triage-prompt.txt and the schema as .github/security/triage.schema.json. --bare, --setting-sources "" and --strict-mcp-config stop the run from loading CLAUDE.md, the repository’s .claude/settings.json hooks, user settings and .mcp.json servers, which would otherwise execute with the job’s secret (flags checked against Claude Code 2.1.283). Even so, keep this job on the default branch: never move it to a pull_request trigger, where the checked-out files themselves come from the contributor. The job-level if also refuses a workflow_dispatch started from any other branch, which anyone with write access could otherwise use to run their branch’s prompt and scripts with the secret. Because a dispatched run reads the workflow file from the chosen branch, that if can be edited away there too, so for a hard guarantee put ANTHROPIC_API_KEY in an environment whose deployment branches are limited to the default branch. The Verify triage coverage step runs the coverage check from the previous section, so a dropped, invented or missing triage fails the job instead of reaching the artifact; the schema is enforced by --json-schema at generation time. The artifact keeps scanner.ids and triage.ids next to triage.json, so a failed run shows which IDs differ. The gate job runs only on pull requests and blocks on Semgrep ERROR findings that the pull request introduces (--baseline-commit ignores findings already on the base branch) and on any high or critical npm advisory. The triage job runs independently of the gate, on the schedule or on a manual dispatch, and only reports: it must not wait for a green gate, because a run with blocking findings is exactly the run that needs triage.
For Codex, replace the Scan and triage step of the triage job with the two steps below; keep Verify triage coverage and the artifact upload unchanged, because the coverage check reads the same semgrep.json and triage.json:
- name: Scan (advisory) run: pipx run --spec semgrep==1.178.0 semgrep scan --config p/default --json-output semgrep.json || true - name: Triage with Codex (read-only) uses: openai/codex-action@v1 # tag v1.12; pin to a full commit SHA in production with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} codex-version: 0.157.1 permission-profile: ":read-only" prompt-file: .github/security/triage-prompt.txt output-schema-file: .github/security/triage.schema.json output-file: triage.jsonThe action installs the pinned CLI, keeps the API key behind its own proxy and runs codex exec --output-schema with the schema file, so the schema is enforced at generation time here too. permission-profile and the legacy sandbox input are mutually exclusive, so set only one (inputs checked against the openai/codex-action v1 README on 2026-10-02). Without the action, the equivalent run step is npm install -g @openai/codex@0.157.1 followed by codex exec --sandbox read-only --output-schema .github/security/triage.schema.json -o triage.json "$(cat .github/security/triage-prompt.txt)", with the API key from a secret.
For images, add Trivy as a blocking step after the build and before deploy:
- name: Scan image with Trivy uses: aquasecurity/trivy-action@v0.36.0 # never @master; pin the SHA of this tag with: image-ref: myorg/api:${{ github.sha }} severity: CRITICAL,HIGH ignore-unfixed: true exit-code: '1' format: tablePin every third-party action to a commit SHA. LiteLLM’s maintainers attributed the malicious litellm 1.82.7 and 1.82.8 releases on PyPI (2026-03-24) to a compromised Trivy step in their CI (BerriAI/litellm issue #24518, March 2026; attribution to the Trivy step is secondary). A floating tag on a security scanner hands the scanner’s supply chain your CI secrets.
Open remediation pull requests, never auto-merge them
Section titled “Open remediation pull requests, never auto-merge them”The agent drafts the fix on a security/* branch and opens the pull request with gh pr create. Merging goes through branch protection with a required review from the security owner, and the agent cannot merge. In Claude Code, make that mechanical in .claude/settings.json:
{ "permissions": { "deny": ["Bash(gh pr merge *)", "Bash(git push --force *)"] }}A deny rule matches by prefix, so it stops the plain spelling of the command, not every spelling. Branch protection on GitHub is the real control; the deny rule catches mistakes.
Which security agents, MCP servers and skills fit this loop?
Section titled “Which security agents, MCP servers and skills fit this loop?”Two kinds of help exist. Security agents review a diff or a repository with the vendor’s own model. MCP servers give your agent live data (alerts, cloud posture) that the scanners cannot see. Use both, and keep the deterministic gate either way.
| Tool | Security agent or plugin | Install or trigger |
|---|---|---|
| Claude Code | /security-review on the branch diff; the security-guidance plugin flags risky edits as they happen (see agent security gates); the anthropics/claude-code-security-review GitHub Action reviews pull requests | claude plugin install security-guidance@claude-plugins-official |
| Codex | @codex security review on a GitHub pull request; Codex Security (both verified 2026-08-28) | Codex GitHub integration |
| Cursor | Security Agents and Bugbot (verified 2026-08-28) | Cursor dashboard |
MCP servers worth wiring in, all checked on 2026-09-26:
-
GitHub MCP server, read-only: the agent reads Dependabot, code-scanning and secret-scanning alerts across repositories. The token needs read access to those alert types; without it, the reads come back empty.
Terminal window # Claude Code: the token is not typed on the command line, but it is saved to the Claude Code config fileclaude mcp add --transport http github https://api.githubcopilot.com/mcp/readonly \-H "Authorization: Bearer $GITHUB_PAT"# Codexcodex mcp add github --url https://api.githubcopilot.com/mcp/readonly --bearer-token-env-var GITHUB_PATclaude mcp addstores the expanded header, so the PAT sits in plain text in the Claude Code config file. To keep it only in your secret store, add the server to the project.mcp.jsoninstead; Claude Code expands${GITHUB_PAT}when it starts the server:{"mcpServers": {"github": {"type": "http","url": "https://api.githubcopilot.com/mcp/readonly","headers": { "Authorization": "Bearer ${GITHUB_PAT}" }}}}Cursor uses the same URL in
.cursor/mcp.json; see the GitHub MCP server guide for its config. -
AWS: the AWS Labs
awslabs.well-architected-security-mcp-server(PyPI 0.2.0) reports GuardDuty, Security Hub, Inspector and IAM Access Analyzer status and findings. Run it under a read-only IAM role, not access keys in the config. The package’s executable isawslabs.well-architected-security-mcp-server; the shorter name in the upstream README fails (checked withuvxon 2026-09-26):{"mcpServers": {"aws-security": {"command": "uvx","args": ["--from", "awslabs.well-architected-security-mcp-server@0.2.0", "awslabs.well-architected-security-mcp-server"],"env": { "AWS_PROFILE": "security-readonly", "AWS_REGION": "eu-central-1" }}}}Codex takes the same fields as a
[mcp_servers.aws-security]table in~/.codex/config.toml. -
Azure:
@azure/mcpuses youraz loginidentity. Pin the stable release, because@azure/mcp@latestresolves to a 3.0.0 beta (checked 2026-09-26):claude mcp add azure -- npx -y @azure/mcp@2.0.5 server start. -
Semgrep skill:
npx skills add semgrep/skillsinstalls the skills Semgrep publishes in that repository; check its README for the current list. A skill is lighter than a server; use it when you want the agent to write custom Semgrep rules for your codebase.
Every server you add is new attack surface; the agent threat model lists what an attacker can reach through one. Scan your own agent configuration with Snyk Agent Scan (uvx snyk-agent-scan@0.6.8, the PyPI release on 2026-09-26; needs SNYK_TOKEN). It starts stdio servers to inspect them, so run it in a sandbox for configs you do not trust. See how to secure MCP servers for the full procedure.
Harden the cluster with Pod Security Admission
Section titled “Harden the cluster with Pod Security Admission”An admission policy stops a vulnerable or over-privileged workload that slipped past CI. PodSecurityPolicy was removed in Kubernetes 1.25; the built-in replacement is Pod Security Admission, set by namespace labels:
apiVersion: v1kind: Namespacemetadata: name: payments labels: pod-security.kubernetes.io/enforce: baseline pod-security.kubernetes.io/warn: restricted pod-security.kubernetes.io/audit: restrictedWhere does compliance evidence fit?
Section titled “Where does compliance evidence fit?”Scanner runs, triage files and remediation pull requests are the evidence SOC 2 and ISO 27001 auditors ask for under change management and vulnerability management. Keep them as build artifacts with a retention period. Mapping them to controls is its own workflow, covered in regulatory compliance automation.