@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/README.md +131 -0
- package/package.json +4 -2
- package/src/client.js +326 -20
- package/src/client.test.js +99 -0
- package/src/context.js +82 -0
- package/src/fail-posture.js +100 -0
- package/src/fail-posture.test.js +385 -0
- package/src/index.js +14 -0
- package/src/no-network.test-helper.js +108 -0
- package/src/propagation.js +388 -0
- package/src/propagation.test.js +429 -0
- package/src/protect-tool.js +362 -0
- package/src/protect-tool.test.js +446 -0
- package/src/secret-detector.js +21 -3
- package/src/secret-detector.test.js +155 -0
- package/src/tracer.js +278 -9
- package/src/tracer.test.js +194 -0
- package/types.d.ts +265 -5
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry behaviour of the SDK's HTTP helper — parity with the Python client.
|
|
3
|
+
* Run with: node --test src/client.test.js
|
|
4
|
+
*
|
|
5
|
+
* · A guard verdict (/v1/guard/*) is bounded: one retry, a short attempt
|
|
6
|
+
* timeout, a 10 s wall-clock budget. The BFSI demo hung for 90 s on a
|
|
7
|
+
* transfer request while the Python SDK retried tool-check on the
|
|
8
|
+
* general ladder; this client had the same ladder.
|
|
9
|
+
* · A 5xx whose body carries fail_open / fail_closed IS the verdict and is
|
|
10
|
+
* returned, not retried (Python SDK, 13 Sep 2026).
|
|
11
|
+
* · Retry-After is honoured but capped at 60 s.
|
|
12
|
+
* · Everything else keeps the ladder it had.
|
|
13
|
+
*/
|
|
14
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { _retryInternals as R } from './client.js';
|
|
17
|
+
|
|
18
|
+
let realFetch;
|
|
19
|
+
beforeEach(() => { realFetch = globalThis.fetch; });
|
|
20
|
+
afterEach(() => { globalThis.fetch = realFetch; });
|
|
21
|
+
|
|
22
|
+
function respond(status, body, headers = {}) {
|
|
23
|
+
const calls = { n: 0, timeouts: [] };
|
|
24
|
+
globalThis.fetch = async (url, init) => {
|
|
25
|
+
calls.n += 1;
|
|
26
|
+
calls.timeouts.push(init?.signal);
|
|
27
|
+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });
|
|
28
|
+
};
|
|
29
|
+
return calls;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('guard calls are bounded', () => {
|
|
33
|
+
test('a 5xx on tool-check is retried once at most, quickly', async () => {
|
|
34
|
+
const calls = respond(503, { error: 'classifier unavailable' });
|
|
35
|
+
const t0 = Date.now();
|
|
36
|
+
await assert.rejects(R.request('https://api.test/v1/guard/tool-check', { method: 'POST', body: {} }));
|
|
37
|
+
assert.ok(calls.n <= R.GUARD_MAX_RETRIES + 1, `${calls.n} attempts`);
|
|
38
|
+
assert.ok(Date.now() - t0 < 3000, 'no long backoff on a verdict');
|
|
39
|
+
});
|
|
40
|
+
test('a 5xx on guard/check likewise', async () => {
|
|
41
|
+
const calls = respond(502, { error: 'bad gateway' });
|
|
42
|
+
await assert.rejects(R.request('https://api.test/v1/guard/check', { method: 'POST', body: {} }));
|
|
43
|
+
assert.ok(calls.n <= R.GUARD_MAX_RETRIES + 1);
|
|
44
|
+
});
|
|
45
|
+
test('ingest keeps the full ladder', async () => {
|
|
46
|
+
const calls = respond(503, { error: 'upstream unavailable' });
|
|
47
|
+
await assert.rejects(R.request('https://api.test/v1/trace/spans', { method: 'POST', body: {}, timeout: 500 }));
|
|
48
|
+
assert.equal(calls.n, R.MAX_RETRIES + 1);
|
|
49
|
+
});
|
|
50
|
+
test('isGuardUrl reads the path, not the host', () => {
|
|
51
|
+
assert.equal(R.isGuardUrl('https://www.ciphyrs.com/v1/guard/tool-check'), true);
|
|
52
|
+
assert.equal(R.isGuardUrl('https://ca.ciphyrs.com/v1/guard/check?x=1'), true);
|
|
53
|
+
assert.equal(R.isGuardUrl('https://www.ciphyrs.com/v1/trace/spans'), false);
|
|
54
|
+
assert.equal(R.isGuardUrl('https://guard.example.com/v1/scan/mask'), false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('a final verdict is not retried', () => {
|
|
59
|
+
test('503 with fail_open is returned as the verdict, once', async () => {
|
|
60
|
+
const calls = respond(503, { action: 'allow', fail_open: true, reason: 'tool-check service error; failed open' });
|
|
61
|
+
const out = await R.request('https://api.test/v1/guard/tool-check', { method: 'POST', body: {} });
|
|
62
|
+
assert.equal(calls.n, 1);
|
|
63
|
+
assert.equal(out.fail_open, true);
|
|
64
|
+
});
|
|
65
|
+
test('503 with fail_closed likewise', async () => {
|
|
66
|
+
const calls = respond(503, { action: 'block', fail_closed: true, reason: 'failing closed' });
|
|
67
|
+
const out = await R.request('https://api.test/v1/guard/tool-check', { method: 'POST', body: {} });
|
|
68
|
+
assert.equal(calls.n, 1);
|
|
69
|
+
assert.equal(out.fail_closed, true);
|
|
70
|
+
});
|
|
71
|
+
test('carriesFinalVerdict is strict about the marker', () => {
|
|
72
|
+
assert.equal(R.carriesFinalVerdict({ fail_open: true }), true);
|
|
73
|
+
assert.equal(R.carriesFinalVerdict({ fail_open: 'yes' }), false);
|
|
74
|
+
assert.equal(R.carriesFinalVerdict({ error: 'x' }), false);
|
|
75
|
+
assert.equal(R.carriesFinalVerdict(null), false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('Retry-After is capped', () => {
|
|
80
|
+
test('a huge Retry-After does not park the thread', async () => {
|
|
81
|
+
let n = 0;
|
|
82
|
+
globalThis.fetch = async () => {
|
|
83
|
+
n += 1;
|
|
84
|
+
return new Response(JSON.stringify({ error: 'limit' }), { status: 429, headers: { 'retry-after': n === 1 ? '3600' : '0', 'content-type': 'application/json' } });
|
|
85
|
+
};
|
|
86
|
+
const t0 = Date.now();
|
|
87
|
+
// Not a guard path: the general ladder applies, so the first sleep is
|
|
88
|
+
// the Retry-After — capped. We cannot wait 60 s in a test, so assert the
|
|
89
|
+
// cap constant and that the code path used it by racing a short timer.
|
|
90
|
+
const race = Promise.race([
|
|
91
|
+
R.request('https://api.test/v1/trace/spans', { method: 'POST', body: {}, timeout: 500 }).catch(() => 'done'),
|
|
92
|
+
new Promise((r) => setTimeout(() => r('still sleeping'), 1500)),
|
|
93
|
+
]);
|
|
94
|
+
const outcome = await race;
|
|
95
|
+
assert.equal(outcome, 'still sleeping', 'the capped Retry-After (60 s) is still honoured, so the request is asleep');
|
|
96
|
+
assert.equal(R.MAX_RETRY_AFTER_MS, 60_000);
|
|
97
|
+
assert.ok(Date.now() - t0 < 2500);
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/context.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// Ambient trace/span context for the JavaScript SDK.
|
|
3
|
+
//
|
|
4
|
+
// WHY. Spans took `parentSpanId` as a hand-threaded option, so in practice
|
|
5
|
+
// nothing was ever nested: every span in a trace arrived parentless. The
|
|
6
|
+
// server derives the topology graph from parent links, so a flat trace draws
|
|
7
|
+
// no edges — the customer saw a row of disconnected agents and concluded
|
|
8
|
+
// topology was broken. It also meant a guard call inside a span had no span
|
|
9
|
+
// to point at, exactly the defect the Python SDK had (measured on
|
|
10
|
+
// production: every guard-path detection had a NULL span_id).
|
|
11
|
+
//
|
|
12
|
+
// AsyncLocalStorage, not a module global: a global is wrong the moment two
|
|
13
|
+
// requests are in flight, which is the normal shape of a Node agent. ALS is
|
|
14
|
+
// per-async-context and is inherited by anything the current task spawns.
|
|
15
|
+
//
|
|
16
|
+
// TWO WAYS IN, deliberately. `span.run(fn)` is a real scope and is what the
|
|
17
|
+
// docs recommend. But this SDK's public shape has always been
|
|
18
|
+
// `const s = trace.span(...); …; s.end()`, with no callback, so
|
|
19
|
+
// `enterWith` is used at construction and unwound at `end()`. That is what
|
|
20
|
+
// makes the existing style nest correctly without asking every user to
|
|
21
|
+
// rewrite their agent.
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
23
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
24
|
+
|
|
25
|
+
export const traceStorage = new AsyncLocalStorage();
|
|
26
|
+
export const spanStorage = new AsyncLocalStorage();
|
|
27
|
+
|
|
28
|
+
/** The trace/span an inbound request arrived under (see propagation.js). */
|
|
29
|
+
export const remoteStorage = new AsyncLocalStorage();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The active trace and span, if any.
|
|
33
|
+
*
|
|
34
|
+
* Prefers the span, which carries both ids — a guard call made inside span()
|
|
35
|
+
* should attribute to that span, not merely to its trace. Falls back to the
|
|
36
|
+
* remote parent so a guard call made straight from a request handler, with no
|
|
37
|
+
* local span open, still attributes to the distributed trace it belongs to.
|
|
38
|
+
* Returns an object with undefined fields rather than null so callers can
|
|
39
|
+
* spread it without branching.
|
|
40
|
+
*/
|
|
41
|
+
export function activeIds() {
|
|
42
|
+
const span = spanStorage.getStore();
|
|
43
|
+
if (span?.trace_id) {
|
|
44
|
+
return { trace_id: span.trace_id, span_id: span.span_id };
|
|
45
|
+
}
|
|
46
|
+
const trace = traceStorage.getStore();
|
|
47
|
+
if (trace?.trace_id) {
|
|
48
|
+
return { trace_id: trace.trace_id, span_id: undefined };
|
|
49
|
+
}
|
|
50
|
+
const remote = remoteStorage.getStore();
|
|
51
|
+
if (remote?.trace_id) {
|
|
52
|
+
return { trace_id: remote.trace_id, span_id: remote.span_id };
|
|
53
|
+
}
|
|
54
|
+
return { trace_id: undefined, span_id: undefined };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The kind of the innermost LOCAL span ('agent' | 'tool' | 'llm' | 'retriever'),
|
|
59
|
+
* or undefined when no local span is open.
|
|
60
|
+
*
|
|
61
|
+
* Only spanStorage answers this, deliberately. A span kind never travels in
|
|
62
|
+
* Ciphyrs baggage, so a remote parent has none, and a trace is not a span —
|
|
63
|
+
* in both of those cases the honest answer is "I do not know what that span
|
|
64
|
+
* is", not "agent". The one caller that needs it is protect-tool.js: telling
|
|
65
|
+
* the gateway which span authorised a tool call is only true if the span is
|
|
66
|
+
* the tool span, and claiming it otherwise is worse than saying nothing.
|
|
67
|
+
*/
|
|
68
|
+
export function activeSpanKind() {
|
|
69
|
+
return spanStorage.getStore()?.kind || undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The agent name of the innermost active span, for outbound headers. */
|
|
73
|
+
export function activeAgent() {
|
|
74
|
+
return spanStorage.getStore()?.agent_name
|
|
75
|
+
|| remoteStorage.getStore()?.agent_name
|
|
76
|
+
|| undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The remote parent, if this async context is serving an instrumented request. */
|
|
80
|
+
export function remoteParent() {
|
|
81
|
+
return remoteStorage.getStore() || undefined;
|
|
82
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// One failure posture for the whole client.
|
|
3
|
+
//
|
|
4
|
+
// WHY. The three entry points that stand between a customer and their
|
|
5
|
+
// LLM/tool disagreed about what a Ciphyrs outage means. protectTool caught
|
|
6
|
+
// the transport error and ran the tool anyway (fail-OPEN); guard.wrap and
|
|
7
|
+
// scan.protect let the error propagate out of check()/mask(), so nothing ran
|
|
8
|
+
// (fail-CLOSED). One client, two opposite behaviours under one outage — and
|
|
9
|
+
// the unsafe one sat on the path that authorises tool execution, i.e. the
|
|
10
|
+
// path that moves money. Neither closed path offered any way to opt out, so
|
|
11
|
+
// a customer could not align them either.
|
|
12
|
+
//
|
|
13
|
+
// So the posture is one option, resolved here, and used identically by all
|
|
14
|
+
// three: `failOpen: true` means "a Ciphyrs outage must not stop my work",
|
|
15
|
+
// `failOpen: false` (the default, everywhere) means the work does not
|
|
16
|
+
// proceed. Settable on the client and overridable per call.
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
18
|
+
|
|
19
|
+
/** What to tell a caller who just got blocked by an outage. */
|
|
20
|
+
export const FAIL_OPEN_HINT =
|
|
21
|
+
'To run anyway while Ciphyrs is unreachable, pass { failOpen: true } on this call ' +
|
|
22
|
+
'or construct the client with new CiphyrsClient({ failOpen: true }).'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read one posture value: true, false, or undefined for "nobody chose".
|
|
26
|
+
*
|
|
27
|
+
* WHY this is not `Boolean(v)` / `!v`. A posture reaches us from
|
|
28
|
+
* configuration at least as often as from code — a JSON file, an env var, a
|
|
29
|
+
* database column, a `{ ...defaults }` spread — and those hand over `null` or
|
|
30
|
+
* a string, which plain truthiness reads the UNSAFE way:
|
|
31
|
+
*
|
|
32
|
+
* `!null` is true, so a `failClosed` column nobody has ever set resolved to
|
|
33
|
+
* fail OPEN — an unset value silently choosing the direction that runs the
|
|
34
|
+
* tool during an outage.
|
|
35
|
+
* `Boolean('false')` is true, so `failOpen: process.env.FAIL_OPEN` with the
|
|
36
|
+
* var set to "false" resolved to fail OPEN too — a written-down "no" read as
|
|
37
|
+
* a yes.
|
|
38
|
+
*
|
|
39
|
+
* Fail-open is the direction that costs the customer money or leaks their PII,
|
|
40
|
+
* so it is only ever reached by a value that unmistakably says so. Anything
|
|
41
|
+
* else — null, '', an object, NaN, a string we cannot read — is "no choice
|
|
42
|
+
* made", which falls through to the next level and ultimately to fail CLOSED.
|
|
43
|
+
* Note the asymmetry that made this urgent: a nullish `failOpen` already
|
|
44
|
+
* landed safely; only the inverted key landed unsafely.
|
|
45
|
+
*/
|
|
46
|
+
const YES = new Set(['true', '1', 'yes', 'on'])
|
|
47
|
+
const NO = new Set(['false', '0', 'no', 'off'])
|
|
48
|
+
|
|
49
|
+
function readPosture(v) {
|
|
50
|
+
if (v === undefined || v === null) return undefined
|
|
51
|
+
if (typeof v === 'boolean') return v
|
|
52
|
+
// 0/1 is how a boolean survives MySQL and SQLite, so a number is a real
|
|
53
|
+
// choice — except NaN, which is what `Number(undefined)` produces.
|
|
54
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v !== 0 : undefined
|
|
55
|
+
if (typeof v === 'string') {
|
|
56
|
+
const s = v.trim().toLowerCase()
|
|
57
|
+
if (YES.has(s)) return true
|
|
58
|
+
if (NO.has(s)) return false
|
|
59
|
+
return undefined // '' or anything unrecognised: not a choice
|
|
60
|
+
}
|
|
61
|
+
return undefined // objects, arrays, functions: not a posture
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Should this call proceed when Ciphyrs cannot be reached?
|
|
66
|
+
*
|
|
67
|
+
* @param {object} [callOpts] Per-call options ({ failOpen }, plus legacy
|
|
68
|
+
* { failClosed } on the surfaces that opt in)
|
|
69
|
+
* @param {boolean} [clientDefault=false] The client-wide posture
|
|
70
|
+
* @param {object} [surface]
|
|
71
|
+
* @param {boolean} [surface.legacyFailClosed=false]
|
|
72
|
+
* Read the legacy `failClosed` key on this surface. OFF by default, and
|
|
73
|
+
* opted into by protectTool alone, because protectTool is the only
|
|
74
|
+
* surface `failClosed` ever did anything on. guard.wrap ignored the key
|
|
75
|
+
* outright and scan.protect forwarded it to mask(), which whitelists its
|
|
76
|
+
* fields, so it was inert there too. Reading it everywhere turned a key
|
|
77
|
+
* someone copy-pasted from a protectTool call site into a live switch on
|
|
78
|
+
* two new surfaces — and `{ failClosed: false }` on scan.protect means
|
|
79
|
+
* sending UNMASKED text to the customer's model. A resolver shared by
|
|
80
|
+
* three surfaces must not hand a fourth one the legacy key by default.
|
|
81
|
+
* @returns {boolean} true = fail open (run anyway), false = fail closed (do not run)
|
|
82
|
+
*/
|
|
83
|
+
export function resolveFailOpen(callOpts, clientDefault = false, { legacyFailClosed = false } = {}) {
|
|
84
|
+
const o = (callOpts && typeof callOpts === 'object') ? callOpts : {}
|
|
85
|
+
|
|
86
|
+
const open = readPosture(o.failOpen)
|
|
87
|
+
if (open !== undefined) return open
|
|
88
|
+
|
|
89
|
+
// `failClosed` has shipped on protectTool since V60 and must keep working in
|
|
90
|
+
// BOTH directions there. A caller who wrote `failClosed: false` wrote down a
|
|
91
|
+
// request to fail open; silently re-reading that as "did not choose" and
|
|
92
|
+
// applying the new fail-closed default is precisely the upgrade surprise
|
|
93
|
+
// this option exists to prevent.
|
|
94
|
+
if (legacyFailClosed) {
|
|
95
|
+
const closed = readPosture(o.failClosed)
|
|
96
|
+
if (closed !== undefined) return !closed
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return readPosture(clientDefault) === true
|
|
100
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One failure posture, three entry points.
|
|
3
|
+
*
|
|
4
|
+
* The audit this fixes: protectTool caught a gateway outage and ran the tool
|
|
5
|
+
* anyway, while guard.wrap and scan.protect on the SAME client let the error
|
|
6
|
+
* out and refused to proceed. One client, two opposite answers to "Ciphyrs is
|
|
7
|
+
* down", with the unsafe one on the path that authorises tool execution — and
|
|
8
|
+
* no option anywhere to align them.
|
|
9
|
+
*
|
|
10
|
+
* So the question these tests ask is not "does it block?" but: DO ALL THREE
|
|
11
|
+
* ANSWER THE OUTAGE THE SAME WAY, and does the caller's choice reach all three
|
|
12
|
+
* from both the client and the call?
|
|
13
|
+
*
|
|
14
|
+
* protectTool's own outage tests live in protect-tool.test.js; this file
|
|
15
|
+
* covers guard.wrap, scan.protect, and the resolution rule they share.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import './no-network.test-helper.js'
|
|
19
|
+
import { describe, it } from 'node:test'
|
|
20
|
+
import assert from 'node:assert/strict'
|
|
21
|
+
import net from 'node:net'
|
|
22
|
+
import { CiphyrsClient } from './client.js'
|
|
23
|
+
import { CiphyrsError } from './errors.js'
|
|
24
|
+
import { resolveFailOpen } from './fail-posture.js'
|
|
25
|
+
import { expectBlocked, networkAttempts } from './no-network.test-helper.js'
|
|
26
|
+
|
|
27
|
+
// These tests need a REAL client — the posture is resolved in its constructor
|
|
28
|
+
// and read by three of its methods — and a real client is a loaded gun in a
|
|
29
|
+
// test file. Two things are disarmed here, at construction, so no test can
|
|
30
|
+
// re-arm them by running in the wrong order:
|
|
31
|
+
//
|
|
32
|
+
// baseUrl — it defaults to https://www.ciphyrs.com. Every request
|
|
33
|
+
// this file provokes used to be aimed at production.
|
|
34
|
+
// _announceTools — protectTool announces every wrapped tool on a 2s
|
|
35
|
+
// debounce, and queueToolAnnounce skips the registry
|
|
36
|
+
// entirely when the client has no _announceTools function.
|
|
37
|
+
// Shadowing the prototype method with `undefined` means the
|
|
38
|
+
// timer is never even created, so there is nothing left to
|
|
39
|
+
// fire after the last test has restored whatever it stubbed.
|
|
40
|
+
//
|
|
41
|
+
// The socket guard imported above is the backstop for both.
|
|
42
|
+
const SENTINEL_BASE = 'http://ciphyrs.invalid'
|
|
43
|
+
const clientWith = (opts = {}) => {
|
|
44
|
+
const c = new CiphyrsClient({ apiKey: 'cyp_test_key', baseUrl: SENTINEL_BASE, ...opts })
|
|
45
|
+
c._announceTools = undefined
|
|
46
|
+
return c
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// An outage is "the primitive threw after its retries were exhausted", which
|
|
50
|
+
// is what request() does on a 503 or a refused connection. Stubbing the
|
|
51
|
+
// primitive keeps these tests off the retry/backoff clock; the one test at the
|
|
52
|
+
// bottom drives a real transport failure end to end.
|
|
53
|
+
const OUTAGE = () => { throw new CiphyrsError('ECONNREFUSED') }
|
|
54
|
+
const downGuard = (c) => { c.guard.check = async () => OUTAGE(); return c }
|
|
55
|
+
const downMasker = (c) => { c.scan.mask = async () => OUTAGE(); return c }
|
|
56
|
+
|
|
57
|
+
// ── The resolution rule itself ──────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe('resolveFailOpen — precedence', () => {
|
|
60
|
+
it('fails closed when nobody chose', () => {
|
|
61
|
+
assert.equal(resolveFailOpen(undefined, undefined), false)
|
|
62
|
+
assert.equal(resolveFailOpen({}, false), false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('takes the client posture when the call is silent', () => {
|
|
66
|
+
assert.equal(resolveFailOpen({}, true), true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('lets the call override the client, in both directions', () => {
|
|
70
|
+
assert.equal(resolveFailOpen({ failOpen: false }, true), false)
|
|
71
|
+
assert.equal(resolveFailOpen({ failOpen: true }, false), true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('reads legacy failClosed in both directions — on the surfaces that ask for it', () => {
|
|
75
|
+
// `failClosed: false` is a caller who WROTE DOWN a fail-open posture.
|
|
76
|
+
// Re-reading that as "made no choice" and applying the new default is the
|
|
77
|
+
// silent upgrade break this whole option exists to avoid. Only protectTool
|
|
78
|
+
// opts in, so only protectTool's call shape is asserted here.
|
|
79
|
+
const legacy = { legacyFailClosed: true }
|
|
80
|
+
assert.equal(resolveFailOpen({ failClosed: false }, false, legacy), true)
|
|
81
|
+
assert.equal(resolveFailOpen({ failClosed: true }, true, legacy), false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('ignores legacy failClosed unless the surface opts in', () => {
|
|
85
|
+
// guard.wrap and scan.protect call it WITHOUT the flag. The key was inert
|
|
86
|
+
// on both before the shared resolver existed, and honouring it there turns
|
|
87
|
+
// a copy-pasted protectTool option into a live switch — on scan.protect,
|
|
88
|
+
// one that sends unmasked text to the customer's model.
|
|
89
|
+
assert.equal(resolveFailOpen({ failClosed: false }, false), false)
|
|
90
|
+
assert.equal(resolveFailOpen({ failClosed: false }, true), true, 'it must not override the client either')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('prefers the new spelling when a caller passes both', () => {
|
|
94
|
+
assert.equal(resolveFailOpen({ failOpen: false, failClosed: false }, true, { legacyFailClosed: true }), false)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('resolveFailOpen — values that are not a choice', () => {
|
|
99
|
+
// Postures arrive from JSON config, a database column and `{ ...defaults }`
|
|
100
|
+
// spreads, which produce null and strings. Every one of these used to be
|
|
101
|
+
// read by plain truthiness, and the inverted key read them all the UNSAFE
|
|
102
|
+
// way: `!null` is true, so an unset column meant fail OPEN.
|
|
103
|
+
const legacy = { legacyFailClosed: true }
|
|
104
|
+
|
|
105
|
+
it('treats a nullish legacy failClosed as unset, not as fail open', () => {
|
|
106
|
+
assert.equal(resolveFailOpen({ failClosed: null }, false, legacy), false)
|
|
107
|
+
assert.equal(resolveFailOpen({ failClosed: undefined }, false, legacy), false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('lets the client posture through when the call value is nullish', () => {
|
|
111
|
+
// Unset means "I did not choose", so the next level decides — it does not
|
|
112
|
+
// mean "fail open" and it does not mean "override the client with closed".
|
|
113
|
+
assert.equal(resolveFailOpen({ failClosed: null }, true, legacy), true)
|
|
114
|
+
assert.equal(resolveFailOpen({ failOpen: null }, true), true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('treats a nullish failOpen as unset too — the two keys agree', () => {
|
|
118
|
+
assert.equal(resolveFailOpen({ failOpen: null }, false), false)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('reads a string posture as written, not as truthiness', () => {
|
|
122
|
+
// `Boolean('false')` is true. A config file or an env var that says "false"
|
|
123
|
+
// must not turn into fail OPEN.
|
|
124
|
+
assert.equal(resolveFailOpen({ failOpen: 'false' }, false), false)
|
|
125
|
+
assert.equal(resolveFailOpen({ failOpen: 'true' }, false), true)
|
|
126
|
+
assert.equal(resolveFailOpen({ failClosed: 'true' }, true, legacy), false)
|
|
127
|
+
assert.equal(resolveFailOpen({ failClosed: 'false' }, false, legacy), true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('refuses to guess at an empty string or a value it cannot read', () => {
|
|
131
|
+
assert.equal(resolveFailOpen({ failOpen: '' }, false), false)
|
|
132
|
+
assert.equal(resolveFailOpen({ failClosed: '' }, false, legacy), false)
|
|
133
|
+
assert.equal(resolveFailOpen({ failOpen: 'maybe' }, false), false)
|
|
134
|
+
assert.equal(resolveFailOpen({ failOpen: {} }, false), false)
|
|
135
|
+
assert.equal(resolveFailOpen({ failOpen: NaN }, false), false)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('keeps 0/1 working, because that is how a boolean survives a database', () => {
|
|
139
|
+
assert.equal(resolveFailOpen({ failOpen: 1 }, false), true)
|
|
140
|
+
assert.equal(resolveFailOpen({ failOpen: 0 }, true), false)
|
|
141
|
+
assert.equal(resolveFailOpen({ failClosed: 1 }, true, legacy), false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('never lets a junk CLIENT default land on fail open', () => {
|
|
145
|
+
assert.equal(resolveFailOpen({}, null), false)
|
|
146
|
+
assert.equal(resolveFailOpen({}, 'false'), false)
|
|
147
|
+
assert.equal(resolveFailOpen({}, 'true'), true)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('survives a caller who passes no options object at all', () => {
|
|
151
|
+
assert.equal(resolveFailOpen(null, false), false)
|
|
152
|
+
assert.equal(resolveFailOpen('nonsense', false), false)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// ── guard.wrap ──────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe('guard.wrap — when Ciphyrs is unreachable', () => {
|
|
159
|
+
it('blocks by default: the LLM is never called', async () => {
|
|
160
|
+
const c = downGuard(clientWith())
|
|
161
|
+
let ran = false
|
|
162
|
+
await assert.rejects(c.guard.wrap('hi', async () => { ran = true; return 'out' }), CiphyrsError)
|
|
163
|
+
assert.equal(ran, false)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('runs the LLM when the CALL asks to fail open, and says the check was skipped', async () => {
|
|
167
|
+
const c = downGuard(clientWith())
|
|
168
|
+
const r = await c.guard.wrap('hi', async () => 'out', { failOpen: true })
|
|
169
|
+
assert.equal(r.blocked, false)
|
|
170
|
+
assert.equal(r.output, 'out')
|
|
171
|
+
assert.equal(r.failedOpen, true, 'an un-decided allow must not look like a decided one')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('runs the LLM when the CLIENT asks to fail open', async () => {
|
|
175
|
+
const c = downGuard(clientWith({ failOpen: true }))
|
|
176
|
+
const r = await c.guard.wrap('hi', async () => 'out')
|
|
177
|
+
assert.equal(r.output, 'out')
|
|
178
|
+
assert.equal(r.failedOpen, true)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('lets a call close a fail-open client back down', async () => {
|
|
182
|
+
const c = downGuard(clientWith({ failOpen: true }))
|
|
183
|
+
let ran = false
|
|
184
|
+
await assert.rejects(c.guard.wrap('hi', async () => { ran = true; return 'out' }, { failOpen: false }))
|
|
185
|
+
assert.equal(ran, false)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('ignores the legacy failClosed key — it has never meant anything here', async () => {
|
|
189
|
+
// `failClosed: false` copy-pasted from a protectTool call site. On
|
|
190
|
+
// protectTool it is a written-down fail-open posture; on guard.wrap it has
|
|
191
|
+
// always been inert (check() whitelists its fields), and starting to read
|
|
192
|
+
// it would silently uncover the LLM for anyone who moved the key across.
|
|
193
|
+
const c = downGuard(clientWith())
|
|
194
|
+
let ran = false
|
|
195
|
+
await assert.rejects(
|
|
196
|
+
c.guard.wrap('hi', async () => { ran = true; return 'out' }, { failClosed: false }), CiphyrsError)
|
|
197
|
+
assert.equal(ran, false, 'a legacy key from another surface turned fail-closed off')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('does not mark a healthy round-trip as failedOpen', async () => {
|
|
201
|
+
const c = clientWith({ failOpen: true })
|
|
202
|
+
c.guard.check = async () => ({ decision: 'allow', detections: [], decision_id: 'd-1' })
|
|
203
|
+
const r = await c.guard.wrap('hi', async () => 'out')
|
|
204
|
+
assert.equal(r.failedOpen, undefined)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('still blocks on a real block verdict while failing open', async () => {
|
|
208
|
+
// Fail-open is about unreachability, not about ignoring decisions.
|
|
209
|
+
const c = clientWith({ failOpen: true })
|
|
210
|
+
c.guard.check = async () => ({ decision: 'block', reason: 'injection', detections: [], decision_id: 'd-2' })
|
|
211
|
+
let ran = false
|
|
212
|
+
const r = await c.guard.wrap('hi', async () => { ran = true; return 'out' })
|
|
213
|
+
assert.equal(r.blocked, true)
|
|
214
|
+
assert.equal(ran, false)
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// ── scan.protect ────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
describe('scan.protect — when the masker is unreachable', () => {
|
|
221
|
+
it('blocks by default: raw text never reaches the LLM', async () => {
|
|
222
|
+
const c = downMasker(clientWith())
|
|
223
|
+
let seen = null
|
|
224
|
+
await assert.rejects(c.scan.protect('my ssn is 123-45-6789', async (t) => { seen = t; return 'ok' }),
|
|
225
|
+
CiphyrsError)
|
|
226
|
+
assert.equal(seen, null, 'unmasked PII was handed to the LLM during an outage')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('sends raw text only when explicitly asked, and marks the result', async () => {
|
|
230
|
+
const c = downMasker(clientWith())
|
|
231
|
+
const warn = console.warn; const warned = []
|
|
232
|
+
console.warn = (m) => warned.push(m)
|
|
233
|
+
try {
|
|
234
|
+
const r = await c.scan.protect('my ssn is 123-45-6789', async (t) => `saw: ${t}`, { failOpen: true })
|
|
235
|
+
assert.equal(r.output, 'saw: my ssn is 123-45-6789')
|
|
236
|
+
assert.equal(r.failedOpen, true)
|
|
237
|
+
assert.equal(r.sessionId, null)
|
|
238
|
+
assert.ok(warned.some((m) => /UNMASKED/.test(m)),
|
|
239
|
+
'the one fail-open path that leaks PII must say so out loud')
|
|
240
|
+
} finally { console.warn = warn }
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('honours the client-level posture', async () => {
|
|
244
|
+
const c = downMasker(clientWith({ failOpen: true }))
|
|
245
|
+
const warn = console.warn; console.warn = () => {}
|
|
246
|
+
try {
|
|
247
|
+
const r = await c.scan.protect('hi', async (t) => t)
|
|
248
|
+
assert.equal(r.failedOpen, true)
|
|
249
|
+
} finally { console.warn = warn }
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('lets a call close a fail-open client back down', async () => {
|
|
253
|
+
const c = downMasker(clientWith({ failOpen: true }))
|
|
254
|
+
let seen = null
|
|
255
|
+
await assert.rejects(c.scan.protect('hi', async (t) => { seen = t; return 'ok' }, { failOpen: false }))
|
|
256
|
+
assert.equal(seen, null)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('ignores the legacy failClosed key — raw text still never reaches the LLM', async () => {
|
|
260
|
+
// This is the worst place for the legacy key to become live: on
|
|
261
|
+
// scan.protect, "fail open" means handing the customer's unmasked PII to
|
|
262
|
+
// their model. Nobody who copy-pasted an inert key from a protectTool call
|
|
263
|
+
// site asked for that.
|
|
264
|
+
const c = downMasker(clientWith())
|
|
265
|
+
let seen = null
|
|
266
|
+
await assert.rejects(
|
|
267
|
+
c.scan.protect('my ssn is 123-45-6789', async (t) => { seen = t; return 'ok' }, { failClosed: false }),
|
|
268
|
+
CiphyrsError)
|
|
269
|
+
assert.equal(seen, null, 'a legacy key from another surface sent unmasked PII to the LLM')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('does not mark a healthy round-trip as failedOpen', async () => {
|
|
273
|
+
const c = clientWith()
|
|
274
|
+
c.scan.mask = async () => ({ maskedText: '[PERSON_1]', sessionId: 's-1', entitiesFound: [] })
|
|
275
|
+
c.scan.restore = async () => ({ restoredText: 'Jane', tokensRestored: 1, purged: true })
|
|
276
|
+
const r = await c.scan.protect('Jane', async (t) => t)
|
|
277
|
+
assert.equal(r.failedOpen, undefined)
|
|
278
|
+
assert.equal(r.output, 'Jane')
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// ── All three, one outage ───────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
describe('the whole client under one outage', () => {
|
|
285
|
+
const stub = (c) => { downGuard(c); downMasker(c); c._toolCheck = async () => OUTAGE(); return c }
|
|
286
|
+
|
|
287
|
+
it('every entry point refuses by default', async () => {
|
|
288
|
+
const { protectTool, ToolBlocked } = await import('./protect-tool.js')
|
|
289
|
+
const c = stub(clientWith())
|
|
290
|
+
const ran = { tool: false, llm: false, mask: false }
|
|
291
|
+
const tool = protectTool(c, { agent: 'billing-bot', name: 'refund' }, async () => { ran.tool = true })
|
|
292
|
+
|
|
293
|
+
await assert.rejects(tool({ amount: 1 }), ToolBlocked)
|
|
294
|
+
await assert.rejects(c.guard.wrap('hi', async () => { ran.llm = true; return 'o' }))
|
|
295
|
+
await assert.rejects(c.scan.protect('hi', async () => { ran.mask = true; return 'o' }))
|
|
296
|
+
assert.deepEqual(ran, { tool: false, llm: false, mask: false })
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('one client-level failOpen moves every entry point together', async () => {
|
|
300
|
+
const { protectTool } = await import('./protect-tool.js')
|
|
301
|
+
const c = stub(clientWith({ failOpen: true }))
|
|
302
|
+
const warn = console.warn; console.warn = () => {}
|
|
303
|
+
try {
|
|
304
|
+
const ran = { tool: false, llm: false, mask: false }
|
|
305
|
+
await protectTool(c, { agent: 'billing-bot', name: 'refund' }, async () => { ran.tool = true })({ amount: 1 })
|
|
306
|
+
await c.guard.wrap('hi', async () => { ran.llm = true; return 'o' })
|
|
307
|
+
await c.scan.protect('hi', async () => { ran.mask = true; return 'o' })
|
|
308
|
+
assert.deepEqual(ran, { tool: true, llm: true, mask: true })
|
|
309
|
+
} finally { console.warn = warn }
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('the legacy failClosed key moves protectTool and ONLY protectTool', async () => {
|
|
313
|
+
// One key, three surfaces, and it is honoured on the one where it shipped.
|
|
314
|
+
// Both halves matter: dropping it on protectTool breaks callers who wrote
|
|
315
|
+
// their posture down, and honouring it on the other two silently uncovers
|
|
316
|
+
// callers who moved an inert key across.
|
|
317
|
+
const { protectTool } = await import('./protect-tool.js')
|
|
318
|
+
const c = stub(clientWith())
|
|
319
|
+
const ran = { tool: false, llm: false, mask: false }
|
|
320
|
+
await protectTool(c, { agent: 'billing-bot', name: 'refund', failClosed: false },
|
|
321
|
+
async () => { ran.tool = true })({ amount: 1 })
|
|
322
|
+
await assert.rejects(c.guard.wrap('hi', async () => { ran.llm = true; return 'o' }, { failClosed: false }))
|
|
323
|
+
await assert.rejects(c.scan.protect('hi', async () => { ran.mask = true; return 'o' }, { failClosed: false }))
|
|
324
|
+
assert.deepEqual(ran, { tool: true, llm: false, mask: false })
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// ── Anchored to the real transport ──────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
describe('a real transport failure reaches the posture', () => {
|
|
331
|
+
it('guard.wrap fails closed on a refused connection, not just on a stubbed throw', async () => {
|
|
332
|
+
// The stubs above assume request() surfaces an outage as a thrown error
|
|
333
|
+
// once its retries are spent. This one pays the retry budget to prove it.
|
|
334
|
+
//
|
|
335
|
+
// fetch is still stubbed rather than allowed to dial a dead port: a real
|
|
336
|
+
// dial is a socket, and this suite does not open sockets. The swap is the
|
|
337
|
+
// one thing this file does to a global — the socket guard is what makes
|
|
338
|
+
// getting the restore wrong a failed run instead of a production request.
|
|
339
|
+
const realFetch = globalThis.fetch
|
|
340
|
+
globalThis.fetch = async () => { throw new Error('ECONNREFUSED') }
|
|
341
|
+
try {
|
|
342
|
+
const c = clientWith()
|
|
343
|
+
let ran = false
|
|
344
|
+
await assert.rejects(c.guard.wrap('hi', async () => { ran = true; return 'o' }), CiphyrsError)
|
|
345
|
+
assert.equal(ran, false)
|
|
346
|
+
} finally { globalThis.fetch = realFetch }
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
// ── The suite may not touch the network ─────────────────────────────────────
|
|
351
|
+
//
|
|
352
|
+
// This file used to POST to https://www.ciphyrs.com/v1/agent-inventory/announce
|
|
353
|
+
// twice on every run, from the tool-inventory announce protectTool queues at
|
|
354
|
+
// wrap time. It fired 2s later, on the real fetch, after the last test had
|
|
355
|
+
// restored it — so no stub, in any order, could have stopped it.
|
|
356
|
+
|
|
357
|
+
describe('no test reaches the network', () => {
|
|
358
|
+
it('a real client is never pointed at production', () => {
|
|
359
|
+
const c = clientWith()
|
|
360
|
+
assert.equal(c._baseUrl, SENTINEL_BASE)
|
|
361
|
+
assert.ok(!/ciphyrs\.com/.test(c._baseUrl), 'a test client was aimed at the live service')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('wrapping a tool queues no announce at all', () => {
|
|
365
|
+
// Not "the announce fails harmlessly" — there is no timer to fire. This is
|
|
366
|
+
// the structural half; the socket guard is the backstop.
|
|
367
|
+
const c = clientWith()
|
|
368
|
+
assert.equal(typeof c._announceTools, 'undefined',
|
|
369
|
+
'protectTool would queue a debounced announce against this client')
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('the guard actually blocks a socket, and fails the run when one is opened', () => {
|
|
373
|
+
const message = expectBlocked(() => net.connect({ host: 'www.ciphyrs.com', port: 443 }))
|
|
374
|
+
assert.match(message, /www\.ciphyrs\.com:443/)
|
|
375
|
+
assert.match(message, /never reach the network/)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('records nothing when the suite behaves', () => {
|
|
379
|
+
// expectBlocked() above un-records its own deliberate attempt, so anything
|
|
380
|
+
// left here is a real escape — and the guard's exit handler turns a
|
|
381
|
+
// non-empty list into a non-zero exit code even for an attempt whose
|
|
382
|
+
// caller swallowed the error.
|
|
383
|
+
assert.deepEqual(networkAttempts(), [])
|
|
384
|
+
})
|
|
385
|
+
})
|