Skip to content

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.

StepOwnerOutputBlocks a merge?
DetectScanner in CIsemgrep.json, audit.json, trivy.json, gitleaks.jsonYes, on new Semgrep ERROR findings and any high or critical npm advisory
TriageAgent, read-only toolstriage.json, validated against a schemaNo, advisory
FixAgent, on a security/* branchA pull request per finding classOnly through the normal gates
AcceptSecurity owner (a named person)Review of every P0 and a sample of dismissalsYes, required review
RotateHuman with provider accessRevoked credential, new secret in the secret storeNot 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.

  1. Install the scanners. None of them is an npm package, so do not npm install them:

    Terminal window
    pipx install semgrep # or: brew install semgrep
    brew install trivy gitleaks # Linux: use each project's release binaries
  2. Run each scanner with JSON output:

    Terminal window
    # Static analysis, curated default rules
    semgrep scan --config p/default --json-output semgrep.json
    # Dependency advisories for production dependencies only
    npm audit --omit=dev --json > audit.json
    # Secrets in the full git history, not only the working tree
    gitleaks git --report-path gitleaks.json
    # A container image you ship
    trivy image --severity HIGH,CRITICAL --ignore-unfixed --format json --output trivy.json myorg/api:latest
  3. 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:

Terminal window
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.json

Checked 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.

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.

  1. 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.ids
    jq -r '.findings[] | select(.source=="semgrep") | .id' triage.json | sort > triage.ids
    diff 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.scanned is non-empty. The fourth line fails when the agent returned no findings array, for example when structured output failed or the budget cap stopped the run and triage.json holds null.

  2. Schema. The output validates against triage.schema.json. Claude Code and Codex enforce this at generation time; for Cursor output, run a validator such as ajv in CI.

  3. 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.

  4. Sampled dismissals. The security owner reads every P0 and a fixed sample of FALSE_POSITIVE verdicts, 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.

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.

.github/workflows/security.yml
name: Security
on:
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.ids

Commit 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.json

The 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: table

Pin 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.

ToolSecurity agent or pluginInstall 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 requestsclaude 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
CursorSecurity 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 file
    claude mcp add --transport http github https://api.githubcopilot.com/mcp/readonly \
    -H "Authorization: Bearer $GITHUB_PAT"
    # Codex
    codex mcp add github --url https://api.githubcopilot.com/mcp/readonly --bearer-token-env-var GITHUB_PAT

    claude mcp add stores 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.json instead; 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 is awslabs.well-architected-security-mcp-server; the shorter name in the upstream README fails (checked with uvx on 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/mcp uses your az login identity. Pin the stable release, because @azure/mcp@latest resolves 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/skills installs 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: v1
kind: Namespace
metadata:
name: payments
labels:
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/audit: restricted

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.

When AI-assisted security operations break

Section titled “When AI-assisted security operations break”