sandoichi 0.4.1 → 0.4.2

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,183 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import { ensureDirectory, withLock } from './provider-usage.mjs';
7
+ import { PLUGIN_VERSION } from './version.mjs';
8
+ import { SCHEMA_VERSION, serializeEvent, toOtlpLogs } from './telemetry.mjs';
9
+
10
+ export const F4_EVENT_SCHEMA = 'sando-f4-event/v1';
11
+ export const F4_EVENT_VERSION = 1;
12
+ export const F4_HOSTS = Object.freeze(['claude', 'codex', 'unknown']);
13
+ export const F4_OPERATIONS = Object.freeze(['catalog', 'call']);
14
+ export const F4_OUTCOMES = Object.freeze(['success', 'rejected', 'timeout', 'cancelled', 'error']);
15
+ export const F4_LATENCY_BUCKETS = Object.freeze(['lt_10ms', '10_to_100ms', '100_to_1000ms', 'gte_1000ms']);
16
+ export const F4_RESULT_BUCKETS = Object.freeze(['zero', 'one', '2_to_5', '6_to_20', 'gt_20', 'unknown']);
17
+ export const DEFAULT_F4_TELEMETRY_ENDPOINT = 'http://127.0.0.1:4319/v1/logs';
18
+
19
+ const CAPABILITY_DIGEST = /^sha256:[0-9a-f]{64}$/;
20
+
21
+ function text(value) { return typeof value === 'string' && value.length > 0; }
22
+
23
+ function timestamp(value) {
24
+ const date = value instanceof Date ? value : new Date(value ?? Date.now());
25
+ if (Number.isNaN(date.getTime())) throw new TypeError('F4 event timestamp is invalid');
26
+ return date.toISOString();
27
+ }
28
+
29
+ export function digestCapability(value) {
30
+ if (!text(value) || value.length > 256) throw new TypeError('F4 capability is invalid');
31
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
32
+ }
33
+
34
+ export function latencyBucket(latencyMs) {
35
+ if (typeof latencyMs !== 'number' || !Number.isFinite(latencyMs) || latencyMs < 0) throw new TypeError('F4 latency is invalid');
36
+ if (latencyMs < 10) return 'lt_10ms';
37
+ if (latencyMs < 100) return '10_to_100ms';
38
+ if (latencyMs < 1000) return '100_to_1000ms';
39
+ return 'gte_1000ms';
40
+ }
41
+
42
+ export function resultBucket(resultCount) {
43
+ if (resultCount === null || resultCount === undefined) return 'unknown';
44
+ if (!Number.isSafeInteger(resultCount) || resultCount < 0) throw new TypeError('F4 result count is invalid');
45
+ if (resultCount === 0) return 'zero';
46
+ if (resultCount === 1) return 'one';
47
+ if (resultCount <= 5) return '2_to_5';
48
+ if (resultCount <= 20) return '6_to_20';
49
+ return 'gt_20';
50
+ }
51
+
52
+ export function defaultF4EventsPath(env = process.env) {
53
+ const configured = env.SANDO_F4_EVENTS_PATH;
54
+ if (configured !== undefined) {
55
+ if (typeof configured !== 'string' || !path.isAbsolute(configured)) throw new Error('F4 events path must be absolute');
56
+ return configured;
57
+ }
58
+ const stateHome = env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
59
+ if (!path.isAbsolute(stateHome)) throw new Error('state directory must be absolute');
60
+ return path.join(stateHome, 'sando', 'f4-events.jsonl');
61
+ }
62
+
63
+ function validateEvent(event) {
64
+ if (!event || typeof event !== 'object' || Array.isArray(event)
65
+ || event.schema !== F4_EVENT_SCHEMA || event.version !== F4_EVENT_VERSION
66
+ || typeof event.at !== 'string' || Number.isNaN(Date.parse(event.at))
67
+ || !F4_HOSTS.includes(event.host) || !F4_OPERATIONS.includes(event.operation)
68
+ || !F4_OUTCOMES.includes(event.outcome) || !F4_LATENCY_BUCKETS.includes(event.latency_bucket)
69
+ || !F4_RESULT_BUCKETS.includes(event.result_bucket)
70
+ || (event.capability_digest !== null && !CAPABILITY_DIGEST.test(event.capability_digest))) {
71
+ throw new TypeError('F4 event is invalid');
72
+ }
73
+ const keys = Object.keys(event).sort().join(',');
74
+ if (keys !== 'at,capability_digest,host,latency_bucket,operation,outcome,result_bucket,schema,version') {
75
+ throw new TypeError('F4 event contains unsupported fields');
76
+ }
77
+ return event;
78
+ }
79
+
80
+ export function buildF4Event({
81
+ host = process.env.SANDO_F4_HOST || 'unknown',
82
+ operation,
83
+ outcome,
84
+ latencyMs,
85
+ resultCount = null,
86
+ capability,
87
+ capabilityDigest = null,
88
+ at,
89
+ } = {}) {
90
+ const normalizedCapability = capabilityDigest === null || capabilityDigest === undefined
91
+ ? (capability === undefined || capability === null ? null : digestCapability(capability))
92
+ : capabilityDigest;
93
+ if (normalizedCapability !== null && !CAPABILITY_DIGEST.test(normalizedCapability)) throw new TypeError('F4 capability digest is invalid');
94
+ const event = {
95
+ schema: F4_EVENT_SCHEMA,
96
+ version: F4_EVENT_VERSION,
97
+ at: timestamp(at),
98
+ host,
99
+ operation,
100
+ outcome,
101
+ latency_bucket: latencyBucket(latencyMs),
102
+ result_bucket: resultBucket(resultCount),
103
+ capability_digest: normalizedCapability,
104
+ };
105
+ return validateEvent(event);
106
+ }
107
+
108
+ export function serializeF4Event(event) {
109
+ const value = validateEvent(event);
110
+ const serialized = JSON.stringify(value);
111
+ if (Buffer.byteLength(serialized) > 1024) throw new Error('F4 event exceeds serialized size limit');
112
+ return serialized;
113
+ }
114
+
115
+ export function buildF4TelemetryEvent(event, pluginVersion = PLUGIN_VERSION) {
116
+ if (!event || typeof event !== 'object' || Array.isArray(event)
117
+ || event.schema !== F4_EVENT_SCHEMA || event.version !== F4_EVENT_VERSION
118
+ || typeof event.at !== 'string' || Number.isNaN(Date.parse(event.at))
119
+ || !F4_HOSTS.includes(event.host) || !F4_OPERATIONS.includes(event.operation)
120
+ || !F4_OUTCOMES.includes(event.outcome) || !F4_LATENCY_BUCKETS.includes(event.latency_bucket)
121
+ || !F4_RESULT_BUCKETS.includes(event.result_bucket)) {
122
+ throw new TypeError('F4 event is invalid');
123
+ }
124
+ const telemetryEvent = {
125
+ schema_version: SCHEMA_VERSION,
126
+ event: 'f4_gateway',
127
+ day_utc: new Date(event.at).toISOString().slice(0, 10),
128
+ plugin_version: pluginVersion,
129
+ f4_host: event.host,
130
+ f4_operation: event.operation,
131
+ f4_outcome: event.outcome,
132
+ f4_latency_bucket: event.latency_bucket,
133
+ f4_result_bucket: event.result_bucket,
134
+ };
135
+ serializeEvent(telemetryEvent);
136
+ return telemetryEvent;
137
+ }
138
+
139
+ export async function publishF4Telemetry(event, {
140
+ endpoint = DEFAULT_F4_TELEMETRY_ENDPOINT,
141
+ fetchImpl = fetch,
142
+ timeoutMs = 2_500,
143
+ } = {}) {
144
+ const telemetryEvent = buildF4TelemetryEvent(event);
145
+ const controller = new AbortController();
146
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
147
+ try {
148
+ const response = await fetchImpl(endpoint, {
149
+ method: 'POST',
150
+ headers: { 'content-type': 'application/json' },
151
+ body: JSON.stringify(toOtlpLogs([telemetryEvent])),
152
+ signal: controller.signal,
153
+ });
154
+ if (!response.ok) throw new Error(`F4 telemetry endpoint returned ${response.status}`);
155
+ return { events: 1, status: response.status };
156
+ } finally {
157
+ clearTimeout(timer);
158
+ }
159
+ }
160
+
161
+ function resolvePath(storagePath) {
162
+ if (typeof storagePath !== 'string' || !path.isAbsolute(storagePath)) throw new Error('F4 events path must be absolute');
163
+ return storagePath;
164
+ }
165
+
166
+ function assertRegularFile(filePath) {
167
+ if (!fs.existsSync(filePath)) return;
168
+ const stat = fs.lstatSync(filePath);
169
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('F4 events file is unsafe');
170
+ }
171
+
172
+ export function recordF4Event({ storagePath, env = process.env, ...options } = {}) {
173
+ const event = buildF4Event(options);
174
+ const filePath = resolvePath(storagePath ?? defaultF4EventsPath(env));
175
+ ensureDirectory(path.dirname(filePath));
176
+ assertRegularFile(filePath);
177
+ withLock(`${filePath}.lock`, () => {
178
+ assertRegularFile(filePath);
179
+ fs.appendFileSync(filePath, `${serializeF4Event(event)}\n`, { flag: 'a', mode: 0o600 });
180
+ fs.chmodSync(filePath, 0o600);
181
+ });
182
+ return event;
183
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ import { GATE_EVIDENCE_SCHEMA, evaluateGatewayGate } from './gateway-gate.mjs';
8
+
9
+ const MAX_INPUT_BYTES = 1 * 1024 * 1024;
10
+
11
+ function usage() {
12
+ return 'Usage: sando context gateway-gate [--input EVIDENCE.json] [--json]\n';
13
+ }
14
+
15
+ function parseArgs(argv) {
16
+ let args = [...argv];
17
+ if (args[0] === 'context') args = args.slice(1);
18
+ if (args[0] === 'gateway-gate') args = args.slice(1);
19
+ const result = { input: undefined, json: false, help: false };
20
+ for (let index = 0; index < args.length; index += 1) {
21
+ const argument = args[index];
22
+ if (argument === '--help' || argument === '-h') result.help = true;
23
+ else if (argument === '--json') result.json = true;
24
+ else if (argument === '--input') {
25
+ const value = args[index + 1];
26
+ if (!value || value.startsWith('--')) throw new Error('--input requires a value');
27
+ result.input = value;
28
+ index += 1;
29
+ } else throw new Error('unknown context gateway-gate option');
30
+ }
31
+ return result;
32
+ }
33
+
34
+ function readEvidence(inputPath) {
35
+ let descriptor;
36
+ try {
37
+ descriptor = fs.openSync(inputPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
38
+ const stat = fs.fstatSync(descriptor);
39
+ if (!stat.isFile() || stat.size > MAX_INPUT_BYTES) throw new Error('gateway evidence is too large or not a file');
40
+ const source = fs.readFileSync(descriptor, 'utf8');
41
+ if (Buffer.byteLength(source, 'utf8') > MAX_INPUT_BYTES) throw new Error('gateway evidence is too large');
42
+ try {
43
+ return JSON.parse(source);
44
+ } catch {
45
+ throw new Error('gateway evidence JSON is invalid');
46
+ }
47
+ } catch (error) {
48
+ if (error?.message?.startsWith('gateway evidence')) throw error;
49
+ throw new Error('gateway evidence cannot be read');
50
+ } finally {
51
+ if (descriptor !== undefined) fs.closeSync(descriptor);
52
+ }
53
+ }
54
+
55
+ function emptyEvidence() {
56
+ return { schema: GATE_EVIDENCE_SCHEMA, version: 1, hosts: [] };
57
+ }
58
+
59
+ export function formatGatewayGate(report) {
60
+ const failed = report.checks.filter((check) => check.status !== 'pass').length;
61
+ return [
62
+ `Sando lazy MCP gateway: ${report.status}`,
63
+ `hosts: ${report.hosts.map((host) => host.host).join(', ') || 'none'}`,
64
+ `blocked checks: ${failed}`,
65
+ `reasons: ${report.reasons.join(', ') || 'none'}`,
66
+ `provenance: ${report.provenanceDigest}`,
67
+ ].join('\n') + '\n';
68
+ }
69
+
70
+ export function runGatewayGateCli({ argv = process.argv.slice(2), stdout = process.stdout, stderr = process.stderr } = {}) {
71
+ try {
72
+ const options = parseArgs(argv);
73
+ if (options.help) {
74
+ stdout.write(usage());
75
+ return null;
76
+ }
77
+ const evidence = options.input ? readEvidence(options.input) : emptyEvidence();
78
+ const report = evaluateGatewayGate({ evidence });
79
+ stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : formatGatewayGate(report));
80
+ return report;
81
+ } catch (error) {
82
+ stderr.write(`sando context gateway-gate: ${error instanceof Error ? error.message : String(error)}\n${usage()}`);
83
+ process.exitCode = 2;
84
+ return null;
85
+ }
86
+ }
87
+
88
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) runGatewayGateCli();