@webpieces/hook-runtime 0.0.1
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 +37 -0
- package/src/adapters/agent-adapters.d.ts +13 -0
- package/src/adapters/agent-adapters.js +23 -0
- package/src/adapters/agent-adapters.js.map +1 -0
- package/src/adapters/agent-payload.d.ts +48 -0
- package/src/adapters/agent-payload.js +30 -0
- package/src/adapters/agent-payload.js.map +1 -0
- package/src/adapters/claude-code-adapter.d.ts +26 -0
- package/src/adapters/claude-code-adapter.js +69 -0
- package/src/adapters/claude-code-adapter.js.map +1 -0
- package/src/adapters/codex-adapter.d.ts +19 -0
- package/src/adapters/codex-adapter.js +48 -0
- package/src/adapters/codex-adapter.js.map +1 -0
- package/src/adapters/detect-ai.d.ts +70 -0
- package/src/adapters/detect-ai.js +82 -0
- package/src/adapters/detect-ai.js.map +1 -0
- package/src/core/apply-patch-parse.d.ts +36 -0
- package/src/core/apply-patch-parse.js +155 -0
- package/src/core/apply-patch-parse.js.map +1 -0
- package/src/core/build-context.d.ts +11 -0
- package/src/core/build-context.js +69 -0
- package/src/core/build-context.js.map +1 -0
- package/src/core/command-scan.d.ts +122 -0
- package/src/core/command-scan.js +282 -0
- package/src/core/command-scan.js.map +1 -0
- package/src/core/custom-rule-adapter.d.ts +23 -0
- package/src/core/custom-rule-adapter.js +50 -0
- package/src/core/custom-rule-adapter.js.map +1 -0
- package/src/core/delete-scoped-rules.d.ts +9 -0
- package/src/core/delete-scoped-rules.js +31 -0
- package/src/core/delete-scoped-rules.js.map +1 -0
- package/src/core/disable-directives.d.ts +8 -0
- package/src/core/disable-directives.js +86 -0
- package/src/core/disable-directives.js.map +1 -0
- package/src/core/effective-tree.d.ts +197 -0
- package/src/core/effective-tree.js +269 -0
- package/src/core/effective-tree.js.map +1 -0
- package/src/core/excluded-paths.d.ts +23 -0
- package/src/core/excluded-paths.js +54 -0
- package/src/core/excluded-paths.js.map +1 -0
- package/src/core/file-evaluation.d.ts +11 -0
- package/src/core/file-evaluation.js +45 -0
- package/src/core/file-evaluation.js.map +1 -0
- package/src/core/fix-hint.d.ts +40 -0
- package/src/core/fix-hint.js +53 -0
- package/src/core/fix-hint.js.map +1 -0
- package/src/core/glob.d.ts +1 -0
- package/src/core/glob.js +42 -0
- package/src/core/glob.js.map +1 -0
- package/src/core/report.d.ts +21 -0
- package/src/core/report.js +74 -0
- package/src/core/report.js.map +1 -0
- package/src/core/root-manifest.d.ts +1 -0
- package/src/core/root-manifest.js +21 -0
- package/src/core/root-manifest.js.map +1 -0
- package/src/core/rule-base.d.ts +40 -0
- package/src/core/rule-base.js +37 -0
- package/src/core/rule-base.js.map +1 -0
- package/src/core/rule-evaluation.d.ts +18 -0
- package/src/core/rule-evaluation.js +179 -0
- package/src/core/rule-evaluation.js.map +1 -0
- package/src/core/rules/shell-segment-scan.d.ts +64 -0
- package/src/core/rules/shell-segment-scan.js +88 -0
- package/src/core/rules/shell-segment-scan.js.map +1 -0
- package/src/core/shell-read-parity.d.ts +34 -0
- package/src/core/shell-read-parity.js +157 -0
- package/src/core/shell-read-parity.js.map +1 -0
- package/src/core/strip-ts-noise.d.ts +1 -0
- package/src/core/strip-ts-noise.js +191 -0
- package/src/core/strip-ts-noise.js.map +1 -0
- package/src/core/target-tree.d.ts +84 -0
- package/src/core/target-tree.js +148 -0
- package/src/core/target-tree.js.map +1 -0
- package/src/core/types.d.ts +149 -0
- package/src/core/types.js +190 -0
- package/src/core/types.js.map +1 -0
- package/src/harness-vocabulary.d.ts +2 -0
- package/src/harness-vocabulary.js +10 -0
- package/src/harness-vocabulary.js.map +1 -0
- package/src/hook-app.d.ts +15 -0
- package/src/hook-app.js +58 -0
- package/src/hook-app.js.map +1 -0
- package/src/hook-evaluator.d.ts +5 -0
- package/src/hook-evaluator.js +13 -0
- package/src/hook-evaluator.js.map +1 -0
- package/src/hook-ports.d.ts +9 -0
- package/src/hook-ports.js +40 -0
- package/src/hook-ports.js.map +1 -0
- package/src/index.d.ts +31 -0
- package/src/index.js +59 -0
- package/src/index.js.map +1 -0
- package/src/outcome.d.ts +14 -0
- package/src/outcome.js +20 -0
- package/src/outcome.js.map +1 -0
- package/src/protocol.d.ts +40 -0
- package/src/protocol.js +55 -0
- package/src/protocol.js.map +1 -0
- package/src/response.d.ts +2 -0
- package/src/response.js +21 -0
- package/src/response.js.map +1 -0
- package/src/to-error.d.ts +1 -0
- package/src/to-error.js +9 -0
- package/src/to-error.js.map +1 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkConfigSync = checkConfigSync;
|
|
4
|
+
exports.runRuleCheck = runRuleCheck;
|
|
5
|
+
exports.runBashRules = runBashRules;
|
|
6
|
+
exports.runEditRules = runEditRules;
|
|
7
|
+
exports.runFileRules = runFileRules;
|
|
8
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
9
|
+
const to_error_1 = require("@webpieces/tooling-common/to-error");
|
|
10
|
+
const types_1 = require("./types");
|
|
11
|
+
const glob_1 = require("./glob");
|
|
12
|
+
// The set of CONFIG KEYS explicitly present in webpieces.config.json (every key except rulesDir).
|
|
13
|
+
// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers
|
|
14
|
+
function configuredRuleNames(config) {
|
|
15
|
+
return new Set(Object.keys(config).filter((k) => k !== 'rulesDir'));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Fault Y — a loaded rule whose CONFIG KEY has no entry.
|
|
19
|
+
*
|
|
20
|
+
* Compared on `configKey`, not `name`. Eight of the loaded rules are classes behind two policy keys, so
|
|
21
|
+
* comparing names here would demand entries named `feature-branch-guard`, `pr-merge-guard` and six
|
|
22
|
+
* others — keys the validator then REJECTS as retired. That is the two-enforcement-paths deadlock this
|
|
23
|
+
* repo has already hit once (see the narration in guards.spec.ts), and it blocks every Bash/Write/Edit.
|
|
24
|
+
*
|
|
25
|
+
* De-duplicated on the key too, so one missing policy entry reports ONE paste-ready snippet rather than
|
|
26
|
+
* four copies of the same one under four different headings.
|
|
27
|
+
*/
|
|
28
|
+
// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers
|
|
29
|
+
function checkConfigSync(rules, config, registry) {
|
|
30
|
+
const configured = configuredRuleNames(config);
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const unconfiguredRules = rules.filter((r) => {
|
|
33
|
+
if (configured.has(r.configKey) || seen.has(r.configKey))
|
|
34
|
+
return false;
|
|
35
|
+
seen.add(r.configKey);
|
|
36
|
+
return true;
|
|
37
|
+
});
|
|
38
|
+
if (unconfiguredRules.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
// ONE action, no menu, no escalation — the config-validation invariant (guards/L0-tooling.md): every
|
|
41
|
+
// config problem cures to "make the file right", and editing it is never denied. This message used
|
|
42
|
+
// to tell the agent to interview the human about each rule; agents did not do it, so the block just
|
|
43
|
+
// stalled. Each rule now ships a paste-ready entry at its recommended mode.
|
|
44
|
+
//
|
|
45
|
+
// Note this is the CONFIG-BEHIND-CODE direction. The opposite one — the config names a rule the
|
|
46
|
+
// installed validator has no schema for — is unknownRuleError() in rules-config/validate-config.ts
|
|
47
|
+
// and surfaces in the validation banner, not here.
|
|
48
|
+
const lines = [
|
|
49
|
+
// Fault Y's header lives in ./l0-matrix beside the rest of the L0 fault table (same reason as
|
|
50
|
+
// CONFIG_MISSING_REPORT: one place states what this fault is and what cures it).
|
|
51
|
+
rules_config_1.CONFIG_OUT_OF_SYNC_HEADER,
|
|
52
|
+
'',
|
|
53
|
+
`Add an entry for each rule below to ${rules_config_1.CONFIG_FILENAME}. Editing that file is ALWAYS allowed through`,
|
|
54
|
+
'the guard — including right now, while this block is up — so paste the entries and retry.',
|
|
55
|
+
'',
|
|
56
|
+
'Each entry below is ready to paste at its recommended mode; adjust the option values if your',
|
|
57
|
+
'project needs different ones.',
|
|
58
|
+
'',
|
|
59
|
+
`Do NOT delete a rule from ${rules_config_1.CONFIG_FILENAME} to silence it — an entry is REQUIRED for every rule,`,
|
|
60
|
+
'and "mode": "OFF" is how a rule is turned off.',
|
|
61
|
+
'',
|
|
62
|
+
];
|
|
63
|
+
for (const rule of unconfiguredRules) {
|
|
64
|
+
lines.push(`--- ${rule.configKey} ---`);
|
|
65
|
+
lines.push(`Description: ${rule.description}`);
|
|
66
|
+
const opts = rule.defaultOptions;
|
|
67
|
+
const optKeys = Object.keys(opts);
|
|
68
|
+
if (optKeys.length > 0) {
|
|
69
|
+
lines.push(`Available options (suggested defaults shown):`);
|
|
70
|
+
for (const key of optKeys) {
|
|
71
|
+
lines.push(` ${key}: ${JSON.stringify(opts[key])}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
lines.push('Available options: none beyond mode');
|
|
76
|
+
}
|
|
77
|
+
// The SAME entry the installer would seed: recommended mode, both hatches, and every other
|
|
78
|
+
// schema-required field — so pasting it satisfies the loader in one pass.
|
|
79
|
+
lines.push(`Entry to add to ${rules_config_1.CONFIG_FILENAME}:`);
|
|
80
|
+
lines.push(` "${rule.configKey}": ${JSON.stringify((0, rules_config_1.seedEntryForRule)(rule.configKey, registry))}`);
|
|
81
|
+
lines.push('');
|
|
82
|
+
}
|
|
83
|
+
// Fault Y, stamped for the audit trail — see configMissingBlock for why the producer names it.
|
|
84
|
+
return new types_1.BlockedResult(lines.join('\n'), rules_config_1.L0_FAULT_CONFIG_OUT_OF_SYNC);
|
|
85
|
+
}
|
|
86
|
+
// N-legs pattern: each rule runs independently so one rule can never abort the others. A rule may
|
|
87
|
+
// EITHER return Violation[] OR throw — both accumulate here into visible violations the AI sees:
|
|
88
|
+
// - a thrown RuleFailError → an expected, well-formed violation (its line/snippet/fixOptions kept);
|
|
89
|
+
// - a thrown plain Error → a "crashed" violation (a bug, surfaced not swallowed).
|
|
90
|
+
// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers
|
|
91
|
+
function runRuleCheck(rule, ctx) {
|
|
92
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
93
|
+
try {
|
|
94
|
+
return rule.check(ctx);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
const error = (0, to_error_1.toError)(err);
|
|
98
|
+
if (error instanceof rules_config_1.RuleFailError) {
|
|
99
|
+
return [violationFromRuleFail(error)];
|
|
100
|
+
}
|
|
101
|
+
return [new types_1.Violation(0, '', `Rule '${rule.name}' crashed: ${error.message}`)];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// A thrown RuleFailError carries its own AI-facing message + optional location and cures. Fold the
|
|
105
|
+
// cures into the message because Violation has no fixHint field (RuleGroup's fixHint comes from the
|
|
106
|
+
// rule definition, not a per-throw value). The "Fix Option N:"/"(preferred)" labels come from the ONE
|
|
107
|
+
// framework-owned renderer, exactly as report.ts renders a rule's static FixHint — a rule never
|
|
108
|
+
// hand-numbers its own cures.
|
|
109
|
+
// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers
|
|
110
|
+
function violationFromRuleFail(error) {
|
|
111
|
+
return new types_1.Violation(error.line ?? 0, error.snippet ?? '', (0, rules_config_1.renderRuleFailForAi)(error));
|
|
112
|
+
}
|
|
113
|
+
// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers
|
|
114
|
+
function ruleMatchesFile(rule, relativePath) {
|
|
115
|
+
for (const pattern of rule.files) {
|
|
116
|
+
if ((0, glob_1.globMatches)(pattern, relativePath))
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers
|
|
122
|
+
function runBashRules(rules, bashContext) {
|
|
123
|
+
const groups = [];
|
|
124
|
+
for (const rule of rules) {
|
|
125
|
+
if (rule.scope !== 'bash')
|
|
126
|
+
continue;
|
|
127
|
+
if (!rule.shouldRun())
|
|
128
|
+
continue;
|
|
129
|
+
const vs = runRuleCheck(rule, bashContext);
|
|
130
|
+
if (vs.length > 0) {
|
|
131
|
+
groups.push(new types_1.RuleGroup(rule.name, rule.description, rule.fixHint, [...vs]));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return groups;
|
|
135
|
+
}
|
|
136
|
+
// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers
|
|
137
|
+
function runEditRules(rules, editContexts) {
|
|
138
|
+
const groups = [];
|
|
139
|
+
for (const rule of rules) {
|
|
140
|
+
if (rule.scope !== 'edit')
|
|
141
|
+
continue;
|
|
142
|
+
if (!rule.shouldRun())
|
|
143
|
+
continue;
|
|
144
|
+
const allViolations = [];
|
|
145
|
+
for (const ctx of editContexts) {
|
|
146
|
+
if (!ruleMatchesFile(rule, ctx.relativePath))
|
|
147
|
+
continue;
|
|
148
|
+
const vs = runRuleCheck(rule, ctx);
|
|
149
|
+
for (const v of vs) {
|
|
150
|
+
const copy = new types_1.Violation(v.line, v.snippet, v.message);
|
|
151
|
+
copy.editIndex = ctx.editIndex;
|
|
152
|
+
copy.editCount = ctx.editCount;
|
|
153
|
+
allViolations.push(copy);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (allViolations.length > 0) {
|
|
157
|
+
groups.push(new types_1.RuleGroup(rule.name, rule.description, rule.fixHint, allViolations));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return groups;
|
|
161
|
+
}
|
|
162
|
+
// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers
|
|
163
|
+
function runFileRules(rules, fileContext) {
|
|
164
|
+
const groups = [];
|
|
165
|
+
for (const rule of rules) {
|
|
166
|
+
if (rule.scope !== 'file')
|
|
167
|
+
continue;
|
|
168
|
+
if (!rule.shouldRun())
|
|
169
|
+
continue;
|
|
170
|
+
if (!ruleMatchesFile(rule, fileContext.relativePath))
|
|
171
|
+
continue;
|
|
172
|
+
const vs = runRuleCheck(rule, fileContext);
|
|
173
|
+
if (vs.length > 0) {
|
|
174
|
+
groups.push(new types_1.RuleGroup(rule.name, rule.description, rule.fixHint, [...vs]));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return groups;
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=rule-evaluation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rule-evaluation.js","sourceRoot":"","sources":["../../../../../../packages/tooling/hook-runtime/src/core/rule-evaluation.ts"],"names":[],"mappings":"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","sourcesContent":["import { CONFIG_FILENAME, CONFIG_OUT_OF_SYNC_HEADER, L0_FAULT_CONFIG_OUT_OF_SYNC, seedEntryForRule, RulePackRegistry, WebpiecesRulesConfig, RuleFailError, renderRuleFailForAi } from '@webpieces/rules-config';\nimport { toError } from '@webpieces/tooling-common/to-error';\nimport { Rule, EditContext, FileContext, BashContext, Violation, RuleGroup, BlockedResult } from './types';\n\nimport { globMatches } from './glob';\n\n// The set of CONFIG KEYS explicitly present in webpieces.config.json (every key except rulesDir).\n// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers\nfunction configuredRuleNames(config: WebpiecesRulesConfig): ReadonlySet<string> {\n return new Set(Object.keys(config).filter((k: string) => k !== 'rulesDir'));\n}\n\n/**\n * Fault Y — a loaded rule whose CONFIG KEY has no entry.\n *\n * Compared on `configKey`, not `name`. Eight of the loaded rules are classes behind two policy keys, so\n * comparing names here would demand entries named `feature-branch-guard`, `pr-merge-guard` and six\n * others — keys the validator then REJECTS as retired. That is the two-enforcement-paths deadlock this\n * repo has already hit once (see the narration in guards.spec.ts), and it blocks every Bash/Write/Edit.\n *\n * De-duplicated on the key too, so one missing policy entry reports ONE paste-ready snippet rather than\n * four copies of the same one under four different headings.\n */\n// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers\nexport function checkConfigSync(rules: readonly Rule[], config: WebpiecesRulesConfig, registry: RulePackRegistry): BlockedResult | null {\n const configured = configuredRuleNames(config);\n const seen = new Set<string>();\n const unconfiguredRules = rules.filter((r: Rule): boolean => {\n if (configured.has(r.configKey) || seen.has(r.configKey)) return false;\n seen.add(r.configKey);\n return true;\n });\n if (unconfiguredRules.length === 0) return null;\n\n // ONE action, no menu, no escalation — the config-validation invariant (guards/L0-tooling.md): every\n // config problem cures to \"make the file right\", and editing it is never denied. This message used\n // to tell the agent to interview the human about each rule; agents did not do it, so the block just\n // stalled. Each rule now ships a paste-ready entry at its recommended mode.\n //\n // Note this is the CONFIG-BEHIND-CODE direction. The opposite one — the config names a rule the\n // installed validator has no schema for — is unknownRuleError() in rules-config/validate-config.ts\n // and surfaces in the validation banner, not here.\n const lines = [\n // Fault Y's header lives in ./l0-matrix beside the rest of the L0 fault table (same reason as\n // CONFIG_MISSING_REPORT: one place states what this fault is and what cures it).\n CONFIG_OUT_OF_SYNC_HEADER,\n '',\n `Add an entry for each rule below to ${CONFIG_FILENAME}. Editing that file is ALWAYS allowed through`,\n 'the guard — including right now, while this block is up — so paste the entries and retry.',\n '',\n 'Each entry below is ready to paste at its recommended mode; adjust the option values if your',\n 'project needs different ones.',\n '',\n `Do NOT delete a rule from ${CONFIG_FILENAME} to silence it — an entry is REQUIRED for every rule,`,\n 'and \"mode\": \"OFF\" is how a rule is turned off.',\n '',\n ];\n\n for (const rule of unconfiguredRules) {\n lines.push(`--- ${rule.configKey} ---`);\n lines.push(`Description: ${rule.description}`);\n const opts = rule.defaultOptions;\n const optKeys = Object.keys(opts);\n if (optKeys.length > 0) {\n lines.push(`Available options (suggested defaults shown):`);\n for (const key of optKeys) {\n lines.push(` ${key}: ${JSON.stringify(opts[key])}`);\n }\n } else {\n lines.push('Available options: none beyond mode');\n }\n // The SAME entry the installer would seed: recommended mode, both hatches, and every other\n // schema-required field — so pasting it satisfies the loader in one pass.\n lines.push(`Entry to add to ${CONFIG_FILENAME}:`);\n lines.push(` \"${rule.configKey}\": ${JSON.stringify(seedEntryForRule(rule.configKey, registry))}`);\n lines.push('');\n }\n\n // Fault Y, stamped for the audit trail — see configMissingBlock for why the producer names it.\n return new BlockedResult(lines.join('\\n'), L0_FAULT_CONFIG_OUT_OF_SYNC);\n}\n\n// N-legs pattern: each rule runs independently so one rule can never abort the others. A rule may\n// EITHER return Violation[] OR throw — both accumulate here into visible violations the AI sees:\n// - a thrown RuleFailError → an expected, well-formed violation (its line/snippet/fixOptions kept);\n// - a thrown plain Error → a \"crashed\" violation (a bug, surfaced not swallowed).\n// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers\nexport function runRuleCheck(rule: Rule, ctx: EditContext | FileContext | BashContext): readonly Violation[] {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return rule.check(ctx);\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof RuleFailError) {\n return [violationFromRuleFail(error)];\n }\n return [new Violation(0, '', `Rule '${rule.name}' crashed: ${error.message}`)];\n }\n}\n\n// A thrown RuleFailError carries its own AI-facing message + optional location and cures. Fold the\n// cures into the message because Violation has no fixHint field (RuleGroup's fixHint comes from the\n// rule definition, not a per-throw value). The \"Fix Option N:\"/\"(preferred)\" labels come from the ONE\n// framework-owned renderer, exactly as report.ts renders a rule's static FixHint — a rule never\n// hand-numbers its own cures.\n// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers\nfunction violationFromRuleFail(error: RuleFailError): Violation {\n return new Violation(error.line ?? 0, error.snippet ?? '', renderRuleFailForAi(error));\n}\n\n// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers\nfunction ruleMatchesFile(rule: Rule, relativePath: string): boolean {\n for (const pattern of rule.files) {\n if (globMatches(pattern, relativePath)) return true;\n }\n return false;\n}\n\n// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers\nexport function runBashRules(rules: readonly Rule[], bashContext: BashContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'bash') continue;\n if (!rule.shouldRun()) continue;\n const vs = runRuleCheck(rule, bashContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n\n// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers\nexport function runEditRules(rules: readonly Rule[], editContexts: readonly EditContext[]): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'edit') continue;\n if (!rule.shouldRun()) continue;\n const allViolations: Violation[] = [];\n for (const ctx of editContexts) {\n if (!ruleMatchesFile(rule, ctx.relativePath)) continue;\n const vs = runRuleCheck(rule, ctx);\n for (const v of vs) {\n const copy = new Violation(v.line, v.snippet, v.message);\n copy.editIndex = ctx.editIndex;\n copy.editCount = ctx.editCount;\n allViolations.push(copy);\n }\n }\n if (allViolations.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, allViolations,\n ));\n }\n }\n return groups;\n}\n\n// webpieces-disable no-function-outside-class -- existing stateless evaluator helpers extracted intact for both hook providers\nexport function runFileRules(rules: readonly Rule[], fileContext: FileContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'file') continue;\n if (!rule.shouldRun()) continue;\n if (!ruleMatchesFile(rule, fileContext.relativePath)) continue;\n const vs = runRuleCheck(rule, fileContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n"]}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { CommandScanner, CommandSegment } from '../command-scan';
|
|
2
|
+
/**
|
|
3
|
+
* What ROLE one segment of a shell command plays — the question every allowlist-shaped bash guard has
|
|
4
|
+
* to answer before it can judge a compound command.
|
|
5
|
+
*
|
|
6
|
+
* WHY this exists: `merged-branch-bash-guard` allowlists the commands that get you OFF a merged
|
|
7
|
+
* branch, and the redirect it prints tells the agent to run them. An agent bounds tool output by
|
|
8
|
+
* reflex, so it runs `git fetch origin main 2>&1 | tail -5` — and the guard, evaluating `tail -5` as
|
|
9
|
+
* an ordinary command, denied the very remedy it had just printed. Verified pairs from the field:
|
|
10
|
+
*
|
|
11
|
+
* pnpm wp-cleanup allowed
|
|
12
|
+
* pnpm wp-cleanup 2>&1 | tail -40 BLOCKED
|
|
13
|
+
* git fetch origin main allowed
|
|
14
|
+
* git fetch origin main 2>&1; echo BLOCKED
|
|
15
|
+
*
|
|
16
|
+
* Nothing in `| tail -40` or `; echo done` can touch the repo, and `done`/`do`/`for x in a b` are not
|
|
17
|
+
* commands at all. So a segment is one of three things:
|
|
18
|
+
*
|
|
19
|
+
* - STRUCTURE — pure shell syntax, invokes nothing (`for b in a b c`, `done`, `fi`).
|
|
20
|
+
* - SHAPING — cannot change the repo: a pager/filter fed by a PIPE (`| tail`, `| head`, `| wc`),
|
|
21
|
+
* or an always-inert command (`echo`, `cd`, `pwd`, `true`).
|
|
22
|
+
* - COMMAND — a real invocation, with `words` giving the effective argv AFTER leading shell
|
|
23
|
+
* keywords are stripped, so `do gh pr list` classifies as `gh pr list`.
|
|
24
|
+
*
|
|
25
|
+
* Two things keep SHAPING honest. A filter counts only when a PIPE fed it — bare `tail src/x.ts`
|
|
26
|
+
* reads the working tree and stays a COMMAND. And a segment carrying an output REDIRECT (`> file`,
|
|
27
|
+
* `>> file`) is always a COMMAND, because `echo x > src/y.ts` writes the repo. `2>&1` is not a
|
|
28
|
+
* redirect to a file and is deliberately not caught.
|
|
29
|
+
*
|
|
30
|
+
* Deciding WHETHER a shaping segment is acceptable is still the guard's call: merged-branch-bash-guard
|
|
31
|
+
* pairs this with ContentReadScan so `git status | cat src/foo.ts` (a filter with a workspace path)
|
|
32
|
+
* stays blocked.
|
|
33
|
+
*/
|
|
34
|
+
export type SegmentRole = 'structure' | 'shaping' | 'command';
|
|
35
|
+
/** Data-only (per CLAUDE.md, classes for data). */
|
|
36
|
+
export declare class SegmentVerdict {
|
|
37
|
+
role: SegmentRole;
|
|
38
|
+
/** The effective argv for a COMMAND, leading shell keywords stripped. Empty for the other roles. */
|
|
39
|
+
words: readonly string[];
|
|
40
|
+
constructor(role: SegmentRole, words: readonly string[]);
|
|
41
|
+
}
|
|
42
|
+
export declare class ShellSegmentScan {
|
|
43
|
+
private readonly scanner;
|
|
44
|
+
constructor(scanner?: CommandScanner);
|
|
45
|
+
classify(segment: CommandSegment): SegmentVerdict;
|
|
46
|
+
/**
|
|
47
|
+
* The effective argv of a segment with leading shell keywords removed — what a guard should judge
|
|
48
|
+
* instead of the raw words. `for b in $(…); do git status; done` splits into three segments and
|
|
49
|
+
* the middle one is literally `do git status`; without this, `do` is the command name and every
|
|
50
|
+
* loop body walks straight past a git allowlist.
|
|
51
|
+
*/
|
|
52
|
+
effectiveWords(segmentText: string): readonly string[];
|
|
53
|
+
private stripKeywords;
|
|
54
|
+
/**
|
|
55
|
+
* `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.
|
|
56
|
+
*
|
|
57
|
+
* PUBLIC because RecoveryAllowlist asks the same question of a segment it is about to allow on the
|
|
58
|
+
* strength of the program name alone (`curl`, `gh`): those cannot touch the tree by themselves, but
|
|
59
|
+
* `curl … > src/x.ts` can. One implementation, so the two callers cannot disagree about what
|
|
60
|
+
* counts as a redirect — the `2>&1` carve-out above is exactly the kind of detail a second copy
|
|
61
|
+
* gets wrong.
|
|
62
|
+
*/
|
|
63
|
+
redirectsToFile(words: readonly string[]): boolean;
|
|
64
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ShellSegmentScan = exports.SegmentVerdict = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const path = tslib_1.__importStar(require("path"));
|
|
6
|
+
const command_scan_1 = require("../command-scan");
|
|
7
|
+
/** Data-only (per CLAUDE.md, classes for data). */
|
|
8
|
+
class SegmentVerdict {
|
|
9
|
+
role;
|
|
10
|
+
/** The effective argv for a COMMAND, leading shell keywords stripped. Empty for the other roles. */
|
|
11
|
+
words;
|
|
12
|
+
constructor(role, words) {
|
|
13
|
+
this.role = role;
|
|
14
|
+
this.words = words;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
exports.SegmentVerdict = SegmentVerdict;
|
|
18
|
+
class ShellSegmentScan {
|
|
19
|
+
scanner;
|
|
20
|
+
constructor(scanner = new command_scan_1.CommandScanner()) {
|
|
21
|
+
this.scanner = scanner;
|
|
22
|
+
}
|
|
23
|
+
classify(segment) {
|
|
24
|
+
const words = this.stripKeywords(this.scanner.words(segment.text));
|
|
25
|
+
if (words.length === 0)
|
|
26
|
+
return STRUCTURE;
|
|
27
|
+
const head = path.basename(words[0]);
|
|
28
|
+
if (STRUCTURE_HEADS.has(head))
|
|
29
|
+
return STRUCTURE;
|
|
30
|
+
// A redirect can create or overwrite a file, so it is never inert — judge it as a command.
|
|
31
|
+
if (this.redirectsToFile(words))
|
|
32
|
+
return new SegmentVerdict('command', words);
|
|
33
|
+
if (ALWAYS_INERT.has(head))
|
|
34
|
+
return new SegmentVerdict('shaping', words);
|
|
35
|
+
if (segment.join === '|' && OUTPUT_FILTERS.has(head))
|
|
36
|
+
return new SegmentVerdict('shaping', words);
|
|
37
|
+
return new SegmentVerdict('command', words);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The effective argv of a segment with leading shell keywords removed — what a guard should judge
|
|
41
|
+
* instead of the raw words. `for b in $(…); do git status; done` splits into three segments and
|
|
42
|
+
* the middle one is literally `do git status`; without this, `do` is the command name and every
|
|
43
|
+
* loop body walks straight past a git allowlist.
|
|
44
|
+
*/
|
|
45
|
+
effectiveWords(segmentText) {
|
|
46
|
+
return this.stripKeywords(this.scanner.words(segmentText));
|
|
47
|
+
}
|
|
48
|
+
stripKeywords(words) {
|
|
49
|
+
let i = 0;
|
|
50
|
+
while (i < words.length && STRIPPABLE_KEYWORDS.has(words[i]))
|
|
51
|
+
i++;
|
|
52
|
+
return words.slice(i);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.
|
|
56
|
+
*
|
|
57
|
+
* PUBLIC because RecoveryAllowlist asks the same question of a segment it is about to allow on the
|
|
58
|
+
* strength of the program name alone (`curl`, `gh`): those cannot touch the tree by themselves, but
|
|
59
|
+
* `curl … > src/x.ts` can. One implementation, so the two callers cannot disagree about what
|
|
60
|
+
* counts as a redirect — the `2>&1` carve-out above is exactly the kind of detail a second copy
|
|
61
|
+
* gets wrong.
|
|
62
|
+
*/
|
|
63
|
+
redirectsToFile(words) {
|
|
64
|
+
return words.some((word) => REDIRECT_TO_FILE.test(word));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
exports.ShellSegmentScan = ShellSegmentScan;
|
|
68
|
+
const STRUCTURE = new SegmentVerdict('structure', []);
|
|
69
|
+
// Keywords that PRECEDE a real command; strip them and judge what follows.
|
|
70
|
+
const STRIPPABLE_KEYWORDS = new Set([
|
|
71
|
+
'do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '}', '(', ')',
|
|
72
|
+
]);
|
|
73
|
+
// Segments that invoke nothing at all: loop/case HEADERS (their tail is a word list, and any `$(…)`
|
|
74
|
+
// inside was already split into its own segment by CommandScanner) and the closing keywords.
|
|
75
|
+
const STRUCTURE_HEADS = new Set([
|
|
76
|
+
'for', 'case', 'select', 'done', 'fi', 'esac', ';;', 'in',
|
|
77
|
+
]);
|
|
78
|
+
// Commands that cannot read repo content or change the repo, piped or not.
|
|
79
|
+
const ALWAYS_INERT = new Set([
|
|
80
|
+
'echo', 'printf', 'true', 'false', ':', 'cd', 'pwd', 'date', 'whoami', 'which', 'sleep', 'test', '[',
|
|
81
|
+
]);
|
|
82
|
+
// Pagers/filters that read STDIN. Only when a pipe fed them — bare, they read the working tree.
|
|
83
|
+
const OUTPUT_FILTERS = new Set([
|
|
84
|
+
'head', 'tail', 'wc', 'cat', 'less', 'more', 'nl', 'tac', 'rev', 'sort', 'uniq', 'cut', 'tr',
|
|
85
|
+
'column', 'fold', 'expand', 'grep', 'egrep', 'fgrep', 'rg', 'sed', 'awk', 'jq', 'yq',
|
|
86
|
+
]);
|
|
87
|
+
const REDIRECT_TO_FILE = /^\d*>>?(?!&)/;
|
|
88
|
+
//# sourceMappingURL=shell-segment-scan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shell-segment-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/hook-runtime/src/core/rules/shell-segment-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\n\n/**\n * What ROLE one segment of a shell command plays — the question every allowlist-shaped bash guard has\n * to answer before it can judge a compound command.\n *\n * WHY this exists: `merged-branch-bash-guard` allowlists the commands that get you OFF a merged\n * branch, and the redirect it prints tells the agent to run them. An agent bounds tool output by\n * reflex, so it runs `git fetch origin main 2>&1 | tail -5` — and the guard, evaluating `tail -5` as\n * an ordinary command, denied the very remedy it had just printed. Verified pairs from the field:\n *\n * pnpm wp-cleanup allowed\n * pnpm wp-cleanup 2>&1 | tail -40 BLOCKED\n * git fetch origin main allowed\n * git fetch origin main 2>&1; echo BLOCKED\n *\n * Nothing in `| tail -40` or `; echo done` can touch the repo, and `done`/`do`/`for x in a b` are not\n * commands at all. So a segment is one of three things:\n *\n * - STRUCTURE — pure shell syntax, invokes nothing (`for b in a b c`, `done`, `fi`).\n * - SHAPING — cannot change the repo: a pager/filter fed by a PIPE (`| tail`, `| head`, `| wc`),\n * or an always-inert command (`echo`, `cd`, `pwd`, `true`).\n * - COMMAND — a real invocation, with `words` giving the effective argv AFTER leading shell\n * keywords are stripped, so `do gh pr list` classifies as `gh pr list`.\n *\n * Two things keep SHAPING honest. A filter counts only when a PIPE fed it — bare `tail src/x.ts`\n * reads the working tree and stays a COMMAND. And a segment carrying an output REDIRECT (`> file`,\n * `>> file`) is always a COMMAND, because `echo x > src/y.ts` writes the repo. `2>&1` is not a\n * redirect to a file and is deliberately not caught.\n *\n * Deciding WHETHER a shaping segment is acceptable is still the guard's call: merged-branch-bash-guard\n * pairs this with ContentReadScan so `git status | cat src/foo.ts` (a filter with a workspace path)\n * stays blocked.\n */\nexport type SegmentRole = 'structure' | 'shaping' | 'command';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class SegmentVerdict {\n role: SegmentRole;\n /** The effective argv for a COMMAND, leading shell keywords stripped. Empty for the other roles. */\n words: readonly string[];\n\n constructor(role: SegmentRole, words: readonly string[]) {\n this.role = role;\n this.words = words;\n }\n}\n\nexport class ShellSegmentScan {\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {}\n\n classify(segment: CommandSegment): SegmentVerdict {\n const words = this.stripKeywords(this.scanner.words(segment.text));\n if (words.length === 0) return STRUCTURE;\n\n const head = path.basename(words[0]);\n if (STRUCTURE_HEADS.has(head)) return STRUCTURE;\n\n // A redirect can create or overwrite a file, so it is never inert — judge it as a command.\n if (this.redirectsToFile(words)) return new SegmentVerdict('command', words);\n\n if (ALWAYS_INERT.has(head)) return new SegmentVerdict('shaping', words);\n if (segment.join === '|' && OUTPUT_FILTERS.has(head)) return new SegmentVerdict('shaping', words);\n\n return new SegmentVerdict('command', words);\n }\n\n /**\n * The effective argv of a segment with leading shell keywords removed — what a guard should judge\n * instead of the raw words. `for b in $(…); do git status; done` splits into three segments and\n * the middle one is literally `do git status`; without this, `do` is the command name and every\n * loop body walks straight past a git allowlist.\n */\n effectiveWords(segmentText: string): readonly string[] {\n return this.stripKeywords(this.scanner.words(segmentText));\n }\n\n private stripKeywords(words: readonly string[]): readonly string[] {\n let i = 0;\n while (i < words.length && STRIPPABLE_KEYWORDS.has(words[i])) i++;\n return words.slice(i);\n }\n\n /**\n * `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.\n *\n * PUBLIC because RecoveryAllowlist asks the same question of a segment it is about to allow on the\n * strength of the program name alone (`curl`, `gh`): those cannot touch the tree by themselves, but\n * `curl … > src/x.ts` can. One implementation, so the two callers cannot disagree about what\n * counts as a redirect — the `2>&1` carve-out above is exactly the kind of detail a second copy\n * gets wrong.\n */\n redirectsToFile(words: readonly string[]): boolean {\n return words.some((word: string): boolean => REDIRECT_TO_FILE.test(word));\n }\n}\n\nconst STRUCTURE = new SegmentVerdict('structure', []);\n\n// Keywords that PRECEDE a real command; strip them and judge what follows.\nconst STRIPPABLE_KEYWORDS: ReadonlySet<string> = new Set([\n 'do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '}', '(', ')',\n]);\n\n// Segments that invoke nothing at all: loop/case HEADERS (their tail is a word list, and any `$(…)`\n// inside was already split into its own segment by CommandScanner) and the closing keywords.\nconst STRUCTURE_HEADS: ReadonlySet<string> = new Set([\n 'for', 'case', 'select', 'done', 'fi', 'esac', ';;', 'in',\n]);\n\n// Commands that cannot read repo content or change the repo, piped or not.\nconst ALWAYS_INERT: ReadonlySet<string> = new Set([\n 'echo', 'printf', 'true', 'false', ':', 'cd', 'pwd', 'date', 'whoami', 'which', 'sleep', 'test', '[',\n]);\n\n// Pagers/filters that read STDIN. Only when a pipe fed them — bare, they read the working tree.\nconst OUTPUT_FILTERS: ReadonlySet<string> = new Set([\n 'head', 'tail', 'wc', 'cat', 'less', 'more', 'nl', 'tac', 'rev', 'sort', 'uniq', 'cut', 'tr',\n 'column', 'fold', 'expand', 'grep', 'egrep', 'fgrep', 'rg', 'sed', 'awk', 'jq', 'yq',\n]);\n\nconst REDIRECT_TO_FILE = /^\\d*>>?(?!&)/;\n"]}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export { READ_COMMANDS } from '@webpieces/rules-config';
|
|
2
|
+
/**
|
|
3
|
+
* The only `sed` script shape that is a read: a line range printed with `-n` — as a regex BODY, so the
|
|
4
|
+
* L0 allowlist's ERE twin can be built from the same characters instead of retyping them.
|
|
5
|
+
*
|
|
6
|
+
* EXPORTED for `bin/l0-allowlist.ts`. The L0 entry that lets a Codex session read its way out of an L0
|
|
7
|
+
* fault has to mean the same thing as this module or the two definitions of "read-shaped" drift, and a
|
|
8
|
+
* drifted L0 entry is either a hole or a deadlock. It cannot literally CALL this module — L0's sh half
|
|
9
|
+
* has no JS at all — so the shared thing is the vocabulary, and `codex-l0-read.spec.ts` asserts the two
|
|
10
|
+
* agree over a corpus.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SED_RANGE_BODY = "[0-9]+(,[0-9]+)?p";
|
|
13
|
+
export declare class ShellReadParity {
|
|
14
|
+
/**
|
|
15
|
+
* The absolute paths this command reads, or an empty list when it is not a read-shaped command.
|
|
16
|
+
* `root` is the tree the read must fall inside; a path outside it is not this repo's read.
|
|
17
|
+
*/
|
|
18
|
+
readTargets(command: string, cwd: string, root: string): readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* `sed`'s operands, or null when this `sed` invocation is not a plain range print. `-n` and a
|
|
21
|
+
* single `<range>p` script are BOTH required: without `-n` sed echoes and edits, and any other
|
|
22
|
+
* script is a transformation, not a read.
|
|
23
|
+
*/
|
|
24
|
+
private sedOperands;
|
|
25
|
+
private isFileInTree;
|
|
26
|
+
/**
|
|
27
|
+
* Split on whitespace, honouring single and double quotes. Returns null on an unterminated quote —
|
|
28
|
+
* a command we cannot read confidently is never a read.
|
|
29
|
+
*
|
|
30
|
+
* There is no escape or expansion handling here on purpose: `NOT_ONE_COMMAND` has already refused
|
|
31
|
+
* everything carrying shell syntax, so what reaches this is a literal argv.
|
|
32
|
+
*/
|
|
33
|
+
private tokenize;
|
|
34
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ShellReadParity = exports.SED_RANGE_BODY = exports.READ_COMMANDS = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
/**
|
|
8
|
+
* READ PARITY, and it is a CODEX-ONLY surface.
|
|
9
|
+
*
|
|
10
|
+
* Claude Code has a first-class `Read` tool, so the read-scoped guard (read-stale-guard) and the
|
|
11
|
+
* `calls/` audit trail see every file the agent opens. Codex has no such tool: a file read arrives as
|
|
12
|
+
* `Bash` running `sed -n '1,240p' <path>` (measured). Without this, every Codex read is invisible to
|
|
13
|
+
* the read guard and to the audit log, and the two harnesses cannot be compared at all.
|
|
14
|
+
*
|
|
15
|
+
* The caller gates this on `aiType === 'codex'`. Nothing here is reachable from a Claude Code payload,
|
|
16
|
+
* and a spec pins that.
|
|
17
|
+
*
|
|
18
|
+
* DELIBERATELY CONSERVATIVE, because a false positive here is not a missed read — it is a Read GUARD
|
|
19
|
+
* verdict applied to a command that was never a read, i.e. a new way to block work:
|
|
20
|
+
*
|
|
21
|
+
* - ONE command only. Any `;` `&&` `||` `|` backtick `$(` or redirect and the answer is "not a read";
|
|
22
|
+
* the command still runs the ordinary bash guards, exactly as today.
|
|
23
|
+
* - `argv[0]` must be one of the six pure pagers, or `sed -n '<range>p'`.
|
|
24
|
+
* - EVERY non-flag argument must resolve to a file that exists inside the tree. `cat /etc/passwd` is
|
|
25
|
+
* not a read of this repo, and neither is a heredoc.
|
|
26
|
+
*
|
|
27
|
+
* A match does NOT replace the bash guards — the caller runs BOTH, and either may deny.
|
|
28
|
+
*/
|
|
29
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
30
|
+
var rules_config_2 = require("@webpieces/rules-config");
|
|
31
|
+
Object.defineProperty(exports, "READ_COMMANDS", { enumerable: true, get: function () { return rules_config_2.READ_COMMANDS; } });
|
|
32
|
+
/** Shell syntax that makes a command more than one command, or redirects it. Any hit ⇒ not a read. */
|
|
33
|
+
const NOT_ONE_COMMAND = /[;|&`<>]|\$\(/;
|
|
34
|
+
/** Flags that consume the FOLLOWING token as their value, so it is never mistaken for a file. */
|
|
35
|
+
const VALUE_FLAGS = new Set(['-n', '-c', '-b', '-e', '-f']);
|
|
36
|
+
/**
|
|
37
|
+
* The only `sed` script shape that is a read: a line range printed with `-n` — as a regex BODY, so the
|
|
38
|
+
* L0 allowlist's ERE twin can be built from the same characters instead of retyping them.
|
|
39
|
+
*
|
|
40
|
+
* EXPORTED for `bin/l0-allowlist.ts`. The L0 entry that lets a Codex session read its way out of an L0
|
|
41
|
+
* fault has to mean the same thing as this module or the two definitions of "read-shaped" drift, and a
|
|
42
|
+
* drifted L0 entry is either a hole or a deadlock. It cannot literally CALL this module — L0's sh half
|
|
43
|
+
* has no JS at all — so the shared thing is the vocabulary, and `codex-l0-read.spec.ts` asserts the two
|
|
44
|
+
* agree over a corpus.
|
|
45
|
+
*/
|
|
46
|
+
exports.SED_RANGE_BODY = '[0-9]+(,[0-9]+)?p';
|
|
47
|
+
const SED_RANGE = new RegExp('^' + exports.SED_RANGE_BODY + '$');
|
|
48
|
+
class ShellReadParity {
|
|
49
|
+
/**
|
|
50
|
+
* The absolute paths this command reads, or an empty list when it is not a read-shaped command.
|
|
51
|
+
* `root` is the tree the read must fall inside; a path outside it is not this repo's read.
|
|
52
|
+
*/
|
|
53
|
+
readTargets(command, cwd, root) {
|
|
54
|
+
const trimmed = command.trim();
|
|
55
|
+
if (trimmed === '' || NOT_ONE_COMMAND.test(trimmed))
|
|
56
|
+
return [];
|
|
57
|
+
const tokens = this.tokenize(trimmed);
|
|
58
|
+
if (tokens === null || tokens.length < 2)
|
|
59
|
+
return [];
|
|
60
|
+
const argv0 = tokens[0];
|
|
61
|
+
const rest = argv0 === 'sed' ? this.sedOperands(tokens.slice(1)) : (rules_config_1.READ_COMMANDS.has(argv0) ? tokens.slice(1) : null);
|
|
62
|
+
if (rest === null)
|
|
63
|
+
return [];
|
|
64
|
+
const files = [];
|
|
65
|
+
let index = 0;
|
|
66
|
+
while (index < rest.length) {
|
|
67
|
+
const token = rest[index];
|
|
68
|
+
if (token.startsWith('-') && token !== '-') {
|
|
69
|
+
if (VALUE_FLAGS.has(token))
|
|
70
|
+
index += 1;
|
|
71
|
+
index += 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const resolved = path.resolve(cwd, token);
|
|
75
|
+
if (!this.isFileInTree(resolved, root))
|
|
76
|
+
return [];
|
|
77
|
+
files.push(resolved);
|
|
78
|
+
index += 1;
|
|
79
|
+
}
|
|
80
|
+
return files;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `sed`'s operands, or null when this `sed` invocation is not a plain range print. `-n` and a
|
|
84
|
+
* single `<range>p` script are BOTH required: without `-n` sed echoes and edits, and any other
|
|
85
|
+
* script is a transformation, not a read.
|
|
86
|
+
*/
|
|
87
|
+
sedOperands(args) {
|
|
88
|
+
if (!args.includes('-n'))
|
|
89
|
+
return null;
|
|
90
|
+
const scripts = args.filter((a) => !a.startsWith('-'));
|
|
91
|
+
if (scripts.length < 2)
|
|
92
|
+
return null;
|
|
93
|
+
if (!SED_RANGE.test(scripts[0]))
|
|
94
|
+
return null;
|
|
95
|
+
return scripts.slice(1);
|
|
96
|
+
}
|
|
97
|
+
isFileInTree(resolved, root) {
|
|
98
|
+
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
99
|
+
if (!resolved.startsWith(rootWithSep))
|
|
100
|
+
return false;
|
|
101
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
102
|
+
try {
|
|
103
|
+
return fs.statSync(resolved).isFile();
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
// Nothing is swallowed here: a token we cannot stat is simply NOT a read target, which is an
|
|
107
|
+
// ANSWER, and the command still runs the ordinary bash guards either way. The commented-out
|
|
108
|
+
// toError() call below is the form `catch-error-pattern` requires for a deliberately ignored
|
|
109
|
+
// error — it is the rule's spelling of "ignored on purpose", not a disabled line.
|
|
110
|
+
//const error = toError(err);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Split on whitespace, honouring single and double quotes. Returns null on an unterminated quote —
|
|
116
|
+
* a command we cannot read confidently is never a read.
|
|
117
|
+
*
|
|
118
|
+
* There is no escape or expansion handling here on purpose: `NOT_ONE_COMMAND` has already refused
|
|
119
|
+
* everything carrying shell syntax, so what reaches this is a literal argv.
|
|
120
|
+
*/
|
|
121
|
+
tokenize(command) {
|
|
122
|
+
const tokens = [];
|
|
123
|
+
let current = '';
|
|
124
|
+
let quote = '';
|
|
125
|
+
let started = false;
|
|
126
|
+
for (const ch of command) {
|
|
127
|
+
if (quote !== '') {
|
|
128
|
+
if (ch === quote)
|
|
129
|
+
quote = '';
|
|
130
|
+
else
|
|
131
|
+
current += ch;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (ch === '\'' || ch === '"') {
|
|
135
|
+
quote = ch;
|
|
136
|
+
started = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (ch === ' ' || ch === '\t' || ch === '\n') {
|
|
140
|
+
if (started)
|
|
141
|
+
tokens.push(current);
|
|
142
|
+
current = '';
|
|
143
|
+
started = false;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
current += ch;
|
|
147
|
+
started = true;
|
|
148
|
+
}
|
|
149
|
+
if (quote !== '')
|
|
150
|
+
return null;
|
|
151
|
+
if (started)
|
|
152
|
+
tokens.push(current);
|
|
153
|
+
return tokens;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
exports.ShellReadParity = ShellReadParity;
|
|
157
|
+
//# sourceMappingURL=shell-read-parity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shell-read-parity.js","sourceRoot":"","sources":["../../../../../../packages/tooling/hook-runtime/src/core/shell-read-parity.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * READ PARITY, and it is a CODEX-ONLY surface.\n *\n * Claude Code has a first-class `Read` tool, so the read-scoped guard (read-stale-guard) and the\n * `calls/` audit trail see every file the agent opens. Codex has no such tool: a file read arrives as\n * `Bash` running `sed -n '1,240p' <path>` (measured). Without this, every Codex read is invisible to\n * the read guard and to the audit log, and the two harnesses cannot be compared at all.\n *\n * The caller gates this on `aiType === 'codex'`. Nothing here is reachable from a Claude Code payload,\n * and a spec pins that.\n *\n * DELIBERATELY CONSERVATIVE, because a false positive here is not a missed read — it is a Read GUARD\n * verdict applied to a command that was never a read, i.e. a new way to block work:\n *\n * - ONE command only. Any `;` `&&` `||` `|` backtick `$(` or redirect and the answer is \"not a read\";\n * the command still runs the ordinary bash guards, exactly as today.\n * - `argv[0]` must be one of the six pure pagers, or `sed -n '<range>p'`.\n * - EVERY non-flag argument must resolve to a file that exists inside the tree. `cat /etc/passwd` is\n * not a read of this repo, and neither is a heredoc.\n *\n * A match does NOT replace the bash guards — the caller runs BOTH, and either may deny.\n */\nimport { READ_COMMANDS } from '@webpieces/rules-config';\nexport { READ_COMMANDS } from '@webpieces/rules-config';\n\n/** Shell syntax that makes a command more than one command, or redirects it. Any hit ⇒ not a read. */\nconst NOT_ONE_COMMAND = /[;|&`<>]|\\$\\(/;\n\n/** Flags that consume the FOLLOWING token as their value, so it is never mistaken for a file. */\nconst VALUE_FLAGS: ReadonlySet<string> = new Set(['-n', '-c', '-b', '-e', '-f']);\n\n/**\n * The only `sed` script shape that is a read: a line range printed with `-n` — as a regex BODY, so the\n * L0 allowlist's ERE twin can be built from the same characters instead of retyping them.\n *\n * EXPORTED for `bin/l0-allowlist.ts`. The L0 entry that lets a Codex session read its way out of an L0\n * fault has to mean the same thing as this module or the two definitions of \"read-shaped\" drift, and a\n * drifted L0 entry is either a hole or a deadlock. It cannot literally CALL this module — L0's sh half\n * has no JS at all — so the shared thing is the vocabulary, and `codex-l0-read.spec.ts` asserts the two\n * agree over a corpus.\n */\nexport const SED_RANGE_BODY = '[0-9]+(,[0-9]+)?p';\n\nconst SED_RANGE = new RegExp('^' + SED_RANGE_BODY + '$');\n\nexport class ShellReadParity {\n /**\n * The absolute paths this command reads, or an empty list when it is not a read-shaped command.\n * `root` is the tree the read must fall inside; a path outside it is not this repo's read.\n */\n readTargets(command: string, cwd: string, root: string): readonly string[] {\n const trimmed = command.trim();\n if (trimmed === '' || NOT_ONE_COMMAND.test(trimmed)) return [];\n const tokens = this.tokenize(trimmed);\n if (tokens === null || tokens.length < 2) return [];\n\n const argv0 = tokens[0];\n const rest = argv0 === 'sed' ? this.sedOperands(tokens.slice(1)) : (READ_COMMANDS.has(argv0) ? tokens.slice(1) : null);\n if (rest === null) return [];\n\n const files: string[] = [];\n let index = 0;\n while (index < rest.length) {\n const token = rest[index];\n if (token.startsWith('-') && token !== '-') {\n if (VALUE_FLAGS.has(token)) index += 1;\n index += 1;\n continue;\n }\n const resolved = path.resolve(cwd, token);\n if (!this.isFileInTree(resolved, root)) return [];\n files.push(resolved);\n index += 1;\n }\n return files;\n }\n\n /**\n * `sed`'s operands, or null when this `sed` invocation is not a plain range print. `-n` and a\n * single `<range>p` script are BOTH required: without `-n` sed echoes and edits, and any other\n * script is a transformation, not a read.\n */\n private sedOperands(args: readonly string[]): readonly string[] | null {\n if (!args.includes('-n')) return null;\n const scripts = args.filter((a: string): boolean => !a.startsWith('-'));\n if (scripts.length < 2) return null;\n if (!SED_RANGE.test(scripts[0])) return null;\n return scripts.slice(1);\n }\n\n private isFileInTree(resolved: string, root: string): boolean {\n const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;\n if (!resolved.startsWith(rootWithSep)) return false;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.statSync(resolved).isFile();\n } catch (err: unknown) {\n // Nothing is swallowed here: a token we cannot stat is simply NOT a read target, which is an\n // ANSWER, and the command still runs the ordinary bash guards either way. The commented-out\n // toError() call below is the form `catch-error-pattern` requires for a deliberately ignored\n // error — it is the rule's spelling of \"ignored on purpose\", not a disabled line.\n //const error = toError(err);\n return false;\n }\n }\n\n /**\n * Split on whitespace, honouring single and double quotes. Returns null on an unterminated quote —\n * a command we cannot read confidently is never a read.\n *\n * There is no escape or expansion handling here on purpose: `NOT_ONE_COMMAND` has already refused\n * everything carrying shell syntax, so what reaches this is a literal argv.\n */\n private tokenize(command: string): readonly string[] | null {\n const tokens: string[] = [];\n let current = '';\n let quote = '';\n let started = false;\n for (const ch of command) {\n if (quote !== '') {\n if (ch === quote) quote = '';\n else current += ch;\n continue;\n }\n if (ch === '\\'' || ch === '\"') { quote = ch; started = true; continue; }\n if (ch === ' ' || ch === '\\t' || ch === '\\n') {\n if (started) tokens.push(current);\n current = '';\n started = false;\n continue;\n }\n current += ch;\n started = true;\n }\n if (quote !== '') return null;\n if (started) tokens.push(current);\n return tokens;\n }\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stripTsNoise(source: string): string;
|