@mutmutco/kilo-plugin 4.2.2 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/kilo-plugin",
3
- "version": "4.2.2",
3
+ "version": "4.2.4",
4
4
  "mmiCompat": "4.x",
5
5
  "description": "MMI workflow skills and org gates delivery.",
6
6
  "author": {
@@ -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 outputs = [
388
- git(root, ['diff', '--name-only', `${base}...HEAD`]),
389
- git(root, ['diff', '--name-only', '--cached']),
390
- git(root, ['diff', '--name-only']),
391
- git(root, ['ls-files', '--others', '--exclude-standard']),
392
- ];
393
- return [...new Set(outputs.flatMap((output) => output.split(/\r?\n/).map((path) => path.trim()).filter(Boolean)))];
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: taskDiffPaths(root),
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 testCommandsAllowed = matched.length > 0 || overrideAuthorizes;
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] : [],