@mcp-index/sdk 0.9.1 → 0.10.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/dist/src/gate.js CHANGED
@@ -6,12 +6,12 @@
6
6
  * per-server POSTURE. NO credential, NO transport, NO network: the Gate reads
7
7
  * only the PUBLIC contract dict the caller observed.
8
8
  */
9
- import { Decision, hashTool, makeVerdict, utcNowIso, } from "./preflight.js";
9
+ import { Decision, HoldClass, hashTool, makeVerdict, utcNowIso, } from "./preflight.js";
10
10
  import { ChangeKind, classifyChange, isSafetyRelevant } from "./schemaDiff.js";
11
11
  import { assess, blastRank } from "./risk.js";
12
12
  import { classifyToolDef } from "./actionClass.js";
13
13
  import { scanSchemaHasMarker } from "./scan.js";
14
- import { mdText } from "./render.js";
14
+ import { mdText, stripControl } from "./render.js";
15
15
  import { redactGreenWords } from "./provenance.js";
16
16
  import { Operation } from "./errors.js";
17
17
  import { driftTelemetry } from "./driftTelemetry.js";
@@ -57,7 +57,7 @@ const BREAKING_KIND_REASON = {
57
57
  [ChangeKind.ENUM_VALUES_REMOVED]: "an allowed value was removed — a value your calls send may now be rejected",
58
58
  [ChangeKind.CONSTRAINT_NARROWED]: "an input rule was tightened — a value your calls send may now be rejected",
59
59
  [ChangeKind.TOOL_REMOVED]: "this tool was removed from the server",
60
- [ChangeKind.DEEP_SCHEMA_UNDIFFABLE]: "this tool's schema changed too deeply to verify automatically; held to be safe",
60
+ [ChangeKind.DEEP_SCHEMA_UNDIFFABLE]: "this tool's schema changed too deeply to diff automatically; held rather than guessed",
61
61
  };
62
62
  const DANGEROUS_REASON_MARKERS = [
63
63
  "risk ESCALATED",
@@ -70,7 +70,24 @@ const DANGEROUS_REASON_MARKERS = [
70
70
  ];
71
71
  const BRAND_MARK = "⬡ mcpindex";
72
72
  export const HELD_PROVENANCE_UNAVAILABLE_BANNER = `${BRAND_MARK} — held a call before your agent ran it (provenance unavailable; no detail shown).`;
73
- export const INTERNAL_ERROR_HELD_MESSAGE = `${BRAND_MARK} internal error — held this call for safety. [Override]`;
73
+ /** The AGENT-facing twin of the banner above. Same fact, no brand glyph, no chrome.
74
+ *
75
+ * INSTANCE FOURTEEN, found in round 9 and present in both languages. `heldSurface` has two
76
+ * early returns, one line apart, that both put a constant in the AGENT-message position and
77
+ * bypass `renderHoldMessage` entirely. Round 7 fixed the internal-error one and left this
78
+ * sibling, which shipped the brand glyph to the agent channel - the exact tell the suite's
79
+ * first test bans, and one the 2026-08-05 model named when it read the hold as a UI trying to
80
+ * persuade it. That test only ever ran against the renderer.
81
+ *
82
+ * The banner keeps its glyph: a HUMAN still reads it via `holdBanner()`. One fact, two
83
+ * readers, two renderings - the same split this whole change is about. */
84
+ export const HELD_PROVENANCE_UNAVAILABLE_MESSAGE = 'mcpindex — local contract gate — held this call before your agent ran it. The record ' +
85
+ 'behind it could not be assembled, so no detail is shown.';
86
+ // NO AFFORDANCE, NO GREEN WORD - see the Python twin. `[Override]` points at
87
+ // `Action.OVERRIDE`, which bypasses the gate entirely: strictly stronger than the repin
88
+ // chips that were removed from this channel for exactly this reason.
89
+ export const INTERNAL_ERROR_HELD_MESSAGE = "mcpindex — local contract gate — mcpindex's own code failed, so this call was NOT sent " +
90
+ "to the server. Nothing ran. This is our failure, not a finding about the tool.";
74
91
  const INTERNAL_ERROR_REASON_HOLD = "mcpindex internal error — held for safety";
75
92
  export function isInternalErrorVerdict(verdict) {
76
93
  return verdict.reason === INTERNAL_ERROR_REASON_HOLD;
@@ -81,6 +98,7 @@ export function internalErrorHold(serverId, toolName) {
81
98
  toolName,
82
99
  decision: Decision.HOLD,
83
100
  reason: INTERNAL_ERROR_REASON_HOLD,
101
+ holdClass: HoldClass.INTERNAL, // our own code failed
84
102
  isContractDiff: false,
85
103
  });
86
104
  }
@@ -167,10 +185,332 @@ function bannerWhatChanged(verdict) {
167
185
  }
168
186
  return mdText(verdict.reason);
169
187
  }
170
- export function renderHoldBanner(verdict, monthlyCount = null) {
188
+ // ------------------------------------------------------------- agent-facing HOLD text
189
+ // Byte-for-byte parity with the Python side (`gate.render_hold_message`). Bounds exist
190
+ // because an attacker controls how many paths drift and what they are named: an unbounded
191
+ // render is a denial-of-legibility vector - the agent stops reading and falls back to
192
+ // "this looks like an attack".
193
+ const EVIDENCE_ROW_CAP = 6;
194
+ const SENT_KEY_CAP = 12;
195
+ // Row caps bound CARDINALITY; this bounds LENGTH. Truncate BEFORE escaping so a cut can
196
+ // never land mid-escape-sequence.
197
+ const VALUE_CHAR_CAP = 120;
198
+ /** Bound, escape, then redact one attacker-derived value. Order is load-bearing: redacting
199
+ * before escaping would turn the `[redacted]` placeholder into `\[redacted\]`. Per-value
200
+ * rather than over the joined message, so redaction can only touch attacker-derived spans -
201
+ * over the whole render it also hit our own prose and the pin path. */
202
+ function untrusted(value) {
203
+ // Collapse whitespace RUNS first - see the Python twin. The block is column-aligned, so
204
+ // preserved consecutive spaces let an attacker forge a second column.
205
+ // Explicit class, NOT `\s` - the two languages' `\s` sets differ. See the Python twin.
206
+ const flat = value.replace(/[ \t\n\r\f\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/gu, " ");
207
+ // Code POINTS, not UTF-16 units: `.slice` can split a surrogate pair and diverges from
208
+ // Python's codepoint slice, so the two SDKs would truncate at different offsets.
209
+ const cp = [...flat];
210
+ const bounded = cp.length > VALUE_CHAR_CAP ? `${cp.slice(0, VALUE_CHAR_CAP).join("")}...` : flat;
211
+ return redactGreenWords(mdText(bounded));
212
+ }
213
+ // The line that would have prevented the 2026-08-05 incident on its own. A mid-session
214
+ // drift is caught against the LIVE contract, but the model's tool list is whatever it last
215
+ // fetched, so the agent's own view legitimately disagrees with us. Left unsaid, the agent
216
+ // experiences that as us asserting something demonstrably false - indistinguishable from
217
+ // being lied to, and it correctly refused. Said plainly, the mismatch becomes corroborating.
218
+ const STALE_LIST_NOTE = "Your cached tool list may still show the pinned contract; the change was observed at " +
219
+ "the server after this session listed tools. That mismatch is expected and is what was " +
220
+ "caught.";
221
+ const HONESTY_NOTE = "This is a contract diff, not a safety verdict: no claim is made about intent.";
222
+ // CONSTRAIN, NEVER EXPAND. This message travels on the wrapped server's own error channel -
223
+ // from the agent's seat it is attacker-controlled text, so it may only narrow what the agent
224
+ // does. It must never invite an action that CHANGES TRUST STATE: `[Review · Re-pin ·
225
+ // Validate]` pointed at `repin()`, which makes a changed contract the new trusted baseline -
226
+ // i.e. the gate was emitting, on an untrusted channel, precisely the text an attacker would
227
+ // forge to get a hostile contract blessed. A forged "stop and ask your human" earns an
228
+ // attacker nothing, which is what makes this direction safe to say here.
229
+ const DISPOSITION_NOTE = "Retrying will not clear this, and supplying the changed parameter does not either — " +
230
+ "re-pinning is a trust decision for the person running this agent.";
231
+ // INCONCLUSIVE resolution stated as a FACT, never as an offer: the behavioral check can move
232
+ // a verdict, so inviting the agent to run it would be an action that changes trust state.
233
+ // The RESULT-WITHHELD hold fires AFTER the call executed, and only for high-blast tools.
234
+ // The contract-diff prose is false there - most dangerously the headline.
235
+ const RESULT_HEADLINE = "mcpindex — local contract gate — this call RAN and its side effect, if any, already " +
236
+ "happened; only its RESULT was withheld. Do not retry.";
237
+ const RESULT_HONESTY_NOTE = "This is a result-content finding, not a safety verdict and not a contract diff: the " +
238
+ "side effect, if any, has already happened.";
239
+ const RESULT_DISPOSITION_NOTE = "Retrying would run the call again. Whether to read the withheld content is a decision " +
240
+ "for the person running this agent.";
241
+ // `isContractDiff === false` covers TWO shapes with OPPOSITE execution semantics: a
242
+ // result-withhold hold RAN, an internal-error hold did NOT - our own code failed before the
243
+ // call left the process. See the Python twin.
244
+ const INTERNAL_HEADLINE = "mcpindex — local contract gate — mcpindex's own code failed, so this call was NOT sent " +
245
+ "to the server. Nothing ran.";
246
+ const INTERNAL_NOTE = "This is our failure, not a finding about the tool: no contract change was observed and " +
247
+ "no claim is made about this tool. The gate fails closed, so the call was held rather " +
248
+ "than let through unchecked.";
249
+ const DRILL_DISPOSITION_NOTE = "Nothing about this tool changed. The rehearsal is over - it is one-shot and has already " +
250
+ "disarmed itself - so the next call to this tool proceeds normally.";
251
+ // The un-checkable pre-flight shapes: no pin, tool absent from the listing, derivation error.
252
+ // Nothing was COMPARED, and unlike a real drift the remedy is often just to list and retry.
253
+ // A comparison RAN; something other than a diff fired. Distinct from "uncheckable" (nothing
254
+ // was compared) and from "drift" (a difference was found).
255
+ const FINDING_NOTE = "The contract was checked and something other than a difference stopped this call - the " +
256
+ "reason above says what. This is a finding about the tool's definition, not a claim " +
257
+ "about intent, and not a safety verdict.";
258
+ const FINDING_DISPOSITION_NOTE = "Retrying will not clear this on its own. Whether to proceed is a decision for the " +
259
+ "person running this agent.";
260
+ // The contract is FINE - this tool is held on what the call can DO, not on what it declares.
261
+ const APPROVAL_NOTE = "Nothing about this tool's contract changed - it matches the pinned baseline. It is held " +
262
+ "because of what the call can do, not what it declares.";
263
+ const APPROVAL_DISPOSITION_NOTE = "This class of action is never run unattended. It needs a person, and no retry or " +
264
+ "argument change reaches one.";
265
+ const UNCHECKABLE_NOTE = "No contract comparison was possible here - the reason above says why. The gate fails " +
266
+ "closed rather than let an unverified call through.";
267
+ // NEVER NAME THE UNBLOCKING ACTION - see the Python twin. `observe()` TOFU-writes the pin
268
+ // store, so naming it here is an instruction for the one action that mints a trust baseline.
269
+ // "No baseline exists for this tool in this session" was asserted here as flat fact and is
270
+ // FALSE on six of the seven situations that reach this class - a pinned tool absent from the
271
+ // live listing, a hash-derivation error, a declined battery. Attacker-triggerable on one: a
272
+ // hostile server drops a PINNED tool from tools/list and the agent is told the tool was never
273
+ // pinned rather than that its pinned tool vanished, nine lines above an anchor naming the pin
274
+ // file. Fixed in the Python twin in round 7 and left live here for a release - which is why
275
+ // `holdMessage.test.ts` now pins these constants across both languages.
276
+ const UNCHECKABLE_DISPOSITION_NOTE = "Whether to establish or restore a baseline for this tool is a trust decision for the " +
277
+ "person running this agent.";
278
+ const INTERNAL_DISPOSITION_NOTE = "Nothing has happened yet, so a retry repeats nothing - but it will likely fail the " +
279
+ "same way until the gate is fixed or removed.";
280
+ const INCONCLUSIVE_NOTE = "Resolving this needs behavioral verification, which is a human-initiated action.";
281
+ // A staged drill MUST be distinguishable from a real hold on the agent channel, not just in
282
+ // `data.drill`. An agent that reads a rehearsal as a live security event escalates a false
283
+ // alarm to its user - which is how you train someone to ignore the real thing.
284
+ const DRILL_HEADLINE = "[DRILL] mcpindex — local contract gate — this is a STAGED REHEARSAL, not a real " +
285
+ "contract change. The call was not forwarded.";
286
+ const DRILL_NOTE = "The change above is synthetic - nothing about this tool actually changed, and no " +
287
+ "parameter needs supplying.";
288
+ function evidenceRows(verdict, sentKeys, drill = false) {
289
+ const rows = [["tool", untrusted(verdict.toolName)]];
290
+ // Sort before capping so the same drift always renders the same rows - non-determinism
291
+ // here would break cross-language parity with the Python renderer.
292
+ const changes = [...verdict.drift].sort((a, b) => {
293
+ if (a.safetyRelevant !== b.safetyRelevant)
294
+ return a.safetyRelevant ? -1 : 1;
295
+ // Codepoint compare, NOT localeCompare: the latter is locale/ICU-dependent and diverges
296
+ // from Python's `sorted()`, so with a 6-row cap the two SDKs could display different
297
+ // evidence for identical drift.
298
+ const ak = a.kind.toString();
299
+ const bk = b.kind.toString();
300
+ if (ak !== bk)
301
+ return ak < bk ? -1 : 1;
302
+ const ap = a.path ?? "";
303
+ const bp = b.path ?? "";
304
+ return ap < bp ? -1 : ap > bp ? 1 : 0;
305
+ });
306
+ for (const change of changes.slice(0, EVIDENCE_ROW_CAP)) {
307
+ // `kind` is a ChangeKind member - closed vocabulary. `path` IS attacker-derived and is
308
+ // escaped, exactly as before: the defence is unchanged, only its location moved.
309
+ const where = change.path ? ` at ${untrusted(change.path)}` : "";
310
+ // On a drill the change is INVENTED - see the Python twin.
311
+ rows.push([drill ? "staged change" : "change", `${change.kind.toString()}${where}`]);
312
+ }
313
+ if (changes.length > EVIDENCE_ROW_CAP) {
314
+ rows.push([drill ? "staged change" : "change", `... and ${changes.length - EVIDENCE_ROW_CAP} more`]);
315
+ }
316
+ // `reason` on a non-contract-diff hold is OURS, not the server's: truncating it deletes
317
+ // the corrective clause that distinguishes a result hold from a drift.
318
+ // See the Python twin: on the internal-error shape the headline states the whole case and
319
+ // the sentinel carries a green word, so the row is dropped rather than rendered.
320
+ if (changes.length === 0 && verdict.holdClass !== HoldClass.INTERNAL) {
321
+ rows.push([
322
+ "reason",
323
+ untrusted(verdict.reason),
324
+ ]);
325
+ }
326
+ // Plain-language CONSEQUENCE of the most-severe breaking kind, from the closed curated
327
+ // `BREAKING_KIND_REASON` vocabulary (never attacker-derived, so no escaping needed). An
328
+ // agent reasoning about how to report a hold needs the effect, not just the label.
329
+ // No `effect` row on a drill: the consequence it describes will never happen.
330
+ const friendly = drill ? null : friendlyBreakingClause(verdict);
331
+ if (friendly !== null)
332
+ rows.push(["effect", friendly]);
333
+ if (sentKeys !== null) {
334
+ // NAMES ONLY, NEVER VALUES. Values can carry credentials and PII; names are
335
+ // contract-level data already in the pinned schema and already held by the agent.
336
+ const shown = [...sentKeys].sort().slice(0, SENT_KEY_CAP);
337
+ const more = sentKeys.length - shown.length;
338
+ let rendered = shown.length > 0 ? shown.map(untrusted).join(", ") : "(none)";
339
+ if (more > 0)
340
+ rendered += `, ... and ${more} more`;
341
+ rows.push(["call sent", rendered]);
342
+ }
343
+ return rows;
344
+ }
345
+ /**
346
+ * The AGENT-facing HOLD text: evidence over assertion, constrain never expand.
347
+ *
348
+ * `renderHoldBanner` is the HUMAN brand moment and is unchanged - it still renders for
349
+ * hosts and operators. But it was ALSO the thrown message, which is read by a MODEL.
350
+ * Observed 2026-08-05: Claude Opus 4.8 classified that banner as a prompt-injection
351
+ * attempt, refused to act on it, retried the held call, and told the user something
352
+ * suspicious was intercepting its tools.
353
+ *
354
+ * Its load-bearing objection was not tone - it was that it could not VERIFY us: the gate
355
+ * diffs the pin against the LIVE contract, while the agent sees only its own cached tool
356
+ * list, a third view that legitimately disagrees. Asserting a change the reader cannot
357
+ * observe, then inviting it to act, is the exact shape of a manipulation attempt.
358
+ *
359
+ * Every defence is unchanged: `mdText` on each attacker-derived value, `redactGreenWords`
360
+ * over the whole output, fail-closed behaviour untouched.
361
+ */
362
+ export function renderHoldMessage(verdict, sentKeys = null, pinPath = null, drill = false) {
363
+ // SPLIT FROM `assembleHoldMessage` for the reason the Python twin documents: when
364
+ // assembly and redaction were one function, `redactAgentUnsafe` stripped a banned token
365
+ // from OUR OWN prose before any test could observe it, so a mutation restoring instance
366
+ // twelve verbatim into a producer reason left the suite green. Redaction defends against
367
+ // the attacker; our prose has to be clean before it runs, and the suite asserts that
368
+ // against the RAW assembly.
369
+ const text = redactAgentUnsafe(assembleHoldMessage(verdict, sentKeys, pinPath, drill));
370
+ if (agentChannelViolation(verdict, text) !== null)
371
+ return MINIMAL_HOLD_MESSAGE;
372
+ return text;
373
+ }
374
+ /** The minimal HOLD text: claims nothing beyond "this did not complete normally". The
375
+ * fail-closed fallback for a whole-message invariant no span redaction can repair. */
376
+ export const MINIMAL_HOLD_MESSAGE = "mcpindex — local contract gate — this call did not complete normally. " +
377
+ "The structured error data carries the detail.";
378
+ // Wire-level names an agent can EMIT. Deliberately NARROWER than "any word about trust":
379
+ // the discriminator is whether a reader can ACT on the string. `list_tools` is a method the
380
+ // agent can send on its next turn, and `Gate.observe()` TOFU-writes the pin store when it
381
+ // does — so a HOLD carrying that word mints the very baseline the HOLD exists to withhold.
382
+ const AGENT_UNSAFE_TOKENS = ["list_tools", "listtools", "tools/list"];
383
+ // Match through `mdText` escaping: `list_tools` also matches `list\_tools`. A plain
384
+ // substring scan is precisely why round 7's test passed while the string was LIVE.
385
+ const AGENT_UNSAFE_RE = new RegExp(AGENT_UNSAFE_TOKENS.map((t) => [...t].map((ch) => `\\\\?${ch.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}`).join("")).join("|"), "gi");
386
+ // Bracketed affordance chips — `[Review · Re-pin · Validate]`, `[Override]`. UNESCAPED
387
+ // brackets only: our curated prose is never routed through `mdText` so OUR chips are
388
+ // literal `[`, while attacker values always are and arrive as `\[` — already neutralised,
389
+ // and worth SHOWING as evidence of an attempted forgery. The allowlist is our own state
390
+ // labels, which name nothing for the reader to do.
391
+ const AGENT_SAFE_BRACKETS = ["redacted", "DRILL"];
392
+ const CHIP_RE = new RegExp(`(?<!\\\\)\\[(?!(?:${AGENT_SAFE_BRACKETS.join("|")})\\])[^\\]\\n]{1,60}(?<!\\\\)\\]`, "g");
393
+ /** The single boundary every agent-facing HOLD string crosses. REDACTS, never suppresses.
394
+ *
395
+ * Span-level, because the first cut returned a minimal message on any violation — which
396
+ * handed a hostile server a SUPPRESSION PRIMITIVE: name a tool `[Override]` and the whole
397
+ * detailed HOLD collapses to a sentence that says nothing. This module refuses that same
398
+ * trade for the pin anchor ("drop only the poisoned span, never the whole line").
399
+ * Python twin: `cse.gate.redact_agent_unsafe`. */
400
+ export function redactAgentUnsafe(text) {
401
+ return text.replace(AGENT_UNSAFE_RE, "[redacted]").replace(CHIP_RE, "[redacted]");
402
+ }
403
+ /** WHOLE-MESSAGE invariants no span redaction can repair. Returns a reason, or null.
404
+ * Python twin: `cse.gate.agent_channel_violation`. */
405
+ export function agentChannelViolation(verdict, text) {
406
+ const low = text.toLowerCase();
407
+ // The execution claim decides whether retrying is safe, so it must match the producer's
408
+ // fact in BOTH directions: a phantom side effect blocks a legitimate retry, and a missing
409
+ // real one invites a double-send.
410
+ const ran = low.includes("already ran") || low.includes("this call ran");
411
+ if (verdict.alreadyExecuted === true && !ran)
412
+ return "verdict says the call already ran; the message does not say so";
413
+ if (verdict.alreadyExecuted !== true && ran)
414
+ return "verdict says the call did not run; the message claims it did";
415
+ return null;
416
+ }
417
+ function assertNever(x) {
418
+ throw new Error(`unhandled hold class: ${String(x)}`);
419
+ }
420
+ /** Raw assembly, before the channel boundary. Exported for the suite, which must see our
421
+ * prose as written — handed the redacted output, a no-op assertion is vacuous. */
422
+ export function assembleHoldMessage(verdict, sentKeys = null, pinPath = null, drill = false) {
423
+ // ONE dispatch, on the ONE fact the producer set — the port of the Python rewrite.
424
+ //
425
+ // This was a chain of ternaries and `else if`s over `isContractDiff`, `drift.length`,
426
+ // `isInternalErrorVerdict()` and two holdClass comparisons: a PRIORITY ORDER, not a set of
427
+ // mutually exclusive facts. It disagreed with the producer on three of six classes and the
428
+ // chain won — including a SELF-CONTRADICTION that a hostile server can trigger by dropping
429
+ // a pinned tool from tools/list (holdClass=uncheckable WITH drift rows), which rendered
430
+ // "the change was observed at the server" and "no contract comparison was possible" nine
431
+ // lines apart in one message. `assertNever` makes an unhandled class a compile error.
432
+ const holdClass = verdict.holdClass ?? HoldClass.UNSET;
433
+ const headline = drill
434
+ ? DRILL_HEADLINE
435
+ : holdClass === HoldClass.INTERNAL
436
+ ? INTERNAL_HEADLINE
437
+ : holdClass === HoldClass.WITHHELD
438
+ ? RESULT_HEADLINE
439
+ : "mcpindex — local contract gate — did not forward this call to the server.";
440
+ const lines = [headline, ""];
441
+ const rows = evidenceRows(verdict, sentKeys, drill);
442
+ const width = Math.max(...rows.map(([label]) => label.length));
443
+ for (const [label, value] of rows)
444
+ lines.push(` ${label.padEnd(width)} ${value}`);
445
+ if (drill) {
446
+ // Orthogonal to the class: a drill is a STAGED drift, so every claim below would
447
+ // describe a change that was never made.
448
+ lines.push("", DRILL_NOTE, "", DRILL_DISPOSITION_NOTE);
449
+ }
450
+ else {
451
+ switch (holdClass) {
452
+ case HoldClass.DRIFT:
453
+ // STALE_LIST_NOTE belongs HERE and only here — it asserts "the change was observed
454
+ // at the server", true on exactly this class. Keyed off `drift.length` instead, it
455
+ // rode along on uncheckable and finding holds.
456
+ lines.push("", STALE_LIST_NOTE, "", HONESTY_NOTE, "", DISPOSITION_NOTE);
457
+ break;
458
+ case HoldClass.WITHHELD:
459
+ lines.push("", RESULT_HONESTY_NOTE, "", RESULT_DISPOSITION_NOTE);
460
+ break;
461
+ case HoldClass.INTERNAL:
462
+ lines.push("", INTERNAL_NOTE, "", INTERNAL_DISPOSITION_NOTE);
463
+ break;
464
+ case HoldClass.APPROVAL:
465
+ lines.push("", APPROVAL_NOTE, "", APPROVAL_DISPOSITION_NOTE);
466
+ break;
467
+ case HoldClass.UNCHECKABLE:
468
+ lines.push("", UNCHECKABLE_NOTE, "", UNCHECKABLE_DISPOSITION_NOTE);
469
+ break;
470
+ case HoldClass.FINDING:
471
+ // A comparison RAN and something other than a diff fired.
472
+ lines.push("", FINDING_NOTE, "", FINDING_DISPOSITION_NOTE);
473
+ break;
474
+ case HoldClass.UNSET:
475
+ // A producer forgot. That is a bug in OUR code; say only what is true of every hold
476
+ // rather than guess a class — guessing is what the old "drift" default did.
477
+ return MINIMAL_HOLD_MESSAGE;
478
+ default:
479
+ return assertNever(holdClass);
480
+ }
481
+ }
482
+ if (verdict.decision === Decision.INCONCLUSIVE)
483
+ lines.push("", INCONCLUSIVE_NOTE);
484
+ if (pinPath) {
485
+ // The anti-forgery anchor: a remote server can emit any string, but it cannot put a
486
+ // file on the caller's disk or a binary on their PATH. `pinPath` is OURS.
487
+ // Port of the Python hardening: neutralise, collapse $HOME, and drop only the poisoned
488
+ // span if the path itself would carry banned reassurance - never the whole line, which
489
+ // would hand an attacker a way to suppress the anchor by naming their server.
490
+ // `stripControl` mirrors Python `_strip_unsafe`, which does NOT fold newlines - fold
491
+ // them here so a newline in `pinPath` cannot inject a forged evidence row. Then collapse
492
+ // $HOME, which the previous comment claimed and the code did not do: the absolute form
493
+ // leaks the OS username AND names the exact file a hostile server would poison.
494
+ const flat = stripControl(pinPath).replace(/[\r\n]+/g, " ");
495
+ const home = typeof process !== "undefined" ? (process.env.HOME ?? "") : "";
496
+ const safePin = home && flat.startsWith(`${home}/`) ? `~${flat.slice(home.length)}` : flat;
497
+ const anchor = redactGreenWords(safePin) === safePin
498
+ ? `the pin is a file at ${safePin}`
499
+ : "the pin is a file under ~/.mcpindex/pins/";
500
+ lines.push("", `Checkable locally: ${anchor}, and \`mcpindex-report\` prints the record. ` +
501
+ "A remote server can write neither.");
502
+ }
503
+ // No whole-output redaction: every attacker-derived span already went through `untrusted`.
504
+ // Re-running it over the join would corrupt our own curated prose while adding no defence.
505
+ return lines.join("\n");
506
+ }
507
+ export function renderHoldBanner(verdict, monthlyCount = null, chips = true) {
171
508
  const what = bannerWhatChanged(verdict);
172
509
  let line = `${BRAND_MARK} — caught a silent change: ${what}. Held before your agent ran it.`;
173
- line += " [Review · Re-pin · Validate]";
510
+ // See the Python twin: suppressed at the source for `data.presentation`, never by
511
+ // splitting the rendered string.
512
+ if (chips)
513
+ line += " [Review · Re-pin · Validate]";
174
514
  if (monthlyCount !== null && monthlyCount > 0) {
175
515
  line += ` · ${ordinal(monthlyCount)} silent change caught for you this month`;
176
516
  }
@@ -274,7 +614,10 @@ export class Gate {
274
614
  decideContract(name, observed) {
275
615
  const pinned = this.pin.get(this._serverId, name);
276
616
  if (pinned === null) {
277
- return this.holdOrFailopen(name, "no pin for this tool on this session; observe it via list_tools before relying on it");
617
+ return this.holdOrFailopen(name,
618
+ // NO REMEDY IN THIS STRING - it is rendered verbatim to the agent, and `observe()`
619
+ // TOFU-writes the pin store. See the Python twin.
620
+ "no pin for this tool on this session; the contract has not been observed yet");
278
621
  }
279
622
  if (observed === null) {
280
623
  return this.holdOrFailopen(name, "tool is pinned but ABSENT from the contracts observed this session; cannot verify the live contract");
@@ -332,10 +675,10 @@ export class Gate {
332
675
  marked = scanSchemaHasMarker(obj);
333
676
  }
334
677
  catch {
335
- return this.driftHold(pinned.toolName, [], "outputSchema present but the content scan could not complete; fail-closed HOLD");
678
+ return this.driftHold(pinned.toolName, [], "outputSchema present but the content scan could not complete; fail-closed HOLD", "finding");
336
679
  }
337
680
  if (marked) {
338
- return this.driftHold(pinned.toolName, [], "an injection/exfil marker is present in the tool's outputSchema (an unhashed, agent-consumed channel)");
681
+ return this.driftHold(pinned.toolName, [], "an injection/exfil marker is present in the tool's outputSchema (an unhashed, agent-consumed channel)", "finding");
339
682
  }
340
683
  }
341
684
  }
@@ -359,7 +702,7 @@ export class Gate {
359
702
  drift = this.classifyDrift(pinned, observed);
360
703
  }
361
704
  catch {
362
- return this.driftHold(name, [], "contract DRIFTED and validation could not complete; fail-closed HOLD");
705
+ return this.driftHold(name, [], "contract DRIFTED and validation could not complete; fail-closed HOLD", HoldClass.DRIFT);
363
706
  }
364
707
  // FIX 1 (fail-closed, H2): the canonical hash CHANGED (we only reach here on a hash
365
708
  // mismatch), yet the classifier produced NO change — UN-EXPLAINABLE drift (an
@@ -375,13 +718,13 @@ export class Gate {
375
718
  safetyRelevant: true,
376
719
  detail: "contract hash DRIFTED but no structured change could be classified (an unmodeled schema facet narrowed the contract); cannot prove benign",
377
720
  };
378
- return this.driftHold(name, [synthetic], "contract hash DRIFTED but no structured change could be classified; cannot prove benign — fail-closed HOLD");
721
+ return this.driftHold(name, [synthetic], "contract hash DRIFTED but no structured change could be classified; cannot prove benign — fail-closed HOLD", "finding");
379
722
  }
380
723
  const mandated = drift.filter((c) => this.behavioralMandatedKinds.has(c.kind)).map((c) => c.kind.toString());
381
724
  if (!this.autoAcceptBenign) {
382
725
  if (mandated.length > 0)
383
726
  return this.behavioralInconclusive(name, drift, mandated);
384
- return this.driftHold(name, drift, "contract DRIFTED from your pin");
727
+ return this.driftHold(name, drift, "contract DRIFTED from your pin", HoldClass.DRIFT);
385
728
  }
386
729
  if (mandated.length > 0)
387
730
  return this.behavioralInconclusive(name, drift, mandated);
@@ -395,27 +738,27 @@ export class Gate {
395
738
  ].sort();
396
739
  if (nonAllowlisted.length > 0) {
397
740
  return this.driftHold(name, drift, "contract DRIFTED; not in the benign allowlist (added-optional-param / tool-added with no description change): " +
398
- nonAllowlisted.join(", "));
741
+ nonAllowlisted.join(", "), HoldClass.DRIFT);
399
742
  }
400
743
  let escalation;
401
744
  try {
402
745
  escalation = this.riskEscalation(pinned, observed);
403
746
  }
404
747
  catch {
405
- return this.driftHold(name, drift, "contract DRIFTED and the risk assessment could not complete; fail-closed HOLD");
748
+ return this.driftHold(name, drift, "contract DRIFTED and the risk assessment could not complete; fail-closed HOLD", HoldClass.DRIFT);
406
749
  }
407
750
  if (escalation !== null) {
408
- return this.driftHold(name, drift, "contract DRIFTED; risk ESCALATED on an otherwise-allowlisted change: " + escalation);
751
+ return this.driftHold(name, drift, "contract DRIFTED; risk ESCALATED on an otherwise-allowlisted change: " + escalation, HoldClass.DRIFT);
409
752
  }
410
753
  let hasMarker;
411
754
  try {
412
755
  hasMarker = this.markerIn(observed);
413
756
  }
414
757
  catch {
415
- return this.driftHold(name, drift, "contract DRIFTED and the content scan could not complete; fail-closed HOLD");
758
+ return this.driftHold(name, drift, "contract DRIFTED and the content scan could not complete; fail-closed HOLD", HoldClass.DRIFT);
416
759
  }
417
760
  if (hasMarker) {
418
- return this.driftHold(name, drift, "contract DRIFTED; an injection/exfil marker is present in the new definition (input schema or description)");
761
+ return this.driftHold(name, drift, "contract DRIFTED; an injection/exfil marker is present in the new definition (input schema or description)", HoldClass.DRIFT);
419
762
  }
420
763
  this.repinOne(name, observed);
421
764
  return makeVerdict({
@@ -478,12 +821,18 @@ export class Gate {
478
821
  decision: Decision.HOLD,
479
822
  reason: "contract DRIFTED: the tool DESCRIPTION changed (the agent-consumed, attacker-controllable channel); review the before/after and re-pin if expected",
480
823
  drift,
824
+ holdClass: HoldClass.DRIFT, // description-poisoning: a comparison found a difference
481
825
  descBefore: oldDesc,
482
826
  descAfter: newDesc,
483
827
  });
484
828
  }
485
- driftHold(name, drift, reason) {
486
- return makeVerdict({ serverId: this._serverId, toolName: name, decision: Decision.HOLD, reason, drift });
829
+ driftHold(name, drift, reason,
830
+ // REQUIRED, no default. The claim in the comment this replaces — "every CALLER states
831
+ // it" — was FALSE: `tsc` names 6 callers that inherited it silently, the same 10 that
832
+ // mypy surfaced in the Python twin. A defaulted parameter on a helper reintroduces at
833
+ // the call site exactly what removing the field default fixed at the constructor.
834
+ holdClass) {
835
+ return makeVerdict({ serverId: this._serverId, toolName: name, decision: Decision.HOLD, reason, drift, holdClass });
487
836
  }
488
837
  behavioralInconclusive(name, drift, mandatedKinds) {
489
838
  const kinds = [...new Set(mandatedKinds)].sort().join(", ");
@@ -493,6 +842,7 @@ export class Gate {
493
842
  decision: Decision.INCONCLUSIVE,
494
843
  reason: `contract DRIFTED in a class the declared contract cannot clear or condemn (${kinds}); behavioral validation needed before use`,
495
844
  drift,
845
+ holdClass: HoldClass.DRIFT, // a comparison found a difference; behaviour must adjudicate
496
846
  });
497
847
  }
498
848
  holdOrFailopen(name, reason) {
@@ -504,6 +854,7 @@ export class Gate {
504
854
  reason,
505
855
  failOpenWarning: true,
506
856
  isContractDiff: false,
857
+ holdClass: HoldClass.UNCHECKABLE, // no comparison was possible
507
858
  });
508
859
  }
509
860
  return makeVerdict({
@@ -512,6 +863,7 @@ export class Gate {
512
863
  decision: Decision.HOLD,
513
864
  reason,
514
865
  isContractDiff: false,
866
+ holdClass: HoldClass.UNCHECKABLE, // no comparison was possible
515
867
  });
516
868
  }
517
869
  // ------------------------------------------------------------- posture layer
@@ -606,7 +958,7 @@ export class Gate {
606
958
  }
607
959
  if (observed === null) {
608
960
  return [
609
- this.holdOrFailopen(toolName, "no observed contract to behaviorally validate; list_tools before validating"),
961
+ this.holdOrFailopen(toolName, "no observed contract to behaviorally validate; no baseline to validate against"),
610
962
  null,
611
963
  ];
612
964
  }
@@ -651,7 +1003,7 @@ export class Gate {
651
1003
  const reason = result.outcome === BehavioralOutcome.FAILED
652
1004
  ? `behavioral validation FAILED (${suffix})`
653
1005
  : `behavioral validation UNAVAILABLE (${suffix}); stay HOLD`;
654
- return [this.driftHold(toolName, [...staticVerdict.drift], reason), result.outcome];
1006
+ return [this.driftHold(toolName, [...staticVerdict.drift], reason, HoldClass.DRIFT), result.outcome];
655
1007
  }
656
1008
  }
657
1009
  export { isSafetyRelevant, DEFAULT_BEHAVIORAL_MANDATED_KINDS };