sandoichi 0.4.1 → 0.5.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.
@@ -0,0 +1,412 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const GATE_EVIDENCE_SCHEMA = 'sando-progressive-gateway-evidence/v1';
4
+ export const GATE_SCHEMA = 'sando-progressive-context-gate/v1';
5
+ export const GATE_VERSION = 1;
6
+ export const GATE_THRESHOLDS = Object.freeze({
7
+ hosts: Object.freeze(['claude', 'codex']),
8
+ minimumPairedSamples: 10,
9
+ minimumDiscoveryIntents: 50,
10
+ minimumResidualMcpTokens: 10_000,
11
+ minimumResidualMcpRatio: 0.10,
12
+ minimumDiscoverySuccessRate: 0.95,
13
+ minimumSafetyCriticalDiscoveryRate: 1,
14
+ maximumWrongMutativeCalls: 0,
15
+ minimumProviderInputReductionRate: 0.10,
16
+ maximumExtraToolCallsMedian: 1,
17
+ maximumP50LatencyOverhead: 0.15,
18
+ maximumStartupOverhead: 0.10,
19
+ minimumSchemaValidationRate: 1,
20
+ minimumCancellationPropagationRate: 1,
21
+ minimumTimeoutPropagationRate: 1,
22
+ minimumErrorPropagationRate: 1,
23
+ minimumProgressPropagationRate: 1,
24
+ minimumListChangedInvalidationRate: 1,
25
+ maximumWeightedCostRatio: 1,
26
+ });
27
+
28
+ const HOSTS = new Set(GATE_THRESHOLDS.hosts);
29
+ const STATES = new Set(['enabled', 'disabled', 'unavailable', 'indeterminate']);
30
+ const METRIC_FIELDS = Object.freeze([
31
+ 'discoverySuccessRate',
32
+ 'safetyCriticalDiscoveryRate',
33
+ 'wrongMutativeCalls',
34
+ 'providerInputReductionRate',
35
+ 'extraToolCallsMedian',
36
+ 'p50LatencyOverhead',
37
+ 'startupOverhead',
38
+ 'schemaValidationRate',
39
+ 'cancellationPropagationRate',
40
+ 'timeoutPropagationRate',
41
+ 'errorPropagationRate',
42
+ 'progressPropagationRate',
43
+ 'listChangedInvalidationRate',
44
+ 'authPropagationRate',
45
+ 'approvalPropagationRate',
46
+ 'elicitationPropagationRate',
47
+ 'weightedCostRatio',
48
+ 'acceptanceControlRate',
49
+ 'acceptanceTreatmentRate',
50
+ ]);
51
+ const BOUNDED_RATE_FIELDS = new Set([
52
+ 'discoverySuccessRate',
53
+ 'safetyCriticalDiscoveryRate',
54
+ 'schemaValidationRate',
55
+ 'cancellationPropagationRate',
56
+ 'timeoutPropagationRate',
57
+ 'errorPropagationRate',
58
+ 'progressPropagationRate',
59
+ 'listChangedInvalidationRate',
60
+ 'authPropagationRate',
61
+ 'approvalPropagationRate',
62
+ 'elicitationPropagationRate',
63
+ 'acceptanceControlRate',
64
+ 'acceptanceTreatmentRate',
65
+ ]);
66
+
67
+ function object(value) {
68
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
69
+ }
70
+
71
+ function digest(value, name) {
72
+ if (typeof value !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(value)) throw new TypeError(`${name} is invalid`);
73
+ return value;
74
+ }
75
+
76
+ function integer(value, name) {
77
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} is invalid`);
78
+ return value;
79
+ }
80
+
81
+ function rate(value, name, { minimum = 0, maximum = Infinity } = {}) {
82
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < minimum || value > maximum) throw new TypeError(`${name} is invalid`);
83
+ return value;
84
+ }
85
+
86
+ function optionalInteger(value, name) {
87
+ if (value === undefined || value === null) return null;
88
+ return integer(value, name);
89
+ }
90
+
91
+ function optionalRate(value, name, options) {
92
+ if (value === undefined || value === null) return null;
93
+ return rate(value, name, options);
94
+ }
95
+
96
+ function stableJson(value, seen = new Set()) {
97
+ if (value === null || typeof value !== 'object') {
98
+ const result = JSON.stringify(value);
99
+ if (result === undefined) throw new TypeError('gateway gate value is not serializable');
100
+ return result;
101
+ }
102
+ if (seen.has(value)) throw new TypeError('gateway gate value must not be cyclic');
103
+ seen.add(value);
104
+ const result = Array.isArray(value)
105
+ ? `[${value.map((item) => stableJson(item, seen)).join(',')}]`
106
+ : `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key], seen)}`).join(',')}}`;
107
+ seen.delete(value);
108
+ return result;
109
+ }
110
+
111
+ function provenanceDigest(value) {
112
+ return `sha256:${createHash('sha256').update(stableJson(value)).digest('hex')}`;
113
+ }
114
+
115
+ function identity(value, name) {
116
+ if (value === undefined || value === null) return null;
117
+ if (!object(value)) throw new TypeError(`${name} is invalid`);
118
+ const result = {};
119
+ for (const field of ['scenarioDigest', 'workloadDigest']) {
120
+ result[field] = value[field] === undefined || value[field] === null
121
+ ? null : digest(value[field], `${name}.${field}`);
122
+ }
123
+ for (const field of ['promptDigest', 'modelDigest', 'clientVersionDigest']) {
124
+ if (value[field] !== undefined && value[field] !== null) result[field] = digest(value[field], `${name}.${field}`);
125
+ }
126
+ return result;
127
+ }
128
+
129
+ function sameIdentity(control, treatment) {
130
+ if (!control || !treatment || control.scenarioDigest === null || control.workloadDigest === null
131
+ || treatment.scenarioDigest === null || treatment.workloadDigest === null) return false;
132
+ const fields = new Set([...Object.keys(control), ...Object.keys(treatment)]);
133
+ return [...fields].every((field) => control[field] === treatment[field]);
134
+ }
135
+
136
+ function normalizeMetrics(value, host) {
137
+ if (value === undefined || value === null) {
138
+ return { source: null, ...Object.fromEntries(METRIC_FIELDS.map((field) => [field, null])) };
139
+ }
140
+ if (!object(value) || value.source === 'mechanical-estimate'
141
+ || (value.source !== undefined && value.source !== null && value.source !== 'provider-reported')) {
142
+ throw new TypeError(`${host}.metrics must use provider-reported evidence`);
143
+ }
144
+ const result = { source: value.source ?? null };
145
+ for (const field of METRIC_FIELDS) {
146
+ const candidate = value[field];
147
+ if (candidate === undefined || candidate === null) result[field] = null;
148
+ else if (field === 'wrongMutativeCalls') result[field] = integer(candidate, `${host}.metrics.${field}`);
149
+ else if (field === 'extraToolCallsMedian') result[field] = rate(candidate, `${host}.metrics.${field}`);
150
+ else if (field === 'weightedCostRatio') result[field] = rate(candidate, `${host}.metrics.${field}`);
151
+ else result[field] = rate(candidate, `${host}.metrics.${field}`, {
152
+ minimum: field === 'providerInputReductionRate' ? -Infinity : 0,
153
+ maximum: BOUNDED_RATE_FIELDS.has(field) || field === 'providerInputReductionRate' ? 1 : Infinity,
154
+ });
155
+ }
156
+ return result;
157
+ }
158
+
159
+ function normalizeHost(value) {
160
+ if (!object(value) || !HOSTS.has(value.host)) throw new TypeError('gateway evidence host is invalid');
161
+ const controlArm = value.controlArm ?? null;
162
+ const treatmentArm = value.treatmentArm ?? null;
163
+ if ((controlArm !== null && controlArm !== 'native-tool-search')
164
+ || (treatmentArm !== null && treatmentArm !== 'sando-gateway')) {
165
+ throw new TypeError(`${value.host} arm provenance is invalid`);
166
+ }
167
+ if ((value.realWorkload !== undefined && typeof value.realWorkload !== 'boolean')
168
+ || (value.originalMcpDisabled !== undefined && typeof value.originalMcpDisabled !== 'boolean')) {
169
+ throw new TypeError(`${value.host} workload isolation evidence is invalid`);
170
+ }
171
+ if ((value.catalogReadOnly !== undefined && typeof value.catalogReadOnly !== 'boolean')
172
+ || (value.allowlistEnforced !== undefined && typeof value.allowlistEnforced !== 'boolean')
173
+ || (value.killSwitchRollbackTested !== undefined && typeof value.killSwitchRollbackTested !== 'boolean')) {
174
+ throw new TypeError(`${value.host} gateway safety evidence is invalid`);
175
+ }
176
+ const nativeToolSearchState = value.nativeToolSearchState ?? null;
177
+ if (nativeToolSearchState !== null && !STATES.has(nativeToolSearchState)) throw new TypeError(`${value.host} Tool Search state is invalid`);
178
+ const identityEvidence = object(value.identity)
179
+ ? value.identity
180
+ : value.identity === undefined || value.identity === null ? {} : null;
181
+ if (!identityEvidence) throw new TypeError(`${value.host} identity evidence is invalid`);
182
+ const control = identity(identityEvidence.control, `${value.host}.identity.control`);
183
+ const treatment = identity(identityEvidence.treatment, `${value.host}.identity.treatment`);
184
+ const prerequisite = object(value.prerequisite) ? value.prerequisite : {};
185
+ const metrics = normalizeMetrics(value.metrics, value.host);
186
+ return {
187
+ host: value.host,
188
+ controlArm,
189
+ treatmentArm,
190
+ realWorkload: value.realWorkload ?? null,
191
+ originalMcpDisabled: value.originalMcpDisabled ?? null,
192
+ catalogReadOnly: value.catalogReadOnly ?? null,
193
+ allowlistEnforced: value.allowlistEnforced ?? null,
194
+ killSwitchRollbackTested: value.killSwitchRollbackTested ?? null,
195
+ nativeToolSearchState,
196
+ samplePairs: value.samplePairs === undefined || value.samplePairs === null
197
+ ? null : integer(value.samplePairs, `${value.host}.samplePairs`),
198
+ discoveryIntents: value.discoveryIntents === undefined || value.discoveryIntents === null
199
+ ? null : integer(value.discoveryIntents, `${value.host}.discoveryIntents`),
200
+ identity: { control, treatment, paired: sameIdentity(control, treatment) },
201
+ prerequisite: {
202
+ residualMcpTokens: optionalInteger(prerequisite.residualMcpTokens, `${value.host}.prerequisite.residualMcpTokens`),
203
+ initialContextTokens: optionalInteger(prerequisite.initialContextTokens, `${value.host}.prerequisite.initialContextTokens`),
204
+ selectionStartupDefectRate: optionalRate(
205
+ prerequisite.selectionStartupDefectRate,
206
+ `${value.host}.prerequisite.selectionStartupDefectRate`,
207
+ { maximum: 1 },
208
+ ),
209
+ },
210
+ metrics,
211
+ };
212
+ }
213
+
214
+ function check(host, id, observed, required, pass, { missing = observed === null } = {}) {
215
+ return {
216
+ host,
217
+ id,
218
+ status: missing ? 'missing' : pass ? 'pass' : 'fail',
219
+ observed,
220
+ required,
221
+ };
222
+ }
223
+
224
+ function prerequisiteValue(value) {
225
+ const { residualMcpTokens, initialContextTokens, selectionStartupDefectRate } = value.prerequisite;
226
+ const residualTokens = residualMcpTokens !== null && residualMcpTokens >= GATE_THRESHOLDS.minimumResidualMcpTokens;
227
+ const residualRatio = initialContextTokens !== null && initialContextTokens > 0 && residualMcpTokens !== null
228
+ && residualMcpTokens / initialContextTokens >= GATE_THRESHOLDS.minimumResidualMcpRatio;
229
+ const defect = selectionStartupDefectRate !== null && selectionStartupDefectRate > 0;
230
+ const available = residualMcpTokens !== null || initialContextTokens !== null || selectionStartupDefectRate !== null;
231
+ return { residualTokens, residualRatio, defect, available, qualified: residualTokens || residualRatio || defect };
232
+ }
233
+
234
+ function hostChecks(value) {
235
+ const prerequisite = prerequisiteValue(value);
236
+ const metrics = value.metrics;
237
+ const checks = [
238
+ check(value.host, 'native-tool-search-control-arm', value.controlArm, 'native-tool-search', value.controlArm === 'native-tool-search', {
239
+ missing: value.controlArm === null,
240
+ }),
241
+ check(value.host, 'sando-gateway-treatment-arm', value.treatmentArm, 'sando-gateway', value.treatmentArm === 'sando-gateway', {
242
+ missing: value.treatmentArm === null,
243
+ }),
244
+ check(value.host, 'paired-identity', value.identity.paired, true, value.identity.paired, { missing: !value.identity.paired }),
245
+ check(value.host, 'real-workload', value.realWorkload, true, value.realWorkload === true, { missing: value.realWorkload === null }),
246
+ check(value.host, 'native-tool-search-control', value.nativeToolSearchState, 'enabled', value.nativeToolSearchState === 'enabled', {
247
+ missing: !['enabled', 'disabled'].includes(value.nativeToolSearchState),
248
+ }),
249
+ check(value.host, 'isolated-original-mcp', value.originalMcpDisabled, true, value.originalMcpDisabled === true, { missing: value.originalMcpDisabled === null }),
250
+ check(value.host, 'paired-sample-count', value.samplePairs, GATE_THRESHOLDS.minimumPairedSamples,
251
+ value.samplePairs !== null && value.samplePairs >= GATE_THRESHOLDS.minimumPairedSamples),
252
+ check(value.host, 'discovery-intent-count', value.discoveryIntents, GATE_THRESHOLDS.minimumDiscoveryIntents,
253
+ value.discoveryIntents !== null && value.discoveryIntents >= GATE_THRESHOLDS.minimumDiscoveryIntents),
254
+ check(value.host, 'catalog-read-only', value.catalogReadOnly, true, value.catalogReadOnly === true, { missing: value.catalogReadOnly === null }),
255
+ check(value.host, 'allowlist', value.allowlistEnforced, true, value.allowlistEnforced === true, { missing: value.allowlistEnforced === null }),
256
+ check(value.host, 'kill-switch-rollback', value.killSwitchRollbackTested, true, value.killSwitchRollbackTested === true, { missing: value.killSwitchRollbackTested === null }),
257
+ check(value.host, 'gateway-prerequisite', {
258
+ residualMcpTokens: value.prerequisite.residualMcpTokens,
259
+ initialContextTokens: value.prerequisite.initialContextTokens,
260
+ selectionStartupDefectRate: value.prerequisite.selectionStartupDefectRate,
261
+ qualified: prerequisite.qualified,
262
+ }, 'residual threshold or measured selection/startup defect', prerequisite.qualified, {
263
+ missing: !prerequisite.available,
264
+ }),
265
+ check(value.host, 'provider-reported-metrics', metrics.source, 'provider-reported', metrics.source === 'provider-reported', {
266
+ missing: metrics.source === null,
267
+ }),
268
+ check(value.host, 'discovery-success', metrics.discoverySuccessRate, GATE_THRESHOLDS.minimumDiscoverySuccessRate,
269
+ metrics.discoverySuccessRate !== null && metrics.discoverySuccessRate >= GATE_THRESHOLDS.minimumDiscoverySuccessRate),
270
+ check(value.host, 'safety-critical-discovery', metrics.safetyCriticalDiscoveryRate, GATE_THRESHOLDS.minimumSafetyCriticalDiscoveryRate,
271
+ metrics.safetyCriticalDiscoveryRate !== null && metrics.safetyCriticalDiscoveryRate >= GATE_THRESHOLDS.minimumSafetyCriticalDiscoveryRate),
272
+ check(value.host, 'wrong-mutative-calls', metrics.wrongMutativeCalls, GATE_THRESHOLDS.maximumWrongMutativeCalls,
273
+ metrics.wrongMutativeCalls !== null && metrics.wrongMutativeCalls <= GATE_THRESHOLDS.maximumWrongMutativeCalls),
274
+ check(value.host, 'provider-input-reduction', metrics.providerInputReductionRate, GATE_THRESHOLDS.minimumProviderInputReductionRate,
275
+ metrics.providerInputReductionRate !== null && metrics.providerInputReductionRate >= GATE_THRESHOLDS.minimumProviderInputReductionRate),
276
+ check(value.host, 'extra-tool-calls', metrics.extraToolCallsMedian, GATE_THRESHOLDS.maximumExtraToolCallsMedian,
277
+ metrics.extraToolCallsMedian !== null && metrics.extraToolCallsMedian <= GATE_THRESHOLDS.maximumExtraToolCallsMedian),
278
+ check(value.host, 'p50-latency-overhead', metrics.p50LatencyOverhead, GATE_THRESHOLDS.maximumP50LatencyOverhead,
279
+ metrics.p50LatencyOverhead !== null && metrics.p50LatencyOverhead <= GATE_THRESHOLDS.maximumP50LatencyOverhead),
280
+ check(value.host, 'startup-overhead', metrics.startupOverhead, GATE_THRESHOLDS.maximumStartupOverhead,
281
+ metrics.startupOverhead !== null && metrics.startupOverhead <= GATE_THRESHOLDS.maximumStartupOverhead),
282
+ check(value.host, 'schema-validation', metrics.schemaValidationRate, GATE_THRESHOLDS.minimumSchemaValidationRate,
283
+ metrics.schemaValidationRate !== null && metrics.schemaValidationRate >= GATE_THRESHOLDS.minimumSchemaValidationRate),
284
+ check(value.host, 'cancellation-propagation', metrics.cancellationPropagationRate, GATE_THRESHOLDS.minimumCancellationPropagationRate,
285
+ metrics.cancellationPropagationRate !== null && metrics.cancellationPropagationRate >= GATE_THRESHOLDS.minimumCancellationPropagationRate),
286
+ check(value.host, 'timeout-propagation', metrics.timeoutPropagationRate, GATE_THRESHOLDS.minimumTimeoutPropagationRate,
287
+ metrics.timeoutPropagationRate !== null && metrics.timeoutPropagationRate >= GATE_THRESHOLDS.minimumTimeoutPropagationRate),
288
+ check(value.host, 'error-propagation', metrics.errorPropagationRate, GATE_THRESHOLDS.minimumErrorPropagationRate,
289
+ metrics.errorPropagationRate !== null && metrics.errorPropagationRate >= GATE_THRESHOLDS.minimumErrorPropagationRate),
290
+ check(value.host, 'progress-propagation', metrics.progressPropagationRate, GATE_THRESHOLDS.minimumProgressPropagationRate,
291
+ metrics.progressPropagationRate !== null && metrics.progressPropagationRate >= GATE_THRESHOLDS.minimumProgressPropagationRate),
292
+ check(value.host, 'list-changed-invalidation', metrics.listChangedInvalidationRate, GATE_THRESHOLDS.minimumListChangedInvalidationRate,
293
+ metrics.listChangedInvalidationRate !== null && metrics.listChangedInvalidationRate >= GATE_THRESHOLDS.minimumListChangedInvalidationRate),
294
+ check(value.host, 'auth-propagation', metrics.authPropagationRate, 1,
295
+ metrics.authPropagationRate !== null && metrics.authPropagationRate >= 1),
296
+ check(value.host, 'approval-propagation', metrics.approvalPropagationRate, 1,
297
+ metrics.approvalPropagationRate !== null && metrics.approvalPropagationRate >= 1),
298
+ check(value.host, 'elicitation-propagation', metrics.elicitationPropagationRate, 1,
299
+ metrics.elicitationPropagationRate !== null && metrics.elicitationPropagationRate >= 1),
300
+ check(value.host, 'weighted-cost', metrics.weightedCostRatio, GATE_THRESHOLDS.maximumWeightedCostRatio,
301
+ metrics.weightedCostRatio !== null && metrics.weightedCostRatio <= GATE_THRESHOLDS.maximumWeightedCostRatio),
302
+ check(value.host, 'acceptance-no-regression', {
303
+ control: metrics.acceptanceControlRate,
304
+ treatment: metrics.acceptanceTreatmentRate,
305
+ }, 'treatment >= control',
306
+ metrics.acceptanceControlRate !== null && metrics.acceptanceTreatmentRate !== null
307
+ && metrics.acceptanceTreatmentRate >= metrics.acceptanceControlRate,
308
+ { missing: metrics.acceptanceControlRate === null || metrics.acceptanceTreatmentRate === null }),
309
+ ];
310
+ return checks;
311
+ }
312
+
313
+ function reasonFor(checkValue) {
314
+ const names = {
315
+ 'paired-identity': 'paired-identity-required',
316
+ 'native-tool-search-control-arm': 'native-tool-search-control-arm-required',
317
+ 'sando-gateway-treatment-arm': 'sando-gateway-treatment-arm-required',
318
+ 'provider-reported-metrics': 'provider-reported-metrics-required',
319
+ 'both-hosts': 'both-hosts-required',
320
+ 'real-workload': 'real-workload-required',
321
+ 'native-tool-search-control': 'native-tool-search-control-required',
322
+ 'isolated-original-mcp': 'isolated-original-mcp-required',
323
+ 'paired-sample-count': 'minimum-paired-samples-required',
324
+ 'discovery-intent-count': 'minimum-discovery-intents-required',
325
+ 'catalog-read-only': 'catalog-read-only-required',
326
+ allowlist: 'allowlist-required',
327
+ 'kill-switch-rollback': 'kill-switch-rollback-required',
328
+ 'gateway-prerequisite': 'gateway-prerequisite-not-established',
329
+ 'discovery-success': 'discovery-success-threshold',
330
+ 'safety-critical-discovery': 'safety-critical-discovery-threshold',
331
+ 'wrong-mutative-calls': 'wrong-mutative-call',
332
+ 'provider-input-reduction': 'provider-input-reduction-threshold',
333
+ 'extra-tool-calls': 'extra-tool-call-threshold',
334
+ 'p50-latency-overhead': 'latency-overhead-threshold',
335
+ 'startup-overhead': 'startup-overhead-threshold',
336
+ 'schema-validation': 'schema-validation-threshold',
337
+ 'cancellation-propagation': 'cancellation-propagation-threshold',
338
+ 'timeout-propagation': 'timeout-propagation-threshold',
339
+ 'error-propagation': 'error-propagation-threshold',
340
+ 'progress-propagation': 'progress-propagation-threshold',
341
+ 'list-changed-invalidation': 'list-changed-invalidation-threshold',
342
+ 'auth-propagation': 'auth-propagation-threshold',
343
+ 'approval-propagation': 'approval-propagation-threshold',
344
+ 'elicitation-propagation': 'elicitation-propagation-threshold',
345
+ 'weighted-cost': 'weighted-cost-regression',
346
+ 'acceptance-no-regression': 'acceptance-regression',
347
+ };
348
+ return names[checkValue.id] ?? `${checkValue.id}-check`;
349
+ }
350
+
351
+ function normalizeEvidence(value) {
352
+ if (!object(value) || value.schema !== GATE_EVIDENCE_SCHEMA || value.version !== GATE_VERSION) {
353
+ throw new TypeError('gateway evidence schema is invalid');
354
+ }
355
+ if (!Array.isArray(value.hosts)) throw new TypeError('gateway evidence hosts are invalid');
356
+ const hosts = value.hosts.map(normalizeHost).sort((left, right) => left.host.localeCompare(right.host));
357
+ if (new Set(hosts.map((item) => item.host)).size !== hosts.length) throw new TypeError('gateway evidence contains duplicate hosts');
358
+ return hosts;
359
+ }
360
+
361
+ function safeHost(value) {
362
+ return {
363
+ host: value.host,
364
+ controlArm: value.controlArm,
365
+ treatmentArm: value.treatmentArm,
366
+ samplePairs: value.samplePairs,
367
+ discoveryIntents: value.discoveryIntents,
368
+ realWorkload: value.realWorkload,
369
+ originalMcpDisabled: value.originalMcpDisabled,
370
+ catalogReadOnly: value.catalogReadOnly,
371
+ allowlistEnforced: value.allowlistEnforced,
372
+ killSwitchRollbackTested: value.killSwitchRollbackTested,
373
+ nativeToolSearchState: value.nativeToolSearchState,
374
+ identity: value.identity,
375
+ prerequisite: value.prerequisite,
376
+ metrics: value.metrics,
377
+ };
378
+ }
379
+
380
+ export function evaluateGatewayGate({ evidence } = {}) {
381
+ const hosts = normalizeEvidence(evidence ?? { schema: GATE_EVIDENCE_SCHEMA, version: GATE_VERSION, hosts: [] });
382
+ const checks = hosts.flatMap(hostChecks);
383
+ if (hosts.length !== GATE_THRESHOLDS.hosts.length || hosts.some((item) => !HOSTS.has(item.host))) {
384
+ checks.unshift({
385
+ host: 'all', id: 'both-hosts', status: 'missing', observed: hosts.map((item) => item.host), required: GATE_THRESHOLDS.hosts,
386
+ });
387
+ } else {
388
+ checks.unshift({ host: 'all', id: 'both-hosts', status: 'pass', observed: hosts.map((item) => item.host), required: GATE_THRESHOLDS.hosts });
389
+ }
390
+ const missing = checks.filter((item) => item.status === 'missing');
391
+ const failed = checks.filter((item) => item.status === 'fail');
392
+ const status = missing.length > 0 ? 'insufficient-evidence' : failed.length > 0 ? 'no-go' : 'go';
393
+ const reasons = [...new Set(checks.filter((item) => item.status !== 'pass').map(reasonFor))].sort();
394
+ const report = {
395
+ schema: GATE_SCHEMA,
396
+ version: GATE_VERSION,
397
+ feature: 'lazy-mcp-gateway',
398
+ status,
399
+ thresholds: GATE_THRESHOLDS,
400
+ hosts: hosts.map(safeHost),
401
+ checks,
402
+ reasons,
403
+ };
404
+ return { ...report, provenanceDigest: provenanceDigest(report) };
405
+ }
406
+
407
+ export function serializeGatewayGate(report) {
408
+ if (!object(report) || report.schema !== GATE_SCHEMA || report.version !== GATE_VERSION) {
409
+ throw new TypeError('gateway gate report is invalid');
410
+ }
411
+ return stableJson(report);
412
+ }
@@ -0,0 +1,80 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
6
+ const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
7
+
8
+ function shellQuote(value) {
9
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
10
+ }
11
+
12
+ function safeDirectory(target) {
13
+ const stat = fs.lstatSync(target, { throwIfNoEntry: false });
14
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error('history artifact directory is unsafe');
15
+ if (!stat) fs.mkdirSync(target, { mode: 0o700 });
16
+ }
17
+
18
+ function storedBytes(directory, destination) {
19
+ let total = 0;
20
+ for (const name of fs.readdirSync(directory)) {
21
+ if (!/^[a-f0-9]{64}\.txt$/.test(name)) continue;
22
+ const target = path.join(directory, name);
23
+ const stat = fs.lstatSync(target);
24
+ if (!stat.isFile() || stat.isSymbolicLink() || fs.realpathSync(target) !== target) {
25
+ throw new Error('history artifact directory contains an unsafe entry');
26
+ }
27
+ if (target !== destination) total += stat.size;
28
+ }
29
+ return total;
30
+ }
31
+
32
+ export function prepareHistoryArtifact({ root, content } = {}) {
33
+ if (typeof root !== 'string' || !path.isAbsolute(root)) throw new TypeError('history archive root must be an absolute path');
34
+ if (typeof content !== 'string') throw new TypeError('history artifact content is invalid');
35
+ const canonicalRoot = fs.realpathSync(root);
36
+ if (!fs.statSync(canonicalRoot).isDirectory()) throw new TypeError('history archive root is not a directory');
37
+ const bytes = Buffer.byteLength(content);
38
+ if (bytes > MAX_ARTIFACT_BYTES) throw new RangeError('history artifact exceeds recovery limit');
39
+ const digest = `sha256:${createHash('sha256').update(content).digest('hex')}`;
40
+ const ref = `sando:${digest}`;
41
+ const artifactPath = path.join(canonicalRoot, '.sando', 'sando', 'artifacts', `${digest.slice('sha256:'.length)}.txt`);
42
+ const lines = content.split('\n').length;
43
+ const firstPageEnd = Math.min(80, lines);
44
+ return {
45
+ root: canonicalRoot,
46
+ content,
47
+ bytes,
48
+ digest,
49
+ ref,
50
+ marker: `[sando archived result ${ref}; ${bytes}B, ${lines} lines; use rtk grep -n on archive ${shellQuote(artifactPath)}; full exact text: use native Read on the archive file; optional first page: sando artifact get --root ${shellQuote(canonicalRoot)} --ref ${ref} --start-line 1 --end-line ${firstPageEnd} --max-bytes 8192; bounded, continue with valid line ranges up to ${lines}]`,
51
+ };
52
+ }
53
+
54
+ export function persistHistoryArtifact(artifact) {
55
+ if (!artifact || typeof artifact.root !== 'string' || typeof artifact.content !== 'string'
56
+ || !/^sha256:[a-f0-9]{64}$/.test(artifact.digest ?? '') || artifact.ref !== `sando:${artifact.digest}`) {
57
+ throw new TypeError('history artifact is invalid');
58
+ }
59
+ const stateRoot = path.join(artifact.root, '.sando');
60
+ const privateRoot = path.join(stateRoot, 'sando');
61
+ const directory = path.join(privateRoot, 'artifacts');
62
+ for (const target of [stateRoot, privateRoot, directory]) safeDirectory(target);
63
+ const name = `${artifact.digest.slice('sha256:'.length)}.txt`;
64
+ const destination = path.join(directory, name);
65
+ if (storedBytes(directory, destination) + artifact.bytes > MAX_ARCHIVE_BYTES) {
66
+ throw new Error('history artifact archive is full');
67
+ }
68
+ const temporary = path.join(directory, `.${name}.${process.pid}.${randomUUID()}`);
69
+ try {
70
+ fs.writeFileSync(temporary, artifact.content, { flag: 'wx', mode: 0o600 });
71
+ try { fs.linkSync(temporary, destination); }
72
+ catch (error) {
73
+ if (error?.code !== 'EEXIST' || fs.readFileSync(destination, 'utf8') !== artifact.content) throw error;
74
+ }
75
+ } finally {
76
+ fs.rmSync(temporary, { force: true });
77
+ }
78
+ fs.chmodSync(destination, 0o600);
79
+ return artifact;
80
+ }
@@ -0,0 +1,70 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { createRedactionProfile } from './redaction-profile.mjs';
4
+
5
+ export const HISTORY_DISCLOSURE_SCHEMA = 'sando-history-disclosure/v1';
6
+ export const HISTORY_DISCLOSURE_VERSION = 1;
7
+ const DEFAULT_PROFILE = createRedactionProfile();
8
+
9
+ function digest(text) {
10
+ return `sha256:${createHash('sha256').update(text).digest('hex')}`;
11
+ }
12
+
13
+ function typeOf(toolName) {
14
+ const name = toolName.toLowerCase();
15
+ if (name === 'read') return 'read';
16
+ if (name === 'grep') return 'grep';
17
+ if (name === 'bash' || name === 'exec') return 'bash';
18
+ if (name === 'log') return 'log';
19
+ return 'mcp';
20
+ }
21
+
22
+ function bytes(value, name) {
23
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} is invalid`);
24
+ return value;
25
+ }
26
+
27
+ export function buildHistoryDisclosure({ toolName, reason, originalText, visibleText, recovery = 'rerun-tool', redactionProfile } = {}) {
28
+ if (typeof toolName !== 'string' || !toolName.trim() || typeof reason !== 'string' || !reason
29
+ || typeof originalText !== 'string' || typeof visibleText !== 'string') {
30
+ throw new TypeError('history disclosure input is invalid');
31
+ }
32
+ if (!['rerun-tool', 'newer-result'].includes(recovery)) throw new TypeError('history disclosure recovery is invalid');
33
+ const original = Buffer.byteLength(originalText, 'utf8');
34
+ const profile = redactionProfile ?? DEFAULT_PROFILE;
35
+ if (!profile || typeof profile.redact !== 'function') throw new TypeError('history disclosure redaction profile is invalid');
36
+ const redactedText = profile.redact(originalText).text;
37
+ const redacted = Buffer.byteLength(redactedText, 'utf8');
38
+ const visible = Buffer.byteLength(visibleText, 'utf8');
39
+ return {
40
+ schema: HISTORY_DISCLOSURE_SCHEMA,
41
+ version: HISTORY_DISCLOSURE_VERSION,
42
+ type: typeOf(toolName),
43
+ reason,
44
+ provenanceDigest: digest(redactedText),
45
+ bytes: {
46
+ original: bytes(original, 'original bytes'),
47
+ redacted: bytes(redacted, 'redacted bytes'),
48
+ visible: bytes(visible, 'visible bytes'),
49
+ },
50
+ recovery: { mode: recovery, bounded: true },
51
+ };
52
+ }
53
+
54
+ function stableJson(value, seen = new Set()) {
55
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
56
+ if (seen.has(value)) throw new TypeError('history disclosure must not be cyclic');
57
+ seen.add(value);
58
+ const result = Array.isArray(value)
59
+ ? `[${value.map((item) => stableJson(item, seen)).join(',')}]`
60
+ : `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key], seen)}`).join(',')}}`;
61
+ seen.delete(value);
62
+ return result;
63
+ }
64
+
65
+ export function serializeHistoryDisclosure(report) {
66
+ if (!report || report.schema !== HISTORY_DISCLOSURE_SCHEMA || report.version !== HISTORY_DISCLOSURE_VERSION) {
67
+ throw new TypeError('history disclosure is invalid');
68
+ }
69
+ return stableJson(report);
70
+ }
package/src/hook-cli.mjs CHANGED
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
3
3
  import path from 'node:path';
4
4
 
5
5
  import { createReceipt, normalizeEvent, normalizePolicy, optimizeToolOutput } from './core.mjs';
6
+ import { cleanupArtifacts } from './artifact-lifecycle.mjs';
6
7
  import { loadProjectRedactionProfile } from './redaction-config.mjs';
7
8
  import { defaultMetricsPath, recordMetrics } from './metrics.mjs';
8
9
  import {
@@ -12,6 +13,13 @@ import { PLUGIN_VERSION } from './version.mjs';
12
13
 
13
14
  function todayUtc() { return new Date().toISOString().slice(0, 10); }
14
15
 
16
+ function artifactPresent(target) {
17
+ let stat;
18
+ try { stat = fs.lstatSync(target); } catch { return false; }
19
+ if (!stat.isFile() || stat.isSymbolicLink()) return false;
20
+ try { return fs.realpathSync(target) === target; } catch { return false; }
21
+ }
22
+
15
23
  /** Only counts (never content, paths, or IDs). */
16
24
  function recordHookTelemetry({ host, env, policy, optimization }) {
17
25
  try {
@@ -23,6 +31,7 @@ function recordHookTelemetry({ host, env, policy, optimization }) {
23
31
  incrementCounter({
24
32
  statePaths,
25
33
  day: todayUtc(),
34
+ pluginVersion: PLUGIN_VERSION,
26
35
  event: 'hook_summary',
27
36
  host,
28
37
  mode: policy.mode === 'apply' ? 'enforce' : policy.mode === 'dry-run' ? 'dry_run' : 'observe',
@@ -46,7 +55,7 @@ function recordHookFailure({ host, env, failureStage }) {
46
55
  const statePaths = defaultTelemetryStatePaths(env);
47
56
  const day = todayUtc();
48
57
  recordActiveDay({ statePaths, day, pluginVersion: PLUGIN_VERSION, host });
49
- recordFailure({ statePaths, day, event: 'hook_failure_summary', host, failureStage });
58
+ recordFailure({ statePaths, day, pluginVersion: PLUGIN_VERSION, event: 'hook_failure_summary', host, failureStage });
50
59
  closeFinishedDays({ statePaths, configPath, day, pluginVersion: PLUGIN_VERSION });
51
60
  } catch { /* telemetry is best-effort and must never affect hook output */ }
52
61
  }
@@ -69,6 +78,7 @@ function artifactPath(cwd, artifact) {
69
78
  if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error('artifact directory is unsafe');
70
79
  if (!stat) fs.mkdirSync(target, { mode: 0o700 });
71
80
  }
81
+ cleanupArtifacts(directory);
72
82
  const name = `${artifact.sourceDigest.slice('sha256:'.length)}.txt`;
73
83
  const destination = path.join(directory, name);
74
84
  const temporary = path.join(directory, `.${name}.${process.pid}.${randomUUID()}`);
@@ -82,6 +92,8 @@ function artifactPath(cwd, artifact) {
82
92
  fs.rmSync(temporary, { force: true });
83
93
  }
84
94
  fs.chmodSync(destination, 0o600);
95
+ cleanupArtifacts(directory, { preserveName: name });
96
+ if (!artifactPresent(destination)) throw new Error('artifact storage limit removed the new artifact');
85
97
  return path.posix.join('.sando/sando', 'artifacts', name);
86
98
  }
87
99
 
@@ -101,6 +113,10 @@ export function runHookCli({ host, env = process.env } = {}) {
101
113
  const eventName = input.hook_event_name ?? input.hookEventName ?? input.event_name ?? input.eventName;
102
114
  if (eventName === 'PostToolUse') {
103
115
  const event = normalizeEvent(input);
116
+ if (host === 'claude' && event.toolName.startsWith('mcp__')) {
117
+ process.stdout.write('{}\n');
118
+ return;
119
+ }
104
120
  failureStage = 'redaction';
105
121
  const redactionProfile = policy.redact ? loadProjectRedactionProfile(event.cwd).profile : undefined;
106
122
  failureStage = 'optimization';