@mutmutco/kilo-plugin 3.85.0 → 3.87.0

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": "3.85.0",
3
+ "version": "3.87.0",
4
4
  "description": "MMI workflow skills and org gates delivery.",
5
5
  "author": {
6
6
  "name": "MMI Future",
@@ -135,7 +135,7 @@ function normalizeInput(surface, gate, input) {
135
135
  * and the deny was gone. So the line is elected by PARSING it, `hookSpecificOutput` carriers first, and
136
136
  * the result reports `malformed` when stdout exists but cannot be reduced to exactly one — which
137
137
  * runPolicyGate treats as a gate fault, i.e. the fail-closed fallback for vault-edit/command-ladder. */
138
- function decisionEnvelope(stdout) {
138
+ export function decisionEnvelope(stdout) {
139
139
  if (!stdout.trim()) return { envelope: '', stray: '', malformed: false };
140
140
  const lines = stdout.split(/\r?\n/);
141
141
  const objects = [];
@@ -493,21 +493,28 @@ export async function runHookGate({ input: buffered } = {}) {
493
493
  }
494
494
  const healed = Boolean(decision?.redactable) && canRewriteOutput;
495
495
  const outcome = !decision ? 'ran' : healed ? 'heal' : 'observe';
496
+ // #4015/#4021: every operator- and log-facing string names the tool the HOST fired, never
497
+ // `decision.toolName` — that one is the name hook-run.mjs rewrote into the Claude vocabulary so it can
498
+ // be tested against UPDATABLE_TOOLS, and it collapsed Codex `shell` and `local_shell` into "PowerShell".
499
+ // #4012 fixed this call's `tool` FIELD and #4015 the stderr arms; the `action` TEXT below was converted
500
+ // by neither, so one row read `{"tool":"shell","action":"secret detected in PowerShell output …"}` and
501
+ // disagreed with itself — in the log doctor, the Stop summary and scrooge.md's detection counts read.
502
+ const alarmTool = tracedTool(input) || decision?.toolName || 'tool';
496
503
  const action = !decision
497
504
  ? 'clean'
498
505
  : healed
499
506
  ? 'redacted secrets from tool output'
500
507
  : decision.redactable
501
- ? `secret detected in ${decision.toolName || 'tool'} output and NOT masked — this host cannot rewrite tool output (no updatedToolOutput channel on this surface)`
502
- : `secret detected in ${decision.toolName || 'tool'} output but PostToolUse cannot redact this tool (harness limitation)`;
508
+ ? `secret detected in ${alarmTool} output and NOT masked — this host cannot rewrite tool output (no updatedToolOutput channel on this surface)`
509
+ : `secret detected in ${alarmTool} output but PostToolUse cannot redact this tool (harness limitation)`;
503
510
  // Detection without masking must be audible, not just logged: on Codex the value is still on screen.
504
511
  if (decision?.redactable && !canRewriteOutput) {
505
- process.stderr.write(`[mmi-hook] secret-redact: a secret-shaped value was detected in ${decision.toolName || 'tool'} output and could NOT be masked on this host — treat the transcript as exposed.\n`);
512
+ process.stderr.write(`[mmi-hook] secret-redact: a secret-shaped value was detected in ${alarmTool} output and could NOT be masked on this host — treat the transcript as exposed.\n`);
506
513
  }
507
514
  // #3630: same audibility for the non-redactable-TOOL arm. A detection in Read/WebFetch/Agent/mcp__*
508
515
  // output landed only in the trace file — silent on the one surface where the value is still visible.
509
516
  if (decision && !decision.redactable) {
510
- process.stderr.write(`[mmi-hook] secret-redact: a secret-shaped value was detected in ${decision.toolName || 'tool'} output and cannot be masked for this tool (no updatedToolOutput channel) — treat the transcript as exposed.\n`);
517
+ process.stderr.write(`[mmi-hook] secret-redact: a secret-shaped value was detected in ${alarmTool} output and cannot be masked for this tool (no updatedToolOutput channel) — treat the transcript as exposed.\n`);
511
518
  }
512
519
  appendHookActivity({ event: 'PostToolUse', script: 'secret-redact', outcome, action, tool: tracedTool(input) });
513
520
  } catch (err) {
package/server.mjs CHANGED
@@ -24,6 +24,8 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync
24
24
  import { homedir } from 'node:os';
25
25
  import { dirname, join } from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
+ import { decisionEnvelope } from './scripts/hook-run.mjs';
28
+ import { hookGate } from './scripts/hook-policy.mjs';
27
29
 
28
30
  const HERE = dirname(fileURLToPath(import.meta.url));
29
31
  const PACKAGE_JSON = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8'));
@@ -124,10 +126,22 @@ export function runGate(gate, payload) {
124
126
  return { denied: false, reason: '', stdout: '', error: String(err && err.message) };
125
127
  }
126
128
  const stdout = typeof result.stdout === 'string' ? result.stdout : '';
127
- const denyLine = stdout.split(/\r?\n/).find((line) => line.trim().startsWith('{'));
128
- if (denyLine) {
129
+ // #4018: elect the envelope with the RUNNER's own helper, never "the first line beginning with `{`".
130
+ // That weaker rule is exactly what #4016 measured failing OPEN on vault-edit: a stray line that is
131
+ // itself JSON wins over the real deny, and a stray write with no trailing newline merges with the
132
+ // envelope so nothing matches at all. It was safe here only because hook-run.mjs happens to emit one
133
+ // envelope or nothing — an invariant held by a different file and unasserted at this boundary.
134
+ const { envelope, malformed } = decisionEnvelope(stdout);
135
+ if (malformed) {
136
+ // The runner treats an unreducible stdout as a gate fault, i.e. fail-closed on the gates declared
137
+ // that way. Kilo is the surface where `permissionDecision` is honoured, so it must not be the one
138
+ // host that answers "allow" to a stream it could not read.
139
+ if (hookGate(gate).failure === 'closed') {
140
+ return { denied: true, reason: `${gate} produced no readable decision — failing closed`, stdout };
141
+ }
142
+ } else if (envelope) {
129
143
  try {
130
- const decision = JSON.parse(denyLine).hookSpecificOutput;
144
+ const decision = JSON.parse(envelope).hookSpecificOutput;
131
145
  if (decision?.permissionDecision === 'deny') {
132
146
  return { denied: true, reason: decision.permissionDecisionReason || `${gate} denied the tool call`, stdout };
133
147
  }
@@ -166,10 +180,12 @@ function editToolInput(args) {
166
180
  /** The redactor's stdout is `{"hookSpecificOutput":{"updatedToolOutput": <redacted>}}` when it changed
167
181
  * something, and nothing when clean. Returns the rewrite, or undefined when there is none. */
168
182
  function readRedactorRewrite(stdout) {
169
- const line = String(stdout ?? '').split(/\r?\n/).find((l) => l.trim().startsWith('{'));
170
- if (!line) return undefined;
183
+ // #4018: same election rule as runGate. `secret-output` is fail-OPEN, so an unreducible stream means
184
+ // "no rewrite" — the original output stands and the redactor's own stderr alarm stays the signal.
185
+ const { envelope, malformed } = decisionEnvelope(String(stdout ?? ''));
186
+ if (malformed || !envelope) return undefined;
171
187
  try {
172
- const updated = JSON.parse(line).hookSpecificOutput?.updatedToolOutput;
188
+ const updated = JSON.parse(envelope).hookSpecificOutput?.updatedToolOutput;
173
189
  return typeof updated === 'string' || updated !== undefined ? updated : undefined;
174
190
  } catch {
175
191
  return undefined;
@@ -8,7 +8,7 @@
8
8
  { "target": ".github/ISSUE_TEMPLATE/config.yml", "source": "self", "ownership": "repo", "classes": ["deployable", "content"] },
9
9
  { "target": "scripts/next-version.mjs", "source": "self", "ownership": "org", "classes": ["deployable"] },
10
10
  { "target": ".github/workflows/gate.yml", "source": "seed:gate.template.yml", "ownership": "org", "classes": ["deployable"] },
11
- { "target": ".github/workflows/agent-pr.yml", "source": "self", "ownership": "org", "classes": ["deployable"] },
11
+ { "target": ".github/workflows/agent-pr.yml", "source": "self", "ownership": "org", "classes": ["deployable"], "waivers": { "jerv-jervcode": "runs a hardened variant the org copy does not carry — pull_request_target off the trusted base branch, an immutable BASE..HEAD compare with a file-count equality wall, a repo-scoped App token, and a certified-head merge that re-asserts base/head/state. Its own scripts/workflow-boundary-core.test.mjs pins those properties, so a seed refresh both weakens the repo and reds its gate. Widen the org seed to match before lifting this (#4033)." } },
12
12
  { "target": ".github/rulesets/mmi-product-required-checks.json", "source": "seed:mmi-product-required-checks.template.json", "ownership": "org", "classes": ["deployable"] },
13
13
  { "target": ".gitignore", "source": "managed-block", "ownership": "org", "classes": ["deployable", "content"] },
14
14
  { "target": "README.md", "source": "seed:README.template.md", "ownership": "repo", "classes": ["deployable", "content"] },