@webpieces/ai-hook-rules 0.4.692 → 0.4.694
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/core/command-scan.d.ts +14 -0
- package/src/core/command-scan.js +35 -0
- package/src/core/command-scan.js.map +1 -1
- package/src/core/load-rules.d.ts +4 -0
- package/src/core/load-rules.js +10 -1
- package/src/core/load-rules.js.map +1 -1
- package/src/core/rules/build-output-pipe-guard.d.ts +56 -0
- package/src/core/rules/build-output-pipe-guard.js +116 -0
- package/src/core/rules/build-output-pipe-guard.js.map +1 -0
- package/src/core/rules/build-output-pipe-scan.d.ts +47 -0
- package/src/core/rules/build-output-pipe-scan.js +84 -0
- package/src/core/rules/build-output-pipe-scan.js.map +1 -0
- package/src/core/rules/whole-repo-build-guard.d.ts +7 -6
- package/src/core/rules/whole-repo-build-guard.js +24 -18
- package/src/core/rules/whole-repo-build-guard.js.map +1 -1
- package/src/core/rules/whole-repo-build-scan.d.ts +0 -8
- package/src/core/rules/whole-repo-build-scan.js +6 -35
- package/src/core/rules/whole-repo-build-scan.js.map +1 -1
- package/src/core/runner.js +1 -2
- package/src/core/runner.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.694",
|
|
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",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "packages/tooling/ai-hook-rules"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@webpieces/rules-config": "0.4.
|
|
28
|
+
"@webpieces/rules-config": "0.4.694"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|
|
@@ -83,6 +83,20 @@ export declare class CommandScanner {
|
|
|
83
83
|
gitSubcommandArgs(segment: string, subcommand: string): readonly string[] | null;
|
|
84
84
|
/** Index of the subcommand token in already-prefix-stripped words, or -1 when git is not invoked. */
|
|
85
85
|
private gitSubcommandIndex;
|
|
86
|
+
/**
|
|
87
|
+
* The segment's words with the package-manager WRAPPER stripped, so `pnpm exec vitest run` and
|
|
88
|
+
* `vitest run` reduce to the same words. `pnpm --silent run build-all` → `['build-all']`.
|
|
89
|
+
*
|
|
90
|
+
* Lives HERE rather than in a guard because two guards now need it and they must agree: one blocks a
|
|
91
|
+
* whole-repo build, the other blocks piping a build's output. If they disagreed about whether
|
|
92
|
+
* `npx wp-build` is `wp-build`, one of them would have a spelling-shaped side door — which is exactly
|
|
93
|
+
* the failure mode the runner stripping exists to close.
|
|
94
|
+
*/
|
|
95
|
+
runnerStrippedWords(segment: string): readonly string[];
|
|
96
|
+
/** `./node_modules/.bin/nx` and `/usr/local/bin/pnpm.cmd` are the programs `nx` and `pnpm`. */
|
|
97
|
+
programName(token: string): string;
|
|
98
|
+
/** True when a package-manager runner precedes the program (`pnpm …`, `npx …`). */
|
|
99
|
+
viaRunner(segment: string): boolean;
|
|
86
100
|
/** True when this segment actually invokes `git <subcommand>`. */
|
|
87
101
|
invokesGit(segment: string, subcommand: string): boolean;
|
|
88
102
|
/** True when ANY segment of the command invokes one of `subcommands`. */
|
package/src/core/command-scan.js
CHANGED
|
@@ -26,6 +26,10 @@ const GIT_FLAGS_WITH_VALUE = new Set([
|
|
|
26
26
|
'-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path',
|
|
27
27
|
]);
|
|
28
28
|
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
29
|
+
// Package-manager wrappers that precede the real program (`pnpm exec vitest`, `npx nx`, `yarn build-all`).
|
|
30
|
+
const RUNNERS = new Set(['pnpm', 'npm', 'yarn', 'npx', 'pnpx', 'bun', 'bunx']);
|
|
31
|
+
// The runner's own verb, consumed with it: `pnpm run build-all` and `pnpm build-all` are one command.
|
|
32
|
+
const RUNNER_VERBS = new Set(['run', 'run-script', 'exec', 'dlx', 'x']);
|
|
29
33
|
/**
|
|
30
34
|
* One invoked segment of a command, plus whether a PIPE fed it.
|
|
31
35
|
*
|
|
@@ -177,6 +181,37 @@ class CommandScanner {
|
|
|
177
181
|
}
|
|
178
182
|
return -1;
|
|
179
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* The segment's words with the package-manager WRAPPER stripped, so `pnpm exec vitest run` and
|
|
186
|
+
* `vitest run` reduce to the same words. `pnpm --silent run build-all` → `['build-all']`.
|
|
187
|
+
*
|
|
188
|
+
* Lives HERE rather than in a guard because two guards now need it and they must agree: one blocks a
|
|
189
|
+
* whole-repo build, the other blocks piping a build's output. If they disagreed about whether
|
|
190
|
+
* `npx wp-build` is `wp-build`, one of them would have a spelling-shaped side door — which is exactly
|
|
191
|
+
* the failure mode the runner stripping exists to close.
|
|
192
|
+
*/
|
|
193
|
+
runnerStrippedWords(segment) {
|
|
194
|
+
let words = this.words(segment);
|
|
195
|
+
while (words.length > 0 && RUNNERS.has(this.programName(words[0]))) {
|
|
196
|
+
words = words.slice(1);
|
|
197
|
+
// The runner's own flags (`--silent`, `-r`) sit between it and the program.
|
|
198
|
+
while (words.length > 0 && words[0].startsWith('-'))
|
|
199
|
+
words = words.slice(1);
|
|
200
|
+
if (words.length > 0 && RUNNER_VERBS.has(words[0]))
|
|
201
|
+
words = words.slice(1);
|
|
202
|
+
}
|
|
203
|
+
return words;
|
|
204
|
+
}
|
|
205
|
+
/** `./node_modules/.bin/nx` and `/usr/local/bin/pnpm.cmd` are the programs `nx` and `pnpm`. */
|
|
206
|
+
programName(token) {
|
|
207
|
+
const base = token.split('/').pop() ?? token;
|
|
208
|
+
return base.endsWith('.cmd') ? base.slice(0, -'.cmd'.length) : base;
|
|
209
|
+
}
|
|
210
|
+
/** True when a package-manager runner precedes the program (`pnpm …`, `npx …`). */
|
|
211
|
+
viaRunner(segment) {
|
|
212
|
+
const raw = this.words(segment);
|
|
213
|
+
return raw.length > 0 && RUNNERS.has(this.programName(raw[0]));
|
|
214
|
+
}
|
|
180
215
|
/** True when this segment actually invokes `git <subcommand>`. */
|
|
181
216
|
invokesGit(segment, subcommand) {
|
|
182
217
|
return this.gitSubcommand(segment) === subcommand;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"command-scan.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/command-scan.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared shell-command scanning for the bash guards.\n *\n * A guard that bans a command family (`git merge`, `git push`, …) must answer one question\n * precisely: *does this command actually invoke `git <subcommand>`?* A bare\n * `/\\bgit\\s+merge\\b/.test(command)` gets that wrong in both directions:\n *\n * - False positive: `grep 'git merge main' notes.md` or `echo \"git rebase main\"` merely MENTION\n * the phrase. A diagnostic grep was blocked this way while triaging the incident that motivated\n * the merge/rebase ban.\n * - False positive: `\\b` sits between `e` and `-`, so `/\\bgit\\s+merge\\b/` matches the read-only\n * `git merge-base origin/main HEAD` — which appears in this repo's own documented build command.\n *\n * Both classes vanish if you tokenize instead of substring-match: a command invokes git only when a\n * segment's first word IS `git`, and the subcommand is then an exact token (`merge-base` is simply\n * not the token `merge`). No lookahead regex needed.\n */\n\n// Wrappers/prefixes that may precede the real command word (`sudo git merge`, `GIT_DIR=x git merge`).\nconst COMMAND_PREFIXES: ReadonlySet<string> = new Set(['sudo', 'command', 'nohup', 'time', 'env', 'exec']);\n\n// git's own global flags that consume the FOLLOWING token as their value, so\n// `git -C /some/path merge main` still resolves to the `merge` subcommand.\nconst GIT_FLAGS_WITH_VALUE: ReadonlySet<string> = new Set([\n '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path',\n]);\n\nconst ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;\n\n/**\n * One invoked segment of a command, plus whether a PIPE fed it.\n *\n * `pipedInto` is what separates `git log | grep foo` (grep consumes the pipe — reads no file) from\n * `grep foo src/` (grep reads the working tree). A guard that cares about which FILES a command\n * reads cannot tell those apart from the segment text alone, because splitting on `|` throws exactly\n * that fact away. Data-only, so a class (per CLAUDE.md).\n */\nexport class CommandSegment {\n text: string;\n pipedInto: boolean;\n\n constructor(text: string, pipedInto: boolean) {\n this.text = text;\n this.pipedInto = pipedInto;\n }\n}\n\nexport class CommandScanner {\n /**\n * Split a raw command into individually-invoked segments.\n *\n * Splits on `&&`, `||`, `;`, `|`, `&`, newline, and the `(`/`)` of subshells and `$(…)` command\n * substitution — the last of these matters, since it means `--base=$(git rebase main)` is scanned\n * as its own `git rebase main` segment rather than hiding inside a `pnpm …` segment.\n *\n * Quoted spans are opaque: a separator inside quotes is literal text, so\n * `git commit -m \"fix; ship it\"` stays one segment. (Corollary: a `$(…)` nested inside double\n * quotes is not split out. Bash would expand it; we do not scan it. Contrived enough to accept.)\n */\n commandSegments(command: string): readonly string[] {\n return this.segmentsWithPipes(command).map((s: CommandSegment): string => s.text);\n }\n\n /**\n * commandSegments, but each segment also carries whether the separator BEFORE it was a pipe.\n * Only a guard reasoning about which files a segment reads needs that; everything else uses\n * commandSegments, which is this method with the flag dropped.\n */\n segmentsWithPipes(command: string): readonly CommandSegment[] {\n const segments: CommandSegment[] = [];\n let current = '';\n let quote: string | null = null;\n let piped = false; // was the separator that ENDED the previous segment a pipe?\n\n for (let i = 0; i < command.length; i++) {\n const ch = command[i];\n\n if (quote !== null) {\n current += ch;\n // A backslash-escaped quote does not close the span (only meaningful inside \"…\").\n if (ch === quote && command[i - 1] !== '\\\\') quote = null;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n current += ch;\n continue;\n }\n\n // The `&` of a REDIRECTION (`2>&1`, `1>&2`, `&>log`) is not a separator. Splitting on it\n // tore `git fetch origin main 2>&1 | tail -5` into THREE segments — `git fetch … 2>`, `1`\n // and `tail -5` — and the bare `1` is not an allowlisted command, so every guard that\n // requires all segments to pass denied the command. That is `2>&1`, the single most common\n // decoration an agent appends.\n if (ch === '&' && command[i + 1] !== '&' && (current.trimEnd().endsWith('>') || command[i + 1] === '>')) {\n current += ch;\n continue;\n }\n\n if (ch === '\\n' || ch === ';' || ch === '|' || ch === '&' || ch === '(' || ch === ')') {\n // Consume the second char of `&&` / `||` so it does not start an empty segment.\n const doubled = (ch === '|' || ch === '&') && command[i + 1] === ch;\n if (doubled) i++;\n segments.push(new CommandSegment(current, piped));\n // `|` pipes into the next segment; `||` is a separator, not a pipe.\n piped = ch === '|' && !doubled;\n current = '';\n continue;\n }\n\n current += ch;\n }\n segments.push(new CommandSegment(current, piped));\n\n return segments\n .map((s: CommandSegment): CommandSegment => new CommandSegment(s.text.trim(), s.pipedInto))\n .filter((s: CommandSegment): boolean => s.text.length > 0);\n }\n\n /**\n * One segment's shell words, with wrappers/env-assignments stripped, so `words('sudo cat a b')`\n * is `['cat', 'a', 'b']`. The public view of the same tokenizer gitSubcommand uses — a guard that\n * must inspect a NON-git command's arguments (which paths does this `grep` actually read?) needs\n * the tokens, and re-splitting on whitespace in the guard would get quoting wrong.\n */\n words(segment: string): readonly string[] {\n return this.stripPrefixes(this.tokenize(segment));\n }\n\n /**\n * The git subcommand a segment invokes, or null when the segment does not invoke git at all\n * (a different program, a mere mention inside quotes, an empty segment).\n *\n * Returns the subcommand as an EXACT token: `git merge-base …` yields `'merge-base'`, never `'merge'`.\n */\n gitSubcommand(segment: string): string | null {\n return this.gitSubcommandOf(this.stripPrefixes(this.tokenize(segment)));\n }\n\n /**\n * gitSubcommand, for a caller that already holds the segment's effective words (ShellSegmentScan\n * strips leading shell keywords, so `do git status` must be resolved from ITS words, not from the\n * raw segment text where `do` is the command).\n */\n gitSubcommandOf(words: readonly string[]): string | null {\n const tokens = this.stripPrefixes(words);\n const at = this.gitSubcommandIndex(tokens);\n return at === -1 ? null : tokens[at];\n }\n\n /**\n * The ARGUMENTS following `git <subcommand>` in this segment, or null when the segment does not\n * invoke that subcommand.\n *\n * `gitSubcommand` answers *which* subcommand; a guard that judges the subcommand's own flags needs\n * the tokens after it, and slicing them in the guard would mean re-deriving where the subcommand\n * sits — i.e. re-deriving the `sudo` / `env VAR=x` / `-C <path>` skipping this class exists to own.\n * `git -C /x commit -m \"msg\"` yields `['-m', 'msg']`, never `['/x', 'commit', '-m', 'msg']`.\n *\n * The tokens are QUOTE-STRIPPED (see tokenize), so a quoted argument arrives as ONE token holding\n * its literal text — newlines, backticks and all. That is what makes an argument's CONTENT\n * inspectable at all.\n */\n gitSubcommandArgs(segment: string, subcommand: string): readonly string[] | null {\n const tokens = this.stripPrefixes(this.tokenize(segment));\n const at = this.gitSubcommandIndex(tokens);\n if (at === -1 || tokens[at] !== subcommand) return null;\n return tokens.slice(at + 1);\n }\n\n /** Index of the subcommand token in already-prefix-stripped words, or -1 when git is not invoked. */\n private gitSubcommandIndex(tokens: readonly string[]): number {\n if (tokens.length === 0 || tokens[0] !== 'git') return -1;\n\n let i = 1;\n while (i < tokens.length) {\n const token = tokens[i];\n if (GIT_FLAGS_WITH_VALUE.has(token)) { i += 2; continue; }\n // `--git-dir=/x` style (value attached) and any other global flag.\n if (token.startsWith('-')) { i++; continue; }\n return i;\n }\n return -1;\n }\n\n /** True when this segment actually invokes `git <subcommand>`. */\n invokesGit(segment: string, subcommand: string): boolean {\n return this.gitSubcommand(segment) === subcommand;\n }\n\n /** True when ANY segment of the command invokes one of `subcommands`. */\n commandInvokesAnyGit(command: string, subcommands: readonly string[]): boolean {\n return this.commandSegments(command).some((seg: string) =>\n subcommands.some((sub: string) => this.invokesGit(seg, sub)));\n }\n\n /**\n * Split one segment into shell words, dropping quote characters (so the ARGUMENT of\n * `echo \"git merge main\"` is the single word `git merge main`, never the word `git`).\n */\n private tokenize(segment: string): readonly string[] {\n const tokens: string[] = [];\n let current = '';\n let started = false;\n let quote: string | null = null;\n\n for (let i = 0; i < segment.length; i++) {\n const ch = segment[i];\n\n if (quote !== null) {\n if (ch === quote) quote = null;\n else current += ch;\n started = true;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n started = true;\n continue;\n }\n\n if (/\\s/.test(ch)) {\n if (started) {\n tokens.push(current);\n current = '';\n started = false;\n }\n continue;\n }\n\n current += ch;\n started = true;\n }\n if (started) tokens.push(current);\n\n return tokens;\n }\n\n private stripPrefixes(tokens: readonly string[]): readonly string[] {\n let i = 0;\n while (i < tokens.length) {\n const token = tokens[i];\n if (COMMAND_PREFIXES.has(token)) { i++; continue; }\n if (ENV_ASSIGNMENT.test(token)) { i++; continue; }\n break;\n }\n return tokens.slice(i);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"command-scan.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/command-scan.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared shell-command scanning for the bash guards.\n *\n * A guard that bans a command family (`git merge`, `git push`, …) must answer one question\n * precisely: *does this command actually invoke `git <subcommand>`?* A bare\n * `/\\bgit\\s+merge\\b/.test(command)` gets that wrong in both directions:\n *\n * - False positive: `grep 'git merge main' notes.md` or `echo \"git rebase main\"` merely MENTION\n * the phrase. A diagnostic grep was blocked this way while triaging the incident that motivated\n * the merge/rebase ban.\n * - False positive: `\\b` sits between `e` and `-`, so `/\\bgit\\s+merge\\b/` matches the read-only\n * `git merge-base origin/main HEAD` — which appears in this repo's own documented build command.\n *\n * Both classes vanish if you tokenize instead of substring-match: a command invokes git only when a\n * segment's first word IS `git`, and the subcommand is then an exact token (`merge-base` is simply\n * not the token `merge`). No lookahead regex needed.\n */\n\n// Wrappers/prefixes that may precede the real command word (`sudo git merge`, `GIT_DIR=x git merge`).\nconst COMMAND_PREFIXES: ReadonlySet<string> = new Set(['sudo', 'command', 'nohup', 'time', 'env', 'exec']);\n\n// git's own global flags that consume the FOLLOWING token as their value, so\n// `git -C /some/path merge main` still resolves to the `merge` subcommand.\nconst GIT_FLAGS_WITH_VALUE: ReadonlySet<string> = new Set([\n '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path',\n]);\n\nconst ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;\n\n// Package-manager wrappers that precede the real program (`pnpm exec vitest`, `npx nx`, `yarn build-all`).\nconst RUNNERS: ReadonlySet<string> = new Set(['pnpm', 'npm', 'yarn', 'npx', 'pnpx', 'bun', 'bunx']);\n\n// The runner's own verb, consumed with it: `pnpm run build-all` and `pnpm build-all` are one command.\nconst RUNNER_VERBS: ReadonlySet<string> = new Set(['run', 'run-script', 'exec', 'dlx', 'x']);\n\n/**\n * One invoked segment of a command, plus whether a PIPE fed it.\n *\n * `pipedInto` is what separates `git log | grep foo` (grep consumes the pipe — reads no file) from\n * `grep foo src/` (grep reads the working tree). A guard that cares about which FILES a command\n * reads cannot tell those apart from the segment text alone, because splitting on `|` throws exactly\n * that fact away. Data-only, so a class (per CLAUDE.md).\n */\nexport class CommandSegment {\n text: string;\n pipedInto: boolean;\n\n constructor(text: string, pipedInto: boolean) {\n this.text = text;\n this.pipedInto = pipedInto;\n }\n}\n\nexport class CommandScanner {\n /**\n * Split a raw command into individually-invoked segments.\n *\n * Splits on `&&`, `||`, `;`, `|`, `&`, newline, and the `(`/`)` of subshells and `$(…)` command\n * substitution — the last of these matters, since it means `--base=$(git rebase main)` is scanned\n * as its own `git rebase main` segment rather than hiding inside a `pnpm …` segment.\n *\n * Quoted spans are opaque: a separator inside quotes is literal text, so\n * `git commit -m \"fix; ship it\"` stays one segment. (Corollary: a `$(…)` nested inside double\n * quotes is not split out. Bash would expand it; we do not scan it. Contrived enough to accept.)\n */\n commandSegments(command: string): readonly string[] {\n return this.segmentsWithPipes(command).map((s: CommandSegment): string => s.text);\n }\n\n /**\n * commandSegments, but each segment also carries whether the separator BEFORE it was a pipe.\n * Only a guard reasoning about which files a segment reads needs that; everything else uses\n * commandSegments, which is this method with the flag dropped.\n */\n segmentsWithPipes(command: string): readonly CommandSegment[] {\n const segments: CommandSegment[] = [];\n let current = '';\n let quote: string | null = null;\n let piped = false; // was the separator that ENDED the previous segment a pipe?\n\n for (let i = 0; i < command.length; i++) {\n const ch = command[i];\n\n if (quote !== null) {\n current += ch;\n // A backslash-escaped quote does not close the span (only meaningful inside \"…\").\n if (ch === quote && command[i - 1] !== '\\\\') quote = null;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n current += ch;\n continue;\n }\n\n // The `&` of a REDIRECTION (`2>&1`, `1>&2`, `&>log`) is not a separator. Splitting on it\n // tore `git fetch origin main 2>&1 | tail -5` into THREE segments — `git fetch … 2>`, `1`\n // and `tail -5` — and the bare `1` is not an allowlisted command, so every guard that\n // requires all segments to pass denied the command. That is `2>&1`, the single most common\n // decoration an agent appends.\n if (ch === '&' && command[i + 1] !== '&' && (current.trimEnd().endsWith('>') || command[i + 1] === '>')) {\n current += ch;\n continue;\n }\n\n if (ch === '\\n' || ch === ';' || ch === '|' || ch === '&' || ch === '(' || ch === ')') {\n // Consume the second char of `&&` / `||` so it does not start an empty segment.\n const doubled = (ch === '|' || ch === '&') && command[i + 1] === ch;\n if (doubled) i++;\n segments.push(new CommandSegment(current, piped));\n // `|` pipes into the next segment; `||` is a separator, not a pipe.\n piped = ch === '|' && !doubled;\n current = '';\n continue;\n }\n\n current += ch;\n }\n segments.push(new CommandSegment(current, piped));\n\n return segments\n .map((s: CommandSegment): CommandSegment => new CommandSegment(s.text.trim(), s.pipedInto))\n .filter((s: CommandSegment): boolean => s.text.length > 0);\n }\n\n /**\n * One segment's shell words, with wrappers/env-assignments stripped, so `words('sudo cat a b')`\n * is `['cat', 'a', 'b']`. The public view of the same tokenizer gitSubcommand uses — a guard that\n * must inspect a NON-git command's arguments (which paths does this `grep` actually read?) needs\n * the tokens, and re-splitting on whitespace in the guard would get quoting wrong.\n */\n words(segment: string): readonly string[] {\n return this.stripPrefixes(this.tokenize(segment));\n }\n\n /**\n * The git subcommand a segment invokes, or null when the segment does not invoke git at all\n * (a different program, a mere mention inside quotes, an empty segment).\n *\n * Returns the subcommand as an EXACT token: `git merge-base …` yields `'merge-base'`, never `'merge'`.\n */\n gitSubcommand(segment: string): string | null {\n return this.gitSubcommandOf(this.stripPrefixes(this.tokenize(segment)));\n }\n\n /**\n * gitSubcommand, for a caller that already holds the segment's effective words (ShellSegmentScan\n * strips leading shell keywords, so `do git status` must be resolved from ITS words, not from the\n * raw segment text where `do` is the command).\n */\n gitSubcommandOf(words: readonly string[]): string | null {\n const tokens = this.stripPrefixes(words);\n const at = this.gitSubcommandIndex(tokens);\n return at === -1 ? null : tokens[at];\n }\n\n /**\n * The ARGUMENTS following `git <subcommand>` in this segment, or null when the segment does not\n * invoke that subcommand.\n *\n * `gitSubcommand` answers *which* subcommand; a guard that judges the subcommand's own flags needs\n * the tokens after it, and slicing them in the guard would mean re-deriving where the subcommand\n * sits — i.e. re-deriving the `sudo` / `env VAR=x` / `-C <path>` skipping this class exists to own.\n * `git -C /x commit -m \"msg\"` yields `['-m', 'msg']`, never `['/x', 'commit', '-m', 'msg']`.\n *\n * The tokens are QUOTE-STRIPPED (see tokenize), so a quoted argument arrives as ONE token holding\n * its literal text — newlines, backticks and all. That is what makes an argument's CONTENT\n * inspectable at all.\n */\n gitSubcommandArgs(segment: string, subcommand: string): readonly string[] | null {\n const tokens = this.stripPrefixes(this.tokenize(segment));\n const at = this.gitSubcommandIndex(tokens);\n if (at === -1 || tokens[at] !== subcommand) return null;\n return tokens.slice(at + 1);\n }\n\n /** Index of the subcommand token in already-prefix-stripped words, or -1 when git is not invoked. */\n private gitSubcommandIndex(tokens: readonly string[]): number {\n if (tokens.length === 0 || tokens[0] !== 'git') return -1;\n\n let i = 1;\n while (i < tokens.length) {\n const token = tokens[i];\n if (GIT_FLAGS_WITH_VALUE.has(token)) { i += 2; continue; }\n // `--git-dir=/x` style (value attached) and any other global flag.\n if (token.startsWith('-')) { i++; continue; }\n return i;\n }\n return -1;\n }\n\n /**\n * The segment's words with the package-manager WRAPPER stripped, so `pnpm exec vitest run` and\n * `vitest run` reduce to the same words. `pnpm --silent run build-all` → `['build-all']`.\n *\n * Lives HERE rather than in a guard because two guards now need it and they must agree: one blocks a\n * whole-repo build, the other blocks piping a build's output. If they disagreed about whether\n * `npx wp-build` is `wp-build`, one of them would have a spelling-shaped side door — which is exactly\n * the failure mode the runner stripping exists to close.\n */\n runnerStrippedWords(segment: string): readonly string[] {\n let words = this.words(segment);\n while (words.length > 0 && RUNNERS.has(this.programName(words[0]))) {\n words = words.slice(1);\n // The runner's own flags (`--silent`, `-r`) sit between it and the program.\n while (words.length > 0 && words[0].startsWith('-')) words = words.slice(1);\n if (words.length > 0 && RUNNER_VERBS.has(words[0])) words = words.slice(1);\n }\n return words;\n }\n\n /** `./node_modules/.bin/nx` and `/usr/local/bin/pnpm.cmd` are the programs `nx` and `pnpm`. */\n programName(token: string): string {\n const base = token.split('/').pop() ?? token;\n return base.endsWith('.cmd') ? base.slice(0, -'.cmd'.length) : base;\n }\n\n /** True when a package-manager runner precedes the program (`pnpm …`, `npx …`). */\n viaRunner(segment: string): boolean {\n const raw = this.words(segment);\n return raw.length > 0 && RUNNERS.has(this.programName(raw[0]));\n }\n\n /** True when this segment actually invokes `git <subcommand>`. */\n invokesGit(segment: string, subcommand: string): boolean {\n return this.gitSubcommand(segment) === subcommand;\n }\n\n /** True when ANY segment of the command invokes one of `subcommands`. */\n commandInvokesAnyGit(command: string, subcommands: readonly string[]): boolean {\n return this.commandSegments(command).some((seg: string) =>\n subcommands.some((sub: string) => this.invokesGit(seg, sub)));\n }\n\n /**\n * Split one segment into shell words, dropping quote characters (so the ARGUMENT of\n * `echo \"git merge main\"` is the single word `git merge main`, never the word `git`).\n */\n private tokenize(segment: string): readonly string[] {\n const tokens: string[] = [];\n let current = '';\n let started = false;\n let quote: string | null = null;\n\n for (let i = 0; i < segment.length; i++) {\n const ch = segment[i];\n\n if (quote !== null) {\n if (ch === quote) quote = null;\n else current += ch;\n started = true;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n started = true;\n continue;\n }\n\n if (/\\s/.test(ch)) {\n if (started) {\n tokens.push(current);\n current = '';\n started = false;\n }\n continue;\n }\n\n current += ch;\n started = true;\n }\n if (started) tokens.push(current);\n\n return tokens;\n }\n\n private stripPrefixes(tokens: readonly string[]): readonly string[] {\n let i = 0;\n while (i < tokens.length) {\n const token = tokens[i];\n if (COMMAND_PREFIXES.has(token)) { i++; continue; }\n if (ENV_ASSIGNMENT.test(token)) { i++; continue; }\n break;\n }\n return tokens.slice(i);\n }\n}\n"]}
|
package/src/core/load-rules.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export declare function loadRules(config: WebpiecesRulesConfig, workspaceRoot: s
|
|
|
24
24
|
* - `commit-message-substitution-guard` acts unconditionally. Nobody legitimately wants a backtick
|
|
25
25
|
* expanded inside a commit message, and its cure (`git commit -F <file>`) is available for every
|
|
26
26
|
* input and can never itself match the guard — so there is nothing for a switch to rescue.
|
|
27
|
+
* - `build-output-pipe-guard` acts unconditionally, on the same test. Piping `wp-build` /
|
|
28
|
+
* `wp-review-upsert-pr` / `wp-finish-upsert-pr` withholds their heartbeat until they exit and gets
|
|
29
|
+
* the build killed by the 600s watchdog; the cure is the SAME command with less typing, available
|
|
30
|
+
* for every input, and it cannot itself match the guard.
|
|
27
31
|
*
|
|
28
32
|
* `affectedBuildCommand` is the project's gate command, passed through so a refusal quotes what THIS
|
|
29
33
|
* repo's gate actually runs.
|
package/src/core/load-rules.js
CHANGED
|
@@ -28,6 +28,7 @@ const no_process_exit_outside_main_1 = require("./rules/no-process-exit-outside-
|
|
|
28
28
|
const branch_creation_guard_1 = require("./rules/branch-creation-guard");
|
|
29
29
|
const pr_creation_or_push_guard_1 = require("./rules/pr-creation-or-push-guard");
|
|
30
30
|
const merge_in_progress_guard_1 = require("./rules/merge-in-progress-guard");
|
|
31
|
+
const build_output_pipe_guard_1 = require("./rules/build-output-pipe-guard");
|
|
31
32
|
const pr_merge_guard_1 = require("./rules/pr-merge-guard");
|
|
32
33
|
const redirect_how_to_merge_main_1 = require("./rules/redirect-how-to-merge-main");
|
|
33
34
|
const no_js_files_1 = require("./rules/no-js-files");
|
|
@@ -109,13 +110,21 @@ function loadRules(config, workspaceRoot, guardHints) {
|
|
|
109
110
|
* - `commit-message-substitution-guard` acts unconditionally. Nobody legitimately wants a backtick
|
|
110
111
|
* expanded inside a commit message, and its cure (`git commit -F <file>`) is available for every
|
|
111
112
|
* input and can never itself match the guard — so there is nothing for a switch to rescue.
|
|
113
|
+
* - `build-output-pipe-guard` acts unconditionally, on the same test. Piping `wp-build` /
|
|
114
|
+
* `wp-review-upsert-pr` / `wp-finish-upsert-pr` withholds their heartbeat until they exit and gets
|
|
115
|
+
* the build killed by the 600s watchdog; the cure is the SAME command with less typing, available
|
|
116
|
+
* for every input, and it cannot itself match the guard.
|
|
112
117
|
*
|
|
113
118
|
* `affectedBuildCommand` is the project's gate command, passed through so a refusal quotes what THIS
|
|
114
119
|
* repo's gate actually runs.
|
|
115
120
|
*/
|
|
116
121
|
// webpieces-disable no-function-outside-class -- sibling of loadRules/loadMatchRules in this module; the whole loader is module-scope functions and a lone class for this one would break the file's shape
|
|
117
122
|
function loadKeylessBashRules(affectedBuildCommand) {
|
|
118
|
-
return [
|
|
123
|
+
return [
|
|
124
|
+
new whole_repo_build_guard_1.WholeRepoBuildGuardRule(affectedBuildCommand),
|
|
125
|
+
new commit_message_substitution_guard_1.CommitMessageSubstitutionGuardRule(),
|
|
126
|
+
new build_output_pipe_guard_1.BuildOutputPipeGuardRule(),
|
|
127
|
+
];
|
|
119
128
|
}
|
|
120
129
|
// One MatchRule per entry of the `match-rules` array. Kept separate from loadRules (built-ins/custom)
|
|
121
130
|
// because match-rules live in their own validated section — they must NOT flow through the
|
|
@@ -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,\n PrLifecycleGuardConfig, BranchStateGuardConfig,\n NoJsFilesConfig, 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 { builtInConfigKeys } 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 { ReadStaleGuardRule } from './rules/read-stale-guard';\nimport { MergedBranchBashGuardRule } from './rules/merged-branch-bash-guard';\nimport { StaleMainBashGuardRule } from './rules/stale-main-bash-guard';\nimport { WholeRepoBuildGuardRule } from './rules/whole-repo-build-guard';\nimport { CommitMessageSubstitutionGuardRule } from './rules/commit-message-substitution-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/**\n * ONE CONFIG KEY → N RULES.\n *\n * Each built-in rule is constructed from its typed *Config (the entry in webpieces.config.json). The\n * config arrives as a plain object structurally typed as the *Config class, so the `as` narrows the\n * shared BaseRuleConfig param back to the concrete config the rule consumes.\n *\n * The map is keyed by CONFIG KEY and each factory returns an ARRAY, because a hookGuards key names a\n * POLICY and a policy may be implemented by several classes: `branch-state-guard` builds all four\n * branch-state guards from one entry, `pr-lifecycle-guard` all four PR-lifecycle guards. It used to be\n * `Record<string, (c) => Rule>` — one factory per key — which is precisely why four classes could not\n * share a key and why the config had to carry nine switches for three decisions.\n *\n * `guardHints` are the resolved `commands.guardHints` strings, handed to the two rules that print a\n * gated command. They arrive as a constructor argument rather than a config field, so there is exactly\n * one spelling of each command in the config (see PrLifecycleGuardConfig).\n */\ntype RuleFactory = (config: BaseRuleConfig, guardHints: GuardHintCommands) => readonly Rule[];\n\n/** The two gated-command strings guards print, resolved from `commands.guardHints`. Data-only. */\nexport class GuardHintCommands {\n constructor(readonly upsertPr: string, readonly mergeComplete: string) {}\n}\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 'no-js-files': (c: BaseRuleConfig) => [new NoJsFilesRule(c as NoJsFilesConfig)],\n 'branch-creation-guard': (c: BaseRuleConfig) => [new BranchCreationGuardRule(c as BranchCreationGuardConfig)],\n // THE TWO COLLAPSED POLICIES. Order inside each array is the order the rules run in, and it is the\n // same order the previous per-key registry produced.\n 'pr-lifecycle-guard': (c: BaseRuleConfig, hints: GuardHintCommands) => [\n new PrCreationOrPushGuardRule(c as PrLifecycleGuardConfig, hints.upsertPr),\n new MergeInProgressGuardRule(c as PrLifecycleGuardConfig, hints.mergeComplete),\n new PrMergeGuardRule(c as PrLifecycleGuardConfig),\n new RedirectHowToMergeMainRule(c as PrLifecycleGuardConfig),\n ],\n 'branch-state-guard': (c: BaseRuleConfig) => [\n new FeatureBranchGuardRule(c as BranchStateGuardConfig),\n new ReadStaleGuardRule(c as BranchStateGuardConfig),\n new MergedBranchBashGuardRule(c as BranchStateGuardConfig),\n new StaleMainBashGuardRule(c as BranchStateGuardConfig),\n ],\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\n// webpieces-disable no-function-outside-class -- the module's entry point, beside loadMatchRules/loadKeylessBashRules; this whole loader is module-scope functions and a lone class for one of them would break the file's shape\nexport function loadRules(\n config: WebpiecesRulesConfig,\n workspaceRoot: string,\n guardHints: GuardHintCommands,\n): readonly Rule[] {\n const builtIns = loadBuiltInRules(config, guardHints);\n const custom = loadCustomRules(config, workspaceRoot);\n return [...builtIns, ...custom];\n}\n\n/**\n * The KEYLESS bash guards: rules that have NO webpieces.config.json entry, and are therefore\n * deliberately kept out of `builtInConfigKeys`/`BUILT_IN_RULE_MAP` — so the config-sync check (fault Y,\n * \"every built-in rule needs an entry, or every Bash call is blocked\") can never see them. That\n * containment is the whole point: whole-repo-build-guard shipped inside the config-driven set once and\n * took every upgrading consumer's shell down with it.\n *\n * Each rule here decides for ITSELF whether it acts, and the two do it differently on purpose:\n *\n * - `whole-repo-build-guard` is EXPERIMENTAL and inert unless the optional machine-local\n * `~/.webpieces/config.json` opts IN with `experimental.whole-repo-build-guard: true`. Every\n * experimental flag defaults OFF, and it takes no file and no key to be in that default state —\n * which is the difference between this and the required-key release that blocked every upgrading\n * consumer's shell.\n * - `commit-message-substitution-guard` acts unconditionally. Nobody legitimately wants a backtick\n * expanded inside a commit message, and its cure (`git commit -F <file>`) is available for every\n * input and can never itself match the guard — so there is nothing for a switch to rescue.\n *\n * `affectedBuildCommand` is the project's gate command, passed through so a refusal quotes what THIS\n * repo's gate actually runs.\n */\n// webpieces-disable no-function-outside-class -- sibling of loadRules/loadMatchRules in this module; the whole loader is module-scope functions and a lone class for this one would break the file's shape\nexport function loadKeylessBashRules(affectedBuildCommand: string): Rule[] {\n return [new WholeRepoBuildGuardRule(affectedBuildCommand), new CommitMessageSubstitutionGuardRule()];\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\n// Iterates CONFIG KEYS, not rule names — one entry can yield several rules (see BUILT_IN_RULE_MAP).\n// webpieces-disable no-function-outside-class -- the body of loadRules above, in the same module of loader functions\nfunction loadBuiltInRules(config: WebpiecesRulesConfig, guardHints: GuardHintCommands): Rule[] {\n const map = asConfigMap(config);\n const rules: Rule[] = [];\n for (const configKey of builtInConfigKeys) {\n const factory = BUILT_IN_RULE_MAP[configKey];\n if (!factory) {\n process.stderr.write(`[ai-hooks] unknown built-in config key: ${configKey}\\n`);\n continue;\n }\n const ruleConfig = map[configKey] ?? new EmptyRuleConfig();\n rules.push(...factory(ruleConfig, guardHints));\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,\n PrLifecycleGuardConfig, BranchStateGuardConfig,\n NoJsFilesConfig, 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 { builtInConfigKeys } 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 { BuildOutputPipeGuardRule } from './rules/build-output-pipe-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 { ReadStaleGuardRule } from './rules/read-stale-guard';\nimport { MergedBranchBashGuardRule } from './rules/merged-branch-bash-guard';\nimport { StaleMainBashGuardRule } from './rules/stale-main-bash-guard';\nimport { WholeRepoBuildGuardRule } from './rules/whole-repo-build-guard';\nimport { CommitMessageSubstitutionGuardRule } from './rules/commit-message-substitution-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/**\n * ONE CONFIG KEY → N RULES.\n *\n * Each built-in rule is constructed from its typed *Config (the entry in webpieces.config.json). The\n * config arrives as a plain object structurally typed as the *Config class, so the `as` narrows the\n * shared BaseRuleConfig param back to the concrete config the rule consumes.\n *\n * The map is keyed by CONFIG KEY and each factory returns an ARRAY, because a hookGuards key names a\n * POLICY and a policy may be implemented by several classes: `branch-state-guard` builds all four\n * branch-state guards from one entry, `pr-lifecycle-guard` all four PR-lifecycle guards. It used to be\n * `Record<string, (c) => Rule>` — one factory per key — which is precisely why four classes could not\n * share a key and why the config had to carry nine switches for three decisions.\n *\n * `guardHints` are the resolved `commands.guardHints` strings, handed to the two rules that print a\n * gated command. They arrive as a constructor argument rather than a config field, so there is exactly\n * one spelling of each command in the config (see PrLifecycleGuardConfig).\n */\ntype RuleFactory = (config: BaseRuleConfig, guardHints: GuardHintCommands) => readonly Rule[];\n\n/** The two gated-command strings guards print, resolved from `commands.guardHints`. Data-only. */\nexport class GuardHintCommands {\n constructor(readonly upsertPr: string, readonly mergeComplete: string) {}\n}\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 'no-js-files': (c: BaseRuleConfig) => [new NoJsFilesRule(c as NoJsFilesConfig)],\n 'branch-creation-guard': (c: BaseRuleConfig) => [new BranchCreationGuardRule(c as BranchCreationGuardConfig)],\n // THE TWO COLLAPSED POLICIES. Order inside each array is the order the rules run in, and it is the\n // same order the previous per-key registry produced.\n 'pr-lifecycle-guard': (c: BaseRuleConfig, hints: GuardHintCommands) => [\n new PrCreationOrPushGuardRule(c as PrLifecycleGuardConfig, hints.upsertPr),\n new MergeInProgressGuardRule(c as PrLifecycleGuardConfig, hints.mergeComplete),\n new PrMergeGuardRule(c as PrLifecycleGuardConfig),\n new RedirectHowToMergeMainRule(c as PrLifecycleGuardConfig),\n ],\n 'branch-state-guard': (c: BaseRuleConfig) => [\n new FeatureBranchGuardRule(c as BranchStateGuardConfig),\n new ReadStaleGuardRule(c as BranchStateGuardConfig),\n new MergedBranchBashGuardRule(c as BranchStateGuardConfig),\n new StaleMainBashGuardRule(c as BranchStateGuardConfig),\n ],\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\n// webpieces-disable no-function-outside-class -- the module's entry point, beside loadMatchRules/loadKeylessBashRules; this whole loader is module-scope functions and a lone class for one of them would break the file's shape\nexport function loadRules(\n config: WebpiecesRulesConfig,\n workspaceRoot: string,\n guardHints: GuardHintCommands,\n): readonly Rule[] {\n const builtIns = loadBuiltInRules(config, guardHints);\n const custom = loadCustomRules(config, workspaceRoot);\n return [...builtIns, ...custom];\n}\n\n/**\n * The KEYLESS bash guards: rules that have NO webpieces.config.json entry, and are therefore\n * deliberately kept out of `builtInConfigKeys`/`BUILT_IN_RULE_MAP` — so the config-sync check (fault Y,\n * \"every built-in rule needs an entry, or every Bash call is blocked\") can never see them. That\n * containment is the whole point: whole-repo-build-guard shipped inside the config-driven set once and\n * took every upgrading consumer's shell down with it.\n *\n * Each rule here decides for ITSELF whether it acts, and the two do it differently on purpose:\n *\n * - `whole-repo-build-guard` is EXPERIMENTAL and inert unless the optional machine-local\n * `~/.webpieces/config.json` opts IN with `experimental.whole-repo-build-guard: true`. Every\n * experimental flag defaults OFF, and it takes no file and no key to be in that default state —\n * which is the difference between this and the required-key release that blocked every upgrading\n * consumer's shell.\n * - `commit-message-substitution-guard` acts unconditionally. Nobody legitimately wants a backtick\n * expanded inside a commit message, and its cure (`git commit -F <file>`) is available for every\n * input and can never itself match the guard — so there is nothing for a switch to rescue.\n * - `build-output-pipe-guard` acts unconditionally, on the same test. Piping `wp-build` /\n * `wp-review-upsert-pr` / `wp-finish-upsert-pr` withholds their heartbeat until they exit and gets\n * the build killed by the 600s watchdog; the cure is the SAME command with less typing, available\n * for every input, and it cannot itself match the guard.\n *\n * `affectedBuildCommand` is the project's gate command, passed through so a refusal quotes what THIS\n * repo's gate actually runs.\n */\n// webpieces-disable no-function-outside-class -- sibling of loadRules/loadMatchRules in this module; the whole loader is module-scope functions and a lone class for this one would break the file's shape\nexport function loadKeylessBashRules(affectedBuildCommand: string): Rule[] {\n return [\n new WholeRepoBuildGuardRule(affectedBuildCommand),\n new CommitMessageSubstitutionGuardRule(),\n new BuildOutputPipeGuardRule(),\n ];\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\n// Iterates CONFIG KEYS, not rule names — one entry can yield several rules (see BUILT_IN_RULE_MAP).\n// webpieces-disable no-function-outside-class -- the body of loadRules above, in the same module of loader functions\nfunction loadBuiltInRules(config: WebpiecesRulesConfig, guardHints: GuardHintCommands): Rule[] {\n const map = asConfigMap(config);\n const rules: Rule[] = [];\n for (const configKey of builtInConfigKeys) {\n const factory = BUILT_IN_RULE_MAP[configKey];\n if (!factory) {\n process.stderr.write(`[ai-hooks] unknown built-in config key: ${configKey}\\n`);\n continue;\n }\n const ruleConfig = map[configKey] ?? new EmptyRuleConfig();\n rules.push(...factory(ruleConfig, guardHints));\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"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { BashContext, Violation } from '../types';
|
|
2
|
+
import { BashRuleBase, EmptyRuleConfig } from '../rule-base';
|
|
3
|
+
import { FixHint } from '../fix-hint';
|
|
4
|
+
/**
|
|
5
|
+
* Blocks a Bash command that PIPES or REDIRECTS the output of `pnpm wp-build`,
|
|
6
|
+
* `pnpm wp-review-upsert-pr` or `pnpm wp-finish-upsert-pr`, and hands back the bare command plus a
|
|
7
|
+
* `grep` of the log those commands already write.
|
|
8
|
+
*
|
|
9
|
+
* ─── The incident, measured ────────────────────────────────────────────────────────────────────────
|
|
10
|
+
* The Claude Code harness backgrounds — and then kills — a foreground Bash command that has produced
|
|
11
|
+
* no output for 600 seconds. Those three commands defend against that already: the build's output goes
|
|
12
|
+
* to a FILE, and the console gets a heartbeat roughly every ten seconds plus a `FullLog :` pointer.
|
|
13
|
+
*
|
|
14
|
+
* A PIPE deletes that defence. `pnpm wp-review-upsert-pr 2>&1 | tail -50` withholds every byte until
|
|
15
|
+
* the writer exits, because that is what `tail` is — so the terminal sees NOTHING for the length of a
|
|
16
|
+
* full build, the watchdog fires, and the work is thrown away. The last fleet audit attributed ≥100
|
|
17
|
+
* minutes of wall time to those kills across 8 agents, and this repo's primary tree alone holds 85
|
|
18
|
+
* piped `wp-*` calls, 42 of them on a build-running command (`wp-review-upsert-pr` 22,
|
|
19
|
+
* `wp-finish-upsert-pr` 17, `wp-build` 3).
|
|
20
|
+
*
|
|
21
|
+
* A `> file` redirect is the same silence, and additionally writes a second copy of a log the command
|
|
22
|
+
* already wrote.
|
|
23
|
+
*
|
|
24
|
+
* ─── Why the ORDER of the fix matters, and why this half came second ───────────────────────────────
|
|
25
|
+
* Agents pipe these commands for a REASON: stage ② and stage ③ used to print everything they did. So
|
|
26
|
+
* the output was shrunk FIRST — both stages now capture their verbose body to a log and keep only the
|
|
27
|
+
* lines an agent must act on (see `StageOutputLog` in @webpieces/pr-gate). Blocking the pipe before
|
|
28
|
+
* that would have traded a watchdog kill for a flooded context, and an agent would have routed around
|
|
29
|
+
* it with `> file` — which is why the redirect is on the blocklist too.
|
|
30
|
+
*
|
|
31
|
+
* ─── It acts UNCONDITIONALLY, and has NO config key ────────────────────────────────────────────────
|
|
32
|
+
* Like `commit-message-substitution-guard`, and for the same two reasons.
|
|
33
|
+
*
|
|
34
|
+
* It has no key because a NEW key under `hookGuards` is a key every consumer must ADD or have every
|
|
35
|
+
* Bash call blocked on upgrade (fault Y) — that shipped once already, with `whole-repo-build-guard`,
|
|
36
|
+
* and took upgrading consumers' shells down for a feature nobody had asked for. Loading here, outside
|
|
37
|
+
* the config-driven set, makes the config-sync check structurally unable to see it.
|
|
38
|
+
*
|
|
39
|
+
* It needs no switch because there is nothing for one to rescue: the cure is available for every
|
|
40
|
+
* input, is strictly better than what was blocked, and can never itself match this guard. Running the
|
|
41
|
+
* command bare gives you a heartbeat instead of silence and a log you can `grep` any number of times
|
|
42
|
+
* instead of a fixed 50 lines you have to re-run a build to change.
|
|
43
|
+
*/
|
|
44
|
+
export declare class BuildOutputPipeGuardRule extends BashRuleBase<EmptyRuleConfig> {
|
|
45
|
+
constructor();
|
|
46
|
+
private readonly scan;
|
|
47
|
+
readonly description: string;
|
|
48
|
+
get fixHint(): FixHint;
|
|
49
|
+
check(ctx: BashContext): readonly Violation[];
|
|
50
|
+
private allow;
|
|
51
|
+
private block;
|
|
52
|
+
private message;
|
|
53
|
+
private what;
|
|
54
|
+
private truncate;
|
|
55
|
+
private logDecision;
|
|
56
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BuildOutputPipeGuardRule = void 0;
|
|
4
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
5
|
+
const types_1 = require("../types");
|
|
6
|
+
const rule_base_1 = require("../rule-base");
|
|
7
|
+
const fix_hint_1 = require("../fix-hint");
|
|
8
|
+
const l0_fault_codes_1 = require("../l0-fault-codes");
|
|
9
|
+
const decision_log_1 = require("../decision-log");
|
|
10
|
+
const command_scan_1 = require("../command-scan");
|
|
11
|
+
const shell_segment_scan_1 = require("./shell-segment-scan");
|
|
12
|
+
const build_output_pipe_scan_1 = require("./build-output-pipe-scan");
|
|
13
|
+
/**
|
|
14
|
+
* Blocks a Bash command that PIPES or REDIRECTS the output of `pnpm wp-build`,
|
|
15
|
+
* `pnpm wp-review-upsert-pr` or `pnpm wp-finish-upsert-pr`, and hands back the bare command plus a
|
|
16
|
+
* `grep` of the log those commands already write.
|
|
17
|
+
*
|
|
18
|
+
* ─── The incident, measured ────────────────────────────────────────────────────────────────────────
|
|
19
|
+
* The Claude Code harness backgrounds — and then kills — a foreground Bash command that has produced
|
|
20
|
+
* no output for 600 seconds. Those three commands defend against that already: the build's output goes
|
|
21
|
+
* to a FILE, and the console gets a heartbeat roughly every ten seconds plus a `FullLog :` pointer.
|
|
22
|
+
*
|
|
23
|
+
* A PIPE deletes that defence. `pnpm wp-review-upsert-pr 2>&1 | tail -50` withholds every byte until
|
|
24
|
+
* the writer exits, because that is what `tail` is — so the terminal sees NOTHING for the length of a
|
|
25
|
+
* full build, the watchdog fires, and the work is thrown away. The last fleet audit attributed ≥100
|
|
26
|
+
* minutes of wall time to those kills across 8 agents, and this repo's primary tree alone holds 85
|
|
27
|
+
* piped `wp-*` calls, 42 of them on a build-running command (`wp-review-upsert-pr` 22,
|
|
28
|
+
* `wp-finish-upsert-pr` 17, `wp-build` 3).
|
|
29
|
+
*
|
|
30
|
+
* A `> file` redirect is the same silence, and additionally writes a second copy of a log the command
|
|
31
|
+
* already wrote.
|
|
32
|
+
*
|
|
33
|
+
* ─── Why the ORDER of the fix matters, and why this half came second ───────────────────────────────
|
|
34
|
+
* Agents pipe these commands for a REASON: stage ② and stage ③ used to print everything they did. So
|
|
35
|
+
* the output was shrunk FIRST — both stages now capture their verbose body to a log and keep only the
|
|
36
|
+
* lines an agent must act on (see `StageOutputLog` in @webpieces/pr-gate). Blocking the pipe before
|
|
37
|
+
* that would have traded a watchdog kill for a flooded context, and an agent would have routed around
|
|
38
|
+
* it with `> file` — which is why the redirect is on the blocklist too.
|
|
39
|
+
*
|
|
40
|
+
* ─── It acts UNCONDITIONALLY, and has NO config key ────────────────────────────────────────────────
|
|
41
|
+
* Like `commit-message-substitution-guard`, and for the same two reasons.
|
|
42
|
+
*
|
|
43
|
+
* It has no key because a NEW key under `hookGuards` is a key every consumer must ADD or have every
|
|
44
|
+
* Bash call blocked on upgrade (fault Y) — that shipped once already, with `whole-repo-build-guard`,
|
|
45
|
+
* and took upgrading consumers' shells down for a feature nobody had asked for. Loading here, outside
|
|
46
|
+
* the config-driven set, makes the config-sync check structurally unable to see it.
|
|
47
|
+
*
|
|
48
|
+
* It needs no switch because there is nothing for one to rescue: the cure is available for every
|
|
49
|
+
* input, is strictly better than what was blocked, and can never itself match this guard. Running the
|
|
50
|
+
* command bare gives you a heartbeat instead of silence and a log you can `grep` any number of times
|
|
51
|
+
* instead of a fixed 50 lines you have to re-run a build to change.
|
|
52
|
+
*/
|
|
53
|
+
class BuildOutputPipeGuardRule extends rule_base_1.BashRuleBase {
|
|
54
|
+
constructor() {
|
|
55
|
+
// configKey === name and is DELIBERATELY not a real webpieces.config.json key — see the class
|
|
56
|
+
// docstring on fault Y.
|
|
57
|
+
super(new rule_base_1.EmptyRuleConfig(), 'build-output-pipe-guard', 'build-output-pipe-guard');
|
|
58
|
+
}
|
|
59
|
+
scan = new build_output_pipe_scan_1.BuildOutputPipeScan(new command_scan_1.CommandScanner(), new shell_segment_scan_1.ShellSegmentScan());
|
|
60
|
+
description = 'Block piping or redirecting the output of pnpm wp-build / wp-review-upsert-pr / ' +
|
|
61
|
+
'wp-finish-upsert-pr — a pipe withholds their heartbeat until exit, so the 600s watchdog kills ' +
|
|
62
|
+
'the build — and name the bare command plus a grep of the log they already write.';
|
|
63
|
+
get fixHint() {
|
|
64
|
+
return new fix_hint_1.FixHint('Piping one of these commands hides the heartbeat that proves it is alive, and the harness ' +
|
|
65
|
+
'kills a command that has printed nothing for 600 seconds — after a full build has run.', 'Their output is ALREADY in a file. Run the command bare and read the file it names:', [
|
|
66
|
+
new rules_config_1.Option('Run it with nothing after it:\n' +
|
|
67
|
+
` pnpm ${build_output_pipe_scan_1.LOGGED_BUILD_COMMANDS[0]}\n` +
|
|
68
|
+
'The console stays short — a size heartbeat, then the result and a "FullLog : <path>" line.', true),
|
|
69
|
+
new rules_config_1.Option('Then read as much or as little of that log as you like, as many times as you like:\n' +
|
|
70
|
+
' grep -n error "<the FullLog path it printed>"\n' +
|
|
71
|
+
' tail -50 "<the FullLog path it printed>"\n' +
|
|
72
|
+
'Use the path from THIS run, never one typed from memory — a stale relative path ' +
|
|
73
|
+
'greps nothing, which reads exactly like a clean build.'),
|
|
74
|
+
new rules_config_1.Option('The previous run is still on disk as "<that path>.bak", so comparing two runs ' +
|
|
75
|
+
'needs no third one.'),
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
check(ctx) {
|
|
79
|
+
// Blocklist-shaped, so match on commandCode: stripping heredocs and quoted prose can only ever
|
|
80
|
+
// block LESS, and this repo's own commit messages and docs are full of these command names.
|
|
81
|
+
const hit = this.scan.firstHit(ctx.commandCode);
|
|
82
|
+
if (hit === null)
|
|
83
|
+
return this.allow(ctx);
|
|
84
|
+
return this.block(ctx, hit);
|
|
85
|
+
}
|
|
86
|
+
allow(ctx) {
|
|
87
|
+
this.logDecision(ctx, 'ALLOW', 'output-not-bounded');
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
block(ctx, hit) {
|
|
91
|
+
// BLOCK_AI_CURE: the cure is the SAME command with less typing, which the agent runs itself.
|
|
92
|
+
this.logDecision(ctx, 'BLOCK_AI_CURE', `${hit.command}-${hit.shape}`);
|
|
93
|
+
return [new types_1.Violation(1, this.truncate(ctx.command), this.message(hit))];
|
|
94
|
+
}
|
|
95
|
+
// Short on purpose: it is read mid-task by an agent that needs the ONE next move.
|
|
96
|
+
message(hit) {
|
|
97
|
+
return `Blocked: ${this.what(hit)} \`${hit.command}\`. That command already writes its full output `
|
|
98
|
+
+ 'to a log file and prints a heartbeat while it runs — and bounding it withholds every byte '
|
|
99
|
+
+ 'until the command exits, so the harness sees a silent command and KILLS it at 600s, after '
|
|
100
|
+
+ 'the whole build has run.\n\n'
|
|
101
|
+
+ ` pnpm ${hit.command}\n\n`
|
|
102
|
+
+ 'Then grep the "FullLog : <path>" it prints. The previous run is kept beside it as `.bak`.';
|
|
103
|
+
}
|
|
104
|
+
what(hit) {
|
|
105
|
+
return hit.shape === build_output_pipe_scan_1.BOUND_BY_PIPE ? 'you piped' : 'you redirected the output of';
|
|
106
|
+
}
|
|
107
|
+
truncate(s) {
|
|
108
|
+
const MAX = 120;
|
|
109
|
+
return s.length <= MAX ? s : s.slice(0, MAX) + '…';
|
|
110
|
+
}
|
|
111
|
+
logDecision(ctx, verdict, reason) {
|
|
112
|
+
(0, decision_log_1.logGuardDecision)(ctx.workspaceRoot, new decision_log_1.GuardDecision('build-output-pipe-guard', 'Bash', ctx.command, '-', verdict, reason, '-', l0_fault_codes_1.L0_FAULT_NONE, decision_log_1.MATRIX_L2_UNROWED));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
exports.BuildOutputPipeGuardRule = BuildOutputPipeGuardRule;
|
|
116
|
+
//# sourceMappingURL=build-output-pipe-guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-output-pipe-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/build-output-pipe-guard.ts"],"names":[],"mappings":";;;AAAA,0DAAiD;AAGjD,oCAA0C;AAC1C,4CAA6D;AAC7D,0CAAsC;AACtC,sDAAkD;AAClD,kDAA8F;AAC9F,kDAAiD;AACjD,6DAAwD;AACxD,qEAEkC;AAElC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,MAAa,wBAAyB,SAAQ,wBAA6B;IACvE;QACI,8FAA8F;QAC9F,wBAAwB;QACxB,KAAK,CAAC,IAAI,2BAAe,EAAE,EAAE,yBAAyB,EAAE,yBAAyB,CAAC,CAAC;IACvF,CAAC;IAEgB,IAAI,GAAG,IAAI,4CAAmB,CAAC,IAAI,6BAAc,EAAE,EAAE,IAAI,qCAAgB,EAAE,CAAC,CAAC;IAErF,WAAW,GAChB,kFAAkF;QAClF,gGAAgG;QAChG,kFAAkF,CAAC;IAEvF,IAAI,OAAO;QACP,OAAO,IAAI,kBAAO,CACd,4FAA4F;YAC5F,wFAAwF,EACxF,qFAAqF,EACrF;YACI,IAAI,qBAAM,CACN,iCAAiC;gBACjC,YAAY,8CAAqB,CAAC,CAAC,CAAC,IAAI;gBACxC,4FAA4F,EAC5F,IAAI,CAAC;YACT,IAAI,qBAAM,CACN,sFAAsF;gBACtF,qDAAqD;gBACrD,gDAAgD;gBAChD,kFAAkF;gBAClF,wDAAwD,CAAC;YAC7D,IAAI,qBAAM,CACN,gFAAgF;gBAChF,qBAAqB,CAAC;SAC7B,CACJ,CAAC;IACN,CAAC;IAED,KAAK,CAAC,GAAgB;QAClB,+FAA+F;QAC/F,4FAA4F;QAC5F,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QAChD,IAAI,GAAG,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACzC,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAChC,CAAC;IAEO,KAAK,CAAC,GAAgB;QAC1B,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,oBAAoB,CAAC,CAAC;QACrD,OAAO,EAAE,CAAC;IACd,CAAC;IAEO,KAAK,CAAC,GAAgB,EAAE,GAAqB;QACjD,6FAA6F;QAC7F,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,GAAG,CAAC,OAAO,IAAI,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC;QACtE,OAAO,CAAC,IAAI,iBAAC,CAAC,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACrE,CAAC;IAED,kFAAkF;IAC1E,OAAO,CAAC,GAAqB;QACjC,OAAO,YAAY,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,OAAO,kDAAkD;cAC9F,4FAA4F;cAC5F,4FAA4F;cAC5F,8BAA8B;cAC9B,YAAY,GAAG,CAAC,OAAO,MAAM;cAC7B,2FAA2F,CAAC;IACtG,CAAC;IAEO,IAAI,CAAC,GAAqB;QAC9B,OAAO,GAAG,CAAC,KAAK,KAAK,sCAAa,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,8BAA8B,CAAC;IACtF,CAAC;IAEO,QAAQ,CAAC,CAAS;QACtB,MAAM,GAAG,GAAG,GAAG,CAAC;QAChB,OAAO,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC;IACvD,CAAC;IAEO,WAAW,CAAC,GAAgB,EAAE,OAAgB,EAAE,MAAc;QAClE,IAAA,+BAAgB,EACZ,GAAG,CAAC,aAAa,EACjB,IAAI,4BAAa,CACb,yBAAyB,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,EACpE,GAAG,EAAE,8BAAa,EAAE,gCAAiB,CACxC,CACJ,CAAC;IACN,CAAC;CACJ;AArFD,4DAqFC","sourcesContent":["import { Option } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase, EmptyRuleConfig } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { logGuardDecision, GuardDecision, Verdict, MATRIX_L2_UNROWED } from '../decision-log';\nimport { CommandScanner } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\nimport {\n BuildOutputPipeScan, BoundedOutputHit, BOUND_BY_PIPE, LOGGED_BUILD_COMMANDS,\n} from './build-output-pipe-scan';\n\n/**\n * Blocks a Bash command that PIPES or REDIRECTS the output of `pnpm wp-build`,\n * `pnpm wp-review-upsert-pr` or `pnpm wp-finish-upsert-pr`, and hands back the bare command plus a\n * `grep` of the log those commands already write.\n *\n * ─── The incident, measured ────────────────────────────────────────────────────────────────────────\n * The Claude Code harness backgrounds — and then kills — a foreground Bash command that has produced\n * no output for 600 seconds. Those three commands defend against that already: the build's output goes\n * to a FILE, and the console gets a heartbeat roughly every ten seconds plus a `FullLog :` pointer.\n *\n * A PIPE deletes that defence. `pnpm wp-review-upsert-pr 2>&1 | tail -50` withholds every byte until\n * the writer exits, because that is what `tail` is — so the terminal sees NOTHING for the length of a\n * full build, the watchdog fires, and the work is thrown away. The last fleet audit attributed ≥100\n * minutes of wall time to those kills across 8 agents, and this repo's primary tree alone holds 85\n * piped `wp-*` calls, 42 of them on a build-running command (`wp-review-upsert-pr` 22,\n * `wp-finish-upsert-pr` 17, `wp-build` 3).\n *\n * A `> file` redirect is the same silence, and additionally writes a second copy of a log the command\n * already wrote.\n *\n * ─── Why the ORDER of the fix matters, and why this half came second ───────────────────────────────\n * Agents pipe these commands for a REASON: stage ② and stage ③ used to print everything they did. So\n * the output was shrunk FIRST — both stages now capture their verbose body to a log and keep only the\n * lines an agent must act on (see `StageOutputLog` in @webpieces/pr-gate). Blocking the pipe before\n * that would have traded a watchdog kill for a flooded context, and an agent would have routed around\n * it with `> file` — which is why the redirect is on the blocklist too.\n *\n * ─── It acts UNCONDITIONALLY, and has NO config key ────────────────────────────────────────────────\n * Like `commit-message-substitution-guard`, and for the same two reasons.\n *\n * It has no key because a NEW key under `hookGuards` is a key every consumer must ADD or have every\n * Bash call blocked on upgrade (fault Y) — that shipped once already, with `whole-repo-build-guard`,\n * and took upgrading consumers' shells down for a feature nobody had asked for. Loading here, outside\n * the config-driven set, makes the config-sync check structurally unable to see it.\n *\n * It needs no switch because there is nothing for one to rescue: the cure is available for every\n * input, is strictly better than what was blocked, and can never itself match this guard. Running the\n * command bare gives you a heartbeat instead of silence and a log you can `grep` any number of times\n * instead of a fixed 50 lines you have to re-run a build to change.\n */\nexport class BuildOutputPipeGuardRule extends BashRuleBase<EmptyRuleConfig> {\n constructor() {\n // configKey === name and is DELIBERATELY not a real webpieces.config.json key — see the class\n // docstring on fault Y.\n super(new EmptyRuleConfig(), 'build-output-pipe-guard', 'build-output-pipe-guard');\n }\n\n private readonly scan = new BuildOutputPipeScan(new CommandScanner(), new ShellSegmentScan());\n\n readonly description =\n 'Block piping or redirecting the output of pnpm wp-build / wp-review-upsert-pr / ' +\n 'wp-finish-upsert-pr — a pipe withholds their heartbeat until exit, so the 600s watchdog kills ' +\n 'the build — and name the bare command plus a grep of the log they already write.';\n\n get fixHint(): FixHint {\n return new FixHint(\n 'Piping one of these commands hides the heartbeat that proves it is alive, and the harness ' +\n 'kills a command that has printed nothing for 600 seconds — after a full build has run.',\n 'Their output is ALREADY in a file. Run the command bare and read the file it names:',\n [\n new Option(\n 'Run it with nothing after it:\\n' +\n ` pnpm ${LOGGED_BUILD_COMMANDS[0]}\\n` +\n 'The console stays short — a size heartbeat, then the result and a \"FullLog : <path>\" line.',\n true),\n new Option(\n 'Then read as much or as little of that log as you like, as many times as you like:\\n' +\n ' grep -n error \"<the FullLog path it printed>\"\\n' +\n ' tail -50 \"<the FullLog path it printed>\"\\n' +\n 'Use the path from THIS run, never one typed from memory — a stale relative path ' +\n 'greps nothing, which reads exactly like a clean build.'),\n new Option(\n 'The previous run is still on disk as \"<that path>.bak\", so comparing two runs ' +\n 'needs no third one.'),\n ],\n );\n }\n\n check(ctx: BashContext): readonly Violation[] {\n // Blocklist-shaped, so match on commandCode: stripping heredocs and quoted prose can only ever\n // block LESS, and this repo's own commit messages and docs are full of these command names.\n const hit = this.scan.firstHit(ctx.commandCode);\n if (hit === null) return this.allow(ctx);\n return this.block(ctx, hit);\n }\n\n private allow(ctx: BashContext): readonly Violation[] {\n this.logDecision(ctx, 'ALLOW', 'output-not-bounded');\n return [];\n }\n\n private block(ctx: BashContext, hit: BoundedOutputHit): readonly Violation[] {\n // BLOCK_AI_CURE: the cure is the SAME command with less typing, which the agent runs itself.\n this.logDecision(ctx, 'BLOCK_AI_CURE', `${hit.command}-${hit.shape}`);\n return [new V(1, this.truncate(ctx.command), this.message(hit))];\n }\n\n // Short on purpose: it is read mid-task by an agent that needs the ONE next move.\n private message(hit: BoundedOutputHit): string {\n return `Blocked: ${this.what(hit)} \\`${hit.command}\\`. That command already writes its full output `\n + 'to a log file and prints a heartbeat while it runs — and bounding it withholds every byte '\n + 'until the command exits, so the harness sees a silent command and KILLS it at 600s, after '\n + 'the whole build has run.\\n\\n'\n + ` pnpm ${hit.command}\\n\\n`\n + 'Then grep the \"FullLog : <path>\" it prints. The previous run is kept beside it as `.bak`.';\n }\n\n private what(hit: BoundedOutputHit): string {\n return hit.shape === BOUND_BY_PIPE ? 'you piped' : 'you redirected the output of';\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, verdict: Verdict, reason: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision(\n 'build-output-pipe-guard', 'Bash', ctx.command, '-', verdict, reason,\n '-', L0_FAULT_NONE, MATRIX_L2_UNROWED,\n ),\n );\n }\n}\n"]}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { CommandScanner } from '../command-scan';
|
|
2
|
+
import { ShellSegmentScan } from './shell-segment-scan';
|
|
3
|
+
/**
|
|
4
|
+
* Decides the one question `build-output-pipe-guard` asks: does this command BOUND the output of a
|
|
5
|
+
* webpieces command that already writes its output to a file?
|
|
6
|
+
*
|
|
7
|
+
* ─── The three commands, and why only these three ──────────────────────────────────────────────────
|
|
8
|
+
* `wp-build`, `wp-review-upsert-pr` and `wp-finish-upsert-pr` are the commands that RUN A BUILD. Each
|
|
9
|
+
* one redirects the build's full stdout+stderr to a log file, prints a ~10-second heartbeat while it
|
|
10
|
+
* runs, and ends with a `FullLog :` pointer at the file. Nothing else in the `wp-*` family both takes
|
|
11
|
+
* minutes and writes a log, so nothing else belongs on this list — a guard that also refused
|
|
12
|
+
* `pnpm wp-cleanup | tail` would be refusing something with no cure.
|
|
13
|
+
*
|
|
14
|
+
* ─── What is a hit ─────────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
* BLOCKED a PIPE out of the command — `pnpm wp-build | tail -50`, `… 2>&1 | grep error`, `| tee`.
|
|
16
|
+
* A pipe is the measured hazard and it does not depend on WHAT it feeds: the pipeline's
|
|
17
|
+
* reader (`tail`, `head`, `grep`, `wc`) withholds every byte until the writer EXITS, so the
|
|
18
|
+
* heartbeat never reaches the terminal, the harness sees a command silent for 600 seconds,
|
|
19
|
+
* and it kills a full build. Measured in this repo's own call log: 85 piped `wp-*` calls,
|
|
20
|
+
* 42 of them on one of these three commands.
|
|
21
|
+
* BLOCKED a stdout REDIRECT to a file — `pnpm wp-build > /tmp/out.log`. Same silence, and it also
|
|
22
|
+
* makes a SECOND copy of a log the command already wrote.
|
|
23
|
+
*
|
|
24
|
+
* ALLOWED the bare command, which is the whole cure.
|
|
25
|
+
* ALLOWED `2>&1` on its own — it rewires fds and buffers nothing. `ShellSegmentScan.redirectsToFile`
|
|
26
|
+
* owns that carve-out, and it is shared rather than re-spelled here.
|
|
27
|
+
* ALLOWED every other command, piped or not. This guard is blocklist-shaped and narrow on purpose.
|
|
28
|
+
*/
|
|
29
|
+
/** The commands whose output already goes to a file, so bounding it can only ever lose information. */
|
|
30
|
+
export declare const LOGGED_BUILD_COMMANDS: readonly string[];
|
|
31
|
+
/** How the output was bounded. The two shapes read differently in a refusal, so they are named. */
|
|
32
|
+
export declare const BOUND_BY_PIPE = "pipe";
|
|
33
|
+
export declare const BOUND_BY_REDIRECT = "redirect";
|
|
34
|
+
/** One bounded invocation: WHICH command, and HOW its output was bounded. Data-only (per CLAUDE.md). */
|
|
35
|
+
export declare class BoundedOutputHit {
|
|
36
|
+
command: string;
|
|
37
|
+
shape: string;
|
|
38
|
+
constructor(command: string, shape: string);
|
|
39
|
+
}
|
|
40
|
+
export declare class BuildOutputPipeScan {
|
|
41
|
+
private readonly scanner;
|
|
42
|
+
private readonly segments;
|
|
43
|
+
constructor(scanner: CommandScanner, segments: ShellSegmentScan);
|
|
44
|
+
/** The first bounded logging command in `command`, or null when there is none. */
|
|
45
|
+
firstHit(command: string): BoundedOutputHit | null;
|
|
46
|
+
private loggingCommandIn;
|
|
47
|
+
}
|