@emilia-protocol/openai-agents 0.1.0 → 0.1.2

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
@@ -129,14 +129,26 @@ export function requireReceiptForOpenAIAgent(opts = {}) {
129
129
  }
130
130
 
131
131
  /**
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.
132
+ * Decide a single interruption against a single receipt.
133
+ *
134
+ * The decision NEVER carries the raw verifier output — only an allowlisted,
135
+ * sanitized shape ({decision, action, toolName, callId, reason} plus, on the
136
+ * approve path only, receipt_id + the accountable subject). A rejection never
137
+ * leaks the signer, the verifier's `detail`, or any other library internals.
138
+ *
139
+ * Consume-on-approve: when `decide` returns an `approve`, it marks the
140
+ * receipt_id consumed (replay defense) — and ONLY then. A reject (including a
141
+ * replayed or invalid receipt) never consumes anything, so a blocked approval
142
+ * stays retryable with a fresh, valid receipt. Replay is still checked here,
143
+ * before any approval, so a receipt can satisfy at most one interruption.
135
144
  */
136
145
  function decide(interruption, receipt) {
137
146
  const toolName = interruptionToolName(interruption);
138
147
  const callId = interruptionCallId(interruption);
139
148
  const args = interruptionArgs(interruption);
149
+ // Bind the receipt to THIS tool call. actionFor SHOULD incorporate the
150
+ // specific call identity (callId or a hash of args) so a single receipt
151
+ // cannot be reused across distinct tool calls — see the README note.
140
152
  const action = toolName != null ? actionFor(toolName, args) : null;
141
153
 
142
154
  const base = { action, toolName, callId };
@@ -155,14 +167,26 @@ export function requireReceiptForOpenAIAgent(opts = {}) {
155
167
  action, // binds the receipt's claim.action_type to THIS tool call
156
168
  });
157
169
  if (!v.ok) {
170
+ // Sanitized: only the reason code crosses the boundary — never v.signer,
171
+ // v.subject, or v.detail.
158
172
  return { decision: 'reject', ...base, reason: v.reason || 'invalid_receipt' };
159
173
  }
160
174
 
161
175
  // Replay defense: a receipt_id may earn an approval only once per process.
162
- if (v.receipt_id && consumed.has(v.receipt_id)) {
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)))) {
163
182
  return { decision: 'reject', ...base, reason: 'receipt_replayed', receipt_id: v.receipt_id };
164
183
  }
165
- if (v.receipt_id) consumed.add(v.receipt_id);
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);
189
+ }
166
190
 
167
191
  return {
168
192
  decision: 'approve',
@@ -222,13 +246,41 @@ export function requireReceiptForOpenAIAgent(opts = {}) {
222
246
  const rejected = [];
223
247
  const decisions = [];
224
248
 
249
+ // Undo a consumption recorded by decide() when the approval is not actually
250
+ // driven (no approve() to call, or approve() threw) — keeps it retryable.
251
+ const unconsume = (d) => {
252
+ if (!d.receipt_id) return;
253
+ consumed.delete(d.receipt_id);
254
+ if (d.callId != null) consumed.delete(`${d.receipt_id}#${d.callId}`);
255
+ };
256
+
225
257
  for (const interruption of interruptions) {
226
258
  const receipt = lookupReceipt(receipts, interruption);
227
259
  const d = decide(interruption, receipt);
228
260
  decisions.push(d);
229
261
  if (d.decision === 'approve') {
230
- approved.push(interruption);
231
- if (state && typeof state.approve === 'function') state.approve(interruption);
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.
266
+ if (state && typeof state.approve === 'function') {
267
+ try {
268
+ state.approve(interruption);
269
+ approved.push(interruption);
270
+ } catch (err) {
271
+ unconsume(d);
272
+ d.decision = 'reject';
273
+ d.reason = 'approve_failed';
274
+ rejected.push(interruption);
275
+ if (state && typeof state.reject === 'function') {
276
+ state.reject(interruption, { message: `EMILIA: approve_failed` });
277
+ }
278
+ }
279
+ } else {
280
+ // No approve() to drive -> do not spend the receipt.
281
+ unconsume(d);
282
+ approved.push(interruption);
283
+ }
232
284
  } else {
233
285
  rejected.push(interruption);
234
286
  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.0",
3
+ "version": "0.1.2",
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",