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