@emilia-protocol/openai-agents 0.1.1 → 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 +9 -2
- package/index.d.ts +7 -1
- package/index.js +59 -7
- package/package.json +1 -1
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
|
@@ -129,14 +129,26 @@ export function requireReceiptForOpenAIAgent(opts = {}) {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
|
-
* Decide a single interruption against a single receipt.
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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.
|
|
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",
|