shapeup-sdlc 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.claude/settings.local.example.json +20 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +18 -0
  4. package/.env.shapeup.example +14 -0
  5. package/AGENTS.md +133 -0
  6. package/LICENSE +21 -0
  7. package/README.md +362 -0
  8. package/SECURITY.md +72 -0
  9. package/bin/init.mjs +329 -0
  10. package/commands/build.md +14 -0
  11. package/commands/eval.md +15 -0
  12. package/commands/hammer.md +12 -0
  13. package/commands/orient.md +11 -0
  14. package/commands/qa.md +12 -0
  15. package/commands/retro.md +13 -0
  16. package/commands/scopes.md +14 -0
  17. package/commands/shape.md +12 -0
  18. package/commands/ship.md +53 -0
  19. package/commands/wire.md +11 -0
  20. package/hooks/anti-rationalization.mjs +244 -0
  21. package/hooks/compact-snapshot.mjs +47 -0
  22. package/hooks/gate-deadline.mjs +151 -0
  23. package/hooks/gate-intake.mjs +110 -0
  24. package/hooks/gate-l2.mjs +161 -0
  25. package/hooks/gate-zerowork.mjs +264 -0
  26. package/hooks/hooks.json +118 -0
  27. package/hooks/lib/decision.mjs +183 -0
  28. package/hooks/safety-spine.mjs +296 -0
  29. package/hooks/sandbox-guard.mjs +172 -0
  30. package/hooks/session-rehydrate.mjs +109 -0
  31. package/hooks/slop-cleaner.mjs +176 -0
  32. package/oracles/_shared.mjs +46 -0
  33. package/oracles/http-oracle.mjs +155 -0
  34. package/oracles/index.mjs +36 -0
  35. package/oracles/process-oracle.mjs +146 -0
  36. package/oracles/snapshot-oracle.mjs +119 -0
  37. package/oracles/test-oracle.mjs +138 -0
  38. package/package.json +49 -0
  39. package/skills/advisor-protocol/SKILL.md +171 -0
  40. package/skills/ba-pitch-analyzer/SKILL.md +175 -0
  41. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +84 -0
  42. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +65 -0
  43. package/skills/ba-pitch-analyzer/assets/templates/assess-report.tmpl.md +127 -0
  44. package/skills/ba-pitch-analyzer/assets/templates/contracts/be-service.contract.tmpl.md +62 -0
  45. package/skills/ba-pitch-analyzer/assets/templates/contracts/offline-storage.contract.tmpl.md +92 -0
  46. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +66 -0
  47. package/skills/ba-pitch-analyzer/assets/templates/cross-context/context-map.tmpl.md +64 -0
  48. package/skills/ba-pitch-analyzer/assets/templates/cross-context/event-choreography.tmpl.md +77 -0
  49. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +104 -0
  50. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +79 -0
  51. package/skills/ba-pitch-analyzer/assets/templates/domain-model.tmpl.md +79 -0
  52. package/skills/ba-pitch-analyzer/assets/templates/feedback.tmpl.md +87 -0
  53. package/skills/ba-pitch-analyzer/assets/templates/integration.tmpl.md +64 -0
  54. package/skills/ba-pitch-analyzer/assets/templates/run-state.tmpl.md +77 -0
  55. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +105 -0
  56. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +215 -0
  57. package/skills/ba-pitch-analyzer/assets/templates/task-board.tmpl.md +35 -0
  58. package/skills/ba-pitch-analyzer/assets/templates/task-spike.tmpl.md +86 -0
  59. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +84 -0
  60. package/skills/ba-pitch-analyzer/assets/templates/usecase.tmpl.md +105 -0
  61. package/skills/ba-pitch-analyzer/assets/templates/ux-behavior.tmpl.md +65 -0
  62. package/skills/ba-pitch-analyzer/references/contract-patterns.md +152 -0
  63. package/skills/ba-pitch-analyzer/references/ddd-patterns.md +173 -0
  64. package/skills/ba-pitch-analyzer/references/doc-schemas.md +275 -0
  65. package/skills/ba-pitch-analyzer/references/integration-analysis.md +152 -0
  66. package/skills/ba-pitch-analyzer/references/task-generation.md +623 -0
  67. package/skills/ba-pitch-analyzer/references/test-surface.md +102 -0
  68. package/skills/ba-pitch-analyzer/references/ux-behavior-patterns.md +269 -0
  69. package/skills/ba-pitch-analyzer/scripts/board-derive.mjs +241 -0
  70. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +239 -0
  71. package/skills/coach/SKILL.md +202 -0
  72. package/skills/orient/SKILL.md +262 -0
  73. package/skills/qa-edge-hunter/SKILL.md +369 -0
  74. package/skills/scope-architect/SKILL.md +111 -0
  75. package/skills/scope-hammer/SKILL.md +186 -0
  76. package/skills/shapeup/SKILL.md +403 -0
  77. package/skills/shapeup/resources/breadboard-reflection.md +88 -0
  78. package/skills/shapeup/resources/breadboarding.md +334 -0
  79. package/skills/shapeup/resources/context-compaction.md +240 -0
  80. package/skills/shapeup/resources/framing-doc.md +92 -0
  81. package/skills/shapeup/resources/kickoff-doc.md +95 -0
  82. package/skills/shapeup/resources/shaping.md +194 -0
  83. package/skills/shapeup/resources/spike.md +86 -0
  84. package/skills/solution-architect/SKILL.md +129 -0
  85. package/skills/spec-evaluator/README.md +93 -0
  86. package/skills/spec-evaluator/SKILL.md +213 -0
  87. package/skills/spec-evaluator/references/anti-leniency.md +50 -0
  88. package/skills/spec-evaluator/references/dimension-contract.md +126 -0
  89. package/skills/spec-evaluator/references/dimensions/_registry.md +40 -0
  90. package/skills/spec-evaluator/references/dimensions/completeness.md +102 -0
  91. package/skills/spec-evaluator/references/dimensions/integration.md +129 -0
  92. package/skills/spec-evaluator/references/dimensions/performance.md +48 -0
  93. package/skills/spec-evaluator/references/dimensions/security.md +60 -0
  94. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +134 -0
  95. package/skills/spec-evaluator/references/dimensions/tdd-surface.md +110 -0
  96. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +100 -0
  97. package/skills/spec-evaluator/references/probing.md +177 -0
  98. package/skills/spec-evaluator/references/report-schema.md +101 -0
  99. package/skills/spec-evaluator/references/verdict-ledger.md +92 -0
  100. package/skills/spec-evaluator/scripts/verdict-ledger.mjs +166 -0
  101. package/skills/task-executor/SKILL.md +194 -0
  102. package/skills/tech-lead/README.md +71 -0
  103. package/skills/tech-lead/SKILL.md +459 -0
  104. package/skills/tech-lead/references/delegation.md +254 -0
  105. package/skills/tech-lead/references/gates.md +379 -0
  106. package/skills/tech-lead/references/invocation.md +45 -0
  107. package/skills/tech-lead/references/ledger-schema.md +214 -0
  108. package/skills/tech-lead/references/round-protocol.md +184 -0
  109. package/skills/tech-lead/references/state-model.md +66 -0
  110. package/skills/tech-lead/references/tiny-lane.md +52 -0
  111. package/skills/tech-lead/schemas/domain.schema.json +2294 -0
  112. package/skills/tech-lead/schemas/gate-answers.schema.json +92 -0
  113. package/skills/tech-lead/schemas/work-order.schema.json +21 -0
  114. package/skills/tech-lead/schemas/work-result.schema.json +40 -0
  115. package/skills/tech-lead/scripts/aegis-digest.mjs +124 -0
  116. package/skills/tech-lead/scripts/budget-check.mjs +156 -0
  117. package/skills/tech-lead/scripts/compile-order.mjs +518 -0
  118. package/skills/tech-lead/scripts/fit-check.mjs +196 -0
  119. package/skills/tech-lead/scripts/gate-answers.mjs +338 -0
  120. package/skills/tech-lead/scripts/ingest-result.mjs +270 -0
  121. package/skills/tech-lead/scripts/init-run.mjs +326 -0
  122. package/skills/tech-lead/scripts/lib/argv.mjs +224 -0
  123. package/skills/tech-lead/scripts/lib/contract-md.mjs +481 -0
  124. package/skills/tech-lead/scripts/lib/is-main.mjs +82 -0
  125. package/skills/tech-lead/scripts/lib/paths.mjs +278 -0
  126. package/skills/tech-lead/scripts/lib/ratchet-tree.mjs +112 -0
  127. package/skills/tech-lead/scripts/run-snapshot.mjs +269 -0
  128. package/skills/tech-lead/scripts/ship-report.mjs +275 -0
  129. package/skills/tech-lead/scripts/stats.mjs +423 -0
  130. package/skills/tech-lead/scripts/t0-verify.mjs +470 -0
  131. package/skills/tech-lead/scripts/trace-lint.mjs +367 -0
  132. package/skills/tech-lead/scripts/validate-envelope.mjs +283 -0
  133. package/skills/translator/README.md +66 -0
  134. package/skills/translator/SKILL.md +258 -0
  135. package/skills/translator/references/preservation-rules.md +102 -0
@@ -0,0 +1,183 @@
1
+ // runHook — give `allow` a receipt.
2
+ //
3
+ // WHY THIS FILE EXISTS (reproduced by executing the shipped hooks, not theorized).
4
+ //
5
+ // Every enforcement tool's FAILURE signature was identical to its SUCCESS signature. Fed malformed
6
+ // input, every gate in this repo answered the same way:
7
+ //
8
+ // $ echo 'NOT JSON AT ALL {{{' | node hooks/<gate>.mjs
9
+ // gate-l2 exit=0 stdout_len=0
10
+ // gate-zerowork exit=0 stdout_len=0
11
+ // sandbox-guard exit=0 stdout_len=0
12
+ // safety-spine exit=0 stdout_len=0
13
+ // gate-deadline exit=0 stdout_len=0
14
+ // gate-intake exit=0 stdout_len=0
15
+ // validate-envelope exit=0 stdout_len=0
16
+ //
17
+ // exit 0 + silence = allow. But that is ALSO what "inspected the board and deferred" looks like,
18
+ // and what "no rule matched" looks like, and what a thrown exception looks like, and what F-16
19
+ // looked like — a hook whose entire body silently never ran. Four states, one signature. No test,
20
+ // orchestrator or auditor could tell them apart, which is how 26 enforcement points sat inert
21
+ // behind 610 green checks while every one of them reported success.
22
+ //
23
+ // FAIL-OPEN IS RETAINED, DELIBERATELY. `gate-l2.mjs` argues for it correctly in its own header: a
24
+ // gate that breaks legitimate or standalone runs just gets disabled, and a disabled gate enforces
25
+ // nothing. The defect was never the direction. It is that `allow` carried NO EVIDENCE.
26
+ //
27
+ // THE PREDICATE IS ALREADY INVENTED IN THIS REPO. `tests/structural/11-is-main.mjs` calls its
28
+ // helper `spoke()` — did the script produce output? That is exactly the right question. It existed
29
+ // only in the test harness, applied to entry points. This promotes it to runtime and applies it to
30
+ // hooks, which closes F-16's whole CLASS rather than its instance:
31
+ //
32
+ // inspected-and-permitted · no-rule-matched · threw · never ran
33
+ //
34
+ // all four become distinguishable facts in `.shapeup-sdlc/decisions.jsonl`.
35
+ //
36
+ // TIER: LOCAL, and checkout-wide rather than per-slug — hooks fire outside any run, so there is
37
+ // frequently no `<slug>` to file under. Pure run-trace; the durable cross-machine record is the
38
+ // committed metrics shard, which `stats --hooks` aggregates into.
39
+ //
40
+ // THE RECEIPT IS BEST-EFFORT BY DESIGN. An unwritable `decisions.jsonl` must never turn into a
41
+ // failed tool call: a receipt that can break a run would get the whole layer disabled, which is
42
+ // the exact outcome this file exists to prevent. Every write here is inside a try/catch.
43
+
44
+ import { appendFileSync, mkdirSync } from "node:fs";
45
+ import { join, dirname } from "node:path";
46
+
47
+ /**
48
+ * Where the receipts land.
49
+ *
50
+ * `SHAPEUP_DECISIONS_PATH` redirects the ledger. It exists because this project's OWN test suite
51
+ * executes the real hooks, and without a redirect every `npm test` appended ~21 rows to the
52
+ * developer's live `decisions.jsonl` — which would then be read back by `stats --hooks` as if
53
+ * they were evaluations from a real run. A measurement instrument that its own test suite
54
+ * contaminates is not an instrument.
55
+ *
56
+ * @param {string} [cwd] - Project root; defaults to the process cwd.
57
+ * @returns {string} The ledger path — `SHAPEUP_DECISIONS_PATH` when set, else
58
+ * `<cwd>/.shapeup-sdlc/decisions.jsonl`.
59
+ */
60
+ export function decisionsPath(cwd) {
61
+ return process.env.SHAPEUP_DECISIONS_PATH || join(cwd || process.cwd(), ".shapeup-sdlc", "decisions.jsonl");
62
+ }
63
+
64
+ /**
65
+ * Append one decision row. Never throws.
66
+ * @param {object} row - The decision record (see {@link runHook} for the shape).
67
+ * @param {string} [cwd] - Project root.
68
+ * @returns {boolean} True when the row reached disk.
69
+ */
70
+ export function record(row, cwd) {
71
+ try {
72
+ const path = decisionsPath(cwd);
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ appendFileSync(path, JSON.stringify(row) + "\n");
75
+ return true;
76
+ } catch { return false; }
77
+ }
78
+
79
+ /**
80
+ * Read the raw hook payload from stdin. Hooks that need it before deciding can call this;
81
+ * {@link runHook} does not read stdin itself, so a hook keeps control of its own parsing.
82
+ * @returns {Promise<string>} The stdin text, or "" when stdin is closed or errors.
83
+ */
84
+ export function readStdin() {
85
+ return new Promise((res) => {
86
+ let d = "";
87
+ process.stdin.on("data", (c) => (d += c));
88
+ process.stdin.on("end", () => res(d));
89
+ process.stdin.on("error", () => res(""));
90
+ });
91
+ }
92
+
93
+ /**
94
+ * The sentinel `settle()` throws so a hook can leave from anywhere without a `process.exit` of
95
+ * its own. `runHook` unwraps it back into an ordinary decision.
96
+ */
97
+ export class HookDecision extends Error {
98
+ /**
99
+ * @param {object} decision - The decision record (see {@link runHook}).
100
+ */
101
+ constructor(decision) {
102
+ super(decision.verdict);
103
+ this.name = "HookDecision";
104
+ this.decision = decision;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Settle the hook from anywhere in its body — the shape a `defer()` / `deny()` helper wraps.
110
+ *
111
+ * Hooks are written as a straight-line sequence of "is this even my business?" tests, each of
112
+ * which used to call `process.exit(0)` directly. Throwing instead of exiting means every one of
113
+ * those early outs still passes through the receipt.
114
+ *
115
+ * @param {object} decision - The decision record (see {@link runHook}).
116
+ * @returns {never} Never returns — always throws {@link HookDecision}.
117
+ */
118
+ export function settle(decision) {
119
+ throw new HookDecision(decision);
120
+ }
121
+
122
+ /**
123
+ * The single exit path for every hook.
124
+ *
125
+ * A hook's body becomes a function that RETURNS a decision instead of calling `process.exit`
126
+ * itself. `runHook` records that decision and then exits — so there is exactly one place a hook
127
+ * can leave, and no route out of one that skips the receipt.
128
+ *
129
+ * The decision shape:
130
+ * {
131
+ * verdict: "allow" | "warn" | "deny" | "block" | "error",
132
+ * // "error" is still fail-open, now recorded;
133
+ * // "warn" permits but says so — see below
134
+ * reason: string, // why, in one line
135
+ * rule?: string, // which rule fired, when one did
136
+ * event?: string, tool?: string, subject?: string,// what was being judged
137
+ * payload?: object, // the JSON the host reads
138
+ * emit?: boolean, // print `payload` even on an allow
139
+ * cwd?: string, // where to file the receipt
140
+ * }
141
+ *
142
+ * A body that returns nothing is recorded as `{verdict:"allow", reason:"no rule matched"}` — the
143
+ * commonest case, and previously the one indistinguishable from never having run.
144
+ *
145
+ * @param {string} name - The hook's name, as it appears in `hooks.json`.
146
+ * @param {function(): (object|Promise<object|undefined>|undefined)} fn - The hook body.
147
+ * @returns {Promise<never>} Does not return — always exits 0, per the fail-open contract.
148
+ */
149
+ export async function runHook(name, fn) {
150
+ let d;
151
+ try {
152
+ d = (await fn()) ?? { verdict: "allow", reason: "no rule matched" };
153
+ } catch (e) {
154
+ if (e instanceof HookDecision) d = e.decision;
155
+ // Still fail-open — but now the throw is a FACT on disk instead of the same silence as a
156
+ // clean allow. This is the state that used to be completely unobservable.
157
+ else d = { verdict: "error", reason: String(e?.message ?? e) };
158
+ }
159
+ record({
160
+ at: new Date().toISOString(),
161
+ hook: name,
162
+ pid: process.pid,
163
+ event: d.event ?? null,
164
+ tool: d.tool ?? null,
165
+ subject: d.subject ?? null,
166
+ verdict: d.verdict ?? "allow",
167
+ reason: d.reason ?? null,
168
+ rule: d.rule ?? null,
169
+ }, d.cwd);
170
+ // Deny/block/warn payloads are emitted by definition. `emit: true` covers the hooks whose whole
171
+ // job is to SAY something on an allow — session-rehydrate's additionalContext, for instance — so
172
+ // a permitting hook can still write to stdout without pretending to be a denial.
173
+ //
174
+ // WHY `warn` IS ITS OWN VERDICT (ADR-0001). An advisory gate permits the call, so the obvious
175
+ // encoding is `verdict: "allow"`. That would make "permitted because the rule was satisfied"
176
+ // byte-identical to "permitted DESPITE the rule being broken" — the exact indistinguishability
177
+ // this file exists to eliminate, reintroduced one level up. A gate that downgrades from deny to
178
+ // advisory must stay countable, or `stats --hooks` silently loses the measurement.
179
+ if (d.payload && (d.verdict === "deny" || d.verdict === "block" || d.verdict === "warn" || d.emit)) {
180
+ process.stdout.write(JSON.stringify(d.payload));
181
+ }
182
+ process.exit(0);
183
+ }
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env node
2
+ // Safety spine — PreToolUse hook (v1.2, absorb-audit P1).
3
+ //
4
+ // The three older hooks guard the PIPELINE (gate order, envelopes, substrates); this one
5
+ // guards the MACHINE and the git remote — the substrate everything else sits on. It denies
6
+ // the small set of provably destructive operations no harness round (and no standalone
7
+ // session) ever legitimately needs:
8
+ // • destructive-fs — rm -rf on /, ~, $HOME, .., bare * / ., 1-segment absolute paths;
9
+ // git clean -f with -d/-x and no pathspec
10
+ // • git-destructive — push --force (--force-with-lease stays allowed), explicit push to
11
+ // main/master, git reset --hard (the harness's own rule: stash, never
12
+ // hard-discard — see task-executor round protocol)
13
+ // • sql-destructive — DROP TABLE/DATABASE/SCHEMA, TRUNCATE
14
+ // • secret-read — reader commands (and the Read tool) on .env/*.pem/*.key/ssh keys/
15
+ // cloud credentials
16
+ // • self-protect — any write/move/delete touching .shapeup/safety-overrides.json:
17
+ // the model must never widen its own safety envelope (same principle
18
+ // as sandbox-guard's active-scope pointer rule)
19
+ //
20
+ // Deliberately conservative, mirrors gate-l2/sandbox-guard: fail-OPEN on unparseable input
21
+ // or an unmatched command (precision over reach — a spine that broke `rm -rf ./build` would
22
+ // just get disabled), fail-CLOSED the moment a rule provably matches.
23
+ //
24
+ // Escape hatch: .shapeup/safety-overrides.json (schema: domain.schema.json#/$defs/
25
+ // SafetyOverrides) — human-authored, per-project, auditable. A malformed overrides file is
26
+ // treated as ABSENT: the override channel fails closed so a parse error can never disable
27
+ // the spine. Every exercised override is logged as a SAFETY-OVERRIDE pathology row —
28
+ // overrides stay visible, propose-never-dispose.
29
+ //
30
+ // Contract: PreToolUse stdin JSON { tool_name, tool_input:{ command | file_path | edits[] }, cwd }.
31
+ // Deny via { hookSpecificOutput: { hookEventName, permissionDecision:"deny", permissionDecisionReason } }.
32
+
33
+ import { readFileSync, existsSync } from "node:fs";
34
+ import { resolve, join, basename } from "node:path";
35
+ import { globToRegExp, logPathology } from "./sandbox-guard.mjs";
36
+ import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
37
+ import { LOCAL, safetyOverrides, metricsShard } from "../skills/tech-lead/scripts/lib/paths.mjs";
38
+
39
+ import { runHook, readStdin, settle } from "./lib/decision.mjs";
40
+
41
+ // --- overrides ---------------------------------------------------------------
42
+
43
+ export function loadOverrides(cwd) {
44
+ const p = safetyOverrides(cwd);
45
+ if (!existsSync(p)) return null;
46
+ try {
47
+ const o = JSON.parse(readFileSync(p, "utf8"));
48
+ // Fail closed: anything that isn't a well-formed v1 overrides object is ignored.
49
+ if (o?.schema_version !== 1) return null;
50
+ return {
51
+ allow_commands: Array.isArray(o.allow_commands) ? o.allow_commands : [],
52
+ allow_secret_paths: Array.isArray(o.allow_secret_paths) ? o.allow_secret_paths : [],
53
+ note: typeof o.note === "string" ? o.note : "",
54
+ };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function commandOverridden(cmd, overrides) {
61
+ return (overrides?.allow_commands || []).some((src) => {
62
+ try { return new RegExp(src).test(cmd); } catch { return false; }
63
+ });
64
+ }
65
+
66
+ // --- secret paths --------------------------------------------------------------
67
+
68
+ const SECRET_PATTERNS = [
69
+ /(^|[\\/])\.env(\.[^\\/]+)?$/i, // .env, .env.local, … (example/sample excluded below)
70
+ /\.pem$/i,
71
+ /\.key$/i,
72
+ /(^|[\\/])id_(rsa|ed25519|ecdsa|dsa)$/i, // private keys only — id_rsa.pub is public
73
+ /(^|[\\/])\.ssh[\\/]/,
74
+ /(^|[\\/])\.aws[\\/]credentials$/,
75
+ /(^|[\\/])credentials\.(json|ya?ml)$/i,
76
+ ];
77
+ const SECRET_EXEMPT = /\.(example|sample|template|dist)$/i;
78
+
79
+ export function isSecretPath(path, overrides) {
80
+ if (!path || typeof path !== "string") return false;
81
+ const clean = path.replace(/^['"]|['"]$/g, "");
82
+ if (SECRET_EXEMPT.test(clean)) return false;
83
+ if (!SECRET_PATTERNS.some((re) => re.test(clean))) return false;
84
+ if ((overrides?.allow_secret_paths || []).some((g) => globToRegExp(g).test(clean.replace(/^\.\//, "")))) return false;
85
+ return true;
86
+ }
87
+
88
+ // --- Bash command classification ------------------------------------------------
89
+
90
+ const READER_CMDS = new Set([
91
+ "cat", "head", "tail", "less", "more", "strings", "grep", "rg", "awk", "sed", "cut", "base64", "xxd", "od",
92
+ ]);
93
+
94
+ function tokens(segment) {
95
+ return segment.trim().split(/\s+/).filter(Boolean)
96
+ .map((t) => t.replace(/^['"]|['"]$/g, ""));
97
+ }
98
+
99
+ /** Strip leading env assignments and privilege/no-op wrappers to find the real command. */
100
+ function commandTokens(segment) {
101
+ const ts = tokens(segment);
102
+ let i = 0;
103
+ while (i < ts.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(ts[i]) || ["sudo", "command", "env", "nohup", "time"].includes(ts[i]))) i++;
104
+ return ts.slice(i);
105
+ }
106
+
107
+ function isDangerousRmTarget(t) {
108
+ if (["/", "/*", "~", "~/", "$HOME", "${HOME}", "..", "../", "*", "."].includes(t)) return true;
109
+ // 1-segment absolute path: /usr, /etc, /Volumes … (multi-segment absolutes stay allowed)
110
+ if (t.startsWith("/") && t.replace(/\/+$/, "").split("/").filter(Boolean).length === 1) return true;
111
+ return false;
112
+ }
113
+
114
+ function classifySegment(segment, overrides) {
115
+ const ts = commandTokens(segment);
116
+ if (ts.length === 0) return null;
117
+ const cmd = basename(ts[0]);
118
+
119
+ // destructive-fs: rm with recursive+force on a provably dangerous target
120
+ if (cmd === "rm") {
121
+ const flags = ts.slice(1).filter((t) => t.startsWith("-"));
122
+ const recursive = flags.some((f) => f === "--recursive" || /^-[a-zA-Z]*[rR]/.test(f));
123
+ const force = flags.some((f) => f === "--force" || (/^-[a-zA-Z]*f/.test(f) && !f.startsWith("--")));
124
+ if (recursive && force) {
125
+ const target = ts.slice(1).filter((t) => !t.startsWith("-")).find(isDangerousRmTarget);
126
+ if (target) {
127
+ return { category: "destructive-fs", reason: `\`rm -rf ${target}\` targets a path whose loss is unrecoverable (${target}). Delete a specific project sub-path instead (relative, multi-segment targets like ./build are allowed).` };
128
+ }
129
+ }
130
+ }
131
+
132
+ // destructive-fs: git clean -f with -d/-x and no pathspec (wipes the whole untracked tree)
133
+ if (cmd === "git" && ts[1] === "clean") {
134
+ const rest = ts.slice(2);
135
+ const flags = rest.filter((t) => t.startsWith("-"));
136
+ const paths = rest.filter((t) => !t.startsWith("-"));
137
+ const force = flags.some((f) => /^-[a-zA-Z]*f/.test(f) || f === "--force");
138
+ const wide = flags.some((f) => /^-[a-zA-Z]*[dxX]/.test(f));
139
+ if (force && wide && paths.length === 0) {
140
+ return { category: "destructive-fs", reason: "`git clean -f` with -d/-x and no pathspec wipes every untracked file in the tree — including gitignored run-state. Name an explicit pathspec." };
141
+ }
142
+ }
143
+
144
+ if (cmd === "git") {
145
+ // git-destructive: force push / push to main
146
+ if (ts[1] === "push") {
147
+ const rest = ts.slice(2);
148
+ if (rest.some((t) => (t === "--force" || t === "-f") )) {
149
+ return { category: "git-destructive", reason: "`git push --force` rewrites remote history. Use --force-with-lease if a forced update is genuinely intended (it is allowed)." };
150
+ }
151
+ const nonFlags = rest.filter((t) => !t.startsWith("-"));
152
+ const ref = nonFlags[1] || "";
153
+ if (nonFlags.length >= 2 && (/^(main|master)$/.test(ref) || /:(main|master)$/.test(ref))) {
154
+ return { category: "git-destructive", reason: `\`git push ${nonFlags[0]} ${ref}\` pushes directly to the default branch. Push the working branch and open a PR (plain \`git push\` of the current branch is allowed).` };
155
+ }
156
+ }
157
+ // git-destructive: hard reset
158
+ if (ts[1] === "reset" && ts.includes("--hard")) {
159
+ return { category: "git-destructive", reason: "`git reset --hard` discards uncommitted work irrecoverably. The harness's own round protocol is `git stash push -u` — stash, never hard-discard (`git reset --soft` is allowed)." };
160
+ }
161
+ }
162
+
163
+ // sql-destructive
164
+ if (/\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE(\s+TABLE)?)\b/i.test(segment)) {
165
+ return { category: "sql-destructive", reason: "The command contains a destructive SQL statement (DROP/TRUNCATE). Run schema-destroying SQL yourself, outside the session." };
166
+ }
167
+
168
+ // secret-read: a reader command whose argument is a secret path
169
+ if (READER_CMDS.has(cmd)) {
170
+ const secret = ts.slice(1).filter((t) => !t.startsWith("-")).find((t) => isSecretPath(t, overrides));
171
+ if (secret) {
172
+ return { category: "secret-read", reason: `\`${cmd} ${secret}\` reads a secret file. Secrets must not enter the conversation; if this is a false positive, add the path to ${LOCAL}/safety-overrides.json (allow_secret_paths).` };
173
+ }
174
+ }
175
+
176
+ // self-protect: any write/move/delete naming the overrides file
177
+ if (segment.includes("safety-overrides.json")) {
178
+ const writer =
179
+ /(^|[^>])>>?(?!&)/.test(segment) || // redirection (>, >>) — but not >&2
180
+ /\btee\b/.test(segment) ||
181
+ /\bsed\s+-[a-zA-Z]*i/.test(segment) ||
182
+ ["rm", "mv", "cp", "truncate"].includes(cmd);
183
+ if (writer) {
184
+ return { category: "self-protect", reason: `The safety-overrides file is human-authored only — the session must never widen (or remove) its own safety envelope. Ask the PO to edit ${LOCAL}/safety-overrides.json.` };
185
+ }
186
+ }
187
+
188
+ return null;
189
+ }
190
+
191
+ /** Classify a full Bash command (raw string + each &&/;/|/newline segment).
192
+ * Returns { deny, category, reason } — deny:false means the spine has nothing to say. */
193
+ export function classifyCommand(command, overrides) {
194
+ if (!command || typeof command !== "string") return { deny: false };
195
+ for (const segment of command.split(/\s*(?:\|\||&&|;|\||\n)\s*/).filter(Boolean)) {
196
+ const hit = classifySegment(segment, overrides);
197
+ if (hit) return { deny: true, ...hit };
198
+ }
199
+ return { deny: false };
200
+ }
201
+
202
+ // --- hook entry ------------------------------------------------------------------
203
+
204
+ const HOOK_TOOLS = new Set(["Bash", "Read", "Write", "Edit", "MultiEdit"]);
205
+
206
+ function extractPaths(toolInput) {
207
+ const paths = [];
208
+ if (toolInput?.file_path) paths.push(toolInput.file_path);
209
+ if (Array.isArray(toolInput?.edits)) {
210
+ for (const e of toolInput.edits) if (e?.file_path) paths.push(e.file_path);
211
+ }
212
+ return paths;
213
+ }
214
+
215
+ async function main() {
216
+ await runHook("safety-spine", async () => {
217
+ const raw = await readStdin();
218
+ let p;
219
+ /** Fail-open, with the reason on the record (hooks/lib/decision.mjs). */
220
+ const defer = (reason, rule) => settle({
221
+ verdict: "allow", event: "PreToolUse", tool: p?.tool_name ?? null, cwd: p?.cwd, reason, rule,
222
+ });
223
+ try { p = JSON.parse(raw || "{}"); }
224
+ catch (e) { settle({ verdict: "error", event: "PreToolUse", reason: `unparseable payload: ${e.message}` }); }
225
+
226
+ if (!HOOK_TOOLS.has(p.tool_name)) defer(`${p.tool_name ?? "no tool_name"} is not a guarded tool — out of scope`);
227
+
228
+ const cwd = p.cwd || process.cwd();
229
+ const overrides = loadOverrides(cwd);
230
+ const metricsPath = metricsShard(cwd);
231
+
232
+ const deny = (category, reason, detail) => {
233
+ logPathology(metricsPath, {
234
+ schema_version: 1,
235
+ at: new Date().toISOString(),
236
+ kind: "pathology",
237
+ pathology: "SAFETY",
238
+ category,
239
+ tool: p.tool_name,
240
+ ...detail,
241
+ });
242
+ settle({
243
+ verdict: "deny", event: "PreToolUse", tool: p.tool_name, cwd, rule: category,
244
+ subject: detail?.path ?? detail?.command ?? null, reason,
245
+ payload: {
246
+ hookSpecificOutput: {
247
+ hookEventName: "PreToolUse",
248
+ permissionDecision: "deny",
249
+ permissionDecisionReason: `Safety spine (${category}) — ${reason}`,
250
+ },
251
+ },
252
+ });
253
+ };
254
+
255
+ if (p.tool_name === "Bash") {
256
+ const command = p.tool_input?.command || "";
257
+ const verdict = classifyCommand(command, overrides);
258
+ if (!verdict.deny) defer("command matched no destructive rule — inspected and permitted", "bash-clean");
259
+ if (commandOverridden(command, overrides)) {
260
+ // Exercised override: allowed, but never invisible.
261
+ logPathology(metricsPath, {
262
+ schema_version: 1,
263
+ at: new Date().toISOString(),
264
+ kind: "pathology",
265
+ pathology: "SAFETY-OVERRIDE",
266
+ category: verdict.category,
267
+ tool: "Bash",
268
+ command: command.slice(0, 200),
269
+ note: overrides?.note || "",
270
+ });
271
+ defer(`${verdict.category} allowed by a human-authored override — permitted, never invisible`, "override");
272
+ }
273
+ deny(verdict.category, verdict.reason, { command: command.slice(0, 200) });
274
+ }
275
+
276
+ if (p.tool_name === "Read") {
277
+ const path = p.tool_input?.file_path || "";
278
+ if (isSecretPath(path, overrides)) {
279
+ deny("secret-read", `Read(${path}) targets a secret file. Secrets must not enter the conversation; if this is a false positive, add the path to ${LOCAL}/safety-overrides.json (allow_secret_paths).`, { path });
280
+ }
281
+ defer("read target is not a secret path — inspected and permitted", "read-clean");
282
+ }
283
+
284
+ // Write | Edit | MultiEdit — only the self-protect rule; substrates stay sandbox-guard's job.
285
+ const overridesAbs = resolve(safetyOverrides(cwd));
286
+ const hit = extractPaths(p.tool_input).find((raw) => resolve(cwd, raw) === overridesAbs);
287
+ if (hit) {
288
+ deny("self-protect", `The safety-overrides file is human-authored only — the session must never widen (or remove) its own safety envelope. Ask the PO to edit ${LOCAL}/safety-overrides.json.`, { path: hit });
289
+ }
290
+ defer("write does not touch the safety envelope — inspected and permitted", "write-clean");
291
+ });
292
+ }
293
+
294
+ if (isMain(import.meta.url)) {
295
+ main();
296
+ }
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ // Sandbox guard — PreToolUse hook (design spec v1.1 §4.5/Blueprint E, PA3 countermeasure).
3
+ //
4
+ // Blocks Edit/Write/MultiEdit calls that touch a file outside the active scope's
5
+ // `allowed_file_substrate` (+ declared `shared_substrate`). Turns "generator only edits its
6
+ // own scope" from prose into a precondition the model cannot talk past — the same pattern as
7
+ // hooks/gate-l2.mjs for GATE L2.
8
+ //
9
+ // Design (deliberately conservative, mirrors gate-l2.mjs):
10
+ // • Fail-OPEN whenever there is nothing to enforce: no active-scope pointer (not running
11
+ // inside a scoped harness round), pointer names a scope contract that doesn't exist or is
12
+ // unparsable, or the tool call carries no resolvable file path. A guard that breaks
13
+ // legitimate non-harness edits would just get disabled.
14
+ // • Fail-CLOSED the moment an active scope IS declared and the target path matches none of
15
+ // its globs — deny, naming the substrate so the model can self-correct.
16
+ // • Run-trace carve-out — writes under the ACTIVE feature's LOCAL gitignored root
17
+ // (`.shapeup/<slug>/`) are always allowed: that root is harness bookkeeping the doer
18
+ // is REQUIRED to write (task-executor P3 status/AC ticks + tasks/_index.md, run-state,
19
+ // execution logs, the P3.7 discovery ledger). Substrate globs whitelist product code and
20
+ // never list the run-trace, so without the carve-out every scoped round strands its own
21
+ // board (island-escape shipped 16/20 task files stale this way). Deliberately narrow:
22
+ // only the active slug's root — `.shapeup/active-scope` (this guard's own pointer)
23
+ // and other features' roots remain subject to the substrate whitelist.
24
+ // • Every denial is also appended to the metrics pathology log (telemetry, not just defense).
25
+ //
26
+ // Contract: PreToolUse stdin JSON { tool_name, tool_input:{file_path | edits[].file_path}, cwd }.
27
+ // Deny via { hookSpecificOutput: { hookEventName, permissionDecision:"deny", permissionDecisionReason } }.
28
+
29
+ import { readFileSync, existsSync, appendFileSync, mkdirSync } from "node:fs";
30
+ import { resolve, join, relative, dirname, sep } from "node:path";
31
+ import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
32
+ import { LOCAL, activeScope, scopeContract, metricsShard } from "../skills/tech-lead/scripts/lib/paths.mjs";
33
+ import { readContract, SCOPE_CONTRACT } from "../skills/tech-lead/scripts/lib/contract-md.mjs";
34
+ import { runHook, readStdin, settle } from "./lib/decision.mjs";
35
+
36
+ // --- tiny glob matcher: supports *, **, ? — enough for substrate globs, zero dependencies ---
37
+ export function globToRegExp(glob) {
38
+ let re = "";
39
+ for (let i = 0; i < glob.length; i++) {
40
+ const c = glob[i];
41
+ if (c === "*") {
42
+ if (glob[i + 1] === "*") {
43
+ re += ".*";
44
+ i++;
45
+ if (glob[i + 1] === "/") i++;
46
+ } else {
47
+ re += "[^/]*";
48
+ }
49
+ } else if (c === "?") {
50
+ re += "[^/]";
51
+ } else if (".+^${}()|[]\\".includes(c)) {
52
+ re += "\\" + c;
53
+ } else {
54
+ re += c;
55
+ }
56
+ }
57
+ return new RegExp(`^${re}$`);
58
+ }
59
+
60
+ export function matchesAny(relPath, globs) {
61
+ return (globs || []).some((g) => globToRegExp(g).test(relPath));
62
+ }
63
+
64
+ function readJSON(p) {
65
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
66
+ }
67
+
68
+ function extractPaths(toolInput) {
69
+ const paths = [];
70
+ if (toolInput?.file_path) paths.push(toolInput.file_path);
71
+ if (Array.isArray(toolInput?.edits)) {
72
+ for (const e of toolInput.edits) if (e?.file_path) paths.push(e.file_path);
73
+ }
74
+ return paths;
75
+ }
76
+
77
+ export function logPathology(metricsPath, event) {
78
+ try {
79
+ mkdirSync(dirname(metricsPath), { recursive: true });
80
+ appendFileSync(metricsPath, JSON.stringify(event) + "\n");
81
+ } catch { /* telemetry is best-effort; never block on a logging failure */ }
82
+ }
83
+
84
+ async function main() {
85
+ await runHook("sandbox-guard", async () => {
86
+ const raw = await readStdin();
87
+ let p;
88
+ /** Fail-open, with the reason on the record (hooks/lib/decision.mjs). */
89
+ const defer = (reason, rule) => settle({
90
+ verdict: "allow", event: "PreToolUse", tool: p?.tool_name ?? null, cwd: p?.cwd, reason, rule,
91
+ });
92
+ try { p = JSON.parse(raw || "{}"); }
93
+ catch (e) { settle({ verdict: "error", event: "PreToolUse", reason: `unparseable payload: ${e.message}` }); }
94
+
95
+ if (!["Edit", "Write", "MultiEdit"].includes(p.tool_name)) {
96
+ defer(`${p.tool_name ?? "no tool_name"} is not a write tool — out of scope`);
97
+ }
98
+
99
+ const cwd = p.cwd || process.cwd();
100
+ const activeScopePath = activeScope(cwd);
101
+ // no harness round in progress → don't enforce
102
+ if (!existsSync(activeScopePath)) defer("no active-scope pointer — no harness round in progress", "no-round");
103
+
104
+ const active = readJSON(activeScopePath);
105
+ if (!active?.slug || !active?.scope_id) defer("active-scope pointer is unreadable or incomplete", "bad-pointer");
106
+
107
+ // Markdown first, legacy JSON second (ADR-0001) — a project mid-migration must stay sandboxed.
108
+ let found = null;
109
+ try { found = readContract(scopeContract(cwd, active.slug, active.scope_id), SCOPE_CONTRACT); }
110
+ catch (e) { defer(`scope contract is unparseable (${e.message})`, "bad-contract"); }
111
+ // pointer stale / contract not committed yet → don't break the run
112
+ if (!found) defer(`no contract for ${active.scope_id} — pointer stale or not committed yet`, "no-contract");
113
+ const contract = found.contract;
114
+ if (!contract) defer("scope contract is unparseable", "bad-contract");
115
+
116
+ const allowed = [...(contract.allowed_file_substrate || []), ...(contract.shared_substrate || [])];
117
+ // no whitelist declared → nothing to enforce
118
+ if (allowed.length === 0) defer(`scope ${active.scope_id} declares no write whitelist`, "no-whitelist");
119
+
120
+ const targetPaths = extractPaths(p.tool_input);
121
+ if (targetPaths.length === 0) defer("no writable path in the tool input", "no-target");
122
+
123
+ const metricsPath = metricsShard(cwd);
124
+ // Run-trace carve-out (see header): the active feature's LOCAL root only. The prefix ends
125
+ // with a separator so a sibling `<local>/<slug>-other/` can't ride along, and the active-scope
126
+ // pointer sits outside it by construction.
127
+ const runTracePrefix = join(LOCAL, active.slug) + sep;
128
+ const violations = [];
129
+ for (const raw of targetPaths) {
130
+ const abs = resolve(cwd, raw);
131
+ const rel = relative(cwd, abs);
132
+ if (rel.startsWith(runTracePrefix)) continue;
133
+ if (!matchesAny(rel, allowed)) violations.push(rel);
134
+ }
135
+
136
+ // Inside the substrate — the "inspected and permitted" row. Previously byte-identical to
137
+ // "this hook never ran", which is how 26 enforcement points sat inert behind 610 green checks.
138
+ if (violations.length === 0) {
139
+ defer(`${targetPaths.length} path(s) inside scope ${active.scope_id} substrate — permitted`, "in-substrate");
140
+ }
141
+
142
+ logPathology(metricsPath, {
143
+ schema_version: 1,
144
+ at: new Date().toISOString(),
145
+ kind: "pathology",
146
+ pathology: "PA3",
147
+ scope_id: active.scope_id,
148
+ slug: active.slug,
149
+ blocked_paths: violations,
150
+ });
151
+
152
+ return {
153
+ verdict: "deny", event: "PreToolUse", tool: p.tool_name, subject: active.scope_id, cwd,
154
+ rule: "outside-substrate",
155
+ reason: `${violations.length} write(s) outside the scope substrate: ${violations.join(", ")}`,
156
+ payload: {
157
+ hookSpecificOutput: {
158
+ hookEventName: "PreToolUse",
159
+ permissionDecision: "deny",
160
+ permissionDecisionReason:
161
+ `Sandbox guard (PA3) — scope "${active.scope_id}" may only write ${JSON.stringify(allowed)}. ` +
162
+ `Blocked: ${violations.join(", ")}. If this write legitimately crosses scopes, add the path to ` +
163
+ `the contract's shared_substrate (via ba --remap) rather than editing outside the substrate.`,
164
+ },
165
+ },
166
+ };
167
+ });
168
+ }
169
+
170
+ if (isMain(import.meta.url)) {
171
+ main();
172
+ }