@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.
- package/index.js +87 -38
- 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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
//
|
|
186
|
-
if
|
|
187
|
-
|
|
188
|
-
|
|
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:
|
|
196
|
-
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
|
-
//
|
|
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
|
|
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
|
-
|
|
254
|
-
if (
|
|
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
|
-
//
|
|
263
|
-
//
|
|
264
|
-
// throws,
|
|
265
|
-
// retryable — a receipt is spent only when it
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
35
|
+
"@emilia-protocol/require-receipt": "^0.4.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@openai/agents": ">=0.0.1"
|