This file provides guidance to Claude Code (and other coding agents) when working in this repository.
gh-aw-threat-detection is the threat detection component for GitHub Agentic Workflows (gh-aw). It is a Go CLI (threat-detect) that analyzes artifacts produced by AI agents — prompts, agent output, git patches, comment memory — and decides whether to allow or block the downstream safe-outputs job.
The component runs in two main contexts:
- As a host CLI (
./bin/threat-detect <artifacts-dir>) inside agh-aw-generated detection job. - As a published release-asset binary (Linux or macOS, amd64 or arm64, downloaded via
gh release downloadfromgithub/gh-aw-threat-detectionreleases) installed on the runner and executed under the AWF firewall (see AWF section below).
It detects three categories: prompt injection, secret leak, and malicious patch, and emits a strict JSON contract.
- Language: Go 1.26+ (module
github.com/github/gh-aw-threat-detection) - Binary:
bin/threat-detect(built viamake build) - Distribution: published as GitHub Release assets for Linux and macOS on amd64 and arm64, plus
checksums.txt; no container image - Spec:
specs/threat-detection-spec.md— W3C-style normative spec; the source of truth for behavior.specs/usage-spec.md— W3C-style usage/integration spec (acquire, invoke, conclude). - Engines supported:
copilot(default),claude,codex— invoked fromPATH, not bundled into the binary - Detection: a single agentic CLI engine pass; the engine reports its verdict in-session via the
threat_detection_resulttool (out-of-band result sink), which is the sole source of the verdict
cmd/threat-detect/ CLI entry point and flag parsing (main.go)
pkg/artifacts/ Artifact directory loading and validation
pkg/detector/ Core detection logic
├── detector.go BuildPrompt and prompt template handling (//go:embed prompts/)
├── result.go Result struct + JSON Schema + structured sink parser/writer
├── static.go PromptAnalysis: trusted-template vs untrusted-input breakdown
├── scaffolding.go Detects the gh-aw `<system>` framework preamble (trusted, never injection)
├── correction.go Self-correction retry prompt builders
└── prompts/ Embedded markdown prompts (threat_detection.md)
pkg/engine/ AI engine abstraction
├── engine.go copilot/claude/codex CLI adapters; Copilot uses runCLIWithPromptFile, Claude uses runCLI with stdin, Codex passes prompts via codexArgs/runCLIEnv
└── tool.go threat_detection_result wrapper provisioning + result-sink watcher
pkg/runlog/ Structured JSONL run-log writer (--log-file); nil-safe no-op logger
specs/ Normative spec (threat-detection-spec.md)
skills/ Repo-relevant agent skills (console-rendering, error-messages)
scratchpad/ Retained design references inherited from gh-aw
.github/workflows/ CI, release, promote, replay-detection, smoke-{copilot,claude,codex}-standalone
.devcontainer/ Codespaces / devcontainer setup (Go, gh, Copilot CLI, optional Vertex)
Makefile All build/test/lint/release targets
Run these from the repo root. They are the canonical entry points — prefer them over ad-hoc go invocations.
make deps # go mod download + tidy
make deps-dev # + install gosec, govulncheck, golangci-lint v2.8.0
make build # builds bin/threat-detect with version ldflag
make test # go test -v -race ./...
make test-coverage # writes coverage.out + coverage.html
make lint # go vet ./...
make golint # golangci-lint (requires deps-dev)
make fmt # go fmt ./...
make fmt-check # CI-style gofmt check
make security-scan # gosec + govulncheck
make smoke # build + run bin/threat-detect --version
make sbom # SPDX + CycloneDX SBOMs (requires syft)
make agent-finish # full maintainer validation: deps-dev, fmt, lint, build, test, security-scanAlways run make agent-finish (or at minimum make fmt lint build test) before declaring a code change complete.
threat-detect [flags] <artifacts-dir>
Important flags (see README.md for the full list):
--engine {copilot|claude|codex}(defaultcopilot)--model <name>— model override forwarded to the engine--prompt-template <path>— override the embedded default--output <path>— write JSON result (defaults to stdout)--log-file <path>— write structured JSONL run logs; env:THREAT_DETECTION_LOG_FILE--retries(default1) — retries for malformed detection outputs; env:THREAT_DETECTION_RETRIES
Exit codes (defined in cmd/threat-detect/main.go):
| Code | Meaning |
|---|---|
| 0 | Safe (no threats detected) |
| 1 | Threat detected |
| 2 | Infrastructure / configuration error |
Result contract (from pkg/detector/result.go):
{ "prompt_injection": false, "secret_leak": false, "malicious_patch": false, "reasons": [] }The detector reads the verdict exclusively from the out-of-band result sink. The engine reports its verdict in-session by invoking the threat_detection_result tool, which writes the JSON object (prompt_injection, secret_leak, malicious_patch, reasons) to the sink (detector.ReadResultFile / detector.ParseStructuredResult).
Artifacts directory shape (validated by pkg/artifacts/artifacts.go):
<artifacts-dir>/
├── aw-prompts/
│ ├── prompt.txt # required
│ ├── prompt-template.txt # optional, pre-expansion
│ └── prompt-import-tree.json # optional, runtime-import provenance
├── agent_output.json # required
├── aw_info.json # optional, bounded untrusted activation context
├── aw-*.patch # optional, git format-patch
├── aw-*.bundle # optional, git bundles
├── experiments/ # optional, inventoried only
└── comment-memory/*.md # optional, inventoried only
All files are recursively inventoried in the JSONL run log and Actions step
summary. The prompt consumes only an allowlisted, size-bounded subset of
aw_info.json; unknown fields are ignored and all included values are untrusted.
pkg/detector/detector.go(BuildPrompt) rendersprompts/threat_detection.mdwith placeholders substituted from artifacts andBuildPromptAnalysis(untrusted-input breakdown).- The engine CLI is invoked from
PATHviapkg/engine/engine.go(copilot,claude, andcodexuse engine-specific prompt-passing paths;runCLIWithPromptFileis used by Copilot). - The engine reports its verdict in-session by calling the
threat_detection_resulttool, which writes JSON to an out-of-band result sink (pkg/engine/tool.go); the sink is the sole source of the verdict, and the subprocess is cancelled as soon as a valid result is written. - If no sink result is written, a one-shot self-correction prompt is built (
pkg/detector/correction.go) and retried (--retries, default 1); retry exhaustion is an infrastructure error. The engine transcript is never parsed for the result.
Three workflows orchestrate releases:
.github/workflows/create-release-tag.yml— manual; pushesvX.Y.Z..github/workflows/release.yml— triggered by tag push; gated byrelease-publishenvironment; builds + publishes Linux and macOS binaries for amd64 and arm64 as GitHub Release assets in a prerelease, recording each asset sha256 in the release notes..github/workflows/promote-release.yml— manual; gated byrelease-promote; re-downloads the asset, verifies its sha256 against the recorded value, and marks the GitHub release Latest (stable).
release-targets.txt is the canonical platform/asset matrix consumed by both
tagged and rolling release workflows. The Release Platform Parity workflow
checks it against the supported asset names in gh-aw's installer.
The latest (non-prerelease) GitHub release and "Latest" badge only move on explicit promotion — never automatically.
This repo runs daily AW smoke tests against all three engines:
.github/workflows/smoke-{copilot,claude,codex}-standalone.{md,lock.yml}—features: gh-aw-detection: true, so gh-aw natively downloads this repo's releasedthreat-detectbinary (pinned to a promoted release tag), runs it under AWF, and concludes from the structureddetection_result.jsonviathreat-detect conclude. The.lock.ymlfiles are compiled bygh aw compile.
Recompile with gh aw compile after editing any smoke .md source.
The top-level smoke.yml workflow can be dispatched to start all three standalone smokes at once.
Required Actions secrets/variables for smokes are documented in README.md → Development → AW Smoke Workflows.
.github/workflows/replay-detection.yml — manual dispatch to rerun detection against artifacts from a prior gh-aw run. Supports two detector sources (current, release), engine and model overrides, custom prompt injection, and AWF mode (use_awf=true). Uploads a sanitized replay-detection-<run_id> artifact with manifest, inventory, logs, replay result, and comparison to the original result. Uses the dispatching repo's GITHUB_TOKEN — no extra replay token needed. run_attempt is only safe for the latest attempt of a run.
When changing this repo:
- Spec first: behavior changes must align with
specs/threat-detection-spec.md. Update the spec when the contract changes. - Preserve the JSON result contract:
prompt_injection,secret_leak,malicious_patch,reasons— schema enforced by the parser inpkg/detector/result.go. - Don't bundle engine CLIs into the binary. Engines (Copilot, Claude, Codex) are invoked from
PATH. The runner provides the engine. - No new JS scripts. Detection setup and result parsing are Go. Old gh-aw JS detection scripts are being retired.
- Custom orchestrator steps (
threat-detection.steps) belong in thegh-awjob, not inside the detector. - Prefer small, local packages and targeted tests (
pkg/<area>/<area>_test.go). - Use the skills in
skills/when writing console output (skills/console-rendering) or validation errors (skills/error-messages). - Don't fix unrelated issues in the same change.
Useful retained design references in scratchpad/: code-organization.md, validation-architecture.md, go-type-patterns.md, styles-guide.md, errors.md, testing.md, safe-outputs-specification.md, safe-output-environment-variables.md, safe-output-messages.md, artifact-naming-compatibility.md, security_review.md.
Unit tests, make build, make test, and make smoke need no secrets. Real AI-backed detection needs:
| Variable | When |
|---|---|
COPILOT_GITHUB_TOKEN |
--engine copilot — fine-grained PAT with Copilot Requests: Read; GITHUB_TOKEN is not sufficient |
ANTHROPIC_API_KEY |
--engine claude |
OPENAI_API_KEY |
--engine codex (or CODEX_API_KEY depending on CLI setup) |
WORKFLOW_NAME, WORKFLOW_DESCRIPTION, CUSTOM_PROMPT |
Optional — folded into the prompt |
When a CI workflow fails, always follow this order:
- Reproduce locally first — run the same
maketarget or script the workflow runs. - Identify the root cause — read logs, error messages, system state.
- Test the fix locally.
- Then update the workflow.
This avoids trial-and-error CI commits, which waste runner time and rarely fix the real cause.
For workflow-artifact triage there is a generic gh-aw helper (scripts/download-latest-artifact.sh) in sibling repos; this repo currently relies on gh run download <run-id> directly.
This section is reference material. AWF is a separate project (
github/gh-aw-firewall) — it is not built or developed in this repo. It is documented here because the threat detection job runs inside AWF ingh-aw-compiled workflows, and the*-standalone.lock.ymlsmoke variants exercise that path. When debugging detection-job network behavior, reach for these facts.
awf (Agentic Workflow Firewall) is a Node.js CLI that wraps any command in a sandboxed Docker network. It provides L7 (HTTP/HTTPS) egress control using a Squid proxy, restricting network access to a whitelist of approved domains while giving the agent access to the host workspace and selected system paths via chroot and selective bind mounts.
The system is orchestrated by AWF's src/cli.ts and managed by src/docker-manager.ts. There are three containers — two always required, one optional:
1. Squid Proxy (always required) — containers/squid/, IP 172.30.0.10
- Enforces domain ACL filtering for all HTTP/HTTPS traffic
- Config (
squid.conf) is generated bysrc/squid-config.tsand injected via base64 env varAWF_SQUID_CONFIG_B64(not a file bind mount — avoids Docker-in-Docker issues) - Agent container
depends_onSquid's healthcheck before starting
2. Agent (always required) — containers/agent/, IP 172.30.0.20
- Runs the user's command (e.g.,
claude,copilot,threat-detect,curl) - An iptables-init init container (
awf-iptables-init) shares the agent's network namespace and runssetup-iptables.shto redirect all port 80/443 traffic via DNAT to Squid before the user command starts entrypoint.shhandles UID/GID mapping, DNS config, chroot to/host, and capability drop (SYS_CHROOT,SYS_ADMINdropped before user code runs)- Selective bind mounts (not a blanket host FS mount): system binaries (
/usr,/bin,/sbin,/lib,/lib64,/opt,/sys,/dev) read-only; workspace and/tmpread-write; empty home volume with only whitelisted$HOMEsubdirs (.cache,.config,.local,.anthropic,.claude,.cargo,.rustup,.npm,.copilot); select/etcfiles (SSL certs,passwd,group,nsswitch.conf,ld.so.cache,alternatives,hosts— not/etc/shadow) - Sensitive API keys are NOT present in the agent environment when
--enable-api-proxyis active
3. API Proxy Sidecar (optional) — containers/api-proxy/, IP 172.30.0.30
- Enabled via
--enable-api-proxy - Injects real API credentials (OpenAI, Anthropic, Copilot) that the agent never sees
- Agent calls the sidecar with no auth (e.g.,
http://172.30.0.30:10001for Anthropic); sidecar injects the real key and forwards via Squid - Ports: 10000 (OpenAI), 10001 (Anthropic), 10002 (Copilot), 10003 (Gemini), 10004 (OpenCode) — discrete ports, not a contiguous range
AWF pulls pre-built images from GHCR by default:
- Default:
ghcr.io/github/gh-aw-firewall/{squid,agent,api-proxy}:latest --build-localto build from source--image-registry <registry>and--image-tag <tag>for overrides
awf <flags> -- <command>
↓
CLI generates squid.conf (base64) + docker-compose.yml + seccomp profile in /tmp/awf-<ts>/
↓
Docker Compose: Squid (healthcheck) → [API Proxy (optional)] → Agent
→ iptables-init runs setup-iptables.sh (writes /ready)
↓
User command executes in Agent container (chrooted to /host)
↓
HTTPS (proxy-aware tools) → HTTPS_PROXY → Squid:3128 (CONNECT) → ACL → allowed or blocked
HTTPS (proxy-unaware) → iptables DNAT → Squid:3128 → TLS handshake rejected
HTTP → iptables DNAT → Squid:3128 → ACL → allowed or 403
API calls (optional) → http://172.30.0.30:1000x → API Proxy injects key → Squid → upstream
↓
docker compose down -v + rm /tmp/awf-<ts>/
--allow-domainsvalues are normalized (protocol/trailing slash removed)github.commatchesgithub.comand.github.com(subdomains).github.commatches all subdomains- Squid denies anything not on the allowlist
--dns-servers <comma-separated IPs>(default8.8.8.8,8.8.4.4); IPv6 supported- Docker's internal
127.0.0.11is always allowed - Host-level iptables block DNS to non-whitelisted servers; container reads
AWF_DNS_SERVERS
HTTP_PROXY/HTTPS_PROXY— used by curl, wget, pip, npm, etc.SQUID_PROXY_HOST/SQUID_PROXY_PORT— raw values for tools that need themJAVA_TOOL_OPTIONS— JVM proxy system properties (Maven still needs~/.m2/settings.xml)http_proxy(lowercase) is intentionally not set — avoids httpoxy issues and keeps Squid 403 responses returning non-zero exit codes for security tests
--docker-host— override Docker socket path (auto-detected fromDOCKER_HOST)--docker-host-path-prefix <prefix>— prefix all bind-mount source paths so a separate Docker daemon can resolve runner filesystem paths (kernel VFS/dev,/sys,/procand/dev/nullare passed through unprefixed)--enable-dind— expose the Docker socket inside the agent container
- Squid logs (L7):
firewall_detailedlogformat — timestamp, client, host (SNI/Host), dest, method, status, decision (TCP_TUNNELallowed /TCP_DENIEDblocked), URL, UA.200=allowed,403=blocked. Logs are Unix timestamps. - iptables LOG rules (L3/L4):
[FW_BLOCKED_UDP]and[FW_BLOCKED_OTHER]prefixes with--log-uid. View viadmesg. awf logs statsandawf logs summary(formats:pretty,markdown,json) — pipeawf logs summary >> $GITHUB_STEP_SUMMARYin CI.
AWF propagates the agent container's exit code via docker inspect. Use --keep-containers to preserve /tmp/awf-<ts>/ (squid.conf, docker-compose.yml, agent-logs/, squid-logs/) for debugging.