> ## Documentation Index
> Fetch the complete documentation index at: https://sdlc-rstack.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Guardrails & Enforcement

> How RStack's runtime guardrails make governance promises real in code, not just prompt text.

Governance-model docs describe what RStack *promises* — approval gates, evidence requirements, read-only validators. This page is about the runtime code that actually *enforces* those promises: attempt budgets, a centralized destructive-action classifier, mechanical validator checks, and an approval-audit layer that treats every approval record as untrusted input until proven otherwise. All of it lives under `src/core/harness/` and is consumed identically by every wired harness (Pi, Claude Code, Tau, Operator, Hermes) and the Business Hub — one enforcement path, not one per integration.

## Attempt & telemetry budgets (`guardrails.js`)

`DEFAULT_HARNESS_GUARDRAILS` fixes the defaults every project starts with: `maxTaskAttempts: 2`, `maxDestructiveTaskAttempts: 1`, `maxToolCallsPerTask: 40`, `maxMessagesPerTask: 25`, plus the boolean requirements (`requireBuilderContract`, `requireValidatorContract`, `requireEvidenceForPass`, `requireUserApprovalForDestructiveActions`, `requireUserApprovalForPublishDeployOrForcePush`). Projects override any of these under `guardrails` in `.rstack/rstack.config.json`; `resolveGuardrails` merges overrides key-by-key and refuses malformed values (a non-numeric attempt limit or a non-boolean requirement flag is silently ignored, not coerced) — `loadProjectGuardrails` reads the config file and only treats a genuine `SyntaxError` as "no config," letting real I/O failures (EACCES, EIO) surface rather than masquerading as a clean default.

Two functions do the actual gating:

* **`evaluateTaskClaim({ task, events, approvals, guardrails, expectedRunId })`** counts real `task_started` events for the task (`countTaskAttempts`) and compares against the limit — `maxDestructiveTaskAttempts` when the task is flagged destructive, `maxTaskAttempts` otherwise. Once the limit is hit, the task can only be reclaimed if a `guardrail-override:<taskId>` artifact is APPROVED and passes the same audited-approval path described below (`hasGuardrailOverride` → `trustedApprovedArtifacts`).
* **`evaluateBuilderTelemetry({ builder, guardrails })`** checks the builder contract's self-reported `execution.tool_calls` / `execution.messages` against `maxToolCallsPerTask` / `maxMessagesPerTask` at validate time.

Both return violations shaped for `guardrailEvent()`, which emits a pinned `guardrail_triggered` event (with legacy `limit`/`value` aliases for the `sdlc_trace` CLI renderer) so a block is always visible in the run's event stream, not just a refusal.

## The destructive-action classifier (`destructive-actions.js`)

This is the single source of truth for "is this action destructive" — shared by the builder-side gate, the validator sandbox, and every host's `guard` hook, so no integration can drift from another's definition.

`classifyCommand(command)` runs an ordered list of `COMMAND_RULES` regexes over a shell string and returns the first match as a frozen verdict `{ destructive, category, reason, matched }`. Categories are stable contract ids in `DESTRUCTIVE_CATEGORIES`: `git-force`, `broad-delete`, `perm-destroy`, `publish`, `deploy`, `db-destroy`, `remote-exec` (piping a curl/wget download into a shell), `secret-write`, and `interpreter-exec` (a general-purpose interpreter invoked with inline/stdin eval capability — `node -e`, `python -c`, a bare `bash -c "..."`, even through a bounded chain of wrapper commands like `env`/`sudo`/`timeout`). Deliberately *not* flagged: single-file `rm`, `chmod 644`, `git clean -n` (dry-run), and plain `curl -o file` — recursion, force flags, or a pipe into an interpreter are what escalate a command, not the verb alone.

Two more layers close gaps a pure command-string scan would miss:

* **`classifyWritePath(path)`** classifies a write/edit *target path* — a secret-shaped filename (`.env`, `id_rsa`, `*.pem`) outranks a protected-config match (`.git/`, `.github/workflows/`, `Dockerfile`, `.rstack/` itself, the host's own `.claude/settings*.json` guard-hook config, lockfiles, `*.tf`). `.rstack/` is fully protected *except* a run's own `runs/<id>/{artifacts,tasks,specs}/` — the builder's routine per-task output, carved out (#401) after a live run needed a separate destructive approval for every single task's own product-brief.
* **`classifyProtectedWrite(command)`** mirrors that same path check for *bash* commands that write via redirect, `tee`, `cp`, `mv`, `chmod`, in-place `sed -i`, etc. (`WRITE_COMMAND_VERBS`) — so `echo forged > .rstack/runs/<id>/approvals.json` is caught exactly like a `Write` tool call targeting the same path (#369, the self-approval bypass).

`classifyDestructiveAction(arg)` is the one entry point everything else calls — it accepts a raw command string, a `{ command }` shape, or a host `{ toolName, input }` tool-call shape (tool names canonicalized so Claude Code's `MultiEdit` and Pi's `multi_edit` hit the same check), and dispatches to `classifyCommand` or `classifyWritePath` accordingly.

### Binding an approval to the exact action (#482)

A bare `destructive-action:<taskId>` approval authorizes *the task*, not any particular command — approve one benign delete and every later destructive command on that task is silently pre-authorized forever. `destructiveActionEnvelope({ runId, stageId, taskId, attemptId, action })` closes this: it canonicalizes the run/stage/task/attempt plus the normalized command/category/targets into a JSON payload and hashes it (`action_sha256`), and `destructiveApprovalArtifact(taskId, envelope)` appends the first 16 hex chars of that hash to the artifact name. A different command on the same task simply never matches the new artifact name — no migration code needed, old blanket approvals just go inert.

`evaluateDestructiveAction({ action, taskId, approvals, expectedRunId, envelope })` is the gate itself: it resolves the run's trusted approved artifacts via `trustedApprovedArtifacts` (see below) and calls `requireApprovalForDestructiveAction`, which returns `{ allowed, requiresApproval, verdict, approval_artifact, reason }` — a non-destructive action is always allowed; a destructive one is allowed only when its exact artifact is in the trusted set.

<Note>
  `isInterpreterExecCommand` treats any interpreter with inline/stdin eval capability as an opaque read/write/exec primitive — the module makes no attempt to parse what the inline code does (undecidable in general). `commandWritesFile` uses the same reasoning for the BLOCKED-task write gate: an interpreter invocation is assumed to write, since the alternative is letting an opaque capability slip past a hard block undetected.
</Note>

## Mechanical validator checks (`required-checks.js`)

The validator registry (see [Builder/Validator Sandbox](/getting-started/builder-validator-sandbox)) declares a `required_checks` list per stage profile. Historically these were recorded as *delegated* only — a stage could pass validation without any of its declared checks actually running. `evaluateRequiredChecks(ctx)` closes that: for every check id in `ctx.profile.required_checks`, it looks up an entry in `MECHANICAL_EVALUATORS` and produces a real PASS/FAIL check entry, reading straight from on-disk evidence — the builder contract's signals (`builderContractOk`, `filesModifiedOk`, `testsRunOk`), the stage's canonical artifact JSON (`system_design.json`, `test_report.json`, `threat_model.json`, `compliance_report.json`, etc. — mapped in `STAGE_ARTIFACTS`), or the actual modified file contents.

Notable evaluators:

* **`files_modified_nonempty`** — a stage that declares this check must have actually changed at least one file; an empty `files_modified` array is a no-op, not "done."
* **`no_secrets_introduced`** — scans every modified file (capped at 50 files / 64KB each) against `SECRET_CONTENT_PATTERNS` (AWS keys, GitHub/Slack tokens, PEM blocks, JWTs, hardcoded `password = "..."`-shaped literals). A file beyond the cap or unreadable/oversized fails closed rather than being silently skipped — the check reports it can't verify, it doesn't pretend to have scanned clean.
* **`no_silent_skips`** — scans test/spec files for skip directives (`it.skip(`, `xdescribe`, `@pytest.mark.skip`, `@Disabled`, Go's `t.Skip(`).
* **`high_risks_have_mitigation`**, **`stride_categories_covered`**, **`gaps_have_remediation`**, **`failures_have_root_cause`** — per-entry checks against the stage artifact's own array fields (threats, gaps, failures).

`DELEGATED_SEMANTIC_CHECKS` is deliberately kept as an (empty) exported set: a check id placed there is *never fabricated as PASS and never false-failed* — it stays visibly delegated to specialist judgment. An id that matches **neither** a mechanical evaluator nor the delegated set fails outright with an actionable reason: "provide one, rename to a known check, or accept this FAIL." Nothing required by a profile can pass silently by omission.

## Approval-audit integrity (`approval-audit.js`)

Approval records are a trust boundary, not just data — a record in `approvals.json` unblocks gated work, so every consumer validates before trusting, never the writer alone. This module is the one audited path every gate (required-approval, guardrail-override, destructive-action) shares.

* **`validateApprovalRecord(record, { casing, expectedRunId, signingKey })`** checks a single record: run-level records must use exact uppercase `RUN_APPROVAL_STATUSES` (`APPROVED`/`REJECTED`/`PENDING`/`CONSUMED` — `'approved'` is malformed, not a casing synonym), must carry an `id`, a valid `timestamp`, a non-empty `approver`, and — when `run_id` is stamped — must bind to the `expectedRunId` (cross-run replay rejected). A record whose `source` claims the dashboard path must carry `actor.tokenVerified === true` evidence.
* **`approvalHistoryIssues(history)`** checks a whole per-artifact history, not just the latest record: `approval_no_replay` fails if any record `id` repeats (a spent record re-appended to resurrect it), `approval_ordering_sane` fails if a record's timestamp falls materially before an earlier one in the same append-only file (rewritten or replayed history).
* **`trustedApprovedArtifacts(approvals, { expectedRunId })`** is the *only* set gate decisions may trust: latest-record-wins per artifact, but a malformed latest record poisons the artifact rather than falling back to an earlier valid one — tampering a CONSUMED marker into junk can never resurrect the APPROVED record beneath it.
* **`auditRunApprovals(rawApprovals, { runId, projectRoot, runDir })`** audits an entire run's approvals.json: it first checks the run context itself (safe run id, a real `manifest.json` on disk) — a record for a run that doesn't exist approves nothing — then validates every record, returning `{ valid, rejected }` so a rejection can be surfaced via the pinned `approval_audit_failed` event (`approvalAuditEvent`).

<Info>
  An optional second layer, off by default: setting `RSTACK_APPROVAL_SIGNING_KEY` requires every trust-granting APPROVED record to carry a valid HMAC (`signApprovalRecord` / `verifyApprovalRecordSignature`, timing-safe compare) over its load-bearing fields. On a single-user host where the agent and harness share the same OS user, this is defense-in-depth on top of the real boundary — the destructive-write gate refusing the agent write access to `approvals.json` at all. The signature becomes a true boundary once the key is genuinely out of the agent's reach (CI, a remote approval service, multi-user host).
</Info>

## Try it

Trigger the guardrail path directly from any wired harness by attempting a destructive command without an approval:

```bash theme={null}
rstack-agents guard --tool bash --command "rm -rf dist/"
# exits 2 (blocked) — reason names the category and the exact approval artifact to grant
```

Approve the exact action, then retry:

```text theme={null}
sdlc_approve(artifact="destructive-action:<task_id>", status="APPROVED")
```

Inspect what a run's guardrails currently resolve to:

```bash theme={null}
cat .rstack/rstack.config.json
# { "guardrails": { "maxTaskAttempts": 3 } }
```

A pending guardrail-override or destructive-action approval shows up in `rstack-agents pipeline status` and in the Business Hub's Approvals queue — nothing blocks silently; every block is a named artifact plus an audit-trail event.

## Related

* [Governance Model](/getting-started/governance-model) — the promises this page enforces
* [Builder/Validator Sandbox](/getting-started/builder-validator-sandbox) — the read-only enforcement side (validator context)
* [Approvals & Policy](/reference/approvals) — the full policy/manager-role model referenced by these gates
* [Concepts Overview](/concepts/overview) — how these mechanisms fit the wider lifecycle
