@ciphyrshq/sdk 3.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphyrshq/sdk",
3
- "version": "3.0.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",
@@ -16,13 +16,29 @@
16
16
  "./secret-detector": "./src/secret-detector.js",
17
17
  "./eval-runner": "./src/eval-runner.js"
18
18
  },
19
- "files": ["src", "types.d.ts"],
20
- "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
+ ],
21
35
  "license": "MIT",
22
- "engines": { "node": ">=18" },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
23
39
  "repository": {
24
40
  "type": "git",
25
- "url": "https://github.com/praveen190/Ciphyrs.git",
41
+ "url": "git+https://github.com/praveen190/Ciphyrs.git",
26
42
  "directory": "packages/sdk"
27
43
  },
28
44
  "homepage": "https://ciphyrs.com",
@@ -1,99 +0,0 @@
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
- });
@@ -1,385 +0,0 @@
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
- })
@@ -1,108 +0,0 @@
1
- // ═══════════════════════════════════════════════════════════════════════════
2
- // The test suite may not open a socket. Enforced here, not by convention.
3
- //
4
- // WHY. fail-posture.test.js built a real CiphyrsClient — whose baseUrl
5
- // defaults to https://www.ciphyrs.com — and wrapped tools with protectTool,
6
- // which queues a tool-inventory announce on a 2s debounce. Every `npm test`
7
- // run therefore made two real POSTs to the PRODUCTION announce endpoint with
8
- // a fake API key, and the last test in that file swapped global fetch and
9
- // restored it, so the debounce fired on the real fetch after the restore. No
10
- // amount of stubbing in the right order fixes that: the announce is timed,
11
- // and the test that "protects" it has already finished.
12
- //
13
- // So the guard sits BELOW fetch, at the socket, and is installed when this
14
- // module is imported — before any test body runs. Whatever a test does to
15
- // globalThis.fetch, whichever library it reaches for, and whenever a stray
16
- // timer fires, the connection does not happen and the run fails.
17
- //
18
- // Import it for side effect at the top of every test file:
19
- //
20
- // import './no-network.test-helper.js'
21
- //
22
- // `node --test` runs each test file in its own process, so each file needs
23
- // its own import; a file without one is unguarded.
24
- //
25
- // Not part of the published API. It lives in src/ because src/*.test.js does,
26
- // and it is deliberately NOT named *.test.js so the runner does not treat it
27
- // as a suite of its own.
28
- // ═══════════════════════════════════════════════════════════════════════════
29
- import net from 'node:net'
30
- import tls from 'node:tls'
31
-
32
- /** Every blocked attempt, in order. Emptied only by expectBlocked(). */
33
- const attempts = []
34
-
35
- // net.connect() normalises its arguments before calling Socket.prototype.connect,
36
- // so what arrives is `[[{ host, port }, cb]]` for a URL-shaped dial and
37
- // `(port, host)` for a direct socket.connect — both are unwrapped here so the
38
- // failure message names the host a test was reaching for.
39
- const describeTarget = (arg, second) => {
40
- if (Array.isArray(arg)) return describeTarget(arg[0], arg[1])
41
- if (arg && typeof arg === 'object') return `${arg.host || arg.path || '?'}:${arg.port ?? '?'}`
42
- if (typeof arg === 'number') return `${typeof second === 'string' ? second : 'localhost'}:${arg}`
43
- return String(arg)
44
- }
45
-
46
- function refuse(via, ...args) {
47
- const target = describeTarget(args.length > 1 ? args : args[0])
48
- const message =
49
- `[no-network] a test tried to open a socket to ${target} (${via}). ` +
50
- 'Tests must never reach the network: stub the client method, or point the client at a fake. ' +
51
- 'If a real CiphyrsClient is needed, give it a sentinel baseUrl and neutralise _announceTools.'
52
- attempts.push(message)
53
- // Throwing is not enough on its own. The tool-inventory announce is
54
- // best-effort and swallows its own errors (`.catch(() => {})`), and a timer
55
- // that fires between tests belongs to no test, so an exception there would
56
- // be invisible. The process exit code is what the suite cannot swallow —
57
- // see the exit handler below.
58
- throw new Error(message)
59
- }
60
-
61
- net.Socket.prototype.connect = function blockedConnect(...args) {
62
- refuse('net.Socket.connect', ...args)
63
- }
64
-
65
- // TLSSocket inherits the patched connect above, so this is belt and braces for
66
- // the paths that hand tls.connect an already-open socket.
67
- tls.connect = function blockedTlsConnect(...args) {
68
- refuse('tls.connect', ...args)
69
- }
70
-
71
- process.on('exit', () => {
72
- if (!attempts.length) return
73
- console.error(`\n[no-network] ${attempts.length} network attempt(s) were blocked during this test file:`)
74
- for (const a of attempts) console.error(` - ${a}`)
75
- // A test that opened a socket may still have "passed" — the announce that
76
- // started this swallowed its own failure. Failing the process is what makes
77
- // the run red: node --test reports a non-zero subprocess as a failed file.
78
- process.exitCode = 1
79
- })
80
-
81
- /**
82
- * The attempts blocked so far. For assertions; do not mutate.
83
- */
84
- export function networkAttempts() {
85
- return [...attempts]
86
- }
87
-
88
- /**
89
- * Run `fn`, assert it was stopped by this guard, and un-record the attempt so
90
- * the deliberate one does not fail the run. This is how the guard proves it
91
- * still works — a guard nothing exercises is a guard that quietly stops
92
- * guarding.
93
- *
94
- * @returns {string} the message the guard produced
95
- */
96
- export function expectBlocked(fn) {
97
- const before = attempts.length
98
- let err
99
- try { fn() } catch (e) { err = e }
100
- const recorded = attempts.splice(before)
101
- if (!recorded.length) {
102
- throw new Error('[no-network] expected the guard to block this call, but it went through')
103
- }
104
- if (!err || !/no-network/.test(err.message)) {
105
- throw new Error(`[no-network] the guard recorded the attempt but did not throw for it (got: ${err?.message})`)
106
- }
107
- return recorded[0]
108
- }