@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/CHANGELOG.md +68 -0
- package/dist/src/gate.d.ts +49 -2
- package/dist/src/gate.d.ts.map +1 -1
- package/dist/src/gate.js +387 -24
- package/dist/src/gate.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/preflight.d.ts +38 -0
- package/dist/src/preflight.d.ts.map +1 -1
- package/dist/src/preflight.js +0 -0
- package/dist/src/preflight.js.map +1 -1
- package/dist/src/render.d.ts +4 -0
- package/dist/src/render.d.ts.map +1 -1
- package/dist/src/render.js +18 -2
- package/dist/src/render.js.map +1 -1
- package/dist/src/schemaDiff.d.ts +2 -0
- package/dist/src/schemaDiff.d.ts.map +1 -1
- package/dist/src/schemaDiff.js +28 -1
- package/dist/src/schemaDiff.js.map +1 -1
- package/dist/src/wrap.d.ts +5 -1
- package/dist/src/wrap.d.ts.map +1 -1
- package/dist/src/wrap.js +58 -17
- package/dist/src/wrap.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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 };
|