SLOPSHOPPER

prodguard

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

newbandguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prodguard
› fix the failing auth test and add an audit log call ● prodguard: ⟦prodguard⟧ NO REGISTRY (demo registry unreadable: ENOENT: no such file C:/Projects/claude-mods-rnd/prototypes/prodguard/demo-state.json) — every call passes through ungoverned ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /prodguard ⎿ prodguard: PRODGUARD — degraded. ⎿ prodguard: registry: demo registry unreadable: ENOENT: no such file C:/Projects/claude-mods-rnd/prototypes/prodguard/demo ⎿ prodguard: Every call is passing through ungoverned. ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ PRODGUARD NO REGISTRY — passing every call through (fail-open, see README) │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ PRODGUARD NO REGISTRY — passing every call through (fail-open, see README) │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

PRODUCTION GUARD (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:

  • DashClaw's evidence classifier (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.
  • offlocal's policy engine (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)

The policy core is transport-independent

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.


Verdict mapping (DashClaw's five ↔ offlocal's three ↔ what prodguard does)

DashClaw verdictofflocal PolicyEffectprodguard behaviourEngine mechanism
allowallowruns normallyreturn 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_approvalapproval_requiredopens 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})
blockblockrefused 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.


Architecture

                         ┌──────────────────────────── 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.


Run it

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


What it proves

  1. The combination works. 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.
  2. Enforcement is not cooperative. Every DashClaw and offlocal surface today (MCP tools, the SDK loop, the skills) depends on the model choosing to call them. Here the model has no say: the verdict is computed from the tool call's own arguments.
  3. A blocked command never starts. The engine logged, for all five headless commands, resolved by a hooks module (deny: PRODGUARD …). No process was spawned.
  4. It is fast. Decision latency per governed call, measured from the engine's own timing line: 8.0 ms cold, then 2.7 / 2.3 / 1.7 / 2.7 ms. The classic DashClaw plugin pays a Node launcher process + a 2,625-line Python parse + one or two HTTPS round trips per call; the capability map measured the production harness's whole-tool.call median at 1,099 ms.
  5. Containment is ten lines. DashClaw's _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.
  6. The purchase clamp survives a hostile rule. The demo registry deliberately ships a priority-99 allow for capability: purchase. The verdict still came out approval_required / clamp:purchase, and the model quoted that string.
  7. The verdict tracks the registry, not a constant. The same vercel deploy --prod is approval_required against the production environment and allow (rule:dev_deploy_allow) against the development one.
  8. Alarm fatigue was designed against. 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.
  9. No secret can reach the audit file. STRIPE_SECRET_KEY=sk_live_… psql postgres://u:pw@h/db -c "SELECT 1" is recorded as [redacted] psql [redacted] -c "SELECT 1".

What it does NOT prove

  • 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.
  • The Bash echo-containment branch was exercised on a 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.
  • Nothing here was proven against a real .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.
  • Capability inference is heuristic and will mis-classify. 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.
  • One session, one machine. No cross-session rate limits, no org kill switch, no approvals inbox, no signed receipts. Ed25519 signing needs node:crypto, which a hooks module cannot import.
  • Subagents are audited but not separately governed. 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.

API assumptions

Every event and $ method used, with its status from MOD_CAPABILITY_MAP.md:

UsedStatus in the capability mapObserved here
session.startCONFIRMED [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.resolveCONFIRMED drawn [RUN]band drew at 150 cols, red in production
turn.completeCONFIRMED [RUN]used only to flush the audit file
$.session.id / $.session.repoCONFIRMED [RUN]both returned
$.fs.exists / $.fs.read / $.fs.write$.fs.* CONFIRMED [RUN]all three
$.ui.log / $.ui.status / $.ui.toast / $.ui.invalidateCONFIRMED [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 modulenot in the mapworks (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 behaviour

FailureWhat happens
A hook throwsThe 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 unparseableThe 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 environmentFail 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 failsSwallowed. The audit must never be able to break the guard.
The pane cannot drawNot 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 buildThe catch already covers it and fails closed.

Migration path if the API changes

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:

  1. loadRegistry($) — the only $.fs / $.session.repo consumer. Swap for any reader of the .offlocal/state.json shape.
  2. flushAudit($) / audit($, row) — the only $.fs.write consumer.
  3. the 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.
  4. the tool.check hook body — one {decision, reason} shape.
  5. the 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.


Evidence

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".

Incidental finding

$.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.


Files

PathWhat
.claude-plugin/plugin.jsonmanifest
hooks/hooks.json{"modules": ["./index.tsx"]}
hooks/policy-core.tsthe pure policy core (926 lines). No $, no next. Sections A1–A5, every port citing its source file and line range.
hooks/index.tsxthe hook glue (392 lines). The only file that touches the engine.
demo-state.jsondemo 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>.jsonlone DashClaw-shaped line per decision
evidence/the runs above, plus the sibling-import probe

Ported from (read-only sources, not modified)

| Source

Source 2 files
hooks/index.tsx 393 lines
1// ════════════════════════════════════════════════════════════════════════════
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};
393
hooks/policy-core.ts 928 lines
1// ════════════════════════════════════════════════════════════════════════════
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