@mutmutco/hermes-plugin 4.2.3 → 4.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/plugin.yaml
CHANGED
|
@@ -11,7 +11,7 @@ import { decide as decideCommandLadder, matchedVerb } from './command-ladder-gat
|
|
|
11
11
|
import { handleGateCrash, handleMissingHookInput, recordGateSuccess } from './deny-gate-crash.mjs';
|
|
12
12
|
import { readHookInput } from './hook-io.mjs';
|
|
13
13
|
import { appendHookActivity } from './hook-trace.mjs';
|
|
14
|
-
import { evaluateTestCommandPolicy, isShallowRepository, readOverride } from './test-command-policy-core.mjs';
|
|
14
|
+
import { evaluateTestCommandPolicy, isShallowRepository, isTestPath, readOverride } from './test-command-policy-core.mjs';
|
|
15
15
|
|
|
16
16
|
// Secret echoes are blocked before execution on every active host.
|
|
17
17
|
const SECRET_ECHO_MODE = process.env.MMI_SECRET_ECHO_LINT || 'block';
|
|
@@ -20,6 +20,7 @@ const WINDOWS_OPERATOR_GUARD_ON = !/^(?:0|false|no|off)$/i.test(process.env.MMI_
|
|
|
20
20
|
const GATE_NAME = 'command-ladder';
|
|
21
21
|
const OPERATOR_GATE_NAME = 'windows-operator-input';
|
|
22
22
|
const TEST_COMMAND_GATE_NAME = 'test-command-policy';
|
|
23
|
+
const NUL_REDIRECT_GATE_NAME = 'nul-redirect';
|
|
23
24
|
const MAX_SEGMENT_CHARS = 32_768;
|
|
24
25
|
|
|
25
26
|
// #3121 fast-path: trivially-safe read-only commands that need no gate evaluation.
|
|
@@ -382,15 +383,38 @@ function taskDiffBase(root) {
|
|
|
382
383
|
return base;
|
|
383
384
|
}
|
|
384
385
|
|
|
386
|
+
function lines(output) {
|
|
387
|
+
return output.split(/\r?\n/).map((path) => path.trim()).filter(Boolean);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The task diff's paths, and the subset of them this diff CREATED (#5842).
|
|
392
|
+
*
|
|
393
|
+
* Both come from the same four reads, so the added set can never describe a different diff than the
|
|
394
|
+
* paths it qualifies. Untracked files are added by definition; the tracked side asks git directly
|
|
395
|
+
* with `--diff-filter=AR` — a rename's new name did not exist at the base either, so it is created
|
|
396
|
+
* work for this purpose.
|
|
397
|
+
*/
|
|
385
398
|
function taskDiffPaths(root) {
|
|
386
399
|
const base = taskDiffBase(root);
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
git(root, ['diff', '--name-only',
|
|
390
|
-
git(root, ['diff', '--name-only']),
|
|
391
|
-
git(root, ['
|
|
392
|
-
|
|
393
|
-
|
|
400
|
+
const untracked = lines(git(root, ['ls-files', '--others', '--exclude-standard']));
|
|
401
|
+
const paths = [...new Set([
|
|
402
|
+
...lines(git(root, ['diff', '--name-only', `${base}...HEAD`])),
|
|
403
|
+
...lines(git(root, ['diff', '--name-only', '--cached'])),
|
|
404
|
+
...lines(git(root, ['diff', '--name-only'])),
|
|
405
|
+
...untracked,
|
|
406
|
+
])];
|
|
407
|
+
// The added set only ever changes the verdict when a test file is in the diff at all, and it costs
|
|
408
|
+
// three more git children. On the ordinary refusal path — a diff with no test in it — those reads
|
|
409
|
+
// would be pure latency in front of every shell command the agent runs.
|
|
410
|
+
if (!paths.some(isTestPath)) return { paths, addedPaths: untracked };
|
|
411
|
+
const addedPaths = [...new Set([
|
|
412
|
+
...lines(git(root, ['diff', '--name-only', '--diff-filter=AR', `${base}...HEAD`])),
|
|
413
|
+
...lines(git(root, ['diff', '--name-only', '--diff-filter=AR', '--cached'])),
|
|
414
|
+
...lines(git(root, ['diff', '--name-only', '--diff-filter=AR'])),
|
|
415
|
+
...untracked,
|
|
416
|
+
])];
|
|
417
|
+
return { paths, addedPaths };
|
|
394
418
|
}
|
|
395
419
|
|
|
396
420
|
/**
|
|
@@ -426,8 +450,10 @@ function runTestCommandPolicy(input, { stdout = process.stdout } = {}) {
|
|
|
426
450
|
// #5804: and the same waiver, read from the same range this diff was computed against, feeds the
|
|
427
451
|
// evaluator — an honoured override for out-of-zone test work permits the matching focused test.
|
|
428
452
|
const base = taskDiffBase(root);
|
|
453
|
+
const { paths, addedPaths } = taskDiffPaths(root);
|
|
429
454
|
const decision = evaluateTestCommandPolicy({
|
|
430
|
-
paths
|
|
455
|
+
paths,
|
|
456
|
+
addedPaths,
|
|
431
457
|
mandatory,
|
|
432
458
|
regulated: true,
|
|
433
459
|
override: taskOverride(root, base),
|
|
@@ -453,15 +479,61 @@ function runTestCommandPolicy(input, { stdout = process.stdout } = {}) {
|
|
|
453
479
|
// abort fact, mirroring the operator-input guard's "entire compound tool call was cancelled" wording.
|
|
454
480
|
const reason = 'TEST-POLICY TEST COMMAND REFUSED [test-command-outside-mandatory-zone]: '
|
|
455
481
|
+ 'the ENTIRE command was aborted before execution; no segment ran, so any edit or other non-test step batched into the same call was NOT applied — re-run those steps separately. '
|
|
456
|
-
+ `no path in ${root}'s task diff matches a mandatory glob in its test-policy.json. `
|
|
482
|
+
+ `no path in ${root}'s task diff matches a mandatory glob in its test-policy.json, and it edits no test file that already existed. `
|
|
457
483
|
+ 'Verify that is the repository you meant before quoting this: it is resolved from a leading `cd` or `Set-Location` with a literal path, then the host cwd. '
|
|
458
484
|
+ 'Do not run tests; use policy-approved non-test verification, '
|
|
459
|
-
+ 'or touch and run mandatory-zone coverage only when the diff actually requires it.'
|
|
485
|
+
+ 'or touch and run mandatory-zone coverage only when the diff actually requires it. '
|
|
486
|
+
+ 'A diff that EDITS a test file already in the tree may run tests (#5842); one that CREATES an out-of-zone test still needs an honoured `Test-Policy-Override` (#5804).';
|
|
460
487
|
appendHookActivity({ event: 'PreToolUse', script: TEST_COMMAND_GATE_NAME, outcome: 'deny', action: reason, reasonId: 'test-command-outside-mandatory-zone', tool: input?.tool_name });
|
|
461
488
|
stdout.write(preToolUseDeny(reason) + '\n');
|
|
462
489
|
return { denied: true };
|
|
463
490
|
}
|
|
464
491
|
|
|
492
|
+
// --- `>nul` under Git Bash creates a FILE (#5845) ---------------------------------------------
|
|
493
|
+
//
|
|
494
|
+
// `nul` is a reserved DEVICE name to Win32, so `>nul` from cmd or PowerShell discards output and
|
|
495
|
+
// leaves nothing behind. Bash does not honour that reservation on ANY platform — MSYS/Git Bash
|
|
496
|
+
// resolves the path itself, and on Linux `nul` was never special to begin with — so the identical
|
|
497
|
+
// redirect creates a real, empty, untracked file called `nul` in the working directory, usually the
|
|
498
|
+
// repository root, since that is where agents run. In bash `>nul` therefore never means "discard",
|
|
499
|
+
// which is why the deny needs no platform test: only the SHELL decides what the word means.
|
|
500
|
+
//
|
|
501
|
+
// It then breaks every recursive search over that tree: `rg` and `grep` ask Windows to open `nul`,
|
|
502
|
+
// Windows hands back the device, and the read fails with `Incorrect function. (os error 1)`. The
|
|
503
|
+
// error goes to stderr while the exit code can still be 0, so a sweep looks like it worked and is
|
|
504
|
+
// quietly missing whatever the walker abandoned.
|
|
505
|
+
//
|
|
506
|
+
// Measured on MMI-Katip: one 0-byte `nul` sat in the repo root from 2026-08-22 until 2026-08-29,
|
|
507
|
+
// erroring on every recursive search in between. An audit of that repo's own scripts and CI found no
|
|
508
|
+
// `>nul` at all — the file came from an ad-hoc agent command, which is exactly why the fix belongs
|
|
509
|
+
// in the gate every agent shell passes through rather than in any one repo.
|
|
510
|
+
//
|
|
511
|
+
// Gitignoring `nul` was considered and rejected: `rg` would then skip it silently, so the artifact
|
|
512
|
+
// would keep landing and the only signal that it had would be gone.
|
|
513
|
+
const NUL_REDIRECT_RE = /(?:^|\s)\d*>>?\s*(?:\.[\\/])?nul(?=$|\s)/i;
|
|
514
|
+
|
|
515
|
+
/** Bash-shaped tools only. `>nul` from PowerShell or cmd hits the device and creates nothing, so
|
|
516
|
+
* denying it there would refuse a correct command. */
|
|
517
|
+
function isBashShapedTool(toolName) {
|
|
518
|
+
const tool = String(toolName ?? '').trim();
|
|
519
|
+
return tool === 'Bash' || tool === 'bash';
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function runNulRedirectGuard(input, { stdout = process.stdout } = {}) {
|
|
523
|
+
if (!isBashShapedTool(input?.tool_name)) return { denied: false };
|
|
524
|
+
const offending = boundedShellSegments(input?.tool_input?.command)
|
|
525
|
+
.find((segment) => NUL_REDIRECT_RE.test(segment.text));
|
|
526
|
+
if (!offending) return { denied: false };
|
|
527
|
+
const reason = 'NUL REDIRECT REFUSED [bash-nul-redirect-creates-a-file]: '
|
|
528
|
+
+ 'the ENTIRE command was aborted before execution; no segment ran, so any edit or other step batched into the same call was NOT applied — re-run those steps separately. '
|
|
529
|
+
+ `segment ${offending.ordinal} redirects to \`nul\`, which bash treats as a PATH rather than the Windows null device — it would create an empty, untracked \`nul\` file here. On Windows every later recursive \`rg\`/\`grep\` over this tree then fails on it with "Incorrect function. (os error 1)", on stderr and often with exit 0, so the sweep looks fine while silently missing files. `
|
|
530
|
+
+ 'Write `2>/dev/null` (or `>/dev/null`) in a bash command; `2>$null` is the PowerShell form. '
|
|
531
|
+
+ 'To discard both streams, use `>/dev/null 2>&1`.';
|
|
532
|
+
appendHookActivity({ event: 'PreToolUse', script: NUL_REDIRECT_GATE_NAME, outcome: 'deny', action: reason, reasonId: 'bash-nul-redirect-creates-a-file', tool: input?.tool_name });
|
|
533
|
+
stdout.write(preToolUseDeny(reason) + '\n');
|
|
534
|
+
return { denied: true };
|
|
535
|
+
}
|
|
536
|
+
|
|
465
537
|
function isPowerShellShapedTool(toolName) {
|
|
466
538
|
const tool = String(toolName ?? '').trim();
|
|
467
539
|
return tool === 'PowerShell' || (process.platform === 'win32' && (tool === 'shell' || tool === 'local_shell'));
|
|
@@ -625,6 +697,8 @@ export async function runPreToolUseShellGates({ input: buffered, stdout = proces
|
|
|
625
697
|
if (echo.denied) return;
|
|
626
698
|
const operatorInput = runOperatorInputGuard(input, { stdout });
|
|
627
699
|
if (operatorInput.denied) return;
|
|
700
|
+
const nulRedirect = runNulRedirectGuard(input, { stdout });
|
|
701
|
+
if (nulRedirect.denied) return;
|
|
628
702
|
const testCommandPolicy = runTestCommandPolicy(input, { stdout });
|
|
629
703
|
if (testCommandPolicy.denied) return;
|
|
630
704
|
runCommandLadder(input, { stdout, stderr });
|
|
@@ -51,6 +51,40 @@ export const WAIVABLE_KINDS = [
|
|
|
51
51
|
* "this test is requested" decision, so it — and only it — authorizes the `test` class. */
|
|
52
52
|
const TEST_WORK_KIND = 'unrequested-test-file';
|
|
53
53
|
|
|
54
|
+
/** What counts as a test FILE. Lives here with the verdict that reads it, and is re-imported by
|
|
55
|
+
* cli/src/test-policy-core.ts, for the reason the module header gives: two copies of a predicate
|
|
56
|
+
* both surfaces answer with is how #5519 and #5804 each started. */
|
|
57
|
+
export const TEST_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
58
|
+
export const PY_TEST_FILE_RE = /(?:^|\/)test_[^/]*\.py$|_test\.py$/;
|
|
59
|
+
|
|
60
|
+
/** Is this path a test file by name? JS/TS and Python, because the guard regulates both estates. */
|
|
61
|
+
export function isTestPath(path) {
|
|
62
|
+
return typeof path === 'string' && (TEST_FILE_RE.test(path) || PY_TEST_FILE_RE.test(path));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Does this diff EDIT a test file that already existed at the base? (#5842)
|
|
66
|
+
*
|
|
67
|
+
* A zone outside the mandatory globs means no test is REQUIRED there. It never meant no test may be
|
|
68
|
+
* RUN. Those read the same to a guard that only looks at globs, and the difference is what the
|
|
69
|
+
* refusal cost: a repo whose policy deliberately leaves `scripts/**` unmandated still runs
|
|
70
|
+
* `scripts/*.test.mjs` in its REQUIRED gate, so a fix to one of those tests could go red in CI while
|
|
71
|
+
* every local attempt to run that exact file was denied — a three-minute round trip per attempt,
|
|
72
|
+
* with the file's own fixtures unverifiable any other way.
|
|
73
|
+
*
|
|
74
|
+
* ADDED test files are deliberately excluded, and that exclusion is the whole reason this is not
|
|
75
|
+
* simply "is a test file in the diff". An out-of-zone test the diff CREATES is precisely what
|
|
76
|
+
* `unrequested-test-file` refuses and what a `Test-Policy-Override` waiver exists to authorize
|
|
77
|
+
* (#5804). If writing one also bought permission to run it, the waiver would authorize nothing that
|
|
78
|
+
* was not already free, and "tests are opt-in" would be enforced only at PR time. Editing a test
|
|
79
|
+
* that is already in the tree asserts nothing about whether new test work was requested — the file
|
|
80
|
+
* is there, the required gate already runs it, and the only question left is whether the author may
|
|
81
|
+
* watch it pass. A rename counts as added: the new name did not exist at the base either. */
|
|
82
|
+
export function editsExistingTest(paths, addedPaths = []) {
|
|
83
|
+
const added = new Set(Array.isArray(addedPaths) ? addedPaths : []);
|
|
84
|
+
const list = Array.isArray(paths) ? paths : [];
|
|
85
|
+
return list.some((path) => isTestPath(path) && !added.has(path));
|
|
86
|
+
}
|
|
87
|
+
|
|
54
88
|
/**
|
|
55
89
|
* Glob body → regex body. Supports `**`, `*`, and `{a,b}` (including wildcards inside braces).
|
|
56
90
|
* Kept byte-compatible with {@link globToRegExp} in cli/src/test-policy-core.ts so rule matching
|
|
@@ -115,8 +149,10 @@ export function matchedMandatoryGlobs(paths, mandatory) {
|
|
|
115
149
|
* A waiver whose kinds cover {@link TEST_WORK_KIND} authorizes the `test` class even with zero
|
|
116
150
|
* matched globs: reporting and enforcement then state ONE decision, because both feed this
|
|
117
151
|
* function the same receipt.
|
|
152
|
+
* `addedPaths` — the subset of `paths` this diff CREATED. #5842: an edit to a test that already
|
|
153
|
+
* existed authorizes the class; creating one does not — see {@link editsExistingTest}.
|
|
118
154
|
*/
|
|
119
|
-
export function evaluateTestCommandPolicy({ paths, mandatory, regulated = true, override = null } = {}) {
|
|
155
|
+
export function evaluateTestCommandPolicy({ paths, mandatory, regulated = true, override = null, addedPaths = [] } = {}) {
|
|
120
156
|
const configuredMandatoryCount = mandatoryGlobList(mandatory).length;
|
|
121
157
|
if (!regulated) {
|
|
122
158
|
return {
|
|
@@ -124,18 +160,23 @@ export function evaluateTestCommandPolicy({ paths, mandatory, regulated = true,
|
|
|
124
160
|
matchedMandatoryGlobs: [],
|
|
125
161
|
matchedMandatoryCount: 0,
|
|
126
162
|
testCommandsAllowed: true,
|
|
163
|
+
editsExistingTest: false,
|
|
127
164
|
reasonId: null,
|
|
128
165
|
commandClasses: { allowed: [TEST_COMMAND_CLASS], refused: [] },
|
|
129
166
|
};
|
|
130
167
|
}
|
|
131
168
|
const matched = matchedMandatoryGlobs(paths, mandatory);
|
|
132
169
|
const overrideAuthorizes = Array.isArray(override?.kinds) && override.kinds.includes(TEST_WORK_KIND);
|
|
133
|
-
const
|
|
170
|
+
const existingTestEdited = editsExistingTest(paths, addedPaths);
|
|
171
|
+
const testCommandsAllowed = matched.length > 0 || overrideAuthorizes || existingTestEdited;
|
|
134
172
|
return {
|
|
135
173
|
configuredMandatoryCount,
|
|
136
174
|
matchedMandatoryGlobs: matched,
|
|
137
175
|
matchedMandatoryCount: matched.length,
|
|
138
176
|
testCommandsAllowed,
|
|
177
|
+
/** #5842: true when the diff edits a test file that already existed — one of the facts that can
|
|
178
|
+
* authorize the class, named so a refusal can say what was missing. */
|
|
179
|
+
editsExistingTest: existingTestEdited,
|
|
139
180
|
reasonId: testCommandsAllowed ? null : 'test-command-outside-mandatory-zone',
|
|
140
181
|
commandClasses: {
|
|
141
182
|
allowed: testCommandsAllowed ? [TEST_COMMAND_CLASS] : [],
|