Skip to content

Regulatory Compliance Automation

Regulatory compliance automation uses a coding agent to map SOC 2, HIPAA and GDPR controls to code, write the CI gates that enforce them, and generate the scripts that collect audit evidence. Deterministic tools produce the evidence and a named control owner signs it off; the agent writes the collectors and drafts, never the evidence itself.

The auditor’s request list arrives on Monday: branch-protection settings for the whole observation period, proof that every change to src/auth/ was reviewed, a list of production dependencies with their licenses, and a data-flow description for EU personal data. Last year this took two engineers two weeks of screenshots and spreadsheets. This page is for the developer or tech lead who owns that request list and wants the next audit to be a re-run of scripts, not a scramble.

What automated compliance evidence gives you

Section titled “What automated compliance evidence gives you”
  • A control-mapping prompt that returns a gap table with file:line citations, and a short script that rejects any citation that does not exist.
  • A license-policy gate and a secret gate that block the merge (or the agent’s own commit) and leave a dated artifact on every run.
  • A change-management collector for SOC 2 CC8.1 that reads approvals from GitHub, not from git log.
  • A GDPR data-flow draft and a DSAR-coverage check built from the live schema.
  • A scheduled job that regenerates the evidence weekly, plus a sign-off rule that says who attests to it.

If you are deciding what data may reach the agent in the first place, start with data privacy and enterprise policies for coding agents. If you work in finance, health or the public sector, read agentic engineering in regulated industries for separation of duties. This page assumes both are settled.

Compliance-as-code turns each control into a check that runs on every change, so the evidence accumulates as a side effect of shipping. The agent is fast at the parts that are tedious to write, and its mistakes there are easy to miss. That is exactly why it must not be the last word.

LayerThe agent’s jobWhat runs itEvidence it leaves
Control mappingDraft the gap table with citationsYou, once per control per quarterGap table (JSON) plus citation-check log
Agent hookWrite the hook configClaude Code when the agent runs git commitBlocked-call message from the scanner (local only)
Pre-commitWrite .pre-commit-config.yamlEvery developer’s git commitNone (local only), so never rely on it alone
CI gateWrite the workflow and helper scriptsGitHub Actions on every pull requestJob log and uploaded artifact per run
Evidence collectorWrite the scriptA scheduled job, deterministicallyDated JSON from the source system’s API
NarrativeDraft prose and diagrams from the JSONYou review; the control owner signsSigned document that cites the JSON

Read the table top to bottom as the order to build things in. A narrative without collectors under it is a story, not evidence.

Map one control to your code with file:line citations

Section titled “Map one control to your code with file:line citations”

Start with one control, not a framework. SOC 2 CC6.1 (logical access) is a good first choice because most of its evidence already lives in your repository: authentication middleware, authorization checks, IAM policies in Terraform, and secret handling.

Save this prompt as .compliance/prompts/cc6.1.md.

The “only Met with a citation” rule is the whole point. A compliance prompt is where a model is most tempted to return a tidy, fully compliant answer. Forcing citations turns that answer into something a script can check.

Run it headless and read-only. --allowedTools only auto-approves the tools it names; it does not remove the others. --tools removes every other built-in tool from the session, and in -p mode anything that would prompt is denied. --strict-mcp-config keeps configured MCP servers out of the session. --json-schema with --output-format json validates the answer against the schema from the Codex tab (save it as .compliance/gap.schema.json) and returns it in the structured_output field (all checked against Claude Code 2.1.283).

Terminal window
mkdir -p evidence
claude -p "$(cat .compliance/prompts/cc6.1.md)" \
--tools "Read,Grep,Glob" --allowedTools "Read,Grep,Glob" \
--strict-mcp-config \
--output-format json --json-schema "$(cat .compliance/gap.schema.json)" \
| jq -e '.structured_output' > evidence/cc6.1-gap.json # exits 1 if no validated object

Now check every citation mechanically instead of reading the table. Save this as scripts/compliance/check-citations.sh and run it from the repository root. It needs bash, because it reads from a process substitution; a jq … | while pipeline would set broken in a subshell and always exit 0.

#!/usr/bin/env bash
# scripts/compliance/check-citations.sh <file.json> [jq filter]
# Exits 1 on any citation that is not an existing file plus one line number.
filter="${2:-.requirements[] | select(.status==\"Met\") | .evidence[]}"
jq empty "$1" || exit 1
# The loop only sees citations that exist, so reject a "Met" row with none first.
if [ -z "$2" ]; then
jq -e 'all(.requirements[]; .status != "Met" or (.evidence | length > 0))' "$1" >/dev/null \
|| { echo 'MET WITHOUT CITATION'; exit 1; }
fi
broken=0
while IFS=: read -r file line; do
case "$line" in
''|*[!0-9]*) echo "BROKEN CITATION (no single line number): $file:$line"; broken=1; continue ;;
esac
if [ ! -f "$file" ] || [ "$line" -lt 1 ] || [ "$line" -gt "$(awk 'END{print NR}' "$file")" ]; then
echo "BROKEN CITATION: $file:$line"; broken=1
fi
done < <(jq -r "$filter" "$1")
exit "$broken"

Before you trust it, seed one fake citation (src/nope.ts:1) in a copy of the JSON and confirm the script prints BROKEN CITATION and exits 1. A citation without a single line number, such as file.ts or file.ts:42-50, also fails, and so does a “Met” row whose evidence array is empty (MET WITHOUT CITATION). The line count comes from awk rather than wc -l, because wc -l counts newline characters and would reject a citation of the last line in a file that has no trailing newline.

A broken citation means the table is unreliable as a whole: discard it and re-run, do not patch the one row. When every citation resolves, open three “Met” rows at random and confirm the cited line really implements the requirement. That sample, not a full read, is your review.

Wire the gates that produce evidence on every run

Section titled “Wire the gates that produce evidence on every run”

A gate is better evidence than a report because it runs on every change for the whole observation period. Two gates pay for themselves first: dependency licenses and secrets. Secret scanning with gitleaks, Semgrep and Trivy is covered in depth in security operations automation; this section adds the license gate and makes both leave artifacts an auditor can sample.

The result should look like this. Check it against the prompt rather than trusting it:

name: license-policy
on:
pull_request:
permissions:
contents: read
jobs:
licenses:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm ci --ignore-scripts
- name: Write the license report (always)
run: >
npx --yes license-checker-rseidelsohn@5 --production --excludePrivatePackages
--json --out license-report.json
- name: Enforce the allowlist (the gate)
run: >
npx --yes license-checker-rseidelsohn@5 --production --excludePrivatePackages
--onlyAllow "MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD"
- if: always()
uses: actions/upload-artifact@v7
with:
name: license-report
path: license-report.json
retention-days: 90

Use an allowlist (--onlyAllow), not a denylist (--failOn). A denylist of GPL-3.0 misses GPL-3.0-only, GPL-3.0-or-later and every license string a maintainer typed by hand. An allowlist fails on anything unfamiliar, which is the safe direction: a human then decides and records the exception.

The report and the gate are separate steps because --onlyAllow exits on the first disallowed license, so a single combined step may never write license-report.json, and the failing runs are the ones an auditor samples. Make the gate required in branch protection, then prove it works before you rely on it: open a throwaway pull request that adds one package outside the allowlist and confirm the check goes red.

Stop the agent from committing a violation

Section titled “Stop the agent from committing a violation”

CI catches a violation after it is pushed. A hook catches it before it exists. The layers differ by tool, so set up the one your team uses and keep the shared pre-commit config for everyone.

A PreToolUse hook with an if condition runs only when the agent tries git commit. Add it to .claude/settings.json:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit *)",
"command": "gitleaks git --staged --redact --exit-code 2"
}
]
}
]
}
}

The --exit-code 2 is load-bearing. Claude Code blocks the tool call only on exit code 2. gitleaks exits 1 by default when it finds a leak, which Claude Code treats as a non-blocking error, so the commit would go through.

Install gitleaks on every machine first; a missing binary exits 127, which does not block. The hook scans what is staged before the command runs, so git add -A && git commit in one call slips past it; that is why the CI gate stays the control.

The pattern matches only the spelling it names, so git -C . commit slips past, and the hooks docs call the if filter best-effort; treat this hook as an early warning. The required CI gate remains the control.

The shared layer for every contributor, human or agent, is .pre-commit-config.yaml:

repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks

Each clone runs pre-commit install once. Pre-commit hooks are opt-in per clone and anyone can skip them with --no-verify. They reduce noise in CI; they are not a control. The CI gate is the control, and it is the only layer whose run you can show an auditor.

Collect the change-management audit trail from GitHub, not git log

Section titled “Collect the change-management audit trail from GitHub, not git log”

SOC 2 CC8.1 asks whether changes were authorized, reviewed and tested. git log cannot answer that: it does not record who approved a pull request, whether a review was dismissed, or whether required checks passed. That data lives in GitHub. Give the agent read-only access to it through the GitHub MCP server, using the /readonly endpoint so no write tool is even offered.

Terminal window
claude mcp add --transport http github-ro https://api.githubcopilot.com/mcp/readonly \
-H "Authorization: Bearer $GITHUB_PAT"

The shell expands $GITHUB_PAT before Claude Code sees it, so the token is stored in plain text in your Claude Code MCP config. Use a short-lived token, or put the server in .mcp.json with "Authorization": "Bearer ${GITHUB_PAT}", which Claude Code expands from the environment at startup; the GitHub MCP page has the exact JSON. Either way, use a fine-grained token scoped to the audited repositories with read-only permissions. The same prompt works in all three tools once the server is connected.

The report is a draft for a human. The evidence an auditor keeps is the raw data, and that should come from a script you can re-run on the morning of the audit, not from a chat transcript. Have the agent write that script once:

Review the collector like any security-relevant script: confirm each gh api path is real, that nothing logs the token, and that the exit condition matches your policy. Then run it once and compare its JSON with the branch-protection page in the GitHub UI. Reading branch protection needs a token with administration read permission on the repository, which is a common first failure.

Draft the GDPR data-flow record and DSAR coverage from the live schema

Section titled “Draft the GDPR data-flow record and DSAR coverage from the live schema”

GDPR Article 30 requires a record of processing activities, and Articles 15 and 17 give data subjects the right to access and erase their data. Both break silently when a new table or third-party SDK arrives. The agent can trace personal-data fields through the code faster than you can grep, as long as it reads the real schema and flags what it cannot trace.

Connect a database MCP server with a read-only database role (see database MCP servers for the options). Do not use the archived reference package @modelcontextprotocol/server-postgres: its npm entry is marked deprecated (checked 2026-09-26).

For HIPAA, run the same prompt with protected health information in place of personal data and add one question per flow: is access logged with user and time? That maps each flow to the audit-controls and access-control safeguards of 45 CFR 164.312.

The NEEDS REVIEW tag is the safety valve: it lists exactly what the data protection officer must close by hand. Then turn the same analysis into a standing check, because a DSAR handler that misses one data store fails an access request without any error:

Run it on a schedule and save the answer as evidence/dsar.json. The first command fails the job when any export or erasure value is null, or when the array is empty, since all over nothing is true; the second passes the citation script a DSAR-shaped filter, because the default one reads .requirements[] and would find nothing to check:

Terminal window
jq -e 'length > 0 and all(.[]; .export != null and .erasure != null)' evidence/dsar.json
scripts/compliance/check-citations.sh evidence/dsar.json \
'.[] | .export, .erasure | select(. != null)'

The erasure path is destructive, so it ships only through a reviewed pull request with tests against a seeded database, never straight from an agent session.

Continuous evidence means the collectors run on a calendar, not the week before the audit. A scheduled workflow runs from the default branch, so it executes only reviewed code. A manual workflow_dispatch run does not: anyone with write access can start it from any branch, and it would receive both secrets. The job-level if holds manual runs to the default branch too; replace main with yours. A dispatched run reads the workflow file from the branch it was started on, so a branch can edit that if away, and a branch that adds a push trigger gets repository secrets as well. For a hard guarantee, store both secrets in an environment whose deployment branches are limited to the default branch, and add environment: with that name to the job. Keep the agent step read-only and let the deterministic collector produce the evidence it summarizes.

The agent CLI is pinned to an exact version (2.1.283, the latest channel on 2026-09-26) so every weekly run uses the same reviewed binary; bump it in a pull request, like any other dependency.

name: weekly-compliance-evidence
on:
schedule:
- cron: '17 6 * * 1'
workflow_dispatch:
permissions:
contents: read
jobs:
evidence:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Collect raw evidence (deterministic)
env:
GH_TOKEN: ${{ secrets.EVIDENCE_READ_TOKEN }}
run: node scripts/compliance/evidence-collect.mjs
- name: Draft the weekly summary (read-only agent)
if: always()
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
npx --yes @anthropic-ai/claude-code@2.1.283 -p \
"Read evidence/change-management-*.json. Summarize CC8.1 exceptions: PRs without approval, disabled protections, failed required checks. Cite the PR number for every exception. Do not restate anything that is not in the JSON." \
--tools "Read,Glob" --allowedTools "Read,Glob" --strict-mcp-config \
> evidence/cc8.1-summary.md
- if: always()
uses: actions/upload-artifact@v7
with:
name: compliance-evidence
path: evidence/
retention-days: 90

The summary and upload steps carry if: always() because the collector exits 1 when it finds an exception. Without it, a failing collector skips both steps, so the runs that record an exception, which are the ones an auditor samples, would leave no artifact.

Actions artifacts expire under your retention setting, which is usually shorter than an observation period. Copy each run’s evidence/ folder to storage the auditor can access and nobody can overwrite, such as a bucket with object lock. Keep agent telemetry for the same period: observing the agents covers audit logs of what the agent itself did.

How do you know the evidence is right without reading all of it?

Section titled “How do you know the evidence is right without reading all of it?”

You verify the pipeline once and the output by sampling. Each check below catches a specific failure, and none of them requires reading a whole report.

CheckCatchesRuns
jq empty on every JSON outputProse or truncation around the dataEvery run
Citation check scriptInvented files and line numbersEvery run
Three random “Met” rows opened by handReal file, wrong contentEvery quarter, per control
Seeded violation (bad license, fake key, fake citation, “Met” row with empty evidence; each must exit 1)A gate that never firesAfter every gate change
Diff against last quarter’s JSONSilent drops in coverageEvery quarter
Collector output compared with the GitHub UIWrong API endpoint or permissionsOnce, then after changes
Control owner’s signature on the narrativeNobody accountable for the claimEvery audit

The sign-off rule is not optional. The control owner, a named person, attests to each control; the agent drafts, the owner signs. That is also the line your auditor will draw.

The agent marks a control “Met” with no real evidence. Symptom: the citation check prints BROKEN CITATION or MET WITHOUT CITATION. Recovery: discard the whole table, tighten the prompt (“if you cannot cite a line, the status is Partial”), and re-run. A table with one invented citation cannot be trusted row by row.

The hook never blocks. Symptom: the agent commits a staged test key and nothing happens. Cause: the scanner exits 1, and Claude Code blocks only on exit 2. Recovery: add --exit-code 2 and repeat the seeded-key test.

The license gate passes a copyleft package. Cause: a denylist that does not list the exact SPDX variant, or a package with a hand-typed license string. Recovery: switch to --onlyAllow, record every exception in the repository with the reviewer’s name, and review the exception list each quarter.

The collector reports branch protection as missing. Cause: the token lacks administration read permission; the API then answers 404, which a naive script reads as “not protected”. Recovery: fix the token scope, re-run, and compare with the UI before trusting any “missing” result.

The trail has gaps. Cause: someone with admin rights pushed to main directly, or bypassed protection. Recovery: turn on “Do not allow bypassing the above settings” (or the ruleset equivalent), then list the gap in the report instead of hiding it. Auditors treat a disclosed, explained exception far better than a discovered one.

A generated document leaks a real secret or personal data. Cause: the agent quoted row values or config. Recovery: every prompt on this page asks for names, not values; keep that rule, scan evidence/ with gitleaks before upload, and rotate anything that leaked.

The policy text and the automation drift apart. Symptom: the policy says credentials rotate every 90 days and the rotation job runs every 180. Recovery: make the collector compare the configured schedule with the written policy and fail on a mismatch, instead of restating the policy.

Regulations change under the rules. Recovery: give each rule a control ID and a review date in its header, and put a quarterly task on the control owner’s calendar to review rules against the current text. For the EU regulations that now reach software teams, see CRA, NIS2 and DORA-EU.