@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.
@@ -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))