@integraledger/agent-guard 0.9.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/CHANGELOG.md +57 -0
- package/LICENSE +202 -0
- package/NOTICE +15 -0
- package/README.md +154 -0
- package/dist/decision.d.ts +26 -0
- package/dist/decision.d.ts.map +1 -0
- package/dist/decision.js +28 -0
- package/dist/decision.js.map +1 -0
- package/dist/evaluate.d.ts +15 -0
- package/dist/evaluate.d.ts.map +1 -0
- package/dist/evaluate.js +112 -0
- package/dist/evaluate.js.map +1 -0
- package/dist/fetch.d.ts +61 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +126 -0
- package/dist/fetch.js.map +1 -0
- package/dist/fingerprint.d.ts +14 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +15 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +32 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +8 -0
- package/dist/log.js.map +1 -0
- package/dist/mechanical.d.ts +30 -0
- package/dist/mechanical.d.ts.map +1 -0
- package/dist/mechanical.js +35 -0
- package/dist/mechanical.js.map +1 -0
- package/dist/policy.d.ts +36 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +96 -0
- package/dist/policy.js.map +1 -0
- package/dist/proposal-ack.d.ts +111 -0
- package/dist/proposal-ack.d.ts.map +1 -0
- package/dist/proposal-ack.js +114 -0
- package/dist/proposal-ack.js.map +1 -0
- package/dist/proposal-acp.d.ts +18 -0
- package/dist/proposal-acp.d.ts.map +1 -0
- package/dist/proposal-acp.js +72 -0
- package/dist/proposal-acp.js.map +1 -0
- package/dist/proposal-ap2.d.ts +121 -0
- package/dist/proposal-ap2.d.ts.map +1 -0
- package/dist/proposal-ap2.js +112 -0
- package/dist/proposal-ap2.js.map +1 -0
- package/dist/proposal-mpp.d.ts +44 -0
- package/dist/proposal-mpp.d.ts.map +1 -0
- package/dist/proposal-mpp.js +106 -0
- package/dist/proposal-mpp.js.map +1 -0
- package/dist/proposal-universal.d.ts +239 -0
- package/dist/proposal-universal.d.ts.map +1 -0
- package/dist/proposal-universal.js +470 -0
- package/dist/proposal-universal.js.map +1 -0
- package/dist/proposal.d.ts +33 -0
- package/dist/proposal.d.ts.map +1 -0
- package/dist/proposal.js +107 -0
- package/dist/proposal.js.map +1 -0
- package/dist/transact.d.ts +24 -0
- package/dist/transact.d.ts.map +1 -0
- package/dist/transact.js +11 -0
- package/dist/transact.js.map +1 -0
- package/package.json +81 -0
- package/src/decision.ts +54 -0
- package/src/evaluate.ts +176 -0
- package/src/fetch.ts +169 -0
- package/src/fingerprint.ts +27 -0
- package/src/index.ts +68 -0
- package/src/log.ts +34 -0
- package/src/mechanical.ts +71 -0
- package/src/policy.ts +135 -0
- package/src/proposal-ack.ts +216 -0
- package/src/proposal-acp.ts +89 -0
- package/src/proposal-ap2.ts +195 -0
- package/src/proposal-mpp.ts +125 -0
- package/src/proposal-universal.ts +671 -0
- package/src/proposal.ts +170 -0
- package/src/transact.ts +34 -0
package/src/proposal.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { Assurance } from "@integraledger/lcp-authority";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/** The TYPED inputs the gate decides on. It CANNOT carry natural-language prose — the prompt-injection
|
|
5
|
+
* boundary is architectural (LCP §12.7). Prose is fetched and retained as evidence, never fed to policy. */
|
|
6
|
+
export interface GateProposal {
|
|
7
|
+
readonly advertisedAtrHash: `0x${string}`;
|
|
8
|
+
readonly legalContextUrl: string;
|
|
9
|
+
readonly level: 1 | 2 | 3 | 4;
|
|
10
|
+
readonly offer: {
|
|
11
|
+
readonly amount: string;
|
|
12
|
+
readonly unit: string;
|
|
13
|
+
};
|
|
14
|
+
readonly sellerAssurance: Assurance;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Context the host challenge does NOT carry — the buyer's client establishes it: the LCP trust level and
|
|
19
|
+
* the seller's stated assurance.
|
|
20
|
+
*
|
|
21
|
+
* **There is deliberately no offer-validity window here.** An earlier shape carried `validFrom`/`validUntil`
|
|
22
|
+
* on every proposal and the gate read neither, which told a reader that expiry was gated when it was not.
|
|
23
|
+
* The window is not a gap to fill: whether a quote has gone stale is AGENT OPERATIONS, and LCP has no
|
|
24
|
+
* opinion on those — its subject is that final terms are provably bound to the payment. This gate's job
|
|
25
|
+
* stops at the binding. A buyer that wants offer-expiry policy holds it in its own client, where the
|
|
26
|
+
* decision belongs, and reaches `transact` only when it still intends to pay.
|
|
27
|
+
*/
|
|
28
|
+
export interface ProposalContext {
|
|
29
|
+
readonly level: 1 | 2 | 3 | 4;
|
|
30
|
+
readonly sellerAssurance: Assurance;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Module-internal structural view of the x402 402 wire format (NOT the seller's type — buyer ≠ seller).
|
|
34
|
+
// z.object strips unknown keys, so a real challenge's other fields (scheme/payTo/x402Version) are harmlessly
|
|
35
|
+
// ignored. Kept internal + not z.infer-exported (isolatedDeclarations).
|
|
36
|
+
//
|
|
37
|
+
// x402 v2 carries the reference in TWO Tier A places and BOTH are optional here: the per-requirement
|
|
38
|
+
// `accepts[].extra` and the challenge-level `extensions.legalContext` map. Requiring `extra` — as this
|
|
39
|
+
// schema did — rejected a spec-legal seller that advertises only in `extensions`, which is a carrier shape
|
|
40
|
+
// real seller implementations emit. Which one is present is resolved below, not here:
|
|
41
|
+
// Zod's job is the shape, and "at least one of two carriers, agreeing if both" is a rule about meaning.
|
|
42
|
+
const X402ChallengeSchema = z.object({
|
|
43
|
+
accepts: z
|
|
44
|
+
.array(
|
|
45
|
+
z.object({
|
|
46
|
+
amount: z.string(),
|
|
47
|
+
network: z.string(),
|
|
48
|
+
asset: z.string(),
|
|
49
|
+
extra: z
|
|
50
|
+
.object({
|
|
51
|
+
atrHash: z.string().optional(),
|
|
52
|
+
legalContextUrl: z.string().optional(),
|
|
53
|
+
})
|
|
54
|
+
.optional(),
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
.min(1),
|
|
58
|
+
extensions: z
|
|
59
|
+
.object({
|
|
60
|
+
legalContext: z.object({
|
|
61
|
+
info: z.object({
|
|
62
|
+
type: z.string(),
|
|
63
|
+
value: z.string(),
|
|
64
|
+
legalContextUrl: z.string().optional(),
|
|
65
|
+
}),
|
|
66
|
+
}),
|
|
67
|
+
})
|
|
68
|
+
.optional(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reconcile ONE field across x402's two Tier A carriers.
|
|
73
|
+
*
|
|
74
|
+
* Field-by-field, not carrier-by-carrier, because the two carriers are not required to be symmetric. LCP
|
|
75
|
+
* v1.38 §C.4's own illustration puts `atrHash` + `legalContextUrl` in `accepts[].extra` while
|
|
76
|
+
* `extensions.legalContext.info` carries only `type` + `value` — so treating each carrier as an atomic
|
|
77
|
+
* {hash, url} pair rejects the spec's canonical example.
|
|
78
|
+
*
|
|
79
|
+
* `accepts[].extra` wins when both agree, because it is the per-requirement carrier and binds to the
|
|
80
|
+
* requirement actually being paid. Disagreement is NOT resolved by preference: two different values on one
|
|
81
|
+
* challenge would let a seller advertise different terms to different readers of the same document, and a
|
|
82
|
+
* buyer that quietly picked one would gate against terms the seller can later disown. That refuses.
|
|
83
|
+
*/
|
|
84
|
+
function reconcileField(
|
|
85
|
+
field: string,
|
|
86
|
+
fromExtra: string | undefined,
|
|
87
|
+
fromExtensions: string | undefined,
|
|
88
|
+
equal: (a: string, b: string) => boolean,
|
|
89
|
+
): string {
|
|
90
|
+
if (fromExtra !== undefined && fromExtensions !== undefined) {
|
|
91
|
+
if (!equal(fromExtra, fromExtensions))
|
|
92
|
+
throw new Error(
|
|
93
|
+
`x402 carriers disagree on ${field} — accepts[].extra advertises "${fromExtra}", ` +
|
|
94
|
+
`extensions.legalContext advertises "${fromExtensions}"`,
|
|
95
|
+
);
|
|
96
|
+
return fromExtra;
|
|
97
|
+
}
|
|
98
|
+
const only = fromExtra ?? fromExtensions;
|
|
99
|
+
if (only === undefined)
|
|
100
|
+
throw new Error(
|
|
101
|
+
`x402 challenge advertises no ${field} — neither accepts[].extra nor extensions.legalContext carries one`,
|
|
102
|
+
);
|
|
103
|
+
return only;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ATR_HASH = /^0x[0-9a-fA-F]{64}$/;
|
|
107
|
+
const BASE_UNIT_INT = /^[0-9]+$/; // decimal base-unit integer — no sign, no decimal point, non-empty
|
|
108
|
+
|
|
109
|
+
/** Parse an x402 402 challenge into a typed GateProposal (the LCP §12.7 boundary — no prose field on the type).
|
|
110
|
+
* Fail-fast (throws) on a malformed challenge, a non-0x-32-byte atrHash, a non-HTTPS terms URL, or a
|
|
111
|
+
* non-base-unit-integer amount (this closes the empty/decimal-amount crack at the trust boundary). */
|
|
112
|
+
export function parseProposalFromChallenge(
|
|
113
|
+
challenge: unknown,
|
|
114
|
+
ctx: ProposalContext,
|
|
115
|
+
): GateProposal {
|
|
116
|
+
const parsed = X402ChallengeSchema.parse(challenge);
|
|
117
|
+
// THE FIRST REQUIREMENT, DELIBERATELY, AND THE GATE DOES NOT CHOOSE.
|
|
118
|
+
//
|
|
119
|
+
// x402's `accepts` is a list of ALTERNATIVE payment requirements. Which one to pay is the agent's own
|
|
120
|
+
// decision — a matter of rails, balances and preference — and this gate has no opinion on it, because
|
|
121
|
+
// choosing how to pay is agent operations rather than binding terms to a payment. A caller that wants a
|
|
122
|
+
// different requirement narrows `accepts` to it BEFORE calling, and gets a proposal gated against that
|
|
123
|
+
// one; the amount checked against the buyer's cap is always the amount on the requirement passed in.
|
|
124
|
+
//
|
|
125
|
+
// What this must never become is a preference rule invented here. Reconciling the reference across two
|
|
126
|
+
// carriers refuses on disagreement precisely because a silent choice lets a seller disown whichever
|
|
127
|
+
// reading lost; a silent choice of REQUIREMENT would gate the buyer against a price it did not pick.
|
|
128
|
+
const req = parsed.accepts[0];
|
|
129
|
+
if (req === undefined)
|
|
130
|
+
throw new Error("x402 challenge has no accepted requirement");
|
|
131
|
+
// The extensions carrier states its own carrier type; anything but sha256 is refused rather than read,
|
|
132
|
+
// because `advertisedAtrHash` is compared against a recomputed record hash and nothing else can be.
|
|
133
|
+
const info = parsed.extensions?.legalContext.info;
|
|
134
|
+
if (info !== undefined && info.type !== "sha256")
|
|
135
|
+
throw new Error(
|
|
136
|
+
`x402 extensions.legalContext.info.type must be sha256, got "${info.type}"`,
|
|
137
|
+
);
|
|
138
|
+
const atrHash = reconcileField(
|
|
139
|
+
"atrHash",
|
|
140
|
+
req.extra?.atrHash,
|
|
141
|
+
info?.value,
|
|
142
|
+
(a, b) => a.toLowerCase() === b.toLowerCase(), // hex is case-insensitive; the ATR-canon any-case rule
|
|
143
|
+
);
|
|
144
|
+
const legalContextUrl = reconcileField(
|
|
145
|
+
"legalContextUrl",
|
|
146
|
+
req.extra?.legalContextUrl,
|
|
147
|
+
info?.legalContextUrl,
|
|
148
|
+
(a, b) => a === b,
|
|
149
|
+
);
|
|
150
|
+
if (!ATR_HASH.test(atrHash))
|
|
151
|
+
throw new Error(
|
|
152
|
+
`advertised atrHash is not a 0x-prefixed 32-byte hex: ${atrHash}`,
|
|
153
|
+
);
|
|
154
|
+
if (!legalContextUrl.startsWith("https://"))
|
|
155
|
+
throw new Error(`legalContextUrl must be HTTPS: ${legalContextUrl}`);
|
|
156
|
+
if (!BASE_UNIT_INT.test(req.amount))
|
|
157
|
+
throw new Error(
|
|
158
|
+
`offer amount must be a base-unit integer string: "${req.amount}"`,
|
|
159
|
+
);
|
|
160
|
+
return {
|
|
161
|
+
advertisedAtrHash: atrHash as `0x${string}`,
|
|
162
|
+
legalContextUrl,
|
|
163
|
+
level: ctx.level,
|
|
164
|
+
offer: {
|
|
165
|
+
amount: req.amount,
|
|
166
|
+
unit: `${req.network}:${req.asset}`,
|
|
167
|
+
},
|
|
168
|
+
sellerAssurance: ctx.sellerAssurance,
|
|
169
|
+
};
|
|
170
|
+
}
|
package/src/transact.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { GateDecision } from "./decision.js";
|
|
2
|
+
import { evaluate, type GatePorts } from "./evaluate.js";
|
|
3
|
+
import type { BuyerPolicy } from "./policy.js";
|
|
4
|
+
import type { GateProposal } from "./proposal.js";
|
|
5
|
+
|
|
6
|
+
/** A signer the gate invokes ONLY on Proceed. The gate binds the atrHash it verified; the signer signs THROUGH
|
|
7
|
+
* an authority artifact (the grant/acceptance) by its own contract, never a bare hash. On Decline/Escalate the
|
|
8
|
+
* signer is never called — the key is structurally gated (the "before any signing key is invoked" guarantee). */
|
|
9
|
+
export interface GuardedSigner {
|
|
10
|
+
sign(
|
|
11
|
+
verifiedAtrHash: `0x${string}`,
|
|
12
|
+
): Promise<{ readonly signature: `0x${string}` }>;
|
|
13
|
+
}
|
|
14
|
+
export type TransactResult =
|
|
15
|
+
| {
|
|
16
|
+
readonly kind: "signed";
|
|
17
|
+
readonly signature: `0x${string}`;
|
|
18
|
+
readonly atrHash: `0x${string}`;
|
|
19
|
+
}
|
|
20
|
+
| { readonly kind: "halted"; readonly decision: GateDecision };
|
|
21
|
+
|
|
22
|
+
/** Verify-before-sign as a type + a runtime gate: evaluate the proposal, and invoke the signing key ONLY when
|
|
23
|
+
* the decision is Proceed. On Decline/Escalate the signer is never reached (LCP §5.3), by construction. */
|
|
24
|
+
export async function transact(
|
|
25
|
+
proposal: GateProposal,
|
|
26
|
+
policy: BuyerPolicy,
|
|
27
|
+
ports: GatePorts,
|
|
28
|
+
signer: GuardedSigner,
|
|
29
|
+
): Promise<TransactResult> {
|
|
30
|
+
const decision = await evaluate(proposal, policy, ports);
|
|
31
|
+
if (decision.kind !== "proceed") return { kind: "halted", decision };
|
|
32
|
+
const { signature } = await signer.sign(proposal.advertisedAtrHash);
|
|
33
|
+
return { kind: "signed", signature, atrHash: proposal.advertisedAtrHash };
|
|
34
|
+
}
|