@emilia-protocol/openai-agents 0.1.2 → 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.
Files changed (2) hide show
  1. package/index.js +87 -38
  2. package/package.json +2 -2
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,6 +132,26 @@ 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
156
  * Decide a single interruption against a single receipt.
133
157
  *
@@ -160,40 +184,49 @@ export function requireReceiptForOpenAIAgent(opts = {}) {
160
184
  return { decision: 'reject', ...base, reason: 'no_receipt_for_interruption' };
161
185
  }
162
186
 
163
- const v = verifyEmiliaReceipt(receipt, {
164
- trustedKeys,
165
- allowInlineKey,
166
- maxAgeSec,
167
- action, // binds the receipt's claim.action_type to THIS tool call
168
- });
169
- if (!v.ok) {
170
- // Sanitized: only the reason code crosses the boundary — never v.signer,
171
- // v.subject, or v.detail.
172
- return { decision: 'reject', ...base, reason: v.reason || 'invalid_receipt' };
173
- }
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);
174
191
 
175
- // Replay defense: a receipt_id may earn an approval only once per process.
176
- // Belt-and-suspenders: also refuse if the SAME receipt has already been used
177
- // for a DIFFERENT call (compound key), so one receipt can't satisfy two
178
- // distinct interruptions even if actionFor collides.
179
- const consumeKey = v.receipt_id;
180
- const callKey = v.receipt_id && callId != null ? `${v.receipt_id}#${callId}` : null;
181
- if (v.receipt_id && (consumed.has(consumeKey) || (callKey && consumed.has(callKey)))) {
182
- return { decision: 'reject', ...base, reason: 'receipt_replayed', receipt_id: v.receipt_id };
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 };
183
209
  }
184
- // Consume ONLY on the approve path, below — record both the receipt_id and
185
- // the call-scoped key so a later call with the same receipt is refused.
186
- if (v.receipt_id) {
187
- consumed.add(consumeKey);
188
- if (callKey) consumed.add(callKey);
210
+
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 };
189
217
  }
190
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 });
223
+
191
224
  return {
192
225
  decision: 'approve',
193
226
  ...base,
194
227
  reason: 'valid_action_bound_receipt',
195
- receipt_id: v.receipt_id,
196
- subject: v.subject,
228
+ receipt_id: c.receiptId,
229
+ subject: c.subject,
197
230
  };
198
231
  }
199
232
 
@@ -246,12 +279,27 @@ export function requireReceiptForOpenAIAgent(opts = {}) {
246
279
  const rejected = [];
247
280
  const decisions = [];
248
281
 
249
- // Undo a consumption recorded by decide() when the approval is not actually
282
+ // Release the reservation decide() took when the approval is not actually
250
283
  // driven (no approve() to call, or approve() threw) — keeps it retryable.
251
- const unconsume = (d) => {
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) => {
252
296
  if (!d.receipt_id) return;
253
- consumed.delete(d.receipt_id);
254
- if (d.callId != null) consumed.delete(`${d.receipt_id}#${d.callId}`);
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
+ }
255
303
  };
256
304
 
257
305
  for (const interruption of interruptions) {
@@ -259,16 +307,17 @@ export function requireReceiptForOpenAIAgent(opts = {}) {
259
307
  const d = decide(interruption, receipt);
260
308
  decisions.push(d);
261
309
  if (d.decision === 'approve') {
262
- // The receipt was consumed inside decide() ONLY because this is an
263
- // approve. Drive the SDK's approval now; if approve() is missing or
264
- // throws, roll the consumption back so the (un-driven) approval stays
265
- // retryable — a receipt is spent only when it actually drives an approve.
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.
266
314
  if (state && typeof state.approve === 'function') {
267
315
  try {
268
316
  state.approve(interruption);
317
+ commitPending(d);
269
318
  approved.push(interruption);
270
319
  } catch (err) {
271
- unconsume(d);
320
+ releasePending(d);
272
321
  d.decision = 'reject';
273
322
  d.reason = 'approve_failed';
274
323
  rejected.push(interruption);
@@ -278,7 +327,7 @@ export function requireReceiptForOpenAIAgent(opts = {}) {
278
327
  }
279
328
  } else {
280
329
  // No approve() to drive -> do not spend the receipt.
281
- unconsume(d);
330
+ releasePending(d);
282
331
  approved.push(interruption);
283
332
  }
284
333
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emilia-protocol/openai-agents",
3
- "version": "0.1.2",
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"