@ciphyrshq/sdk 2.6.0 → 3.0.1

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/README.md CHANGED
@@ -189,6 +189,137 @@ Full type definitions included via `types.d.ts`. Import types directly:
189
189
  import type { MaskResult, RestoreResult, CiphyrsClientOptions } from '@ciphyrshq/sdk'
190
190
  ```
191
191
 
192
+ ## Monitoring agents you already deployed (2.7)
193
+
194
+ An agent running on your own infrastructure — Oracle, AWS, Azure, GCP, on-prem
195
+ — needs nothing from us but outbound HTTPS and an API key. Two things used to
196
+ require code you had to remember to write; both are now automatic.
197
+
198
+ ### Agents in separate processes appear as one connected system
199
+
200
+ The topology graph is *derived*: an edge A → B exists because a span of agent B
201
+ names a span of agent A as its parent. Inside one process the SDK tracks that
202
+ for you. Across processes it used to need hand-threaded headers, and without
203
+ them two agents that talked constantly rendered as two disconnected dots.
204
+
205
+ Outbound calls made inside a span now carry W3C `traceparent` and `baggage`
206
+ automatically (`fetch`, and `node:http`/`node:https`, which covers axios,
207
+ node-fetch, got and superagent):
208
+
209
+ ```js
210
+ import { CiphyrsClient, CiphyrsTracer } from '@ciphyrshq/sdk';
211
+
212
+ const client = new CiphyrsClient({ apiKey: process.env.CIPHYRS_API_KEY });
213
+ const tracer = new CiphyrsTracer(client, { projectName: 'orders', agentName: 'RouterAgent' });
214
+
215
+ const t = tracer.trace('handle order');
216
+ await t.span('RouterAgent', { agentName: 'RouterAgent' }).run(async () => {
217
+ // headers are added for you — no options to pass
218
+ await fetch('https://billing.internal/charge', { method: 'POST', body });
219
+ });
220
+ ```
221
+
222
+ On the receiving side, one line activates the caller's trace for the request:
223
+
224
+ ```js
225
+ import express from 'express';
226
+ import { expressMiddleware } from '@ciphyrshq/sdk';
227
+
228
+ const app = express();
229
+ app.use(expressMiddleware()); // Fastify: await app.register(fastifyPlugin)
230
+
231
+ app.post('/charge', async (req, res) => {
232
+ const t = tracer.trace('charge'); // continues the caller's trace
233
+ await t.span('BillingAgent', { agentName: 'BillingAgent' }).run(async () => { /* ... */ });
234
+ res.json({ ok: true });
235
+ });
236
+ ```
237
+
238
+ That is all the edge needs. `t.isContinuation` is true, `t.callerAgent` names
239
+ who called, and the first span is parented to the caller's span.
240
+
241
+ Queues, gRPC, or a framework not listed — two functions:
242
+
243
+ ```js
244
+ import { inject, withRemoteContext } from '@ciphyrshq/sdk';
245
+
246
+ await queue.send({ body, headers: inject({}) }); // producer
247
+ await withRemoteContext(msg.headers, async () => { /* ... */ }); // consumer
248
+ ```
249
+
250
+ Peers instrumented with plain OpenTelemetry interoperate: ids are generated in
251
+ W3C shape (32/16 hex), so a non-Ciphyrs service joins the same trace.
252
+
253
+ Set `propagate: false` on the tracer to opt out. Calls to the Ciphyrs API
254
+ itself are never decorated.
255
+
256
+ ### Spans nest without bookkeeping
257
+
258
+ A span created while another is open is its child, so the graph has edges even
259
+ inside one process:
260
+
261
+ ```js
262
+ const t = tracer.trace('run');
263
+ const outer = t.span('Router', { agentName: 'Router' });
264
+ const inner = t.span('Billing', { agentName: 'Billing' }); // child of Router
265
+ inner.end();
266
+ outer.end();
267
+ ```
268
+
269
+ `span.run(fn)` is the scoped form and is what to prefer — it ends the span even
270
+ if `fn` throws, and records the error on it. Pass an explicit `parentSpanId` to
271
+ override, or `{ scoped: true }` to opt out of ambient context entirely.
272
+
273
+ ### Health is reported, and can be observed
274
+
275
+ Heartbeats are on by default and carry the interval they beat at, so the
276
+ platform sizes each agent's "down" window to that agent rather than applying
277
+ one global threshold to a fleet whose agents beat at very different rates. They
278
+ also ship process metrics (RSS, heap, event-loop lag, CPU, error rate), so the
279
+ fleet can show **degraded** before **down**:
280
+
281
+ ```js
282
+ const tracer = new CiphyrsTracer(client, {
283
+ projectName: 'orders',
284
+ agentName: 'RouterAgent', // visible in the fleet before any traffic
285
+ heartbeatIntervalMs: 60_000, // 0 disables
286
+ heartbeatMetrics: true,
287
+ });
288
+ ```
289
+
290
+ For a real readiness signal, run a probe each tick:
291
+
292
+ ```js
293
+ client.startHealthMonitor({
294
+ agentName: 'RouterAgent',
295
+ probe: async () => {
296
+ const t0 = Date.now();
297
+ await db.ping();
298
+ return { status: 'up', latencyMs: Date.now() - t0 };
299
+ },
300
+ });
301
+ ```
302
+
303
+ And to make `down` something Ciphyrs *observed* rather than inferred from
304
+ silence, register a URL the platform polls:
305
+
306
+ ```js
307
+ await client.setAgentMonitoring(agentId, {
308
+ probeUrl: 'https://agent.example.com/healthz',
309
+ heartbeatTimeoutS: 120,
310
+ });
311
+ ```
312
+
313
+ Private, loopback and cloud-metadata addresses are refused by the prober and
314
+ surface as `probe_status: 'blocked'` — a misconfiguration, never an outage.
315
+
316
+ ### If you cannot propagate headers
317
+
318
+ The platform also infers edges from shared traces, correlation ids and shared
319
+ sessions. Those render dashed with a confidence score, and an operator can
320
+ confirm or dismiss them. A real propagated edge always wins over an inferred
321
+ one, so wiring up the middleware above upgrades them automatically.
322
+
192
323
  ## License
193
324
 
194
325
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphyrshq/sdk",
3
- "version": "2.6.0",
3
+ "version": "3.0.1",
4
4
  "description": "Official JavaScript / TypeScript SDK for the Ciphyrs PII Shield API",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -11,20 +11,38 @@
11
11
  "types": "./types.d.ts"
12
12
  },
13
13
  "./tracer": "./src/tracer.js",
14
+ "./propagation": "./src/propagation.js",
15
+ "./context": "./src/context.js",
14
16
  "./secret-detector": "./src/secret-detector.js",
15
17
  "./eval-runner": "./src/eval-runner.js"
16
18
  },
17
- "files": ["src", "types.d.ts"],
18
- "keywords": ["pii", "privacy", "masking", "llm", "ciphyrs", "gdpr", "dpdp", "sdk"],
19
+ "files": [
20
+ "src",
21
+ "!src/*.test.js",
22
+ "!src/*.test-helper.js",
23
+ "types.d.ts"
24
+ ],
25
+ "keywords": [
26
+ "pii",
27
+ "privacy",
28
+ "masking",
29
+ "llm",
30
+ "ciphyrs",
31
+ "gdpr",
32
+ "dpdp",
33
+ "sdk"
34
+ ],
19
35
  "license": "MIT",
20
- "engines": { "node": ">=18" },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
21
39
  "repository": {
22
40
  "type": "git",
23
- "url": "https://github.com/praveen190/Ciphyrs.git",
41
+ "url": "git+https://github.com/praveen190/Ciphyrs.git",
24
42
  "directory": "packages/sdk"
25
43
  },
26
44
  "homepage": "https://ciphyrs.com",
27
45
  "scripts": {
28
- "test": "node --test src/**/*.test.js"
46
+ "test": "node --test src/*.test.js"
29
47
  }
30
48
  }
package/src/client.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { registerInternalOrigin } from './propagation.js';
2
+ import { resolveFailOpen } from './fail-posture.js';
1
3
  import {
2
4
  CiphyrsError,
3
5
  CiphyrsAuthError,
@@ -14,10 +16,50 @@ const DASH_BASE_URL = 'https://www.ciphyrs.com';
14
16
  const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
15
17
  const MAX_RETRIES = 3;
16
18
  const BASE_DELAY_MS = 500;
19
+ // A server-supplied Retry-After bounds one sleep; without a cap a
20
+ // "Retry-After: 3600" from a plan-limit response parked the agent's thread
21
+ // for an hour. Same cap as the Python SDK.
22
+ const MAX_RETRY_AFTER_MS = 60_000;
23
+
24
+ // GUARD CALLS ARE DIFFERENT. /v1/guard/check and /v1/guard/tool-check are
25
+ // synchronous decisions inside the agent's turn: the model is waiting, the
26
+ // customer is waiting, and a tool the policy meant to refuse is not running
27
+ // until the verdict lands. The general ladder — four attempts on the
28
+ // caller's full timeout — was written for calls that may safely be slow;
29
+ // applied to a verdict it turned a slow gateway into a request that hung
30
+ // for minutes and died with no reply (BFSI demo, 25 Sep 2026, Python SDK —
31
+ // this client had the same ladder). Guard paths get one quick retry, a
32
+ // short per-attempt timeout and a wall-clock budget; the fail posture then
33
+ // decides at once, as documented. Same numbers as ciphyrs 4.1.1 (Python).
34
+ const GUARD_PATH = '/v1/guard/';
35
+ const GUARD_MAX_RETRIES = 1;
36
+ const GUARD_ATTEMPT_TIMEOUT_MS = 6_000;
37
+ const GUARD_TOTAL_BUDGET_MS = 10_000;
38
+
39
+ function isGuardUrl(url) {
40
+ try { return new URL(String(url)).pathname.startsWith(GUARD_PATH); }
41
+ catch { return String(url).includes(GUARD_PATH); }
42
+ }
43
+
44
+ // A 5xx whose body already carries the gateway's final verdict (fail_open or
45
+ // fail_closed) is an answer, not an outage: retrying it asks the same
46
+ // question again and gets the same deliberate answer, three times, with
47
+ // backoff. The Python SDK stopped doing that on 13 Sep 2026; this one had
48
+ // not.
49
+ function carriesFinalVerdict(data) {
50
+ return !!data && typeof data === 'object' && (data.fail_open === true || data.fail_closed === true);
51
+ }
17
52
 
18
53
  // ── Internal HTTP helper with auto-retry ────────────────────────────────────────
19
54
  async function request(url, { method = 'GET', headers = {}, body, timeout = 10_000, maxRetries = MAX_RETRIES } = {}) {
20
55
  let lastErr;
56
+ const guard = isGuardUrl(url);
57
+ if (guard) {
58
+ maxRetries = Math.min(maxRetries, GUARD_MAX_RETRIES);
59
+ timeout = Math.min(timeout, GUARD_ATTEMPT_TIMEOUT_MS);
60
+ }
61
+ const started = Date.now();
62
+ const budgetLeft = () => !guard || (Date.now() - started) < GUARD_TOTAL_BUDGET_MS;
21
63
 
22
64
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
23
65
  let res;
@@ -27,15 +69,18 @@ async function request(url, { method = 'GET', headers = {}, body, timeout = 10_0
27
69
  signal: AbortSignal.timeout(timeout),
28
70
  headers: { 'Content-Type': 'application/json', ...headers },
29
71
  body: body !== undefined ? JSON.stringify(body) : undefined,
72
+ // Telemetry the SDK sends is not the agent doing work: the
73
+ // propagation patch must not decorate it with trace headers.
74
+ ciphyrsInternal: true,
30
75
  });
31
76
  } catch (err) {
32
77
  if (err.name === 'TimeoutError' || err.name === 'AbortError') {
33
78
  lastErr = new CiphyrsTimeoutError();
34
- if (attempt < maxRetries) { await sleep(backoff(attempt)); continue; }
79
+ if (attempt < maxRetries && budgetLeft()) { await sleep(backoff(attempt)); continue; }
35
80
  throw lastErr;
36
81
  }
37
82
  lastErr = new CiphyrsError(err.message);
38
- if (attempt < maxRetries) { await sleep(backoff(attempt)); continue; }
83
+ if (attempt < maxRetries && budgetLeft()) { await sleep(backoff(attempt)); continue; }
39
84
  throw lastErr;
40
85
  }
41
86
 
@@ -44,13 +89,18 @@ async function request(url, { method = 'GET', headers = {}, body, timeout = 10_0
44
89
  if (!res.ok) {
45
90
  const msg = data?.error || `Request failed (${res.status})`;
46
91
 
47
- // Retry on transient errors
48
- if (RETRYABLE_STATUSES.has(res.status) && attempt < maxRetries) {
92
+ // Retry on transient errors — unless the body is already the verdict.
93
+ if (RETRYABLE_STATUSES.has(res.status) && attempt < maxRetries && !carriesFinalVerdict(data) && budgetLeft()) {
49
94
  const retryAfter = res.headers.get('retry-after');
50
- const delay = retryAfter ? parseInt(retryAfter) * 1000 : backoff(attempt);
95
+ const parsed = retryAfter ? parseInt(retryAfter, 10) * 1000 : NaN;
96
+ const delay = Number.isFinite(parsed) && parsed >= 0 ? Math.min(parsed, MAX_RETRY_AFTER_MS) : backoff(attempt);
51
97
  await sleep(delay);
52
98
  continue;
53
99
  }
100
+ // A 5xx that carries the gateway's fail-posture decision is returned
101
+ // as data, the way a 200 verdict is: the caller's guard/protectTool
102
+ // code reads fail_open / fail_closed and acts on it.
103
+ if (carriesFinalVerdict(data)) return data;
54
104
 
55
105
  if (res.status === 401) throw new CiphyrsAuthError(msg);
56
106
  if (res.status === 403) throw new CiphyrsPermissionError(msg);
@@ -78,12 +128,13 @@ function sleep(ms) {
78
128
  // Used by: developers integrating Ciphyrs into their LLM pipelines
79
129
  // ─────────────────────────────────────────────────────────────────────────────
80
130
  class ScanResource {
81
- #headers; #base; #timeout;
131
+ #headers; #base; #timeout; #failOpen;
82
132
 
83
- constructor(headers, base, timeout) {
133
+ constructor(headers, base, timeout, failOpen = false) {
84
134
  this.#headers = headers;
85
135
  this.#base = base;
86
136
  this.#timeout = timeout;
137
+ this.#failOpen = failOpen;
87
138
  }
88
139
 
89
140
  /**
@@ -192,6 +243,18 @@ class ScanResource {
192
243
  * Defaults to true (data minimisation);
193
244
  * set false to keep the session alive
194
245
  * for follow-up turns in a conversation.
246
+ * @param {boolean} [opts.failOpen] — What to do when the masker is
247
+ * unreachable. DEFAULT FALSE: the call
248
+ * throws and your LLM is never given
249
+ * the raw text. `true` sends the RAW,
250
+ * UNMASKED input to your LLM and
251
+ * returns it unrestored with
252
+ * `failedOpen: true` — real PII
253
+ * egress, so only opt in knowingly.
254
+ * Falls back to the client-wide
255
+ * `failOpen`. Same switch, same
256
+ * precedence, as guard.wrap and
257
+ * protectTool.
195
258
  * @returns {Promise<{
196
259
  * output: string, // Unmasked LLM response (return THIS to the user)
197
260
  * maskedInput: string, // What the LLM actually saw (audit)
@@ -221,10 +284,41 @@ class ScanResource {
221
284
  if (typeof llmCall !== 'function') {
222
285
  throw new TypeError('protect: llmCall must be an async function (masked, ctx) => Promise<string>');
223
286
  }
224
- const { purge = true, ...maskOpts } = opts;
225
-
226
- // 1. Mask
227
- const m = await this.mask(userInput, maskOpts);
287
+ // `failClosed` is destructured out but NOT resolved. It has never done
288
+ // anything on this surface: before the shared posture it landed in
289
+ // maskOpts and mask() whitelists its fields, so it went nowhere. Feeding
290
+ // it to the resolver here would turn a key copy-pasted from a protectTool
291
+ // call site into a live switch, and on THIS surface `{ failClosed: false }`
292
+ // means sending the customer's unmasked text to their model. It stays
293
+ // inert, and it stays out of maskOpts so it never reaches the wire either.
294
+ const { purge = true, failOpen, failClosed: _legacyIgnoredHere, ...maskOpts } = opts;
295
+ const runOnOutage = resolveFailOpen({ failOpen }, this.#failOpen);
296
+
297
+ // 1. Mask. An outage here is the same fork protectTool and guard.wrap
298
+ // face, and it is answered with the same option: fail closed (the
299
+ // default) means the LLM is never handed the raw text.
300
+ let m;
301
+ try {
302
+ m = await this.mask(userInput, maskOpts);
303
+ } catch (err) {
304
+ if (!runOnOutage) throw err;
305
+ // Explicitly asked for. Loud, because this is the one fail-open path in
306
+ // the SDK whose cost is data leaving the customer's boundary unmasked,
307
+ // and a silent return here reads identically to a successful mask.
308
+ console.warn(
309
+ `[ciphyrs] scan.protect failed OPEN (failOpen: true): ${err.message}. ` +
310
+ 'Sending UNMASKED input to your LLM.'
311
+ );
312
+ const output = await llmCall(userInput, { sessionId: null, entitiesFound: [] });
313
+ if (typeof output !== 'string') {
314
+ throw new TypeError('protect: llmCall must return a string (the LLM response). Got ' + typeof output + '.');
315
+ }
316
+ return {
317
+ output, maskedInput: userInput, maskedOutput: output,
318
+ sessionId: null, entitiesFound: [], tokensRestored: 0,
319
+ failedOpen: true,
320
+ };
321
+ }
228
322
 
229
323
  // 2. Call the customer's LLM with the masked text
230
324
  let llmResponse;
@@ -267,9 +361,10 @@ class ScanResource {
267
361
  // their LLM calls with check() and it returns allow / block / review in
268
362
  // <50ms. The whole V58 pitch lives here.
269
363
  class GuardResource {
270
- #headers; #base; #timeout;
271
- constructor(headers, base, timeout) {
364
+ #headers; #base; #timeout; #failOpen;
365
+ constructor(headers, base, timeout, failOpen = false) {
272
366
  this.#headers = headers; this.#base = base; this.#timeout = timeout;
367
+ this.#failOpen = failOpen;
273
368
  }
274
369
 
275
370
  /**
@@ -318,6 +413,12 @@ class GuardResource {
318
413
  * One-shot wrapper: check input, if allowed call your LLM, then check
319
414
  * output, then return. Matches the protect() pattern but for blocking.
320
415
  *
416
+ * Failure posture: fail CLOSED by default — if the guard cannot be reached
417
+ * the error propagates and your LLM is not called. `{ failOpen: true }`, or
418
+ * a client built with `failOpen: true`, proceeds instead and marks the
419
+ * result `failedOpen: true`. Same switch and same precedence as
420
+ * scan.protect and protectTool.
421
+ *
321
422
  * @example
322
423
  * const result = await client.guard.wrap(userMessage, async (input) => {
323
424
  * return await openai.chat.completions.create({...}).choices[0].message.content;
@@ -329,8 +430,29 @@ class GuardResource {
329
430
  if (typeof llmCall !== 'function') {
330
431
  throw new TypeError('guard.wrap: llmCall must be a function');
331
432
  }
433
+ // `opts` is the caller's whole GuardCheckParams bag, and only `failOpen` is
434
+ // read out of it as a posture. guard.wrap has never honoured the legacy
435
+ // `failClosed` — check() whitelists its fields, so the key was inert here —
436
+ // and starting to honour it would let `{ failClosed: false }`, copy-pasted
437
+ // from a protectTool call site, quietly disable fail-closed on a surface
438
+ // where it never did before.
439
+ const runOnOutage = resolveFailOpen({ failOpen: opts?.failOpen }, this.#failOpen);
440
+ // An unreachable guard is not a verdict. Fail-closed rethrows what the
441
+ // transport said, which is what this path has always done; fail-open
442
+ // substitutes a synthetic allow so the caller's flow continues, and says
443
+ // so in the result rather than dressing it up as a real decision.
444
+ const checkOrFailOpen = async (params) => {
445
+ try {
446
+ return await this.check(params);
447
+ } catch (err) {
448
+ if (!runOnOutage) throw err;
449
+ return { decision: 'allow', reason: `guard unreachable, failed open: ${err.message}`,
450
+ detections: [], decision_id: null, failedOpen: true };
451
+ }
452
+ };
453
+
332
454
  // 1. Pre-check input
333
- const inGuard = await this.check({ ...opts, input: userInput });
455
+ const inGuard = await checkOrFailOpen({ ...opts, input: userInput });
334
456
  if (inGuard.decision === 'block') {
335
457
  return { blocked: true, reason: inGuard.reason, decision: inGuard.decision,
336
458
  detections: inGuard.detections, decision_id: inGuard.decision_id };
@@ -341,14 +463,17 @@ class GuardResource {
341
463
  throw new TypeError('guard.wrap: llmCall must return a string');
342
464
  }
343
465
  // 3. Post-check output
344
- const outGuard = await this.check({ ...opts, input: userInput, output: llmResponse });
466
+ const outGuard = await checkOrFailOpen({ ...opts, input: userInput, output: llmResponse });
345
467
  if (outGuard.decision === 'block') {
346
468
  return { blocked: true, reason: outGuard.reason, decision: outGuard.decision,
347
469
  detections: outGuard.detections, decision_id: outGuard.decision_id,
348
470
  output: null }; // never leak the blocked output
349
471
  }
350
472
  return { blocked: false, output: llmResponse, decision: outGuard.decision,
351
- detections: outGuard.detections, decision_id: outGuard.decision_id };
473
+ detections: outGuard.detections, decision_id: outGuard.decision_id,
474
+ // Only present when a check was skipped rather than answered, so a
475
+ // caller auditing "was this really checked?" can tell the two apart.
476
+ ...(inGuard.failedOpen || outGuard.failedOpen ? { failedOpen: true } : {}) };
352
477
  }
353
478
 
354
479
  async getPolicy() {
@@ -912,8 +1037,23 @@ export class CiphyrsClient {
912
1037
  * @param {string} [opts.baseUrl] — Override gateway URL (VPC / on-prem deployments)
913
1038
  * @param {string} [opts.dashUrl] — Override dashboard API URL
914
1039
  * @param {number} [opts.timeout] — Request timeout in ms (default: 10000)
1040
+ * @param {boolean} [opts.failOpen=false]
1041
+ * Fleet-wide failure posture: what protectTool, guard.wrap and
1042
+ * scan.protect do when Ciphyrs cannot be reached.
1043
+ *
1044
+ * DEFAULT FALSE — FAIL CLOSED. The work does not proceed: the tool is
1045
+ * not executed, the LLM is not called, and the error surfaces. This is
1046
+ * a CHANGE for protectTool, which used to fail open and run the tool
1047
+ * during an outage of ours while the other two refused to proceed; the
1048
+ * tool path is the one that moves money, so it now gets the safe
1049
+ * default too. Set `true` here (or `{ failOpen: true }` on any single
1050
+ * call) to restore the old behaviour everywhere. A protectTool caller
1051
+ * who already wrote `failClosed: false` keeps failing open unchanged —
1052
+ * but that legacy key is read on protectTool ONLY. It has never done
1053
+ * anything on this constructor, on guard.wrap or on scan.protect, and
1054
+ * it still does not: use `failOpen` on those.
915
1055
  */
916
- constructor({ apiKey, token, baseUrl = DEFAULT_BASE_URL, dashUrl = DASH_BASE_URL, timeout = 10_000 } = {}) {
1056
+ constructor({ apiKey, token, baseUrl = DEFAULT_BASE_URL, dashUrl = DASH_BASE_URL, timeout = 10_000, failOpen } = {}) {
917
1057
  if (!apiKey && !token) throw new Error('CiphyrsClient: provide apiKey (server-side) or token (dashboard)');
918
1058
 
919
1059
  const headers = {
@@ -927,16 +1067,147 @@ export class CiphyrsClient {
927
1067
  this._headers = headers;
928
1068
  /** @internal Default timeout */
929
1069
  this._timeout = timeout;
930
-
931
- this.scan = new ScanResource(headers, this._baseUrl, timeout);
1070
+ /** @internal Fleet-wide failure posture; protectTool reads this off the client.
1071
+ * `failOpen` only: the legacy `failClosed` never existed on the client, so
1072
+ * reading it here would not preserve anybody's behaviour — it would invent
1073
+ * a new way to turn fail-closed off for the whole fleet at once. It is
1074
+ * honoured on protectTool, the surface it shipped on, and nowhere else. */
1075
+ this._failOpen = resolveFailOpen({ failOpen }, false);
1076
+
1077
+ this.scan = new ScanResource(headers, this._baseUrl, timeout, this._failOpen);
932
1078
  this.auth = new AuthResource(headers, this._baseUrl, timeout);
933
1079
  this.metrics = new MetricsResource(headers, dashUrl.replace(/\/$/, ''), timeout);
934
1080
  this.tenant = new TenantResource(headers, dashUrl.replace(/\/$/, ''), timeout);
935
1081
  this.trace = new TraceResource(headers, this._baseUrl, timeout);
936
1082
  // V54-V59 — security platform resources
937
- this.guard = new GuardResource(headers, this._baseUrl, timeout);
1083
+ this.guard = new GuardResource(headers, this._baseUrl, timeout, this._failOpen);
938
1084
  this.security = new SecurityResource(headers, this._baseUrl, timeout);
939
1085
  this.reports = new ReportsResource(headers, this._baseUrl, timeout);
1086
+
1087
+ // So propagation never writes trace headers onto calls to our own API,
1088
+ // including a customer's VPC/on-prem gateway URL.
1089
+ registerInternalOrigin(this._baseUrl);
1090
+ registerInternalOrigin(dashUrl);
1091
+
1092
+ /** @internal recurring health monitor state (startHealthMonitor) */
1093
+ this._healthTimer = null;
1094
+ }
1095
+
1096
+ // ── Agent health (V39 heartbeat, V143 windows + metrics) ─────────────────
1097
+
1098
+ /**
1099
+ * Report one health check. Fire-and-forget by design: a flaky network must
1100
+ * never take down the agent being monitored, so failures resolve to
1101
+ * `{ ok: false }` and warn once rather than throwing.
1102
+ *
1103
+ * Reporting `heartbeatIntervalS` matters — the server sizes THIS agent's
1104
+ * "down" window from it (2x + grace) instead of applying one global
1105
+ * threshold to a fleet whose agents beat at very different rates. Shipping
1106
+ * `metrics` lets the fleet show `degraded` before `down`.
1107
+ *
1108
+ * @param {object} args
1109
+ * @param {string} [args.agentName] — Resolve the agent by name (preferred)
1110
+ * @param {string} [args.agentId] — Or pass an explicit UUID
1111
+ * @param {'up'|'degraded'|'failing'|'down'|'unknown'} [args.status='up']
1112
+ * @param {string} [args.projectName] — Which project the agent belongs to
1113
+ * @param {number} [args.latencyMs]
1114
+ * @param {string} [args.errorMessage] — Expected when status !== 'up'
1115
+ * @param {string} [args.environment]
1116
+ * @param {number} [args.heartbeatIntervalS]
1117
+ * @param {object} [args.metrics]
1118
+ * @param {object} [args.metadata]
1119
+ */
1120
+ async reportHeartbeat({
1121
+ agentName, agentId, status = 'up', projectName, latencyMs = null,
1122
+ errorMessage = null, environment, heartbeatIntervalS, metrics, metadata = {},
1123
+ } = {}) {
1124
+ if (!agentName && !agentId) throw new Error('reportHeartbeat requires agentName or agentId');
1125
+ try {
1126
+ const res = await this._request(`${this._baseUrl}/v1/trace/agents/heartbeat`, {
1127
+ method: 'POST',
1128
+ body: {
1129
+ agent_id: agentId || undefined,
1130
+ agent_name: agentName || undefined,
1131
+ project_name: projectName || undefined,
1132
+ status,
1133
+ latency_ms: latencyMs,
1134
+ error_message: errorMessage,
1135
+ environment: environment || undefined,
1136
+ heartbeat_interval_s: heartbeatIntervalS,
1137
+ metrics,
1138
+ metadata,
1139
+ source: 'sdk',
1140
+ },
1141
+ });
1142
+ return { ok: true, ...res };
1143
+ } catch (err) {
1144
+ console.warn(`[ciphyrs] heartbeat failed for ${agentName || agentId}: ${err.message}`);
1145
+ return { ok: false, error: err.message };
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * Start a recurring heartbeat. Runs `probe()` each tick when given: if it
1151
+ * throws, the beat is sent as `down` with the message; if it returns
1152
+ * `{ status, latencyMs }`, those win. Safe to call repeatedly — the previous
1153
+ * monitor is cleared first. The timer is unref'd so it never holds the
1154
+ * process open.
1155
+ *
1156
+ * @example
1157
+ * client.startHealthMonitor({
1158
+ * agentName: 'billing-agent',
1159
+ * probe: async () => { const t = Date.now(); await dbPing(); return { status: 'up', latencyMs: Date.now() - t }; },
1160
+ * });
1161
+ */
1162
+ startHealthMonitor({ agentName, agentId, projectName, intervalMs = 60_000, probe = null, environment } = {}) {
1163
+ if (!agentName && !agentId) throw new Error('startHealthMonitor requires agentName or agentId');
1164
+ this.stopHealthMonitor();
1165
+ const intervalS = Math.round(intervalMs / 1000);
1166
+ const tick = async () => {
1167
+ let status = 'up', latencyMs = null, errorMessage = null;
1168
+ if (probe) {
1169
+ const t0 = Date.now();
1170
+ try {
1171
+ const r = await probe();
1172
+ status = r?.status || 'up';
1173
+ latencyMs = r?.latencyMs ?? (Date.now() - t0);
1174
+ } catch (err) {
1175
+ status = 'down';
1176
+ latencyMs = Date.now() - t0;
1177
+ errorMessage = err?.message || String(err);
1178
+ }
1179
+ }
1180
+ await this.reportHeartbeat({
1181
+ agentName, agentId, projectName, status, latencyMs, errorMessage,
1182
+ environment, heartbeatIntervalS: intervalS,
1183
+ });
1184
+ };
1185
+ tick().catch(() => {});
1186
+ this._healthTimer = setInterval(() => { tick().catch(() => {}); }, intervalMs);
1187
+ if (this._healthTimer.unref) this._healthTimer.unref();
1188
+ return this._healthTimer;
1189
+ }
1190
+
1191
+ /** Stop the recurring heartbeat started by startHealthMonitor(). */
1192
+ stopHealthMonitor() {
1193
+ if (this._healthTimer) { clearInterval(this._healthTimer); this._healthTimer = null; }
1194
+ }
1195
+
1196
+ /**
1197
+ * Set an agent's down window and/or a synthetic probe URL the platform
1198
+ * polls, so `down` becomes something Ciphyrs observed rather than inferred
1199
+ * from silence. Requires a dashboard token (owner/admin/developer).
1200
+ *
1201
+ * @param {string} agentId
1202
+ * @param {{ heartbeatTimeoutS?: number|null, probeUrl?: string|null }} opts
1203
+ */
1204
+ async setAgentMonitoring(agentId, { heartbeatTimeoutS, probeUrl } = {}) {
1205
+ const body = {};
1206
+ if (heartbeatTimeoutS !== undefined) body.heartbeat_timeout_s = heartbeatTimeoutS;
1207
+ if (probeUrl !== undefined) body.probe_url = probeUrl;
1208
+ return this._request(`${this._baseUrl}/v1/trace/agents/${encodeURIComponent(agentId)}/monitoring`, {
1209
+ method: 'PATCH', body,
1210
+ });
940
1211
  }
941
1212
 
942
1213
  /**
@@ -970,4 +1241,39 @@ export class CiphyrsClient {
970
1241
 
971
1242
  /** Shortcut for client.auth.createApiKey() */
972
1243
  createKey(opts) { return this.auth.createApiKey(opts); }
1244
+
1245
+ // ── V60: tool-call authorization (used by protectTool wrapper) ──────────
1246
+ /** @internal Used by protectTool() — POST /v1/guard/tool-check
1247
+ *
1248
+ * `span_id` is the field the gateway route destructures alongside
1249
+ * `trace_id`; it stores it on agent_tool_calls.span_id, which is the only
1250
+ * key trust.001 can use to match a gate call to the tool span it
1251
+ * authorised. Omitted when the wrapper has no span to name — see
1252
+ * protect-tool.js producerIds(). Never substituted with anything: the
1253
+ * gateway mints its own `gs_…` when this is absent, and the rule reads a
1254
+ * minted id as "this producer cannot be correlated". */
1255
+ async _toolCheck({ agent_name, tool_name, args, trace_id, span_id, user_id }) {
1256
+ return this._request(`${this._baseUrl}/v1/guard/tool-check`, {
1257
+ method: 'POST',
1258
+ body: { agent_name, tool_name, args, trace_id, span_id, user_id },
1259
+ });
1260
+ }
1261
+
1262
+ /** @internal Used by protectTool() — GET /v1/guard/approval/:id (poll) */
1263
+ async _approvalStatus(approvalId) {
1264
+ return this._request(`${this._baseUrl}/v1/guard/approval/${encodeURIComponent(approvalId)}`);
1265
+ }
1266
+
1267
+ /** @internal Used by protectTool() — announce decorated tools at startup
1268
+ * so the inventory (and any default-deny allowlist) covers the full tool
1269
+ * surface before the first call ever happens. Best-effort. */
1270
+ async _announceTools(agentName, tools) {
1271
+ return this._request(`${this._baseUrl}/v1/agent-inventory/announce`, {
1272
+ method: 'POST',
1273
+ body: { agent_name: agentName, tools },
1274
+ });
1275
+ }
973
1276
  }
1277
+
1278
+ /** @internal exported for tests */
1279
+ export const _retryInternals = { isGuardUrl, carriesFinalVerdict, GUARD_MAX_RETRIES, GUARD_ATTEMPT_TIMEOUT_MS, GUARD_TOTAL_BUDGET_MS, MAX_RETRY_AFTER_MS, MAX_RETRIES, request };