@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
|
@@ -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"}
|
package/dist/transact.js
ADDED
|
@@ -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
|
+
}
|
package/src/decision.ts
ADDED
|
@@ -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
|
+
}
|
package/src/evaluate.ts
ADDED
|
@@ -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
|
+
}
|