@webpieces/ai-hook-rules 0.4.413 → 0.4.415

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.4.413",
3
+ "version": "0.4.415",
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.413"
35
+ "@webpieces/rules-config": "0.4.415"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"
@@ -87,6 +87,33 @@ function handleBash(payload, cwd, mode) {
87
87
  // shows the human; permissionDecisionReason is invisible on Bash). See claude-code-response.ts.
88
88
  (0, claude_code_response_1.emitDeny)(result.report, 'Bash');
89
89
  }
90
+ /**
91
+ * The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.
92
+ *
93
+ * Wrapped in its own catch that swallows into an allow. Every other path in this hook fails CLOSED,
94
+ * and that is right for edits and shell commands — but a crash here would block the agent from
95
+ * READING, which includes reading webpieces.config.json to turn the offending guard off. So this one
96
+ * path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.
97
+ */
98
+ // webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design
99
+ function handleRead(filePath, cwd, mode) {
100
+ if (filePath === '')
101
+ return;
102
+ let result = null;
103
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
104
+ try {
105
+ result = (0, runner_1.runRead)(filePath, cwd, mode);
106
+ }
107
+ catch (err) {
108
+ const error = (0, to_error_1.toError)(err);
109
+ void error;
110
+ return; // fail OPEN — see the doc comment
111
+ }
112
+ if (!result)
113
+ return;
114
+ (0, rejection_log_1.logRejection)('Read', new types_1.NormalizedToolInput(filePath, []), result, cwd);
115
+ (0, claude_code_response_1.emitDeny)(result.report, 'Read');
116
+ }
90
117
  function handleFileTool(payload, cwd, mode) {
91
118
  const toolKind = normalizeToolKind(payload.tool_name);
92
119
  if (!toolKind) {
@@ -146,14 +173,19 @@ async function runMain(mode) {
146
173
  // process.cwd(); they match today, but the payload is the authoritative signal and stays
147
174
  // correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).
148
175
  const cwd = payload.cwd ?? process.cwd();
149
- // Read-only tools (Read): log-and-allow. Record the file the AI opened in guard-invocations.log
150
- // (guards mode only rules mode never writes the invocation log), then allow immediately,
151
- // BEFORE healShim and the rule/guard engine. So a read is never blocked or slowed, yet the audit
152
- // trail shows whether the AI read a project's design.json before editing it. See setup.ts.
176
+ // Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped
177
+ // guard (main-stale-guard) and allow. Runs BEFORE healShim and the general rule engine no
178
+ // code-style rule ever sees a Read, and the only way this path can deny is a stale `main`.
179
+ // The audit trail still records every file the AI opened (see setup.ts).
153
180
  if (READ_ONLY_TOOLS.has(payload.tool_name)) {
181
+ const readPath = payload.tool_input.file_path ?? '';
154
182
  if (mode !== 'rules') {
155
- (0, decision_log_1.logGuardInvocation)(cwd, payload.tool_name, payload.tool_input.file_path ?? '');
183
+ (0, decision_log_1.logGuardInvocation)(cwd, payload.tool_name, readPath);
184
+ // Reads vastly outnumber edits, so refreshing here is what actually keeps the shared
185
+ // main-sync cache warm for feature-branch-guard. Detached; never slows the read.
186
+ (0, main_sync_refresh_1.triggerMainSyncRefresh)(cwd);
156
187
  }
188
+ handleRead(readPath, cwd, mode);
157
189
  (0, claude_code_response_1.emitAllow)();
158
190
  }
159
191
  // Keep the committed shim (.claude/webpieces/ai-hook.sh) identical to renderShim() so its
@@ -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 } 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 } from '../core/types';\nimport { toError } from '../core/to-error';\nimport { emitDeny, emitAllow } from './claude-code-response';\nimport { healShim } 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\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/**\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 // Read-only tools (Read): log-and-allow. Record the file the AI opened in guard-invocations.log\n // (guards mode only — rules mode never writes the invocation log), then allow immediately,\n // BEFORE healShim and the rule/guard engine. So a read is never blocked or slowed, yet the audit\n // trail shows whether the AI read a project's design.json before editing it. See setup.ts.\n if (READ_ONLY_TOOLS.has(payload.tool_name)) {\n if (mode !== 'rules') {\n logGuardInvocation(cwd, payload.tool_name, payload.tool_input.file_path ?? '');\n }\n emitAllow();\n }\n\n // Keep the committed shim (.claude/webpieces/ai-hook.sh) identical to renderShim() so its\n // fail-closed escape hatch + installer allowlist never go stale — no human hand-edits it.\n // Runs only when the guards binary is actually installed (i.e. now), is best-effort, and\n // never throws into the decision below. 'rules' hook skips it (guards owns the shim).\n if (mode !== 'rules') {\n healShim(cwd);\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-\n // effort; never blocks the call.\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 { healShim } 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/**\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 // Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped\n // guard (main-stale-guard) and allow. Runs BEFORE healShim and the general rule engine — no\n // code-style rule 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 // Keep the committed shim (.claude/webpieces/ai-hook.sh) identical to renderShim() so its\n // fail-closed escape hatch + installer allowlist never go stale — no human hand-edits it.\n // Runs only when the guards binary is actually installed (i.e. now), is best-effort, and\n // never throws into the decision below. 'rules' hook skips it (guards owns the shim).\n if (mode !== 'rules') {\n healShim(cwd);\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-\n // effort; never blocks the call.\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"]}
@@ -30,6 +30,7 @@ const pr_merge_guard_1 = require("./rules/pr-merge-guard");
30
30
  const redirect_how_to_merge_main_1 = require("./rules/redirect-how-to-merge-main");
31
31
  const no_js_files_1 = require("./rules/no-js-files");
32
32
  const feature_branch_guard_1 = require("./rules/feature-branch-guard");
33
+ const main_stale_guard_1 = require("./rules/main-stale-guard");
33
34
  const match_rule_1 = require("./rules/match-rule");
34
35
  const REQUIRED_FIELDS = ['name', 'description', 'scope', 'files', 'check'];
35
36
  const VALID_SCOPES = new Set(['edit', 'file', 'bash']);
@@ -53,6 +54,7 @@ const BUILT_IN_RULE_MAP = {
53
54
  'redirect-how-to-merge-main': (c) => new redirect_how_to_merge_main_1.RedirectHowToMergeMainRule(c),
54
55
  'no-js-files': (c) => new no_js_files_1.NoJsFilesRule(c),
55
56
  'feature-branch-guard': (c) => new feature_branch_guard_1.FeatureBranchGuardRule(c),
57
+ 'main-stale-guard': (c) => new main_stale_guard_1.MainStaleGuardRule(c),
56
58
  };
57
59
  // Index the typed config by rule name. Each value is the rule's *Config (a plain object from
58
60
  // JSON), or undefined when the rule has no entry yet (the sync check reports those).
@@ -1 +1 @@
1
- {"version":3,"file":"load-rules.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/load-rules.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport {\n BaseRuleConfig, RuleOptions, WebpiecesRulesConfig,\n NoAnyUnknownConfig, NoImplicitAnyConfig, MaxFileLinesConfig, ValidateTsInSrcConfig,\n NoDestructureConfig, RequireReturnTypeConfig, NoUnmanagedExceptionsConfig,\n CatchErrorPatternConfig, ThrowCauseRequiredConfig,\n NoSymbolDiTokensConfig, NoCustomCssConfig, NoProcessExitOutsideMainConfig, BranchCreationGuardConfig, PrCreationOrPushGuardConfig,\n MergeInProgressGuardConfig, PrMergeGuardConfig, RedirectHowToMergeMainConfig,\n NoJsFilesConfig, FeatureBranchGuardConfig, MatchRuleConfig,\n} from '@webpieces/rules-config';\n\nimport type { Rule, PlainRule } from './types';\nimport { InformAiError } from './types';\nimport { toError } from './to-error';\nimport { EmptyRuleConfig } from './rule-base';\nimport { CustomRuleAdapter } from './custom-rule-adapter';\nimport { builtInRuleNames } from './rules/index';\nimport { NoAnyUnknownRule } from './rules/no-any-unknown';\nimport { NoImplicitAnyRule } from './rules/no-implicit-any';\nimport { MaxFileLinesRule } from './rules/max-file-lines';\nimport { ValidateTsInSrcRule } from './rules/validate-ts-in-src';\nimport { NoDestructureRule } from './rules/no-destructure';\nimport { RequireReturnTypeRule } from './rules/require-return-type';\nimport { NoUnmanagedExceptionsRule } from './rules/no-unmanaged-exceptions';\nimport { CatchErrorPatternRule } from './rules/catch-error-pattern';\nimport { ThrowCauseRequiredRule } from './rules/throw-cause-required';\nimport { NoSymbolDiTokensRule } from './rules/no-symbol-di-tokens';\nimport { NoCustomCssRule } from './rules/no-custom-css';\nimport { NoProcessExitOutsideMainRule } from './rules/no-process-exit-outside-main';\nimport { BranchCreationGuardRule } from './rules/branch-creation-guard';\nimport { PrCreationOrPushGuardRule } from './rules/pr-creation-or-push-guard';\nimport { MergeInProgressGuardRule } from './rules/merge-in-progress-guard';\nimport { PrMergeGuardRule } from './rules/pr-merge-guard';\nimport { RedirectHowToMergeMainRule } from './rules/redirect-how-to-merge-main';\nimport { NoJsFilesRule } from './rules/no-js-files';\nimport { FeatureBranchGuardRule } from './rules/feature-branch-guard';\nimport { MatchRule } from './rules/match-rule';\n\nconst REQUIRED_FIELDS: readonly string[] = ['name', 'description', 'scope', 'files', 'check'];\nconst VALID_SCOPES = new Set(['edit', 'file', 'bash']);\n\n// Each built-in rule is constructed from its typed *Config (the entry in webpieces.config.json).\n// The config arrives as a plain object structurally typed as the *Config class, so the `as`\n// narrows the shared BaseRuleConfig param back to the concrete config the rule consumes.\ntype RuleFactory = (config: BaseRuleConfig) => Rule;\n\nconst BUILT_IN_RULE_MAP: Record<string, RuleFactory> = {\n 'no-any-unknown': (c: BaseRuleConfig) => new NoAnyUnknownRule(c as NoAnyUnknownConfig),\n 'no-implicit-any': (c: BaseRuleConfig) => new NoImplicitAnyRule(c as NoImplicitAnyConfig),\n 'max-file-lines': (c: BaseRuleConfig) => new MaxFileLinesRule(c as MaxFileLinesConfig),\n 'validate-ts-in-src': (c: BaseRuleConfig) => new ValidateTsInSrcRule(c as ValidateTsInSrcConfig),\n 'no-destructure': (c: BaseRuleConfig) => new NoDestructureRule(c as NoDestructureConfig),\n 'require-return-type': (c: BaseRuleConfig) => new RequireReturnTypeRule(c as RequireReturnTypeConfig),\n 'no-unmanaged-exceptions': (c: BaseRuleConfig) => new NoUnmanagedExceptionsRule(c as NoUnmanagedExceptionsConfig),\n 'catch-error-pattern': (c: BaseRuleConfig) => new CatchErrorPatternRule(c as CatchErrorPatternConfig),\n 'throw-cause-required': (c: BaseRuleConfig) => new ThrowCauseRequiredRule(c as ThrowCauseRequiredConfig),\n 'no-symbol-di-tokens': (c: BaseRuleConfig) => new NoSymbolDiTokensRule(c as NoSymbolDiTokensConfig),\n 'no-custom-css': (c: BaseRuleConfig) => new NoCustomCssRule(c as NoCustomCssConfig),\n 'no-process-exit-outside-main': (c: BaseRuleConfig) => new NoProcessExitOutsideMainRule(c as NoProcessExitOutsideMainConfig),\n 'branch-creation-guard': (c: BaseRuleConfig) => new BranchCreationGuardRule(c as BranchCreationGuardConfig),\n 'pr-creation-or-push-guard': (c: BaseRuleConfig) => new PrCreationOrPushGuardRule(c as PrCreationOrPushGuardConfig),\n 'merge-in-progress-guard': (c: BaseRuleConfig) => new MergeInProgressGuardRule(c as MergeInProgressGuardConfig),\n 'pr-merge-guard': (c: BaseRuleConfig) => new PrMergeGuardRule(c as PrMergeGuardConfig),\n 'redirect-how-to-merge-main': (c: BaseRuleConfig) => new RedirectHowToMergeMainRule(c as RedirectHowToMergeMainConfig),\n 'no-js-files': (c: BaseRuleConfig) => new NoJsFilesRule(c as NoJsFilesConfig),\n 'feature-branch-guard': (c: BaseRuleConfig) => new FeatureBranchGuardRule(c as FeatureBranchGuardConfig),\n};\n\n// Index the typed config by rule name. Each value is the rule's *Config (a plain object from\n// JSON), or undefined when the rule has no entry yet (the sync check reports those).\nfunction asConfigMap(config: WebpiecesRulesConfig): Record<string, BaseRuleConfig | undefined> {\n // webpieces-disable no-any-unknown -- index the typed config by dynamic rule name\n return config as unknown as Record<string, BaseRuleConfig | undefined>;\n}\n\nexport function loadRules(config: WebpiecesRulesConfig, workspaceRoot: string): readonly Rule[] {\n const builtIns = loadBuiltInRules(config);\n const custom = loadCustomRules(config, workspaceRoot);\n return [...builtIns, ...custom];\n}\n\n// One MatchRule per entry of the `match-rules` array. Kept separate from loadRules (built-ins/custom)\n// because match-rules live in their own validated section — they must NOT flow through the\n// config-sync check, which compares rule names against the `rules`/`hookGuards` map.\nexport function loadMatchRules(matchRules: readonly MatchRuleConfig[]): Rule[] {\n return matchRules.map((c: MatchRuleConfig) => new MatchRule(c));\n}\n\nfunction loadBuiltInRules(config: WebpiecesRulesConfig): Rule[] {\n const map = asConfigMap(config);\n const rules: Rule[] = [];\n for (const name of builtInRuleNames) {\n const factory = BUILT_IN_RULE_MAP[name];\n if (!factory) {\n process.stderr.write(`[ai-hooks] unknown built-in rule: ${name}\\n`);\n continue;\n }\n const ruleConfig = map[name] ?? new EmptyRuleConfig();\n rules.push(factory(ruleConfig));\n }\n return rules;\n}\n\nfunction loadCustomRules(config: WebpiecesRulesConfig, workspaceRoot: string): Rule[] {\n const dirs = config.rulesDir ?? [];\n // webpieces-disable no-any-unknown -- index the typed config by dynamic custom-rule name\n const map = config as unknown as Record<string, RuleOptions | undefined>;\n const rules: Rule[] = [];\n for (const plain of loadCustomPlainRules(dirs, workspaceRoot)) {\n const rawConfig = map[plain.name] ?? {};\n rules.push(new CustomRuleAdapter(plain, rawConfig));\n }\n return rules;\n}\n\nfunction loadCustomPlainRules(rulesDirs: readonly string[], workspaceRoot: string): PlainRule[] {\n const modules: PlainRule[] = [];\n for (const dir of rulesDirs) {\n const absDir = path.isAbsolute(dir) ? dir : path.join(workspaceRoot, dir);\n if (!fs.existsSync(absDir)) {\n process.stderr.write(`[ai-hooks] rulesDir not found: ${absDir}\\n`);\n continue;\n }\n let entries: string[];\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n entries = fs.readdirSync(absDir).filter((e: string) => e.endsWith('.js'));\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Cannot read custom rules directory '${absDir}'`, { cause: error });\n }\n for (const entry of entries) {\n const full = path.join(absDir, entry);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const mod = require(full);\n const candidate = mod.default || mod;\n if (validateRule(candidate)) modules.push(candidate);\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Cannot load custom rule '${full}'`, { cause: error });\n }\n }\n }\n return modules;\n}\n\n// webpieces-disable no-any-unknown -- validates untrusted require() output at system boundary\nfunction validateRule(rule: unknown): rule is PlainRule {\n if (!rule || typeof rule !== 'object') {\n process.stderr.write('[ai-hooks] rule is not an object, skipping\\n');\n return false;\n }\n // webpieces-disable no-any-unknown -- narrowing from unknown at system boundary\n const obj = rule as Record<string, unknown>;\n for (const field of REQUIRED_FIELDS) {\n if (obj[field] === undefined) {\n const name = typeof obj['name'] === 'string' ? obj['name'] : '<unnamed>';\n process.stderr.write(`[ai-hooks] rule \"${name}\" missing required field: ${field}\\n`);\n return false;\n }\n }\n if (!VALID_SCOPES.has(obj['scope'] as string)) {\n process.stderr.write(`[ai-hooks] rule \"${obj['name']}\" has invalid scope: ${String(obj['scope'])}\\n`);\n return false;\n }\n if (!Array.isArray(obj['files'])) {\n process.stderr.write(`[ai-hooks] rule \"${obj['name']}\" files must be an array\\n`);\n return false;\n }\n if (typeof obj['check'] !== 'function') {\n process.stderr.write(`[ai-hooks] rule \"${obj['name']}\" check must be a function\\n`);\n return false;\n }\n return true;\n}\n\nexport function globMatches(pattern: string, filePath: string): boolean {\n const regex = globToRegex(pattern);\n return regex.test(filePath);\n}\n\nfunction globToRegex(pattern: string): RegExp {\n let re = '';\n let i = 0;\n while (i < pattern.length) {\n const ch = pattern[i];\n if (ch === '*') {\n if (pattern[i + 1] === '*') {\n re += '.*';\n i += 2;\n if (pattern[i] === '/') i += 1;\n continue;\n }\n re += '[^/]*';\n i += 1;\n continue;\n }\n if (ch === '?') {\n re += '[^/]';\n i += 1;\n continue;\n }\n if ('.+^$(){}|[]\\\\'.includes(ch)) {\n re += '\\\\' + ch;\n i += 1;\n continue;\n }\n re += ch;\n i += 1;\n }\n return new RegExp('^' + re + '$');\n}\n"]}
1
+ {"version":3,"file":"load-rules.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/load-rules.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport {\n BaseRuleConfig, RuleOptions, WebpiecesRulesConfig,\n NoAnyUnknownConfig, NoImplicitAnyConfig, MaxFileLinesConfig, ValidateTsInSrcConfig,\n NoDestructureConfig, RequireReturnTypeConfig, NoUnmanagedExceptionsConfig,\n CatchErrorPatternConfig, ThrowCauseRequiredConfig,\n NoSymbolDiTokensConfig, NoCustomCssConfig, NoProcessExitOutsideMainConfig, BranchCreationGuardConfig, PrCreationOrPushGuardConfig,\n MergeInProgressGuardConfig, PrMergeGuardConfig, RedirectHowToMergeMainConfig,\n NoJsFilesConfig, FeatureBranchGuardConfig, MainStaleGuardConfig, MatchRuleConfig,\n} from '@webpieces/rules-config';\n\nimport type { Rule, PlainRule } from './types';\nimport { InformAiError } from './types';\nimport { toError } from './to-error';\nimport { EmptyRuleConfig } from './rule-base';\nimport { CustomRuleAdapter } from './custom-rule-adapter';\nimport { builtInRuleNames } from './rules/index';\nimport { NoAnyUnknownRule } from './rules/no-any-unknown';\nimport { NoImplicitAnyRule } from './rules/no-implicit-any';\nimport { MaxFileLinesRule } from './rules/max-file-lines';\nimport { ValidateTsInSrcRule } from './rules/validate-ts-in-src';\nimport { NoDestructureRule } from './rules/no-destructure';\nimport { RequireReturnTypeRule } from './rules/require-return-type';\nimport { NoUnmanagedExceptionsRule } from './rules/no-unmanaged-exceptions';\nimport { CatchErrorPatternRule } from './rules/catch-error-pattern';\nimport { ThrowCauseRequiredRule } from './rules/throw-cause-required';\nimport { NoSymbolDiTokensRule } from './rules/no-symbol-di-tokens';\nimport { NoCustomCssRule } from './rules/no-custom-css';\nimport { NoProcessExitOutsideMainRule } from './rules/no-process-exit-outside-main';\nimport { BranchCreationGuardRule } from './rules/branch-creation-guard';\nimport { PrCreationOrPushGuardRule } from './rules/pr-creation-or-push-guard';\nimport { MergeInProgressGuardRule } from './rules/merge-in-progress-guard';\nimport { PrMergeGuardRule } from './rules/pr-merge-guard';\nimport { RedirectHowToMergeMainRule } from './rules/redirect-how-to-merge-main';\nimport { NoJsFilesRule } from './rules/no-js-files';\nimport { FeatureBranchGuardRule } from './rules/feature-branch-guard';\nimport { MainStaleGuardRule } from './rules/main-stale-guard';\nimport { MatchRule } from './rules/match-rule';\n\nconst REQUIRED_FIELDS: readonly string[] = ['name', 'description', 'scope', 'files', 'check'];\nconst VALID_SCOPES = new Set(['edit', 'file', 'bash']);\n\n// Each built-in rule is constructed from its typed *Config (the entry in webpieces.config.json).\n// The config arrives as a plain object structurally typed as the *Config class, so the `as`\n// narrows the shared BaseRuleConfig param back to the concrete config the rule consumes.\ntype RuleFactory = (config: BaseRuleConfig) => Rule;\n\nconst BUILT_IN_RULE_MAP: Record<string, RuleFactory> = {\n 'no-any-unknown': (c: BaseRuleConfig) => new NoAnyUnknownRule(c as NoAnyUnknownConfig),\n 'no-implicit-any': (c: BaseRuleConfig) => new NoImplicitAnyRule(c as NoImplicitAnyConfig),\n 'max-file-lines': (c: BaseRuleConfig) => new MaxFileLinesRule(c as MaxFileLinesConfig),\n 'validate-ts-in-src': (c: BaseRuleConfig) => new ValidateTsInSrcRule(c as ValidateTsInSrcConfig),\n 'no-destructure': (c: BaseRuleConfig) => new NoDestructureRule(c as NoDestructureConfig),\n 'require-return-type': (c: BaseRuleConfig) => new RequireReturnTypeRule(c as RequireReturnTypeConfig),\n 'no-unmanaged-exceptions': (c: BaseRuleConfig) => new NoUnmanagedExceptionsRule(c as NoUnmanagedExceptionsConfig),\n 'catch-error-pattern': (c: BaseRuleConfig) => new CatchErrorPatternRule(c as CatchErrorPatternConfig),\n 'throw-cause-required': (c: BaseRuleConfig) => new ThrowCauseRequiredRule(c as ThrowCauseRequiredConfig),\n 'no-symbol-di-tokens': (c: BaseRuleConfig) => new NoSymbolDiTokensRule(c as NoSymbolDiTokensConfig),\n 'no-custom-css': (c: BaseRuleConfig) => new NoCustomCssRule(c as NoCustomCssConfig),\n 'no-process-exit-outside-main': (c: BaseRuleConfig) => new NoProcessExitOutsideMainRule(c as NoProcessExitOutsideMainConfig),\n 'branch-creation-guard': (c: BaseRuleConfig) => new BranchCreationGuardRule(c as BranchCreationGuardConfig),\n 'pr-creation-or-push-guard': (c: BaseRuleConfig) => new PrCreationOrPushGuardRule(c as PrCreationOrPushGuardConfig),\n 'merge-in-progress-guard': (c: BaseRuleConfig) => new MergeInProgressGuardRule(c as MergeInProgressGuardConfig),\n 'pr-merge-guard': (c: BaseRuleConfig) => new PrMergeGuardRule(c as PrMergeGuardConfig),\n 'redirect-how-to-merge-main': (c: BaseRuleConfig) => new RedirectHowToMergeMainRule(c as RedirectHowToMergeMainConfig),\n 'no-js-files': (c: BaseRuleConfig) => new NoJsFilesRule(c as NoJsFilesConfig),\n 'feature-branch-guard': (c: BaseRuleConfig) => new FeatureBranchGuardRule(c as FeatureBranchGuardConfig),\n 'main-stale-guard': (c: BaseRuleConfig) => new MainStaleGuardRule(c as MainStaleGuardConfig),\n};\n\n// Index the typed config by rule name. Each value is the rule's *Config (a plain object from\n// JSON), or undefined when the rule has no entry yet (the sync check reports those).\nfunction asConfigMap(config: WebpiecesRulesConfig): Record<string, BaseRuleConfig | undefined> {\n // webpieces-disable no-any-unknown -- index the typed config by dynamic rule name\n return config as unknown as Record<string, BaseRuleConfig | undefined>;\n}\n\nexport function loadRules(config: WebpiecesRulesConfig, workspaceRoot: string): readonly Rule[] {\n const builtIns = loadBuiltInRules(config);\n const custom = loadCustomRules(config, workspaceRoot);\n return [...builtIns, ...custom];\n}\n\n// One MatchRule per entry of the `match-rules` array. Kept separate from loadRules (built-ins/custom)\n// because match-rules live in their own validated section — they must NOT flow through the\n// config-sync check, which compares rule names against the `rules`/`hookGuards` map.\nexport function loadMatchRules(matchRules: readonly MatchRuleConfig[]): Rule[] {\n return matchRules.map((c: MatchRuleConfig) => new MatchRule(c));\n}\n\nfunction loadBuiltInRules(config: WebpiecesRulesConfig): Rule[] {\n const map = asConfigMap(config);\n const rules: Rule[] = [];\n for (const name of builtInRuleNames) {\n const factory = BUILT_IN_RULE_MAP[name];\n if (!factory) {\n process.stderr.write(`[ai-hooks] unknown built-in rule: ${name}\\n`);\n continue;\n }\n const ruleConfig = map[name] ?? new EmptyRuleConfig();\n rules.push(factory(ruleConfig));\n }\n return rules;\n}\n\nfunction loadCustomRules(config: WebpiecesRulesConfig, workspaceRoot: string): Rule[] {\n const dirs = config.rulesDir ?? [];\n // webpieces-disable no-any-unknown -- index the typed config by dynamic custom-rule name\n const map = config as unknown as Record<string, RuleOptions | undefined>;\n const rules: Rule[] = [];\n for (const plain of loadCustomPlainRules(dirs, workspaceRoot)) {\n const rawConfig = map[plain.name] ?? {};\n rules.push(new CustomRuleAdapter(plain, rawConfig));\n }\n return rules;\n}\n\nfunction loadCustomPlainRules(rulesDirs: readonly string[], workspaceRoot: string): PlainRule[] {\n const modules: PlainRule[] = [];\n for (const dir of rulesDirs) {\n const absDir = path.isAbsolute(dir) ? dir : path.join(workspaceRoot, dir);\n if (!fs.existsSync(absDir)) {\n process.stderr.write(`[ai-hooks] rulesDir not found: ${absDir}\\n`);\n continue;\n }\n let entries: string[];\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n entries = fs.readdirSync(absDir).filter((e: string) => e.endsWith('.js'));\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Cannot read custom rules directory '${absDir}'`, { cause: error });\n }\n for (const entry of entries) {\n const full = path.join(absDir, entry);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const mod = require(full);\n const candidate = mod.default || mod;\n if (validateRule(candidate)) modules.push(candidate);\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Cannot load custom rule '${full}'`, { cause: error });\n }\n }\n }\n return modules;\n}\n\n// webpieces-disable no-any-unknown -- validates untrusted require() output at system boundary\nfunction validateRule(rule: unknown): rule is PlainRule {\n if (!rule || typeof rule !== 'object') {\n process.stderr.write('[ai-hooks] rule is not an object, skipping\\n');\n return false;\n }\n // webpieces-disable no-any-unknown -- narrowing from unknown at system boundary\n const obj = rule as Record<string, unknown>;\n for (const field of REQUIRED_FIELDS) {\n if (obj[field] === undefined) {\n const name = typeof obj['name'] === 'string' ? obj['name'] : '<unnamed>';\n process.stderr.write(`[ai-hooks] rule \"${name}\" missing required field: ${field}\\n`);\n return false;\n }\n }\n if (!VALID_SCOPES.has(obj['scope'] as string)) {\n process.stderr.write(`[ai-hooks] rule \"${obj['name']}\" has invalid scope: ${String(obj['scope'])}\\n`);\n return false;\n }\n if (!Array.isArray(obj['files'])) {\n process.stderr.write(`[ai-hooks] rule \"${obj['name']}\" files must be an array\\n`);\n return false;\n }\n if (typeof obj['check'] !== 'function') {\n process.stderr.write(`[ai-hooks] rule \"${obj['name']}\" check must be a function\\n`);\n return false;\n }\n return true;\n}\n\nexport function globMatches(pattern: string, filePath: string): boolean {\n const regex = globToRegex(pattern);\n return regex.test(filePath);\n}\n\nfunction globToRegex(pattern: string): RegExp {\n let re = '';\n let i = 0;\n while (i < pattern.length) {\n const ch = pattern[i];\n if (ch === '*') {\n if (pattern[i + 1] === '*') {\n re += '.*';\n i += 2;\n if (pattern[i] === '/') i += 1;\n continue;\n }\n re += '[^/]*';\n i += 1;\n continue;\n }\n if (ch === '?') {\n re += '[^/]';\n i += 1;\n continue;\n }\n if ('.+^$(){}|[]\\\\'.includes(ch)) {\n re += '\\\\' + ch;\n i += 1;\n continue;\n }\n re += ch;\n i += 1;\n }\n return new RegExp('^' + re + '$');\n}\n"]}
@@ -21,5 +21,6 @@ exports.builtInRuleNames = [
21
21
  'pr-merge-guard',
22
22
  'redirect-how-to-merge-main',
23
23
  'feature-branch-guard',
24
+ 'main-stale-guard',
24
25
  ];
25
26
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/index.ts"],"names":[],"mappings":";;;AAAa,QAAA,gBAAgB,GAAsB;IAC/C,gBAAgB;IAChB,iBAAiB;IACjB,gBAAgB;IAChB,oBAAoB;IACpB,aAAa;IACb,gBAAgB;IAChB,qBAAqB;IACrB,yBAAyB;IACzB,qBAAqB;IACrB,sBAAsB;IACtB,qBAAqB;IACrB,eAAe;IACf,8BAA8B;IAC9B,uBAAuB;IACvB,2BAA2B;IAC3B,yBAAyB;IACzB,gBAAgB;IAChB,4BAA4B;IAC5B,sBAAsB;CACzB,CAAC","sourcesContent":["export const builtInRuleNames: readonly string[] = [\n 'no-any-unknown',\n 'no-implicit-any',\n 'max-file-lines',\n 'validate-ts-in-src',\n 'no-js-files',\n 'no-destructure',\n 'require-return-type',\n 'no-unmanaged-exceptions',\n 'catch-error-pattern',\n 'throw-cause-required',\n 'no-symbol-di-tokens',\n 'no-custom-css',\n 'no-process-exit-outside-main',\n 'branch-creation-guard',\n 'pr-creation-or-push-guard',\n 'merge-in-progress-guard',\n 'pr-merge-guard',\n 'redirect-how-to-merge-main',\n 'feature-branch-guard',\n];\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/index.ts"],"names":[],"mappings":";;;AAAa,QAAA,gBAAgB,GAAsB;IAC/C,gBAAgB;IAChB,iBAAiB;IACjB,gBAAgB;IAChB,oBAAoB;IACpB,aAAa;IACb,gBAAgB;IAChB,qBAAqB;IACrB,yBAAyB;IACzB,qBAAqB;IACrB,sBAAsB;IACtB,qBAAqB;IACrB,eAAe;IACf,8BAA8B;IAC9B,uBAAuB;IACvB,2BAA2B;IAC3B,yBAAyB;IACzB,gBAAgB;IAChB,4BAA4B;IAC5B,sBAAsB;IACtB,kBAAkB;CACrB,CAAC","sourcesContent":["export const builtInRuleNames: readonly string[] = [\n 'no-any-unknown',\n 'no-implicit-any',\n 'max-file-lines',\n 'validate-ts-in-src',\n 'no-js-files',\n 'no-destructure',\n 'require-return-type',\n 'no-unmanaged-exceptions',\n 'catch-error-pattern',\n 'throw-cause-required',\n 'no-symbol-di-tokens',\n 'no-custom-css',\n 'no-process-exit-outside-main',\n 'branch-creation-guard',\n 'pr-creation-or-push-guard',\n 'merge-in-progress-guard',\n 'pr-merge-guard',\n 'redirect-how-to-merge-main',\n 'feature-branch-guard',\n 'main-stale-guard',\n];\n"]}
@@ -0,0 +1,75 @@
1
+ import { MainStaleGuardConfig } from '@webpieces/rules-config';
2
+ import type { FileContext, Violation } from '../types';
3
+ import { FileRuleBase } from '../rule-base';
4
+ import { FixHint } from '../fix-hint';
5
+ /**
6
+ * Blocks READS while you are sitting on a `main` that is behind `origin/main`.
7
+ *
8
+ * WHY READ, of all tools: a stale main means the AI reads stale FILE CONTENT and then reasons,
9
+ * plans and writes against code that no longer exists upstream. Blocking the write is too late —
10
+ * the bad premise is already in context. So the block lands on the read.
11
+ *
12
+ * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `git pull origin main`,
13
+ * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.
14
+ * So there is no command allowlist to maintain and no way to lock the agent out of its own fix.
15
+ * (`git pull origin main` is explicitly permitted on main by redirect-how-to-merge-main, which
16
+ * returns null when the branch IS main — the two guards are complementary, not stacked.)
17
+ *
18
+ * Everything here is FAIL-OPEN. A guard that blocks reads on bad data is far worse than one that
19
+ * misses; every unknown resolves to "allow". The four deliberate escape valves:
20
+ *
21
+ * 1. DIRTY TREE — uncommitted work on main means `git pull` is not a guaranteed fast-forward.
22
+ * Blocking reads there would trap the agent: it could not read the files it
23
+ * needs to resolve the very conflict blocking it. Allow.
24
+ * 2. CACHE LAG — we do NOT compare hashes for equality. The cached `originMain` is written by
25
+ * the detached refresher and is arbitrarily old, so `local !== origin` stays
26
+ * true for a while AFTER a successful pull, which would spin the agent forever.
27
+ * Instead: is the cached origin/main an ANCESTOR of local main? If local main
28
+ * already contains it, we are not behind. That flips the instant the pull lands,
29
+ * with no refresher round-trip. This is the single most important line here.
30
+ * 3. CONFIG READ — webpieces.config.json stays readable so the agent can always read-then-edit
31
+ * it to set `mode: OFF`. Its EDIT is already bypassed in runner.ts + hook-core;
32
+ * this closes the read half of that same escape hatch.
33
+ * 4. NO DATA — no cache, cache for another branch, empty originMain (offline), or no local
34
+ * main at all (fresh clone / worktree) → allow.
35
+ *
36
+ * Runs from the Read fast path in hook-core (Read is neither a file-edit nor a bash payload, so it
37
+ * never reaches the runner's rule loop). Fires the detached refresher on every call, which is also
38
+ * what makes reads keep the shared main-sync cache warm for feature-branch-guard.
39
+ */
40
+ export declare class MainStaleGuardRule extends FileRuleBase<MainStaleGuardConfig> {
41
+ constructor(config: MainStaleGuardConfig);
42
+ readonly description = "Block reads while on a `main` branch that is behind origin/main \u2014 stale main means the AI reads stale files.";
43
+ readonly files: string[];
44
+ readonly defaultOptions: {
45
+ hangTimeoutMinutes: number;
46
+ };
47
+ readonly fixHint: FixHint;
48
+ check(ctx: FileContext): readonly Violation[];
49
+ private contains;
50
+ private isDirty;
51
+ private isConfigFile;
52
+ private behindCount;
53
+ private staleMainMessage;
54
+ private cacheSummary;
55
+ private allow;
56
+ private block;
57
+ private logDecision;
58
+ /**
59
+ * The current branch, WITHOUT spawning git on the common path.
60
+ *
61
+ * This runs on EVERY read, so it is the one call whose cost actually matters. Spawning
62
+ * `git rev-parse --abbrev-ref HEAD` measures ~12ms — essentially all process-spawn overhead —
63
+ * whereas `.git/HEAD` is a single tiny file whose read is microseconds. On a feature branch
64
+ * (the overwhelmingly common case) that file read is the ONLY work this guard does before
65
+ * short-circuiting, so reads stay effectively free.
66
+ *
67
+ * Falls back to spawning git whenever `.git/HEAD` cannot answer authoritatively:
68
+ * - `.git` is a FILE, not a dir → we are in a worktree and HEAD lives elsewhere
69
+ * - detached HEAD → the file holds a raw sha, not a `ref:` line
70
+ * - anything unreadable/unexpected
71
+ * The fallback is correct in all those cases; it is just slower, and they are rare.
72
+ */
73
+ private currentBranch;
74
+ private branchFromGitHead;
75
+ }
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MainStaleGuardRule = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const child_process_1 = require("child_process");
6
+ const fs = tslib_1.__importStar(require("fs"));
7
+ const path = tslib_1.__importStar(require("path"));
8
+ const rules_config_1 = require("@webpieces/rules-config");
9
+ const types_1 = require("../types");
10
+ const rule_base_1 = require("../rule-base");
11
+ const fix_hint_1 = require("../fix-hint");
12
+ const to_error_1 = require("../to-error");
13
+ const main_sync_refresh_1 = require("../main-sync-refresh");
14
+ const decision_log_1 = require("../decision-log");
15
+ /**
16
+ * Blocks READS while you are sitting on a `main` that is behind `origin/main`.
17
+ *
18
+ * WHY READ, of all tools: a stale main means the AI reads stale FILE CONTENT and then reasons,
19
+ * plans and writes against code that no longer exists upstream. Blocking the write is too late —
20
+ * the bad premise is already in context. So the block lands on the read.
21
+ *
22
+ * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `git pull origin main`,
23
+ * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.
24
+ * So there is no command allowlist to maintain and no way to lock the agent out of its own fix.
25
+ * (`git pull origin main` is explicitly permitted on main by redirect-how-to-merge-main, which
26
+ * returns null when the branch IS main — the two guards are complementary, not stacked.)
27
+ *
28
+ * Everything here is FAIL-OPEN. A guard that blocks reads on bad data is far worse than one that
29
+ * misses; every unknown resolves to "allow". The four deliberate escape valves:
30
+ *
31
+ * 1. DIRTY TREE — uncommitted work on main means `git pull` is not a guaranteed fast-forward.
32
+ * Blocking reads there would trap the agent: it could not read the files it
33
+ * needs to resolve the very conflict blocking it. Allow.
34
+ * 2. CACHE LAG — we do NOT compare hashes for equality. The cached `originMain` is written by
35
+ * the detached refresher and is arbitrarily old, so `local !== origin` stays
36
+ * true for a while AFTER a successful pull, which would spin the agent forever.
37
+ * Instead: is the cached origin/main an ANCESTOR of local main? If local main
38
+ * already contains it, we are not behind. That flips the instant the pull lands,
39
+ * with no refresher round-trip. This is the single most important line here.
40
+ * 3. CONFIG READ — webpieces.config.json stays readable so the agent can always read-then-edit
41
+ * it to set `mode: OFF`. Its EDIT is already bypassed in runner.ts + hook-core;
42
+ * this closes the read half of that same escape hatch.
43
+ * 4. NO DATA — no cache, cache for another branch, empty originMain (offline), or no local
44
+ * main at all (fresh clone / worktree) → allow.
45
+ *
46
+ * Runs from the Read fast path in hook-core (Read is neither a file-edit nor a bash payload, so it
47
+ * never reaches the runner's rule loop). Fires the detached refresher on every call, which is also
48
+ * what makes reads keep the shared main-sync cache warm for feature-branch-guard.
49
+ */
50
+ class MainStaleGuardRule extends rule_base_1.FileRuleBase {
51
+ constructor(config) { super(config, 'main-stale-guard'); }
52
+ description = 'Block reads while on a `main` branch that is behind origin/main — stale main means the AI reads stale files.';
53
+ files = ['**/*'];
54
+ defaultOptions = {
55
+ hangTimeoutMinutes: rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES,
56
+ };
57
+ fixHint = new fix_hint_1.FixHint('You are on main and main is behind origin/main — reading files would give you stale content.', 'Bring main up to date before reading anything else:', [
58
+ new fix_hint_1.Option('git pull origin main ← the fix. Then simply retry the read.', true),
59
+ new fix_hint_1.Option('Still allowed right now: EVERY Bash command (installs, upgrades, builds), all Write/Edit, and reading webpieces.config.json.'),
60
+ new fix_hint_1.Option('Disable in webpieces.config.json under hookGuards → main-stale-guard (mode OFF) if intentional.'),
61
+ ]);
62
+ check(ctx) {
63
+ // Outside the workspace root — no jurisdiction.
64
+ if (ctx.relativePath.startsWith('..'))
65
+ return [];
66
+ const branch = this.currentBranch(ctx.workspaceRoot);
67
+ if (branch === null)
68
+ return this.allow(ctx, branch, 'branch-undeterminable (fail-open)');
69
+ // The whole guard is about a checked-out main. Anything else is feature-branch-guard's job.
70
+ if (branch !== 'main')
71
+ return this.allow(ctx, branch, 'not-on-main');
72
+ // Keep the shared cache warm for the next call. Detached; never blocks this read.
73
+ (0, main_sync_refresh_1.triggerMainSyncRefresh)(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES);
74
+ // Escape valve 3 — the read half of the config escape hatch.
75
+ if (this.isConfigFile(ctx.relativePath))
76
+ return this.allow(ctx, branch, 'webpieces-config-read (escape hatch)');
77
+ const status = (0, rules_config_1.readMainSyncStatus)(ctx.workspaceRoot);
78
+ if (status === null)
79
+ return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');
80
+ const cache = this.cacheSummary(status);
81
+ if (status.branch !== 'main')
82
+ return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);
83
+ // Offline / origin unresolvable, or no local main to compare against.
84
+ if (status.originMain === '')
85
+ return this.allow(ctx, branch, 'origin-main-unknown (fail-open)', cache);
86
+ // Escape valve 2 — ancestry, NOT equality. See the class comment.
87
+ if (this.contains(ctx.workspaceRoot, status.originMain)) {
88
+ return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);
89
+ }
90
+ // Escape valve 1 — a dirty tree means the pull is not a clean fast-forward; do not trap
91
+ // the agent away from the files it needs to resolve it.
92
+ if (this.isDirty(ctx.workspaceRoot)) {
93
+ return this.allow(ctx, branch, 'dirty-tree-on-main (fail-open)', cache);
94
+ }
95
+ return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);
96
+ }
97
+ // Is `commit` an ancestor of (i.e. already contained in) HEAD? Local-only and fast — no network.
98
+ //
99
+ // spawnSync, not execSync, precisely because the EXIT CODE is the answer and we must tell three
100
+ // outcomes apart: 0 = ancestor (up to date), 1 = cleanly NOT an ancestor (genuinely behind),
101
+ // anything else = git could not answer (bad/pruned object, not a repo) which must fail OPEN.
102
+ // execSync collapses 1 and "git broke" into the same thrown Error, so it cannot make that call.
103
+ // Arg-array form also means the commit hash is never parsed by a shell.
104
+ contains(workspaceRoot, commit) {
105
+ const result = (0, child_process_1.spawnSync)('git', ['merge-base', '--is-ancestor', commit, 'HEAD'], {
106
+ cwd: workspaceRoot,
107
+ encoding: 'utf8',
108
+ });
109
+ if (result.status === 0)
110
+ return true;
111
+ if (result.status === 1)
112
+ return false;
113
+ return true; // unknown/failed → treat as "contained" so the guard allows
114
+ }
115
+ isDirty(workspaceRoot) {
116
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
117
+ try {
118
+ const out = (0, child_process_1.execSync)('git status --porcelain', {
119
+ cwd: workspaceRoot,
120
+ encoding: 'utf8',
121
+ stdio: ['pipe', 'pipe', 'pipe'],
122
+ });
123
+ return out.trim().length > 0;
124
+ }
125
+ catch (err) {
126
+ const error = (0, to_error_1.toError)(err);
127
+ void error;
128
+ // Cannot tell → assume dirty, which is the fail-OPEN direction for this guard.
129
+ return true;
130
+ }
131
+ }
132
+ isConfigFile(relativePath) {
133
+ return relativePath === 'webpieces.config.json';
134
+ }
135
+ // How far behind we are, for the message. Best-effort — a bare "behind" reads fine without it.
136
+ behindCount(workspaceRoot) {
137
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
138
+ try {
139
+ const out = (0, child_process_1.execSync)('git rev-list --count HEAD..origin/main', {
140
+ cwd: workspaceRoot,
141
+ encoding: 'utf8',
142
+ stdio: ['pipe', 'pipe', 'pipe'],
143
+ }).trim();
144
+ return /^\d+$/.test(out) ? out : '?';
145
+ }
146
+ catch (err) {
147
+ const error = (0, to_error_1.toError)(err);
148
+ void error;
149
+ return '?';
150
+ }
151
+ }
152
+ staleMainMessage(workspaceRoot) {
153
+ return [
154
+ `You are on main and main is ${this.behindCount(workspaceRoot)} commit(s) behind origin/main.`,
155
+ 'Reading files right now would give you STALE content and everything you plan from it',
156
+ 'would be built on code that no longer exists upstream. Reads are blocked until you update.',
157
+ '',
158
+ 'Run exactly this, then retry the read:',
159
+ ' git pull origin main',
160
+ '',
161
+ 'Still allowed while this block is up:',
162
+ ' - EVERY Bash command (pnpm install, any webpieces upgrade, builds, all git/gh)',
163
+ ' - All Write/Edit (feature-branch-guard governs those separately)',
164
+ ' - Reading and editing webpieces.config.json (set main-stale-guard mode OFF to disable)',
165
+ ].join('\n');
166
+ }
167
+ cacheSummary(status) {
168
+ return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} ts=${status.timestamp}`;
169
+ }
170
+ allow(ctx, branch, reason, cache = '-') {
171
+ this.logDecision(ctx, branch, 'ALLOW', reason, cache);
172
+ return [];
173
+ }
174
+ block(ctx, branch, reason, message, cache = '-') {
175
+ this.logDecision(ctx, branch, 'BLOCK', reason, cache);
176
+ return [new types_1.Violation(1, ctx.relativePath, message)];
177
+ }
178
+ logDecision(ctx, branch, verdict, reason, cache) {
179
+ (0, decision_log_1.logGuardDecision)(ctx.workspaceRoot, new decision_log_1.GuardDecision('main-stale-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache));
180
+ }
181
+ /**
182
+ * The current branch, WITHOUT spawning git on the common path.
183
+ *
184
+ * This runs on EVERY read, so it is the one call whose cost actually matters. Spawning
185
+ * `git rev-parse --abbrev-ref HEAD` measures ~12ms — essentially all process-spawn overhead —
186
+ * whereas `.git/HEAD` is a single tiny file whose read is microseconds. On a feature branch
187
+ * (the overwhelmingly common case) that file read is the ONLY work this guard does before
188
+ * short-circuiting, so reads stay effectively free.
189
+ *
190
+ * Falls back to spawning git whenever `.git/HEAD` cannot answer authoritatively:
191
+ * - `.git` is a FILE, not a dir → we are in a worktree and HEAD lives elsewhere
192
+ * - detached HEAD → the file holds a raw sha, not a `ref:` line
193
+ * - anything unreadable/unexpected
194
+ * The fallback is correct in all those cases; it is just slower, and they are rare.
195
+ */
196
+ currentBranch(workspaceRoot) {
197
+ const fromHead = this.branchFromGitHead(workspaceRoot);
198
+ if (fromHead !== null)
199
+ return fromHead;
200
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
201
+ try {
202
+ return (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
203
+ cwd: workspaceRoot,
204
+ encoding: 'utf8',
205
+ stdio: ['pipe', 'pipe', 'pipe'],
206
+ }).trim();
207
+ }
208
+ catch (err) {
209
+ const error = (0, to_error_1.toError)(err);
210
+ void error;
211
+ return null;
212
+ }
213
+ }
214
+ // Parse `.git/HEAD` ("ref: refs/heads/<branch>"). null = cannot answer, caller must fall back.
215
+ branchFromGitHead(workspaceRoot) {
216
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
217
+ try {
218
+ const gitPath = path.join(workspaceRoot, '.git');
219
+ // A worktree/submodule has `.git` as a file pointing at the real gitdir — HEAD is not here.
220
+ if (!fs.statSync(gitPath).isDirectory())
221
+ return null;
222
+ const head = fs.readFileSync(path.join(gitPath, 'HEAD'), 'utf8').trim();
223
+ const match = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
224
+ return match ? match[1] : null; // no match = detached HEAD → fall back
225
+ }
226
+ catch (err) {
227
+ const error = (0, to_error_1.toError)(err);
228
+ void error;
229
+ return null;
230
+ }
231
+ }
232
+ }
233
+ exports.MainStaleGuardRule = MainStaleGuardRule;
234
+ //# sourceMappingURL=main-stale-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main-stale-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/main-stale-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync, spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport {\n MainStaleGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n MainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\n\n/**\n * Blocks READS while you are sitting on a `main` that is behind `origin/main`.\n *\n * WHY READ, of all tools: a stale main means the AI reads stale FILE CONTENT and then reasons,\n * plans and writes against code that no longer exists upstream. Blocking the write is too late —\n * the bad premise is already in context. So the block lands on the read.\n *\n * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `git pull origin main`,\n * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.\n * So there is no command allowlist to maintain and no way to lock the agent out of its own fix.\n * (`git pull origin main` is explicitly permitted on main by redirect-how-to-merge-main, which\n * returns null when the branch IS main — the two guards are complementary, not stacked.)\n *\n * Everything here is FAIL-OPEN. A guard that blocks reads on bad data is far worse than one that\n * misses; every unknown resolves to \"allow\". The four deliberate escape valves:\n *\n * 1. DIRTY TREE — uncommitted work on main means `git pull` is not a guaranteed fast-forward.\n * Blocking reads there would trap the agent: it could not read the files it\n * needs to resolve the very conflict blocking it. Allow.\n * 2. CACHE LAG — we do NOT compare hashes for equality. The cached `originMain` is written by\n * the detached refresher and is arbitrarily old, so `local !== origin` stays\n * true for a while AFTER a successful pull, which would spin the agent forever.\n * Instead: is the cached origin/main an ANCESTOR of local main? If local main\n * already contains it, we are not behind. That flips the instant the pull lands,\n * with no refresher round-trip. This is the single most important line here.\n * 3. CONFIG READ — webpieces.config.json stays readable so the agent can always read-then-edit\n * it to set `mode: OFF`. Its EDIT is already bypassed in runner.ts + hook-core;\n * this closes the read half of that same escape hatch.\n * 4. NO DATA — no cache, cache for another branch, empty originMain (offline), or no local\n * main at all (fresh clone / worktree) → allow.\n *\n * Runs from the Read fast path in hook-core (Read is neither a file-edit nor a bash payload, so it\n * never reaches the runner's rule loop). Fires the detached refresher on every call, which is also\n * what makes reads keep the shared main-sync cache warm for feature-branch-guard.\n */\nexport class MainStaleGuardRule extends FileRuleBase<MainStaleGuardConfig> {\n constructor(config: MainStaleGuardConfig) { super(config, 'main-stale-guard'); }\n\n readonly description = 'Block reads while on a `main` branch that is behind origin/main — stale main means the AI reads stale files.';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'You are on main and main is behind origin/main — reading files would give you stale content.',\n 'Bring main up to date before reading anything else:',\n [\n new Option('git pull origin main ← the fix. Then simply retry the read.', true),\n new Option('Still allowed right now: EVERY Bash command (installs, upgrades, builds), all Write/Edit, and reading webpieces.config.json.'),\n new Option('Disable in webpieces.config.json under hookGuards → main-stale-guard (mode OFF) if intentional.'),\n ],\n );\n\n check(ctx: FileContext): readonly Violation[] {\n // Outside the workspace root — no jurisdiction.\n if (ctx.relativePath.startsWith('..')) return [];\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.allow(ctx, branch, 'branch-undeterminable (fail-open)');\n // The whole guard is about a checked-out main. Anything else is feature-branch-guard's job.\n if (branch !== 'main') return this.allow(ctx, branch, 'not-on-main');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this read.\n triggerMainSyncRefresh(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n // Escape valve 3 — the read half of the config escape hatch.\n if (this.isConfigFile(ctx.relativePath)) return this.allow(ctx, branch, 'webpieces-config-read (escape hatch)');\n\n const status = readMainSyncStatus(ctx.workspaceRoot);\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n if (status.branch !== 'main') return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);\n // Offline / origin unresolvable, or no local main to compare against.\n if (status.originMain === '') return this.allow(ctx, branch, 'origin-main-unknown (fail-open)', cache);\n\n // Escape valve 2 — ancestry, NOT equality. See the class comment.\n if (this.contains(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // Escape valve 1 — a dirty tree means the pull is not a clean fast-forward; do not trap\n // the agent away from the files it needs to resolve it.\n if (this.isDirty(ctx.workspaceRoot)) {\n return this.allow(ctx, branch, 'dirty-tree-on-main (fail-open)', cache);\n }\n\n return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);\n }\n\n // Is `commit` an ancestor of (i.e. already contained in) HEAD? Local-only and fast — no network.\n //\n // spawnSync, not execSync, precisely because the EXIT CODE is the answer and we must tell three\n // outcomes apart: 0 = ancestor (up to date), 1 = cleanly NOT an ancestor (genuinely behind),\n // anything else = git could not answer (bad/pruned object, not a repo) which must fail OPEN.\n // execSync collapses 1 and \"git broke\" into the same thrown Error, so it cannot make that call.\n // Arg-array form also means the commit hash is never parsed by a shell.\n private contains(workspaceRoot: string, commit: string): boolean {\n const result = spawnSync('git', ['merge-base', '--is-ancestor', commit, 'HEAD'], {\n cwd: workspaceRoot,\n encoding: 'utf8',\n });\n if (result.status === 0) return true;\n if (result.status === 1) return false;\n return true; // unknown/failed → treat as \"contained\" so the guard allows\n }\n\n private isDirty(workspaceRoot: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git status --porcelain', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return out.trim().length > 0;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // Cannot tell → assume dirty, which is the fail-OPEN direction for this guard.\n return true;\n }\n }\n\n private isConfigFile(relativePath: string): boolean {\n return relativePath === 'webpieces.config.json';\n }\n\n // How far behind we are, for the message. Best-effort — a bare \"behind\" reads fine without it.\n private behindCount(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git rev-list --count HEAD..origin/main', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return /^\\d+$/.test(out) ? out : '?';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '?';\n }\n }\n\n private staleMainMessage(workspaceRoot: string): string {\n return [\n `You are on main and main is ${this.behindCount(workspaceRoot)} commit(s) behind origin/main.`,\n 'Reading files right now would give you STALE content and everything you plan from it',\n 'would be built on code that no longer exists upstream. Reads are blocked until you update.',\n '',\n 'Run exactly this, then retry the read:',\n ' git pull origin main',\n '',\n 'Still allowed while this block is up:',\n ' - EVERY Bash command (pnpm install, any webpieces upgrade, builds, all git/gh)',\n ' - All Write/Edit (feature-branch-guard governs those separately)',\n ' - Reading and editing webpieces.config.json (set main-stale-guard mode OFF to disable)',\n ].join('\\n');\n }\n\n private cacheSummary(status: MainSyncStatus): string {\n return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} ts=${status.timestamp}`;\n }\n\n private allow(ctx: FileContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: FileContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK', reason, cache);\n return [new V(1, ctx.relativePath, message)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: 'ALLOW' | 'BLOCK', reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('main-stale-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache),\n );\n }\n\n /**\n * The current branch, WITHOUT spawning git on the common path.\n *\n * This runs on EVERY read, so it is the one call whose cost actually matters. Spawning\n * `git rev-parse --abbrev-ref HEAD` measures ~12ms — essentially all process-spawn overhead —\n * whereas `.git/HEAD` is a single tiny file whose read is microseconds. On a feature branch\n * (the overwhelmingly common case) that file read is the ONLY work this guard does before\n * short-circuiting, so reads stay effectively free.\n *\n * Falls back to spawning git whenever `.git/HEAD` cannot answer authoritatively:\n * - `.git` is a FILE, not a dir → we are in a worktree and HEAD lives elsewhere\n * - detached HEAD → the file holds a raw sha, not a `ref:` line\n * - anything unreadable/unexpected\n * The fallback is correct in all those cases; it is just slower, and they are rare.\n */\n private currentBranch(workspaceRoot: string): string | null {\n const fromHead = this.branchFromGitHead(workspaceRoot);\n if (fromHead !== null) return fromHead;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n // Parse `.git/HEAD` (\"ref: refs/heads/<branch>\"). null = cannot answer, caller must fall back.\n private branchFromGitHead(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const gitPath = path.join(workspaceRoot, '.git');\n // A worktree/submodule has `.git` as a file pointing at the real gitdir — HEAD is not here.\n if (!fs.statSync(gitPath).isDirectory()) return null;\n const head = fs.readFileSync(path.join(gitPath, 'HEAD'), 'utf8').trim();\n const match = /^ref:\\s*refs\\/heads\\/(.+)$/.exec(head);\n return match ? match[1] : null; // no match = detached HEAD → fall back\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
@@ -4,4 +4,17 @@ export declare function filterByExcludedPaths(rules: readonly Rule[], relativePa
4
4
  export declare function isGitOrGhCommand(command: string): boolean;
5
5
  export declare function run(toolKind: ToolKind, input: NormalizedToolInput, cwd: string, mode?: HookMode): BlockedResult | null;
6
6
  export declare function runBash(command: string, cwd: string, mode?: HookMode): BlockedResult | null;
7
+ /**
8
+ * The Read path. Deliberately NOT `run()`:
9
+ *
10
+ * - NO config-sync check. A rule present in code but missing from webpieces.config.json blocks
11
+ * every Write/Edit/Bash by design — but applying that to Read would mean an upgrade that adds
12
+ * any new rule instantly blocks the agent from reading the very config file it must edit to fix
13
+ * it. Reads must never carry that failure mode.
14
+ * - NO general rule loop. Only READ_SCOPED_GUARDS run, so no code-style rule can ever see a Read.
15
+ * - Fails OPEN everywhere, including on a thrown rule (the caller catches and allows).
16
+ *
17
+ * Returns null (allow) unless the one guard fires.
18
+ */
19
+ export declare function runRead(filePath: string, cwd: string, mode?: HookMode): BlockedResult | null;
7
20
  export declare function runRuleCheck(rule: Rule, ctx: EditContext | FileContext | BashContext): readonly Violation[];
@@ -4,6 +4,7 @@ exports.filterByExcludedPaths = filterByExcludedPaths;
4
4
  exports.isGitOrGhCommand = isGitOrGhCommand;
5
5
  exports.run = run;
6
6
  exports.runBash = runBash;
7
+ exports.runRead = runRead;
7
8
  exports.runRuleCheck = runRuleCheck;
8
9
  const tslib_1 = require("tslib");
9
10
  const path = tslib_1.__importStar(require("path"));
@@ -105,6 +106,47 @@ function runInternal(toolKind, input, cwd, mode) {
105
106
  function runBash(command, cwd, mode = 'all') {
106
107
  return runBashInternal(command, cwd, mode);
107
108
  }
109
+ // The name of the ONLY rule permitted to block a Read. Reads are the highest-blast-radius tool
110
+ // there is, so this path is an explicit single-rule allowlist rather than the general rule loop.
111
+ const READ_SCOPED_GUARDS = new Set(['main-stale-guard']);
112
+ /**
113
+ * The Read path. Deliberately NOT `run()`:
114
+ *
115
+ * - NO config-sync check. A rule present in code but missing from webpieces.config.json blocks
116
+ * every Write/Edit/Bash by design — but applying that to Read would mean an upgrade that adds
117
+ * any new rule instantly blocks the agent from reading the very config file it must edit to fix
118
+ * it. Reads must never carry that failure mode.
119
+ * - NO general rule loop. Only READ_SCOPED_GUARDS run, so no code-style rule can ever see a Read.
120
+ * - Fails OPEN everywhere, including on a thrown rule (the caller catches and allows).
121
+ *
122
+ * Returns null (allow) unless the one guard fires.
123
+ */
124
+ // webpieces-disable no-function-outside-class -- sibling of run()/runBash() in this module; the whole runner is module-scope functions and a lone class for this one entry point would break the file's shape
125
+ function runRead(filePath, cwd, mode = 'all') {
126
+ // Code-style mode has nothing to say about a read.
127
+ if (mode === 'rules')
128
+ return null;
129
+ const loaded = (0, rules_config_1.loadAndValidate)(cwd);
130
+ // No config → nothing to enforce. Unlike the edit path we do NOT block: an unconfigured repo
131
+ // must still be readable.
132
+ if (loaded.configPath === null)
133
+ return null;
134
+ const workspaceRoot = path.dirname(loaded.configPath);
135
+ // Same git-repo-boundary governance as bash: a read inside a different clone is out of scope.
136
+ const gitRoot = gitToplevel(cwd);
137
+ if (gitRoot !== null && path.resolve(gitRoot) !== path.resolve(workspaceRoot))
138
+ return null;
139
+ const relativePath = path.relative(workspaceRoot, filePath);
140
+ const all = (0, load_rules_1.loadRules)(loaded.rulesConfig, workspaceRoot);
141
+ const rules = filterByExcludedPaths(all.filter((r) => READ_SCOPED_GUARDS.has(r.name)), relativePath, loaded.excludePaths);
142
+ if (rules.length === 0)
143
+ return null;
144
+ const ctx = new types_1.FileContext('Read', filePath, relativePath, workspaceRoot, 0, 0, 0, 0);
145
+ const groups = runFileRules(rules, ctx);
146
+ if (groups.length === 0)
147
+ return null;
148
+ return new types_1.BlockedResult((0, report_1.formatReport)(relativePath, groups));
149
+ }
108
150
  // Installer bypass — package-manager install commands ALWAYS pass, ahead of any config load. A
109
151
  // webpieces.config.json that is ahead of the installed validator (new rule tokens the published
110
152
  // binary doesn't know yet) makes loadAndValidate() throw and would deny `pnpm install` — the very
@@ -1 +1 @@
1
- {"version":3,"file":"runner.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/runner.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport { spawnSync } from 'child_process';\n\nimport { loadAndValidate, WebpiecesRulesConfig, ExcludePaths, isHookGuard, DEFAULT_HANG_TIMEOUT_MINUTES, RepoRootFinder } from '@webpieces/rules-config';\n\nimport { buildContexts, buildBashContext } from './build-context';\nimport { loadRules, loadMatchRules, globMatches } from './load-rules';\nimport { MatchRule } from './rules/match-rule';\nimport { triggerMainSyncRefresh } from './main-sync-refresh';\nimport { logGuardDecision, GuardDecision, branchForLog } from './decision-log';\nimport { toError } from './to-error';\nimport { formatReport } from './report';\nimport { INSTALLER_ALLOW_JS } from '../bin/shim';\nimport {\n ToolKind, NormalizedToolInput, BlockedResult, HookMode,\n Rule, Violation, RuleGroup, RuleFailError,\n EditContext, FileContext, BashContext,\n} from './types';\n\n// Restrict loaded rules to the category this hook invocation runs. The two split hooks each pass a\n// disjoint category ('rules' = code-style, 'guards' = the hookGuards section); 'all' runs both (the\n// openclaw plugin adapter, a single before_tool_call hook). isHookGuard is the shared classifier in\n// @webpieces/rules-config.\nfunction filterByMode(rules: readonly Rule[], mode: HookMode): readonly Rule[] {\n if (mode === 'all') return rules;\n if (mode === 'guards') return rules.filter((r: Rule): boolean => isHookGuard(r.name));\n return rules.filter((r: Rule): boolean => !isHookGuard(r.name));\n}\n\n// Drop rules whose category is excluded for this file path (webpieces.config.json → excludePaths).\n// Two independent glob lists: `guards` suppresses file-scoped guards (e.g. feature-branch-guard),\n// `rules` suppresses code-style rules — so a vendored tree can be exempt from one but not the other.\n// Only file tools reach here; bash git/PR guards (no file path) are never affected.\nexport function filterByExcludedPaths(rules: readonly Rule[], relativePath: string, ex: ExcludePaths): readonly Rule[] {\n return rules.filter((r: Rule): boolean => {\n const patterns = isHookGuard(r.name) ? ex.guards : ex.rules;\n return !patterns.some((p: string): boolean => globMatches(p, relativePath));\n });\n}\n\n// The git repo root of `cwd`, or null if cwd is not in a git repo / git is unavailable. This is the\n// repo-boundary signal: the guards only govern commands whose repo IS the one this webpieces.config\n// governs; a command run inside a nested clone (different git root) is out of scope.\nfunction gitToplevel(cwd: string): string | null {\n const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });\n return r.status === 0 ? (r.stdout ?? '').trim() : null;\n}\n\n// A git or gh invocation anywhere in the command (start, or after a ;/&&/|| separator or pipe).\nconst GIT_OR_GH_RE = /(?:^|[;&|]\\s*)(?:git|gh)\\b/;\nexport function isGitOrGhCommand(command: string): boolean {\n return GIT_OR_GH_RE.test(command);\n}\n\n// Fire-and-forget the detached refresher when feature-branch-guard is loaded and active, so the\n// cache (.webpieces/main-sync-status.json) stays fresh as the AI works. The guard rule itself also\n// triggers this on Write/Edit; this covers the Bash path so the cache is warm on every command.\nfunction maybeRefreshMainSync(rules: readonly Rule[], workspaceRoot: string): void {\n const guard = rules.find((r: Rule): boolean => r.name === 'feature-branch-guard');\n if (guard && guard.shouldRun()) {\n triggerMainSyncRefresh(workspaceRoot, DEFAULT_HANG_TIMEOUT_MINUTES);\n }\n}\n\nconst CONFIG_MISSING_REPORT =\n 'webpieces.config.json not found.\\n' +\n 'Tell the human: run `./node_modules/.bin/wp-setup-ai-hooks` to initialize the project configuration.\\n' +\n 'Do not proceed until the human has done this.';\n\nexport function run(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n cwd: string,\n mode: HookMode = 'all',\n): BlockedResult | null {\n return runInternal(toolKind, input, cwd, mode);\n}\n\nfunction runInternal(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n cwd: string,\n mode: HookMode,\n): BlockedResult | null {\n const loaded = loadAndValidate(cwd);\n if (loaded.configPath === null) return new BlockedResult(CONFIG_MISSING_REPORT);\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Always allow edits to webpieces.config.json — it's the fix target when out of sync\n if (path.resolve(input.filePath) === path.resolve(loaded.configPath)) {\n return null;\n }\n\n // Built-in/custom rules PLUS the client-authored match-rules (content guards). Match-rules run only\n // in the file-edit path (they are code-style, so filterByMode keeps them out of the bash/guards path).\n const allRules = [...loadRules(loaded.rulesConfig, workspaceRoot), ...loadMatchRules(loaded.matchRules)];\n const modeRules = filterByMode(allRules, mode);\n if (modeRules.length === 0) return null;\n\n // Suppress enforcement for files under this category's excludePaths (e.g. vendored repos under\n // repositories/**). Exclusion is all-or-nothing per category, so an excluded file drops the whole\n // rule set and is fully hands-off — no violations AND no config-sync nag on those files.\n const relativePath = path.relative(workspaceRoot, input.filePath);\n const rules = filterByExcludedPaths(modeRules, relativePath, loaded.excludePaths);\n if (rules.length === 0) return null;\n\n // Config-sync applies only to built-in/custom rules; match-rules have their own validated section\n // (loadAndValidate already rejected an invalid `match-rules`), so they must not trip the sync nag.\n const outOfSync = checkConfigSync(rules.filter((r: Rule) => !(r instanceof MatchRule)), loaded.rulesConfig);\n if (outOfSync) return outOfSync;\n\n const contexts = buildContexts(toolKind, input, workspaceRoot);\n\n const editGroups = runEditRules(rules, contexts.editContexts);\n const fileGroups = runFileRules(rules, contexts.fileContext);\n const allGroups = [...editGroups, ...fileGroups];\n\n if (allGroups.length === 0) return null;\n\n const report = formatReport(relativePath, allGroups);\n return new BlockedResult(report);\n}\n\nexport function runBash(command: string, cwd: string, mode: HookMode = 'all'): BlockedResult | null {\n return runBashInternal(command, cwd, mode);\n}\n\n// Installer bypass — package-manager install commands ALWAYS pass, ahead of any config load. A\n// webpieces.config.json that is ahead of the installed validator (new rule tokens the published\n// binary doesn't know yet) makes loadAndValidate() throw and would deny `pnpm install` — the very\n// command that updates the validator (deadlock). Mirrors the fail-closed shim's INSTALLER_ALLOW_ERE\n// (missing-bin case); INSTALLER_ALLOW_JS is its locked JS twin. Match is tight (`pnpm install` /\n// `npm i` + `--flags` only, no chaining) so `pnpm install && rm -rf /` still falls to the guards.\nfunction isInstallerCommand(command: string): boolean {\n return INSTALLER_ALLOW_JS.test(command.trim());\n}\n\nfunction runBashInternal(command: string, cwd: string, mode: HookMode): BlockedResult | null {\n if (isInstallerCommand(command)) {\n // Anchor the audit-log write at the repo root that owns `.webpieces` (config-walk-up first,\n // then git toplevel) so a bypass logged from a subdir/nested clone never scatters a stray\n // `.webpieces` tree. This runs before loadAndValidate, so resolveRepoRoot (not workspaceRoot).\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(root, new GuardDecision('-', 'Bash', command, branchForLog(root), 'ALLOW', 'installer bypass (always allowed)'));\n return null;\n }\n\n const loaded = loadAndValidate(cwd);\n if (loaded.configPath === null) return new BlockedResult(CONFIG_MISSING_REPORT);\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Git-repo-boundary governance. The hook now always runs (via $CLAUDE_PROJECT_DIR), so this is\n // where out-of-scope work is let through deliberately instead of the old accidental 127.\n const gitRoot = gitToplevel(cwd);\n if (gitRoot !== null && path.resolve(gitRoot) !== path.resolve(workspaceRoot)) {\n // cwd is inside a DIFFERENT git repo than this webpieces.config governs (e.g. a clone under\n // repositories/). Out of scope → allow, hands-off. Intentional, not a silent hole.\n logGuardDecision(workspaceRoot, new GuardDecision('-', 'Bash', command, branchForLog(workspaceRoot), 'ALLOW', 'foreign git repo (out of scope)'));\n return null;\n }\n\n const rules = filterByMode(loadRules(loaded.rulesConfig, workspaceRoot), mode);\n if (rules.length === 0) return null;\n\n const outOfSync = checkConfigSync(rules, loaded.rulesConfig);\n if (outOfSync) return outOfSync;\n\n // Force-to-root: git/gh commands must run from the repo root, where the guards can reason about\n // git state coherently. From a subdir, BLOCK with an actionable cd message — never silently skip.\n if (isGitOrGhCommand(command) && path.resolve(cwd) !== path.resolve(workspaceRoot)) {\n const report =\n `❌ Run git/gh commands from the repo root, not a subdirectory.\\n` +\n ` You are in: ${cwd}\\n` +\n ` cd to the repo root first: cd ${workspaceRoot}\\n` +\n ` Then re-run your command. (The webpieces guards evaluate the repo's git state at its root.)`;\n logGuardDecision(workspaceRoot, new GuardDecision('force-to-root', 'Bash', command, branchForLog(workspaceRoot), 'BLOCK', 'git/gh from subdir'));\n return new BlockedResult(report);\n }\n\n // Keep the feature-branch-guard cache warm on EVERY command (not just Write/Edit): the AI runs\n // far more bash than edits, so refreshing here means the guard's next file-edit check reads a\n // fresh status. Detached + fire-and-forget — never blocks the command. Only when the guard is\n // loaded (guards/all mode) and enabled, so a project that opted out never triggers git fetches.\n maybeRefreshMainSync(rules, workspaceRoot);\n\n const ctx = buildBashContext(command, workspaceRoot);\n const groups = runBashRules(rules, ctx);\n if (groups.length === 0) {\n // Record the ALLOW only for git/gh commands — the operations the bash guards actually reason\n // about (branch create, commit, push, merge, PR). Skipping ls/cat/grep keeps the audit log\n // focused (the whole point of the log is \"why did/didn't a guard fire?\"). Blocks are always\n // logged below.\n if (/\\b(?:git|gh)\\b/.test(command)) {\n logGuardDecision(workspaceRoot, new GuardDecision('-', 'Bash', command, branchForLog(workspaceRoot), 'ALLOW', 'no bash-guard block'));\n }\n return null;\n }\n\n const ruleNames = groups.map((g: RuleGroup): string => g.ruleName).join(',');\n logGuardDecision(workspaceRoot, new GuardDecision(ruleNames, 'Bash', command, branchForLog(workspaceRoot), 'BLOCK', 'bash-guard block'));\n const report = formatReport('<bash>', groups);\n return new BlockedResult(report);\n}\n\n// The set of rule names explicitly present in webpieces.config.json (every key except rulesDir).\nfunction configuredRuleNames(config: WebpiecesRulesConfig): ReadonlySet<string> {\n return new Set(Object.keys(config).filter((k: string) => k !== 'rulesDir'));\n}\n\nfunction checkConfigSync(rules: readonly Rule[], config: WebpiecesRulesConfig): BlockedResult | null {\n const configured = configuredRuleNames(config);\n const unconfiguredRules = rules.filter((r: Rule) => !configured.has(r.name));\n if (unconfiguredRules.length === 0) return null;\n\n const lines = [\n 'webpieces.config.json is out of sync — new built-in rules are present that have no entry in webpieces.config.json.',\n '',\n 'Tell the human: the following rules need to be configured. Ask for each one:',\n ' - Should this rule be ON, OFF, NEW_AND_MODIFIED_CODE, or NEW_AND_MODIFIED_FILES?',\n ' - What values do you want for the options listed below?',\n 'Then update webpieces.config.json and retry.',\n '',\n 'Do NOT proceed until webpieces.config.json has an entry for every rule below.',\n '',\n ];\n\n for (const rule of unconfiguredRules) {\n lines.push(`--- ${rule.name} ---`);\n lines.push(`Description: ${rule.description}`);\n const opts = rule.defaultOptions;\n const optKeys = Object.keys(opts);\n if (optKeys.length > 0) {\n lines.push(`Available options (suggested defaults shown):`);\n for (const key of optKeys) {\n lines.push(` ${key}: ${JSON.stringify(opts[key])}`);\n }\n } else {\n lines.push('Available options: none beyond mode');\n }\n lines.push(`Example entry for webpieces.config.json:`);\n lines.push(` \"${rule.name}\": { \"mode\": \"ON\" }`);\n lines.push('');\n }\n\n return new BlockedResult(lines.join('\\n'));\n}\n\n// N-legs pattern: each rule runs independently so one rule can never abort the others. A rule may\n// EITHER return Violation[] OR throw — both accumulate here into visible violations the AI sees:\n// - a thrown RuleFailError → an expected, well-formed violation (its line/snippet/fixHints kept);\n// - a thrown plain Error → a \"crashed\" violation (a bug, surfaced not swallowed).\nexport function runRuleCheck(rule: Rule, ctx: EditContext | FileContext | BashContext): readonly Violation[] {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return rule.check(ctx);\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof RuleFailError) {\n return [violationFromRuleFail(error)];\n }\n return [new Violation(0, '', `Rule '${rule.name}' crashed: ${error.message}`)];\n }\n}\n\n// A thrown RuleFailError carries its own AI-facing message + optional location and fix hints. Fold the\n// fix hints into the message because Violation has no fixHint field (RuleGroup's fixHint comes from the\n// rule definition, not a per-throw value).\nfunction violationFromRuleFail(error: RuleFailError): Violation {\n const hints = error.fixHints.length > 0 ? `\\n Fix: ${error.fixHints.join('\\n Fix: ')}` : '';\n return new Violation(error.line ?? 0, error.snippet ?? '', error.aiMessage + hints);\n}\n\nfunction ruleMatchesFile(rule: Rule, relativePath: string): boolean {\n for (const pattern of rule.files) {\n if (globMatches(pattern, relativePath)) return true;\n }\n return false;\n}\n\nfunction runBashRules(rules: readonly Rule[], bashContext: BashContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'bash') continue;\n if (!rule.shouldRun()) continue;\n const vs = runRuleCheck(rule, bashContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n\nfunction runEditRules(rules: readonly Rule[], editContexts: readonly EditContext[]): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'edit') continue;\n if (!rule.shouldRun()) continue;\n const allViolations: Violation[] = [];\n for (const ctx of editContexts) {\n if (!ruleMatchesFile(rule, ctx.relativePath)) continue;\n const vs = runRuleCheck(rule, ctx);\n for (const v of vs) {\n const copy = new Violation(v.line, v.snippet, v.message);\n copy.editIndex = ctx.editIndex;\n copy.editCount = ctx.editCount;\n allViolations.push(copy);\n }\n }\n if (allViolations.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, allViolations,\n ));\n }\n }\n return groups;\n}\n\nfunction runFileRules(rules: readonly Rule[], fileContext: FileContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'file') continue;\n if (!rule.shouldRun()) continue;\n if (!ruleMatchesFile(rule, fileContext.relativePath)) continue;\n const vs = runRuleCheck(rule, fileContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n"]}
1
+ {"version":3,"file":"runner.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/runner.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport { spawnSync } from 'child_process';\n\nimport { loadAndValidate, WebpiecesRulesConfig, ExcludePaths, isHookGuard, DEFAULT_HANG_TIMEOUT_MINUTES, RepoRootFinder } from '@webpieces/rules-config';\n\nimport { buildContexts, buildBashContext } from './build-context';\nimport { loadRules, loadMatchRules, globMatches } from './load-rules';\nimport { MatchRule } from './rules/match-rule';\nimport { triggerMainSyncRefresh } from './main-sync-refresh';\nimport { logGuardDecision, GuardDecision, branchForLog } from './decision-log';\nimport { toError } from './to-error';\nimport { formatReport } from './report';\nimport { INSTALLER_ALLOW_JS } from '../bin/shim';\nimport {\n ToolKind, NormalizedToolInput, BlockedResult, HookMode,\n Rule, Violation, RuleGroup, RuleFailError,\n EditContext, FileContext, BashContext,\n} from './types';\n\n// Restrict loaded rules to the category this hook invocation runs. The two split hooks each pass a\n// disjoint category ('rules' = code-style, 'guards' = the hookGuards section); 'all' runs both (the\n// openclaw plugin adapter, a single before_tool_call hook). isHookGuard is the shared classifier in\n// @webpieces/rules-config.\nfunction filterByMode(rules: readonly Rule[], mode: HookMode): readonly Rule[] {\n if (mode === 'all') return rules;\n if (mode === 'guards') return rules.filter((r: Rule): boolean => isHookGuard(r.name));\n return rules.filter((r: Rule): boolean => !isHookGuard(r.name));\n}\n\n// Drop rules whose category is excluded for this file path (webpieces.config.json → excludePaths).\n// Two independent glob lists: `guards` suppresses file-scoped guards (e.g. feature-branch-guard),\n// `rules` suppresses code-style rules — so a vendored tree can be exempt from one but not the other.\n// Only file tools reach here; bash git/PR guards (no file path) are never affected.\nexport function filterByExcludedPaths(rules: readonly Rule[], relativePath: string, ex: ExcludePaths): readonly Rule[] {\n return rules.filter((r: Rule): boolean => {\n const patterns = isHookGuard(r.name) ? ex.guards : ex.rules;\n return !patterns.some((p: string): boolean => globMatches(p, relativePath));\n });\n}\n\n// The git repo root of `cwd`, or null if cwd is not in a git repo / git is unavailable. This is the\n// repo-boundary signal: the guards only govern commands whose repo IS the one this webpieces.config\n// governs; a command run inside a nested clone (different git root) is out of scope.\nfunction gitToplevel(cwd: string): string | null {\n const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });\n return r.status === 0 ? (r.stdout ?? '').trim() : null;\n}\n\n// A git or gh invocation anywhere in the command (start, or after a ;/&&/|| separator or pipe).\nconst GIT_OR_GH_RE = /(?:^|[;&|]\\s*)(?:git|gh)\\b/;\nexport function isGitOrGhCommand(command: string): boolean {\n return GIT_OR_GH_RE.test(command);\n}\n\n// Fire-and-forget the detached refresher when feature-branch-guard is loaded and active, so the\n// cache (.webpieces/main-sync-status.json) stays fresh as the AI works. The guard rule itself also\n// triggers this on Write/Edit; this covers the Bash path so the cache is warm on every command.\nfunction maybeRefreshMainSync(rules: readonly Rule[], workspaceRoot: string): void {\n const guard = rules.find((r: Rule): boolean => r.name === 'feature-branch-guard');\n if (guard && guard.shouldRun()) {\n triggerMainSyncRefresh(workspaceRoot, DEFAULT_HANG_TIMEOUT_MINUTES);\n }\n}\n\nconst CONFIG_MISSING_REPORT =\n 'webpieces.config.json not found.\\n' +\n 'Tell the human: run `./node_modules/.bin/wp-setup-ai-hooks` to initialize the project configuration.\\n' +\n 'Do not proceed until the human has done this.';\n\nexport function run(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n cwd: string,\n mode: HookMode = 'all',\n): BlockedResult | null {\n return runInternal(toolKind, input, cwd, mode);\n}\n\nfunction runInternal(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n cwd: string,\n mode: HookMode,\n): BlockedResult | null {\n const loaded = loadAndValidate(cwd);\n if (loaded.configPath === null) return new BlockedResult(CONFIG_MISSING_REPORT);\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Always allow edits to webpieces.config.json — it's the fix target when out of sync\n if (path.resolve(input.filePath) === path.resolve(loaded.configPath)) {\n return null;\n }\n\n // Built-in/custom rules PLUS the client-authored match-rules (content guards). Match-rules run only\n // in the file-edit path (they are code-style, so filterByMode keeps them out of the bash/guards path).\n const allRules = [...loadRules(loaded.rulesConfig, workspaceRoot), ...loadMatchRules(loaded.matchRules)];\n const modeRules = filterByMode(allRules, mode);\n if (modeRules.length === 0) return null;\n\n // Suppress enforcement for files under this category's excludePaths (e.g. vendored repos under\n // repositories/**). Exclusion is all-or-nothing per category, so an excluded file drops the whole\n // rule set and is fully hands-off — no violations AND no config-sync nag on those files.\n const relativePath = path.relative(workspaceRoot, input.filePath);\n const rules = filterByExcludedPaths(modeRules, relativePath, loaded.excludePaths);\n if (rules.length === 0) return null;\n\n // Config-sync applies only to built-in/custom rules; match-rules have their own validated section\n // (loadAndValidate already rejected an invalid `match-rules`), so they must not trip the sync nag.\n const outOfSync = checkConfigSync(rules.filter((r: Rule) => !(r instanceof MatchRule)), loaded.rulesConfig);\n if (outOfSync) return outOfSync;\n\n const contexts = buildContexts(toolKind, input, workspaceRoot);\n\n const editGroups = runEditRules(rules, contexts.editContexts);\n const fileGroups = runFileRules(rules, contexts.fileContext);\n const allGroups = [...editGroups, ...fileGroups];\n\n if (allGroups.length === 0) return null;\n\n const report = formatReport(relativePath, allGroups);\n return new BlockedResult(report);\n}\n\nexport function runBash(command: string, cwd: string, mode: HookMode = 'all'): BlockedResult | null {\n return runBashInternal(command, cwd, mode);\n}\n\n// The name of the ONLY rule permitted to block a Read. Reads are the highest-blast-radius tool\n// there is, so this path is an explicit single-rule allowlist rather than the general rule loop.\nconst READ_SCOPED_GUARDS: ReadonlySet<string> = new Set(['main-stale-guard']);\n\n/**\n * The Read path. Deliberately NOT `run()`:\n *\n * - NO config-sync check. A rule present in code but missing from webpieces.config.json blocks\n * every Write/Edit/Bash by design — but applying that to Read would mean an upgrade that adds\n * any new rule instantly blocks the agent from reading the very config file it must edit to fix\n * it. Reads must never carry that failure mode.\n * - NO general rule loop. Only READ_SCOPED_GUARDS run, so no code-style rule can ever see a Read.\n * - Fails OPEN everywhere, including on a thrown rule (the caller catches and allows).\n *\n * Returns null (allow) unless the one guard fires.\n */\n// webpieces-disable no-function-outside-class -- sibling of run()/runBash() in this module; the whole runner is module-scope functions and a lone class for this one entry point would break the file's shape\nexport function runRead(filePath: string, cwd: string, mode: HookMode = 'all'): BlockedResult | null {\n // Code-style mode has nothing to say about a read.\n if (mode === 'rules') return null;\n\n const loaded = loadAndValidate(cwd);\n // No config → nothing to enforce. Unlike the edit path we do NOT block: an unconfigured repo\n // must still be readable.\n if (loaded.configPath === null) return null;\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Same git-repo-boundary governance as bash: a read inside a different clone is out of scope.\n const gitRoot = gitToplevel(cwd);\n if (gitRoot !== null && path.resolve(gitRoot) !== path.resolve(workspaceRoot)) return null;\n\n const relativePath = path.relative(workspaceRoot, filePath);\n const all = loadRules(loaded.rulesConfig, workspaceRoot);\n const rules = filterByExcludedPaths(\n all.filter((r: Rule): boolean => READ_SCOPED_GUARDS.has(r.name)),\n relativePath,\n loaded.excludePaths,\n );\n if (rules.length === 0) return null;\n\n const ctx = new FileContext('Read', filePath, relativePath, workspaceRoot, 0, 0, 0, 0);\n const groups = runFileRules(rules, ctx);\n if (groups.length === 0) return null;\n\n return new BlockedResult(formatReport(relativePath, groups));\n}\n\n// Installer bypass — package-manager install commands ALWAYS pass, ahead of any config load. A\n// webpieces.config.json that is ahead of the installed validator (new rule tokens the published\n// binary doesn't know yet) makes loadAndValidate() throw and would deny `pnpm install` — the very\n// command that updates the validator (deadlock). Mirrors the fail-closed shim's INSTALLER_ALLOW_ERE\n// (missing-bin case); INSTALLER_ALLOW_JS is its locked JS twin. Match is tight (`pnpm install` /\n// `npm i` + `--flags` only, no chaining) so `pnpm install && rm -rf /` still falls to the guards.\nfunction isInstallerCommand(command: string): boolean {\n return INSTALLER_ALLOW_JS.test(command.trim());\n}\n\nfunction runBashInternal(command: string, cwd: string, mode: HookMode): BlockedResult | null {\n if (isInstallerCommand(command)) {\n // Anchor the audit-log write at the repo root that owns `.webpieces` (config-walk-up first,\n // then git toplevel) so a bypass logged from a subdir/nested clone never scatters a stray\n // `.webpieces` tree. This runs before loadAndValidate, so resolveRepoRoot (not workspaceRoot).\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(root, new GuardDecision('-', 'Bash', command, branchForLog(root), 'ALLOW', 'installer bypass (always allowed)'));\n return null;\n }\n\n const loaded = loadAndValidate(cwd);\n if (loaded.configPath === null) return new BlockedResult(CONFIG_MISSING_REPORT);\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Git-repo-boundary governance. The hook now always runs (via $CLAUDE_PROJECT_DIR), so this is\n // where out-of-scope work is let through deliberately instead of the old accidental 127.\n const gitRoot = gitToplevel(cwd);\n if (gitRoot !== null && path.resolve(gitRoot) !== path.resolve(workspaceRoot)) {\n // cwd is inside a DIFFERENT git repo than this webpieces.config governs (e.g. a clone under\n // repositories/). Out of scope → allow, hands-off. Intentional, not a silent hole.\n logGuardDecision(workspaceRoot, new GuardDecision('-', 'Bash', command, branchForLog(workspaceRoot), 'ALLOW', 'foreign git repo (out of scope)'));\n return null;\n }\n\n const rules = filterByMode(loadRules(loaded.rulesConfig, workspaceRoot), mode);\n if (rules.length === 0) return null;\n\n const outOfSync = checkConfigSync(rules, loaded.rulesConfig);\n if (outOfSync) return outOfSync;\n\n // Force-to-root: git/gh commands must run from the repo root, where the guards can reason about\n // git state coherently. From a subdir, BLOCK with an actionable cd message — never silently skip.\n if (isGitOrGhCommand(command) && path.resolve(cwd) !== path.resolve(workspaceRoot)) {\n const report =\n `❌ Run git/gh commands from the repo root, not a subdirectory.\\n` +\n ` You are in: ${cwd}\\n` +\n ` cd to the repo root first: cd ${workspaceRoot}\\n` +\n ` Then re-run your command. (The webpieces guards evaluate the repo's git state at its root.)`;\n logGuardDecision(workspaceRoot, new GuardDecision('force-to-root', 'Bash', command, branchForLog(workspaceRoot), 'BLOCK', 'git/gh from subdir'));\n return new BlockedResult(report);\n }\n\n // Keep the feature-branch-guard cache warm on EVERY command (not just Write/Edit): the AI runs\n // far more bash than edits, so refreshing here means the guard's next file-edit check reads a\n // fresh status. Detached + fire-and-forget — never blocks the command. Only when the guard is\n // loaded (guards/all mode) and enabled, so a project that opted out never triggers git fetches.\n maybeRefreshMainSync(rules, workspaceRoot);\n\n const ctx = buildBashContext(command, workspaceRoot);\n const groups = runBashRules(rules, ctx);\n if (groups.length === 0) {\n // Record the ALLOW only for git/gh commands — the operations the bash guards actually reason\n // about (branch create, commit, push, merge, PR). Skipping ls/cat/grep keeps the audit log\n // focused (the whole point of the log is \"why did/didn't a guard fire?\"). Blocks are always\n // logged below.\n if (/\\b(?:git|gh)\\b/.test(command)) {\n logGuardDecision(workspaceRoot, new GuardDecision('-', 'Bash', command, branchForLog(workspaceRoot), 'ALLOW', 'no bash-guard block'));\n }\n return null;\n }\n\n const ruleNames = groups.map((g: RuleGroup): string => g.ruleName).join(',');\n logGuardDecision(workspaceRoot, new GuardDecision(ruleNames, 'Bash', command, branchForLog(workspaceRoot), 'BLOCK', 'bash-guard block'));\n const report = formatReport('<bash>', groups);\n return new BlockedResult(report);\n}\n\n// The set of rule names explicitly present in webpieces.config.json (every key except rulesDir).\nfunction configuredRuleNames(config: WebpiecesRulesConfig): ReadonlySet<string> {\n return new Set(Object.keys(config).filter((k: string) => k !== 'rulesDir'));\n}\n\nfunction checkConfigSync(rules: readonly Rule[], config: WebpiecesRulesConfig): BlockedResult | null {\n const configured = configuredRuleNames(config);\n const unconfiguredRules = rules.filter((r: Rule) => !configured.has(r.name));\n if (unconfiguredRules.length === 0) return null;\n\n const lines = [\n 'webpieces.config.json is out of sync — new built-in rules are present that have no entry in webpieces.config.json.',\n '',\n 'Tell the human: the following rules need to be configured. Ask for each one:',\n ' - Should this rule be ON, OFF, NEW_AND_MODIFIED_CODE, or NEW_AND_MODIFIED_FILES?',\n ' - What values do you want for the options listed below?',\n 'Then update webpieces.config.json and retry.',\n '',\n 'Do NOT proceed until webpieces.config.json has an entry for every rule below.',\n '',\n ];\n\n for (const rule of unconfiguredRules) {\n lines.push(`--- ${rule.name} ---`);\n lines.push(`Description: ${rule.description}`);\n const opts = rule.defaultOptions;\n const optKeys = Object.keys(opts);\n if (optKeys.length > 0) {\n lines.push(`Available options (suggested defaults shown):`);\n for (const key of optKeys) {\n lines.push(` ${key}: ${JSON.stringify(opts[key])}`);\n }\n } else {\n lines.push('Available options: none beyond mode');\n }\n lines.push(`Example entry for webpieces.config.json:`);\n lines.push(` \"${rule.name}\": { \"mode\": \"ON\" }`);\n lines.push('');\n }\n\n return new BlockedResult(lines.join('\\n'));\n}\n\n// N-legs pattern: each rule runs independently so one rule can never abort the others. A rule may\n// EITHER return Violation[] OR throw — both accumulate here into visible violations the AI sees:\n// - a thrown RuleFailError → an expected, well-formed violation (its line/snippet/fixHints kept);\n// - a thrown plain Error → a \"crashed\" violation (a bug, surfaced not swallowed).\nexport function runRuleCheck(rule: Rule, ctx: EditContext | FileContext | BashContext): readonly Violation[] {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return rule.check(ctx);\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof RuleFailError) {\n return [violationFromRuleFail(error)];\n }\n return [new Violation(0, '', `Rule '${rule.name}' crashed: ${error.message}`)];\n }\n}\n\n// A thrown RuleFailError carries its own AI-facing message + optional location and fix hints. Fold the\n// fix hints into the message because Violation has no fixHint field (RuleGroup's fixHint comes from the\n// rule definition, not a per-throw value).\nfunction violationFromRuleFail(error: RuleFailError): Violation {\n const hints = error.fixHints.length > 0 ? `\\n Fix: ${error.fixHints.join('\\n Fix: ')}` : '';\n return new Violation(error.line ?? 0, error.snippet ?? '', error.aiMessage + hints);\n}\n\nfunction ruleMatchesFile(rule: Rule, relativePath: string): boolean {\n for (const pattern of rule.files) {\n if (globMatches(pattern, relativePath)) return true;\n }\n return false;\n}\n\nfunction runBashRules(rules: readonly Rule[], bashContext: BashContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'bash') continue;\n if (!rule.shouldRun()) continue;\n const vs = runRuleCheck(rule, bashContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n\nfunction runEditRules(rules: readonly Rule[], editContexts: readonly EditContext[]): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'edit') continue;\n if (!rule.shouldRun()) continue;\n const allViolations: Violation[] = [];\n for (const ctx of editContexts) {\n if (!ruleMatchesFile(rule, ctx.relativePath)) continue;\n const vs = runRuleCheck(rule, ctx);\n for (const v of vs) {\n const copy = new Violation(v.line, v.snippet, v.message);\n copy.editIndex = ctx.editIndex;\n copy.editCount = ctx.editCount;\n allViolations.push(copy);\n }\n }\n if (allViolations.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, allViolations,\n ));\n }\n }\n return groups;\n}\n\nfunction runFileRules(rules: readonly Rule[], fileContext: FileContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'file') continue;\n if (!rule.shouldRun()) continue;\n if (!ruleMatchesFile(rule, fileContext.relativePath)) continue;\n const vs = runRuleCheck(rule, fileContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n"]}
@@ -1,7 +1,7 @@
1
1
  import { RuleOptions } from '@webpieces/rules-config';
2
2
  export { ResolvedConfig, ResolvedRuleConfig, RuleOptions, InformAiError, RuleFailError } from '@webpieces/rules-config';
3
3
  import { FixHint } from './fix-hint';
4
- export type ToolKind = 'Write' | 'Edit' | 'MultiEdit';
4
+ export type ToolKind = 'Write' | 'Edit' | 'MultiEdit' | 'Read';
5
5
  export type RuleScope = 'edit' | 'file' | 'bash';
6
6
  export type HookMode = 'rules' | 'guards' | 'all';
7
7
  export type IsLineDisabled = (lineNum: number, ruleName: string) => boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/types.ts"],"names":[],"mappings":"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","sourcesContent":["// ResolvedConfig / ResolvedRuleConfig / RuleOptions now live in @webpieces/rules-config\n// so ai-hooks and the Nx validate-code executor share one loader and one config file.\nimport { RuleOptions } from '@webpieces/rules-config';\nexport { ResolvedConfig, ResolvedRuleConfig, RuleOptions, InformAiError, RuleFailError } from '@webpieces/rules-config';\nimport { FixHint } from './fix-hint';\n\nexport type ToolKind = 'Write' | 'Edit' | 'MultiEdit';\nexport type RuleScope = 'edit' | 'file' | 'bash';\n// Which category of built-in rules a hook invocation runs: code-style 'rules', git/PR/branch\n// 'guards' (the hookGuards section), or 'all' (both categories — used by the openclaw plugin adapter,\n// which is a single before_tool_call hook rather than two split PreToolUse hooks).\nexport type HookMode = 'rules' | 'guards' | 'all';\nexport type IsLineDisabled = (lineNum: number, ruleName: string) => boolean;\n\nexport class Violation {\n readonly line: number;\n readonly snippet: string;\n // Optional per-occurrence override for the `→` line. When omitted, the report falls back\n // to the rule's `FixHint.violation`. Dynamic rules (param name, line count, marker path,\n // branch/PR) pass a specific message here; static rules omit it.\n readonly message: string | undefined;\n editIndex: number | undefined;\n editCount: number | undefined;\n\n constructor(line: number, snippet: string, message?: string) {\n this.line = line;\n this.snippet = snippet;\n this.message = message;\n this.editIndex = undefined;\n this.editCount = undefined;\n }\n}\n\nexport class NormalizedEdit {\n readonly oldString: string;\n readonly newString: string;\n\n constructor(oldString: string, newString: string) {\n this.oldString = oldString;\n this.newString = newString;\n }\n}\n\nexport class NormalizedToolInput {\n readonly filePath: string;\n readonly edits: readonly NormalizedEdit[];\n\n constructor(filePath: string, edits: readonly NormalizedEdit[]) {\n this.filePath = filePath;\n this.edits = edits;\n }\n}\n\nexport class NormalizedBashInput {\n readonly command: string;\n\n constructor(command: string) {\n this.command = command;\n }\n}\n\nexport class EditContext {\n readonly tool: ToolKind;\n readonly editIndex: number;\n readonly editCount: number;\n readonly filePath: string;\n readonly relativePath: string;\n readonly workspaceRoot: string;\n readonly addedContent: string;\n readonly strippedContent: string;\n readonly lines: readonly string[];\n readonly strippedLines: readonly string[];\n readonly removedContent: string;\n readonly isLineDisabled: IsLineDisabled;\n options: RuleOptions;\n\n constructor(\n tool: ToolKind,\n editIndex: number,\n editCount: number,\n filePath: string,\n relativePath: string,\n workspaceRoot: string,\n addedContent: string,\n strippedContent: string,\n lines: readonly string[],\n strippedLines: readonly string[],\n removedContent: string,\n isLineDisabled: IsLineDisabled,\n ) {\n this.tool = tool;\n this.editIndex = editIndex;\n this.editCount = editCount;\n this.filePath = filePath;\n this.relativePath = relativePath;\n this.workspaceRoot = workspaceRoot;\n this.addedContent = addedContent;\n this.strippedContent = strippedContent;\n this.lines = lines;\n this.strippedLines = strippedLines;\n this.removedContent = removedContent;\n this.isLineDisabled = isLineDisabled;\n this.options = {};\n }\n}\n\nexport class BashContext {\n readonly tool: 'Bash';\n readonly command: string;\n readonly workspaceRoot: string;\n options: RuleOptions;\n\n constructor(command: string, workspaceRoot: string) {\n this.tool = 'Bash';\n this.command = command;\n this.workspaceRoot = workspaceRoot;\n this.options = {};\n }\n}\n\nexport class FileContext {\n readonly tool: ToolKind;\n readonly filePath: string;\n readonly relativePath: string;\n readonly workspaceRoot: string;\n readonly currentFileLines: number;\n readonly linesAdded: number;\n readonly linesRemoved: number;\n readonly projectedFileLines: number;\n options: RuleOptions;\n\n constructor(\n tool: ToolKind,\n filePath: string,\n relativePath: string,\n workspaceRoot: string,\n currentFileLines: number,\n linesAdded: number,\n linesRemoved: number,\n projectedFileLines: number,\n ) {\n this.tool = tool;\n this.filePath = filePath;\n this.relativePath = relativePath;\n this.workspaceRoot = workspaceRoot;\n this.currentFileLines = currentFileLines;\n this.linesAdded = linesAdded;\n this.linesRemoved = linesRemoved;\n this.projectedFileLines = projectedFileLines;\n this.options = {};\n }\n}\n\n/**\n * The shape of a custom rule loaded from a `rulesDir` (a plain object returned by require()).\n * It carries only metadata + a `check` method — no on/off logic of its own.\n */\nexport interface PlainRule {\n readonly name: string;\n readonly description: string;\n readonly scope: RuleScope;\n readonly files: readonly string[];\n readonly defaultOptions: RuleOptions;\n readonly fixHint: FixHint;\n check(ctx: EditContext | FileContext | BashContext): readonly Violation[];\n}\n\n/**\n * The runtime contract the runner iterates. Both the built-in rule classes (which extend\n * EditRuleBase/FileRuleBase/BashRuleBase) and the custom-rule adapter satisfy this: they add\n * `shouldRun()` (mode + escape-hatch decision) on top of the PlainRule metadata.\n */\nexport interface Rule extends PlainRule {\n shouldRun(): boolean;\n}\n\nexport class RuleGroup {\n readonly ruleName: string;\n readonly ruleDescription: string;\n readonly fixHint: FixHint;\n readonly violations: readonly Violation[];\n\n constructor(\n ruleName: string,\n ruleDescription: string,\n fixHint: FixHint,\n violations: readonly Violation[],\n ) {\n this.ruleName = ruleName;\n this.ruleDescription = ruleDescription;\n this.fixHint = fixHint;\n this.violations = violations;\n }\n}\n\nexport class BlockedResult {\n readonly report: string;\n\n constructor(report: string) {\n this.report = report;\n }\n}\n\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/types.ts"],"names":[],"mappings":"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","sourcesContent":["// ResolvedConfig / ResolvedRuleConfig / RuleOptions now live in @webpieces/rules-config\n// so ai-hooks and the Nx validate-code executor share one loader and one config file.\nimport { RuleOptions } from '@webpieces/rules-config';\nexport { ResolvedConfig, ResolvedRuleConfig, RuleOptions, InformAiError, RuleFailError } from '@webpieces/rules-config';\nimport { FixHint } from './fix-hint';\n\n// 'Read' is a first-class member because main-stale-guard is a file-scoped guard that runs on the\n// Read fast path. It is deliberately NOT in HANDLED_FILE_TOOLS (hook-core), so normalizeToolKind()\n// still returns null for it and Read never enters the edit/file rule pipeline — only the one guard\n// that asks for it. Nothing switches exhaustively on this union; it is carried for logging.\nexport type ToolKind = 'Write' | 'Edit' | 'MultiEdit' | 'Read';\nexport type RuleScope = 'edit' | 'file' | 'bash';\n// Which category of built-in rules a hook invocation runs: code-style 'rules', git/PR/branch\n// 'guards' (the hookGuards section), or 'all' (both categories — used by the openclaw plugin adapter,\n// which is a single before_tool_call hook rather than two split PreToolUse hooks).\nexport type HookMode = 'rules' | 'guards' | 'all';\nexport type IsLineDisabled = (lineNum: number, ruleName: string) => boolean;\n\nexport class Violation {\n readonly line: number;\n readonly snippet: string;\n // Optional per-occurrence override for the `→` line. When omitted, the report falls back\n // to the rule's `FixHint.violation`. Dynamic rules (param name, line count, marker path,\n // branch/PR) pass a specific message here; static rules omit it.\n readonly message: string | undefined;\n editIndex: number | undefined;\n editCount: number | undefined;\n\n constructor(line: number, snippet: string, message?: string) {\n this.line = line;\n this.snippet = snippet;\n this.message = message;\n this.editIndex = undefined;\n this.editCount = undefined;\n }\n}\n\nexport class NormalizedEdit {\n readonly oldString: string;\n readonly newString: string;\n\n constructor(oldString: string, newString: string) {\n this.oldString = oldString;\n this.newString = newString;\n }\n}\n\nexport class NormalizedToolInput {\n readonly filePath: string;\n readonly edits: readonly NormalizedEdit[];\n\n constructor(filePath: string, edits: readonly NormalizedEdit[]) {\n this.filePath = filePath;\n this.edits = edits;\n }\n}\n\nexport class NormalizedBashInput {\n readonly command: string;\n\n constructor(command: string) {\n this.command = command;\n }\n}\n\nexport class EditContext {\n readonly tool: ToolKind;\n readonly editIndex: number;\n readonly editCount: number;\n readonly filePath: string;\n readonly relativePath: string;\n readonly workspaceRoot: string;\n readonly addedContent: string;\n readonly strippedContent: string;\n readonly lines: readonly string[];\n readonly strippedLines: readonly string[];\n readonly removedContent: string;\n readonly isLineDisabled: IsLineDisabled;\n options: RuleOptions;\n\n constructor(\n tool: ToolKind,\n editIndex: number,\n editCount: number,\n filePath: string,\n relativePath: string,\n workspaceRoot: string,\n addedContent: string,\n strippedContent: string,\n lines: readonly string[],\n strippedLines: readonly string[],\n removedContent: string,\n isLineDisabled: IsLineDisabled,\n ) {\n this.tool = tool;\n this.editIndex = editIndex;\n this.editCount = editCount;\n this.filePath = filePath;\n this.relativePath = relativePath;\n this.workspaceRoot = workspaceRoot;\n this.addedContent = addedContent;\n this.strippedContent = strippedContent;\n this.lines = lines;\n this.strippedLines = strippedLines;\n this.removedContent = removedContent;\n this.isLineDisabled = isLineDisabled;\n this.options = {};\n }\n}\n\nexport class BashContext {\n readonly tool: 'Bash';\n readonly command: string;\n readonly workspaceRoot: string;\n options: RuleOptions;\n\n constructor(command: string, workspaceRoot: string) {\n this.tool = 'Bash';\n this.command = command;\n this.workspaceRoot = workspaceRoot;\n this.options = {};\n }\n}\n\nexport class FileContext {\n readonly tool: ToolKind;\n readonly filePath: string;\n readonly relativePath: string;\n readonly workspaceRoot: string;\n readonly currentFileLines: number;\n readonly linesAdded: number;\n readonly linesRemoved: number;\n readonly projectedFileLines: number;\n options: RuleOptions;\n\n constructor(\n tool: ToolKind,\n filePath: string,\n relativePath: string,\n workspaceRoot: string,\n currentFileLines: number,\n linesAdded: number,\n linesRemoved: number,\n projectedFileLines: number,\n ) {\n this.tool = tool;\n this.filePath = filePath;\n this.relativePath = relativePath;\n this.workspaceRoot = workspaceRoot;\n this.currentFileLines = currentFileLines;\n this.linesAdded = linesAdded;\n this.linesRemoved = linesRemoved;\n this.projectedFileLines = projectedFileLines;\n this.options = {};\n }\n}\n\n/**\n * The shape of a custom rule loaded from a `rulesDir` (a plain object returned by require()).\n * It carries only metadata + a `check` method — no on/off logic of its own.\n */\nexport interface PlainRule {\n readonly name: string;\n readonly description: string;\n readonly scope: RuleScope;\n readonly files: readonly string[];\n readonly defaultOptions: RuleOptions;\n readonly fixHint: FixHint;\n check(ctx: EditContext | FileContext | BashContext): readonly Violation[];\n}\n\n/**\n * The runtime contract the runner iterates. Both the built-in rule classes (which extend\n * EditRuleBase/FileRuleBase/BashRuleBase) and the custom-rule adapter satisfy this: they add\n * `shouldRun()` (mode + escape-hatch decision) on top of the PlainRule metadata.\n */\nexport interface Rule extends PlainRule {\n shouldRun(): boolean;\n}\n\nexport class RuleGroup {\n readonly ruleName: string;\n readonly ruleDescription: string;\n readonly fixHint: FixHint;\n readonly violations: readonly Violation[];\n\n constructor(\n ruleName: string,\n ruleDescription: string,\n fixHint: FixHint,\n violations: readonly Violation[],\n ) {\n this.ruleName = ruleName;\n this.ruleDescription = ruleDescription;\n this.fixHint = fixHint;\n this.violations = violations;\n }\n}\n\nexport class BlockedResult {\n readonly report: string;\n\n constructor(report: string) {\n this.report = report;\n }\n}\n\n"]}