@mutmutco/hermes-plugin 4.2.0 → 4.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/hermes-plugin",
3
- "version": "4.2.0",
3
+ "version": "4.2.1",
4
4
  "description": "MMI canonical skills transported as a Hermes Agent native plugin.",
5
5
  "author": {
6
6
  "name": "MMI Future",
package/plugin.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 1,
3
3
  "name": "mmi",
4
- "version": "4.2.0",
4
+ "version": "4.2.1",
5
5
  "description": "MMI canonical workflow skills and fail-closed pre-tool policy gates.",
6
6
  "provides_hooks": [
7
7
  "pre_tool_call"
@@ -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 } from './test-command-policy-core.mjs';
14
+ import { evaluateTestCommandPolicy, isShallowRepository, 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';
@@ -369,7 +369,7 @@ function policyMandatoryEntries(root) {
369
369
  return parsed.mandatory;
370
370
  }
371
371
 
372
- function taskDiffPaths(root) {
372
+ function taskDiffBase(root) {
373
373
  const base = ['origin/development', 'origin/main'].find((ref) => {
374
374
  try {
375
375
  git(root, ['rev-parse', '--verify', `${ref}^{commit}`]);
@@ -379,6 +379,11 @@ function taskDiffPaths(root) {
379
379
  }
380
380
  });
381
381
  if (!base) throw new Error('neither origin/development nor origin/main resolves');
382
+ return base;
383
+ }
384
+
385
+ function taskDiffPaths(root) {
386
+ const base = taskDiffBase(root);
382
387
  const outputs = [
383
388
  git(root, ['diff', '--name-only', `${base}...HEAD`]),
384
389
  git(root, ['diff', '--name-only', '--cached']),
@@ -388,6 +393,27 @@ function taskDiffPaths(root) {
388
393
  return [...new Set(outputs.flatMap((output) => output.split(/\r?\n/).map((path) => path.trim()).filter(Boolean)))];
389
394
  }
390
395
 
396
+ /**
397
+ * The waiver the gate may honour for this diff's range, or null (#5804).
398
+ *
399
+ * `mmi-cli tests policy` honours a valid `Test-Policy-Override` trailer on the findings layer while
400
+ * this gate refused the same diff's focused test — one policy, two answers, and the agent that wrote
401
+ * the audited trailer to run ONE focused test was then blocked from running exactly that test. The
402
+ * reader is the SHARED one, so the gate cannot drift from the CLI's verdict on what a waiver is.
403
+ *
404
+ * Two fail-closed guards mirror what the CLI reports before it honours anything:
405
+ * - a SHALLOW clone's `base..HEAD` is unbounded (#3628) — nothing is honoured from a range the gate
406
+ * cannot trust;
407
+ * - refusals newer than the winning trailer (malformed shape, unknown scope) block the CLI's run,
408
+ * so they block here too — no honouring a waiver the reporting layer just refused.
409
+ */
410
+ function taskOverride(root, baseRef) {
411
+ if (isShallowRepository(root)) return null;
412
+ const base = git(root, ['merge-base', 'HEAD', baseRef]).toString().trim();
413
+ const { override, refusals } = readOverride(base, root);
414
+ return refusals.length === 0 ? override : null;
415
+ }
416
+
391
417
  function runTestCommandPolicy(input, { stdout = process.stdout } = {}) {
392
418
  if (!requestedTestCommand(input?.tool_input?.command)) return { denied: false };
393
419
  const root = repositoryRoot(input);
@@ -397,10 +423,14 @@ function runTestCommandPolicy(input, { stdout = process.stdout } = {}) {
397
423
  if (!root || (mandatory = policyMandatoryEntries(root)) === null) return { denied: false };
398
424
  // #5519: same evaluator `mmi-cli tests policy` attaches to its OK summary — matched globs and
399
425
  // command classes cannot disagree with the CLI on the same path set.
426
+ // #5804: and the same waiver, read from the same range this diff was computed against, feeds the
427
+ // evaluator — an honoured override for out-of-zone test work permits the matching focused test.
428
+ const base = taskDiffBase(root);
400
429
  const decision = evaluateTestCommandPolicy({
401
430
  paths: taskDiffPaths(root),
402
431
  mandatory,
403
432
  regulated: true,
433
+ override: taskOverride(root, base),
404
434
  });
405
435
  if (decision.testCommandsAllowed) return { denied: false, decision };
406
436
  } catch (error) {
@@ -1,4 +1,5 @@
1
- // test-command-policy-core.mjs — shared verdict for "may this diff run tests?" (#5519).
1
+ // test-command-policy-core.mjs — shared verdict for "may this diff run tests?" (#5519), and the
2
+ // ONE `Test-Policy-Override` reader both surfaces share (#5804).
2
3
  //
3
4
  // `mmi-cli tests policy` and the PreToolUse test-command gate both answer that question. Before
4
5
  // #5519 they answered it separately: the CLI summary printed the repository's CONFIGURED mandatory
@@ -7,12 +8,49 @@
7
8
  // hit TEST-POLICY TEST COMMAND REFUSED on the next line. Delegated workers and the parent hook also
8
9
  // diverged when they did not share an evaluator.
9
10
  //
10
- // This module is the one evaluator. It reports matched globs separately from configured totals and
11
- // the exact allowed/refused command classes. Pure: no IO, no git.
11
+ // #5804 reopened the same divergence along the waiver axis: the CLI honoured a valid override
12
+ // trailer on the findings layer and printed OK, while the gate — fed the same diff without the
13
+ // waiver — refused the matching focused test on the next line. So the reader lives HERE, next to
14
+ // the verdict it feeds, and both surfaces pass the SAME honoured waiver into `evaluateTestCommandPolicy`.
15
+ // The reader is the only git-IO in this module, and it is git's own trailer parser plus the exact
16
+ // guards #3628/#3637 established — the hook must never grow a second, looser trailer parser.
17
+
18
+ import { execFileSync } from 'node:child_process';
12
19
 
13
20
  /** Command class the PreToolUse gate regulates. Non-test verification is never refused here. */
14
21
  export const TEST_COMMAND_CLASS = 'test';
15
22
 
23
+ /** The commit trailer that carries a waiver. Live here so the reader and every message built from
24
+ * it spell the key identically. */
25
+ export const TRAILER_KEY = 'Test-Policy-Override';
26
+ /** NOT the authority on what is honoured — {@link readOverride} asks git for that. This is the
27
+ * DISAGREEMENT detector: a line shaped like the trailer that git's parser does not report is the
28
+ * defect worth surfacing, because it waives the gate while leaving the audit trail empty. */
29
+ const OVERRIDE_RE = /^Test-Policy-Override:\s*(.+)$/im;
30
+ /** Record and field separators for the one `git log` call that reads sha, trailer and body at once. */
31
+ const REC = '\u001e';
32
+ const FLD = '\u001f';
33
+
34
+ /** The kinds a `Test-Policy-Override` trailer may waive — the diff rules and nothing else. The
35
+ * three refusal kinds (unresolvable-base, untrusted-range, malformed-override-trailer) are
36
+ * deliberately absent: a waiver read out of a range the gate cannot trust would be the original
37
+ * defect wearing the fix's clothes. */
38
+ export const WAIVABLE_KINDS = [
39
+ 'mandatory-zone-untested',
40
+ 'unrequested-test-file',
41
+ 'protected-removed',
42
+ 'stale-protected-entry',
43
+ 'stale-satisfied-by',
44
+ ];
45
+
46
+ /** The one waivable kind whose finding is a VERDICT ON OUT-OF-ZONE TEST WORK ITSELF (#5804). Rule 1
47
+ * always co-occurs with a matched glob (test commands already allowed there), and the deletion and
48
+ * staleness kinds speak to policy hygiene, not to running tests — waiving them buys the deletion,
49
+ * never the command class. `unrequested-test-file` is different: its finding says exactly what the
50
+ * command gate refuses, "this test work was never asked for". A waiver over it IS the human's
51
+ * "this test is requested" decision, so it — and only it — authorizes the `test` class. */
52
+ const TEST_WORK_KIND = 'unrequested-test-file';
53
+
16
54
  /**
17
55
  * Glob body → regex body. Supports `**`, `*`, and `{a,b}` (including wildcards inside braces).
18
56
  * Kept byte-compatible with {@link globToRegExp} in cli/src/test-policy-core.ts so rule matching
@@ -66,13 +104,19 @@ export function matchedMandatoryGlobs(paths, mandatory) {
66
104
  }
67
105
 
68
106
  /**
69
- * Decide whether test commands are allowed against a path set and a mandatory zone.
107
+ * Decide whether test commands are allowed against a path set, a mandatory zone, and the waiver the
108
+ * caller has already read for this range (#5804).
70
109
  *
71
- * @param {{ paths: string[], mandatory?: unknown, regulated?: boolean }} input
110
+ * @param {{ paths: string[], mandatory?: unknown, regulated?: boolean,
111
+ * override?: { kinds?: string[] } | null }} input
72
112
  * `regulated: false` — no test-policy.json (estate default); tests are not gated.
73
113
  * `regulated: true` (default) — a declared policy applies; zero matched globs refuses `test`.
114
+ * `override` — the waiver honoured for this range, as {@link readOverride} returned it, or null.
115
+ * A waiver whose kinds cover {@link TEST_WORK_KIND} authorizes the `test` class even with zero
116
+ * matched globs: reporting and enforcement then state ONE decision, because both feed this
117
+ * function the same receipt.
74
118
  */
75
- export function evaluateTestCommandPolicy({ paths, mandatory, regulated = true } = {}) {
119
+ export function evaluateTestCommandPolicy({ paths, mandatory, regulated = true, override = null } = {}) {
76
120
  const configuredMandatoryCount = mandatoryGlobList(mandatory).length;
77
121
  if (!regulated) {
78
122
  return {
@@ -85,7 +129,8 @@ export function evaluateTestCommandPolicy({ paths, mandatory, regulated = true }
85
129
  };
86
130
  }
87
131
  const matched = matchedMandatoryGlobs(paths, mandatory);
88
- const testCommandsAllowed = matched.length > 0;
132
+ const overrideAuthorizes = Array.isArray(override?.kinds) && override.kinds.includes(TEST_WORK_KIND);
133
+ const testCommandsAllowed = matched.length > 0 || overrideAuthorizes;
89
134
  return {
90
135
  configuredMandatoryCount,
91
136
  matchedMandatoryGlobs: matched,
@@ -98,3 +143,111 @@ export function evaluateTestCommandPolicy({ paths, mandatory, regulated = true }
98
143
  },
99
144
  };
100
145
  }
146
+
147
+ function git(args, cwd) {
148
+ return execFileSync('git', args, { windowsHide: true, cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
149
+ }
150
+
151
+ /** Split an optional `[kind, kind]` scope off the front of a trailer value. */
152
+ function parseScope(value) {
153
+ const scoped = /^\[([^\]]*)\]\s*([\s\S]*)$/.exec(value);
154
+ if (!scoped) return { kinds: [...WAIVABLE_KINDS], reason: value, unknown: [] };
155
+ const named = scoped[1].split(',').map((k) => k.trim()).filter(Boolean);
156
+ return {
157
+ kinds: named,
158
+ reason: scoped[2].trim(),
159
+ unknown: named.filter((k) => !WAIVABLE_KINDS.includes(k)),
160
+ };
161
+ }
162
+
163
+ function invisibleTrailer(sha, line) {
164
+ return {
165
+ kind: 'malformed-override-trailer',
166
+ paths: [],
167
+ detail:
168
+ `OVERRIDE TRAILER GIT CANNOT SEE — commit ${sha.slice(0, 8)} carries\n`
169
+ + ` ${line}\n`
170
+ + ` but \`git log --format='%(trailers:key=${TRAILER_KEY})'\` reports nothing for it, so the waiver would\n`
171
+ + ' exist only to the regex that granted it. The trailer was chosen over a flag because it can be\n'
172
+ + ' AUDITED, and a reason the auditor cannot see is not a reason this gate accepts (#3628).\n'
173
+ + ' Git reads trailers from the LAST paragraph only, and every line of that paragraph must be a\n'
174
+ + ' trailer or an indented continuation — one bare line such as `Closes #123` disqualifies the whole\n'
175
+ + ' block. Indent the continuation lines, and leave nothing but trailers in that paragraph.\n'
176
+ + ' This is fixable forward: push a LATER commit on this branch carrying a well-formed trailer.\n'
177
+ + ' The nearest waiver wins, and it supersedes this one — no force-push, no re-filed branch.',
178
+ };
179
+ }
180
+
181
+ function unknownScope(sha, unknown) {
182
+ return {
183
+ kind: 'malformed-override-trailer',
184
+ paths: [],
185
+ detail:
186
+ `UNKNOWN OVERRIDE SCOPE — commit ${sha.slice(0, 8)} scopes its waiver to ${unknown.join(', ')}, which this\n`
187
+ + ' gate cannot report.\n'
188
+ + ` Waivable kinds: ${WAIVABLE_KINDS.join(', ')}.\n`
189
+ + ' Refused rather than widened: treating a typo as "waive everything" is how a scoped waiver turns\n'
190
+ + ' into a blanket exemption without anyone deciding it should.',
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Whether this clone's history is grafted. Anything other than a plain `false` — including a git that
196
+ * cannot answer — counts as shallow: the question being asked is "may I trust a commit range", and
197
+ * "I could not tell" is not a yes.
198
+ */
199
+ export function isShallowRepository(cwd) {
200
+ try {
201
+ return git(['rev-parse', '--is-shallow-repository'], cwd).trim() !== 'false';
202
+ } catch {
203
+ return true;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Read the waiver out of `base..HEAD` with GIT's own trailer parser, and refuse the disagreements.
209
+ *
210
+ * The regex is kept, but demoted to a cross-check: what it sees and what git sees must be the same
211
+ * set, and where they differ the difference IS the finding. On Jerv-PowerTools@e3d4b8ba the regex
212
+ * granted a waiver git reported no trailer for, and stored the first line of it — `"src/commands/
213
+ * grind.ts is outside test-policy.json's"` — as the justification, argument discarded.
214
+ *
215
+ * Moved here from cli/src/test-policy-core.ts (#5804) so the PreToolUse gate reads the SAME waiver
216
+ * the CLI reports — git's parser, the folded-whole value, the sha, the `[kind]` scoping, and the
217
+ * nearest-waiver-wins precedence — rather than a parallel line regex that would reintroduce the
218
+ * #3628 class on the execution side.
219
+ *
220
+ * @param {string} base merge-base of the change set; the waiver is read from `base..HEAD` only
221
+ * @param {string} cwd repository root the git commands run in
222
+ * @returns {{ override: { sha: string, reason: string, kinds: string[] } | null,
223
+ * refusals: Array<{ kind: string, detail: string, paths: string[] }> }}
224
+ * A non-empty `refusals` lists override-shaped problems from commits NEWER than the winning
225
+ * waiver; the caller must treat the run as refused, never honour `override` past them.
226
+ */
227
+ export function readOverride(base, cwd) {
228
+ const format = `%H${FLD}%(trailers:key=${TRAILER_KEY},valueonly,unfold)${FLD}%B${REC}`;
229
+ const refusals = [];
230
+ let override = null;
231
+ for (const record of git(['log', `${base}..HEAD`, `--format=${format}`], cwd).split(REC)) {
232
+ const [sha, trailer, body] = record.replace(/^\s+/, '').split(FLD);
233
+ if (!sha) continue;
234
+ // git log is newest-first, so the nearest waiver wins — as it always did — and once one is held
235
+ // every remaining record is OLDER than it and cannot change the answer. Stopping here is what
236
+ // gives a malformed trailer a forward-only remedy (#3637): the disagreement check below used to
237
+ // sit above this line, so an unparseable trailer refused the PR forever while the honoured waiver
238
+ // came from a commit after it. Rewriting that commit needs a force-push this estate does not do,
239
+ // which left re-filing the whole branch as the only exit. Precedence already applied to values;
240
+ // it now applies to the refusals read out of the same range.
241
+ if (override) break;
242
+ const value = (trailer ?? '').trim();
243
+ if (!value) {
244
+ const shaped = OVERRIDE_RE.exec(body ?? '');
245
+ if (shaped) refusals.push(invisibleTrailer(sha, shaped[0].trim()));
246
+ continue;
247
+ }
248
+ const { kinds, reason, unknown } = parseScope(value);
249
+ if (unknown.length > 0) refusals.push(unknownScope(sha, unknown));
250
+ else override = { sha, reason, kinds };
251
+ }
252
+ return { override, refusals };
253
+ }
@@ -300,6 +300,15 @@ mmi-cli oracle issue view <N> --repo <owner/repo> --comments # body + every co
300
300
  >
301
301
  > Never truncate at the last `}`; trailing prose can contain braces, and malformed/incomplete JSON must fail closed.
302
302
 
303
+ > **Never capture board JSON with a shell redirect on Windows** (#5802). PowerShell 5.1's `> file.json` is
304
+ > `Out-File`, which writes **UTF-16LE with a BOM**, so the file starts `FF FE` and the next step —
305
+ > `readFileSync(path, 'utf8')` + `JSON.parse` — dies at position 1. Pass `--out <path>` instead: the CLI
306
+ > writes the file itself as UTF-8 with no BOM, so the bytes never reach the shell.
307
+ >
308
+ > ```bash
309
+ > mmi-cli oracle board read --json --out .jerv/tmp/board.json # UTF-8, parses everywhere
310
+ > ```
311
+
303
312
  (Triggers only when a dev commits to an existing item — no-op for the *report a bug / request a feature /
304
313
  something else* paths.)
305
314
 
@@ -278,7 +278,8 @@ secret from a transport-error response.
278
278
  The train dispatches `actions-job-start-canary.yml` on MMI-Hub (Linux hosted, not `windows-latest`) and
279
279
  refuses to mint a tag if the canary job never starts (empty steps / billing refusal). If a tag is already
280
280
  on origin, do **not** recut: `mmi-cli devops release --retry-publish <run-id> --apply` retries that exact
281
- run.
281
+ failed or cancelled run. A failed run retries failed jobs; a cancelled run reruns the whole idempotent
282
+ publish workflow because GitHub does not classify cancelled jobs as failed (#5797).
282
283
 
283
284
  ## Step 0c — hotfix-coverage guard (fail closed, #839, #958)
284
285