@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,362 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// protectTool — V60
|
|
3
|
+
//
|
|
4
|
+
// Tool-call authorization wrapper for any async function. Mirrors the
|
|
5
|
+
// Python SDK's @protect_tool decorator. Use it to gate a tool function so
|
|
6
|
+
// that, before it runs, Ciphyrs evaluates the customer's per-agent /
|
|
7
|
+
// per-tool rules and decides whether the call is allowed, blocked, or
|
|
8
|
+
// needs human approval.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
//
|
|
12
|
+
// import { CiphyrsClient, protectTool } from '@ciphyrshq/sdk'
|
|
13
|
+
//
|
|
14
|
+
// const client = new CiphyrsClient({ apiKey: 'cyp_live_...' })
|
|
15
|
+
//
|
|
16
|
+
// // Async tool
|
|
17
|
+
// const refundPayment = protectTool(client, {
|
|
18
|
+
// agent: 'billing-bot',
|
|
19
|
+
// name: 'refund_payment',
|
|
20
|
+
// }, async ({ customerId, amount }) => {
|
|
21
|
+
// return stripe.refunds.create({ customer: customerId, amount })
|
|
22
|
+
// })
|
|
23
|
+
//
|
|
24
|
+
// // Now this call goes through Ciphyrs first:
|
|
25
|
+
// await refundPayment({ customerId: 'cus_abc', amount: 85000 })
|
|
26
|
+
// // -> if a rule matches, throws ToolBlocked or pauses for approval.
|
|
27
|
+
// // -> if allowed, the underlying function runs.
|
|
28
|
+
//
|
|
29
|
+
// Failure semantics: fail-CLOSED by default. If Ciphyrs cannot be reached,
|
|
30
|
+
// the tool does NOT run and ToolBlocked is thrown. This is a deliberate
|
|
31
|
+
// change of a published default — protectTool used to fail OPEN, so an
|
|
32
|
+
// outage of ours ran the customer's tool unauthorised, while guard.wrap and
|
|
33
|
+
// scan.protect on the same client refused to proceed under the identical
|
|
34
|
+
// outage. This is the path that moves money, so it is the one that gets the
|
|
35
|
+
// safe default, and all three now read the same option:
|
|
36
|
+
//
|
|
37
|
+
// protectTool(client, { agent, failOpen: true }, fn) // per call
|
|
38
|
+
// new CiphyrsClient({ apiKey, failOpen: true }) // per client
|
|
39
|
+
//
|
|
40
|
+
// { failClosed: true } and { failClosed: false } both still mean exactly
|
|
41
|
+
// what they did before, so nobody who already wrote down a posture is moved.
|
|
42
|
+
// That legacy key is read HERE and nowhere else — it is the only surface it
|
|
43
|
+
// ever shipped on, and on guard.wrap and scan.protect it has always been
|
|
44
|
+
// inert. A null or unreadable posture on either spelling means "not chosen",
|
|
45
|
+
// which is fail closed; only a value that unmistakably says fail open does.
|
|
46
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
47
|
+
import { activeIds, activeSpanKind } from './context.js'
|
|
48
|
+
import { resolveFailOpen, FAIL_OPEN_HINT } from './fail-posture.js'
|
|
49
|
+
|
|
50
|
+
export class ToolBlocked extends Error {
|
|
51
|
+
constructor(action, reason, opts = {}) {
|
|
52
|
+
super(`[ciphyrs] tool blocked (${action}): ${reason}`)
|
|
53
|
+
this.name = 'ToolBlocked'
|
|
54
|
+
this.action = action
|
|
55
|
+
this.reason = reason
|
|
56
|
+
this.ruleId = opts.ruleId
|
|
57
|
+
this.ruleName = opts.ruleName
|
|
58
|
+
this.decisionId = opts.decisionId
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class ToolApprovalTimeout extends ToolBlocked {
|
|
63
|
+
constructor(reason) {
|
|
64
|
+
super('timeout', reason)
|
|
65
|
+
this.name = 'ToolApprovalTimeout'
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Wrap an async function with Ciphyrs tool-call authorization.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} client CiphyrsClient instance
|
|
73
|
+
* @param {object} opts
|
|
74
|
+
* @param {string} opts.agent Agent name (used to scope rules)
|
|
75
|
+
* @param {string} [opts.name] Tool name reported to Ciphyrs (default: fn.name)
|
|
76
|
+
* @param {boolean} [opts.failOpen] If true, run the tool anyway when Ciphyrs is
|
|
77
|
+
* unreachable. Defaults to the client's posture,
|
|
78
|
+
* which itself defaults to FALSE (fail closed).
|
|
79
|
+
* @param {boolean} [opts.failClosed]
|
|
80
|
+
* Legacy spelling of the same switch, honoured in
|
|
81
|
+
* both directions (`false` still means fail open).
|
|
82
|
+
* @param {number} [opts.pollIntervalMs=1000]
|
|
83
|
+
* Approval poll interval
|
|
84
|
+
* @param {number} [opts.maxPollSeconds]
|
|
85
|
+
* Hard cap on poll duration; defaults to whatever the rule says
|
|
86
|
+
* @param {(args: any) => any} fn The async tool function to wrap
|
|
87
|
+
* @returns {(args: any) => Promise<any>}
|
|
88
|
+
*/
|
|
89
|
+
export function protectTool(client, opts, fn) {
|
|
90
|
+
// Validate BEFORE destructuring: `name = fn.name` below dereferences fn, so
|
|
91
|
+
// with the check underneath it the caller got "Cannot read properties of
|
|
92
|
+
// null (reading 'name')" instead of the message written for them — and the
|
|
93
|
+
// check never ran at all. That is the error for the likeliest misuse of a
|
|
94
|
+
// three-argument function: passing the arguments in the wrong order.
|
|
95
|
+
if (typeof fn !== 'function') throw new Error('protectTool: a function is required as the third argument')
|
|
96
|
+
|
|
97
|
+
const {
|
|
98
|
+
agent,
|
|
99
|
+
name = fn.name || 'anonymous',
|
|
100
|
+
pollIntervalMs = 1000,
|
|
101
|
+
maxPollSeconds,
|
|
102
|
+
} = opts || {}
|
|
103
|
+
|
|
104
|
+
if (!agent) throw new Error('protectTool: opts.agent is required')
|
|
105
|
+
|
|
106
|
+
// Resolved at wrap time, not per invocation: the wrapped function's
|
|
107
|
+
// signature belongs to the customer's tool, so there is nowhere to put a
|
|
108
|
+
// per-invocation option without colliding with its arguments. The wrap site
|
|
109
|
+
// IS the per-call level here; the client supplies the fleet-wide default.
|
|
110
|
+
// `legacyFailClosed` is opted into HERE and nowhere else: protectTool is the
|
|
111
|
+
// only surface the legacy key ever shipped on, so it is the only surface
|
|
112
|
+
// where honouring it preserves behaviour rather than creating new behaviour.
|
|
113
|
+
const failOpen = resolveFailOpen(opts, client?._failOpen, { legacyFailClosed: true })
|
|
114
|
+
|
|
115
|
+
// Announce this tool to the inventory at wrap time (debounced per client,
|
|
116
|
+
// best-effort). The dashboard sees — and can write allowlist policy for —
|
|
117
|
+
// every decorated tool seconds after the agent process boots, before any
|
|
118
|
+
// traffic. Without this, rarely-called tools stay invisible until their
|
|
119
|
+
// first invocation, which makes default-deny allowlists incomplete.
|
|
120
|
+
queueToolAnnounce(client, agent, {
|
|
121
|
+
name,
|
|
122
|
+
description: opts?.description,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return async function protectedTool(...args) {
|
|
126
|
+
const argsPayload = serializeArgs(args)
|
|
127
|
+
|
|
128
|
+
// WHICH STEP is asking. The gateway's trust.001 rule ("tool ran without a
|
|
129
|
+
// tool-check") correlates an exported tool span against the gate call that
|
|
130
|
+
// authorised it by span id — see services/api-gateway/src/lib/trust-checks.js,
|
|
131
|
+
// unverifiedToolSpans() and spanCorrelatedAgents(). Field names are the
|
|
132
|
+
// gateway's: POST /v1/guard/tool-check destructures `trace_id` and
|
|
133
|
+
// `span_id` off the body. Read producerIds() for why the span id is sent
|
|
134
|
+
// so rarely, and why sending the wrong one is worse than sending none.
|
|
135
|
+
const { trace_id, span_id } = producerIds()
|
|
136
|
+
|
|
137
|
+
let verdict
|
|
138
|
+
try {
|
|
139
|
+
verdict = await client._toolCheck({
|
|
140
|
+
agent_name: agent,
|
|
141
|
+
tool_name: name,
|
|
142
|
+
args: argsPayload,
|
|
143
|
+
// Omitted, never invented and never borrowed from a span of another
|
|
144
|
+
// kind: an id that is not this tool call's own would not merely fail to
|
|
145
|
+
// correlate, it would tell trust.001 that correlation IS possible for
|
|
146
|
+
// this agent and let it judge spans it can never match — a
|
|
147
|
+
// high-severity accusation against a compliant app.
|
|
148
|
+
...(trace_id ? { trace_id } : {}),
|
|
149
|
+
...(span_id ? { span_id } : {}),
|
|
150
|
+
})
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (!failOpen) {
|
|
153
|
+
throw new ToolBlocked('error',
|
|
154
|
+
`Ciphyrs unreachable, so the tool did not run: ${err.message}. ` +
|
|
155
|
+
`Tool execution fails closed by default. ${FAIL_OPEN_HINT}`)
|
|
156
|
+
}
|
|
157
|
+
// Fail open — explicitly asked for.
|
|
158
|
+
return fn(...args)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (verdict.action === 'require_approval') {
|
|
162
|
+
verdict = await pollApproval(
|
|
163
|
+
client, verdict.approval_id,
|
|
164
|
+
pollIntervalMs,
|
|
165
|
+
(maxPollSeconds || verdict.approval_timeout_seconds || 300) * 1000,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
enforce(verdict, failOpen)
|
|
170
|
+
// The tool runs on the args the POLICY approved, which are not always the
|
|
171
|
+
// args the caller passed. enforce() treats redact_args as permitted; if
|
|
172
|
+
// the redaction were not applied here too, a redact_args rule would report
|
|
173
|
+
// success on the dashboard while the tool received the raw value — the
|
|
174
|
+
// exact opposite of what the customer configured.
|
|
175
|
+
return fn(...applyRedaction(args, verdict))
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─── Internals ─────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
// `gs_<hex>` is what routes/guard.js mints when the caller sends no span id,
|
|
182
|
+
// and lib/trust-checks.js reads a stored id of that shape as proof that the
|
|
183
|
+
// producer did NOT say which step it was asking about. See producerIds().
|
|
184
|
+
const GATEWAY_MINTED_SPAN_ID = /^gs_[0-9a-f]{1,32}$/
|
|
185
|
+
|
|
186
|
+
// The span kinds the gateway will ingest AS a tool span — TOOL_KINDS in
|
|
187
|
+
// lib/trust-checks.js. Only a span of one of these kinds is the span this tool
|
|
188
|
+
// call will be judged by, so only its id is worth putting on the wire.
|
|
189
|
+
const TOOL_SPAN_KINDS = new Set(['tool', 'tool_use', 'tool_call'])
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The ids to attribute this gate call to.
|
|
193
|
+
*
|
|
194
|
+
* `trace_id` whenever a trace is open: it is always true, and it is what the
|
|
195
|
+
* batch read uses to find this agent's records at all.
|
|
196
|
+
*
|
|
197
|
+
* `span_id` ONLY when the ambient span is a TOOL span — i.e. when the id we
|
|
198
|
+
* would send is the id of the span this tool call will actually export.
|
|
199
|
+
* protectTool emits no span of its own; it wraps a function, it does not trace
|
|
200
|
+
* it. So the ambient span is normally the enclosing AGENT span, and sending
|
|
201
|
+
* that id does damage in both directions at once:
|
|
202
|
+
*
|
|
203
|
+
* - unverifiedToolSpans() exonerates a tool span only when a record carries
|
|
204
|
+
* the SAME span id. An agent-span id can never equal a tool-span id, so
|
|
205
|
+
* the exact-match path stays dead — exactly as dead as sending nothing.
|
|
206
|
+
* - spanCorrelatedAgents() treats any non-`gs_` id as proof that this
|
|
207
|
+
* agent's spans and its gate calls CAN be matched exactly. That moves the
|
|
208
|
+
* agent out of `agent_not_span_correlated` — trust.001's abstention, "we
|
|
209
|
+
* refuse to judge you" — and into judgement on the (agent, tool) ±2-minute
|
|
210
|
+
* fallback, a correlation nothing has ever validated, for a high-severity
|
|
211
|
+
* enforcement_bypass accusation.
|
|
212
|
+
*
|
|
213
|
+
* So the wrong id converts abstention into judgement on a guess. Sending
|
|
214
|
+
* nothing keeps the abstention, which is the honest posture for a wrapper that
|
|
215
|
+
* does not know which span it is inside. A remote parent never qualifies
|
|
216
|
+
* either: its kind does not travel in baggage, and it is the CALLER's span,
|
|
217
|
+
* not the span this tool call exports.
|
|
218
|
+
*/
|
|
219
|
+
function producerIds() {
|
|
220
|
+
const { trace_id, span_id } = activeIds()
|
|
221
|
+
// `gs_…` is what routes/guard.js mints when the caller sends no span id, and
|
|
222
|
+
// lib/trust-checks.js reads a stored id of that shape as proof that the
|
|
223
|
+
// producer did NOT say which step it was asking about. context.js can hand
|
|
224
|
+
// back a span id carried in inbound Ciphyrs baggage — an arbitrary string
|
|
225
|
+
// chosen by whoever called us — so echoing a gateway-shaped one back would
|
|
226
|
+
// claim the producer chose an id that says correlation is impossible.
|
|
227
|
+
const namesThisToolSpan =
|
|
228
|
+
TOOL_SPAN_KINDS.has(activeSpanKind())
|
|
229
|
+
&& !!span_id && !GATEWAY_MINTED_SPAN_ID.test(span_id)
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
trace_id: trace_id || undefined,
|
|
233
|
+
span_id: namesThisToolSpan ? span_id : undefined,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Startup announce: collect tools per (client, agent) as they're wrapped,
|
|
238
|
+
// flush once 2s after the last registration. WeakMap so we never keep a
|
|
239
|
+
// client alive; timer is unref()'d so it never holds the process open;
|
|
240
|
+
// failures are swallowed — announce is telemetry, not enforcement.
|
|
241
|
+
const _announceRegistry = new WeakMap() // client -> { agents: Map<agent, Map<name, meta>>, timer }
|
|
242
|
+
|
|
243
|
+
function queueToolAnnounce(client, agent, tool) {
|
|
244
|
+
if (!client || typeof client._announceTools !== 'function') return
|
|
245
|
+
let entry = _announceRegistry.get(client)
|
|
246
|
+
if (!entry) {
|
|
247
|
+
entry = { agents: new Map(), timer: null }
|
|
248
|
+
_announceRegistry.set(client, entry)
|
|
249
|
+
}
|
|
250
|
+
if (!entry.agents.has(agent)) entry.agents.set(agent, new Map())
|
|
251
|
+
entry.agents.get(agent).set(tool.name, {
|
|
252
|
+
name: tool.name,
|
|
253
|
+
description: tool.description ? String(tool.description).slice(0, 1000) : undefined,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
257
|
+
entry.timer = setTimeout(() => {
|
|
258
|
+
entry.timer = null
|
|
259
|
+
const batches = [...entry.agents.entries()]
|
|
260
|
+
entry.agents = new Map()
|
|
261
|
+
for (const [agentName, tools] of batches) {
|
|
262
|
+
client._announceTools(agentName, [...tools.values()]).catch(() => {})
|
|
263
|
+
}
|
|
264
|
+
}, 2000)
|
|
265
|
+
if (typeof entry.timer.unref === 'function') entry.timer.unref()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function serializeArgs(args) {
|
|
269
|
+
// args is the rest-array. If the function is called with one object,
|
|
270
|
+
// unwrap it so rules can reference fields by name (`args.amount`).
|
|
271
|
+
// Otherwise return the positional list under `args`.
|
|
272
|
+
if (args.length === 1 && args[0] && typeof args[0] === 'object' && !Array.isArray(args[0])) {
|
|
273
|
+
return safeClone(args[0])
|
|
274
|
+
}
|
|
275
|
+
return { args: args.map(safeClone) }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function safeClone(v) {
|
|
279
|
+
try {
|
|
280
|
+
return JSON.parse(JSON.stringify(v, (_k, val) => {
|
|
281
|
+
if (typeof val === 'function') return `<fn:${val.name || 'anon'}>`
|
|
282
|
+
if (typeof val === 'undefined') return null
|
|
283
|
+
if (typeof val === 'bigint') return val.toString()
|
|
284
|
+
return val
|
|
285
|
+
}))
|
|
286
|
+
} catch {
|
|
287
|
+
return `<non-serializable: ${typeof v}>`
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function pollApproval(client, approvalId, intervalMs, maxMs) {
|
|
292
|
+
const deadline = Date.now() + maxMs
|
|
293
|
+
while (Date.now() < deadline) {
|
|
294
|
+
await sleep(intervalMs)
|
|
295
|
+
let r
|
|
296
|
+
try { r = await client._approvalStatus(approvalId) }
|
|
297
|
+
catch { continue }
|
|
298
|
+
if (r.status === 'approved') return { action: 'allow', reason: r.reason || 'approved' }
|
|
299
|
+
// 'cancelled' is in the agent_approvals CHECK constraint alongside the
|
|
300
|
+
// other two. Omitting it did not let anything through — the loop just
|
|
301
|
+
// failed to notice the decision and waited out the window — but the agent
|
|
302
|
+
// then stalled for the full approval timeout on a call already withdrawn,
|
|
303
|
+
// and reported it as a timeout rather than a cancellation.
|
|
304
|
+
if (r.status === 'denied' || r.status === 'timeout' || r.status === 'cancelled') {
|
|
305
|
+
throw new ToolBlocked(r.status, r.reason || r.status)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
throw new ToolApprovalTimeout(`No approval within ${Math.round(maxMs/1000)}s`)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Rebuild the call arguments from a redact_args verdict.
|
|
313
|
+
*
|
|
314
|
+
* Has to mirror serializeArgs() exactly, because that is the shape the server
|
|
315
|
+
* redacted: a lone object went up unwrapped, so the redaction comes back
|
|
316
|
+
* unwrapped; positional arguments went up under `args`, so they come back
|
|
317
|
+
* there too.
|
|
318
|
+
*/
|
|
319
|
+
function applyRedaction(args, verdict) {
|
|
320
|
+
const action = verdict.action || verdict.decision
|
|
321
|
+
if (action !== 'redact_args') return args
|
|
322
|
+
|
|
323
|
+
const redacted = verdict.redacted_args
|
|
324
|
+
if (!redacted || typeof redacted !== 'object') {
|
|
325
|
+
// The gateway populates redacted_args whenever it returns this action, so
|
|
326
|
+
// arriving here means the verdict is malformed. Running the tool on the
|
|
327
|
+
// originals would be the silent-leak bug this function exists to close,
|
|
328
|
+
// and there is no safe way to guess what should have been masked.
|
|
329
|
+
throw new ToolBlocked('redact_args',
|
|
330
|
+
'policy required argument redaction but the verdict carried no redacted_args')
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const singleObject = args.length === 1 && args[0]
|
|
334
|
+
&& typeof args[0] === 'object' && !Array.isArray(args[0])
|
|
335
|
+
if (singleObject) return [redacted]
|
|
336
|
+
if (Array.isArray(redacted.args)) return redacted.args
|
|
337
|
+
throw new ToolBlocked('redact_args',
|
|
338
|
+
'policy required argument redaction but the verdict did not match the call shape')
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function enforce(verdict, failOpen = false) {
|
|
342
|
+
const declared = verdict.action || verdict.decision
|
|
343
|
+
// A response carrying no verdict at all is a NON-DECISION. Defaulting it to
|
|
344
|
+
// 'allow' is how a misconfigured proxy or a half-deployed gateway turns an
|
|
345
|
+
// outage into blanket permission — the failure the inline proxy already
|
|
346
|
+
// shipped once (HTTP 200 + decision:'allow' on internal error). A caller who
|
|
347
|
+
// asked to fail open has asked for that too; everyone else gets the block.
|
|
348
|
+
if (!declared && !failOpen) {
|
|
349
|
+
throw new ToolBlocked('error',
|
|
350
|
+
'Ciphyrs returned no verdict — neither action nor decision present — so the tool did not run. ' +
|
|
351
|
+
FAIL_OPEN_HINT)
|
|
352
|
+
}
|
|
353
|
+
const action = declared || 'allow'
|
|
354
|
+
if (action === 'allow' || action === 'log_only' || action === 'redact_args') return
|
|
355
|
+
throw new ToolBlocked(action, verdict.reason || 'blocked by Ciphyrs rule', {
|
|
356
|
+
ruleId: verdict.rule_id,
|
|
357
|
+
ruleName: verdict.rule_name,
|
|
358
|
+
decisionId: verdict.decision_id,
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|