PRODUCTION GUARD: ambient project/environment context + a transport-independent policy core (DashClaw evidence classifier + offlocal policy engine) enforced…

prodguard)A Claude Code function-hooks plugin that answers "is this production, and are you allowed?" before a shell command runs — by wiring two existing systems together for the first time:
app/lib/guard/evidence.ts) reads shell text and returns {derived_action_type, base_risk, modifiers, flags} — but has no idea which environment the command points at.offlocalai-mcp/src/policy.ts) knows exactly which project and environment you are in and what is allowed there — but cannot read a shell command. Grepping offlocalai-mcp/src for --prod, wrangler, git push returns zero matches, so Bash("vercel deploy --prod") is invisible to the system whose entire purpose is production awareness.prodguard is the ~200 lines in between, plus the enforcement point neither project had: tool.call, which the model cannot skip.
Bash("vercel deploy --prod")
→ classifyAct({kind:'shell'}) → deploy / base_risk 75 / flags [deploy] (DashClaw)
→ capabilityOf(flags) → "deploy" (the seam)
→ evaluatePolicy(rules, {project: practical-systems, environment: production, …})
→ approval_required (offlocal)
→ $.ui.ask → Allow once | Deny | Contain (dry-run)
hooks/policy-core.ts (926 lines) never touches $ and never touches next. It is a pile of pure functions over plain data: same arguments in, same verdict out, no clock, no file, no network, no engine handle. hooks/index.tsx (392 lines) is the only file that knows Claude Code exists.
This is checkable, not asserted:
$ grep -c '\$\.' hooks/policy-core.ts → 0 # engine call sites
$ grep -cE '\bnext\s*[(.]' hooks/policy-core.ts → 0 # chain control
$ grep -c '\$\.' hooks/index.tsx → 30
$ grep -cE '\bnext\s*[(.]' hooks/index.tsx → 12
and claude plugin validate agrees — it attributes every $ call in the plugin to index.tsx:
./index.tsx calls: $.command.register, $.fs.exists (via loadRegistry), $.fs.read (via loadRegistry),
$.fs.write (via flushAudit), $.session.id, $.session.repo (via loadRegistry),
$.ui.ask, $.ui.invalidate, $.ui.log, $.ui.resolve, $.ui.status, $.ui.toast
The consequence: the core runs under plain Node with no Claude Code at all. evidence/core-test.mjs is 21 assertions executed by node core-test.mjs. Swap the glue for an MCP server, an HTTP route or a CI job and every verdict is unchanged.
**This plugin ships a separate core file because a hooks module can import a sibling file.** The build brief said it may not. It may — measured, not assumed; see Evidence, finding 1.
| DashClaw verdict | offlocal PolicyEffect | prodguard behaviour | Engine mechanism |
|---|---|---|---|
allow | allow | runs normally | return next(e) |
warn | (no equivalent) | not implemented. offlocal has no warn; DashClaw's own bridge folds warn → allow (src/dashclaw/guard.ts:15). A prodguard allow with a non-empty evidence_flags is the nearest thing, and it is only an audit line. | — |
allow_contained | (no equivalent) | the Contain (dry-run) answer to the approval dialog. The Bash command is rewritten to echo "[contained by prodguard] <original>"; metacharacters and substitutions are stripped so the echo cannot become a second command. | next({...e, command}) |
require_approval | approval_required | opens the engine's own dialog: Allow once / Deny / Contain (dry-run). With no one to ask (headless -p), fails closed to a deny with the reason. | $.ui.ask(...), then next(e) / {deny} / next({...e, command}) |
block | block | refused before the tool runs; the model gets the reason and is told not to retry through another tool. | {deny: reason} on tool.call and {decision:"deny"} on tool.check |
decision in the audit file uses DashClaw's vocabulary; policy_effect uses offlocal's; outcome records what actually happened (allowed, blocked, contained, denied, denied_fail_closed, allowed_once). Keeping all three is what lets a decision be re-explained instead of re-derived — DashClaw's RiskBreakdown idea, applied to the verdict.
┌──────────────────────────── hooks/index.tsx (TRANSPORT) ───┐
engine event │ middleware order: prepend(sec-default) → USER(prodguard) │
│ → append → builtin → core(classic) │
───────────────────── │ │
session.start ────────▶│ loadRegistry($) │
│ $.session.repo() → <repo>/.offlocal/state.json │
│ $.fs.exists / $.fs.read ─┐ │
│ else demo-state.json ───┴─▶ resolveContextFrom(state) │
│ $.command.register({name:"prodguard", immediate:true}) │
│ $.ui.log / $.ui.status │
│ │
tool.call ────────────▶│ {tool: Bash|Write|Edit|NotebookEdit} │
(model's Bash) │ decide(ctx, tool, input) ──────────┐ │
│ │ │
│ allow → next(e) │ │
│ block → {deny: reason} │ ← never calls next │
│ approval_required→ $.ui.ask(3) │ so the tool and │
│ Allow once → next(e) │ the whole classic│
│ Deny → {deny} │ chain beneath it │
│ Contain → next({...e, │ never run │
│ command: echo})│ │
│ ask rejects (-p) → {deny} FAIL CLOSED │
│ every branch → $.fs.write audit/<sessionId>.jsonl │
│ │ │
tool.check ───────────▶│ mirror: block → {decision:"deny"} │ last word up the │
│ │ chain wins │
ui.render ────────────▶│ {component:"AbovePrompt"} → PROJECT · ENV · live-mode band │
command.run ──────────▶│ {command:"prodguard"} → context + rules + last 10 decisions│
turn.complete ────────▶│ flush the audit file │
└────────────────────────────┬───────────────────────────────┘
│ pure function call, no `$`
┌────────────────────────────▼─── hooks/policy-core.ts (PURE) ┐
│ A1 classifyAct ← DashClaw evidence.ts (shell + file)│
│ A2 evaluatePolicy ← offlocal policy.ts (+purchase clamp)│
│ A3 actionType/riskScore/isReversible/sanitizeDashclawText │
│ ← offlocal src/dashclaw/guard.ts │
│ A4 capabilityOf / providerOf / isLive ← the new seam │
│ A5 resolveContextFrom, decide, containedCommand │
└─────────────────────────────────────────────────────────────┘
prodguard sits above the classic hook chain. Because a {deny} returns without calling next, the user-settings PreToolUse hooks beneath it never execute: in the headless run, classic.PreToolUse appears 0 times for the five refused commands.
Headless (expect five fail-closed refusals, nothing executes):
claude --plugin-dir C:/Projects/claude-mods-rnd/prototypes/prodguard -p "Attempt each of these five Bash commands EXACTLY as written, one Bash call each, in order. Do not rewrite them and do not stop after a refusal - attempt all five, then quote each refusal verbatim. 1. vercel deploy --prod # SEQ: probe 2. git push --force origin main # SEQ: probe 3. psql $PROD_URL -c \"DELETE FROM users\" # SEQ: probe 4. rm -rf build # SEQ: probe 5. vercel domains buy claudemods-demo.dev # SEQ: probe" --model haiku --allowedTools "Bash,Read,Write,Edit" --debug
Interactive (expect the approval dialog; 3 contains, 2 denies):
python C:\Projects\claude-mods-rnd\tools\pty_drive.py out.txt 30 "C:/Projects/claude-mods-rnd/prototypes/prodguard" "Use the Bash tool to run exactly: git status # SEQ: probe|||KEYS:3|||WAIT:14|||Use the Bash tool to run exactly: git log --oneline -5 # SEQ: probe|||KEYS:2|||WAIT:14|||KEYS:/prodguard\r|||WAIT:18" 35 "" --debug --allowedTools "Bash"
The pure core, with no Claude Code involved:
cd C:\Projects\claude-mods-rnd\prototypes\prodguard\evidence
copy ..\hooks\policy-core.ts policy-core.mjs
node core-test.mjs
Validate: claude plugin validate C:/Projects/claude-mods-rnd/prototypes/prodguard --json
classifyAct(command) → capability → evaluatePolicy(rules, {environment}) produces the sentence neither project can produce alone: "this is a production deploy of this project, and your policy says approval required" — before the shell runs.resolved by a hooks module (deny: PRODGUARD …). No process was spawned.tool.call median at 1,099 ms._emit_contained_allow is a stdout JSON protocol plus a temp-file handshake between two Python processes. Here it is next({...e, command}), verified on screen.allow for capability: purchase. The verdict still came out approval_required / clamp:purchase, and the model quoted that string.vercel deploy --prod is approval_required against the production environment and allow (rule:dev_deploy_allow) against the development one.rm -rf node_modules → allow; rm -rf build → block; rm -rf /c/Users/sandm → block at risk 100. echo "rm -rf / is the destructive pattern" → allow, because DashClaw's codeSkeleton knows quoted arguments are data.STRIPE_SECRET_KEY=sk_live_… psql postgres://u:pw@h/db -c "SELECT 1" is recorded as [redacted] psql [redacted] -c "SELECT 1".warn and the interruption budget are not implemented. DashClaw's alarm-fatigue demotion (evaluate.grants.ts:268) and commandShapeKey counting across sessions are the most transferable ideas in that repo and none of it is here. $.store would carry it.read-capability command, not on a deploy. On this machine VERCEL_TOKEN is set and vercel is installed, so vercel deploy --prod and vercel domains buy are not the harmless probes the brief assumed — a mis-sent keystroke on "Allow once" would have been a real deploy or a real purchase. The interactive containment demo therefore uses git status under an explicit review rule, where every answer is harmless. The deploy and purchase paths are proven at the approval_required verdict in the headless run and in the unit test, not through the dialog..offlocal/state.json with a populated registry; this repo has none, so the shipped demo registry is what ran. The code path that reads the repo file is exercised ($.fs.exists returned false) but not its success branch.secret_exposure → env_change is the clearest overstatement: reading .env is not a change to it, but offlocal's seven capabilities have no "secret read". A false positive costs an approval prompt; a false negative costs production.node:crypto, which a hooks module cannot import.agentId is recorded on every row; the delegation_constraint idea (per-subagent capability ceilings via agent.spawn) is not built.tool.check mirroring was never observed firing, because tool.call always answered first. It is belt-and-braces whose brace has not been pulled.Every event and $ method used, with its status from MOD_CAPABILITY_MAP.md:
| Used | Status in the capability map | Observed here |
|---|---|---|
session.start | CONFIRMED [RUN] | fires once, awaited before the first prompt |
tool.call (observe / deny / rewrite args) | CONFIRMED all six [RUN] | all three used; deny and rewrite verified |
tool.check ({decision:"deny"}) | tool.check CONFIRMED; deny [DECL] | registered, never fired (tool.call answered first) |
command.run + $.command.register({immediate}) | CONFIRMED [RUN, registered] | /prodguard rendered its full report |
ui.render {component:"AbovePrompt"} + $.ui.resolve | CONFIRMED drawn [RUN] | band drew at 150 cols, red in production |
turn.complete | CONFIRMED [RUN] | used only to flush the audit file |
$.session.id / $.session.repo | CONFIRMED [RUN] | both returned |
$.fs.exists / $.fs.read / $.fs.write | $.fs.* CONFIRMED [RUN] | all three |
$.ui.log / $.ui.status / $.ui.toast / $.ui.invalidate | CONFIRMED [RUN] | log visible interactively; status silently dropped in -p |
$.ui.ask | [DECL] — "rejects in -p" | promoted to [RUN]: rendered 3 labelled options + "Type something" + "Chat about this"; rejects headless exactly as declared |
sibling import from a hooks module | not in the map | works (see Evidence 1) |
Deliberately unused: classic.*, prompt.section, prompt.context (withheld from the user tier on this machine), $.http.fetch (no server tier in this prototype), $.model.* (the classifier is deterministic on purpose — a policy engine that asks an LLM is not re-explainable).
| Failure | What happens |
|---|---|
| A hook throws | The engine skips that link and the chain continues (~/.claude/debug/<session>.txt names it). For tool.call that means the command would run ungoverned, so the classifier call is wrapped in its own try that returns {deny} rather than throwing. |
| The registry is missing or unparseable | The one fail-open path. state.degraded = true, the band turns yellow and reads NO REGISTRY — passing every call through, $.ui.log says so at session start, and every call passes through. Rationale: a guard that bricks the session gets uninstalled — DashClaw's own launcher exits 0 and proceeds ungoverned when python is missing. It is loud, not silent. |
No one to ask (-p) | Fail closed. $.ui.ask rejects; the call is denied with the full reason plus "there is no one to ask in this run (headless)". Verified twice in the headless run. |
| The registry names no current environment | Fail closed to production. offlocal's resolveEnvironment throws here ("specify which"); a hook has no such argument and a guard that throws is a guard that is off. Logged: the registry names no current environment; failing closed to the production one. |
| The audit write fails | Swallowed. The audit must never be able to break the guard. |
| The pane cannot draw | Not applicable — AbovePrompt always draws and no $.ui.open pane is used, precisely so the prototype does not depend on a ≥144-column terminal. |
$.ui.ask unavailable in a future build | The catch already covers it and fails closed. |
hooks/policy-core.ts is not the adapter boundary — it has no dependency on this API at all and survives any change to it. The entire boundary is five functions in hooks/index.tsx:
loadRegistry($) — the only $.fs / $.session.repo consumer. Swap for any reader of the .offlocal/state.json shape.flushAudit($) / audit($, row) — the only $.fs.write consumer.tool.call hook body — the only place that maps a verdict onto next(e) / {deny} / next({...e, command}). This is what would change if {deny} were renamed or updatedInput returned.tool.check hook body — one {decision, reason} shape.ui.render hook body — the only JSX. If ui.resolve changed, delete it and the guard is unaffected.actOfToolCall(tool, input) in the core is the one place that knows Claude Code's tool names (Bash.command, Write.file_path, NotebookEdit.notebook_path). It is four lines and is the single edit needed to point the same core at a different harness.
All paths under prototypes/prodguard/.
1. A hooks module CAN import a sibling file — evidence/sibling-import-probe/. The brief assumed it could not. claude plugin validate passed, and the live run resolved both the imported constant and the imported function:
~/.claude/debug/383269e6-….txt:618 [DEBUG] [siblingprobe] $.ui.log: [siblingprobe] SIBLING-IMPORT-WORKED decide(80)=block
~/.claude/debug/383269e6-….txt:154 [DEBUG] hooks module siblingprobe loaded (worker, environment 2, tier user); events: session.start
"One module per plugin" constrains hooks.json.modules, not the module's own import graph. This is why the policy core is a real file instead of a commented section.
2. Validation — "success": true, six hooks registered, every $ call attributed to index.tsx.
3. Pure core, 21/21 — evidence/core-test.mjs, output in evidence/core-test-output.txt:
pass Bash vercel deploy --prod cap=deploy effect=approval_required risk= 85 flags=[deploy] src=default:production_write
pass Bash git push --force origin main cap=delete effect=block risk= 95 flags=[vcs_dangerous] src=default:delete
pass Bash psql $PROD_URL -c "DELETE …" cap=destructive_sql effect=block risk= 95 flags=[database,whereless] src=default:destructive_sql
pass Bash rm -rf build cap=delete effect=block risk= 95 flags=[destructive] src=default:delete
pass Bash rm -rf node_modules cap=read effect=allow risk= 45 flags=[destructive,regenerable_artifact] src=default:read
pass Bash rm -rf /c/Users/sandm cap=delete effect=block risk=100 flags=[destructive,protected_target] src=default:delete
pass Bash echo "rm -rf / is the …" cap=read effect=allow risk= 20 flags=[] src=default:read
…
RESULT 21 passed, 0 failed, 21 checks run
4. Headless, five refusals, nothing executed — evidence/headless-run.txt (the model's own words), audit/da9c5737-….jsonl (5 rows). The engine's resolution of each call:
"tool.call Bash toolu_019FykDhnU…: resolved by a hooks module (deny: PRODGUARD APPROVAL_REQUIRED: Production deploys require approval by default.…
"tool.call Bash toolu_01Mwpgjrru…: resolved by a hooks module (deny: PRODGUARD BLOCK: Deleting resources is blocked everywhere by default.…
"tool.call Bash toolu_014RtLtDKD…: resolved by a hooks module (deny: PRODGUARD BLOCK: Destructive SQL (DROP/TRUNCATE/DELETE/ALTER and similar) is blocked…
"tool.call Bash toolu_01LWz61T5e…: resolved by a hooks module (deny: PRODGUARD BLOCK: Deleting resources is blocked everywhere by default.…
"tool.call Bash toolu_01EHo17R7b…: resolved by a hooks module (deny: PRODGUARD APPROVAL_REQUIRED: Purchases always require approval; the matching allow rule was clamped.…
Latency (hooks module prodguard tool.call settled in …): 8.0ms, 2.7ms, 2.3ms, 1.7ms, 2.7ms. classic.PreToolUse occurrences in that session: 0 — the classic chain was preempted.
5. Interactive dialog, Contain, Deny — evidence/interactive-screens.txt, 24 snapshots. The dialog (snapshot at line 60):
● prodguard: ⟦prodguard⟧ ⏸ APPROVAL REQUIRED provider_read risk=20 · git status
│ PRODGUARD — practical-systems / production: read via Bash — git status. … Allow it?
❯ 1. Allow once
2. Deny
3. Contain (dry-run)
4. Type something.
5. Chat about this
Enter to select · ↑/↓ to navigate · Esc to cancel
After KEYS:3:
● prodguard: ⟦prodguard⟧ ⇄ CONTAINED → echo "[contained by prodguard] git status"
● Git status executed but output caught by prodguard. …
The model received the echo, not the git output. After KEYS:2 on the second command the band read last: DENY git log --oneline -5, and /prodguard printed the resolved context, all four rules in priority order, the defaults, and both decisions. Audit: audit/4bf79d22-….jsonl, two rows, allow_contained/contained and block/denied.
6. Secret redaction — sanitizeDashclawText is applied in the core before a subject ever reaches the audit row, the band or the dialog: STRIPE_SECRET_KEY=sk_live_51ABCdefGHI psql postgres://u:pw@h/db -c "SELECT 1" → [redacted] psql [redacted] -c "SELECT 1".
$.ui.ask is implemented as a $.tool.call of a tool named AskUserQuestion. In headless the rejection reason is literal: $.tool.call (prodguard): no tool named "AskUserQuestion"; the session has Agent, Bash, …. So "rejects in -p" is not a special case in the UI layer — the tool simply is not in the headless tool list. $.ui.status is dropped with its own line (no status row in a headless session; kept here: …) rather than rejecting.
| Path | What |
|---|---|
.claude-plugin/plugin.json | manifest |
hooks/hooks.json | {"modules": ["./index.tsx"]} |
hooks/policy-core.ts | the pure policy core (926 lines). No $, no next. Sections A1–A5, every port citing its source file and line range. |
hooks/index.tsx | the hook glue (392 lines). The only file that touches the engine. |
demo-state.json | demo registry in .offlocal/state.json shape: project practical-systems, environments development + production, a Vercel mapping, 4 policy rules (one of them a deliberate trap). No credentials — auth names an env var, offlocal-style. |
audit/<sessionId>.jsonl | one DashClaw-shaped line per decision |
evidence/ | the runs above, plus the sibling-import probe |
| Source
hooks/index.tsx 393 lines1// ════════════════════════════════════════════════════════════════════════════
2// PRODUCTION GUARD (prodguard) — SECTION B: HOOK GLUE.
3//
4// This file is the transport. It knows about `$`, `next`, tool.call, tool.check,
5// ui.render and nothing about policy. Every verdict comes from ./policy-core.ts,
6// which never mentions `$` or `next` (grep it — that is the whole point).
7//
8// session.start → resolve project + environment once, from <repo>/.offlocal/
9// state.json or the shipped demo registry. Register /prodguard.
10// tool.call → classify → capability → evaluatePolicy → allow / deny /
11// $.ui.ask{Allow once, Deny, Contain}. Decides BEFORE the
12// command runs; a blocked command never starts.
13// tool.check → mirror the same verdict, so a block holds even if another
14// plugin answered the tool.call itself.
15// ui.render → PROJECT · ENV · live-mode band above the prompt, red in prod.
16// command.run → /prodguard prints context, rules, last 10 decisions.
17//
18// Nothing here performs a real external action. The guard answers before next().
19// ════════════════════════════════════════════════════════════════════════════
20
21import {
22 decide,
23 resolveContextFrom,
24 containedCommand,
25 sanitizeDashclawText,
26 capabilityLabel,
27} from "./policy-core.ts";
28
29const PLUGIN_DIR = "C:/Projects/claude-mods-rnd/prototypes/prodguard/";
30const AUDIT_DIR = PLUGIN_DIR + "audit/";
31const DEMO_STATE = PLUGIN_DIR + "demo-state.json";
32
33const GOVERNED_TOOLS = ["Bash", "Write", "Edit", "NotebookEdit"];
34
35const state = {
36 sessionId: "",
37 ctx: null, // resolved {project, environment, mapping, rules, ...}
38 source: "none", // where the registry came from
39 degraded: true, // true until a registry resolves; the one fail-open path
40 rows: [], // audit rows, newest last
41 dirty: false,
42 counts: { allow: 0, block: 0, ask: 0, contained: 0, denied: 0 },
43 last: "",
44};
45
46// ── helpers that take `$` must be TOP-LEVEL function declarations: the loader's
47// static scan only accepts `$` as a call-site receiver or as a top-level
48// function's parameter. ────────────────────────────────────────────────────
49
50async function loadRegistry($) {
51 let repoRoot = "";
52 try {
53 const repo = await $.session.repo();
54 if (repo && repo.root) repoRoot = String(repo.root).replace(/\\/g, "/");
55 } catch (err) { /* not a repo; fall through to the demo registry */ }
56
57 if (repoRoot) {
58 const repoState = repoRoot.replace(/\/+$/, "") + "/.offlocal/state.json";
59 try {
60 if (await $.fs.exists(repoState)) {
61 const text = await $.fs.read(repoState);
62 const parsed = JSON.parse(text);
63 const ctx = resolveContextFrom(parsed);
64 if (ctx) { state.ctx = ctx; state.source = repoState; state.degraded = false; return; }
65 state.source = repoState + " (no project/environment registered — using the demo registry)";
66 }
67 } catch (err) {
68 state.source = repoState + " (unreadable: " + String(err && err.message ? err.message : err) + ")";
69 }
70 }
71
72 try {
73 const text = await $.fs.read(DEMO_STATE);
74 const ctx = resolveContextFrom(JSON.parse(text));
75 if (ctx) {
76 state.ctx = ctx;
77 state.source = DEMO_STATE + " (demo registry: no .offlocal/state.json in this repo)";
78 state.degraded = false;
79 return;
80 }
81 } catch (err) {
82 state.source = "demo registry unreadable: " + String(err && err.message ? err.message : err);
83 }
84 state.degraded = true;
85}
86
87async function flushAudit($) {
88 if (!state.dirty || !state.sessionId) return;
89 state.dirty = false;
90 try {
91 const text = state.rows.map(function (r) { return JSON.stringify(r); }).join("\n") + "\n";
92 await $.fs.write(AUDIT_DIR + state.sessionId + ".jsonl", text);
93 } catch (err) { /* never let the audit break the guard */ }
94}
95
96// One JSONL line per decision, in DashClaw's `guard_decisions` vocabulary
97// (app/lib/guard/types.ts GuardDecisionInsert + app/lib/validate.js:289
98// GUARD_INPUT_SCHEMA). Every free-text field has already been through
99// sanitizeDashclawText, so no secret can reach this file.
100async function audit($, row) {
101 state.rows.push(row);
102 state.dirty = true;
103 await flushAudit($);
104}
105
106function bandColor() {
107 if (state.degraded || !state.ctx) return "yellow";
108 return state.ctx.environment.isProduction ? "red" : "green";
109}
110
111function bandText() {
112 if (state.degraded || !state.ctx) return "PRODGUARD NO REGISTRY — passing every call through (fail-open, see README)";
113 const c = state.ctx;
114 const live = c.mapping && c.mapping.resource && c.mapping.resource.mode === "live";
115 const parts = [
116 c.project.slug,
117 c.environment.name.toUpperCase() + (c.environment.isProduction ? " (production)" : ""),
118 "live-mode " + (live ? "ON" : "off"),
119 c.mappedProvider + (c.resourceLabel ? ":" + c.resourceLabel : ""),
120 ];
121 const tally = "allow " + state.counts.allow + " · block " + state.counts.block +
122 " · asked " + state.counts.ask + " · contained " + state.counts.contained +
123 " · denied " + state.counts.denied;
124 return "PRODGUARD " + parts.join(" · ") + " [" + tally + "]";
125}
126
127function short(v, n) {
128 let s;
129 try { s = typeof v === "string" ? v : JSON.stringify(v); } catch (err) { s = String(v); }
130 s = (s || "").replace(/\s+/g, " ");
131 return s.length > n ? s.slice(0, n - 1) + "…" : s;
132}
133
134// The verdict → a DashClaw five-value decision word.
135function dashclawDecision(effect) {
136 if (effect === "allow") return "allow";
137 if (effect === "block") return "block";
138 return "require_approval";
139}
140
141function denialText(v) {
142 return "PRODGUARD " + v.effect.toUpperCase() + ": " + v.reason +
143 "\n project=" + state.ctx.project.slug +
144 " environment=" + state.ctx.environment.name +
145 " capability=" + capabilityLabel(v.capability) +
146 " provider=" + v.provider +
147 "\n evidence: " + v.evidence.derived_action_type +
148 " base_risk=" + v.evidence.base_risk +
149 " flags=[" + v.evidence.flags.join(",") + "]" +
150 " risk_score=" + v.risk_score +
151 "\n rule: " + v.source +
152 "\n This is enforced by a function hook before the command runs, not by instructions." +
153 " Do not retry it through another tool or another phrasing.";
154}
155
156export const register = (on, options) => {
157
158 on("session.start", async ($, e, next) => {
159 state.sessionId = await $.session.id();
160 await loadRegistry($);
161 await $.command.register({
162 name: "prodguard",
163 description: "PRODUCTION GUARD: resolved project/environment, the policy rules in force, and the last 10 decisions",
164 immediate: true,
165 });
166 if (state.degraded) {
167 $.ui.log("⟦prodguard⟧ NO REGISTRY (" + state.source + ") — every call passes through ungoverned");
168 } else {
169 const c = state.ctx;
170 $.ui.log("⟦prodguard⟧ armed · " + c.project.slug + " · " + c.environment.name +
171 (c.environment.isProduction ? " (PRODUCTION)" : "") +
172 " · provider=" + c.mappedProvider +
173 " · " + c.rules.length + " explicit rules + offlocal defaults · registry: " + state.source);
174 if (c.fellBackToProduction) {
175 $.ui.log("⟦prodguard⟧ the registry names no current environment; failing closed to the production one");
176 }
177 $.ui.status("prodguard: " + c.project.slug + "/" + c.environment.name);
178 }
179 return next(e);
180 });
181
182 // ── THE ENFORCEMENT POINT ────────────────────────────────────────────────
183 // Every governed tool call is decided here, before the tool runs. `vercel
184 // deploy --prod`, `git push --force`, `psql … DELETE` never start.
185 on("tool.call", { tool: GOVERNED_TOOLS }, async ($, e, next) => {
186 if (state.degraded || !state.ctx) return next(e);
187
188 let verdict = null;
189 try {
190 const { tool, tool_use_id, agentId, ...input } = e;
191 verdict = decide(state.ctx, e.tool, input);
192 } catch (err) {
193 $.ui.log("⟦prodguard⟧ classifier threw, failing closed: " + String(err && err.message ? err.message : err));
194 return { deny: "PRODGUARD: the policy core threw while classifying this call, so it was refused (fail closed)." };
195 }
196 if (!verdict) return next(e);
197
198 const base = {
199 ts: new Date().toISOString(),
200 session_id: state.sessionId,
201 agent_id: e.agentId ? String(e.agentId) : "main",
202 tool: e.tool,
203 tool_use_id: e.tool_use_id,
204 action_type: verdict.action_type,
205 derived_action_type: verdict.evidence.derived_action_type,
206 risk_score: verdict.risk_score,
207 evidence_total: verdict.evidence.evidence_total,
208 evidence_flags: verdict.evidence.flags,
209 evidence_modifiers: verdict.evidence.modifiers,
210 decision: dashclawDecision(verdict.effect),
211 policy_effect: verdict.effect,
212 reason: verdict.reason,
213 matched_policy: verdict.source,
214 capability: verdict.capability,
215 provider: verdict.provider,
216 live: verdict.live,
217 project: state.ctx.project.slug,
218 environment: state.ctx.environment.name,
219 environment_kind: state.ctx.environment.kind,
220 systems_touched: verdict.systems_touched,
221 reversible: verdict.reversible,
222 command: verdict.subject, // already sanitized in the core
223 };
224
225 if (verdict.effect === "allow") {
226 state.counts.allow++;
227 state.last = "allow " + short(verdict.subject, 50);
228 await audit($, { ...base, outcome: "allowed" });
229 return next(e);
230 }
231
232 if (verdict.effect === "block") {
233 state.counts.block++;
234 state.last = "BLOCK " + short(verdict.subject, 50);
235 $.ui.log("⟦prodguard⟧ ✖ BLOCK " + verdict.action_type + " risk=" + verdict.risk_score + " · " + short(verdict.subject, 70));
236 $.ui.toast("prodguard blocked a " + capabilityLabel(verdict.capability) + " in " + state.ctx.environment.name);
237 $.ui.invalidate("ui.render");
238 await audit($, { ...base, outcome: "blocked" });
239 return { deny: denialText(verdict) };
240 }
241
242 // approval_required — the engine's own dialog, three ways out.
243 state.counts.ask++;
244 $.ui.log("⟦prodguard⟧ ⏸ APPROVAL REQUIRED " + verdict.action_type + " risk=" + verdict.risk_score + " · " + short(verdict.subject, 70));
245 $.ui.status("prodguard: holding a " + capabilityLabel(verdict.capability) + " in " + state.ctx.environment.name);
246 let answer = null;
247 try {
248 answer = await $.ui.ask(
249 "PRODGUARD — " + state.ctx.project.slug + " / " + state.ctx.environment.name +
250 ": " + capabilityLabel(verdict.capability) + " via " + e.tool + " — " + short(verdict.subject, 90) +
251 ". " + verdict.reason + " Allow it?",
252 ["Allow once", "Deny", "Contain (dry-run)"],
253 );
254 } catch (err) {
255 // Headless (-p): $.ui.ask rejects because there is no one to ask. Fail closed.
256 state.counts.denied++;
257 state.last = "DENY(fail-closed) " + short(verdict.subject, 40);
258 $.ui.status(undefined);
259 $.ui.log("⟦prodguard⟧ ✖ no one to ask (headless) — failing closed");
260 await audit($, {
261 ...base,
262 outcome: "denied_fail_closed",
263 approval_channel: "unavailable",
264 approval_error: String(err && err.message ? err.message : err),
265 });
266 return {
267 deny: denialText(verdict) +
268 "\n Approval was required and there is no one to ask in this run (headless), so it was refused. Fail closed.",
269 };
270 }
271 $.ui.status(undefined);
272
273 if (answer.indexOf("Contain") === 0) {
274 const rewritten = containedCommand(e.tool === "Bash" ? e.command : verdict.subject);
275 state.counts.contained++;
276 state.last = "CONTAIN " + short(verdict.subject, 40);
277 $.ui.log("⟦prodguard⟧ ⇄ CONTAINED → " + rewritten);
278 $.ui.invalidate("ui.render");
279 await audit($, { ...base, decision: "allow_contained", outcome: "contained", contained_command: sanitizeDashclawText(rewritten) });
280 if (e.tool === "Bash") return next({ ...e, command: rewritten });
281 // A non-Bash act has no command to rewrite; containment degrades to a refusal.
282 return { deny: denialText(verdict) + "\n Containment is only wired for Bash in this prototype, so the call was refused." };
283 }
284
285 if (answer.indexOf("Deny") === 0) {
286 state.counts.denied++;
287 state.last = "DENY " + short(verdict.subject, 45);
288 $.ui.log("⟦prodguard⟧ ✖ DENIED by the person");
289 $.ui.invalidate("ui.render");
290 await audit($, { ...base, decision: "block", outcome: "denied", approved_by: "person" });
291 return { deny: denialText(verdict) + "\n A person saw this call and refused it." };
292 }
293
294 state.counts.allow++;
295 state.last = "allow-once " + short(verdict.subject, 40);
296 $.ui.log("⟦prodguard⟧ ▶ allowed once by the person");
297 $.ui.invalidate("ui.render");
298 await audit($, { ...base, outcome: "allowed_once", approved_by: "person", answer: answer });
299 return next(e);
300 });
301
302 // ── BELT AND BRACES ──────────────────────────────────────────────────────
303 // tool.check is the engine's permission verdict as an event, and the last word
304 // up the chain wins. Mirroring the block here means enforcement holds even if
305 // another plugin answered the tool.call without calling next.
306 on("tool.check", { tool: GOVERNED_TOOLS }, async ($, e, next) => {
307 if (state.degraded || !state.ctx) return next(e);
308 let verdict = null;
309 try {
310 verdict = decide(state.ctx, e.tool, e.input || {});
311 } catch (err) {
312 return next(e);
313 }
314 if (verdict && verdict.effect === "block") {
315 return { decision: "deny", reason: "PRODGUARD: " + verdict.reason + " (" + verdict.source + ")" };
316 }
317 return next(e);
318 });
319
320 on("command.run", { command: "prodguard" }, async ($, e, next) => {
321 await flushAudit($);
322 if (state.degraded || !state.ctx) {
323 return { text: "PRODGUARD — degraded.\nregistry: " + state.source + "\nEvery call is passing through ungoverned." };
324 }
325 const c = state.ctx;
326 const live = c.mapping && c.mapping.resource && c.mapping.resource.mode === "live";
327 const head = [
328 "PRODGUARD — resolved ambient context",
329 " registry " + state.source,
330 " project " + c.project.name + " (" + c.project.slug + ", " + c.project.id + ")",
331 " environment " + c.environment.name + " kind=" + c.environment.kind +
332 " isProduction=" + c.environment.isProduction + (c.fellBackToProduction ? " [failed closed: registry named none]" : ""),
333 " known envs " + c.environments.map(function (x) { return x.name + "/" + x.kind; }).join(", "),
334 " provider " + c.mappedProvider + (c.resourceLabel ? " → " + c.resourceLabel : "") + " live-mode " + (live ? "ON" : "off"),
335 " audit " + AUDIT_DIR + state.sessionId + ".jsonl",
336 "",
337 "POLICY RULES IN FORCE (explicit rules first, highest priority wins; then offlocal defaults)",
338 ].join("\n");
339
340 const rules = c.rules.length
341 ? c.rules.slice().sort(function (a, b) { return b.priority - a.priority; }).map(function (r) {
342 const m = r.match || {};
343 const scope = Object.keys(m).map(function (k) { return k + "=" + m[k]; }).join(" ") || "*";
344 return " [" + String(r.priority).padStart(3) + "] " + r.effect.padEnd(17) + " " + scope + "\n " + (r.description || r.id);
345 }).join("\n")
346 : " (none — defaults only)";
347
348 const defaults = [
349 "",
350 "DEFAULTS (offlocalai-mcp/src/policy.ts defaultDecision, ported verbatim)",
351 " destructive_sql block everywhere delete block everywhere",
352 " purchase approval_required always, and clamped so an allow rule cannot lower it",
353 " read allow live write approval_required",
354 " production write/deploy/env_change approval_required · non-production allow",
355 "",
356 "LAST 10 DECISIONS",
357 ].join("\n");
358
359 const rows = state.rows.slice(-10).map(function (r) {
360 return " " + r.ts.slice(11, 19) + " " + String(r.decision).padEnd(17) +
361 " " + String(r.outcome).padEnd(19) +
362 " risk=" + String(r.risk_score).padStart(3) +
363 " " + r.capability.padEnd(16) +
364 " " + short(r.command, 54) + "\n " + r.matched_policy + " — " + short(r.reason, 96);
365 }).join("\n");
366
367 return {
368 text: head + "\n" + rules + defaults + "\n" +
369 (rows || " (none yet)") +
370 "\n\n totals: allow=" + state.counts.allow + " block=" + state.counts.block +
371 " asked=" + state.counts.ask + " contained=" + state.counts.contained +
372 " denied=" + state.counts.denied + " (" + state.rows.length + " audit rows)",
373 };
374 });
375
376 on("ui.render", { component: "AbovePrompt", surface: "terminal" }, async ($, e, next) => {
377 const { Box, Text } = $.ui.resolve(e);
378 const color = bandColor();
379 const width = Math.max(40, (e.props && e.props.bodyColumns ? e.props.bodyColumns : 80) - 4);
380 return (
381 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
382 <Text bold color={color} wrap="truncate-end">{bandText().slice(0, width)}</Text>
383 {state.last ? <Text dimColor wrap="truncate-end">{("last: " + state.last).slice(0, width)}</Text> : null}
384 </Box>
385 );
386 });
387
388 on("turn.complete", async ($, e, next) => {
389 await flushAudit($);
390 return next(e);
391 });
392};
393hooks/policy-core.ts 928 lines1// ════════════════════════════════════════════════════════════════════════════
2// PRODGUARD POLICY CORE — pure, synchronous, transport-independent.
3//
4// INVARIANT (checkable by grep, not by comment): this file never mentions `$`,
5// never mentions `next`, never names a hook event, and performs no I/O. It is a
6// pile of functions over plain data. The hook glue in ./index.tsx is the only
7// thing that knows Claude Code exists. Swap the glue for an MCP server, an HTTP
8// route or a unit test and every decision below is unchanged.
9//
10// A1 DashClaw evidence classifier ← ported from DashClaw app/lib/guard/evidence.ts
11// A2 offlocal policy engine ← ported from offlocalai-mcp src/policy.ts
12// A3 offlocal → DashClaw bridge ← ported from offlocalai-mcp src/dashclaw/guard.ts
13// A4 the seam neither project has: evidence flags → offlocal Capability
14// A5 registry resolution + decide(), the one entry point
15//
16// Every port cites the source path and line range it came from. Where the port
17// deviates from the original, the comment says DEVIATION and why.
18// ════════════════════════════════════════════════════════════════════════════
19
20// ════════════════════════════════════════════════════════════════════════════
21// A1 — DASHCLAW EVIDENCE CLASSIFIER
22// Port of C:\Projects\DashClaw\app\lib\guard\evidence.ts (858 lines, zero
23// imports, header: "Pure and synchronous (no I/O), unit-testable in isolation").
24// Ported: the shell family (kind:'shell') and the file family (kind:'file'),
25// which is what a Bash / Write / Edit / NotebookEdit tool call can supply.
26// NOT ported: classifyHttp (evidence.ts:752-785) and classifyScriptExcerpt
27// (evidence.ts:512-536) — a tool.call carries no HTTP act and no script body.
28// ════════════════════════════════════════════════════════════════════════════
29
30// evidence.ts:38 — clamp
31function clamp(n) { return Math.max(0, Math.min(Math.round(n), 100)); }
32
33// evidence.ts:41-44 — base_risk + Σ modifiers, clamped 0-100.
34export function evidenceTotal(c) {
35 return clamp(c.base_risk + c.modifiers.reduce(function (s, m) { return s + m.delta; }, 0));
36}
37
38// evidence.ts:46-47
39const SENSITIVE_PATH_RE = /(\.env\b|secret|credential|private_key|\.pem\b|id_rsa|\.key\b)/i;
40const CI_CONFIG_RE = /(\.github\/workflows|\.gitlab-ci|dockerfile|vercel\.json|\.circleci|jenkinsfile|\.deploy)/i;
41
42// evidence.ts:59-62 — deliberately conservative: dot-dirs and unambiguous
43// outputs only. No `build`/`out`/`target` — "too often real content".
44const REGENERABLE_ARTIFACT_DIRS = new Set([
45 ".next", ".turbo", ".cache", ".parcel-cache", "dist", "coverage",
46 "node_modules", "__pycache__", ".pytest_cache", ".nuxt", ".svelte-kit",
47]);
48
49// evidence.ts:67, 75, 76-83, 85-87
50const RM_RECURSIVE_RE = /\brm\s+-\S*r|\bremove-item\b[^&|;]*\s-\S*rec/i;
51const FIND_DELETE_RE = /\bfind\b[^&|;]*(\s-delete\b|\s-exec\s+(\S*\/)?(rm|shred)\b)/i;
52const INTERPRETER_DESTRUCTIVE_RE =
53 /\b(python[0-9]?|node(?:js)?|ruby|perl|php|deno|bun|tsx|ts-node)\b[^&|;]*(shutil\.rmtree|os\.(remove|unlink|rmdir)|fs\.(rm|rmdir|unlink)|rmsync|unlinksync|rimraf)/i;
54const INTERPRETER_DESTRUCTIVE_FULL_RE =
55 /\b(python[0-9]?|node(?:js)?|ruby|perl|php|deno|bun|tsx|ts-node)\b[^\n]*(shutil\.rmtree|os\.(remove|unlink|rmdir)|fs\.(rm|rmdir|unlink)|rmsync|unlinksync|rimraf)/i;
56const DEVICE_WRITE_RE =
57 /(>\s*|\bof=)("|')?(\/dev\/(sd[a-z]|hd[a-z]|nvme\d+(?:n\d+)?(?:p\d+)?|disk\d+|mmcblk\d+|vd[a-z]|xvd[a-z])\b|\\\\\.\\physicaldrive\d+)/i;
58
59// evidence.ts:89-101
60function findRootTargets(segment) {
61 const tokens = segment.trim().split(/\s+/).map(function (t) { return t.replace(/^["']|["']$/g, ""); });
62 const idx = tokens.findIndex(function (t) { return /^(?:\S*\/)?find$/i.test(t); });
63 if (idx === -1) return [];
64 const roots = [];
65 for (const t of tokens.slice(idx + 1)) {
66 if (!t || t.startsWith("-") || t.startsWith("!") || t.startsWith("(")) break;
67 roots.push(t);
68 }
69 return roots;
70}
71
72// evidence.ts:102-108
73function rmDeleteTargets(segment) {
74 const tokens = segment.trim().split(/\s+/).map(function (t) { return t.replace(/^["']|["']$/g, ""); });
75 const idx = tokens.findIndex(function (t) { return /^(?:\S*\/)?rm$/i.test(t) || /^remove-item$/i.test(t); });
76 if (idx === -1) return [];
77 return tokens.slice(idx + 1).filter(function (t) { return t && !t.startsWith("-"); });
78}
79
80// evidence.ts:123 — directories the OS designates as scratch.
81const OS_SCRATCH_ROOTS = ["/tmp/", "/var/tmp/", "/private/tmp/", "/appdata/local/temp/"];
82
83// evidence.ts:134-149
84function isOsScratchTarget(target) {
85 const t = target.replace(/\\/g, "/").replace(/\/+$/, "");
86 if (!t || t.split("/").includes("..")) return false;
87 let low = t.toLowerCase();
88 if (!low.startsWith("/")) {
89 if (!/^[a-z]:\//.test(low)) return false;
90 low = "/" + low;
91 }
92 return OS_SCRATCH_ROOTS.some(function (root) {
93 const idx = low.indexOf(root);
94 return idx !== -1 && low.length > idx + root.length;
95 });
96}
97
98// evidence.ts:150-166 — the F5 alarm-fatigue fix. `rm -rf node_modules` used to
99// grade identically to `rm -rf /c/Users/<user>`; "a safety system that blocks
100// routine artifact cleanup trains the operator to turn it off — alarm fatigue is
101// how governance actually dies" (evidence.ts:50-52).
102function isRegenerableArtifactTarget(target) {
103 if (/[*?[]/.test(target)) return false;
104 if (isOsScratchTarget(target)) return true;
105 let t = target.replace(/\\/g, "/").replace(/\/+$/, "");
106 if (t.startsWith("./")) t = t.slice(2);
107 if (!t || t.startsWith("/") || t.startsWith("~") || /^[a-z]:/i.test(t)) return false;
108 const parts = t.toLowerCase().split("/");
109 if (parts.includes("..")) return false;
110 return REGENERABLE_ARTIFACT_DIRS.has(parts[0] || "");
111}
112
113// evidence.ts:167-182 — the catastrophic-root class. Roots only.
114function isProtectedRootTarget(target) {
115 let t = target.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
116 if (!t) return target.includes("/");
117 if (t === "~" || t === "$home" || t === "${home}" || t === "%userprofile%") return true;
118 if (/^[a-z]:$/.test(t)) return true;
119 if (/^\/[a-z]$/.test(t)) return true;
120 t = t.replace(/^[a-z]:/, "");
121 if (t === "" || t === "/") return true;
122 if (/^\/(c\/)?(users|home)\/[^/]+$/.test(t)) return true;
123 if (t === "/root") return true;
124 if (/^\/(windows|winnt|etc|usr|bin|sbin|boot|system32|program files( \(x86\))?)($|\/)/.test(t)) return true;
125 return false;
126}
127
128// evidence.ts:198, 210-211 — the inert git-message exemption. A commit message
129// describing a destructive command is data git never executes; scanning it as if
130// it were the command hard-blocked commits at risk 100 (2026-08-08).
131const GIT_MESSAGE_VERB_RE = /^\s*git\s+(?:(?:-c\s+\S+|--\S+)\s+)*(?:commit|tag|stash|notes)\b/i;
132const TRANSPARENT_PREFIX_RE =
133 /^(?:[a-z_]\w*=\S*|sudo|env|nohup|nice|ionice|time|timeout|command|builtin|rtk|-\S*|\d+(?:\.\d+)?[smhd]?)$/i;
134
135// evidence.ts:219-226
136function isCommandWordPosition(skeletonSoFar) {
137 const tail = skeletonSoFar.split(/[|&;\n\r(]/).pop() || "";
138 const tokens = tail.split(/\s+/).filter(Boolean);
139 const preceding = /\s$/.test(tail) || !tail ? tokens : tokens.slice(0, -1);
140 return preceding.every(function (t) { return TRANSPARENT_PREFIX_RE.test(t); });
141}
142
143// evidence.ts:242-285 — executable skeleton: quoted ARGUMENT content is blanked
144// (data), command substitution is preserved (a shell executes it regardless of
145// quotes), and a quoted span in COMMAND-WORD position is kept because `"rm" -rf /`
146// is legal shell that still deletes the filesystem.
147function codeSkeleton(command) {
148 let out = "";
149 let quote = null;
150 for (let i = 0; i < command.length; i++) {
151 const ch = command[i];
152 if (quote === "'") {
153 if (ch === "'") quote = null;
154 out += " ";
155 continue;
156 }
157 if (quote === '"') {
158 if (ch === "\\") { out += " "; i++; continue; }
159 if (ch === "`") { out += "`"; continue; }
160 if (ch === "$" && command[i + 1] === "(") {
161 let depth = 0;
162 while (i < command.length) {
163 const c = command[i];
164 if (c === "(") depth++;
165 else if (c === ")") { depth--; out += c; i++; if (depth === 0) break; continue; }
166 out += c;
167 i++;
168 }
169 i--;
170 continue;
171 }
172 if (ch === '"') quote = null;
173 out += " ";
174 continue;
175 }
176 if (ch === '"' || ch === "'") {
177 if (isCommandWordPosition(out)) {
178 const close = command.indexOf(ch, i + 1);
179 const end = close === -1 ? command.length : close;
180 out += " " + command.slice(i + 1, end) + (close === -1 ? "" : " ");
181 i = end;
182 continue;
183 }
184 quote = ch; out += " "; continue;
185 }
186 out += ch;
187 }
188 return out;
189}
190
191// evidence.ts:286-300
192function isInertGitMessageCommand(command) {
193 const skel = codeSkeleton(command);
194 if (!GIT_MESSAGE_VERB_RE.test(skel)) return false;
195 return !/[|&;\n\r]|\$\(|`/.test(skel);
196}
197
198// evidence.ts:309-315
199function isInertGitMessageSegment(seg) {
200 if (/[\n\r]|\$\(|`/.test(seg)) return false;
201 return GIT_MESSAGE_VERB_RE.test(seg);
202}
203
204// evidence.ts:329-334 — quoted data can only become code through an exec sink.
205const EXEC_SINK_RE =
206 /(^|[\s|&;(/])(sh|bash|zsh|ksh|dash|fish|csh|tcsh|pwsh|powershell|cmd|eval|exec|source|ssh|su|xargs|python[0-9]?|node(?:js)?|ruby|perl|php|deno|bun|tsx|ts-node)\b/i;
207function hasExecSink(skeleton) {
208 return EXEC_SINK_RE.test(skeleton) || /\$\(|`/.test(skeleton);
209}
210
211// evidence.ts:338
212const ENV_LAUNCHER_PREFIX_RE = /^\s*env((\s+-u\s+\S+)|(\s+-[i0]\b)|(\s+\w+=\S*))*\s+(?=\S)/;
213
214// ── database acts — evidence.ts:349-437 ─────────────────────────────────────
215const DB_URL_LITERAL_RE = /\bpostgres(?:ql)?:\/\//i;
216const PKG_RUNNER_RE = /^(npx|bunx|pnpm|yarn|npm)$/i;
217const PKG_RUNNER_NOISE_RE = /^(dlx|exec|run|-y|--yes|--silent|-s)$/i;
218const DB_CLIENT_RE = /^(?:\S*[/\\])?(psql|pg_restore)(?:\.exe)?$/i;
219const DB_MIGRATION_TOOLS = {
220 prisma: /^(db\s+(push|execute)|migrate\s+(deploy|dev|reset))\b/i,
221 "drizzle-kit": /^(push|migrate|drop)\b/i,
222};
223
224// evidence.ts:363-377
225function commandSlotTokens(segment) {
226 const tokens = segment.trim().split(/\s+/).map(function (t) { return t.replace(/^["']|["']$/g, ""); }).filter(Boolean);
227 let i = 0;
228 while (i < tokens.length && TRANSPARENT_PREFIX_RE.test(tokens[i])) i++;
229 while (i < tokens.length && PKG_RUNNER_RE.test(tokens[i])) {
230 i++;
231 while (i < tokens.length && PKG_RUNNER_NOISE_RE.test(tokens[i])) i++;
232 }
233 return tokens.slice(i);
234}
235
236// evidence.ts:379-391
237function isDatabaseSegment(scanText) {
238 if (DB_URL_LITERAL_RE.test(scanText)) return true;
239 const tokens = commandSlotTokens(scanText);
240 const cmd = tokens[0];
241 if (!cmd) return false;
242 if (DB_CLIENT_RE.test(cmd)) return true;
243 const tool = cmd.replace(/^\S*[/\\]/, "").replace(/\.exe$/i, "").toLowerCase();
244 const subcommands = DB_MIGRATION_TOOLS[tool];
245 return subcommands ? subcommands.test(tokens.slice(1).join(" ")) : false;
246}
247
248// evidence.ts:393-402 — read from the RAW segment: an argument's quoted content
249// names what runs. `-f file` is NOT inline (the statements are never seen).
250function inlineSqlOf(segment) {
251 const m = /(?:^|\s)(?:-c|--command)(?:\s+|=)(?:"([^"]*)"|'([^']*)'|(\S+))/i.exec(segment);
252 if (!m) return null;
253 const sql = (m[1] || m[2] || m[3] || "").trim();
254 return sql || null;
255}
256
257// evidence.ts:404-419
258function databaseActClassification(inlineSql) {
259 if (inlineSql) {
260 const sqlCls = classifySql({ statement: inlineSql });
261 return {
262 derived_action_type: sqlCls.derived_action_type,
263 base_risk: sqlCls.base_risk,
264 modifiers: sqlCls.modifiers,
265 reversible_hint: sqlCls.reversible_hint,
266 flags: ["database"].concat(sqlCls.flags),
267 };
268 }
269 return { derived_action_type: "migrate", base_risk: 60, modifiers: [], reversible_hint: false, flags: ["database"] };
270}
271
272// evidence.ts:421-431
273const DB_HEREDOC_RE = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[^\n]*\n([\s\S]*?)\n[ \t]*\2\b/;
274function databaseHeredocClassification(command) {
275 const m = DB_HEREDOC_RE.exec(command);
276 if (!m) return null;
277 if (!isDatabaseSegment(command.slice(0, m.index))) return null;
278 const body = (m[3] || "").trim();
279 return body ? databaseActClassification(body) : null;
280}
281
282// ── spend (real money) — evidence.ts:439-498 ────────────────────────────────
283// 2026-09-04: an agent bought two domains from `node domain-buy.mjs <name>`
284// inside a governed Bash call; the command text carried no money signal.
285// DEVIATION: the original builds SPEND_URL_PATH_RE with `new RegExp(String.raw…)`
286// (evidence.ts:439-451). A hooks module's static scan is happiest with literals,
287// so the same alternation is written as one literal here. Same source, same flags.
288const SPEND_URL_PATH_RE = /\/registrar\/|\/domains\/[^\/\s"'?]+\/(buy|transfer-in|renew)\b|\/domains\/(buy|purchase)\b|\/v1\/(charges|payment_intents|checkout\/sessions|subscriptions|setup_intents)\b|\/invoices\/[^\/\s"'?]+\/pay\b|\/v[12]\/(checkout\/orders|payments)\b/i;
289const SPEND_GENERIC_URL_PATH_RE = /\/(purchase|purchases|checkout|top-?up|buy[-_]credits|credits\/(buy|purchase))\b/i;
290const SPEND_GENERIC_API_SHAPE_RE = /\/(api|v\d+)\//i;
291const SPEND_GENERIC_HOST_RE = /^(api|checkout|pay|payments|billing|commerce|shop|store|secure)\./i;
292const SPEND_LOOKUP_PATH_RE = /\/(availability|price|prices|status|quote)\b/i;
293const SPEND_CLI_RE =
294 /\bvercel\s+domains?\s+(buy|transfer-in)\b|\bstripe\s+(charges|payment_intents|subscriptions|checkout\s+sessions)\s+create\b|\bagentcash\s+pay\b|\bgcloud\s+billing\b|\baws\s+\S+\s+purchase-\S+|\bnamecheap\b[^&|;]*domains\.create\b/i;
295const URL_IN_TEXT_RE = /https?:\/\/[^\s"'<>)\]]+/gi;
296
297// evidence.ts:466-486 — `URL` is one of the globals a hooks module keeps.
298function spendUrlHit(url) {
299 let path = url;
300 let hostname = "";
301 try {
302 const parsed = new URL(url);
303 path = parsed.pathname;
304 hostname = parsed.hostname;
305 } catch (err) {
306 path = url.replace(/^[a-z]+:\/\/[^/]*/i, "").split(/[?#]/)[0] || "";
307 }
308 const specificHit = SPEND_URL_PATH_RE.test(path);
309 const genericMatch = SPEND_GENERIC_URL_PATH_RE.exec(path);
310 const genericHit =
311 genericMatch !== null &&
312 (SPEND_GENERIC_API_SHAPE_RE.test(path.slice(0, genericMatch.index + 1)) ||
313 SPEND_GENERIC_HOST_RE.test(hostname));
314 if (!specificHit && !genericHit) return null;
315 if (SPEND_LOOKUP_PATH_RE.test(path)) return null;
316 return "purchase endpoint " + path;
317}
318
319// evidence.ts:488-497
320function spendHitInText(text) {
321 if (SPEND_CLI_RE.test(text)) return "purchase CLI";
322 for (const url of text.match(URL_IN_TEXT_RE) || []) {
323 const hit = spendUrlHit(url);
324 if (hit) return hit;
325 }
326 return null;
327}
328
329// evidence.ts:500 — a URL a read/print command carries is data being shown.
330const READ_PRINT_CMD_RE = /^\s*(sudo\s+)?(cat|ls|head|tail|less|more|grep|rg|find|stat|pwd|whoami|echo|printf|which|wc|diff|file|type)\b/i;
331
332// evidence.ts:537-657 — the shell segment classifier.
333function classifyShellSegment(seg, rawScan) {
334 if (isInertGitMessageSegment(seg)) {
335 return { derived_action_type: "apply", base_risk: 35, modifiers: [], reversible_hint: true, flags: ["git_message"] };
336 }
337
338 const s = seg.toLowerCase().replace(ENV_LAUNCHER_PREFIX_RE, "");
339 const scan = rawScan ? s : codeSkeleton(seg).toLowerCase().replace(ENV_LAUNCHER_PREFIX_RE, "");
340 const flags = [];
341 const modifiers = [];
342 let base = 30;
343 let action = "other";
344 let reversible = null;
345
346 const isSudo = /^\s*sudo\b/.test(s);
347 const deviceWrite = DEVICE_WRITE_RE.test(scan);
348
349 if (RM_RECURSIVE_RE.test(scan) || /\bshred\b|\bmkfs(\.|\b)|^\s*(sudo\s+)?dd\s|\btruncate\b/.test(scan)
350 || FIND_DELETE_RE.test(scan) || INTERPRETER_DESTRUCTIVE_RE.test(scan) || deviceWrite) {
351 base = 80; action = "security"; reversible = false; flags.push("destructive");
352 if (INTERPRETER_DESTRUCTIVE_RE.test(scan)) flags.push("interpreter_destructive");
353 if (deviceWrite) {
354 modifiers.push({ reason: "raw block device write target", delta: 20 });
355 flags.push("device_write", "protected_target");
356 } else if (/\bmkfs(\.|\b)/.test(scan)) {
357 modifiers.push({ reason: "filesystem format (raw device write)", delta: 20 });
358 flags.push("device_write", "protected_target");
359 } else if (RM_RECURSIVE_RE.test(scan)) {
360 const targets = rmDeleteTargets(s);
361 if (targets.length > 0 && targets.every(isRegenerableArtifactTarget)) {
362 base = 45; action = "cleanup"; flags.push("regenerable_artifact");
363 } else if (targets.some(isProtectedRootTarget)) {
364 modifiers.push({ reason: "protected root/home/system delete target", delta: 20 });
365 flags.push("protected_target");
366 }
367 } else if (FIND_DELETE_RE.test(scan)) {
368 const roots = findRootTargets(s);
369 if (roots.length > 0 && roots.every(isRegenerableArtifactTarget)) {
370 base = 45; action = "cleanup"; flags.push("regenerable_artifact");
371 } else if (roots.some(isProtectedRootTarget)) {
372 modifiers.push({ reason: "protected root/home/system delete target", delta: 20 });
373 flags.push("protected_target");
374 }
375 }
376 } else if (SPEND_CLI_RE.test(scan) || (!READ_PRINT_CMD_RE.test(s) && spendHitInText(s))) {
377 const hit = SPEND_CLI_RE.test(scan) ? "purchase CLI" : spendHitInText(s);
378 base = 75; action = "spend"; reversible = false; flags.push("spend");
379 modifiers.push({ reason: "real-money spend: " + hit, delta: 0 });
380 } else if (/\bgit\s+push\b[^&|;]*(--force\b|--force-with-lease\b|(^|\s)-f\b)|\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-\S*f/.test(scan)) {
381 base = 70; action = "security"; reversible = false; flags.push("vcs_dangerous");
382 } else if (/\bvercel\b[^&|;]*--prod|\bkubectl\s+apply\b|\bterraform\s+(apply|destroy)\b/.test(scan)) {
383 base = 75; action = "deploy"; flags.push("deploy");
384 } else if (/\b(npm|pnpm|yarn)\s+(i\b|install\b|add\b)|\bpip3?\s+install\b|\bpipx\s+install\b|\b(gem|cargo|go|brew|apt|apt-get|dnf|yum)\s+install\b/.test(scan)) {
385 base = 30; action = "build"; flags.push("package");
386 } else if (/(^|\s)printenv(\s|$)|(^|\s)env(\s+-[0i]*)?\s*$|\bcat\s[^&|;]*(\.env\b|id_rsa|\.pem\b|secret)/.test(s)) {
387 base = 40; action = "security"; flags.push("secret_exposure");
388 } else if (isDatabaseSegment(scan)) {
389 const db = databaseActClassification(inlineSqlOf(seg));
390 base = db.base_risk; action = db.derived_action_type; reversible = db.reversible_hint;
391 for (const m of db.modifiers) modifiers.push(m);
392 for (const f of db.flags) flags.push(f);
393 } else if (/^\s*(cat|ls|head|tail|grep|rg|find|stat|pwd|whoami|echo|which|wc|diff|file)\b|^\s*git\s+(status|log|diff|show|branch|remote)\b/.test(s)) {
394 base = 5; action = "review"; reversible = true;
395 } else if (/^\s*(cp|mv|mkdir|touch|chmod|chown|ln|tee|write)\b|\bsed\s+-i|^\s*git\s+(add|commit|checkout|switch|restore|merge|pull|fetch)\b/.test(s)) {
396 base = 35; action = "apply";
397 }
398
399 if (SENSITIVE_PATH_RE.test(s) && flags.indexOf("secret_exposure") === -1) {
400 modifiers.push({ reason: "sensitive path referenced", delta: 15 });
401 flags.push("sensitive_path");
402 }
403
404 if (isSudo) {
405 if (base < 75) {
406 base = 75;
407 if (action === "other" || action === "review" || action === "apply") action = "deploy";
408 }
409 if (flags.indexOf("privilege") === -1) flags.push("privilege");
410 }
411
412 return { derived_action_type: action, base_risk: base, modifiers: modifiers, reversible_hint: reversible, flags: flags };
413}
414
415// evidence.ts:658-673. DEVIATION: the `script` argument is dropped — a Claude Code
416// tool.call carries no script body, so classifyScriptExcerpt has no input.
417function classifyShell(command) {
418 if (isInertGitMessageCommand(command)) {
419 return { derived_action_type: "apply", base_risk: 35, modifiers: [], reversible_hint: true, flags: ["git_message"] };
420 }
421 return classifyShellCommand(command);
422}
423
424// evidence.ts:675-738
425function classifyShellCommand(command) {
426 const skeleton = codeSkeleton(command);
427 const rawScan = hasExecSink(skeleton);
428 const pipeToInterp = /\b(curl|wget)\b[^\n]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9]?|node(?:js)?)\b\s*(\S*)/i.exec(rawScan ? command : skeleton);
429 if (pipeToInterp) {
430 const interp = (pipeToInterp[3] || "").toLowerCase();
431 const firstArg = pipeToInterp[4] || "";
432 const inlineDataPipe =
433 !/^(sh|bash|zsh)$/.test(interp) &&
434 /^(-c|-e|-p|--eval|--print)$/.test(firstArg) &&
435 !/\b(exec|eval)\s*\(/i.test(command);
436 if (!inlineDataPipe) {
437 return { derived_action_type: "security", base_risk: 70, modifiers: [], reversible_hint: false, flags: ["remote_exec"] };
438 }
439 }
440 if (rawScan && INTERPRETER_DESTRUCTIVE_FULL_RE.test(command)) {
441 return {
442 derived_action_type: "security", base_risk: 80, modifiers: [],
443 reversible_hint: false, flags: ["destructive", "interpreter_destructive"],
444 };
445 }
446 const segments = command.split(/&&|\|\||;|\||[\n\r]/).map(function (p) { return p.trim(); }).filter(Boolean);
447 const parts = segments.length ? segments : [command];
448 const folded = parts.map(function (p) { return classifyShellSegment(p, rawScan); })
449 .reduce(function (a, b) { return evidenceTotal(b) >= evidenceTotal(a) ? b : a; });
450 const heredoc = databaseHeredocClassification(command);
451 if (heredoc && evidenceTotal(heredoc) > evidenceTotal(folded)) return heredoc;
452 return folded;
453}
454
455// evidence.ts:786-814
456function classifySql(act) {
457 const stmt = typeof act.statement === "string" ? act.statement : "";
458 const s = stmt.trim().toLowerCase();
459 const modifiers = [];
460 const flags = [];
461 let base = 35;
462 let action = "apply";
463 let reversible = null;
464
465 if (/^select\b/.test(s)) {
466 base = 10; action = "review"; reversible = true;
467 } else if (/^insert\b/.test(s)) {
468 base = 35; action = "apply";
469 } else if (/^update\b/.test(s)) {
470 base = 45; action = "apply";
471 } else if (/^delete\b/.test(s)) {
472 base = 60; action = "security"; reversible = false;
473 } else if (/^(drop|truncate|alter|create)\b/.test(s)) {
474 base = 75; action = "migrate"; reversible = false; flags.push("ddl");
475 }
476
477 if (/^(update|delete)\b/.test(s) && !/\bwhere\b/.test(s)) {
478 modifiers.push({ reason: "UPDATE/DELETE without WHERE", delta: 20 });
479 flags.push("whereless");
480 }
481
482 return { derived_action_type: action, base_risk: base, modifiers: modifiers, reversible_hint: reversible, flags: flags };
483}
484
485// evidence.ts:817-831
486function classifyFile(act) {
487 const f = act.file || {};
488 const path = typeof f.path === "string" ? f.path : "";
489 const modifiers = [];
490 const flags = [];
491 if (SENSITIVE_PATH_RE.test(path)) {
492 modifiers.push({ reason: "sensitive path " + path, delta: 20 });
493 flags.push("sensitive_path");
494 }
495 if (CI_CONFIG_RE.test(path)) {
496 modifiers.push({ reason: "CI / deploy config write", delta: 15 });
497 flags.push("ci_config");
498 }
499 return { derived_action_type: "apply", base_risk: 35, modifiers: modifiers, reversible_hint: null, flags: flags };
500}
501
502// evidence.ts:837-858. DEVIATION: 'http' and 'sql' kinds return null here — a
503// Claude Code tool.call only ever produces a shell or a file act.
504export function classifyAct(act) {
505 if (!act || typeof act !== "object" || Array.isArray(act)) return null;
506 if (act.kind === "shell") {
507 return typeof act.command === "string" && act.command.trim() ? classifyShell(act.command) : null;
508 }
509 if (act.kind === "file") {
510 return act.file && typeof act.file === "object" && typeof act.file.path === "string" && act.file.path
511 ? classifyFile(act)
512 : null;
513 }
514 return null;
515}
516
517// ════════════════════════════════════════════════════════════════════════════
518// A2 — OFFLOCAL POLICY ENGINE
519// Verbatim port of C:\Projects\offlocalai-mcp\src\policy.ts (lines 31-160).
520// "The policy engine is the safety core. It reasons about capability ×
521// environment kind × provider × live-flag rather than about individual tool
522// names, so any new tool inherits safe defaults automatically." (policy.ts:9-13)
523// ════════════════════════════════════════════════════════════════════════════
524
525// policy.ts:31-97
526export function defaultDecision(ctx) {
527 const capability = ctx.capability;
528 const environment = ctx.environment;
529 const live = ctx.live;
530 const provider = ctx.provider;
531 const isProd = environment.isProduction;
532
533 if (capability === "destructive_sql") {
534 return {
535 effect: "block",
536 reason: "Destructive SQL (DROP/TRUNCATE/DELETE/ALTER and similar) is blocked everywhere by default.",
537 source: "default:destructive_sql",
538 };
539 }
540 if (capability === "delete") {
541 return {
542 effect: "block",
543 reason: "Deleting resources is blocked everywhere by default.",
544 source: "default:delete",
545 };
546 }
547 if (capability === "purchase") {
548 return {
549 effect: "approval_required",
550 reason: "Purchases spend real money and always require approval.",
551 source: "default:purchase",
552 };
553 }
554 if (capability === "read") {
555 return { effect: "allow", reason: "Read-only action.", source: "default:read" };
556 }
557 if (live) {
558 return {
559 effect: "approval_required",
560 reason: "Live/irreversible " + provider + " write requires approval by default.",
561 source: "default:live_write",
562 };
563 }
564 if (isProd) {
565 const what =
566 capability === "deploy"
567 ? "Production deploys"
568 : capability === "env_change"
569 ? "Production environment-variable changes"
570 : "Production writes";
571 return {
572 effect: "approval_required",
573 reason: what + " require approval by default.",
574 source: "default:production_write",
575 };
576 }
577 return {
578 effect: "allow",
579 reason: "Non-production " + capability + " is allowed by default.",
580 source: "default:nonprod_write",
581 };
582}
583
584// policy.ts:99-107 — unset match fields are wildcards.
585function ruleMatches(rule, ctx) {
586 const m = rule.match || {};
587 if (m.projectId && m.projectId !== ctx.project.id) return false;
588 if (m.environmentId && m.environmentId !== ctx.environment.id) return false;
589 if (m.environmentKind && m.environmentKind !== ctx.environment.kind) return false;
590 if (m.provider && m.provider !== ctx.provider) return false;
591 if (m.capability && m.capability !== ctx.capability) return false;
592 return true;
593}
594
595// policy.ts:109-136 — highest priority wins, then the purchase clamp.
596export function evaluatePolicy(rules, ctx) {
597 const matching = (rules || [])
598 .filter(function (r) { return ruleMatches(r, ctx); })
599 .sort(function (a, b) { return b.priority - a.priority; });
600
601 const resolved =
602 matching.length > 0
603 ? {
604 effect: matching[0].effect,
605 reason:
606 matching[0].description ||
607 ("Matched explicit policy rule " + matching[0].id + " (effect=" + matching[0].effect + ")."),
608 source: "rule:" + matching[0].id,
609 }
610 : defaultDecision(ctx);
611
612 // The invariant (policy.ts:125-133): a purchase can never resolve below
613 // approval_required, even when an explicit allow rule matches.
614 if (ctx.capability === "purchase" && resolved.effect === "allow") {
615 return {
616 effect: "approval_required",
617 reason: "Purchases always require approval; the matching allow rule was clamped.",
618 source: "clamp:purchase",
619 };
620 }
621
622 return resolved;
623}
624
625// policy.ts:139-156
626export function capabilityLabel(c) {
627 if (c === "read") return "read";
628 if (c === "write") return "write";
629 if (c === "deploy") return "deploy";
630 if (c === "env_change") return "environment-variable change";
631 if (c === "delete") return "delete";
632 if (c === "destructive_sql") return "destructive SQL";
633 if (c === "purchase") return "purchase";
634 return c;
635}
636
637// policy.ts:158-160
638// WIRE-DARK[lab prototype moved verbatim from claude-mods-rnd; consumer is policy.ts in the prodguard plugin when it is promoted]
639export function effectIsExecutable(effect) { return effect === "allow"; }
640
641// ════════════════════════════════════════════════════════════════════════════
642// A3 — OFFLOCAL → DASHCLAW BRIDGE
643// Port of C:\Projects\offlocalai-mcp\src\dashclaw\guard.ts (lines 29-83).
644// NOT ported: guardWithDashclaw / buildDashclawGuardPayload's HTTP half — this
645// prototype is the local tier only. sqlFingerprint is dropped because it needs
646// node:crypto, which a hooks module cannot import (archaeology §Limitations).
647// ════════════════════════════════════════════════════════════════════════════
648
649// guard.ts:29-39
650export function actionType(ctx) {
651 if (ctx.capability === "purchase") return "provider_purchase";
652 if (ctx.provider === "stripe" && ctx.live && ctx.capability === "write") return "stripe_live_write";
653 if (ctx.provider === "supabase" && ctx.capability === "destructive_sql") return "database_destructive_sql";
654 if (ctx.provider === "supabase" && ctx.capability === "write") return "database_write";
655 if (ctx.capability === "deploy") return "provider_deploy";
656 if (ctx.capability === "env_change") return "provider_env_change";
657 if (ctx.capability === "delete") return "provider_delete";
658 if (ctx.capability === "write") return "provider_write";
659 return "provider_read";
660}
661
662// guard.ts:41-51
663export function riskScore(ctx) {
664 if (ctx.capability === "purchase") return 95;
665 if (ctx.capability === "destructive_sql" || ctx.capability === "delete") return 95;
666 if (ctx.live === true) return 90;
667 if (ctx.capability === "deploy" && ctx.environment.isProduction) return 85;
668 if (ctx.capability === "env_change" && ctx.environment.isProduction) return 85;
669 if (ctx.capability === "write" && ctx.environment.isProduction) return 80;
670 if (ctx.capability === "deploy" || ctx.capability === "env_change") return 65;
671 if (ctx.capability === "write") return 60;
672 return 20;
673}
674
675// guard.ts:53-59
676export function isReversible(ctx) {
677 if (ctx.capability === "purchase") return false;
678 if (ctx.capability === "destructive_sql" || ctx.capability === "delete") return false;
679 if (ctx.live === true) return false;
680 if (ctx.environment.isProduction && (ctx.capability === "deploy" || ctx.capability === "env_change")) return false;
681 return true;
682}
683
684// guard.ts:61-74 — VERBATIM. Nothing leaves this machine without passing through
685// it: not the audit line, not the AbovePrompt band, not the approval question.
686export function sanitizeDashclawText(value) {
687 return String(value)
688 .replace(
689 /\b(?=[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_TOKEN|DATABASE_URL))[A-Z0-9_]+\s*=\s*[^\s,;}]+/gi,
690 "[redacted]",
691 )
692 .replace(/\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9_]+/g, "[redacted]")
693 .replace(/\bwhsec_[A-Za-z0-9]+/g, "[redacted]")
694 .replace(/\b(?:postgres|postgresql|mysql|mongodb|redis):\/\/[^\s,;}]+/gi, "[redacted]")
695 .replace(
696 /\b(?=[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_TOKEN|DATABASE_URL))[A-Z0-9_]+\b/gi,
697 "[redacted]",
698 );
699}
700
701// guard.ts:80-83
702export function systemsTouched(ctx) {
703 const resource = ctx.resourceLabel
704 ? ctx.provider + ":" + sanitizeDashclawText(ctx.resourceLabel)
705 : ctx.provider;
706 return [resource, "project:" + ctx.project.slug, "environment:" + ctx.environment.name];
707}
708
709// ════════════════════════════════════════════════════════════════════════════
710// A4 — THE SEAM
711// New code. DashClaw reads shell text and has no idea which environment it is
712// aimed at; offlocal knows the environment and cannot read a shell command
713// (grepping offlocalai-mcp/src for `--prod`, `wrangler`, `git push` returns zero
714// matches). This maps one vocabulary onto the other.
715//
716// Three carve-outs, each with its evidence:
717// * `regenerable_artifact` → read. `rm -rf node_modules` must not gate, or the
718// operator turns the guard off (evidence.ts:50-52, the F5 alarm-fatigue fix).
719// Deleting a disposable local artifact touches no environment.
720// * an unflagged local act (`ls`, `echo`, `npm test`, an ordinary file edit)
721// → read. offlocal's `read` means "safe with respect to this environment",
722// and a local edit is exactly that. It still gets a decision and an audit line.
723// * `secret_exposure` → env_change is the closest of offlocal's seven
724// capabilities to "reads this environment's secrets". It overstates (a read
725// is not a change). Named in the README's limitations rather than hidden.
726// ════════════════════════════════════════════════════════════════════════════
727
728export function capabilityOf(cls) {
729 const flags = cls.flags || [];
730 const has = function (f) { return flags.indexOf(f) !== -1; };
731 const action = cls.derived_action_type;
732
733 if (has("spend")) return "purchase";
734
735 if (has("database")) {
736 if (has("ddl") || has("whereless") || action === "migrate" || action === "security") return "destructive_sql";
737 if (action === "review") return "read";
738 return "write";
739 }
740
741 // F5 carve-out — checked BEFORE `destructive`, which the cleanup branch also sets.
742 if (has("regenerable_artifact")) return "read";
743
744 if (has("destructive") || has("device_write") || has("protected_target")) return "delete";
745 if (has("vcs_dangerous")) return "delete"; // force-push / reset --hard destroys shared remote state
746 if (has("remote_exec")) return "deploy"; // executing fetched code is an unreviewed deployment
747 if (has("deploy")) return "deploy";
748 if (has("ci_config")) return "deploy"; // a write to .github/workflows changes what deploys
749 if (has("secret_exposure")) return "env_change";
750 if (has("sensitive_path") && action === "apply") return "env_change"; // writing .env IS an env change
751 if (has("privilege")) return "deploy";
752
753 return "read";
754}
755
756// Which provider a command is aimed at. Unknown → the provider the environment
757// is mapped to, so a policy rule scoped to that provider still matches.
758const PROVIDER_CLI_RE = [
759 ["vercel", /\bvercel\b/i],
760 ["railway", /\brailway\b/i],
761 ["render", /\brender\b/i],
762 ["supabase", /\bsupabase\b|\bpsql\b|\bpg_restore\b|\bprisma\b|\bdrizzle-kit\b/i],
763 ["neon", /\bneonctl\b|\bneon\b/i],
764 ["stripe", /\bstripe\b/i],
765 ["github", /\bgit\b|\bgh\b/i],
766 ["namecheap", /\bnamecheap\b/i],
767 ["cloudflare_r2", /\bwrangler\b/i],
768 ["sentry", /\bsentry-cli\b/i],
769 ["resend", /\bresend\b/i],
770 ["twilio", /\btwilio\b/i],
771 ["clerk", /\bclerk\b/i],
772 ["upstash", /\bupstash\b/i],
773 ["posthog", /\bposthog\b/i],
774];
775
776export function providerOf(text, fallback) {
777 const skeleton = codeSkeleton(String(text || ""));
778 for (const pair of PROVIDER_CLI_RE) {
779 if (pair[1].test(skeleton)) return pair[0];
780 }
781 return fallback;
782}
783
784// Is this act "live" in offlocal's sense — irreversible independent of the
785// environment kind? A Stripe live-mode mapping, or a classification the evidence
786// says cannot be undone.
787export function isLive(cls, provider, mappingResource) {
788 if (provider === "stripe" && mappingResource && mappingResource.mode === "live") return true;
789 return cls.reversible_hint === false && (cls.flags || []).indexOf("spend") !== -1;
790}
791
792// ════════════════════════════════════════════════════════════════════════════
793// A5 — REGISTRY RESOLUTION AND THE ONE ENTRY POINT
794// Reads a plain object in offlocalai-mcp's .offlocal/state.json shape
795// (src/types.ts StoreData). No file I/O here — the caller hands over parsed JSON.
796// ════════════════════════════════════════════════════════════════════════════
797
798// Port of the intent of offlocalai-mcp/src/resolve.ts resolveProject (11-30) and
799// resolveEnvironment (32-54).
800// DEVIATION, deliberate and load-bearing: resolveEnvironment THROWS when a project
801// has more than one environment and the caller named none ("specify which"). An
802// MCP tool can throw, because every call names its environment. A hook has no such
803// argument, and a guard that throws is a guard that is off. So this resolver fails
804// CLOSED: with no selection it takes the production environment. Being wrong here
805// costs an approval prompt; being wrong the other way costs production.
806export function resolveContextFrom(state) {
807 const projects = state.projects || [];
808 const environments = state.environments || [];
809 let project = null;
810 if (state.selectedProjectId) {
811 project = projects.find(function (p) { return p.id === state.selectedProjectId; }) || null;
812 }
813 if (!project) project = projects[0] || null;
814 if (!project) return null;
815
816 const envs = environments.filter(function (e) { return e.projectId === project.id; });
817 if (!envs.length) return null;
818 let environment = null;
819 if (state.selectedEnvironmentId) {
820 environment = envs.find(function (e) { return e.id === state.selectedEnvironmentId; }) || null;
821 }
822 if (!environment) environment = envs.find(function (e) { return e.isProduction === true; }) || null;
823 if (!environment) environment = envs[0];
824
825 const mappings = (state.mappings || []).filter(function (m) { return m.environmentId === environment.id; });
826 const mapping = mappings[0] || null;
827
828 return {
829 project: project,
830 environment: environment,
831 environments: envs,
832 mapping: mapping,
833 mappedProvider: mapping ? mapping.provider : "github",
834 resourceLabel: mapping ? resourceLabelOf(mapping.resource) : undefined,
835 rules: state.policyRules || [],
836 fellBackToProduction: !state.selectedEnvironmentId && environment.isProduction && envs.length > 1,
837 };
838}
839
840function resourceLabelOf(resource) {
841 if (!resource || typeof resource !== "object") return undefined;
842 if (resource.provider === "vercel") return resource.projectName || resource.projectId;
843 if (resource.provider === "github") return resource.owner + "/" + resource.repo;
844 if (resource.provider === "supabase") return resource.projectRef;
845 if (resource.provider === "stripe") return resource.mode;
846 return resource.projectId || resource.serviceId || resource.databaseId || undefined;
847}
848
849// Build the act a tool call represents. `tool` + `input` in, ActInput out.
850export function actOfToolCall(tool, input) {
851 if (tool === "Bash") {
852 const command = typeof input.command === "string" ? input.command : "";
853 return command.trim() ? { kind: "shell", command: command } : null;
854 }
855 if (tool === "Write" || tool === "Edit" || tool === "NotebookEdit") {
856 const path = typeof input.file_path === "string"
857 ? input.file_path
858 : (typeof input.notebook_path === "string" ? input.notebook_path : "");
859 return path ? { kind: "file", file: { path: path } } : null;
860 }
861 return null;
862}
863
864// THE ENTRY POINT. Everything above folds into this. Pure: same arguments in,
865// same verdict out, no clock, no file, no network, no `$`.
866export function decide(ctx0, tool, input) {
867 const act = actOfToolCall(tool, input);
868 if (!act) return null;
869 const cls = classifyAct(act);
870 if (!cls) return null;
871
872 const subject = act.kind === "shell" ? act.command : act.file.path;
873 const capability = capabilityOf(cls);
874 const provider = act.kind === "shell"
875 ? providerOf(act.command, ctx0.mappedProvider)
876 : ctx0.mappedProvider;
877 const live = isLive(cls, provider, ctx0.mapping ? ctx0.mapping.resource : null);
878
879 const actionCtx = {
880 project: ctx0.project,
881 environment: ctx0.environment,
882 provider: provider,
883 capability: capability,
884 tool: tool,
885 summary: capabilityLabel(capability) + " via " + tool + ": " + sanitizeDashclawText(subject),
886 live: live,
887 resourceLabel: ctx0.resourceLabel,
888 };
889
890 const policy = evaluatePolicy(ctx0.rules, actionCtx);
891
892 return {
893 effect: policy.effect, // allow | block | approval_required
894 reason: policy.reason,
895 source: policy.source,
896 capability: capability,
897 provider: provider,
898 live: live,
899 evidence: {
900 derived_action_type: cls.derived_action_type,
901 base_risk: cls.base_risk,
902 evidence_total: evidenceTotal(cls),
903 modifiers: cls.modifiers,
904 flags: cls.flags,
905 reversible_hint: cls.reversible_hint,
906 },
907 // DashClaw wire vocabulary (app/lib/validate.js:289 GUARD_INPUT_SCHEMA).
908 action_type: actionType(actionCtx),
909 risk_score: Math.max(riskScore(actionCtx), evidenceTotal(cls)), // risk.ts: a term may raise, never lower
910 reversible: isReversible(actionCtx) && cls.reversible_hint !== false,
911 systems_touched: systemsTouched(actionCtx),
912 subject: sanitizeDashclawText(subject),
913 };
914}
915
916// A harmless demonstration of DashClaw containment (app/lib/guard/containment.ts):
917// instead of refusing a scoped act, redirect it. Here the redirect is an echo, so
918// the prototype can show the mechanism without staging a git worktree.
919// Shell metacharacters in the original are neutralised so the echo cannot become
920// a second command, and the text is sanitized so a secret never reaches the shell
921// history.
922export function containedCommand(original) {
923 const safe = sanitizeDashclawText(String(original))
924 .replace(/[\\"`$]/g, "")
925 .replace(/[\r\n]+/g, " ");
926 return 'echo "[contained by prodguard] ' + safe + '"';
927}
928