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.
- package/.claude/settings.local.example.json +20 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.env.shapeup.example +14 -0
- package/AGENTS.md +133 -0
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/SECURITY.md +72 -0
- package/bin/init.mjs +329 -0
- package/commands/build.md +14 -0
- package/commands/eval.md +15 -0
- package/commands/hammer.md +12 -0
- package/commands/orient.md +11 -0
- package/commands/qa.md +12 -0
- package/commands/retro.md +13 -0
- package/commands/scopes.md +14 -0
- package/commands/shape.md +12 -0
- package/commands/ship.md +53 -0
- package/commands/wire.md +11 -0
- package/hooks/anti-rationalization.mjs +244 -0
- package/hooks/compact-snapshot.mjs +47 -0
- package/hooks/gate-deadline.mjs +151 -0
- package/hooks/gate-intake.mjs +110 -0
- package/hooks/gate-l2.mjs +161 -0
- package/hooks/gate-zerowork.mjs +264 -0
- package/hooks/hooks.json +118 -0
- package/hooks/lib/decision.mjs +183 -0
- package/hooks/safety-spine.mjs +296 -0
- package/hooks/sandbox-guard.mjs +172 -0
- package/hooks/session-rehydrate.mjs +109 -0
- package/hooks/slop-cleaner.mjs +176 -0
- package/oracles/_shared.mjs +46 -0
- package/oracles/http-oracle.mjs +155 -0
- package/oracles/index.mjs +36 -0
- package/oracles/process-oracle.mjs +146 -0
- package/oracles/snapshot-oracle.mjs +119 -0
- package/oracles/test-oracle.mjs +138 -0
- package/package.json +49 -0
- package/skills/advisor-protocol/SKILL.md +171 -0
- package/skills/ba-pitch-analyzer/SKILL.md +175 -0
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +84 -0
- package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +65 -0
- package/skills/ba-pitch-analyzer/assets/templates/assess-report.tmpl.md +127 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/be-service.contract.tmpl.md +62 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/offline-storage.contract.tmpl.md +92 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +66 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/context-map.tmpl.md +64 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/event-choreography.tmpl.md +77 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +104 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +79 -0
- package/skills/ba-pitch-analyzer/assets/templates/domain-model.tmpl.md +79 -0
- package/skills/ba-pitch-analyzer/assets/templates/feedback.tmpl.md +87 -0
- package/skills/ba-pitch-analyzer/assets/templates/integration.tmpl.md +64 -0
- package/skills/ba-pitch-analyzer/assets/templates/run-state.tmpl.md +77 -0
- package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +105 -0
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +215 -0
- package/skills/ba-pitch-analyzer/assets/templates/task-board.tmpl.md +35 -0
- package/skills/ba-pitch-analyzer/assets/templates/task-spike.tmpl.md +86 -0
- package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +84 -0
- package/skills/ba-pitch-analyzer/assets/templates/usecase.tmpl.md +105 -0
- package/skills/ba-pitch-analyzer/assets/templates/ux-behavior.tmpl.md +65 -0
- package/skills/ba-pitch-analyzer/references/contract-patterns.md +152 -0
- package/skills/ba-pitch-analyzer/references/ddd-patterns.md +173 -0
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +275 -0
- package/skills/ba-pitch-analyzer/references/integration-analysis.md +152 -0
- package/skills/ba-pitch-analyzer/references/task-generation.md +623 -0
- package/skills/ba-pitch-analyzer/references/test-surface.md +102 -0
- package/skills/ba-pitch-analyzer/references/ux-behavior-patterns.md +269 -0
- package/skills/ba-pitch-analyzer/scripts/board-derive.mjs +241 -0
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +239 -0
- package/skills/coach/SKILL.md +202 -0
- package/skills/orient/SKILL.md +262 -0
- package/skills/qa-edge-hunter/SKILL.md +369 -0
- package/skills/scope-architect/SKILL.md +111 -0
- package/skills/scope-hammer/SKILL.md +186 -0
- package/skills/shapeup/SKILL.md +403 -0
- package/skills/shapeup/resources/breadboard-reflection.md +88 -0
- package/skills/shapeup/resources/breadboarding.md +334 -0
- package/skills/shapeup/resources/context-compaction.md +240 -0
- package/skills/shapeup/resources/framing-doc.md +92 -0
- package/skills/shapeup/resources/kickoff-doc.md +95 -0
- package/skills/shapeup/resources/shaping.md +194 -0
- package/skills/shapeup/resources/spike.md +86 -0
- package/skills/solution-architect/SKILL.md +129 -0
- package/skills/spec-evaluator/README.md +93 -0
- package/skills/spec-evaluator/SKILL.md +213 -0
- package/skills/spec-evaluator/references/anti-leniency.md +50 -0
- package/skills/spec-evaluator/references/dimension-contract.md +126 -0
- package/skills/spec-evaluator/references/dimensions/_registry.md +40 -0
- package/skills/spec-evaluator/references/dimensions/completeness.md +102 -0
- package/skills/spec-evaluator/references/dimensions/integration.md +129 -0
- package/skills/spec-evaluator/references/dimensions/performance.md +48 -0
- package/skills/spec-evaluator/references/dimensions/security.md +60 -0
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +134 -0
- package/skills/spec-evaluator/references/dimensions/tdd-surface.md +110 -0
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +100 -0
- package/skills/spec-evaluator/references/probing.md +177 -0
- package/skills/spec-evaluator/references/report-schema.md +101 -0
- package/skills/spec-evaluator/references/verdict-ledger.md +92 -0
- package/skills/spec-evaluator/scripts/verdict-ledger.mjs +166 -0
- package/skills/task-executor/SKILL.md +194 -0
- package/skills/tech-lead/README.md +71 -0
- package/skills/tech-lead/SKILL.md +459 -0
- package/skills/tech-lead/references/delegation.md +254 -0
- package/skills/tech-lead/references/gates.md +379 -0
- package/skills/tech-lead/references/invocation.md +45 -0
- package/skills/tech-lead/references/ledger-schema.md +214 -0
- package/skills/tech-lead/references/round-protocol.md +184 -0
- package/skills/tech-lead/references/state-model.md +66 -0
- package/skills/tech-lead/references/tiny-lane.md +52 -0
- package/skills/tech-lead/schemas/domain.schema.json +2294 -0
- package/skills/tech-lead/schemas/gate-answers.schema.json +92 -0
- package/skills/tech-lead/schemas/work-order.schema.json +21 -0
- package/skills/tech-lead/schemas/work-result.schema.json +40 -0
- package/skills/tech-lead/scripts/aegis-digest.mjs +124 -0
- package/skills/tech-lead/scripts/budget-check.mjs +156 -0
- package/skills/tech-lead/scripts/compile-order.mjs +518 -0
- package/skills/tech-lead/scripts/fit-check.mjs +196 -0
- package/skills/tech-lead/scripts/gate-answers.mjs +338 -0
- package/skills/tech-lead/scripts/ingest-result.mjs +270 -0
- package/skills/tech-lead/scripts/init-run.mjs +326 -0
- package/skills/tech-lead/scripts/lib/argv.mjs +224 -0
- package/skills/tech-lead/scripts/lib/contract-md.mjs +481 -0
- package/skills/tech-lead/scripts/lib/is-main.mjs +82 -0
- package/skills/tech-lead/scripts/lib/paths.mjs +278 -0
- package/skills/tech-lead/scripts/lib/ratchet-tree.mjs +112 -0
- package/skills/tech-lead/scripts/run-snapshot.mjs +269 -0
- package/skills/tech-lead/scripts/ship-report.mjs +275 -0
- package/skills/tech-lead/scripts/stats.mjs +423 -0
- package/skills/tech-lead/scripts/t0-verify.mjs +470 -0
- package/skills/tech-lead/scripts/trace-lint.mjs +367 -0
- package/skills/tech-lead/scripts/validate-envelope.mjs +283 -0
- package/skills/translator/README.md +66 -0
- package/skills/translator/SKILL.md +258 -0
- 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
|
+
}
|