@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,24 @@
1
+ import type { GateDecision } from "./decision.js";
2
+ import { type GatePorts } from "./evaluate.js";
3
+ import type { BuyerPolicy } from "./policy.js";
4
+ import type { GateProposal } from "./proposal.js";
5
+ /** A signer the gate invokes ONLY on Proceed. The gate binds the atrHash it verified; the signer signs THROUGH
6
+ * an authority artifact (the grant/acceptance) by its own contract, never a bare hash. On Decline/Escalate the
7
+ * signer is never called — the key is structurally gated (the "before any signing key is invoked" guarantee). */
8
+ export interface GuardedSigner {
9
+ sign(verifiedAtrHash: `0x${string}`): Promise<{
10
+ readonly signature: `0x${string}`;
11
+ }>;
12
+ }
13
+ export type TransactResult = {
14
+ readonly kind: "signed";
15
+ readonly signature: `0x${string}`;
16
+ readonly atrHash: `0x${string}`;
17
+ } | {
18
+ readonly kind: "halted";
19
+ readonly decision: GateDecision;
20
+ };
21
+ /** Verify-before-sign as a type + a runtime gate: evaluate the proposal, and invoke the signing key ONLY when
22
+ * the decision is Proceed. On Decline/Escalate the signer is never reached (LCP §5.3), by construction. */
23
+ export declare function transact(proposal: GateProposal, policy: BuyerPolicy, ports: GatePorts, signer: GuardedSigner): Promise<TransactResult>;
24
+ //# sourceMappingURL=transact.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transact.d.ts","sourceRoot":"","sources":["../src/transact.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAY,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD;;kHAEkH;AAClH,MAAM,WAAW,aAAa;IAC5B,IAAI,CACF,eAAe,EAAE,KAAK,MAAM,EAAE,GAC7B,OAAO,CAAC;QAAE,QAAQ,CAAC,SAAS,EAAE,KAAK,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;CACnD;AACD,MAAM,MAAM,cAAc,GACtB;IACE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,KAAK,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,OAAO,EAAE,KAAK,MAAM,EAAE,CAAC;CACjC,GACD;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAA;CAAE,CAAC;AAEjE;4GAC4G;AAC5G,wBAAsB,QAAQ,CAC5B,QAAQ,EAAE,YAAY,EACtB,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,SAAS,EAChB,MAAM,EAAE,aAAa,GACpB,OAAO,CAAC,cAAc,CAAC,CAKzB"}
@@ -0,0 +1,11 @@
1
+ import { evaluate } from "./evaluate.js";
2
+ /** Verify-before-sign as a type + a runtime gate: evaluate the proposal, and invoke the signing key ONLY when
3
+ * the decision is Proceed. On Decline/Escalate the signer is never reached (LCP §5.3), by construction. */
4
+ export async function transact(proposal, policy, ports, signer) {
5
+ const decision = await evaluate(proposal, policy, ports);
6
+ if (decision.kind !== "proceed")
7
+ return { kind: "halted", decision };
8
+ const { signature } = await signer.sign(proposal.advertisedAtrHash);
9
+ return { kind: "signed", signature, atrHash: proposal.advertisedAtrHash };
10
+ }
11
+ //# sourceMappingURL=transact.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transact.js","sourceRoot":"","sources":["../src/transact.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAkB,MAAM,eAAe,CAAC;AAoBzD;4GAC4G;AAC5G,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC5B,QAAsB,EACtB,MAAmB,EACnB,KAAgB,EAChB,MAAqB;IAErB,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;IACzD,IAAI,QAAQ,CAAC,IAAI,KAAK,SAAS;QAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC;IACrE,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,iBAAiB,CAAC,CAAC;IACpE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,CAAC,iBAAiB,EAAE,CAAC;AAC5E,CAAC"}
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@integraledger/agent-guard",
3
+ "version": "0.9.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "default": "./dist/index.js"
9
+ }
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "src",
14
+ "CHANGELOG.md",
15
+ "LICENSE",
16
+ "NOTICE"
17
+ ],
18
+ "publishConfig": {
19
+ "registry": "https://registry.npmjs.org",
20
+ "access": "public"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/IntegraLedger/integra-agent-guard.git",
25
+ "directory": "packages/agent-guard"
26
+ },
27
+ "dependencies": {
28
+ "zod": "4.4.3"
29
+ },
30
+ "peerDependencies": {
31
+ "@integraledger/lcp-authority": "^0.10.1",
32
+ "@integraledger/lcp-binding-core": "^0.10.1",
33
+ "@integraledger/lcp-discovery": "^0.10.1",
34
+ "@integraledger/lcp-evidence": "^0.10.1",
35
+ "@integraledger/lcp-kernel": "^0.10.1",
36
+ "@integraledger/lcp-placement-ack": "^0.10.1",
37
+ "@integraledger/lcp-placement-ap2": "^0.10.1",
38
+ "@integraledger/lcp-placements": "^0.10.1",
39
+ "@integraledger/lcp-verify": "^0.10.1"
40
+ },
41
+ "devDependencies": {
42
+ "@integraledger/lcp-authority": "0.10.1",
43
+ "@integraledger/lcp-binding-core": "0.10.1",
44
+ "@integraledger/lcp-discovery": "0.10.1",
45
+ "@integraledger/lcp-evidence": "0.10.1",
46
+ "@integraledger/lcp-kernel": "0.10.1",
47
+ "@integraledger/lcp-placement-ack": "0.10.1",
48
+ "@integraledger/lcp-placement-acp": "0.10.1",
49
+ "@integraledger/lcp-placement-ap2": "0.10.1",
50
+ "@integraledger/lcp-placements": "0.10.1",
51
+ "@integraledger/lcp-verify": "0.10.1",
52
+ "@types/node": "24.13.3",
53
+ "vitest": "4.1.10"
54
+ },
55
+ "license": "Apache-2.0",
56
+ "description": "Integra Agent Guard — the buyer-side verify-before-sign guard for agentic purchases. Fetches the terms a seller advertised, recomputes the fingerprint, and halts before any signing key is invoked if they disagree. Works against any seller.",
57
+ "keywords": [
58
+ "lcp",
59
+ "legal-context-protocol",
60
+ "agentic-commerce",
61
+ "ai-agent",
62
+ "x402",
63
+ "ap2",
64
+ "acp",
65
+ "verify-before-pay",
66
+ "buyer-protection",
67
+ "agent-guard"
68
+ ],
69
+ "engines": {
70
+ "node": ">=24"
71
+ },
72
+ "bugs": {
73
+ "url": "https://github.com/IntegraLedger/integra-agent-guard/issues"
74
+ },
75
+ "homepage": "https://github.com/IntegraLedger/integra-agent-guard/tree/main/packages/agent-guard#readme",
76
+ "scripts": {
77
+ "build": "tsc -p tsconfig.build.json",
78
+ "typecheck": "tsc -p tsconfig.json --noEmit",
79
+ "test": "vitest run"
80
+ }
81
+ }
@@ -0,0 +1,54 @@
1
+ import type { HaltClass } from "@integraledger/lcp-binding-core";
2
+ export type StepStatus =
3
+ | "proved"
4
+ | "failed"
5
+ | "indeterminate"
6
+ | "not-attempted";
7
+ export type Disposition = "proceed" | "decline" | "escalate";
8
+ export type GateDecision =
9
+ | { readonly kind: "proceed" }
10
+ | {
11
+ readonly kind: "decline";
12
+ readonly haltClass: HaltClass;
13
+ readonly code: string;
14
+ readonly detail: string;
15
+ }
16
+ | {
17
+ readonly kind: "escalate";
18
+ readonly bytes: Uint8Array;
19
+ readonly hash: `0x${string}`;
20
+ readonly reason: string;
21
+ };
22
+
23
+ /** The TOTAL map from a step's four-valued status onto a disposition. `failed` is ALWAYS decline (a policy
24
+ * can never proceed past a failure). `indeterminate`/`not-attempted` are the buyer's STATED dispositions
25
+ * (never silent defaults). The switch is exhaustive — the `never` default fails the build if StepStatus grows. */
26
+ export function decideOutcome(
27
+ status: StepStatus,
28
+ gaps: {
29
+ readonly onIndeterminate: Disposition;
30
+ readonly onNotAttempted: Disposition;
31
+ },
32
+ ): Disposition {
33
+ switch (status) {
34
+ case "proved":
35
+ return "proceed";
36
+ case "failed":
37
+ return "decline";
38
+ case "indeterminate":
39
+ return gaps.onIndeterminate;
40
+ case "not-attempted":
41
+ return gaps.onNotAttempted;
42
+ default: {
43
+ const _exhaustive: never = status;
44
+ return _exhaustive;
45
+ }
46
+ }
47
+ }
48
+
49
+ /** Fold per-step dispositions into one: any decline ⇒ Decline-dominant; else any escalate ⇒ Escalate; else proceed. */
50
+ export function foldDispositions(ds: readonly Disposition[]): Disposition {
51
+ if (ds.includes("decline")) return "decline";
52
+ if (ds.includes("escalate")) return "escalate";
53
+ return "proceed";
54
+ }
@@ -0,0 +1,176 @@
1
+ import type { HaltClass } from "@integraledger/lcp-binding-core";
2
+ import {
3
+ type LegalContextJson,
4
+ parseLegalContextJson,
5
+ } from "@integraledger/lcp-discovery";
6
+ import { hashAtr } from "@integraledger/lcp-kernel";
7
+ import type { GateDecision } from "./decision.js";
8
+ import type { TermsFetcher } from "./fetch.js";
9
+ import { recomputeAndCompare } from "./fingerprint.js";
10
+ import type { Orc4Log } from "./log.js";
11
+ import { type BuyerPolicy, evaluatePolicy } from "./policy.js";
12
+ import type { GateProposal } from "./proposal.js";
13
+
14
+ export interface GatePorts {
15
+ readonly fetcher: TermsFetcher;
16
+ readonly now: () => string;
17
+ readonly log?: Orc4Log;
18
+ }
19
+
20
+ /** The gate orchestration: fetch+retain (LCP §5.4) → level floor (LCP §4.2) → fingerprint HALT (LCP §5.3) → policy on the
21
+ * TYPED envelope only (LCP §12.7) → coverage-gap disposition → assurance → Proceed. The signing key is NEVER
22
+ * touched here — this returns a decision that `transact` enforces against the guarded signer. */
23
+ export async function evaluate(
24
+ proposal: GateProposal,
25
+ policy: BuyerPolicy,
26
+ ports: GatePorts,
27
+ ): Promise<GateDecision> {
28
+ const at = ports.now();
29
+ const decline = (
30
+ haltClass: HaltClass,
31
+ code: string,
32
+ detail: string,
33
+ ): GateDecision => {
34
+ ports.log?.append({
35
+ timestamp: at,
36
+ decision: "decline",
37
+ haltClass,
38
+ code,
39
+ detail,
40
+ atrHash: proposal.advertisedAtrHash,
41
+ legalContextUrl: proposal.legalContextUrl,
42
+ });
43
+ return { kind: "decline", haltClass, code, detail };
44
+ };
45
+
46
+ // LCP §5.4: ALWAYS fetch + retain the terms as evidence, even at Level 1 (a policy decision, never a silent skip).
47
+ //
48
+ // The fetch is CAUGHT because a counterparty must not be able to make this function THROW. Every failure
49
+ // the shipped fetcher raises is counterparty-reachable — a 404 or a TLS error on a URL the seller chose, a
50
+ // body over the cap on a document the seller serves, a host the seller published that resolves to a private
51
+ // address and trips the SSRF guard. Each has to arrive as a value the buyer can act on: the ORC-4 log
52
+ // records the most common counterparty failure there is, the caller gets the `haltClass`/`code` pair every
53
+ // other refusal here is a value of, and the Decline taxonomy — the seam a seller's integration is measured
54
+ // against — names "your terms document did not serve".
55
+ //
56
+ // A throw would fail closed, since the signer is unreachable on any path that does not reach a Proceed. But
57
+ // failing closed is not the same as failing WELL, and this package's own SECURITY.md names that difference
58
+ // as a defect rather than as acceptable behaviour.
59
+ //
60
+ // `verification-failure`, not `policy-rejection`: no policy of the buyer's rejected anything. The terms
61
+ // could not be obtained, so the fingerprint could not be recomputed, so the guard cannot say the document
62
+ // is the one that was advertised — which is the same thing a mismatch says, arrived at one step earlier.
63
+ let fetched: Awaited<ReturnType<typeof ports.fetcher.fetch>>;
64
+ try {
65
+ fetched = await ports.fetcher.fetch(proposal.legalContextUrl);
66
+ } catch (cause) {
67
+ // The fetcher's own message names the cause precisely — the status, the cap, the resolved address that
68
+ // failed the unicast check — and it is the seller's defect, so it is carried through verbatim rather
69
+ // than flattened into "fetch failed". A buyer that cannot see WHICH failure it was cannot report it.
70
+ return decline(
71
+ "verification-failure",
72
+ "gate/terms-unfetchable",
73
+ `could not fetch the advertised terms at ${proposal.legalContextUrl}: ${cause instanceof Error ? cause.message : String(cause)}`,
74
+ );
75
+ }
76
+
77
+ // LCP §4.2: the buyer's stated trust-level floor. Level 1 is a policy decision (requiredLevel), never a default.
78
+ if (proposal.level < policy.requiredLevel)
79
+ return decline(
80
+ "policy-rejection",
81
+ "gate/below-required-level",
82
+ `service level ${proposal.level} is below the buyer's required level ${policy.requiredLevel}`,
83
+ );
84
+
85
+ // Verify before sign (LCP §5.3): recompute the fingerprint over the fetched bytes vs the advertised atrHash — a mismatch HALTS before
86
+ // any signing key. Every GateProposal carries a validated advertisedAtrHash, so verification is ALWAYS run
87
+ // (verify-whenever-present; skipping a present, verifiable hash would be a silent fail-open).
88
+ const fp = await recomputeAndCompare(
89
+ fetched.bytes,
90
+ proposal.advertisedAtrHash,
91
+ );
92
+ if (fp.status === "failed")
93
+ return decline(
94
+ "verification-failure",
95
+ "gate/fingerprint-mismatch",
96
+ `recomputed fingerprint ${fp.recomputed} ≠ advertised ${fp.advertised} — HALTING before sign (LCP §5.3)`,
97
+ );
98
+
99
+ // The seller-assurance floor (IDN-3) is a counterparty-trust dimension INDEPENDENT of the terms FORMAT —
100
+ // it gates EVERY proceed path, so it is checked here, before the typed/coverage-gap branch. A buyer that
101
+ // tolerates non-machine-readable terms (onNotAttempted) must NOT thereby lose their assurance floor.
102
+ if (
103
+ policy.requiredAssurance !== "any" &&
104
+ proposal.sellerAssurance !== policy.requiredAssurance
105
+ )
106
+ return decline(
107
+ "policy-rejection",
108
+ "gate/assurance",
109
+ `seller assurance ${proposal.sellerAssurance} below required ${policy.requiredAssurance}`,
110
+ );
111
+
112
+ // Policy on the TYPED envelope only (never the prose bytes) — LCP §12.7. parseLegalContextJson takes a PARSED
113
+ // object and THROWS on a non-conformant record, so a non-machine-readable body is a CAUGHT coverage gap the
114
+ // buyer's STATED onNotAttempted disposition decides — never a silent proceed.
115
+ let typed: LegalContextJson | undefined;
116
+ try {
117
+ typed = parseLegalContextJson(
118
+ JSON.parse(new TextDecoder().decode(fetched.bytes)),
119
+ );
120
+ } catch {
121
+ typed = undefined;
122
+ }
123
+ if (typed === undefined) {
124
+ const disp = policy.onNotAttempted; // non-machine-readable record → coverage gap; buyer's STATED disposition
125
+ if (disp === "decline")
126
+ return decline(
127
+ "policy-rejection",
128
+ "gate/unparseable-terms",
129
+ "terms are not machine-readable legal-context JSON",
130
+ );
131
+ if (disp === "escalate")
132
+ return escalate(
133
+ fetched.bytes,
134
+ at,
135
+ "terms not machine-readable — escalating per policy",
136
+ ports,
137
+ proposal,
138
+ );
139
+ // disp === "proceed": a STATED election to proceed without a typed record (the policy's call, not a default).
140
+ } else {
141
+ const pr = evaluatePolicy(policy, typed, proposal.offer);
142
+ // A hard ATA-2 violation is a Decline (policy-rejection); the escalate lane is for coverage gaps, not forbidden terms.
143
+ if (!pr.ok) return decline("policy-rejection", pr.code, pr.detail);
144
+ }
145
+
146
+ ports.log?.append({
147
+ timestamp: at,
148
+ decision: "proceed",
149
+ code: "gate/proceed",
150
+ detail: "verified + in policy",
151
+ atrHash: proposal.advertisedAtrHash,
152
+ legalContextUrl: proposal.legalContextUrl,
153
+ });
154
+ return { kind: "proceed" };
155
+ }
156
+
157
+ /** Escalate binds a display-equals-signed hash: `hash === sha256(bytes)` and `bytes` are the EXACT displayed
158
+ * terms an approver signs (LCP §12.7 MUST). */
159
+ async function escalate(
160
+ bytes: Uint8Array,
161
+ at: string,
162
+ reason: string,
163
+ ports: GatePorts,
164
+ proposal: GateProposal,
165
+ ): Promise<GateDecision> {
166
+ const hash = await hashAtr(bytes);
167
+ ports.log?.append({
168
+ timestamp: at,
169
+ decision: "escalate",
170
+ code: "gate/escalate",
171
+ detail: reason,
172
+ atrHash: proposal.advertisedAtrHash,
173
+ legalContextUrl: proposal.legalContextUrl,
174
+ });
175
+ return { kind: "escalate", bytes, hash, reason };
176
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,169 @@
1
+ import { isUnicastPublic } from "@integraledger/lcp-evidence";
2
+
3
+ export interface FetchedTerms {
4
+ readonly bytes: Uint8Array;
5
+ readonly format: string;
6
+ readonly fetchedAt: string;
7
+ }
8
+ export interface TermsFetcher {
9
+ fetch(url: string): Promise<FetchedTerms>;
10
+ }
11
+
12
+ /** One resolved address for a host. Mirrors `node:dns/promises` `lookup(host, { all: true })`. */
13
+ export interface ResolvedAddress {
14
+ readonly address: string;
15
+ readonly family: number;
16
+ }
17
+ /**
18
+ * The host-resolution port. REQUIRED, and deliberately not defaulted: the buyer fetches a URL the SELLER
19
+ * chose, so this is the guard between an agent's HTTP client and the buyer's own network — a config that
20
+ * can omit it silently omits the guard. Node deployments pass `nodeDnsLookup`; a Workers deployment passes
21
+ * its own (or one that throws, stating it relies on the platform's private-range egress blocking). The
22
+ * port MUST return literal-IP hosts unchanged, as `node:dns` does.
23
+ */
24
+ export type HostLookup = (host: string) => Promise<readonly ResolvedAddress[]>;
25
+
26
+ export interface CachingFetcherConfig {
27
+ readonly httpFetch: typeof fetch;
28
+ readonly now: () => string;
29
+ /** Resolves a hostname to the addresses the unicast guard checks. See `HostLookup` — no default. */
30
+ readonly lookup: HostLookup;
31
+ /** LCP §2.6: clients SHOULD NOT cache beyond this; default 24h. Re-fetch after it is a MUST (enforced here). */
32
+ readonly maxAgeSeconds?: number;
33
+ /** Fail-loud byte ceiling (DoS guard); default 1 MiB. */
34
+ readonly maxBytes?: number;
35
+ /** Max distinct URLs retained (bounded memory / anti-DoS); default 256. Oldest inserted entry evicted past it. */
36
+ readonly maxEntries?: number;
37
+ }
38
+ const DAY = 86_400;
39
+
40
+ /**
41
+ * `node:dns/promises` as a `HostLookup`. Imported lazily so the module graph stays runtime-neutral (the
42
+ * gate is client-side and may run where `node:dns` does not exist) — a Workers build that never calls this
43
+ * never pulls it in.
44
+ */
45
+ export const nodeDnsLookup: HostLookup = async (host: string) => {
46
+ const dns = await import("node:dns/promises");
47
+ return dns.lookup(host, { all: true });
48
+ };
49
+
50
+ /**
51
+ * HTTPS-only, cached (LCP §2.6), size-capped, SSRF-guarded terms fetcher. The MUST (re-fetch after expiry) is
52
+ * unconditional; the SHOULD (≤24h) is the default cap.
53
+ *
54
+ * SSRF posture — this is the BUYER's exposure, because the URL comes off the seller's 402 challenge:
55
+ * - HTTPS scheme required;
56
+ * - `redirect: "error"`, so a redirect to a non-HTTPS or internal host fails loudly rather than being
57
+ * followed silently;
58
+ * - EVERY resolved address for the host must be public unicast (`evidence.isUnicastPublic` — the one
59
+ * implementation of that predicate; this package does not re-derive it). The check runs on every
60
+ * network fetch, not once per URL, so a cache expiry re-opens it rather than grandfathering a host.
61
+ * - the byte cap is enforced while STREAMING, plus a `Content-Length` pre-check. Reading the whole body
62
+ * and measuring afterwards protects the check but not the memory, and a chunked response with no
63
+ * declared length is exactly the case the cap exists for.
64
+ *
65
+ * HONEST LIMITATION — DNS rebinding, carried verbatim from `evidence`'s resolver: the guard validates what
66
+ * `lookup` returns, but `httpFetch` performs its own resolution and nothing pins the connection to the
67
+ * validated address. This is real defense-in-depth (the naive name → private IP case is blocked outright),
68
+ * NOT a complete guarantee; closing it needs connection-level IP pinning inside the injected `httpFetch`.
69
+ */
70
+ export function makeCachingFetcher(cfg: CachingFetcherConfig): TermsFetcher {
71
+ const maxAge = cfg.maxAgeSeconds ?? DAY;
72
+ const maxBytes = cfg.maxBytes ?? 1024 * 1024;
73
+ const maxEntries = cfg.maxEntries ?? 256;
74
+ const cache = new Map<string, { at: number; terms: FetchedTerms }>();
75
+ const secs = (iso: string): number => Math.floor(Date.parse(iso) / 1000);
76
+
77
+ async function assertPublicHost(url: URL): Promise<void> {
78
+ // BRACKETS ARE URL SYNTAX, NOT ADDRESS SYNTAX, and stripping them here is what lets an IPv6 literal
79
+ // reach the guard at all. `new URL("https://[::1]/x").hostname` is `"[::1]"`, and `node:dns` answers
80
+ // that with ENOTFOUND — so the request was refused for the right outcome and the WRONG REASON: the
81
+ // buyer's record named a DNS failure where the truth was an SSRF refusal, and that message is what a
82
+ // buyer would report to a seller.
83
+ //
84
+ // It also refused hosts it should allow. `isUnicastPublic` parses the bracketed form correctly, but the
85
+ // lookup never returned an address for it, so a seller publishing a legitimate PUBLIC IPv6 literal was
86
+ // refused with the same ENOTFOUND as a loopback one.
87
+ //
88
+ // Unbracketed, `node:dns` returns a literal unchanged — `::1` comes back as `{address:"::1",family:6}`,
89
+ // exactly as `127.0.0.1` does — which is the behaviour the `HostLookup` contract already requires of
90
+ // every port. Normalising here rather than inside `nodeDnsLookup` means a Workers or Deno port inherits
91
+ // the fix instead of having to repeat it.
92
+ // ONE regex rather than `startsWith("[") && endsWith("]")`, because a matching pair is ONE fact and the
93
+ // conjunction spelt it as two. `new URL()` cannot produce a hostname with only one bracket, so neither
94
+ // conjunct was individually observable — mutating either left behaviour identical, which is a survivor
95
+ // no test can honestly kill. Expressing the pair as a single match makes the check say what it means and
96
+ // leaves nothing unobservable behind it.
97
+ const host = /^\[(.+)\]$/.exec(url.hostname)?.[1] ?? url.hostname;
98
+ const addrs = await cfg.lookup(host);
99
+ if (addrs.length === 0)
100
+ throw new Error(`no address for ${url.hostname} — refused (SSRF guard)`);
101
+ for (const { address } of addrs)
102
+ if (!isUnicastPublic(address))
103
+ throw new Error(
104
+ `${url.hostname} resolves to a non-public address (${address}) — refused (SSRF guard)`,
105
+ );
106
+ }
107
+
108
+ async function readCapped(res: Response): Promise<Uint8Array> {
109
+ const body = res.body;
110
+ if (body === null) return new Uint8Array(0);
111
+ const reader = body.getReader();
112
+ const chunks: Uint8Array[] = [];
113
+ let total = 0;
114
+ for (;;) {
115
+ const { done, value } = await reader.read();
116
+ if (done) break;
117
+ total += value.length;
118
+ if (total > maxBytes) {
119
+ await reader.cancel();
120
+ throw new Error(`terms exceed ${maxBytes} bytes (fail-loud)`);
121
+ }
122
+ chunks.push(value);
123
+ }
124
+ const out = new Uint8Array(total);
125
+ let o = 0;
126
+ for (const c of chunks) {
127
+ out.set(c, o);
128
+ o += c.length;
129
+ }
130
+ return out;
131
+ }
132
+
133
+ return {
134
+ async fetch(url: string): Promise<FetchedTerms> {
135
+ if (!url.startsWith("https://"))
136
+ throw new Error(`terms url must be HTTPS (SSRF posture): ${url}`);
137
+ const nowS = secs(cfg.now());
138
+ const hit = cache.get(url);
139
+ if (hit !== undefined && nowS - hit.at < maxAge) return hit.terms; // SHOULD: serve fresh from cache
140
+
141
+ // Re-validated on every network fetch — an expiring cache entry must not grandfather a host.
142
+ await assertPublicHost(new URL(url));
143
+
144
+ const res = await cfg.httpFetch(url, { redirect: "error" }); // MUST: re-fetch after expiry; never follow redirects
145
+ if (!res.ok) throw new Error(`terms fetch failed: ${res.status} ${url}`);
146
+ const declared = res.headers.get("content-length");
147
+ if (
148
+ declared !== null &&
149
+ Number.isFinite(Number(declared)) &&
150
+ Number(declared) > maxBytes
151
+ )
152
+ throw new Error(
153
+ `terms declare ${declared} bytes > ${maxBytes} (fail-loud): ${url}`,
154
+ );
155
+ const buf = await readCapped(res);
156
+ const terms: FetchedTerms = {
157
+ bytes: buf,
158
+ format: res.headers.get("content-type") ?? "application/octet-stream",
159
+ fetchedAt: cfg.now(),
160
+ };
161
+ cache.set(url, { at: nowS, terms });
162
+ if (cache.size > maxEntries) {
163
+ const oldest = cache.keys().next().value;
164
+ if (oldest !== undefined) cache.delete(oldest);
165
+ }
166
+ return terms;
167
+ },
168
+ };
169
+ }
@@ -0,0 +1,27 @@
1
+ import type { HaltClass } from "@integraledger/lcp-binding-core";
2
+ import { hashAtr } from "@integraledger/lcp-kernel";
3
+ export type FingerprintOutcome =
4
+ | { readonly status: "proved"; readonly atrHash: `0x${string}` }
5
+ | {
6
+ readonly status: "failed";
7
+ readonly haltClass: HaltClass;
8
+ readonly recomputed: `0x${string}`;
9
+ readonly advertised: string;
10
+ };
11
+
12
+ /** The LCP §5.3 core: recompute SHA-256 over the fetched terms bytes and compare to the advertised atrHash. A
13
+ * mismatch is `failed(verification-failure)` — the caller MUST halt before invoking any signing key. */
14
+ export async function recomputeAndCompare(
15
+ bytes: Uint8Array,
16
+ advertisedAtrHash: string,
17
+ ): Promise<FingerprintOutcome> {
18
+ const recomputed = await hashAtr(bytes);
19
+ return recomputed.toLowerCase() === advertisedAtrHash.toLowerCase()
20
+ ? { status: "proved", atrHash: recomputed }
21
+ : {
22
+ status: "failed",
23
+ haltClass: "verification-failure",
24
+ recomputed,
25
+ advertised: advertisedAtrHash,
26
+ };
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ export {
2
+ type Disposition,
3
+ decideOutcome,
4
+ foldDispositions,
5
+ type GateDecision,
6
+ type StepStatus,
7
+ } from "./decision.js";
8
+ export { evaluate, type GatePorts } from "./evaluate.js";
9
+ export {
10
+ type CachingFetcherConfig,
11
+ type FetchedTerms,
12
+ type HostLookup,
13
+ makeCachingFetcher,
14
+ nodeDnsLookup,
15
+ type ResolvedAddress,
16
+ type TermsFetcher,
17
+ } from "./fetch.js";
18
+ export { type FingerprintOutcome, recomputeAndCompare } from "./fingerprint.js";
19
+ export { InMemoryOrc4Log, type Orc4Entry, type Orc4Log } from "./log.js";
20
+ export { type MechanicalPorts, verifySettled } from "./mechanical.js";
21
+ export {
22
+ type BuyerPolicy,
23
+ evaluatePolicy,
24
+ type PolicyResult,
25
+ } from "./policy.js";
26
+ export {
27
+ type GateProposal,
28
+ type ProposalContext,
29
+ parseProposalFromChallenge,
30
+ } from "./proposal.js";
31
+ export {
32
+ ACK_DID_METHODS,
33
+ type AckReceiptContribution,
34
+ type AckReceiptFacts,
35
+ ackReceiptContribution,
36
+ parseAckReceipt,
37
+ } from "./proposal-ack.js";
38
+ export { parseProposalFromAcpCheckout } from "./proposal-acp.js";
39
+ export {
40
+ AP2_HALT_POINT,
41
+ type Ap2HaltPoint,
42
+ type Ap2ProposalContext,
43
+ type Ap2Step,
44
+ assertBeforeAp2HaltPoint,
45
+ isAp2SigningStep,
46
+ parseProposalFromAp2Envelope,
47
+ } from "./proposal-ap2.js";
48
+ export { parseProposalFromMppRequest } from "./proposal-mpp.js";
49
+ export {
50
+ ACP_SESSION_STATUS,
51
+ type AdvertisedTerms,
52
+ type AdvertisedTermsUrl,
53
+ detectProtocol,
54
+ matchProtocols,
55
+ PROPOSAL_PARSERS,
56
+ PROTOCOL_DISCRIMINANTS,
57
+ type ProposalParser,
58
+ type ProtocolDiscriminant,
59
+ parseableProtocols,
60
+ parseProposalUniversal,
61
+ readAdvertisedTerms,
62
+ VI_OPEN_MANDATE_VCT,
63
+ } from "./proposal-universal.js";
64
+ export {
65
+ type GuardedSigner,
66
+ type TransactResult,
67
+ transact,
68
+ } from "./transact.js";
package/src/log.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { HaltClass } from "@integraledger/lcp-binding-core";
2
+
3
+ export interface Orc4Entry {
4
+ readonly timestamp: string;
5
+ readonly decision: "proceed" | "decline" | "escalate";
6
+ readonly haltClass?: HaltClass;
7
+ readonly code: string;
8
+ readonly detail: string;
9
+ readonly atrHash?: string;
10
+ /**
11
+ * The discovery document the proposal pointed at — the terms this decision was reached about.
12
+ *
13
+ * WITHOUT IT AN ENTRY NAMES A VERDICT AND NOT ITS SUBJECT. `atrHash` alone does not identify the
14
+ * document: on the decision that matters most — a fingerprint mismatch — the advertised hash is by
15
+ * construction NOT the hash of what was served, so a reader holding only that value cannot say which
16
+ * document disagreed with it. A seller told "your terms did not match" and given a hash it cannot
17
+ * reproduce learns nothing it can act on; given the URL, it can fetch the bytes and see.
18
+ *
19
+ * Optional for the same reason `atrHash` is: this is the sink's input type, and a producer other than
20
+ * `evaluate` — a mechanical verification, say — may legitimately have no document to name. `evaluate`
21
+ * always sets it.
22
+ */
23
+ readonly legalContextUrl?: string;
24
+ }
25
+ export interface Orc4Log {
26
+ append(e: Orc4Entry): void;
27
+ }
28
+ /** A real, complete in-memory ORC-4 sink (not a mock) — a deployment injects a durable one. */
29
+ export class InMemoryOrc4Log implements Orc4Log {
30
+ readonly entries: Orc4Entry[] = [];
31
+ append(e: Orc4Entry): void {
32
+ this.entries.push(e);
33
+ }
34
+ }