@ciphyrshq/sdk 2.6.0 → 3.0.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/src/index.js CHANGED
@@ -9,5 +9,19 @@ export {
9
9
  CiphyrsJobTimeoutError,
10
10
  } from './errors.js';
11
11
  export { CiphyrsTracer, Trace, Span } from './tracer.js';
12
+ // 2.7 — cross-process trace propagation: what makes agents in separate
13
+ // processes appear as one connected topology instead of isolated nodes.
14
+ export {
15
+ inject, extract, withRemoteContext, currentContext,
16
+ instrumentFetch, instrumentHttp, autoInstrument,
17
+ expressMiddleware, fastifyPlugin, withPropagation,
18
+ w3cTraceId, w3cSpanId, TRACEPARENT, BAGGAGE,
19
+ } from './propagation.js';
20
+ // activeSpanKind is exported alongside the ids because "which span am I in?"
21
+ // is not answerable from the ids alone, and the answer decides whether a span
22
+ // id may be reported to the tool gate at all — see protect-tool.js.
23
+ export { activeIds, activeAgent, activeSpanKind, remoteParent } from './context.js';
12
24
  export { SecretDetector } from './secret-detector.js';
13
25
  export { EvalRunner } from './eval-runner.js';
26
+ // V60 — agentic governance: tool-call authorization
27
+ export { protectTool, ToolBlocked, ToolApprovalTimeout } from './protect-tool.js';
@@ -0,0 +1,108 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // The test suite may not open a socket. Enforced here, not by convention.
3
+ //
4
+ // WHY. fail-posture.test.js built a real CiphyrsClient — whose baseUrl
5
+ // defaults to https://www.ciphyrs.com — and wrapped tools with protectTool,
6
+ // which queues a tool-inventory announce on a 2s debounce. Every `npm test`
7
+ // run therefore made two real POSTs to the PRODUCTION announce endpoint with
8
+ // a fake API key, and the last test in that file swapped global fetch and
9
+ // restored it, so the debounce fired on the real fetch after the restore. No
10
+ // amount of stubbing in the right order fixes that: the announce is timed,
11
+ // and the test that "protects" it has already finished.
12
+ //
13
+ // So the guard sits BELOW fetch, at the socket, and is installed when this
14
+ // module is imported — before any test body runs. Whatever a test does to
15
+ // globalThis.fetch, whichever library it reaches for, and whenever a stray
16
+ // timer fires, the connection does not happen and the run fails.
17
+ //
18
+ // Import it for side effect at the top of every test file:
19
+ //
20
+ // import './no-network.test-helper.js'
21
+ //
22
+ // `node --test` runs each test file in its own process, so each file needs
23
+ // its own import; a file without one is unguarded.
24
+ //
25
+ // Not part of the published API. It lives in src/ because src/*.test.js does,
26
+ // and it is deliberately NOT named *.test.js so the runner does not treat it
27
+ // as a suite of its own.
28
+ // ═══════════════════════════════════════════════════════════════════════════
29
+ import net from 'node:net'
30
+ import tls from 'node:tls'
31
+
32
+ /** Every blocked attempt, in order. Emptied only by expectBlocked(). */
33
+ const attempts = []
34
+
35
+ // net.connect() normalises its arguments before calling Socket.prototype.connect,
36
+ // so what arrives is `[[{ host, port }, cb]]` for a URL-shaped dial and
37
+ // `(port, host)` for a direct socket.connect — both are unwrapped here so the
38
+ // failure message names the host a test was reaching for.
39
+ const describeTarget = (arg, second) => {
40
+ if (Array.isArray(arg)) return describeTarget(arg[0], arg[1])
41
+ if (arg && typeof arg === 'object') return `${arg.host || arg.path || '?'}:${arg.port ?? '?'}`
42
+ if (typeof arg === 'number') return `${typeof second === 'string' ? second : 'localhost'}:${arg}`
43
+ return String(arg)
44
+ }
45
+
46
+ function refuse(via, ...args) {
47
+ const target = describeTarget(args.length > 1 ? args : args[0])
48
+ const message =
49
+ `[no-network] a test tried to open a socket to ${target} (${via}). ` +
50
+ 'Tests must never reach the network: stub the client method, or point the client at a fake. ' +
51
+ 'If a real CiphyrsClient is needed, give it a sentinel baseUrl and neutralise _announceTools.'
52
+ attempts.push(message)
53
+ // Throwing is not enough on its own. The tool-inventory announce is
54
+ // best-effort and swallows its own errors (`.catch(() => {})`), and a timer
55
+ // that fires between tests belongs to no test, so an exception there would
56
+ // be invisible. The process exit code is what the suite cannot swallow —
57
+ // see the exit handler below.
58
+ throw new Error(message)
59
+ }
60
+
61
+ net.Socket.prototype.connect = function blockedConnect(...args) {
62
+ refuse('net.Socket.connect', ...args)
63
+ }
64
+
65
+ // TLSSocket inherits the patched connect above, so this is belt and braces for
66
+ // the paths that hand tls.connect an already-open socket.
67
+ tls.connect = function blockedTlsConnect(...args) {
68
+ refuse('tls.connect', ...args)
69
+ }
70
+
71
+ process.on('exit', () => {
72
+ if (!attempts.length) return
73
+ console.error(`\n[no-network] ${attempts.length} network attempt(s) were blocked during this test file:`)
74
+ for (const a of attempts) console.error(` - ${a}`)
75
+ // A test that opened a socket may still have "passed" — the announce that
76
+ // started this swallowed its own failure. Failing the process is what makes
77
+ // the run red: node --test reports a non-zero subprocess as a failed file.
78
+ process.exitCode = 1
79
+ })
80
+
81
+ /**
82
+ * The attempts blocked so far. For assertions; do not mutate.
83
+ */
84
+ export function networkAttempts() {
85
+ return [...attempts]
86
+ }
87
+
88
+ /**
89
+ * Run `fn`, assert it was stopped by this guard, and un-record the attempt so
90
+ * the deliberate one does not fail the run. This is how the guard proves it
91
+ * still works — a guard nothing exercises is a guard that quietly stops
92
+ * guarding.
93
+ *
94
+ * @returns {string} the message the guard produced
95
+ */
96
+ export function expectBlocked(fn) {
97
+ const before = attempts.length
98
+ let err
99
+ try { fn() } catch (e) { err = e }
100
+ const recorded = attempts.splice(before)
101
+ if (!recorded.length) {
102
+ throw new Error('[no-network] expected the guard to block this call, but it went through')
103
+ }
104
+ if (!err || !/no-network/.test(err.message)) {
105
+ throw new Error(`[no-network] the guard recorded the attempt but did not throw for it (got: ${err?.message})`)
106
+ }
107
+ return recorded[0]
108
+ }
@@ -0,0 +1,388 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Trace-context propagation between agents (W3C `traceparent` + `baggage`)
3
+ //
4
+ // WHY THIS EXISTS. The topology graph is derived on the server: an edge
5
+ // A → B is drawn when a span of agent B names a span of agent A as its
6
+ // parent. Inside one process the SDK's AsyncLocalStorage provides that.
7
+ // Across processes nothing did — the SDK never wrote `traceparent` on an
8
+ // outbound call and never read it on an inbound one — so two agents that
9
+ // talked to each other constantly rendered as two disconnected nodes.
10
+ //
11
+ // Outbound: `instrumentFetch()` wraps global fetch and `instrumentHttp()`
12
+ // wraps node:http / node:https request+get, which together cover fetch,
13
+ // axios, node-fetch, got and superagent. Calls to the Ciphyrs API itself are
14
+ // never decorated (registerInternalOrigin, called by CiphyrsClient).
15
+ //
16
+ // Inbound: `expressMiddleware()`, `fastifyPlugin()` and the generic
17
+ // `withRemoteContext(headers, fn)` activate the caller's trace for the
18
+ // duration of a request. `tracer.trace()` then continues that trace and its
19
+ // first span is parented to the caller's span, so the edge exists as soon as
20
+ // both spans arrive, in either order (server-side V143).
21
+ //
22
+ // Anything else (queues, gRPC, a framework not listed): `inject(headers)`
23
+ // when you send, `withRemoteContext(headers, fn)` when you receive.
24
+ //
25
+ // ID SHAPE. `traceparent` requires 32-hex trace ids and 16-hex span ids.
26
+ // Since 2.7 the tracer generates ids in exactly that shape, so the header
27
+ // carries the REAL ids and an OpenTelemetry-instrumented peer joins the same
28
+ // trace. Ids that are not hex (one you supplied, or one from an older SDK)
29
+ // are hashed into the W3C fields while the exact originals travel in
30
+ // `baggage`, so a Ciphyrs peer still links precisely.
31
+ // ═══════════════════════════════════════════════════════════════════════════
32
+ import { createHash } from 'node:crypto';
33
+ import { createRequire } from 'node:module';
34
+ import { activeAgent, remoteParent, remoteStorage, spanStorage, traceStorage } from './context.js';
35
+
36
+ export const TRACEPARENT = 'traceparent';
37
+ export const BAGGAGE = 'baggage';
38
+
39
+ const B_TRACE = 'ciphyrs.trace_id';
40
+ const B_SPAN = 'ciphyrs.span_id';
41
+ const B_AGENT = 'ciphyrs.agent';
42
+ const B_PROJECT = 'ciphyrs.project';
43
+
44
+ const HEX32 = /^[0-9a-f]{32}$/;
45
+ const HEX16 = /^[0-9a-f]{16}$/;
46
+ const TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
47
+
48
+ const ZERO32 = '0'.repeat(32);
49
+ const ZERO16 = '0'.repeat(16);
50
+
51
+ let defaultProject;
52
+ /** Set by CiphyrsTracer so baggage names the caller's project. */
53
+ export function setDefaultProject(name) { defaultProject = name || undefined; }
54
+
55
+ // Origins that belong to Ciphyrs itself. Calls there are the SDK reporting
56
+ // telemetry, not the agent doing work, and must not carry trace headers.
57
+ const internalOrigins = new Set(['https://www.ciphyrs.com', 'https://ciphyrs.com']);
58
+ export function registerInternalOrigin(url) {
59
+ try { internalOrigins.add(new URL(url).origin); } catch { /* not a URL — ignore */ }
60
+ }
61
+ function isInternal(url) {
62
+ try { return internalOrigins.has(new URL(url).origin); } catch { return false; }
63
+ }
64
+
65
+ // ── W3C field shaping ──────────────────────────────────────────────────────
66
+ export function w3cTraceId(traceId) {
67
+ const t = String(traceId).toLowerCase();
68
+ if (HEX32.test(t)) return t;
69
+ return createHash('sha256').update(String(traceId)).digest('hex').slice(0, 32);
70
+ }
71
+ export function w3cSpanId(spanId) {
72
+ const s = String(spanId).toLowerCase();
73
+ if (HEX16.test(s)) return s;
74
+ return createHash('sha256').update(String(spanId)).digest('hex').slice(0, 16);
75
+ }
76
+
77
+ // ── Outbound ───────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * The context an outbound call should carry: the local span if we are in one,
81
+ * else the trace, else the remote parent we are serving (pass-through).
82
+ */
83
+ export function currentContext() {
84
+ const span = spanStorage.getStore();
85
+ if (span?.trace_id) {
86
+ return { trace_id: span.trace_id, span_id: span.span_id, agent_name: span.agent_name, project: defaultProject };
87
+ }
88
+ const trace = traceStorage.getStore();
89
+ if (trace?.trace_id) {
90
+ return { trace_id: trace.trace_id, span_id: undefined, agent_name: activeAgent(), project: defaultProject };
91
+ }
92
+ const remote = remoteParent();
93
+ if (remote?.trace_id) {
94
+ return {
95
+ trace_id: remote.trace_id, span_id: remote.span_id,
96
+ agent_name: activeAgent() || remote.agent_name,
97
+ project: defaultProject || remote.project,
98
+ };
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ function mergeBaggage(existing, items) {
104
+ const kept = [];
105
+ for (const member of String(existing || '').split(',')) {
106
+ const m = member.trim();
107
+ if (!m) continue;
108
+ const key = m.split('=', 1)[0].trim();
109
+ if (key in items) continue; // ours — replaced, never duplicated
110
+ kept.push(m);
111
+ }
112
+ for (const [k, v] of Object.entries(items)) kept.push(`${k}=${encodeURIComponent(v)}`);
113
+ return kept.join(',');
114
+ }
115
+
116
+ function headerValues(ctx, existingBaggage) {
117
+ const spanHex = ctx.span_id ? w3cSpanId(ctx.span_id) : `${'0'.repeat(15)}1`;
118
+ const items = { [B_TRACE]: ctx.trace_id };
119
+ if (ctx.span_id) items[B_SPAN] = ctx.span_id;
120
+ if (ctx.agent_name) items[B_AGENT] = ctx.agent_name;
121
+ if (ctx.project) items[B_PROJECT] = ctx.project;
122
+ return {
123
+ [TRACEPARENT]: `00-${w3cTraceId(ctx.trace_id)}-${spanHex}-01`,
124
+ [BAGGAGE]: mergeBaggage(existingBaggage, items),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Add `traceparent` and `baggage` for the active context.
130
+ *
131
+ * Accepts (and returns) a plain object or a Headers/fetch-style object with
132
+ * get/set. Outside a trace it is a no-op, so it is safe to call always:
133
+ *
134
+ * await fetch(url, { headers: inject({ 'content-type': 'application/json' }) })
135
+ */
136
+ export function inject(headers = {}) {
137
+ const ctx = currentContext();
138
+ if (!ctx) return headers;
139
+ const settable = typeof headers?.set === 'function';
140
+ const existing = settable
141
+ ? headers.get?.(BAGGAGE)
142
+ : headers[BAGGAGE] ?? headers[BAGGAGE.toUpperCase()] ?? headers.Baggage;
143
+ const values = headerValues(ctx, existing);
144
+ for (const [k, v] of Object.entries(values)) {
145
+ if (settable) headers.set(k, v); else headers[k] = v;
146
+ }
147
+ return headers;
148
+ }
149
+
150
+ // ── Inbound ────────────────────────────────────────────────────────────────
151
+
152
+ function lookup(headers) {
153
+ const out = {};
154
+ if (!headers) return out;
155
+ const want = new Set([TRACEPARENT, BAGGAGE]);
156
+ const take = (k, v) => {
157
+ if (v == null) return;
158
+ let key = String(k).toLowerCase();
159
+ if (key.startsWith('http_')) key = key.slice(5).replace(/_/g, '-'); // CGI-style
160
+ if (want.has(key)) out[key] = Array.isArray(v) ? v[0] : String(v);
161
+ };
162
+ if (typeof headers.get === 'function') { // Headers / fetch Request
163
+ for (const key of want) take(key, headers.get(key));
164
+ return out;
165
+ }
166
+ if (typeof headers.forEach === 'function' && !Array.isArray(headers)) { // Map-like
167
+ headers.forEach((v, k) => take(k, v));
168
+ return out;
169
+ }
170
+ if (Array.isArray(headers)) { // [[k, v], …]
171
+ for (const pair of headers) if (Array.isArray(pair)) take(pair[0], pair[1]);
172
+ return out;
173
+ }
174
+ for (const [k, v] of Object.entries(headers)) take(k, v); // node req.headers
175
+ return out;
176
+ }
177
+
178
+ function parseBaggage(raw) {
179
+ const out = {};
180
+ for (const member of String(raw || '').split(',')) {
181
+ const m = member.trim();
182
+ if (!m || !m.includes('=')) continue;
183
+ const kv = m.split(';', 1)[0];
184
+ const idx = kv.indexOf('=');
185
+ const k = kv.slice(0, idx).trim();
186
+ if (k) out[k] = decodeURIComponent(kv.slice(idx + 1).trim());
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * Read the caller's trace context from inbound headers. Returns undefined
193
+ * when nothing usable is present. Ciphyrs baggage (exact ids) wins over
194
+ * `traceparent` (hashed/hex ids).
195
+ */
196
+ export function extract(headers) {
197
+ const h = lookup(headers);
198
+ if (!Object.keys(h).length) return undefined;
199
+ const bag = parseBaggage(h[BAGGAGE]);
200
+ let traceId = bag[B_TRACE] || undefined;
201
+ let spanId = bag[B_SPAN] || undefined;
202
+ let sampled = true;
203
+ const tp = h[TRACEPARENT];
204
+ if (tp) {
205
+ const m = TRACEPARENT_RE.exec(String(tp).trim().toLowerCase());
206
+ if (m) {
207
+ const [, , tpTrace, tpSpan, flags] = m;
208
+ if (tpTrace !== ZERO32) {
209
+ traceId = traceId || tpTrace;
210
+ spanId = spanId || (tpSpan !== ZERO16 ? tpSpan : undefined);
211
+ }
212
+ const f = parseInt(flags, 16);
213
+ sampled = Number.isNaN(f) ? true : Boolean(f & 0x01);
214
+ }
215
+ }
216
+ if (!traceId) return undefined;
217
+ return {
218
+ trace_id: traceId,
219
+ span_id: spanId,
220
+ agent_name: bag[B_AGENT] || undefined,
221
+ project: bag[B_PROJECT] || undefined,
222
+ sampled,
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Run `fn` as the continuation of a caller's trace:
228
+ *
229
+ * await withRemoteContext(message.headers, async () => {
230
+ * const t = tracer.trace('handle job'); // continues the trace
231
+ * await t.span('worker-agent').run(async () => {}) // parented to the caller
232
+ * })
233
+ *
234
+ * Accepts headers in any shape `extract` takes, or a ready context object.
235
+ * With nothing usable it simply runs `fn`.
236
+ */
237
+ export function withRemoteContext(headersOrCtx, fn) {
238
+ const ctx = headersOrCtx && headersOrCtx.trace_id ? headersOrCtx : extract(headersOrCtx);
239
+ if (!ctx) return fn();
240
+ return remoteStorage.run(ctx, fn);
241
+ }
242
+
243
+ // ── Client instrumentation ─────────────────────────────────────────────────
244
+
245
+ let fetchPatched = false;
246
+ /** Wrap global fetch so calls made inside a span carry the headers. Idempotent. */
247
+ export function instrumentFetch() {
248
+ if (fetchPatched || typeof globalThis.fetch !== 'function') return false;
249
+ const original = globalThis.fetch;
250
+ globalThis.fetch = function ciphyrsFetch(input, init = {}) {
251
+ try {
252
+ const url = typeof input === 'string' ? input : (input?.url ?? String(input));
253
+ if (!init?.ciphyrsInternal && !isInternal(url) && currentContext()) {
254
+ // A Request object's headers are immutable through init, so rebuild.
255
+ if (typeof Request !== 'undefined' && input instanceof Request) {
256
+ const headers = new Headers(input.headers);
257
+ inject(headers);
258
+ return original.call(this, new Request(input, { headers }), init);
259
+ }
260
+ const headers = init.headers instanceof Headers ? init.headers : new Headers(init.headers || {});
261
+ inject(headers);
262
+ return original.call(this, input, { ...init, headers });
263
+ }
264
+ } catch { /* never break the customer's call */ }
265
+ return original.call(this, input, init);
266
+ };
267
+ globalThis.fetch.__ciphyrsOriginal = original;
268
+ fetchPatched = true;
269
+ return true;
270
+ }
271
+
272
+ let httpPatched = false;
273
+ /**
274
+ * Wrap node:http / node:https request+get, which is how axios, node-fetch,
275
+ * got and superagent actually send. Idempotent; returns false if already done.
276
+ */
277
+ export function instrumentHttp() {
278
+ if (httpPatched) return false;
279
+ let http, https;
280
+ try {
281
+ // A static `import` of node:http would be evaluated in every environment
282
+ // that loads this module, including browsers/bundlers where it does not
283
+ // exist. createRequire keeps the dependency lazy and Node-only.
284
+ const req = createRequire(import.meta.url);
285
+ http = req('node:http');
286
+ https = req('node:https');
287
+ } catch {
288
+ return false; // not Node, or no CJS resolver
289
+ }
290
+ const patch = (mod, name, scheme) => {
291
+ const original = mod[name];
292
+ if (typeof original !== 'function' || original.__ciphyrs) return;
293
+ const wrapped = function ciphyrsRequest(...args) {
294
+ try {
295
+ const ctx = currentContext();
296
+ if (ctx) {
297
+ // Signatures: (url[, options][, cb]) and (options[, cb]).
298
+ let urlStr = null;
299
+ let optIdx = -1;
300
+ if (typeof args[0] === 'string' || args[0] instanceof URL) {
301
+ urlStr = String(args[0]);
302
+ if (args[1] && typeof args[1] === 'object') optIdx = 1;
303
+ } else if (args[0] && typeof args[0] === 'object') {
304
+ optIdx = 0;
305
+ const o = args[0];
306
+ const host = o.host || o.hostname || 'localhost';
307
+ urlStr = `${o.protocol || scheme}//${host}${o.path || '/'}`;
308
+ }
309
+ if (!isInternal(urlStr)) {
310
+ if (optIdx === -1) {
311
+ // No options object to carry headers — add one.
312
+ const opts = {};
313
+ inject(opts.headers = {});
314
+ args.splice(typeof args[1] === 'function' ? 1 : args.length, 0, opts);
315
+ } else {
316
+ const opts = args[optIdx];
317
+ opts.headers = opts.headers || {};
318
+ inject(opts.headers);
319
+ }
320
+ }
321
+ }
322
+ } catch { /* never break the customer's call */ }
323
+ return original.apply(this, args);
324
+ };
325
+ wrapped.__ciphyrs = true;
326
+ wrapped.__ciphyrsOriginal = original;
327
+ mod[name] = wrapped;
328
+ };
329
+ patch(http, 'request', 'http:');
330
+ patch(http, 'get', 'http:');
331
+ patch(https, 'request', 'https:');
332
+ patch(https, 'get', 'https:');
333
+ httpPatched = true;
334
+ return true;
335
+ }
336
+
337
+ /** Instrument every supported outbound client. Called by CiphyrsTracer. */
338
+ export function autoInstrument() {
339
+ return { fetch: instrumentFetch(), http: instrumentHttp() };
340
+ }
341
+
342
+ // ── Server integrations ────────────────────────────────────────────────────
343
+
344
+ /**
345
+ * Express / Connect middleware:
346
+ *
347
+ * app.use(expressMiddleware())
348
+ */
349
+ export function expressMiddleware() {
350
+ return function ciphyrsPropagation(req, res, next) {
351
+ const ctx = extract(req.headers);
352
+ if (!ctx) return next();
353
+ return remoteStorage.run(ctx, () => next());
354
+ };
355
+ }
356
+
357
+ /**
358
+ * Fastify plugin:
359
+ *
360
+ * await app.register(fastifyPlugin)
361
+ *
362
+ * Uses an onRequest hook wrapped around the rest of the lifecycle via
363
+ * AsyncLocalStorage.run, which Fastify propagates to handlers.
364
+ */
365
+ export async function fastifyPlugin(app) {
366
+ app.addHook('onRequest', (req, reply, done) => {
367
+ const ctx = extract(req.headers);
368
+ if (!ctx) return done();
369
+ return remoteStorage.run(ctx, () => done());
370
+ });
371
+ }
372
+ fastifyPlugin[Symbol.for('skip-override')] = true;
373
+
374
+ /**
375
+ * Raw node http handler wrapper:
376
+ *
377
+ * http.createServer(withPropagation((req, res) => { … }))
378
+ */
379
+ export function withPropagation(handler) {
380
+ return function ciphyrsHandler(req, res) {
381
+ const ctx = extract(req.headers);
382
+ if (!ctx) return handler(req, res);
383
+ return remoteStorage.run(ctx, () => handler(req, res));
384
+ };
385
+ }
386
+
387
+ /** @internal — exported for tests. */
388
+ export const _parseBaggage = parseBaggage;