@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 +9 -2
- package/index.d.ts +7 -1
- package/index.js +122 -21
- package/package.json +2 -2
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
//
|
|
162
|
-
if
|
|
163
|
-
|
|
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
|
-
|
|
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:
|
|
172
|
-
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
|
-
|
|
231
|
-
|
|
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.
|
|
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"
|