@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 +131 -0
- package/package.json +24 -6
- package/src/client.js +326 -20
- package/src/context.js +82 -0
- package/src/fail-posture.js +100 -0
- package/src/index.js +14 -0
- package/src/propagation.js +388 -0
- package/src/protect-tool.js +362 -0
- package/src/secret-detector.js +21 -3
- package/src/tracer.js +278 -9
- package/types.d.ts +265 -5
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": "
|
|
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": [
|
|
18
|
-
|
|
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": {
|
|
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
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
//
|
|
227
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 };
|