@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +15 -0
  4. package/README.md +154 -0
  5. package/dist/decision.d.ts +26 -0
  6. package/dist/decision.d.ts.map +1 -0
  7. package/dist/decision.js +28 -0
  8. package/dist/decision.js.map +1 -0
  9. package/dist/evaluate.d.ts +15 -0
  10. package/dist/evaluate.d.ts.map +1 -0
  11. package/dist/evaluate.js +112 -0
  12. package/dist/evaluate.js.map +1 -0
  13. package/dist/fetch.d.ts +61 -0
  14. package/dist/fetch.d.ts.map +1 -0
  15. package/dist/fetch.js +126 -0
  16. package/dist/fetch.js.map +1 -0
  17. package/dist/fingerprint.d.ts +14 -0
  18. package/dist/fingerprint.d.ts.map +1 -0
  19. package/dist/fingerprint.js +15 -0
  20. package/dist/fingerprint.js.map +1 -0
  21. package/dist/index.d.ts +15 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +15 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/log.d.ts +32 -0
  26. package/dist/log.d.ts.map +1 -0
  27. package/dist/log.js +8 -0
  28. package/dist/log.js.map +1 -0
  29. package/dist/mechanical.d.ts +30 -0
  30. package/dist/mechanical.d.ts.map +1 -0
  31. package/dist/mechanical.js +35 -0
  32. package/dist/mechanical.js.map +1 -0
  33. package/dist/policy.d.ts +36 -0
  34. package/dist/policy.d.ts.map +1 -0
  35. package/dist/policy.js +96 -0
  36. package/dist/policy.js.map +1 -0
  37. package/dist/proposal-ack.d.ts +111 -0
  38. package/dist/proposal-ack.d.ts.map +1 -0
  39. package/dist/proposal-ack.js +114 -0
  40. package/dist/proposal-ack.js.map +1 -0
  41. package/dist/proposal-acp.d.ts +18 -0
  42. package/dist/proposal-acp.d.ts.map +1 -0
  43. package/dist/proposal-acp.js +72 -0
  44. package/dist/proposal-acp.js.map +1 -0
  45. package/dist/proposal-ap2.d.ts +121 -0
  46. package/dist/proposal-ap2.d.ts.map +1 -0
  47. package/dist/proposal-ap2.js +112 -0
  48. package/dist/proposal-ap2.js.map +1 -0
  49. package/dist/proposal-mpp.d.ts +44 -0
  50. package/dist/proposal-mpp.d.ts.map +1 -0
  51. package/dist/proposal-mpp.js +106 -0
  52. package/dist/proposal-mpp.js.map +1 -0
  53. package/dist/proposal-universal.d.ts +239 -0
  54. package/dist/proposal-universal.d.ts.map +1 -0
  55. package/dist/proposal-universal.js +470 -0
  56. package/dist/proposal-universal.js.map +1 -0
  57. package/dist/proposal.d.ts +33 -0
  58. package/dist/proposal.d.ts.map +1 -0
  59. package/dist/proposal.js +107 -0
  60. package/dist/proposal.js.map +1 -0
  61. package/dist/transact.d.ts +24 -0
  62. package/dist/transact.d.ts.map +1 -0
  63. package/dist/transact.js +11 -0
  64. package/dist/transact.js.map +1 -0
  65. package/package.json +81 -0
  66. package/src/decision.ts +54 -0
  67. package/src/evaluate.ts +176 -0
  68. package/src/fetch.ts +169 -0
  69. package/src/fingerprint.ts +27 -0
  70. package/src/index.ts +68 -0
  71. package/src/log.ts +34 -0
  72. package/src/mechanical.ts +71 -0
  73. package/src/policy.ts +135 -0
  74. package/src/proposal-ack.ts +216 -0
  75. package/src/proposal-acp.ts +89 -0
  76. package/src/proposal-ap2.ts +195 -0
  77. package/src/proposal-mpp.ts +125 -0
  78. package/src/proposal-universal.ts +671 -0
  79. package/src/proposal.ts +170 -0
  80. package/src/transact.ts +34 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The AP2 buyer surface: read the LCP reference off the transport envelope that carries an AP2 mandate, and
3
+ * name the moment "verify before sign" is measured against.
4
+ *
5
+ * AP2 is the one wire in this set where the sign moment is genuinely ambiguous. x402 has one (the EIP-3009
6
+ * authorization), ACP has one (the checkout confirmation). AP2 has FOUR mandates — Checkout and Payment,
7
+ * each in an Open and a Closed stage — signed by different keys in different modes. A gate that says "verify
8
+ * before sign" without saying WHICH sign is a gate that cannot be audited, so `AP2_HALT_POINT` names it, and
9
+ * `parseProposalFromAp2Envelope` refuses to run at or after it.
10
+ */
11
+
12
+ import { ap2Placement } from "@integraledger/lcp-placement-ap2";
13
+ import type { GateProposal, ProposalContext } from "./proposal.js";
14
+
15
+ /**
16
+ * The steps of AP2 v0.2's authorization flow, in the order a Shopping Agent passes through them. CLOSED —
17
+ * an unknown token is a wire this parser has no halt point for, and inventing one would be the whole defect
18
+ * this type exists to prevent.
19
+ *
20
+ * `agent-key-signing` occurs in Autonomous mode only; `key-binding-presentation` is the `kb+sd-jwt` binding
21
+ * made when the closed mandate is PRESENTED to the merchant, which is why it is a separate step from the
22
+ * signature that created the mandate.
23
+ */
24
+ export type Ap2Step =
25
+ | "checkout-jwt-received"
26
+ | "mandate-content-built"
27
+ | "trusted-surface-authorization"
28
+ | "agent-key-signing"
29
+ | "key-binding-presentation";
30
+
31
+ /** The named sign moment, its defence, and the last step at which gating is still meaningful. */
32
+ export interface Ap2HaltPoint {
33
+ /** The last step at which no signing key has been invoked — where the gate must have finished. */
34
+ readonly lastSafeStep: Ap2Step;
35
+ /** The first step at which a signing key IS invoked. The gate halts strictly before this. */
36
+ readonly haltBefore: Ap2Step;
37
+ /** Every step at which some key signs, in flow order. `haltBefore` is the first of them. */
38
+ readonly signingSteps: readonly Ap2Step[];
39
+ /** Why this step and not another — the argument, not a restatement of the field. */
40
+ readonly rationale: string;
41
+ /** The host protocol's own text this is defended against — AP2 decides its flow, not LCP's appendix. */
42
+ readonly specRef: string;
43
+ }
44
+
45
+ /**
46
+ * AP2's halt point: **immediately before the Trusted Surface is invoked.**
47
+ *
48
+ * The defence, from AP2 v0.2's own specification rather than from LCP's Appendix C:
49
+ *
50
+ * - **Human Present (Direct).** "When a Shopping Agent has a Checkout JWT for the closed Checkout from the
51
+ * Merchant, they construct the Checkout and Payment Mandate Content and pass it to a Trusted Surface for
52
+ * display to the user and signing." The user's key signs at the Trusted Surface. Nothing signs before it.
53
+ * - **Human Not Present (Autonomous).** "When a Shopping Agent needs to operate autonomously, it will create
54
+ * open Checkout and Payment Mandate Content and have these authorized by the Trusted Surface. These MUST
55
+ * include the agent's public key as a `cnf` claim." The Trusted Surface is invoked FIRST here too; the
56
+ * Shopping Agent's own Agent Key signs the closed mandates only afterwards.
57
+ *
58
+ * So both modes place the Trusted Surface first, and that makes it the halt point for both — the gate does
59
+ * not need to know which mode it is in, which is the property that makes this auditable. `agent-key-signing`
60
+ * is a later signing step, not an earlier one, and gating there would already be too late.
61
+ *
62
+ * **And the window closes hard.** The closed Checkout Mandate carries `checkout_hash`, "a base64url-encoded
63
+ * hash of the merchant-signed `checkout_jwt`", the Payment Mandate "is bound to a particular Checkout using
64
+ * the cryptographic hash of the Checkout JWT", and the closed mandate is a `kb+sd-jwt` whose key-binding
65
+ * signature is constructed over `sd_hash` **at presentation**. Once the Trusted Surface has signed, nothing
66
+ * inside either mandate is mutable and no later verification can be turned back into a decision.
67
+ *
68
+ * **This is a HALT POINT, not a placement constraint.** The reference rides the transport envelope's
69
+ * metadata, which AP2 never signs and never reads — so the SELLER may place it at any time, under no
70
+ * ordering precondition at all. Contrast ACK, where placement must happen BEFORE the issuer signs: a field
71
+ * added after issuance either breaks the embedded signature or falls outside the payload the host treats as
72
+ * authoritative. What is time-critical here is the BUYER's decision, because after this step the buyer has
73
+ * no decision left to make.
74
+ */
75
+ export const AP2_HALT_POINT: Ap2HaltPoint = {
76
+ lastSafeStep: "mandate-content-built",
77
+ haltBefore: "trusted-surface-authorization",
78
+ signingSteps: [
79
+ "trusted-surface-authorization",
80
+ "agent-key-signing",
81
+ "key-binding-presentation",
82
+ ],
83
+ rationale:
84
+ "Both AP2 modes invoke the Trusted Surface before any other key: in Direct mode the Shopping Agent passes the Mandate Content to it for user signing, and in Autonomous mode it authorizes the OPEN mandates before the Agent Key signs the closed ones. It is therefore the first signing step in either mode, and the closed mandate's checkout_hash plus its presentation-time key binding leave nothing mutable afterwards.",
85
+ specRef:
86
+ "AP2 v0.2 (google-agentic-commerce/AP2 @ main, release 0.2.0 dated 2026-04-28) docs/ap2/specification.md §Human Present / §Human Not Present, and docs/ap2/checkout_mandate.md (vct mandate.checkout.1, checkout_hash, kb+sd-jwt over sd_hash at presentation); read 2026-07-30",
87
+ };
88
+
89
+ /** Does this step invoke a signing key? Membership in `AP2_HALT_POINT.signingSteps`, as a function. */
90
+ export function isAp2SigningStep(step: Ap2Step): boolean {
91
+ return AP2_HALT_POINT.signingSteps.includes(step);
92
+ }
93
+
94
+ /**
95
+ * Throw unless the client is still strictly before the halt point.
96
+ *
97
+ * Fail-fast rather than a returned flag: a caller that gates at `agent-key-signing` has already handed the
98
+ * user's key to the Trusted Surface, and there is no honest outcome to report at that point — the decision
99
+ * the gate exists to make has already been made by someone else.
100
+ */
101
+ export function assertBeforeAp2HaltPoint(step: Ap2Step): void {
102
+ if (isAp2SigningStep(step))
103
+ throw new Error(
104
+ `AP2 gate refused at "${step}": that is at or after the halt point (${AP2_HALT_POINT.haltBefore}). ` +
105
+ `Verify before sign means gating no later than "${AP2_HALT_POINT.lastSafeStep}". ${AP2_HALT_POINT.rationale}`,
106
+ );
107
+ }
108
+
109
+ /**
110
+ * The context AP2's wire does NOT carry, which the buyer's client establishes — and it is more than the
111
+ * other parsers need, for two reasons that are both properties of AP2 rather than gaps here.
112
+ */
113
+ export interface Ap2ProposalContext extends ProposalContext {
114
+ /**
115
+ * The terms document's locator.
116
+ *
117
+ * AP2's placement is INTEGRITY-ONLY: `AP2_PLACEMENT` declares no `termsUrlField`, because the A2A
118
+ * `Message.metadata` map holds the reference and AP2 models no terms-URL field anywhere. So unlike ACP
119
+ * (`metadata.legal_context_url`) and x402 (`extra.legalContextUrl`), the locator cannot come off the wire.
120
+ * It comes from LCP §2 discovery — the seller's `/.well-known/legal-context.json` — resolved by the
121
+ * client before it calls this parser. HTTPS is enforced here exactly as it is on every other wire.
122
+ */
123
+ readonly legalContextUrl: string;
124
+ /**
125
+ * The offer the client is about to authorize.
126
+ *
127
+ * AP2 v0.2 carries the price inside the merchant-signed Checkout JWT, referenced from the closed mandate
128
+ * only by `checkout_hash`. The transport carries mandates as OPAQUE SD-JWT compact strings; this parser
129
+ * does not decode one, and decoding one would mean trusting an unverified payload to produce the number
130
+ * the gate compares against a policy cap. The client already holds the checkout it built its Mandate
131
+ * Content from, so it supplies the amount it is about to commit — the honest source.
132
+ */
133
+ readonly offer: {
134
+ readonly amount: string;
135
+ readonly unit: string;
136
+ };
137
+ /** Where the client is in AP2's flow. At or after the halt point this parser refuses. */
138
+ readonly step: Ap2Step;
139
+ }
140
+
141
+ const BASE_UNIT_INT = /^[0-9]+$/; // decimal base-unit integer — no sign, no decimal point, non-empty
142
+
143
+ /**
144
+ * Parse an AP2 transport envelope into the SAME typed `GateProposal` every other wire produces.
145
+ *
146
+ * The reference is read through `@integraledger/lcp-placement-ap2`, never through a hand-rolled path. The
147
+ * manifest declares both the canonical `metadata.legalContext` and the snake_case alias
148
+ * `metadata.legal_context`; a second reader here would be a second spelling of the manifest, free to drift
149
+ * from the one the seller writes through. It also inherits the placement's deliberate blindness to the Tier
150
+ * B shape — a reference embedded INSIDE the mandate is not read, so a buyer cannot be walked into blessing
151
+ * a placement no shipped package can emit, because LCP's appendix illustrates and the host protocol binds.
152
+ *
153
+ * Fail-fast (throws) on: a step at or after `AP2_HALT_POINT`, an envelope carrying no reference, a carrier
154
+ * that is not `sha256`, a non-HTTPS terms URL, or a non-base-unit-integer amount.
155
+ */
156
+ export function parseProposalFromAp2Envelope(
157
+ envelope: unknown,
158
+ ctx: Ap2ProposalContext,
159
+ ): GateProposal {
160
+ // FIRST, before any reading. A gate that parses and then discovers it was too late has already spent the
161
+ // buyer's time pretending a decision was available.
162
+ assertBeforeAp2HaltPoint(ctx.step);
163
+
164
+ const extracted = ap2Placement.extract(envelope);
165
+ if (!("ok" in extracted))
166
+ throw new Error(
167
+ `AP2 envelope carries no readable LCP reference (${extracted.haltClass}/${extracted.code})`,
168
+ );
169
+ const ref = extracted.value;
170
+ if (ref.type !== "sha256")
171
+ throw new Error(
172
+ `AP2 legalContext must be a sha256 carrier, got "${ref.type}" — the gate compares the advertised ` +
173
+ "hash against a recomputed one, and a locator commits to nothing",
174
+ );
175
+ if (!ctx.legalContextUrl.startsWith("https://"))
176
+ throw new Error(`legalContextUrl must be HTTPS: ${ctx.legalContextUrl}`);
177
+ if (!BASE_UNIT_INT.test(ctx.offer.amount))
178
+ throw new Error(
179
+ `offer amount must be a base-unit integer string: "${ctx.offer.amount}"`,
180
+ );
181
+
182
+ // No 0x-32-byte re-check: `extract` decodes through binding-core's codec, whose `sha256` validity rule IS
183
+ // `kernel.isAtrHash`. A second copy of that regex could reject nothing the decode admitted — the same
184
+ // asymmetry `proposal-acp.ts` documents, and for the same reason.
185
+ return {
186
+ advertisedAtrHash: ref.value as `0x${string}`,
187
+ legalContextUrl: ctx.legalContextUrl,
188
+ level: ctx.level,
189
+ offer: {
190
+ amount: ctx.offer.amount,
191
+ unit: ctx.offer.unit,
192
+ },
193
+ sellerAssurance: ctx.sellerAssurance,
194
+ };
195
+ }
@@ -0,0 +1,125 @@
1
+ import { isAtrHash } from "@integraledger/lcp-kernel";
2
+ import { z } from "zod";
3
+ import type { GateProposal, ProposalContext } from "./proposal.js";
4
+
5
+ // Module-internal structural view of the MPP `request` body (NOT the seller's type — buyer ≠ seller).
6
+ // `z.object` strips unknown keys, so the optional members this parser has no use for — `description`,
7
+ // `expires`, `externalId`, `recipient` — are harmlessly ignored. Kept internal and not `z.infer`-exported
8
+ // (isolatedDeclarations), exactly as `X402ChallengeSchema` and `AcpSessionSchema` are.
9
+ //
10
+ // `methodDetails` is OPTIONAL in the charge intent (draft-payment-intent-charge-00 §5.1.2, Table 3), which
11
+ // is why it is optional here and its absence is a refusal below rather than a schema error: a request body
12
+ // with no `methodDetails` is a perfectly conformant MPP document that simply advertises no LCP reference,
13
+ // and saying so is more useful than a Zod path error.
14
+ const MppRequestSchema = z.object({
15
+ amount: z.string(),
16
+ currency: z.string(),
17
+ methodDetails: z
18
+ .object({
19
+ atrHash: z.string().optional(),
20
+ legalContextUrl: z.string().optional(),
21
+ })
22
+ .optional(),
23
+ });
24
+
25
+ // Decimal base-unit integer — no sign, no decimal point, non-empty. Identical in shape to the x402 parser's
26
+ // check and required for the same reason: `policy.ts` compares the offer with `BigInt(offer.amount)`, and
27
+ // `BigInt("10.50")` THROWS rather than returning, which would escape `evaluate`'s contract to RETURN a
28
+ // `GateDecision`. The grammar is the host's, not ours: draft-payment-intent-charge-00 Table 2 defines
29
+ // `amount` as "Payment amount in base units (smallest denomination)", and §3 defines base units as "the
30
+ // smallest denomination of a currency or asset. For USD, this is cents (1/100)." Every example in the
31
+ // specification agrees — "5000"/usd, "1000000"/token, "100000"/sat — so a decimal amount is malformed MPP
32
+ // and is refused here rather than thrown from inside the gate.
33
+ const BASE_UNIT_INT = /^[0-9]+$/;
34
+
35
+ /**
36
+ * Parse an MPP `request` body into the SAME typed `GateProposal` the x402 and ACP parsers produce (the
37
+ * LCP §12.7 boundary — no prose field on the type). Fail-fast (throws) on a malformed body, an absent or
38
+ * non-0x-32-byte `atrHash`, a non-HTTPS terms URL, or a non-base-unit-integer amount.
39
+ *
40
+ * **The document is the `request` body, not the challenge.** MPP's identity lives one layer out, in the
41
+ * `WWW-Authenticate: Payment` challenge's auth-params; the body this parses is the base64url(JCS(JSON))
42
+ * payload that challenge's `request` auth-param carries. A caller holding a decoded body is expected to
43
+ * have decoded it from there.
44
+ *
45
+ * **BY NAME, and deliberately not in `PROPOSAL_PARSERS`.** MPP's discriminant is recorded as
46
+ * `kind: "undiscriminable"`: the body's members are `amount` and `currency` plus optionals, and an
47
+ * amount/currency pair is the shape of almost every payment document there is. Nothing in the body names
48
+ * MPP. So a caller must name `mpp` and reach this function directly — the same by-name route
49
+ * `parseProposalFromAp2Envelope` takes, and for a sibling reason. Adding an `mpp` row to the universal
50
+ * dispatch map would not make it reachable; it would make the map claim a discrimination it cannot perform.
51
+ *
52
+ * **The carrier is `bare-value`, so no codec has validated it.** `placement-mpp` declares
53
+ * `field: "methodDetails.atrHash"` with `encoding: "bare-value"` and `carrierTypes: ["sha256"]` — the wire
54
+ * carries the raw hash rather than an `lcp:sha256:0x…` string. That is why this validates the hash itself
55
+ * through the kernel's `isAtrHash`, as the x402 parser validates its raw `extra.atrHash`, and why it does
56
+ * NOT call `decodeLegalContextString` as the ACP parser does. Using the kernel's own predicate rather than
57
+ * restating its regex keeps one definition of what an ATR hash is.
58
+ *
59
+ * **`legalContextUrl` is the field the placement names.** `placement-mpp` declares
60
+ * `termsUrlField: "methodDetails.legalContextUrl"`, so the field this parser demands is the field the
61
+ * manifest names — which is what makes the seller's write and this buyer's read compose. There is no
62
+ * fallback to any other member: MPP defines none that means "these terms", and inventing one would gate the
63
+ * buyer against a document nobody pointed at.
64
+ *
65
+ * **What the carrier is worth is weaker than Tier A alone suggests, and the buyer should know it.** MPP
66
+ * binds the challenge `id` to the challenge parameters, so a client cannot alter the advertised values and
67
+ * still be accepted — but the binding key is a server secret the specification requires implementations to
68
+ * keep server-side, so **the buyer cannot verify that MAC.** What this reference gets is tamper-evidence,
69
+ * not a buyer-verifiable seller commitment. The gate treats it as an advertised value to be recomputed
70
+ * against fetched bytes, exactly as it treats every other protocol's, and that recomputation is what the
71
+ * guarantee actually rests on.
72
+ *
73
+ * `GateProposal` and `ProposalContext` are IMPORTED, never redefined — one type for every wire is the whole
74
+ * point of a single typed proposal, and a second copy would let the two drift.
75
+ */
76
+ export function parseProposalFromMppRequest(
77
+ request: unknown,
78
+ ctx: ProposalContext,
79
+ ): GateProposal {
80
+ const parsed = MppRequestSchema.parse(request);
81
+
82
+ // Absence is a refusal, not a permission. A conformant MPP body may omit `methodDetails` entirely, and
83
+ // may carry one that names no LCP reference; neither is a document this gate can bind terms from, and
84
+ // both are named rather than collapsed into one message so a seller reading the error knows which it is.
85
+ const details = parsed.methodDetails;
86
+ if (details === undefined)
87
+ throw new Error(
88
+ "MPP request body carries no `methodDetails` — nothing advertises an LCP reference",
89
+ );
90
+ const { atrHash, legalContextUrl } = details;
91
+ if (atrHash === undefined)
92
+ throw new Error(
93
+ "MPP request body advertises no `methodDetails.atrHash` — the placement's declared carrier is absent",
94
+ );
95
+ if (legalContextUrl === undefined)
96
+ throw new Error(
97
+ "MPP request body advertises no `methodDetails.legalContextUrl` — the placement's declared terms-URL field is absent",
98
+ );
99
+
100
+ if (!isAtrHash(atrHash))
101
+ throw new Error(
102
+ `advertised atrHash is not a 0x-prefixed 32-byte hex: ${atrHash}`,
103
+ );
104
+ if (!legalContextUrl.startsWith("https://"))
105
+ throw new Error(`legalContextUrl must be HTTPS: ${legalContextUrl}`);
106
+ if (!BASE_UNIT_INT.test(parsed.amount))
107
+ throw new Error(
108
+ `offer amount must be a base-unit integer string: "${parsed.amount}"`,
109
+ );
110
+
111
+ return {
112
+ advertisedAtrHash: atrHash,
113
+ legalContextUrl,
114
+ level: ctx.level,
115
+ offer: {
116
+ amount: parsed.amount,
117
+ // MPP's `currency` is a "Currency or asset identifier" (Table 2) and spans the whole family — an ISO
118
+ // 4217 code for `card`/`stripe`, a token address for `evm`, `sat` for `lightning`. It is carried
119
+ // verbatim as the unit string, exactly as ACP's fiat code and x402's `network:asset` pair are: the
120
+ // gate compares units for equality against the buyer's cap and never interprets them.
121
+ unit: parsed.currency,
122
+ },
123
+ sellerAssurance: ctx.sellerAssurance,
124
+ };
125
+ }