open-action-consent 0.2.1 → 0.3.0
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/dist/conformance.js +8 -2
- package/dist/consent.d.ts +2 -0
- package/dist/consent.js +4 -2
- package/dist/discovery.js +2 -4
- package/dist/errors.js +8 -4
- package/dist/gate.d.ts +14 -0
- package/dist/gate.js +46 -14
- package/dist/index.d.ts +4 -2
- package/dist/index.js +5 -3
- package/dist/jcs.js +1 -3
- package/dist/jws.d.ts +1 -1
- package/dist/jws.js +2 -5
- package/dist/mandate.d.ts +1 -1
- package/dist/mandate.js +3 -5
- package/dist/mcp/index.d.ts +15 -2
- package/dist/mcp/index.js +65 -17
- package/dist/occurrence.d.ts +24 -0
- package/dist/occurrence.js +46 -0
- package/dist/preview.js +1 -2
- package/dist/receipts.js +13 -4
- package/dist/shapes.d.ts +2 -1
- package/dist/shapes.js +27 -10
- package/dist/status.js +1 -3
- package/dist/testing.js +1 -2
- package/dist/tiers.js +1 -2
- package/dist/undo.js +1 -2
- package/dist/vercel-ai/index.js +2 -6
- package/dist/webauthn.d.ts +12 -0
- package/dist/webauthn.js +55 -0
- package/package.json +1 -1
package/dist/conformance.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// if any result differs from the expected one. Same files as the Python runner.
|
|
1
|
+
// Run the shared vectors: node src/conformance.ts vectors
|
|
3
2
|
import * as fs from "node:fs";
|
|
4
3
|
import * as path from "node:path";
|
|
5
4
|
import { OACError } from "./errors.js";
|
|
@@ -15,6 +14,7 @@ import { checkUndo } from "./undo.js";
|
|
|
15
14
|
import { vectorKey } from "./testing.js";
|
|
16
15
|
import { bit, decode } from "./status.js";
|
|
17
16
|
import { keysetFrom, metadataUrl } from "./discovery.js";
|
|
17
|
+
import { claim, MemoryOccurrences } from "./occurrence.js";
|
|
18
18
|
const OK = { ok: true };
|
|
19
19
|
const OPS = {
|
|
20
20
|
jcs: (i) => ({ canonical: canonical(i.value) }),
|
|
@@ -27,6 +27,7 @@ const OPS = {
|
|
|
27
27
|
verify_consent: (i) => (verifyConsent(i.token, {
|
|
28
28
|
keys: new KeySet(i.keys), executor: i.executor, sub: i.sub, previews: i.previews, actions: i.actions, profiles: i.profiles,
|
|
29
29
|
now: i.now, nonces: new MemoryNonces(i.seen_nonces ?? []), policyFloor: i.policy_floor, tainted: i.tainted ?? false,
|
|
30
|
+
device: i.device,
|
|
30
31
|
}), OK),
|
|
31
32
|
check_mandate: (i) => (checkMandate(i.token, {
|
|
32
33
|
keys: new KeySet(i.keys), action: i.action, profile: i.profile, holder: i.holder, sub: i.sub, now: i.now,
|
|
@@ -39,6 +40,11 @@ const OPS = {
|
|
|
39
40
|
keys: new KeySet(i.keys), executor: i.executor, receiptToken: i.receipt_token, receiptKeys: new KeySet(i.receipt_keys),
|
|
40
41
|
now: i.now, nonces: new MemoryNonces(i.seen_nonces ?? []), undone: i.undone ?? [], compensation: i.compensation,
|
|
41
42
|
}), OK),
|
|
43
|
+
claim_occurrence: (i) => {
|
|
44
|
+
const key = ["person", "tool", "occurrence"];
|
|
45
|
+
claim(new MemoryOccurrences(i.state ? [[key, i.state]] : []), key);
|
|
46
|
+
return OK;
|
|
47
|
+
},
|
|
42
48
|
status_bit: (i) => ({ revoked: bit(decode(i.encoded_list), i.index) }),
|
|
43
49
|
metadata_url: (i) => ({ url: metadataUrl(i.iss) }),
|
|
44
50
|
keyset_from: (i) => ({ issuer: keysetFrom(i.docs.map((d) => [d[0], d[1]])).issuer(i.kid) }),
|
package/dist/consent.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { KeySet, type SigningKey } from "./jws.ts";
|
|
2
|
+
import { type DeviceCheck } from "./webauthn.ts";
|
|
2
3
|
export declare const TYP = "oac-consent+jws";
|
|
3
4
|
export declare const MAX_S = 900;
|
|
4
5
|
export declare const SKEW_S = 60;
|
|
@@ -36,4 +37,5 @@ export declare function verifyConsent(token: string, o: {
|
|
|
36
37
|
nonces: NonceStore;
|
|
37
38
|
policyFloor?: string;
|
|
38
39
|
tainted?: boolean;
|
|
40
|
+
device?: DeviceCheck;
|
|
39
41
|
}): Record<string, any>;
|
package/dist/consent.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
//
|
|
2
|
-
// first failure is the one reported. Mirrors consent.py.
|
|
1
|
+
// Consents (spec 6): the person's signed yes to one or more Previews.
|
|
3
2
|
import * as crypto from "node:crypto";
|
|
4
3
|
import * as shapes from "./shapes.js";
|
|
5
4
|
import { OACError } from "./errors.js";
|
|
6
5
|
import { digest, KeySet, sign, verify } from "./jws.js";
|
|
7
6
|
import { actionDigest, checkBinding } from "./preview.js";
|
|
8
7
|
import { tierFor } from "./tiers.js";
|
|
8
|
+
import { verifyDeviceProof } from "./webauthn.js";
|
|
9
9
|
export const TYP = "oac-consent+jws";
|
|
10
10
|
export const MAX_S = 900;
|
|
11
11
|
export const SKEW_S = 60;
|
|
@@ -89,6 +89,8 @@ export function verifyConsent(token, o) {
|
|
|
89
89
|
});
|
|
90
90
|
if (pays === 0 && Object.hasOwn(p, "payment"))
|
|
91
91
|
throw new OACError("payment_mismatch", "payment given but no item pays");
|
|
92
|
+
if (o.device)
|
|
93
|
+
verifyDeviceProof(token, o.device);
|
|
92
94
|
if (!o.nonces.useOnce(p.jti, p.exp))
|
|
93
95
|
throw new OACError("replay");
|
|
94
96
|
return p;
|
package/dist/discovery.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
// Issuer metadata (spec
|
|
2
|
-
// https://host/.well-known/oac-issuer/path (the RFC 8414 pattern) and its `issuer` must equal the issuer exactly.
|
|
3
|
-
// `keysetFrom` turns documents you already have into a KeySet; `fetchMetadata` is a small, strict HTTPS fetcher.
|
|
1
|
+
// Issuer metadata (spec 3.5): where an issuer publishes its public keys.
|
|
4
2
|
import * as shapes from "./shapes.js";
|
|
5
3
|
import { OACError } from "./errors.js";
|
|
6
4
|
import { parse } from "./jcs.js";
|
|
7
5
|
import { KeySet } from "./jws.js";
|
|
8
6
|
export const MAX_DOC = 64 * 1024;
|
|
9
|
-
//
|
|
7
|
+
// matched as text, not with a URL parser, so every language agrees
|
|
10
8
|
const LABEL = "[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?";
|
|
11
9
|
const ISSUER = new RegExp(`^https://(${LABEL}(\\.${LABEL})*)(:[0-9]{1,5})?((/(?!\\.\\.?(/|$))[A-Za-z0-9._~-]+)*)/?$`);
|
|
12
10
|
export function metadataUrl(iss) {
|
package/dist/errors.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Error codes shared by every OAC implementation
|
|
2
|
-
// check that fails, in the order the spec lists them.
|
|
1
|
+
// Error codes shared by every OAC implementation.
|
|
3
2
|
export const CODES = new Set([
|
|
4
3
|
"malformed", "not_canonical", "bad_type", "bad_alg", "unknown_key", "bad_signature",
|
|
5
4
|
"wrong_audience", "not_yet_valid", "expired", "lifetime_too_long", "replay",
|
|
@@ -9,10 +8,15 @@ export const CODES = new Set([
|
|
|
9
8
|
"payee_not_allowed", "site_not_allowed", "currency_mismatch", "over_amount", "over_daily_amount", "over_calls",
|
|
10
9
|
"chain_broken", "chain_seq", "chain_issuer", "receipt_invalid", "receipt_mismatch", "not_reversible",
|
|
11
10
|
"undo_mode_mismatch", "undo_window_closed",
|
|
12
|
-
//
|
|
11
|
+
// identity, arguments, revocation, undo
|
|
13
12
|
"wrong_issuer", "undeclared_argument", "revoked", "already_undone", "compensation_mismatch",
|
|
14
|
-
//
|
|
13
|
+
// status lists and seals
|
|
15
14
|
"status_unknown", "seal_mismatch",
|
|
15
|
+
// device proofs (WebAuthn)
|
|
16
|
+
"device_proof_missing", "device_proof_mismatch", "unknown_credential", "challenge_mismatch", "wrong_origin",
|
|
17
|
+
"rp_mismatch", "user_not_present", "user_not_verified",
|
|
18
|
+
// business occurrences
|
|
19
|
+
"occurrence_unresolved", "duplicate_occurrence",
|
|
16
20
|
]);
|
|
17
21
|
/** A refusal. `code` is one of CODES; `detail` is for logs, never for deciding anything. */
|
|
18
22
|
export class OACError extends Error {
|
package/dist/gate.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { KeySet, type SigningKey } from "./jws.ts";
|
|
2
2
|
import { type NonceStore } from "./consent.ts";
|
|
3
|
+
import { type OccurrenceStore } from "./occurrence.ts";
|
|
3
4
|
export declare class NeedsConsent extends Error {
|
|
4
5
|
readonly preview: Record<string, any>;
|
|
5
6
|
constructor(preview: Record<string, any>);
|
|
6
7
|
}
|
|
8
|
+
/** Throw from a tool when the action surely did not happen. Other errors count as unknown. */
|
|
9
|
+
export declare class ActionFailed extends Error {
|
|
10
|
+
}
|
|
7
11
|
export type ProfileSpec = {
|
|
8
12
|
effect: string;
|
|
9
13
|
undo?: string;
|
|
@@ -11,17 +15,20 @@ export type ProfileSpec = {
|
|
|
11
15
|
tierFloor?: string;
|
|
12
16
|
binds?: Record<string, string>;
|
|
13
17
|
otherArgs?: string[];
|
|
18
|
+
occurrence?: string;
|
|
14
19
|
};
|
|
15
20
|
export declare class Gate {
|
|
16
21
|
readonly executor: string;
|
|
17
22
|
readonly key: SigningKey;
|
|
18
23
|
readonly signers: KeySet;
|
|
19
24
|
readonly nonces: NonceStore;
|
|
25
|
+
readonly occurrences: OccurrenceStore;
|
|
20
26
|
readonly policyFloor: string;
|
|
21
27
|
readonly profiles: Record<string, Record<string, any>>;
|
|
22
28
|
readonly pending: Map<string, Record<string, any>>;
|
|
23
29
|
readonly consents: Map<string, string>;
|
|
24
30
|
readonly receipts: Map<string, string[]>;
|
|
31
|
+
readonly tokens: Map<string, string>;
|
|
25
32
|
private readonly clock;
|
|
26
33
|
private readonly summarize;
|
|
27
34
|
constructor(o: {
|
|
@@ -29,6 +36,7 @@ export declare class Gate {
|
|
|
29
36
|
key: SigningKey;
|
|
30
37
|
signers: KeySet;
|
|
31
38
|
nonces?: NonceStore;
|
|
39
|
+
occurrences?: OccurrenceStore;
|
|
32
40
|
policyFloor?: string;
|
|
33
41
|
summarize?: (a: Record<string, any>) => string;
|
|
34
42
|
clock?: () => number;
|
|
@@ -40,9 +48,15 @@ export declare class Gate {
|
|
|
40
48
|
previewById(id: string): Record<string, any> | undefined;
|
|
41
49
|
acceptConsent(token: string): boolean;
|
|
42
50
|
private authorize;
|
|
51
|
+
private outcome;
|
|
43
52
|
run<T>(tool: string, args: Record<string, unknown>, fn: (args: Record<string, unknown>) => T | Promise<T>, o: {
|
|
44
53
|
person: string;
|
|
45
54
|
tainted?: boolean;
|
|
46
55
|
}): Promise<[T, string]>;
|
|
56
|
+
/** Record what the provider says happened to an unknown attempt: ok or failed. */
|
|
57
|
+
resolve(tool: string, occurrence: string, o: {
|
|
58
|
+
person: string;
|
|
59
|
+
outcome: "ok" | "failed";
|
|
60
|
+
}): void;
|
|
47
61
|
private receipt;
|
|
48
62
|
}
|
package/dist/gate.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
// the action, run, signed Receipt. A pending Preview is reused for the same person and action until it expires, so
|
|
3
|
-
// a framework that re-runs a step after an interruption gets the Preview the person approved.
|
|
1
|
+
// Executor gate shared by the framework adapters. Same behaviour as gate.py.
|
|
4
2
|
import * as crypto from "node:crypto";
|
|
5
3
|
import * as shapes from "./shapes.js";
|
|
6
4
|
import { OACError } from "./errors.js";
|
|
7
5
|
import { digest, KeySet, tokenDigest, verify } from "./jws.js";
|
|
8
6
|
import { MemoryNonces, verifyConsent } from "./consent.js";
|
|
7
|
+
import { checkOpen, claim, MemoryOccurrences, occurrenceKey, resolve } from "./occurrence.js";
|
|
9
8
|
import { actionDigest, buildPreview } from "./preview.js";
|
|
10
9
|
import { makeReceipt } from "./receipts.js";
|
|
11
10
|
import { tierFor } from "./tiers.js";
|
|
@@ -13,16 +12,21 @@ export class NeedsConsent extends Error {
|
|
|
13
12
|
preview;
|
|
14
13
|
constructor(preview) { super("the person must approve this preview"); this.preview = preview; }
|
|
15
14
|
}
|
|
15
|
+
/** Throw from a tool when the action surely did not happen. Other errors count as unknown. */
|
|
16
|
+
export class ActionFailed extends Error {
|
|
17
|
+
}
|
|
16
18
|
export class Gate {
|
|
17
19
|
executor;
|
|
18
20
|
key;
|
|
19
21
|
signers;
|
|
20
22
|
nonces;
|
|
23
|
+
occurrences;
|
|
21
24
|
policyFloor;
|
|
22
25
|
profiles = {};
|
|
23
26
|
pending = new Map(); // person \0 action digest -> preview
|
|
24
27
|
consents = new Map();
|
|
25
28
|
receipts = new Map();
|
|
29
|
+
tokens = new Map(); // authority digest in a receipt -> the exact token
|
|
26
30
|
clock;
|
|
27
31
|
summarize;
|
|
28
32
|
constructor(o) {
|
|
@@ -30,6 +34,7 @@ export class Gate {
|
|
|
30
34
|
this.key = o.key;
|
|
31
35
|
this.signers = o.signers;
|
|
32
36
|
this.nonces = o.nonces ?? new MemoryNonces();
|
|
37
|
+
this.occurrences = o.occurrences ?? new MemoryOccurrences();
|
|
33
38
|
this.policyFloor = o.policyFloor ?? "A";
|
|
34
39
|
this.clock = o.clock ?? (() => Math.floor(Date.now() / 1000));
|
|
35
40
|
this.summarize = o.summarize ?? ((a) => `${a.tool}: ` + Object.keys(a.args).sort().map((k) => `${k}=${JSON.stringify(a.args[k])}`).join(", ").slice(0, 480));
|
|
@@ -39,6 +44,8 @@ export class Gate {
|
|
|
39
44
|
tier_floor: p.tierFloor ?? "A", binds: { ...(p.binds ?? {}) } };
|
|
40
45
|
if (p.otherArgs?.length)
|
|
41
46
|
doc.other_args = [...p.otherArgs];
|
|
47
|
+
if (p.occurrence)
|
|
48
|
+
doc.occurrence = p.occurrence;
|
|
42
49
|
this.profiles[tool] = shapes.profile(doc);
|
|
43
50
|
return doc;
|
|
44
51
|
}
|
|
@@ -89,38 +96,61 @@ export class Gate {
|
|
|
89
96
|
const tier = this.tier(tool, args, tainted);
|
|
90
97
|
if (tier === "D")
|
|
91
98
|
throw new OACError("tier_d", "this action never runs");
|
|
99
|
+
const occurrence = occurrenceKey(person, this.profiles[tool], args);
|
|
92
100
|
if (tier === "A" || tier === "B")
|
|
93
|
-
return [tier, { kind: "tier" }, null];
|
|
101
|
+
return [tier, { kind: "tier" }, null, claim(this.occurrences, occurrence)];
|
|
94
102
|
const action = { tool, args: { ...args } };
|
|
95
103
|
const preview = this.preview(tool, args, person);
|
|
96
104
|
const pd = digest(preview);
|
|
97
105
|
const token = this.consents.get(pd);
|
|
98
|
-
if (!token)
|
|
106
|
+
if (!token) {
|
|
107
|
+
checkOpen(this.occurrences, occurrence);
|
|
99
108
|
throw new NeedsConsent(preview);
|
|
100
|
-
|
|
101
|
-
|
|
109
|
+
}
|
|
110
|
+
const claimed = claim(this.occurrences, occurrence);
|
|
111
|
+
let c;
|
|
112
|
+
try {
|
|
113
|
+
c = verifyConsent(token, { keys: this.signers, executor: this.executor, sub: person, previews: [preview], actions: [action],
|
|
114
|
+
profiles: this.profiles, now: this.clock(), nonces: this.nonces, policyFloor: this.policyFloor, tainted });
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
if (claimed)
|
|
118
|
+
this.occurrences.release(claimed);
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
102
121
|
this.pending.delete(person + "\0" + actionDigest(action));
|
|
103
|
-
|
|
122
|
+
this.tokens.set(tokenDigest(token), token);
|
|
123
|
+
return ["C", { kind: "consent", ref: c.jti, digest: tokenDigest(token) }, pd, claimed];
|
|
124
|
+
}
|
|
125
|
+
outcome(claimed, error) {
|
|
126
|
+
const outcome = error === undefined ? "ok" : claimed === null || error instanceof ActionFailed ? "failed" : "unknown";
|
|
127
|
+
if (claimed)
|
|
128
|
+
this.occurrences.finish(claimed, outcome);
|
|
129
|
+
return outcome;
|
|
104
130
|
}
|
|
105
131
|
async run(tool, args, fn, o) {
|
|
106
|
-
const [tier, authority, pd] = this.authorize(tool, args, o.person, o.tainted ?? true);
|
|
132
|
+
const [tier, authority, pd, claimed] = this.authorize(tool, args, o.person, o.tainted ?? true);
|
|
107
133
|
let result;
|
|
108
134
|
try {
|
|
109
135
|
result = await fn(args);
|
|
110
136
|
}
|
|
111
137
|
catch (e) {
|
|
112
|
-
this.receipt(o.person, tool, args, tier, authority, pd, null,
|
|
138
|
+
this.receipt(o.person, tool, args, tier, authority, pd, null, this.outcome(claimed, e ?? new Error("thrown")));
|
|
113
139
|
throw e;
|
|
114
140
|
}
|
|
115
|
-
return [result, this.receipt(o.person, tool, args, tier, authority, pd, result,
|
|
141
|
+
return [result, this.receipt(o.person, tool, args, tier, authority, pd, result, this.outcome(claimed, undefined))];
|
|
142
|
+
}
|
|
143
|
+
/** Record what the provider says happened to an unknown attempt: ok or failed. */
|
|
144
|
+
resolve(tool, occurrence, o) {
|
|
145
|
+
resolve(this.occurrences, [o.person, tool, occurrence], o.outcome);
|
|
116
146
|
}
|
|
117
|
-
receipt(person, tool, args, tier, authority, pd, result,
|
|
147
|
+
receipt(person, tool, args, tier, authority, pd, result, outcome) {
|
|
118
148
|
const profile = this.profiles[tool];
|
|
119
149
|
const chain = this.receipts.get(person) ?? [];
|
|
120
150
|
this.receipts.set(person, chain);
|
|
121
151
|
const now = this.clock();
|
|
122
152
|
const undo = { kind: profile.undo.kind };
|
|
123
|
-
if (undo.kind !== "none" &&
|
|
153
|
+
if (undo.kind !== "none" && outcome === "ok") {
|
|
124
154
|
undo.until = now + profile.undo.window_s;
|
|
125
155
|
if (undo.kind === "native")
|
|
126
156
|
undo.token = "u_" + crypto.randomBytes(12).toString("base64url");
|
|
@@ -133,9 +163,11 @@ export class Gate {
|
|
|
133
163
|
rd = digest(String(result));
|
|
134
164
|
}
|
|
135
165
|
const payload = { iss: this.executor, sub: person, seq: chain.length + 1, prev: chain.length ? tokenDigest(chain[chain.length - 1]) : null,
|
|
136
|
-
iat: now, tool, tier, authority, action: actionDigest({ tool, args: { ...args } }), outcome
|
|
166
|
+
iat: now, tool, tier, authority, action: actionDigest({ tool, args: { ...args } }), outcome, result: rd, undo };
|
|
137
167
|
if (pd)
|
|
138
168
|
payload.preview = pd;
|
|
169
|
+
if (Object.hasOwn(profile, "occurrence"))
|
|
170
|
+
payload.occurrence = args[profile.occurrence];
|
|
139
171
|
const r = makeReceipt(this.key, payload);
|
|
140
172
|
chain.push(r);
|
|
141
173
|
return r;
|
package/dist/index.d.ts
CHANGED
|
@@ -8,5 +8,7 @@ export { makeConsent, MemoryNonces, verifyConsent, type NonceStore } from "./con
|
|
|
8
8
|
export { checkMandate, loosens, makeMandate } from "./mandate.ts";
|
|
9
9
|
export { makeReceipt, verifyChain } from "./receipts.ts";
|
|
10
10
|
export { checkUndo, makeUndo } from "./undo.ts";
|
|
11
|
-
export { Gate, NeedsConsent, type ProfileSpec } from "./gate.ts";
|
|
12
|
-
export
|
|
11
|
+
export { ActionFailed, Gate, NeedsConsent, type ProfileSpec } from "./gate.ts";
|
|
12
|
+
export { MemoryOccurrences, type OccurrenceStore } from "./occurrence.ts";
|
|
13
|
+
export { verifyDeviceProof, type DeviceCheck } from "./webauthn.ts";
|
|
14
|
+
export declare const VERSION = "0.3.0";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Open Action Consent for TypeScript. Spec: spec/oac-v0.1.md
|
|
2
2
|
export { CODES, OACError } from "./errors.js";
|
|
3
3
|
export { canonical, parse } from "./jcs.js";
|
|
4
4
|
export { digest, generate, KeySet, sign, tokenDigest, verify } from "./jws.js";
|
|
@@ -9,5 +9,7 @@ export { makeConsent, MemoryNonces, verifyConsent } from "./consent.js";
|
|
|
9
9
|
export { checkMandate, loosens, makeMandate } from "./mandate.js";
|
|
10
10
|
export { makeReceipt, verifyChain } from "./receipts.js";
|
|
11
11
|
export { checkUndo, makeUndo } from "./undo.js";
|
|
12
|
-
export { Gate, NeedsConsent } from "./gate.js";
|
|
13
|
-
export
|
|
12
|
+
export { ActionFailed, Gate, NeedsConsent } from "./gate.js";
|
|
13
|
+
export { MemoryOccurrences } from "./occurrence.js";
|
|
14
|
+
export { verifyDeviceProof } from "./webauthn.js";
|
|
15
|
+
export const VERSION = "0.3.0";
|
package/dist/jcs.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
// RFC 8785
|
|
2
|
-
// units, time in unix seconds), so JCS reduces to: keys sorted by UTF-16 code units (JavaScript's default sort),
|
|
3
|
-
// no whitespace, strings as JSON.stringify writes them, integers in plain decimal within +-(2^53 - 1).
|
|
1
|
+
// RFC 8785 canonical JSON for OAC's integer-only subset, and a strict parser.
|
|
4
2
|
import { OACError } from "./errors.js";
|
|
5
3
|
export const MAX_DEPTH = 64; // arrays and objects nested deeper than this are refused
|
|
6
4
|
function str(s) {
|
package/dist/jws.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export declare function thumbprint(jwk: Jwk): string;
|
|
|
23
23
|
export declare function ed25519FromSeed(seed: Uint8Array): SigningKey;
|
|
24
24
|
export declare function p256FromSeed(seed: Uint8Array): SigningKey;
|
|
25
25
|
export declare function generate(alg?: string): SigningKey;
|
|
26
|
-
/**
|
|
26
|
+
/** Trusted public keys, grouped by issuer: {iss: [jwk, ...]}. Each key has one issuer. */
|
|
27
27
|
export declare class KeySet {
|
|
28
28
|
private keys;
|
|
29
29
|
constructor(byIssuer: Record<string, Jwk[]>);
|
package/dist/jws.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
// Keys, digests and the JWS envelope
|
|
2
|
-
// JCS-canonical JSON; a token whose bytes are not the canonical form of what they parse to is refused.
|
|
3
|
-
// Algorithms: EdDSA (Ed25519, RFC 8037) and ES256 (P-256, raw r||s, low-S only). kid = the RFC 7638 JWK thumbprint.
|
|
4
|
-
// Every trusted key belongs to exactly one issuer, and a token's `iss` must be its key's issuer.
|
|
1
|
+
// Keys, digests and the compact JWS envelope used by every signed OAC object.
|
|
5
2
|
import * as crypto from "node:crypto";
|
|
6
3
|
import { OACError } from "./errors.js";
|
|
7
4
|
import { canonical, canonicalBytes, parse } from "./jcs.js";
|
|
@@ -66,7 +63,7 @@ export function generate(alg = "EdDSA") {
|
|
|
66
63
|
}
|
|
67
64
|
throw new OACError("bad_alg", alg);
|
|
68
65
|
}
|
|
69
|
-
/**
|
|
66
|
+
/** Trusted public keys, grouped by issuer: {iss: [jwk, ...]}. Each key has one issuer. */
|
|
70
67
|
export class KeySet {
|
|
71
68
|
keys = new Map();
|
|
72
69
|
constructor(byIssuer) {
|
package/dist/mandate.d.ts
CHANGED
|
@@ -20,5 +20,5 @@ export declare function checkMandate(token: string, o: {
|
|
|
20
20
|
statusLists?: Record<string, string>;
|
|
21
21
|
policyFloor?: string;
|
|
22
22
|
}): Record<string, any>;
|
|
23
|
-
/** What
|
|
23
|
+
/** What the new mandate allows that the old one did not (spec 7.4). */
|
|
24
24
|
export declare function loosens(old: Record<string, any>, nw: Record<string, any>): string[];
|
package/dist/mandate.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Mandates (spec
|
|
2
|
-
// the taint floor; a pay tool needs caps and a payee list. Mirrors mandate.py, check for check.
|
|
1
|
+
// Mandates (spec 7): standing, bounded permission that can replace a Consent on tier C.
|
|
3
2
|
import * as shapes from "./shapes.js";
|
|
4
3
|
import { OACError } from "./errors.js";
|
|
5
4
|
import { canonical } from "./jcs.js";
|
|
@@ -77,7 +76,7 @@ export function checkMandate(token, o) {
|
|
|
77
76
|
throw new OACError("over_calls");
|
|
78
77
|
return p;
|
|
79
78
|
}
|
|
80
|
-
/** What
|
|
79
|
+
/** What the new mandate allows that the old one did not (spec 7.4). */
|
|
81
80
|
export function loosens(old, nw) {
|
|
82
81
|
shapes.mandate(old);
|
|
83
82
|
shapes.mandate(nw);
|
|
@@ -117,8 +116,7 @@ export function loosens(old, nw) {
|
|
|
117
116
|
}
|
|
118
117
|
}
|
|
119
118
|
}
|
|
120
|
-
//
|
|
121
|
-
// payees and sites: no list means no limit, so dropping the list, or a new entry, loosens.
|
|
119
|
+
// no recipient list means nobody; no payee or site list means anyone
|
|
122
120
|
if (nb.recipients !== undefined && (ob.recipients === undefined || newOnes(nb.recipients, ob.recipients)))
|
|
123
121
|
out.push("recipients");
|
|
124
122
|
for (const key of ["payees", "sites"]) {
|
package/dist/mcp/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { KeySet, MemoryNonces, type SigningKey } from "../index.ts";
|
|
3
|
+
import { type OccurrenceStore } from "../occurrence.ts";
|
|
3
4
|
export declare const ID = "com.atlasiw/oac";
|
|
4
5
|
export declare const PROFILE_KEY = "com.atlasiw/oac-profile", PREVIEW_KEY = "com.atlasiw/oac-preview", RECEIPT_KEY = "com.atlasiw/oac-receipt", CONSENT_KEY = "com.atlasiw/oac-consent";
|
|
6
|
+
export declare const OUTCOME_KEY = "com.atlasiw/oac-outcome";
|
|
5
7
|
type Ctx = any;
|
|
6
8
|
type Result = {
|
|
7
9
|
content: {
|
|
@@ -17,11 +19,12 @@ export interface OACOptions {
|
|
|
17
19
|
signers: KeySet;
|
|
18
20
|
consentUrl: (preview: Record<string, any>) => string;
|
|
19
21
|
person: (ctx: Ctx) => string;
|
|
20
|
-
/** From the host
|
|
22
|
+
/** From the host, never the model. Defaults to tainted. */
|
|
21
23
|
tainted?: (ctx: Ctx) => boolean;
|
|
22
24
|
summarize?: (action: Record<string, any>) => string;
|
|
23
25
|
policyFloor?: string;
|
|
24
26
|
clock?: () => number;
|
|
27
|
+
occurrences?: OccurrenceStore;
|
|
25
28
|
}
|
|
26
29
|
export declare class OAC {
|
|
27
30
|
readonly profiles: Record<string, Record<string, any>>;
|
|
@@ -29,6 +32,8 @@ export declare class OAC {
|
|
|
29
32
|
readonly consents: Map<string, string>;
|
|
30
33
|
readonly nonces: MemoryNonces;
|
|
31
34
|
readonly receipts: Map<string, string[]>;
|
|
35
|
+
readonly tokens: Map<string, string>;
|
|
36
|
+
readonly occurrences: OccurrenceStore;
|
|
32
37
|
private readonly stateKey;
|
|
33
38
|
readonly o: OACOptions;
|
|
34
39
|
constructor(o: OACOptions);
|
|
@@ -42,15 +47,23 @@ export declare class OAC {
|
|
|
42
47
|
tierFloor?: string;
|
|
43
48
|
binds?: Record<string, string>;
|
|
44
49
|
otherArgs?: string[];
|
|
50
|
+
occurrence?: string;
|
|
45
51
|
}): Record<string, unknown>;
|
|
46
52
|
/** Register a tool on an McpServer with its OAC profile; every call goes through `guard`. */
|
|
47
53
|
registerTool(server: McpServer, name: string, config: Record<string, any>, profile: Parameters<OAC["profile"]>[1], handler: (args: any, ctx: Ctx) => Promise<Result>): void;
|
|
48
54
|
preview(id: string): Record<string, any> | undefined;
|
|
49
|
-
/**
|
|
55
|
+
/** Keep a valid Consent for a pending preview. Never replaces one already kept. */
|
|
50
56
|
acceptConsent(token: string): boolean;
|
|
51
57
|
private seal;
|
|
52
58
|
private unseal;
|
|
53
59
|
guard(name: string, args: Record<string, unknown>, ctx: Ctx, handler: (args: any, ctx: Ctx) => Promise<Result>, clientCaps?: Record<string, any> | undefined): Promise<any>;
|
|
60
|
+
private run;
|
|
61
|
+
private finish;
|
|
62
|
+
/** Record what the provider says happened to an unknown attempt: ok or failed. */
|
|
63
|
+
resolve(tool: string, occurrence: string, o: {
|
|
64
|
+
person: string;
|
|
65
|
+
outcome: "ok" | "failed";
|
|
66
|
+
}): void;
|
|
54
67
|
private ask;
|
|
55
68
|
private withReceipt;
|
|
56
69
|
}
|
package/dist/mcp/index.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
// OAC for the
|
|
2
|
-
// interceptor, so OAC wraps each tool: `oac.registerTool(server, name, config, profile, handler)`.
|
|
3
|
-
// Tier A/B run (B with a receipt and undo token); tier C answers input_required with a URL-mode elicitation and
|
|
4
|
-
// runs only with a valid signed Consent; tier D, unprofiled tools and undeclared arguments never run. Unlike the
|
|
5
|
-
// Python SDK, v2 does not integrity-protect requestState, so OAC signs its own (HMAC-SHA256, short expiry).
|
|
1
|
+
// OAC for the MCP TypeScript SDK v2. The SDK has no interceptor, so this wraps each tool. See guides/mcp-typescript.md.
|
|
6
2
|
import * as crypto from "node:crypto";
|
|
7
3
|
import { inputRequired, inputResponse } from "@modelcontextprotocol/server";
|
|
8
4
|
import { actionDigest, buildPreview, digest, KeySet, makeReceipt, MemoryNonces, OACError, shapes, tierFor, tokenDigest, verify, verifyConsent } from "../index.js";
|
|
5
|
+
import { checkOpen, claim, MemoryOccurrences, occurrenceKey, resolve } from "../occurrence.js";
|
|
9
6
|
export const ID = "com.atlasiw/oac";
|
|
10
7
|
export const PROFILE_KEY = `${ID}-profile`, PREVIEW_KEY = `${ID}-preview`, RECEIPT_KEY = `${ID}-receipt`, CONSENT_KEY = `${ID}-consent`;
|
|
8
|
+
export const OUTCOME_KEY = `${ID}-outcome`;
|
|
11
9
|
const ASK = "oac_consent";
|
|
12
10
|
const STATE_TTL_S = 900;
|
|
13
11
|
const refused = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
@@ -17,9 +15,11 @@ export class OAC {
|
|
|
17
15
|
consents = new Map();
|
|
18
16
|
nonces = new MemoryNonces();
|
|
19
17
|
receipts = new Map();
|
|
18
|
+
tokens = new Map(); // authority digest in a receipt -> the exact token
|
|
19
|
+
occurrences;
|
|
20
20
|
stateKey = crypto.randomBytes(32);
|
|
21
21
|
o;
|
|
22
|
-
constructor(o) { this.o = o; }
|
|
22
|
+
constructor(o) { this.o = o; this.occurrences = o.occurrences ?? new MemoryOccurrences(); }
|
|
23
23
|
now() { return (this.o.clock ?? (() => Math.floor(Date.now() / 1000)))(); }
|
|
24
24
|
/** Capability settings to put under `capabilities.extensions[ID]`. */
|
|
25
25
|
settings() { return { version: "0.1", executor: this.o.executor }; }
|
|
@@ -28,6 +28,8 @@ export class OAC {
|
|
|
28
28
|
tier_floor: p.tierFloor ?? "A", binds: { ...(p.binds ?? {}) } };
|
|
29
29
|
if (p.otherArgs?.length)
|
|
30
30
|
doc.other_args = [...p.otherArgs];
|
|
31
|
+
if (p.occurrence)
|
|
32
|
+
doc.occurrence = p.occurrence;
|
|
31
33
|
this.profiles[tool] = shapes.profile(doc);
|
|
32
34
|
return { [PROFILE_KEY]: doc };
|
|
33
35
|
}
|
|
@@ -38,7 +40,7 @@ export class OAC {
|
|
|
38
40
|
server.registerTool(name, { ...config, _meta: { ...(config._meta ?? {}), ...meta } }, (args, ctx) => this.guard(name, args ?? {}, ctx, handler, caps(ctx)));
|
|
39
41
|
}
|
|
40
42
|
preview(id) { return [...this.previews.values()].find((p) => p.id === id); }
|
|
41
|
-
/**
|
|
43
|
+
/** Keep a valid Consent for a pending preview. Never replaces one already kept. */
|
|
42
44
|
acceptConsent(token) {
|
|
43
45
|
let p;
|
|
44
46
|
try {
|
|
@@ -89,16 +91,23 @@ export class OAC {
|
|
|
89
91
|
}
|
|
90
92
|
if (tier === "D")
|
|
91
93
|
return refused("Refused: this action is never allowed (tier D).");
|
|
92
|
-
if (tier === "A" || tier === "B")
|
|
93
|
-
|
|
94
|
+
if (tier === "A" || tier === "B") {
|
|
95
|
+
let claimed;
|
|
96
|
+
try {
|
|
97
|
+
claimed = claim(this.occurrences, occurrenceKey(person, profile, args));
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
return refused(`Refused (${e instanceof OACError ? e.code : "error"}). Nothing ran.`);
|
|
101
|
+
}
|
|
102
|
+
return this.run(handler, args, ctx, person, profile, action, tier, { kind: "tier" }, null, claimed);
|
|
103
|
+
}
|
|
94
104
|
const missing = {};
|
|
95
105
|
if (!clientCaps?.extensions?.[ID])
|
|
96
106
|
missing.extensions = { [ID]: {} };
|
|
97
107
|
if (!clientCaps?.elicitation?.url)
|
|
98
108
|
missing.elicitation = { url: {} };
|
|
99
109
|
if (Object.keys(missing).length) {
|
|
100
|
-
//
|
|
101
|
-
// results, so the -32021 data travels in the refusal instead of as a protocol error.
|
|
110
|
+
// the v2 SDK turns thrown errors into tool results, so -32021 travels in the refusal
|
|
102
111
|
return { ...refused("Refused (-32021): this client cannot show an approval page (needs the OAC extension and URL elicitation). Nothing ran."),
|
|
103
112
|
_meta: { [`${ID}-missing`]: missing } };
|
|
104
113
|
}
|
|
@@ -115,16 +124,52 @@ export class OAC {
|
|
|
115
124
|
if (!preview || !token)
|
|
116
125
|
return this.ask(action, profile, person);
|
|
117
126
|
let consent;
|
|
127
|
+
let claimed = null;
|
|
118
128
|
try {
|
|
129
|
+
claimed = claim(this.occurrences, occurrenceKey(person, profile, args));
|
|
119
130
|
consent = verifyConsent(token, { keys: this.o.signers, executor: this.o.executor, sub: person, previews: [preview], actions: [action],
|
|
120
131
|
profiles: this.profiles, now: this.now(), nonces: this.nonces, policyFloor: this.o.policyFloor, tainted });
|
|
121
132
|
}
|
|
122
133
|
catch (e) {
|
|
134
|
+
if (claimed)
|
|
135
|
+
this.occurrences.release(claimed);
|
|
123
136
|
return refused(`Refused (${e instanceof OACError ? e.code : "error"}). Nothing ran.`);
|
|
124
137
|
}
|
|
125
|
-
|
|
138
|
+
this.tokens.set(tokenDigest(token), token);
|
|
139
|
+
const authority = { kind: "consent", ref: consent.jti, digest: tokenDigest(token) };
|
|
140
|
+
return this.run(handler, args, ctx, person, profile, action, "C", authority, digest(preview), claimed);
|
|
141
|
+
}
|
|
142
|
+
// a timed-out payment may still have gone through, so an error is "unknown" unless the tool says "failed"
|
|
143
|
+
async run(handler, args, ctx, person, profile, action, tier, authority, previewDigest, claimed) {
|
|
144
|
+
let result;
|
|
145
|
+
try {
|
|
146
|
+
result = await handler(args, ctx);
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
this.withReceipt(null, person, profile, action, tier, authority, previewDigest, this.finish(claimed, claimed ? "unknown" : "failed"));
|
|
150
|
+
throw e;
|
|
151
|
+
}
|
|
152
|
+
let outcome = "ok";
|
|
153
|
+
if (result?.isError === true)
|
|
154
|
+
outcome = claimed && result._meta?.[OUTCOME_KEY] !== "failed" ? "unknown" : "failed";
|
|
155
|
+
return this.withReceipt(result, person, profile, action, tier, authority, previewDigest, this.finish(claimed, outcome));
|
|
156
|
+
}
|
|
157
|
+
finish(claimed, outcome) {
|
|
158
|
+
if (claimed)
|
|
159
|
+
this.occurrences.finish(claimed, outcome);
|
|
160
|
+
return outcome;
|
|
161
|
+
}
|
|
162
|
+
/** Record what the provider says happened to an unknown attempt: ok or failed. */
|
|
163
|
+
resolve(tool, occurrence, o) {
|
|
164
|
+
resolve(this.occurrences, [o.person, tool, occurrence], o.outcome);
|
|
126
165
|
}
|
|
127
166
|
ask(action, profile, person) {
|
|
167
|
+
try {
|
|
168
|
+
checkOpen(this.occurrences, occurrenceKey(person, profile, action.args));
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
return refused(`Refused (${e instanceof OACError ? e.code : "error"}). Nothing ran.`);
|
|
172
|
+
}
|
|
128
173
|
const summarize = this.o.summarize ?? ((a) => `${a.tool} with ${JSON.stringify(a.args)}`.slice(0, 500));
|
|
129
174
|
const p = buildPreview({ profile, action, executor: this.o.executor, sub: person, summary: summarize(action), now: this.now() });
|
|
130
175
|
const d = digest(p);
|
|
@@ -135,24 +180,27 @@ export class OAC {
|
|
|
135
180
|
});
|
|
136
181
|
return { ...result, _meta: { ...(result._meta ?? {}), [PREVIEW_KEY]: p } };
|
|
137
182
|
}
|
|
138
|
-
withReceipt(result, person, profile, action, tier, authority, previewDigest) {
|
|
139
|
-
const failed = result?.isError === true;
|
|
183
|
+
withReceipt(result, person, profile, action, tier, authority, previewDigest, outcome) {
|
|
140
184
|
const chain = this.receipts.get(person) ?? [];
|
|
141
185
|
this.receipts.set(person, chain);
|
|
142
186
|
const now = this.now();
|
|
143
187
|
const undo = { kind: profile.undo.kind };
|
|
144
|
-
if (undo.kind !== "none" &&
|
|
188
|
+
if (undo.kind !== "none" && outcome === "ok") {
|
|
145
189
|
undo.until = now + profile.undo.window_s;
|
|
146
190
|
if (undo.kind === "native")
|
|
147
191
|
undo.token = "u_" + crypto.randomBytes(12).toString("base64url");
|
|
148
192
|
}
|
|
149
193
|
const payload = { iss: this.o.executor, sub: person, seq: chain.length + 1, prev: chain.length ? tokenDigest(chain[chain.length - 1]) : null,
|
|
150
|
-
iat: now, tool: action.tool, tier, authority, action: actionDigest(action), outcome
|
|
151
|
-
result: digest(JSON.parse(JSON.stringify(result
|
|
194
|
+
iat: now, tool: action.tool, tier, authority, action: actionDigest(action), outcome, undo,
|
|
195
|
+
result: digest(JSON.parse(JSON.stringify(result?.content ?? []))) };
|
|
152
196
|
if (previewDigest)
|
|
153
197
|
payload.preview = previewDigest;
|
|
198
|
+
if (Object.hasOwn(profile, "occurrence"))
|
|
199
|
+
payload.occurrence = action.args[profile.occurrence];
|
|
154
200
|
const receipt = makeReceipt(this.o.key, payload);
|
|
155
201
|
chain.push(receipt);
|
|
202
|
+
if (result === null)
|
|
203
|
+
return null;
|
|
156
204
|
return { ...result, _meta: { ...(result._meta ?? {}), [RECEIPT_KEY]: receipt } };
|
|
157
205
|
}
|
|
158
206
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type OccurrenceKey = [person: string, tool: string, occurrence: string];
|
|
2
|
+
export interface OccurrenceStore {
|
|
3
|
+
/** Mark the key running and return null, or return its blocking state. Must be atomic. */
|
|
4
|
+
claim(key: OccurrenceKey): string | null;
|
|
5
|
+
finish(key: OccurrenceKey, outcome: string): void;
|
|
6
|
+
/** Drop a claim whose action never started. */
|
|
7
|
+
release(key: OccurrenceKey): void;
|
|
8
|
+
state(key: OccurrenceKey): string | null;
|
|
9
|
+
}
|
|
10
|
+
/** For tests and examples. A real store keeps these states in a table with a unique key. */
|
|
11
|
+
export declare class MemoryOccurrences implements OccurrenceStore {
|
|
12
|
+
private s;
|
|
13
|
+
constructor(states?: Iterable<[OccurrenceKey, string]>);
|
|
14
|
+
claim(key: OccurrenceKey): string | null;
|
|
15
|
+
finish(key: OccurrenceKey, outcome: string): void;
|
|
16
|
+
release(key: OccurrenceKey): void;
|
|
17
|
+
state(key: OccurrenceKey): string | null;
|
|
18
|
+
}
|
|
19
|
+
export declare function occurrenceKey(person: string, profile: Record<string, any>, args: Record<string, unknown>): OccurrenceKey | null;
|
|
20
|
+
/** Refuse early, before the person is asked, when the occurrence cannot run now. */
|
|
21
|
+
export declare function checkOpen(store: OccurrenceStore, key: OccurrenceKey | null): void;
|
|
22
|
+
export declare function claim(store: OccurrenceStore, key: OccurrenceKey | null): OccurrenceKey | null;
|
|
23
|
+
/** Record what the provider says happened. A "running" record left by a crash counts as unknown. */
|
|
24
|
+
export declare function resolve(store: OccurrenceStore, key: OccurrenceKey, outcome: string): void;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Business occurrences (spec 5.1): one order or payment runs at most once.
|
|
2
|
+
import { OACError } from "./errors.js";
|
|
3
|
+
/** For tests and examples. A real store keeps these states in a table with a unique key. */
|
|
4
|
+
export class MemoryOccurrences {
|
|
5
|
+
s = new Map();
|
|
6
|
+
constructor(states = []) { for (const [k, v] of states)
|
|
7
|
+
this.s.set(JSON.stringify(k), v); }
|
|
8
|
+
claim(key) {
|
|
9
|
+
const now = this.s.get(JSON.stringify(key));
|
|
10
|
+
if (now === "running" || now === "unknown" || now === "ok")
|
|
11
|
+
return now;
|
|
12
|
+
this.s.set(JSON.stringify(key), "running");
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
finish(key, outcome) { this.s.set(JSON.stringify(key), outcome); }
|
|
16
|
+
release(key) { this.s.delete(JSON.stringify(key)); }
|
|
17
|
+
state(key) { return this.s.get(JSON.stringify(key)) ?? null; }
|
|
18
|
+
}
|
|
19
|
+
export function occurrenceKey(person, profile, args) {
|
|
20
|
+
return Object.hasOwn(profile, "occurrence") ? [person, profile.tool, args[profile.occurrence]] : null;
|
|
21
|
+
}
|
|
22
|
+
function refuse(state) {
|
|
23
|
+
if (state === "ok")
|
|
24
|
+
throw new OACError("duplicate_occurrence", "this occurrence already ran");
|
|
25
|
+
if (state === "running" || state === "unknown")
|
|
26
|
+
throw new OACError("occurrence_unresolved", "an earlier attempt has no known outcome yet");
|
|
27
|
+
}
|
|
28
|
+
/** Refuse early, before the person is asked, when the occurrence cannot run now. */
|
|
29
|
+
export function checkOpen(store, key) {
|
|
30
|
+
if (key !== null)
|
|
31
|
+
refuse(store.state(key));
|
|
32
|
+
}
|
|
33
|
+
export function claim(store, key) {
|
|
34
|
+
if (key === null)
|
|
35
|
+
return null;
|
|
36
|
+
refuse(store.claim(key));
|
|
37
|
+
return key;
|
|
38
|
+
}
|
|
39
|
+
/** Record what the provider says happened. A "running" record left by a crash counts as unknown. */
|
|
40
|
+
export function resolve(store, key, outcome) {
|
|
41
|
+
const state = store.state(key);
|
|
42
|
+
if ((outcome !== "ok" && outcome !== "failed") || (state !== "running" && state !== "unknown")) {
|
|
43
|
+
throw new Error("only an attempt with no known outcome can be resolved, to ok or failed");
|
|
44
|
+
}
|
|
45
|
+
store.finish(key, outcome);
|
|
46
|
+
}
|
package/dist/preview.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Actions and Previews (spec
|
|
2
|
-
// Action Profile, so what the person reads is provably what will run.
|
|
1
|
+
// Actions and Previews (spec 5 and 6.1).
|
|
3
2
|
import * as crypto from "node:crypto";
|
|
4
3
|
import * as shapes from "./shapes.js";
|
|
5
4
|
import { OACError } from "./errors.js";
|
package/dist/receipts.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Receipts (spec
|
|
2
|
-
// previous receipt's whole token, so a dropped, reordered or edited receipt breaks the chain. Mirrors receipts.py.
|
|
1
|
+
// Receipts (spec 8): the executor's signed record of each action, chained per person.
|
|
3
2
|
import * as shapes from "./shapes.js";
|
|
4
3
|
import { OACError } from "./errors.js";
|
|
5
4
|
import { KeySet, sign, tokenDigest, verify } from "./jws.js";
|
|
@@ -19,8 +18,11 @@ export function checkSemantics(r) {
|
|
|
19
18
|
const tier = r.tier, kind = r.authority.kind, undo = r.undo;
|
|
20
19
|
if (tier === "D")
|
|
21
20
|
throw new OACError("receipt_invalid", "a tier D action never runs");
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
for (const name of ["ref", "digest"]) {
|
|
22
|
+
if ((kind !== "tier") !== Object.hasOwn(r.authority, name)) {
|
|
23
|
+
throw new OACError("receipt_invalid", `consent, mandate and undo authority carry a ${name}; tier carries none`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
24
26
|
if (kind === "tier" && tier !== "A" && tier !== "B")
|
|
25
27
|
throw new OACError("receipt_invalid", "tier C needs a consent, mandate or undo authority");
|
|
26
28
|
if (undo.kind === "none" && ("until" in undo || "token" in undo))
|
|
@@ -35,6 +37,7 @@ export function checkSemantics(r) {
|
|
|
35
37
|
}
|
|
36
38
|
export function verifyChain(tokens, o) {
|
|
37
39
|
const out = [];
|
|
40
|
+
const done = new Set();
|
|
38
41
|
tokens.forEach((token, i) => {
|
|
39
42
|
const r = verify(token, o.keys, TYP);
|
|
40
43
|
shapes.receipt(r);
|
|
@@ -53,6 +56,12 @@ export function verifyChain(tokens, o) {
|
|
|
53
56
|
if (r.prev !== tokenDigest(tokens[i - 1]))
|
|
54
57
|
throw new OACError("chain_broken");
|
|
55
58
|
}
|
|
59
|
+
if (Object.hasOwn(r, "occurrence") && r.outcome === "ok" && r.authority.kind !== "undo") {
|
|
60
|
+
const key = JSON.stringify([r.tool, r.occurrence]);
|
|
61
|
+
if (done.has(key))
|
|
62
|
+
throw new OACError("duplicate_occurrence", "the same occurrence ran twice");
|
|
63
|
+
done.add(key);
|
|
64
|
+
}
|
|
56
65
|
out.push(r);
|
|
57
66
|
});
|
|
58
67
|
if (o.seals && o.seals.length) {
|
package/dist/shapes.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export declare function check(v: unknown, fields: Fields, what: string): void;
|
|
|
8
8
|
export declare const PREVIEW_FIELDS: Record<string, Check>;
|
|
9
9
|
export declare const REQUIRED_BINDS: Record<string, string[]>;
|
|
10
10
|
export declare function profile(v: any): Record<string, any>;
|
|
11
|
-
/** Every argument is declared
|
|
11
|
+
/** Every argument is declared and every bound value is a valid field. */
|
|
12
12
|
export declare function checkAction(actionDoc: Record<string, any>, prof: Record<string, any>): void;
|
|
13
13
|
export declare function action(v: any): Record<string, any>;
|
|
14
14
|
export declare function preview(v: any): Record<string, any>;
|
|
@@ -21,4 +21,5 @@ export declare function receipt(v: any): Record<string, any>;
|
|
|
21
21
|
/** What a mandate already did, from the executor's own counters (spec 7.3). */
|
|
22
22
|
export declare function usage(v: any): Record<string, number>;
|
|
23
23
|
export declare function undoRequest(v: any): Record<string, any>;
|
|
24
|
+
export declare function deviceProof(v: any): Record<string, any>;
|
|
24
25
|
export {};
|
package/dist/shapes.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Field
|
|
2
|
-
// Schema cannot say (marked "rule"). Every failure is `malformed`. Unknown fields are refused. Mirrors shapes.py.
|
|
1
|
+
// Field checks for every OAC object. Same rules as schemas/, plus the cross-field ones.
|
|
3
2
|
import { OACError } from "./errors.js";
|
|
4
3
|
import { canonical } from "./jcs.js";
|
|
5
4
|
export const VERSION = "0.1";
|
|
@@ -10,6 +9,7 @@ const RAILS = ["stripe_link", "ap2", "vi", "other"];
|
|
|
10
9
|
const HOLDERS = ["agent", "client", "run"];
|
|
11
10
|
const SIGNERS = ["custodial", "device"];
|
|
12
11
|
const AUTHORITIES = ["consent", "mandate", "tier", "undo"];
|
|
12
|
+
const OUTCOMES = ["ok", "failed", "unknown"];
|
|
13
13
|
export const PREVIEW_MAX_S = 900;
|
|
14
14
|
const YEAR_S = 366 * 86400;
|
|
15
15
|
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
@@ -46,15 +46,16 @@ const DIGEST = re(/^sha-256:[A-Za-z0-9_-]{43}$/);
|
|
|
46
46
|
const NONCE = re(/^[A-Za-z0-9_-]{16,128}$/);
|
|
47
47
|
const CURRENCY = re(/^[A-Z]{3}$/);
|
|
48
48
|
const HOST = re(/^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/);
|
|
49
|
-
// one bare ASCII address; a recipient rule
|
|
49
|
+
// one bare ASCII address, no display name or list; a recipient rule may also be "@domain"
|
|
50
50
|
const LOCAL = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]{1,64}";
|
|
51
51
|
const DOMAIN = "[A-Za-z0-9]([A-Za-z0-9.-]{0,188}[A-Za-z0-9])?";
|
|
52
52
|
const EMAIL = re(new RegExp(`^${LOCAL}@${DOMAIN}$`));
|
|
53
53
|
const ADDR = re(new RegExp(`^(${LOCAL})?@${DOMAIN}$`));
|
|
54
|
-
//
|
|
54
|
+
// controls, bidi overrides and zero-width characters can make shown text lie
|
|
55
55
|
const UNSAFE = /[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/;
|
|
56
56
|
const text = (lo, hi) => (v) => strCp(lo, hi)(v) && !UNSAFE.test(v);
|
|
57
57
|
const LAST4 = re(/^[0-9]{4}$/);
|
|
58
|
+
const OCCURRENCE = re(/^[A-Za-z0-9_.:-]{1,128}$/);
|
|
58
59
|
const TIME = int(0);
|
|
59
60
|
const UNDO = { kind: [true, oneOf(...UNDO_KINDS)], window_s: [true, int(0, YEAR_S)], note: [false, strCp(1, 200)] };
|
|
60
61
|
export const PREVIEW_FIELDS = {
|
|
@@ -67,7 +68,6 @@ export const PREVIEW_FIELDS = {
|
|
|
67
68
|
site: HOST,
|
|
68
69
|
count: int(0),
|
|
69
70
|
};
|
|
70
|
-
// rule: a pay tool must show amount, currency and payee; a send tool must show who receives it
|
|
71
71
|
export const REQUIRED_BINDS = { pay: ["amount_minor", "currency", "payee"], send: ["to"] };
|
|
72
72
|
const has = (o, k) => Object.hasOwn(o, k);
|
|
73
73
|
function undoRule(u, what) {
|
|
@@ -81,7 +81,7 @@ export function profile(v) {
|
|
|
81
81
|
check(v, {
|
|
82
82
|
oac: [true, constant(VERSION)], type: [true, constant("action-profile")], tool: [true, TOOL],
|
|
83
83
|
effect: [true, oneOf(...EFFECTS)], undo: [true, obj(UNDO, "undo")], tier_floor: [true, oneOf(...TIERS)],
|
|
84
|
-
binds: [false, binds], other_args: [false, others],
|
|
84
|
+
binds: [false, binds], other_args: [false, others], occurrence: [false, ARG],
|
|
85
85
|
}, "action profile");
|
|
86
86
|
undoRule(v.undo, "action profile");
|
|
87
87
|
if (v.effect === "read" && v.undo.kind !== "none")
|
|
@@ -94,9 +94,14 @@ export function profile(v) {
|
|
|
94
94
|
const missing = (REQUIRED_BINDS[v.effect] ?? []).filter((f) => !has(v.binds ?? {}, f));
|
|
95
95
|
if (missing.length)
|
|
96
96
|
throw new OACError("malformed", `action profile: a ${v.effect} tool must bind ${missing[0]}`);
|
|
97
|
+
if (v.effect === "pay" && !has(v, "occurrence"))
|
|
98
|
+
throw new OACError("malformed", "action profile: a pay tool names its occurrence argument");
|
|
99
|
+
if (has(v, "occurrence") && !bound.includes(v.occurrence) && !other.includes(v.occurrence)) {
|
|
100
|
+
throw new OACError("malformed", "action profile: the occurrence argument is not declared");
|
|
101
|
+
}
|
|
97
102
|
return v;
|
|
98
103
|
}
|
|
99
|
-
/** Every argument is declared
|
|
104
|
+
/** Every argument is declared and every bound value is a valid field. */
|
|
100
105
|
export function checkAction(actionDoc, prof) {
|
|
101
106
|
const binds = prof.binds ?? {}, args = actionDoc.args;
|
|
102
107
|
const declared = new Set([...Object.values(binds), ...(prof.other_args ?? [])]);
|
|
@@ -111,6 +116,9 @@ export function checkAction(actionDoc, prof) {
|
|
|
111
116
|
if (!has(args, binds[field]))
|
|
112
117
|
throw new OACError("malformed", `a ${prof.effect} action needs ${binds[field]}`);
|
|
113
118
|
}
|
|
119
|
+
if (has(prof, "occurrence") && !OCCURRENCE(args[prof.occurrence])) {
|
|
120
|
+
throw new OACError("malformed", `argument ${prof.occurrence} must name the occurrence`);
|
|
121
|
+
}
|
|
114
122
|
}
|
|
115
123
|
export function action(v) {
|
|
116
124
|
check(v, { tool: [true, TOOL], args: [true, isObj] }, "action");
|
|
@@ -189,15 +197,15 @@ export function seal(v) {
|
|
|
189
197
|
return v;
|
|
190
198
|
}
|
|
191
199
|
export function receipt(v) {
|
|
192
|
-
const authority = { kind: [true, oneOf(...AUTHORITIES)], ref: [false, strCp(1, 256)] };
|
|
200
|
+
const authority = { kind: [true, oneOf(...AUTHORITIES)], ref: [false, strCp(1, 256)], digest: [false, DIGEST] };
|
|
193
201
|
const undo = { kind: [true, oneOf(...UNDO_KINDS)], until: [false, TIME], token: [false, strCp(1, 512)] };
|
|
194
202
|
const payment = { rail: [true, oneOf(...RAILS)], ref: [true, strCp(1, 256)], last4: [false, LAST4], order: [false, strCp(1, 256)] };
|
|
195
203
|
check(v, {
|
|
196
204
|
oac: [true, constant(VERSION)], type: [true, constant("receipt")], iss: [true, ID], sub: [true, ID], seq: [true, int(1)],
|
|
197
205
|
prev: [true, (p) => p === null || DIGEST(p)], iat: [true, TIME], tool: [true, TOOL], tier: [true, oneOf(...TIERS)],
|
|
198
206
|
authority: [true, obj(authority, "authority")], action: [true, DIGEST], preview: [false, DIGEST],
|
|
199
|
-
outcome: [true, oneOf(
|
|
200
|
-
undoes: [false, DIGEST], payment: [false, obj(payment, "payment")],
|
|
207
|
+
outcome: [true, oneOf(...OUTCOMES)], result: [true, DIGEST], undo: [true, obj(undo, "undo")],
|
|
208
|
+
undoes: [false, DIGEST], payment: [false, obj(payment, "payment")], occurrence: [false, OCCURRENCE],
|
|
201
209
|
}, "receipt");
|
|
202
210
|
return v;
|
|
203
211
|
}
|
|
@@ -216,3 +224,12 @@ export function undoRequest(v) {
|
|
|
216
224
|
}, "undo request");
|
|
217
225
|
return v;
|
|
218
226
|
}
|
|
227
|
+
export function deviceProof(v) {
|
|
228
|
+
const b64 = re(/^[A-Za-z0-9_-]{1,8192}$/);
|
|
229
|
+
check(v, {
|
|
230
|
+
oac: [true, constant(VERSION)], type: [true, constant("device-proof")], consent: [true, DIGEST],
|
|
231
|
+
credential: [true, re(/^[A-Za-z0-9_-]{1,1366}$/)], authenticator_data: [true, b64], client_data_json: [true, b64],
|
|
232
|
+
signature: [true, b64],
|
|
233
|
+
}, "device proof");
|
|
234
|
+
return v;
|
|
235
|
+
}
|
package/dist/status.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
// Status lists (spec
|
|
2
|
-
// multibase base64url "u"; index 0 = left-most bit; at least 16 KB), in a signed OAC object (typ oac-status+jws).
|
|
3
|
-
// Exactly one gzip member and nothing after it: read by hand, because Node's gunzip joins members and skips zeros.
|
|
1
|
+
// Status lists (spec 7.5): which Mandates a signer revoked, as a W3C bitstring.
|
|
4
2
|
import * as zlib from "node:zlib";
|
|
5
3
|
import * as shapes from "./shapes.js";
|
|
6
4
|
import { OACError } from "./errors.js";
|
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// anything real.
|
|
1
|
+
// Fixed keys for tests and vectors. The seeds are public: never use them for anything real.
|
|
3
2
|
import * as crypto from "node:crypto";
|
|
4
3
|
import { ed25519FromSeed, p256FromSeed } from "./jws.js";
|
|
5
4
|
export function vectorKey(label, alg = "EdDSA") {
|
package/dist/tiers.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Approval tiers (spec
|
|
2
|
-
// runs. tier = max(implied by the effect, profile floor, executor policy, taint floor, any hint); a hint can only raise.
|
|
1
|
+
// Approval tiers (spec 4). A hint can raise the tier, never lower it.
|
|
3
2
|
import * as shapes from "./shapes.js";
|
|
4
3
|
import { OACError } from "./errors.js";
|
|
5
4
|
const ORDER = "ABCD";
|
package/dist/undo.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Undo requests (spec
|
|
2
|
-
// needs its own Consent, named here; none cannot be undone. Mirrors undo.py.
|
|
1
|
+
// Undo requests (spec 8.3).
|
|
3
2
|
import * as shapes from "./shapes.js";
|
|
4
3
|
import { OACError } from "./errors.js";
|
|
5
4
|
import { KeySet, sign, tokenDigest, verify } from "./jws.js";
|
package/dist/vercel-ai/index.js
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
// OAC for the Vercel AI SDK
|
|
2
|
-
// `tool-approval-request`) and resume when a `tool-approval-response` with `approved: true` is sent back. That
|
|
3
|
-
// approval is a boolean (optionally HMAC-signed by the server). `withOAC` adds OAC: tier C (computed, not the
|
|
4
|
-
// model's say) asks for approval with the Preview's summary as the reason, and the tool's execute runs only if the
|
|
5
|
-
// person's signed Consent verifies for exactly this call; an approval without it runs nothing.
|
|
1
|
+
// OAC for the Vercel AI SDK: an approval runs nothing without a signed Consent. See guides/vercel-ai.md.
|
|
6
2
|
import { Gate, NeedsConsent, OACError } from "../index.js";
|
|
7
3
|
export const NOT_APPROVED = "Not approved: the person has not signed a consent for exactly this action. Nothing ran.";
|
|
8
4
|
export function withOAC(gate, tools, profiles, o) {
|
|
9
|
-
const tainted = o.tainted ?? (() => true); //
|
|
5
|
+
const tainted = o.tainted ?? (() => true); // unknown means tainted
|
|
10
6
|
const wrapped = {};
|
|
11
7
|
for (const [name, t] of Object.entries(tools)) {
|
|
12
8
|
gate.profile(name, profiles[name]);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Jwk } from "./jws.ts";
|
|
2
|
+
export declare const USER_PRESENT = 1, USER_VERIFIED = 4;
|
|
3
|
+
/** What an executor that requires a passkey knows about the person and itself. */
|
|
4
|
+
export type DeviceCheck = {
|
|
5
|
+
proof: Record<string, any> | null;
|
|
6
|
+
credentials: Record<string, Jwk>;
|
|
7
|
+
rp_id: string;
|
|
8
|
+
origins: string[];
|
|
9
|
+
};
|
|
10
|
+
/** The WebAuthn challenge for a Consent: SHA-256 of the token's ASCII bytes, base64url. */
|
|
11
|
+
export declare const challenge: (consentToken: string) => string;
|
|
12
|
+
export declare function verifyDeviceProof(consentToken: string, check: DeviceCheck): void;
|
package/dist/webauthn.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Device proofs (spec 3.6): a WebAuthn assertion from the person's passkey over one exact Consent token.
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
3
|
+
import * as shapes from "./shapes.js";
|
|
4
|
+
import { OACError } from "./errors.js";
|
|
5
|
+
import { parse } from "./jcs.js";
|
|
6
|
+
import { b64url, b64urlDecode, tokenDigest } from "./jws.js";
|
|
7
|
+
export const USER_PRESENT = 0x01, USER_VERIFIED = 0x04;
|
|
8
|
+
/** The WebAuthn challenge for a Consent: SHA-256 of the token's ASCII bytes, base64url. */
|
|
9
|
+
export const challenge = (consentToken) => b64url(crypto.createHash("sha256").update(consentToken, "ascii").digest());
|
|
10
|
+
function verifyEs256Der(jwk, data, sig) {
|
|
11
|
+
if (jwk.kty !== "EC" || jwk.crv !== "P-256")
|
|
12
|
+
throw new OACError("bad_alg", "a credential key is P-256");
|
|
13
|
+
let ok = false;
|
|
14
|
+
try {
|
|
15
|
+
const pub = crypto.createPublicKey({ key: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y }, format: "jwk" });
|
|
16
|
+
ok = crypto.verify("sha256", data, { key: pub, dsaEncoding: "der" }, sig);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
ok = false;
|
|
20
|
+
}
|
|
21
|
+
if (!ok)
|
|
22
|
+
throw new OACError("bad_signature");
|
|
23
|
+
}
|
|
24
|
+
export function verifyDeviceProof(consentToken, check) {
|
|
25
|
+
const proof = check.proof;
|
|
26
|
+
if (proof === null || proof === undefined)
|
|
27
|
+
throw new OACError("device_proof_missing");
|
|
28
|
+
shapes.deviceProof(proof);
|
|
29
|
+
if (proof.consent !== tokenDigest(consentToken))
|
|
30
|
+
throw new OACError("device_proof_mismatch", "the proof is for another consent");
|
|
31
|
+
const jwk = Object.hasOwn(check.credentials, proof.credential) ? check.credentials[proof.credential] : undefined;
|
|
32
|
+
if (jwk === undefined)
|
|
33
|
+
throw new OACError("unknown_credential");
|
|
34
|
+
const auth = b64urlDecode(proof.authenticator_data), client = b64urlDecode(proof.client_data_json);
|
|
35
|
+
if (auth.length < 37)
|
|
36
|
+
throw new OACError("malformed", "authenticator data is too short");
|
|
37
|
+
const clientHash = crypto.createHash("sha256").update(client).digest();
|
|
38
|
+
verifyEs256Der(jwk, Buffer.concat([auth, clientHash]), b64urlDecode(proof.signature));
|
|
39
|
+
const data = parse(client);
|
|
40
|
+
if (data === null || typeof data !== "object" || Array.isArray(data) || data.type !== "webauthn.get") {
|
|
41
|
+
throw new OACError("malformed", "client data is not an assertion");
|
|
42
|
+
}
|
|
43
|
+
if (data.challenge !== challenge(consentToken))
|
|
44
|
+
throw new OACError("challenge_mismatch");
|
|
45
|
+
const origins = typeof check.origins === "string" ? [check.origins] : [...check.origins];
|
|
46
|
+
if (!origins.includes(data.origin) || data.crossOrigin === true)
|
|
47
|
+
throw new OACError("wrong_origin");
|
|
48
|
+
const rpHash = crypto.createHash("sha256").update(check.rp_id).digest();
|
|
49
|
+
if (!Buffer.from(auth.subarray(0, 32)).equals(rpHash))
|
|
50
|
+
throw new OACError("rp_mismatch");
|
|
51
|
+
if (!(auth[32] & USER_PRESENT))
|
|
52
|
+
throw new OACError("user_not_present");
|
|
53
|
+
if (!(auth[32] & USER_VERIFIED))
|
|
54
|
+
throw new OACError("user_not_verified");
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-action-consent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Open Action Consent: a verifiable yes for actions AI agents take for people. Core SDK plus MCP (SDK v2) and Vercel AI SDK integrations.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|