Skip to main content
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 (hasGuardrailOverridetrustedApprovedArtifacts).
  • 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.
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 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).
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:
Approve the exact action, then retry:
Inspect what a run’s guardrails currently resolve to:
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.