@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,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
+ }
@@ -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
+ }