The Guardrails Gate
The guardrails gate is one of Forge's four enforced controls. It bounds dangerous actions — the irreversible command, the leaked secret — the failure class an advisory paragraph cannot stop. It is the one control that stands in front of an action instead of only grading a run after the fact.
Two lines of defense
Guardrails are layered, because Forge is not in the model's inference loop — it cannot intercept a keystroke. So it does both jobs it can do:
- Feedforward, before an action runs. A secrets deny-list the agent's own permission system enforces, and a destructive-action PreToolUse hook (
forge guardrails-gate) that blocks a catastrophic command with a non-zero exit. - Enforced scan, after files are written.
forge secscanreads the working tree for leaked secrets, PII, and prompt-injection strings, and returns non-zero while any unwaived finding remains.
The first line steers; the second refuses to bless.
Guide and Sensor in one gate
In the harness model, the deny-list and the destructive-action hook are Guides — feedforward, they shape the run before it acts. forge secscan is a Sensor — feedback, it reads what actually happened and refuses. The guardrails gate is the one control that wears both hats.
The secrets deny-list
When Forge configures an agent, it writes a per-adapter permission overlay that denies both Read and Edit on the files that must never reach a model's context or its edits — dotenv files (.env, .env.*), private keys and certificates (*.pem, *.key), SSH and cloud credentials (.ssh/**, .aws/credentials), OS keychains, and anything under a secrets/ directory. The same categories are denied across every adapter, so switching agents never quietly widens what is readable.
The deny-list is enforced by the agent's own permission system, not by Forge. Forge's job is to write a correct, consistent overlay into every adapter it manages; coverage then depends on what each host supports — see per-adapter coverage.
The destructive-action hook
For Claude Code running in bypass (auto-accept) mode — where the agent runs every tool call without asking — Forge installs a PreToolUse hook wired to forge guardrails-gate. The host hands Forge the command on stdin; Forge evaluates it and returns the exit code the host obeys:
- Exit 2 — blocked. The command matches a catastrophic pattern. The host refuses to run it and relays Forge's reason back to the model.
- Exit 0 — allowed. Everything else proceeds.
What it denies
The gate blocks the small set of commands that are irreversible or history-destroying: a recursive-and-forced rm whose target is the filesystem root or home directory (rm -rf /, rm -rf ~), any sudo-elevated rm, git reset --hard, git rebase, and a hard force-push (git push --force / -f). It deliberately allows --force-with-lease and --force-if-includes — the safe force-push variants that refuse to clobber unseen remote work.
Matching runs over the whole command string, split into statements on ;, &, |, and newlines, with simple subshells unwrapped — so a destructive command chained after a harmless one is still caught.
What "ask" covers
Under guarded mode the agent already pauses for confirmation on every tool call, so the blocking hook is not installed. Instead the per-adapter overlay marks the outward-facing commands — a bare git commit, git push, an arbitrary rm -rf <path>, piped shell installers, recursive chmod/chown — as ask, and the human confirms. The blocking gate exists precisely for bypass mode, where there is no "ask" to fall back on.
The gate is fail-open by design
If the hook payload is unreadable, malformed, or the tool is not Bash, forge guardrails-gate returns exit 0 and allows the call. Only a confirmed destructive match blocks. This is deliberate: a narrow safety gate must never become the thing that disables Bash entirely because a host changed its payload shape.
forge secscan — the enforced scan
The deny-list and the hook stop shapes Forge can see coming. forge secscan catches what already landed. It statically scans a change's files for leaked secrets, PII, and prompt-injection strings, and enforces a gate over the findings.
forge secscan scan --change <c> [--path P ...] [--min-severity high|medium]
forge secscan waive --change <c> --fingerprint <fp>
forge secscan reset --change <c>scan(the default) reads the change's files and evaluates the gate. It is read-only over the tree — it only reads the waivers file, never writes it. A finding blocks (non-zero exit) when it is unwaived and at or above the severity floor. Secrets and prompt-injection are high; PII is medium. The default floor ishigh, so PII is reported but does not block unless you lower the floor.waiverecords a human-acknowledged fingerprint so it stops blocking — the only way a finding is accepted. Waivers persist in.forge/changes/<c>/secscan.yaml.resetclears every waiver for the change.
$ forge secscan scan --change checkout
secscan[checkout] high secret src/config.ts:14 fp=a1b2c3 aws-access-key AKIA…
SECSCAN BLOCKED — 1 unwaived finding(s). STOP and escalate to a human.
# exit 1
$ forge secscan waive --change checkout --fingerprint a1b2c3 # a human accepts it
secscan waived a1b2c3 for change "checkout"
# exit 0
$ forge secscan scan --change checkout
secscan: clean (1 finding(s), all waived or below threshold)
# exit 0Each finding prints one line — secscan[<c>] <severity> <kind> <file>:<line> fp=<fingerprint> <redacted> — and the redacted snippet never contains the raw secret.
A human waives, never the agent
secscan returns non-zero for a reason: an unwaived finding is a stop-and-escalate signal. The agent must not waive its own findings — only a human, after confirming a false positive or an accepted risk, runs forge secscan waive. The gate is idempotent, so it re-runs to the same verdict from a hook, a script, or by hand. The /forge.secscan slash command shells out to the same binary, so agent-side and terminal behavior are identical.
The honest limit
Forge is not in the model's inference loop. It cannot un-write a secret or intercept a keystroke — secscan scans the working tree after the file was saved, and the destructive-action hook only sees what the host chooses to hand it. Both are best-effort, not a sandbox and not a policy engine:
- The destructive-action gate is a heuristic textual matcher, not a shell parser. It catches the shapes the team has encoded; it does not defend against deliberate obfuscation — base64 payloads,
eval, environment-variable indirection. - Coverage is bounded by what each host exposes.
Treat guardrails as a second line of defense behind reviewing diffs and confirming ask prompts yourself — not a substitute for either.
Per-adapter coverage
Only some hosts expose an injectable blocking hook. Forge writes the strongest guardrail each adapter supports and documents the gaps rather than emitting speculative config:
| Adapter | Guardrail mechanism |
|---|---|
| Claude Code | Read/Edit + catastrophic-rm deny-list; forge guardrails-gate PreToolUse hook under bypass |
| OpenCode | read deny-list + bash ask-overlay (commit / push / rebase / reset --hard) |
| Gemini CLI / Qwen Code | mode-level approval (default guarded, auto_edit bypass) |
| Codex | approval_policy toggle + deny-list; no per-command blocking hook |
| VS Code Copilot | global auto-approve toggle; no per-command gate |
forge secscan is host-independent — it runs against the working tree from a terminal, a hook, or CI, regardless of adapter.
Next steps
- The enforcement doctrine — where guardrails sit among the four controls.
- Verify circuit-breaker — the sibling gate that bounds iterations.
- Spend budget — the sibling gate that bounds cost.
- Connect a live agent — wiring the PreToolUse and scan hooks that call Forge.
- Change governance — the advisory triage contract, distinct from these gates.