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 realtask_startedevents for the task (countTaskAttempts) and compares against the limit —maxDestructiveTaskAttemptswhen the task is flagged destructive,maxTaskAttemptsotherwise. Once the limit is hit, the task can only be reclaimed if aguardrail-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-reportedexecution.tool_calls/execution.messagesagainstmaxToolCallsPerTask/maxMessagesPerTaskat validate time.
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*.jsonguard-hook config, lockfiles,*.tf)..rstack/is fully protected except a run’s ownruns/<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-placesed -i, etc. (WRITE_COMMAND_VERBS) — soecho forged > .rstack/runs/<id>/approvals.jsonis caught exactly like aWritetool 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 baredestructive-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.
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.Mechanical validator checks (required-checks.js)
The validator registry (see 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 emptyfiles_modifiedarray is a no-op, not “done.”no_secrets_introduced— scans every modified file (capped at 50 files / 64KB each) againstSECRET_CONTENT_PATTERNS(AWS keys, GitHub/Slack tokens, PEM blocks, JWTs, hardcodedpassword = "..."-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’st.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 uppercaseRUN_APPROVAL_STATUSES(APPROVED/REJECTED/PENDING/CONSUMED—'approved'is malformed, not a casing synonym), must carry anid, a validtimestamp, a non-emptyapprover, and — whenrun_idis stamped — must bind to theexpectedRunId(cross-run replay rejected). A record whosesourceclaims the dashboard path must carryactor.tokenVerified === trueevidence.approvalHistoryIssues(history)checks a whole per-artifact history, not just the latest record:approval_no_replayfails if any recordidrepeats (a spent record re-appended to resurrect it),approval_ordering_sanefails 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 realmanifest.jsonon 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 pinnedapproval_audit_failedevent (approvalAuditEvent).
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).Try it
Trigger the guardrail path directly from any wired harness by attempting a destructive command without an approval: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 — the promises this page enforces
- Builder/Validator Sandbox — the read-only enforcement side (validator context)
- Approvals & Policy — the full policy/manager-role model referenced by these gates
- Concepts Overview — how these mechanisms fit the wider lifecycle
