@webpieces/ai-hook-rules 0.4.660 → 0.4.662
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/bin/shim.js +1 -1
- package/src/bin/shim.js.map +1 -1
- package/src/core/command-scan.d.ts +16 -0
- package/src/core/command-scan.js +28 -3
- package/src/core/command-scan.js.map +1 -1
- package/src/core/load-rules.d.ts +15 -8
- package/src/core/load-rules.js +19 -11
- package/src/core/load-rules.js.map +1 -1
- package/src/core/rules/commit-message-substitution-guard.d.ts +72 -0
- package/src/core/rules/commit-message-substitution-guard.js +134 -0
- package/src/core/rules/commit-message-substitution-guard.js.map +1 -0
- package/src/core/rules/commit-message-substitution-scan.d.ts +42 -0
- package/src/core/rules/commit-message-substitution-scan.js +145 -0
- package/src/core/rules/commit-message-substitution-scan.js.map +1 -0
- package/src/core/runner.js +9 -8
- package/src/core/runner.js.map +1 -1
- package/src/core/version-sync.d.ts +21 -0
- package/src/core/version-sync.js +33 -2
- package/src/core/version-sync.js.map +1 -1
- package/templates/ai-hook.sh +1 -1
package/src/core/load-rules.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GuardHintCommands = void 0;
|
|
4
4
|
exports.loadRules = loadRules;
|
|
5
|
-
exports.
|
|
5
|
+
exports.loadKeylessBashRules = loadKeylessBashRules;
|
|
6
6
|
exports.loadMatchRules = loadMatchRules;
|
|
7
7
|
exports.globMatches = globMatches;
|
|
8
8
|
const tslib_1 = require("tslib");
|
|
@@ -36,6 +36,7 @@ const read_stale_guard_1 = require("./rules/read-stale-guard");
|
|
|
36
36
|
const merged_branch_bash_guard_1 = require("./rules/merged-branch-bash-guard");
|
|
37
37
|
const stale_main_bash_guard_1 = require("./rules/stale-main-bash-guard");
|
|
38
38
|
const whole_repo_build_guard_1 = require("./rules/whole-repo-build-guard");
|
|
39
|
+
const commit_message_substitution_guard_1 = require("./rules/commit-message-substitution-guard");
|
|
39
40
|
const match_rule_1 = require("./rules/match-rule");
|
|
40
41
|
const REQUIRED_FIELDS = ['name', 'description', 'scope', 'files', 'check'];
|
|
41
42
|
const VALID_SCOPES = new Set(['edit', 'file', 'bash']);
|
|
@@ -85,27 +86,34 @@ function asConfigMap(config) {
|
|
|
85
86
|
// webpieces-disable no-any-unknown -- index the typed config by dynamic rule name
|
|
86
87
|
return config;
|
|
87
88
|
}
|
|
88
|
-
// webpieces-disable no-function-outside-class -- the module's entry point, beside loadMatchRules/
|
|
89
|
+
// 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
|
|
89
90
|
function loadRules(config, workspaceRoot, guardHints) {
|
|
90
91
|
const builtIns = loadBuiltInRules(config, guardHints);
|
|
91
92
|
const custom = loadCustomRules(config, workspaceRoot);
|
|
92
93
|
return [...builtIns, ...custom];
|
|
93
94
|
}
|
|
94
95
|
/**
|
|
95
|
-
* The
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
96
|
+
* The KEYLESS bash guards: rules that have NO webpieces.config.json entry, and are therefore
|
|
97
|
+
* deliberately kept out of `builtInConfigKeys`/`BUILT_IN_RULE_MAP` — so the config-sync check (fault Y,
|
|
98
|
+
* "every built-in rule needs an entry, or every Bash call is blocked") can never see them. That
|
|
99
|
+
* containment is the whole point: whole-repo-build-guard shipped inside the config-driven set once and
|
|
100
|
+
* took every upgrading consumer's shell down with it.
|
|
101
|
+
*
|
|
102
|
+
* Each rule here decides for ITSELF whether it acts, and the two do it differently on purpose:
|
|
103
|
+
*
|
|
104
|
+
* - `whole-repo-build-guard` is EXPERIMENTAL and inert unless the optional machine-local
|
|
105
|
+
* `~/.webpieces/config.json` opts in, because building the whole workspace is a thing a person may
|
|
106
|
+
* legitimately want and a wrong block there costs them their shell.
|
|
107
|
+
* - `commit-message-substitution-guard` acts unconditionally. Nobody legitimately wants a backtick
|
|
108
|
+
* expanded inside a commit message, and its cure (`git commit -F <file>`) is available for every
|
|
109
|
+
* input and can never itself match the guard — so there is nothing for a switch to rescue.
|
|
101
110
|
*
|
|
102
|
-
* Each rule here decides for itself, from the home config, whether it does anything at all.
|
|
103
111
|
* `affectedBuildCommand` is the project's gate command, passed through so a refusal quotes what THIS
|
|
104
112
|
* repo's gate actually runs.
|
|
105
113
|
*/
|
|
106
114
|
// 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
|
|
107
|
-
function
|
|
108
|
-
return [new whole_repo_build_guard_1.WholeRepoBuildGuardRule(affectedBuildCommand)];
|
|
115
|
+
function loadKeylessBashRules(affectedBuildCommand) {
|
|
116
|
+
return [new whole_repo_build_guard_1.WholeRepoBuildGuardRule(affectedBuildCommand), new commit_message_substitution_guard_1.CommitMessageSubstitutionGuardRule()];
|
|
109
117
|
}
|
|
110
118
|
// One MatchRule per entry of the `match-rules` array. Kept separate from loadRules (built-ins/custom)
|
|
111
119
|
// 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 { 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/loadExperimentalBashRules; 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 EXPERIMENTAL bash guards: rules that have NO webpieces.config.json entry, are switched only from\n * the optional machine-local `~/.webpieces/config.json`, and are therefore deliberately kept out of\n * `builtInConfigKeys`/`BUILT_IN_RULE_MAP` — so the config-sync check (fault Y, \"every built-in rule needs\n * an entry, or every Bash call is blocked\") can never see them. That containment is the whole point:\n * whole-repo-build-guard shipped inside the config-driven set once and took every upgrading consumer's\n * shell down with it.\n *\n * Each rule here decides for itself, from the home config, whether it does anything at all.\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 loadExperimentalBashRules(affectedBuildCommand: string): Rule[] {\n return [new WholeRepoBuildGuardRule(affectedBuildCommand)];\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 { 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, because building the whole workspace is a thing a person may\n * legitimately want and a wrong block there costs them their 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"]}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { BashContext, Violation } from '../types';
|
|
2
|
+
import { BashRuleBase, EmptyRuleConfig } from '../rule-base';
|
|
3
|
+
import { FixHint } from '../fix-hint';
|
|
4
|
+
/**
|
|
5
|
+
* Blocks `git commit` whose message is passed INLINE (`-m` / `--message` / `-am`) when that message
|
|
6
|
+
* contains a backtick, a `$(`, or a newline — and hands back `git commit -F <file>` instead.
|
|
7
|
+
*
|
|
8
|
+
* ─── The incident: a commit message that HUNG for ten minutes, twice ───────────────────────────────
|
|
9
|
+
* An agent ran `git commit -q -m "…"` with a multi-paragraph message that happened to contain the
|
|
10
|
+
* sentence: *"… `strings` on a .app built with --port 8084 contains no 8084."* The backticks are
|
|
11
|
+
* COMMAND SUBSTITUTION. The shell ran `strings` with no arguments, `strings` read stdin, and stdin
|
|
12
|
+
* never closed. The Bash tool SIGTERM'd it at its ten-minute cap. The retry kept the same sentence and
|
|
13
|
+
* did it again — twenty minutes for one commit.
|
|
14
|
+
*
|
|
15
|
+
* ─── Why this is a GUARD and not a doc line ────────────────────────────────────────────────────────
|
|
16
|
+
* The agent then diagnosed it as "a guard caught a commit message that quoting a blocked command",
|
|
17
|
+
* citing an unrelated deploy-guard note. Three facts settle that it was nothing of the kind, and they
|
|
18
|
+
* are worth writing down because they are the reason the cure has to arrive BEFORE execution:
|
|
19
|
+
*
|
|
20
|
+
* - a PreToolUse guard denies INSTANTLY, before the command runs. This ran for ten minutes.
|
|
21
|
+
* - `Exit code 143` is SIGTERM delivered to a RUNNING process, not a refusal.
|
|
22
|
+
* - the first attempt was `git add -A && git commit -m "…"`, and afterwards the 45 files were
|
|
23
|
+
* STAGED — so the command really executed, and then blocked while expanding its second word.
|
|
24
|
+
*
|
|
25
|
+
* A footnote in a doc could not have reached that agent at that moment; a refusal naming `-F` does.
|
|
26
|
+
*
|
|
27
|
+
* ─── The trigger is the METACHARACTER, never the words ─────────────────────────────────────────────
|
|
28
|
+
* A message is PROSE, and prose goes through the shell's expansion rules whether or not it is meant
|
|
29
|
+
* to. A blocklist of "dangerous commands" would have missed this entirely — `strings` is an ordinary
|
|
30
|
+
* word, and the next hang will name an ordinary word too. So the rule matches backtick, `$(` and a
|
|
31
|
+
* newline inside the message argument, and reads nothing at all into the surrounding sentence.
|
|
32
|
+
*
|
|
33
|
+
* ─── It fires on a SINGLE-QUOTED message too, and that is deliberate ───────────────────────────────
|
|
34
|
+
* `git commit -m 'a `backtick` here'` is, strictly, safe: single quotes suppress substitution, so the
|
|
35
|
+
* shell would pass those backticks through literally. This guard blocks it anyway, and the false
|
|
36
|
+
* positive is the point rather than an oversight — do not "fix" it. Two reasons:
|
|
37
|
+
*
|
|
38
|
+
* - single-quoting PROSE is fragile in the one way that matters. An English sentence eventually
|
|
39
|
+
* contains an apostrophe ("doesn't", "the agent's"), which CLOSES the quote mid-message and drops
|
|
40
|
+
* the rest of the sentence back into the shell's syntax — the exact state this guard exists to
|
|
41
|
+
* prevent, reached by a message that looked safe when it was written.
|
|
42
|
+
* - the cure is free. `-F` costs one Write tool call and works for every message, so being wrong
|
|
43
|
+
* here spends seconds, while being right saves twenty minutes. A guard chooses its errors by what
|
|
44
|
+
* each one COSTS, and these two costs are not close.
|
|
45
|
+
*
|
|
46
|
+
* `blocks a single-quoted message too` in the spec pins this, so a later reader meets the decision as
|
|
47
|
+
* a failing test rather than as a bug report.
|
|
48
|
+
*
|
|
49
|
+
* ─── It matches the RAW command, and that is load-bearing ──────────────────────────────────────────
|
|
50
|
+
* Every other bash guard here matches `ctx.commandCode`, which STRIPS heredoc bodies and quoted prose
|
|
51
|
+
* precisely so a commit message merely MENTIONING `git push` is not read as a push. That stripping
|
|
52
|
+
* deletes exactly the span this rule exists to inspect: on `commandCode` the message is already gone,
|
|
53
|
+
* and the guard would be permanently blind. So it reads `ctx.command`.
|
|
54
|
+
*
|
|
55
|
+
* That is safe here because the rule is still blocklist-shaped and its cure can never itself be
|
|
56
|
+
* blocked: `-F` writes the message to a file and passes a PATH, so nothing about the message text can
|
|
57
|
+
* make the replacement command match this guard. There is no input for which an agent is left with no
|
|
58
|
+
* accepted spelling.
|
|
59
|
+
*/
|
|
60
|
+
export declare class CommitMessageSubstitutionGuardRule extends BashRuleBase<EmptyRuleConfig> {
|
|
61
|
+
constructor();
|
|
62
|
+
private readonly scan;
|
|
63
|
+
readonly description: string;
|
|
64
|
+
get fixHint(): FixHint;
|
|
65
|
+
check(ctx: BashContext): readonly Violation[];
|
|
66
|
+
private allow;
|
|
67
|
+
private block;
|
|
68
|
+
private message;
|
|
69
|
+
private what;
|
|
70
|
+
private truncate;
|
|
71
|
+
private logDecision;
|
|
72
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CommitMessageSubstitutionGuardRule = 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 commit_message_substitution_scan_1 = require("./commit-message-substitution-scan");
|
|
12
|
+
/**
|
|
13
|
+
* Blocks `git commit` whose message is passed INLINE (`-m` / `--message` / `-am`) when that message
|
|
14
|
+
* contains a backtick, a `$(`, or a newline — and hands back `git commit -F <file>` instead.
|
|
15
|
+
*
|
|
16
|
+
* ─── The incident: a commit message that HUNG for ten minutes, twice ───────────────────────────────
|
|
17
|
+
* An agent ran `git commit -q -m "…"` with a multi-paragraph message that happened to contain the
|
|
18
|
+
* sentence: *"… `strings` on a .app built with --port 8084 contains no 8084."* The backticks are
|
|
19
|
+
* COMMAND SUBSTITUTION. The shell ran `strings` with no arguments, `strings` read stdin, and stdin
|
|
20
|
+
* never closed. The Bash tool SIGTERM'd it at its ten-minute cap. The retry kept the same sentence and
|
|
21
|
+
* did it again — twenty minutes for one commit.
|
|
22
|
+
*
|
|
23
|
+
* ─── Why this is a GUARD and not a doc line ────────────────────────────────────────────────────────
|
|
24
|
+
* The agent then diagnosed it as "a guard caught a commit message that quoting a blocked command",
|
|
25
|
+
* citing an unrelated deploy-guard note. Three facts settle that it was nothing of the kind, and they
|
|
26
|
+
* are worth writing down because they are the reason the cure has to arrive BEFORE execution:
|
|
27
|
+
*
|
|
28
|
+
* - a PreToolUse guard denies INSTANTLY, before the command runs. This ran for ten minutes.
|
|
29
|
+
* - `Exit code 143` is SIGTERM delivered to a RUNNING process, not a refusal.
|
|
30
|
+
* - the first attempt was `git add -A && git commit -m "…"`, and afterwards the 45 files were
|
|
31
|
+
* STAGED — so the command really executed, and then blocked while expanding its second word.
|
|
32
|
+
*
|
|
33
|
+
* A footnote in a doc could not have reached that agent at that moment; a refusal naming `-F` does.
|
|
34
|
+
*
|
|
35
|
+
* ─── The trigger is the METACHARACTER, never the words ─────────────────────────────────────────────
|
|
36
|
+
* A message is PROSE, and prose goes through the shell's expansion rules whether or not it is meant
|
|
37
|
+
* to. A blocklist of "dangerous commands" would have missed this entirely — `strings` is an ordinary
|
|
38
|
+
* word, and the next hang will name an ordinary word too. So the rule matches backtick, `$(` and a
|
|
39
|
+
* newline inside the message argument, and reads nothing at all into the surrounding sentence.
|
|
40
|
+
*
|
|
41
|
+
* ─── It fires on a SINGLE-QUOTED message too, and that is deliberate ───────────────────────────────
|
|
42
|
+
* `git commit -m 'a `backtick` here'` is, strictly, safe: single quotes suppress substitution, so the
|
|
43
|
+
* shell would pass those backticks through literally. This guard blocks it anyway, and the false
|
|
44
|
+
* positive is the point rather than an oversight — do not "fix" it. Two reasons:
|
|
45
|
+
*
|
|
46
|
+
* - single-quoting PROSE is fragile in the one way that matters. An English sentence eventually
|
|
47
|
+
* contains an apostrophe ("doesn't", "the agent's"), which CLOSES the quote mid-message and drops
|
|
48
|
+
* the rest of the sentence back into the shell's syntax — the exact state this guard exists to
|
|
49
|
+
* prevent, reached by a message that looked safe when it was written.
|
|
50
|
+
* - the cure is free. `-F` costs one Write tool call and works for every message, so being wrong
|
|
51
|
+
* here spends seconds, while being right saves twenty minutes. A guard chooses its errors by what
|
|
52
|
+
* each one COSTS, and these two costs are not close.
|
|
53
|
+
*
|
|
54
|
+
* `blocks a single-quoted message too` in the spec pins this, so a later reader meets the decision as
|
|
55
|
+
* a failing test rather than as a bug report.
|
|
56
|
+
*
|
|
57
|
+
* ─── It matches the RAW command, and that is load-bearing ──────────────────────────────────────────
|
|
58
|
+
* Every other bash guard here matches `ctx.commandCode`, which STRIPS heredoc bodies and quoted prose
|
|
59
|
+
* precisely so a commit message merely MENTIONING `git push` is not read as a push. That stripping
|
|
60
|
+
* deletes exactly the span this rule exists to inspect: on `commandCode` the message is already gone,
|
|
61
|
+
* and the guard would be permanently blind. So it reads `ctx.command`.
|
|
62
|
+
*
|
|
63
|
+
* That is safe here because the rule is still blocklist-shaped and its cure can never itself be
|
|
64
|
+
* blocked: `-F` writes the message to a file and passes a PATH, so nothing about the message text can
|
|
65
|
+
* make the replacement command match this guard. There is no input for which an agent is left with no
|
|
66
|
+
* accepted spelling.
|
|
67
|
+
*/
|
|
68
|
+
class CommitMessageSubstitutionGuardRule extends rule_base_1.BashRuleBase {
|
|
69
|
+
constructor() {
|
|
70
|
+
// configKey === name and is DELIBERATELY not a real webpieces.config.json key. Like
|
|
71
|
+
// whole-repo-build-guard, this guard is loaded OUTSIDE the config-driven set, so the fault-Y
|
|
72
|
+
// config-sync check ("every built-in rule needs an entry, or every Bash call is blocked") can
|
|
73
|
+
// never see it — a new guard that every consumer must configure to avoid being blocked is a
|
|
74
|
+
// guard that ships an outage on upgrade, which has happened here once already.
|
|
75
|
+
super(new rule_base_1.EmptyRuleConfig(), 'commit-message-substitution-guard', 'commit-message-substitution-guard');
|
|
76
|
+
}
|
|
77
|
+
scan = new commit_message_substitution_scan_1.CommitMessageSubstitutionScan(new command_scan_1.CommandScanner());
|
|
78
|
+
description = 'Block `git commit -m` whose inline message contains a backtick, `$(` or a newline — the shell ' +
|
|
79
|
+
'expands those before git sees them — and name `git commit -F` instead.';
|
|
80
|
+
get fixHint() {
|
|
81
|
+
return new fix_hint_1.FixHint('A commit message passed with -m goes through the shell. A backtick or $( in it is COMMAND ' +
|
|
82
|
+
'SUBSTITUTION, and a substituted command that reads stdin hangs until the tool times out.', 'Keep the message text out of the shell\'s expansion path — pass a PATH, not prose:', [
|
|
83
|
+
new rules_config_1.Option('Write the message to a file with the Write tool, then:\n' +
|
|
84
|
+
'git commit -F /tmp/commit-msg.txt', true),
|
|
85
|
+
new rules_config_1.Option('Feed it on stdin from a QUOTED heredoc:\n' +
|
|
86
|
+
'git commit -F - <<\'EOF\'\n' +
|
|
87
|
+
'<your message>\n' +
|
|
88
|
+
'EOF\n' +
|
|
89
|
+
'The quotes around EOF are what disable substitution. An unquoted <<EOF does NOT — ' +
|
|
90
|
+
'the body is still expanded, and you are back where you started.'),
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
check(ctx) {
|
|
94
|
+
// RAW, not commandCode — see the class docstring. commandCode strips the quoted prose this
|
|
95
|
+
// rule's entire subject is, so matching on it would make the guard permanently blind.
|
|
96
|
+
const hit = this.scan.firstHit(ctx.command);
|
|
97
|
+
if (hit === null)
|
|
98
|
+
return this.allow(ctx, 'no-inline-message-hazard');
|
|
99
|
+
return this.block(ctx, hit);
|
|
100
|
+
}
|
|
101
|
+
allow(ctx, reason) {
|
|
102
|
+
this.logDecision(ctx, 'ALLOW', reason);
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
block(ctx, hit) {
|
|
106
|
+
// BLOCK_AI_CURE: the cure is two commands the agent runs itself, both spelled out in fixHint.
|
|
107
|
+
this.logDecision(ctx, 'BLOCK_AI_CURE', `inline-message-${hit.kind}`);
|
|
108
|
+
return [new types_1.Violation(1, this.truncate(ctx.command), this.message(hit))];
|
|
109
|
+
}
|
|
110
|
+
// Short on purpose: it is read mid-task by an agent that needs the ONE next move.
|
|
111
|
+
message(hit) {
|
|
112
|
+
return `Blocked: the ${hit.flag} message contains ${this.what(hit.kind)}, which the SHELL acts on `
|
|
113
|
+
+ 'before git ever sees the message.\n\n'
|
|
114
|
+
+ ` ${hit.excerpt}\n\n`
|
|
115
|
+
+ 'Write the message to a file with the Write tool and commit with `git commit -F <file>`, or '
|
|
116
|
+
+ 'pipe it in with a QUOTED heredoc: `git commit -F - <<\'EOF\'`.';
|
|
117
|
+
}
|
|
118
|
+
what(kind) {
|
|
119
|
+
if (kind === 'backtick')
|
|
120
|
+
return 'a BACKTICK (command substitution — `cmd` runs cmd)';
|
|
121
|
+
if (kind === 'command-substitution')
|
|
122
|
+
return 'a `$(` (command substitution)';
|
|
123
|
+
return 'a NEWLINE (a multi-paragraph message belongs in a file — that is where the backticks hide)';
|
|
124
|
+
}
|
|
125
|
+
truncate(s) {
|
|
126
|
+
const MAX = 120;
|
|
127
|
+
return s.length <= MAX ? s : s.slice(0, MAX) + '…';
|
|
128
|
+
}
|
|
129
|
+
logDecision(ctx, verdict, reason) {
|
|
130
|
+
(0, decision_log_1.logGuardDecision)(ctx.workspaceRoot, new decision_log_1.GuardDecision('commit-message-substitution-guard', 'Bash', ctx.command, '-', verdict, reason, '-', l0_fault_codes_1.L0_FAULT_NONE, decision_log_1.MATRIX_L2_UNROWED));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
exports.CommitMessageSubstitutionGuardRule = CommitMessageSubstitutionGuardRule;
|
|
134
|
+
//# sourceMappingURL=commit-message-substitution-guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"commit-message-substitution-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/commit-message-substitution-guard.ts"],"names":[],"mappings":"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","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 { CommitMessageSubstitutionScan, MessageHazard, MessageHazardKind } from './commit-message-substitution-scan';\n\n/**\n * Blocks `git commit` whose message is passed INLINE (`-m` / `--message` / `-am`) when that message\n * contains a backtick, a `$(`, or a newline — and hands back `git commit -F <file>` instead.\n *\n * ─── The incident: a commit message that HUNG for ten minutes, twice ───────────────────────────────\n * An agent ran `git commit -q -m \"…\"` with a multi-paragraph message that happened to contain the\n * sentence: *\"… `strings` on a .app built with --port 8084 contains no 8084.\"* The backticks are\n * COMMAND SUBSTITUTION. The shell ran `strings` with no arguments, `strings` read stdin, and stdin\n * never closed. The Bash tool SIGTERM'd it at its ten-minute cap. The retry kept the same sentence and\n * did it again — twenty minutes for one commit.\n *\n * ─── Why this is a GUARD and not a doc line ────────────────────────────────────────────────────────\n * The agent then diagnosed it as \"a guard caught a commit message that quoting a blocked command\",\n * citing an unrelated deploy-guard note. Three facts settle that it was nothing of the kind, and they\n * are worth writing down because they are the reason the cure has to arrive BEFORE execution:\n *\n * - a PreToolUse guard denies INSTANTLY, before the command runs. This ran for ten minutes.\n * - `Exit code 143` is SIGTERM delivered to a RUNNING process, not a refusal.\n * - the first attempt was `git add -A && git commit -m \"…\"`, and afterwards the 45 files were\n * STAGED — so the command really executed, and then blocked while expanding its second word.\n *\n * A footnote in a doc could not have reached that agent at that moment; a refusal naming `-F` does.\n *\n * ─── The trigger is the METACHARACTER, never the words ─────────────────────────────────────────────\n * A message is PROSE, and prose goes through the shell's expansion rules whether or not it is meant\n * to. A blocklist of \"dangerous commands\" would have missed this entirely — `strings` is an ordinary\n * word, and the next hang will name an ordinary word too. So the rule matches backtick, `$(` and a\n * newline inside the message argument, and reads nothing at all into the surrounding sentence.\n *\n * ─── It fires on a SINGLE-QUOTED message too, and that is deliberate ───────────────────────────────\n * `git commit -m 'a `backtick` here'` is, strictly, safe: single quotes suppress substitution, so the\n * shell would pass those backticks through literally. This guard blocks it anyway, and the false\n * positive is the point rather than an oversight — do not \"fix\" it. Two reasons:\n *\n * - single-quoting PROSE is fragile in the one way that matters. An English sentence eventually\n * contains an apostrophe (\"doesn't\", \"the agent's\"), which CLOSES the quote mid-message and drops\n * the rest of the sentence back into the shell's syntax — the exact state this guard exists to\n * prevent, reached by a message that looked safe when it was written.\n * - the cure is free. `-F` costs one Write tool call and works for every message, so being wrong\n * here spends seconds, while being right saves twenty minutes. A guard chooses its errors by what\n * each one COSTS, and these two costs are not close.\n *\n * `blocks a single-quoted message too` in the spec pins this, so a later reader meets the decision as\n * a failing test rather than as a bug report.\n *\n * ─── It matches the RAW command, and that is load-bearing ──────────────────────────────────────────\n * Every other bash guard here matches `ctx.commandCode`, which STRIPS heredoc bodies and quoted prose\n * precisely so a commit message merely MENTIONING `git push` is not read as a push. That stripping\n * deletes exactly the span this rule exists to inspect: on `commandCode` the message is already gone,\n * and the guard would be permanently blind. So it reads `ctx.command`.\n *\n * That is safe here because the rule is still blocklist-shaped and its cure can never itself be\n * blocked: `-F` writes the message to a file and passes a PATH, so nothing about the message text can\n * make the replacement command match this guard. There is no input for which an agent is left with no\n * accepted spelling.\n */\nexport class CommitMessageSubstitutionGuardRule extends BashRuleBase<EmptyRuleConfig> {\n constructor() {\n // configKey === name and is DELIBERATELY not a real webpieces.config.json key. Like\n // whole-repo-build-guard, this guard is loaded OUTSIDE the config-driven set, so the fault-Y\n // config-sync check (\"every built-in rule needs an entry, or every Bash call is blocked\") can\n // never see it — a new guard that every consumer must configure to avoid being blocked is a\n // guard that ships an outage on upgrade, which has happened here once already.\n super(new EmptyRuleConfig(), 'commit-message-substitution-guard', 'commit-message-substitution-guard');\n }\n\n private readonly scan = new CommitMessageSubstitutionScan(new CommandScanner());\n\n readonly description =\n 'Block `git commit -m` whose inline message contains a backtick, `$(` or a newline — the shell ' +\n 'expands those before git sees them — and name `git commit -F` instead.';\n\n get fixHint(): FixHint {\n return new FixHint(\n 'A commit message passed with -m goes through the shell. A backtick or $( in it is COMMAND ' +\n 'SUBSTITUTION, and a substituted command that reads stdin hangs until the tool times out.',\n 'Keep the message text out of the shell\\'s expansion path — pass a PATH, not prose:',\n [\n new Option(\n 'Write the message to a file with the Write tool, then:\\n' +\n 'git commit -F /tmp/commit-msg.txt',\n true,\n ),\n new Option(\n 'Feed it on stdin from a QUOTED heredoc:\\n' +\n 'git commit -F - <<\\'EOF\\'\\n' +\n '<your message>\\n' +\n 'EOF\\n' +\n 'The quotes around EOF are what disable substitution. An unquoted <<EOF does NOT — ' +\n 'the body is still expanded, and you are back where you started.',\n ),\n ],\n );\n }\n\n check(ctx: BashContext): readonly Violation[] {\n // RAW, not commandCode — see the class docstring. commandCode strips the quoted prose this\n // rule's entire subject is, so matching on it would make the guard permanently blind.\n const hit = this.scan.firstHit(ctx.command);\n if (hit === null) return this.allow(ctx, 'no-inline-message-hazard');\n return this.block(ctx, hit);\n }\n\n private allow(ctx: BashContext, reason: string): readonly Violation[] {\n this.logDecision(ctx, 'ALLOW', reason);\n return [];\n }\n\n private block(ctx: BashContext, hit: MessageHazard): readonly Violation[] {\n // BLOCK_AI_CURE: the cure is two commands the agent runs itself, both spelled out in fixHint.\n this.logDecision(ctx, 'BLOCK_AI_CURE', `inline-message-${hit.kind}`);\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: MessageHazard): string {\n return `Blocked: the ${hit.flag} message contains ${this.what(hit.kind)}, which the SHELL acts on `\n + 'before git ever sees the message.\\n\\n'\n + ` ${hit.excerpt}\\n\\n`\n + 'Write the message to a file with the Write tool and commit with `git commit -F <file>`, or '\n + 'pipe it in with a QUOTED heredoc: `git commit -F - <<\\'EOF\\'`.';\n }\n\n private what(kind: MessageHazardKind): string {\n if (kind === 'backtick') return 'a BACKTICK (command substitution — `cmd` runs cmd)';\n if (kind === 'command-substitution') return 'a `$(` (command substitution)';\n return 'a NEWLINE (a multi-paragraph message belongs in a file — that is where the backticks hide)';\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 'commit-message-substitution-guard', 'Bash', ctx.command, '-', verdict, reason,\n '-', L0_FAULT_NONE, MATRIX_L2_UNROWED,\n ),\n );\n }\n}\n"]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CommandScanner } from '../command-scan';
|
|
2
|
+
/** Which shell construct was found inside an inline commit message. */
|
|
3
|
+
export type MessageHazardKind = 'backtick' | 'command-substitution' | 'newline';
|
|
4
|
+
/**
|
|
5
|
+
* One hazard found in one inline `-m` message: the flag that carried it, what it is, and the text
|
|
6
|
+
* around it. Data-only → a class, per CLAUDE.md.
|
|
7
|
+
*
|
|
8
|
+
* `excerpt` is a short window around the hit rather than the whole message: the refusal is read
|
|
9
|
+
* mid-task, and a 40-line commit message pasted back at the agent buries the one thing it must see.
|
|
10
|
+
*/
|
|
11
|
+
export declare class MessageHazard {
|
|
12
|
+
readonly flag: string;
|
|
13
|
+
readonly kind: MessageHazardKind;
|
|
14
|
+
readonly excerpt: string;
|
|
15
|
+
constructor(flag: string, kind: MessageHazardKind, excerpt: string);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Finds a commit message passed INLINE (`-m` / `--message` / `-am`) whose text carries a shell
|
|
19
|
+
* construct the shell will expand before git ever sees it.
|
|
20
|
+
*
|
|
21
|
+
* Split out from the guard for the same reason `WholeRepoBuildScan` is: WHAT counts as a hit is a
|
|
22
|
+
* tokenizer question with its own tests, while the guard owns the verdict, the log line and the words
|
|
23
|
+
* of the refusal.
|
|
24
|
+
*/
|
|
25
|
+
export declare class CommitMessageSubstitutionScan {
|
|
26
|
+
private readonly scanner;
|
|
27
|
+
constructor(scanner: CommandScanner);
|
|
28
|
+
/**
|
|
29
|
+
* The first hazard anywhere in the command, or null.
|
|
30
|
+
*
|
|
31
|
+
* `command` is the RAW command — see the guard's docstring for why `commandCode` cannot be used.
|
|
32
|
+
*/
|
|
33
|
+
firstHit(command: string): MessageHazard | null;
|
|
34
|
+
private hazardInArgs;
|
|
35
|
+
/** The message carried IN the flag token itself (`-mfix`, `--message=fix`), or null. */
|
|
36
|
+
private attachedMessage;
|
|
37
|
+
/** True when the flag's message is the FOLLOWING token (`-m msg`, `-am msg`, `--message msg`). */
|
|
38
|
+
private takesFollowingMessage;
|
|
39
|
+
/** The EARLIEST hazard in this text, so the excerpt points at what the shell hits first. */
|
|
40
|
+
private hazardIn;
|
|
41
|
+
private excerpt;
|
|
42
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CommitMessageSubstitutionScan = exports.MessageHazard = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* One hazard found in one inline `-m` message: the flag that carried it, what it is, and the text
|
|
6
|
+
* around it. Data-only → a class, per CLAUDE.md.
|
|
7
|
+
*
|
|
8
|
+
* `excerpt` is a short window around the hit rather than the whole message: the refusal is read
|
|
9
|
+
* mid-task, and a 40-line commit message pasted back at the agent buries the one thing it must see.
|
|
10
|
+
*/
|
|
11
|
+
class MessageHazard {
|
|
12
|
+
flag;
|
|
13
|
+
kind;
|
|
14
|
+
excerpt;
|
|
15
|
+
constructor(flag, kind, excerpt) {
|
|
16
|
+
this.flag = flag;
|
|
17
|
+
this.kind = kind;
|
|
18
|
+
this.excerpt = excerpt;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
exports.MessageHazard = MessageHazard;
|
|
22
|
+
/** One hazard kind and the literal text that betrays it, in the order they are reported. */
|
|
23
|
+
class HazardNeedle {
|
|
24
|
+
kind;
|
|
25
|
+
needle;
|
|
26
|
+
constructor(kind, needle) {
|
|
27
|
+
this.kind = kind;
|
|
28
|
+
this.needle = needle;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The three constructs that make a commit message dangerous, and NOTHING about the words around them.
|
|
33
|
+
*
|
|
34
|
+
* A blocklist of "dangerous commands" would have missed the incident this exists for entirely — the
|
|
35
|
+
* word that hung was `strings`, which is as ordinary as a word gets. The hazard is the METACHARACTER.
|
|
36
|
+
*/
|
|
37
|
+
const HAZARDS = [
|
|
38
|
+
new HazardNeedle('backtick', '`'),
|
|
39
|
+
new HazardNeedle('command-substitution', '$('),
|
|
40
|
+
new HazardNeedle('newline', '\n'),
|
|
41
|
+
];
|
|
42
|
+
// A short cluster of git-commit boolean short flags that may legally precede `m` in one bundle
|
|
43
|
+
// (`-am`, `-qm`, `-asm`). Deliberately NOT `[A-Za-z]*`: `-Sabcmdef` (a gpg key id containing an `m`)
|
|
44
|
+
// would then read as a message flag with an attached message.
|
|
45
|
+
const SHORT_FLAG_CLUSTER = /^-([asevqnoup]*)m(.*)$/;
|
|
46
|
+
const LONG_MESSAGE_FLAG = '--message';
|
|
47
|
+
/**
|
|
48
|
+
* Finds a commit message passed INLINE (`-m` / `--message` / `-am`) whose text carries a shell
|
|
49
|
+
* construct the shell will expand before git ever sees it.
|
|
50
|
+
*
|
|
51
|
+
* Split out from the guard for the same reason `WholeRepoBuildScan` is: WHAT counts as a hit is a
|
|
52
|
+
* tokenizer question with its own tests, while the guard owns the verdict, the log line and the words
|
|
53
|
+
* of the refusal.
|
|
54
|
+
*/
|
|
55
|
+
class CommitMessageSubstitutionScan {
|
|
56
|
+
scanner;
|
|
57
|
+
constructor(scanner) {
|
|
58
|
+
this.scanner = scanner;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The first hazard anywhere in the command, or null.
|
|
62
|
+
*
|
|
63
|
+
* `command` is the RAW command — see the guard's docstring for why `commandCode` cannot be used.
|
|
64
|
+
*/
|
|
65
|
+
firstHit(command) {
|
|
66
|
+
for (const segment of this.scanner.commandSegments(command)) {
|
|
67
|
+
const args = this.scanner.gitSubcommandArgs(segment, 'commit');
|
|
68
|
+
if (args === null)
|
|
69
|
+
continue;
|
|
70
|
+
const hit = this.hazardInArgs(args);
|
|
71
|
+
if (hit !== null)
|
|
72
|
+
return hit;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
hazardInArgs(args) {
|
|
77
|
+
for (let i = 0; i < args.length; i++) {
|
|
78
|
+
const flag = args[i];
|
|
79
|
+
const attached = this.attachedMessage(flag);
|
|
80
|
+
if (attached !== null) {
|
|
81
|
+
const hit = this.hazardIn(flag, attached);
|
|
82
|
+
if (hit !== null)
|
|
83
|
+
return hit;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!this.takesFollowingMessage(flag))
|
|
87
|
+
continue;
|
|
88
|
+
// The value is the NEXT token, and it may be absent (`git commit -m` alone, which git
|
|
89
|
+
// itself rejects). Nothing to inspect then — a guard never fails on a malformed command.
|
|
90
|
+
const text = args[i + 1];
|
|
91
|
+
i++;
|
|
92
|
+
if (text === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
const hit = this.hazardIn(flag, text);
|
|
95
|
+
if (hit !== null)
|
|
96
|
+
return hit;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/** The message carried IN the flag token itself (`-mfix`, `--message=fix`), or null. */
|
|
101
|
+
attachedMessage(flag) {
|
|
102
|
+
if (flag.startsWith(`${LONG_MESSAGE_FLAG}=`))
|
|
103
|
+
return flag.slice(LONG_MESSAGE_FLAG.length + 1);
|
|
104
|
+
// `--amend`, `--no-edit`, `--fixup=…` — every other long flag, and never a message.
|
|
105
|
+
if (flag.startsWith('--'))
|
|
106
|
+
return null;
|
|
107
|
+
const cluster = SHORT_FLAG_CLUSTER.exec(flag);
|
|
108
|
+
if (cluster === null)
|
|
109
|
+
return null;
|
|
110
|
+
return cluster[2] === '' ? null : cluster[2];
|
|
111
|
+
}
|
|
112
|
+
/** True when the flag's message is the FOLLOWING token (`-m msg`, `-am msg`, `--message msg`). */
|
|
113
|
+
takesFollowingMessage(flag) {
|
|
114
|
+
if (flag === LONG_MESSAGE_FLAG)
|
|
115
|
+
return true;
|
|
116
|
+
if (flag.startsWith('--'))
|
|
117
|
+
return false;
|
|
118
|
+
const cluster = SHORT_FLAG_CLUSTER.exec(flag);
|
|
119
|
+
return cluster !== null && cluster[2] === '';
|
|
120
|
+
}
|
|
121
|
+
/** The EARLIEST hazard in this text, so the excerpt points at what the shell hits first. */
|
|
122
|
+
hazardIn(flag, text) {
|
|
123
|
+
let best = null;
|
|
124
|
+
let bestAt = text.length;
|
|
125
|
+
for (const hazard of HAZARDS) {
|
|
126
|
+
const at = text.indexOf(hazard.needle);
|
|
127
|
+
if (at === -1 || at >= bestAt)
|
|
128
|
+
continue;
|
|
129
|
+
bestAt = at;
|
|
130
|
+
best = new MessageHazard(flag, hazard.kind, this.excerpt(text, at));
|
|
131
|
+
}
|
|
132
|
+
return best;
|
|
133
|
+
}
|
|
134
|
+
// A window around the hit, newlines shown as `\n` so the excerpt stays one line in the report.
|
|
135
|
+
excerpt(text, at) {
|
|
136
|
+
const BEFORE = 30;
|
|
137
|
+
const AFTER = 40;
|
|
138
|
+
const start = Math.max(0, at - BEFORE);
|
|
139
|
+
const end = Math.min(text.length, at + AFTER);
|
|
140
|
+
const body = text.slice(start, end).replace(/\n/g, '\\n');
|
|
141
|
+
return `${start > 0 ? '…' : ''}${body}${end < text.length ? '…' : ''}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
exports.CommitMessageSubstitutionScan = CommitMessageSubstitutionScan;
|
|
145
|
+
//# sourceMappingURL=commit-message-substitution-scan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"commit-message-substitution-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/commit-message-substitution-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import { CommandScanner } from '../command-scan';\n\n/** Which shell construct was found inside an inline commit message. */\nexport type MessageHazardKind = 'backtick' | 'command-substitution' | 'newline';\n\n/**\n * One hazard found in one inline `-m` message: the flag that carried it, what it is, and the text\n * around it. Data-only → a class, per CLAUDE.md.\n *\n * `excerpt` is a short window around the hit rather than the whole message: the refusal is read\n * mid-task, and a 40-line commit message pasted back at the agent buries the one thing it must see.\n */\nexport class MessageHazard {\n constructor(\n readonly flag: string,\n readonly kind: MessageHazardKind,\n readonly excerpt: string,\n ) {}\n}\n\n/** One hazard kind and the literal text that betrays it, in the order they are reported. */\nclass HazardNeedle {\n constructor(readonly kind: MessageHazardKind, readonly needle: string) {}\n}\n\n/**\n * The three constructs that make a commit message dangerous, and NOTHING about the words around them.\n *\n * A blocklist of \"dangerous commands\" would have missed the incident this exists for entirely — the\n * word that hung was `strings`, which is as ordinary as a word gets. The hazard is the METACHARACTER.\n */\nconst HAZARDS: readonly HazardNeedle[] = [\n new HazardNeedle('backtick', '`'),\n new HazardNeedle('command-substitution', '$('),\n new HazardNeedle('newline', '\\n'),\n];\n\n// A short cluster of git-commit boolean short flags that may legally precede `m` in one bundle\n// (`-am`, `-qm`, `-asm`). Deliberately NOT `[A-Za-z]*`: `-Sabcmdef` (a gpg key id containing an `m`)\n// would then read as a message flag with an attached message.\nconst SHORT_FLAG_CLUSTER = /^-([asevqnoup]*)m(.*)$/;\n\nconst LONG_MESSAGE_FLAG = '--message';\n\n/**\n * Finds a commit message passed INLINE (`-m` / `--message` / `-am`) whose text carries a shell\n * construct the shell will expand before git ever sees it.\n *\n * Split out from the guard for the same reason `WholeRepoBuildScan` is: WHAT counts as a hit is a\n * tokenizer question with its own tests, while the guard owns the verdict, the log line and the words\n * of the refusal.\n */\nexport class CommitMessageSubstitutionScan {\n constructor(private readonly scanner: CommandScanner) {}\n\n /**\n * The first hazard anywhere in the command, or null.\n *\n * `command` is the RAW command — see the guard's docstring for why `commandCode` cannot be used.\n */\n firstHit(command: string): MessageHazard | null {\n for (const segment of this.scanner.commandSegments(command)) {\n const args = this.scanner.gitSubcommandArgs(segment, 'commit');\n if (args === null) continue;\n const hit = this.hazardInArgs(args);\n if (hit !== null) return hit;\n }\n return null;\n }\n\n private hazardInArgs(args: readonly string[]): MessageHazard | null {\n for (let i = 0; i < args.length; i++) {\n const flag = args[i];\n const attached = this.attachedMessage(flag);\n if (attached !== null) {\n const hit = this.hazardIn(flag, attached);\n if (hit !== null) return hit;\n continue;\n }\n if (!this.takesFollowingMessage(flag)) continue;\n // The value is the NEXT token, and it may be absent (`git commit -m` alone, which git\n // itself rejects). Nothing to inspect then — a guard never fails on a malformed command.\n const text = args[i + 1];\n i++;\n if (text === undefined) continue;\n const hit = this.hazardIn(flag, text);\n if (hit !== null) return hit;\n }\n return null;\n }\n\n /** The message carried IN the flag token itself (`-mfix`, `--message=fix`), or null. */\n private attachedMessage(flag: string): string | null {\n if (flag.startsWith(`${LONG_MESSAGE_FLAG}=`)) return flag.slice(LONG_MESSAGE_FLAG.length + 1);\n // `--amend`, `--no-edit`, `--fixup=…` — every other long flag, and never a message.\n if (flag.startsWith('--')) return null;\n const cluster = SHORT_FLAG_CLUSTER.exec(flag);\n if (cluster === null) return null;\n return cluster[2] === '' ? null : cluster[2];\n }\n\n /** True when the flag's message is the FOLLOWING token (`-m msg`, `-am msg`, `--message msg`). */\n private takesFollowingMessage(flag: string): boolean {\n if (flag === LONG_MESSAGE_FLAG) return true;\n if (flag.startsWith('--')) return false;\n const cluster = SHORT_FLAG_CLUSTER.exec(flag);\n return cluster !== null && cluster[2] === '';\n }\n\n /** The EARLIEST hazard in this text, so the excerpt points at what the shell hits first. */\n private hazardIn(flag: string, text: string): MessageHazard | null {\n let best: MessageHazard | null = null;\n let bestAt = text.length;\n for (const hazard of HAZARDS) {\n const at = text.indexOf(hazard.needle);\n if (at === -1 || at >= bestAt) continue;\n bestAt = at;\n best = new MessageHazard(flag, hazard.kind, this.excerpt(text, at));\n }\n return best;\n }\n\n // A window around the hit, newlines shown as `\\n` so the excerpt stays one line in the report.\n private excerpt(text: string, at: number): string {\n const BEFORE = 30;\n const AFTER = 40;\n const start = Math.max(0, at - BEFORE);\n const end = Math.min(text.length, at + AFTER);\n const body = text.slice(start, end).replace(/\\n/g, '\\\\n');\n return `${start > 0 ? '…' : ''}${body}${end < text.length ? '…' : ''}`;\n }\n}\n"]}
|