@emilia-protocol/openai-agents 0.1.1 → 0.1.3

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 CHANGED
@@ -47,11 +47,15 @@ const cancelOrder = tool({
47
47
 
48
48
  const agent = new Agent({ name: 'Ops', tools: [cancelOrder] });
49
49
 
50
- // One gate, configured once. actionFor maps a tool call -> canonical EP action_type.
50
+ // One gate, configured once. actionFor maps a tool call -> the canonical EP
51
+ // action_type the receipt must be bound to. For real safety, bind to the
52
+ // SPECIFIC target the call acts on (not just the tool name) so a receipt for one
53
+ // resource can't authorize another — and fold in the call identity so the same
54
+ // receipt can't be reused across two different tool calls.
51
55
  const gate = requireReceiptForOpenAIAgent({
52
56
  trustedKeys: [process.env.EMILIA_ISSUER_KEY], // base64url SPKI-DER issuer key(s) you trust
53
57
  maxAgeSec: 900,
54
- actionFor: (toolName /*, args */) => `openai.tool.${toolName}`,
58
+ actionFor: (toolName, args) => `openai.tool.${toolName}:${args?.orderId ?? ''}`,
55
59
  });
56
60
 
57
61
  let result = await run(agent, 'Cancel order 4242');
@@ -102,6 +106,9 @@ For every pending tool-approval interruption:
102
106
  ## Production note
103
107
 
104
108
  - **Pin `trustedKeys`** to your real issuer key(s). **Drop `allowInlineKey`** (it only proves integrity, never trust).
109
+ - **Bind to the target, not just the tool.** Make `actionFor` incorporate the specific resource the call touches (and ideally the `callId` / an args hash), so a receipt minted for one action can't be replayed against a different one.
110
+ - A receipt is **consumed only when it actually drives an `approve`** — never on a reject — so a blocked approval stays retryable with a fresh, valid receipt.
111
+ - **Rejections are sanitized:** a reject decision carries only a machine-readable `reason` code, never the signer, the subject, or verifier internals.
105
112
  - The **consumed-receipt set is per-process** (replay defense across restarts/instances needs a shared store — see `@emilia-protocol/gate`).
106
113
  - This is **necessary, not sufficient**. It composes with — and never substitutes for — the resource owner's own authorization and policy checks. It makes the human approval that OpenAI already asks for into auditable, portable evidence; it does not decide whether the action *should* be allowed.
107
114
 
package/index.d.ts CHANGED
@@ -36,7 +36,13 @@ export interface RequireReceiptForOpenAIAgentOptions {
36
36
  allowInlineKey?: boolean;
37
37
  /** Reject receipts older than this many seconds. Default 900. */
38
38
  maxAgeSec?: number;
39
- /** REQUIRED. Map a tool call to the canonical EP action_type the receipt must bind. */
39
+ /**
40
+ * REQUIRED. Map a tool call to the canonical EP action_type the receipt must
41
+ * bind. For per-target binding, incorporate the SPECIFIC resource the call
42
+ * acts on (e.g. `payment.release:${args.destination}`); to stop a receipt
43
+ * being reused across distinct calls, fold in the call identity (the
44
+ * interruption's callId or a stable hash of args).
45
+ */
40
46
  actionFor: (toolName: string, args: unknown) => string;
41
47
  }
42
48
 
package/index.js CHANGED
@@ -40,10 +40,14 @@
40
40
  // by relative path — the same convention @emilia-protocol/gate uses. When this
41
41
  // package is installed from npm, the published build resolves the bare
42
42
  // "@emilia-protocol/require-receipt" specifier; both point at the same module.
43
- import { verifyEmiliaReceipt } from '../require-receipt/index.js';
43
+ // The canonical makeReceiptGate encodes verify + target binding + reserve→
44
+ // commit/release replay safety + sanitized {reason} rejections in one place.
45
+ import { makeReceiptGate } from '../require-receipt/gate.js';
44
46
 
45
- /** Process-local set of consumed receipt_ids (replay defense). Per-process only. */
47
+ /** Process-local set of consumed receipt_ids (replay defense). Per-process only.
48
+ * Shared across every per-action gate so one receipt is spent at most once. */
46
49
  const consumed = new Set();
50
+ const sharedStore = { has: (id) => consumed.has(id), add: (id) => consumed.add(id) };
47
51
 
48
52
  /** Reset the consumed-receipt set. Test/ops helper — not a production control. */
49
53
  export function _resetConsumed() {
@@ -128,15 +132,47 @@ export function requireReceiptForOpenAIAgent(opts = {}) {
128
132
  throw new TypeError('requireReceiptForOpenAIAgent: opts.actionFor (toolName, args) => action_type is required');
129
133
  }
130
134
 
135
+ // One canonical gate per bound action. The action_type IS the binding (the
136
+ // verifier matches the receipt's claim.action_type to it), so callId is NOT
137
+ // folded into the gate's action — doing so would break verification against a
138
+ // receipt minted for the bare action. All gates share one consumed store so a
139
+ // receipt is spent at most once across calls; the compound receipt_id#callId
140
+ // key (below) is the belt-and-suspenders cross-call defense.
141
+ const gates = new Map();
142
+ const gateFor = (action) => {
143
+ let gate = gates.get(action);
144
+ if (!gate) {
145
+ gate = makeReceiptGate({ action, trustedKeys, allowInlineKey, maxAgeSec, store: sharedStore });
146
+ gates.set(action, gate);
147
+ }
148
+ return gate;
149
+ };
150
+
151
+ // Track the reservation a decide() approval holds, so resolve() can commit it
152
+ // (on a driven approve) or release it (rollback) — keyed by receipt_id.
153
+ const pending = new Map();
154
+
131
155
  /**
132
- * Decide a single interruption against a single receipt. Pure: no side effects
133
- * EXCEPT marking a receipt_id consumed when (and only when) it is the valid
134
- * receipt that earns an approve.
156
+ * Decide a single interruption against a single receipt.
157
+ *
158
+ * The decision NEVER carries the raw verifier output — only an allowlisted,
159
+ * sanitized shape ({decision, action, toolName, callId, reason} plus, on the
160
+ * approve path only, receipt_id + the accountable subject). A rejection never
161
+ * leaks the signer, the verifier's `detail`, or any other library internals.
162
+ *
163
+ * Consume-on-approve: when `decide` returns an `approve`, it marks the
164
+ * receipt_id consumed (replay defense) — and ONLY then. A reject (including a
165
+ * replayed or invalid receipt) never consumes anything, so a blocked approval
166
+ * stays retryable with a fresh, valid receipt. Replay is still checked here,
167
+ * before any approval, so a receipt can satisfy at most one interruption.
135
168
  */
136
169
  function decide(interruption, receipt) {
137
170
  const toolName = interruptionToolName(interruption);
138
171
  const callId = interruptionCallId(interruption);
139
172
  const args = interruptionArgs(interruption);
173
+ // Bind the receipt to THIS tool call. actionFor SHOULD incorporate the
174
+ // specific call identity (callId or a hash of args) so a single receipt
175
+ // cannot be reused across distinct tool calls — see the README note.
140
176
  const action = toolName != null ? actionFor(toolName, args) : null;
141
177
 
142
178
  const base = { action, toolName, callId };
@@ -148,28 +184,49 @@ export function requireReceiptForOpenAIAgent(opts = {}) {
148
184
  return { decision: 'reject', ...base, reason: 'no_receipt_for_interruption' };
149
185
  }
150
186
 
151
- const v = verifyEmiliaReceipt(receipt, {
152
- trustedKeys,
153
- allowInlineKey,
154
- maxAgeSec,
155
- action, // binds the receipt's claim.action_type to THIS tool call
156
- });
157
- if (!v.ok) {
158
- return { decision: 'reject', ...base, reason: v.reason || 'invalid_receipt' };
187
+ // The action_type is the binding — the gate verifies the receipt against it.
188
+ // callId is NOT passed as a target (that would change the verified binding);
189
+ // it is only used for the compound cross-call replay key below.
190
+ const gate = gateFor(action);
191
+
192
+ // Belt-and-suspenders cross-call defense: refuse if the SAME receipt was
193
+ // already used for a DIFFERENT call. We can only check this once we know the
194
+ // receipt_id, so peek at it from the receipt before the gate runs.
195
+ const receiptId = receipt?.payload?.receipt_id;
196
+ const callKey = receiptId && callId != null ? `${receiptId}#${callId}` : null;
197
+
198
+ // gate.check verifies (sanitized reason), enforces action binding + trust,
199
+ // and reserves the receipt (one-time consumption / replay safety).
200
+ const c = gate.check(receipt);
201
+ if (!c.ok) {
202
+ // Map the gate's sanitized refusal to this adapter's decision vocabulary.
203
+ // The gate uses `replay_refused`; this API has long exposed `receipt_replayed`.
204
+ const reason = c.body?.rejected?.reason || 'invalid_receipt';
205
+ if (reason === 'replay_refused') {
206
+ return { decision: 'reject', ...base, reason: 'receipt_replayed', receipt_id: receiptId };
207
+ }
208
+ return { decision: 'reject', ...base, reason };
159
209
  }
160
210
 
161
- // Replay defense: a receipt_id may earn an approval only once per process.
162
- if (v.receipt_id && consumed.has(v.receipt_id)) {
163
- return { decision: 'reject', ...base, reason: 'receipt_replayed', receipt_id: v.receipt_id };
211
+ // The gate cleared verify + bare-receipt_id replay. Now apply the compound
212
+ // call-scoped guard: if this exact receipt already drove a DIFFERENT call,
213
+ // refuse and release the reservation the gate just took.
214
+ if (callKey && consumed.has(callKey)) {
215
+ gate.release(c.receiptId);
216
+ return { decision: 'reject', ...base, reason: 'receipt_replayed', receipt_id: c.receiptId };
164
217
  }
165
- if (v.receipt_id) consumed.add(v.receipt_id);
218
+
219
+ // Record the reservation so resolve() can commit (driven approve) or release
220
+ // (rollback). decide() reserves but does NOT commit — a standalone approve
221
+ // that is never driven leaves the receipt retryable, matching prior behavior.
222
+ pending.set(c.receiptId, { gate, callKey });
166
223
 
167
224
  return {
168
225
  decision: 'approve',
169
226
  ...base,
170
227
  reason: 'valid_action_bound_receipt',
171
- receipt_id: v.receipt_id,
172
- subject: v.subject,
228
+ receipt_id: c.receiptId,
229
+ subject: c.subject,
173
230
  };
174
231
  }
175
232
 
@@ -222,13 +279,57 @@ export function requireReceiptForOpenAIAgent(opts = {}) {
222
279
  const rejected = [];
223
280
  const decisions = [];
224
281
 
282
+ // Release the reservation decide() took when the approval is not actually
283
+ // driven (no approve() to call, or approve() threw) — keeps it retryable.
284
+ const releasePending = (d) => {
285
+ if (!d.receipt_id) return;
286
+ const p = pending.get(d.receipt_id);
287
+ if (p) {
288
+ p.gate.release(d.receipt_id);
289
+ pending.delete(d.receipt_id);
290
+ }
291
+ };
292
+
293
+ // Finalize one-time consumption for a driven approve: commit in the gate and
294
+ // also record the compound call-scoped key for the cross-call replay guard.
295
+ const commitPending = (d) => {
296
+ if (!d.receipt_id) return;
297
+ const p = pending.get(d.receipt_id);
298
+ if (p) {
299
+ p.gate.commit(d.receipt_id); // moves receipt_id into the shared store
300
+ if (p.callKey) consumed.add(p.callKey);
301
+ pending.delete(d.receipt_id);
302
+ }
303
+ };
304
+
225
305
  for (const interruption of interruptions) {
226
306
  const receipt = lookupReceipt(receipts, interruption);
227
307
  const d = decide(interruption, receipt);
228
308
  decisions.push(d);
229
309
  if (d.decision === 'approve') {
230
- approved.push(interruption);
231
- if (state && typeof state.approve === 'function') state.approve(interruption);
310
+ // decide() RESERVED the receipt for this approve. Drive the SDK's
311
+ // approval now; commit (spend) only if approve() succeeds. If approve()
312
+ // is missing or throws, release the reservation so the (un-driven)
313
+ // approval stays retryable — a receipt is spent only when it drives one.
314
+ if (state && typeof state.approve === 'function') {
315
+ try {
316
+ state.approve(interruption);
317
+ commitPending(d);
318
+ approved.push(interruption);
319
+ } catch (err) {
320
+ releasePending(d);
321
+ d.decision = 'reject';
322
+ d.reason = 'approve_failed';
323
+ rejected.push(interruption);
324
+ if (state && typeof state.reject === 'function') {
325
+ state.reject(interruption, { message: `EMILIA: approve_failed` });
326
+ }
327
+ }
328
+ } else {
329
+ // No approve() to drive -> do not spend the receipt.
330
+ releasePending(d);
331
+ approved.push(interruption);
332
+ }
232
333
  } else {
233
334
  rejected.push(interruption);
234
335
  if (state && typeof state.reject === 'function') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emilia-protocol/openai-agents",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Turn the OpenAI Agents SDK human-in-the-loop tool-approval step into a verifiable, offline-checkable EMILIA authorization receipt. OpenAI pauses an agent and asks for approval; EMILIA makes that approval portable, tamper-evident evidence — a named human accountably authorized this exact tool call. Composes WITH OpenAI's approval primitive (needsApproval / interruptions / state.approve|reject); does not replace it. Reference implementation, experimental.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -32,7 +32,7 @@
32
32
  ],
33
33
  "engines": { "node": ">=18" },
34
34
  "dependencies": {
35
- "@emilia-protocol/require-receipt": "^0.3.0"
35
+ "@emilia-protocol/require-receipt": "^0.4.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@openai/agents": ">=0.0.1"