@suveren/gateway 0.2.10 → 0.2.11
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/dist/control-plane/index.mjs +8 -0
- package/dist/mcp-server/http.mjs +81 -31
- package/node_modules/@hap/core/dist/index.d.mts +83 -1
- package/node_modules/@hap/core/dist/index.d.ts +83 -1
- package/node_modules/@hap/core/dist/index.js +39 -6
- package/node_modules/@hap/core/dist/index.mjs +36 -6
- package/node_modules/@hap/core/package.json +1 -1
- package/node_modules/@hap/core/src/content-binding.ts +83 -0
- package/node_modules/@hap/core/src/index.ts +1 -0
- package/node_modules/@hap/core/src/types.ts +35 -0
- package/package.json +1 -1
- package/profiles/customers/0.4.profile.json +2 -1
- package/profiles/records/0.4.profile.json +2 -1
|
@@ -270,6 +270,12 @@ async function resyncGates() {
|
|
|
270
270
|
}
|
|
271
271
|
return res.json();
|
|
272
272
|
}
|
|
273
|
+
async function runCommittedProposals() {
|
|
274
|
+
await fetch(`${MCP_BASE}/internal/run-committed`, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: internalHeaders()
|
|
277
|
+
});
|
|
278
|
+
}
|
|
273
279
|
async function getIntegrations() {
|
|
274
280
|
const res = await fetch(`${MCP_BASE}/internal/integrations`, {
|
|
275
281
|
headers: internalHeaders()
|
|
@@ -1935,6 +1941,8 @@ app.use(
|
|
|
1935
1941
|
eventBus.emit("proposal-added");
|
|
1936
1942
|
} else if (method === "POST" && /^\/api\/proposals\/[^/]+\/resolve$/.test(path)) {
|
|
1937
1943
|
eventBus.emit("proposal-resolved");
|
|
1944
|
+
void runCommittedProposals().catch(() => {
|
|
1945
|
+
});
|
|
1938
1946
|
} else if (method === "POST" && /^\/api\/proposals\/[^/]+\/approve$/.test(path)) {
|
|
1939
1947
|
eventBus.emit("proposal-approved");
|
|
1940
1948
|
} else if (method === "POST" && /^\/api\/proposals\/[^/]+\/reject$/.test(path)) {
|
package/dist/mcp-server/http.mjs
CHANGED
|
@@ -902,12 +902,12 @@ function buildMandateBrief(opts) {
|
|
|
902
902
|
}
|
|
903
903
|
|
|
904
904
|
// src/tools/authorizations.ts
|
|
905
|
-
import { getProfile as
|
|
905
|
+
import { getProfile as getProfile3 } from "@hap/core";
|
|
906
906
|
|
|
907
907
|
// src/lib/receipt-footer.ts
|
|
908
908
|
var AS_BASE = (process.env.SUVEREN_AS_URL ?? "https://www.suveren.ai").replace(/\/$/, "");
|
|
909
909
|
var FOOTER_PROFILES = /* @__PURE__ */ new Set(["email", "calendar", "publish"]);
|
|
910
|
-
var CONTENT_FIELD_CANDIDATES = ["body", "text", "description"];
|
|
910
|
+
var CONTENT_FIELD_CANDIDATES = ["body", "text", "description", "content"];
|
|
911
911
|
var FOOTER_MARKER = "\u2014 Sent by an AI agent via Suveren";
|
|
912
912
|
function shouldAttachFooter() {
|
|
913
913
|
return true;
|
|
@@ -940,18 +940,51 @@ function stripFooter(value) {
|
|
|
940
940
|
function appendVerificationFooter(tool, args, receiptId) {
|
|
941
941
|
const profile = tool.gating?.profile;
|
|
942
942
|
if (!profile || !FOOTER_PROFILES.has(profile)) return args;
|
|
943
|
-
if (typeof args.raw === "string" && args.raw.trim().length > 0) {
|
|
944
|
-
console.error(
|
|
945
|
-
`[Suveren MCP] ${tool.namespacedName}: 'raw' message present \u2014 skipping verification footer.`
|
|
946
|
-
);
|
|
947
|
-
return args;
|
|
948
|
-
}
|
|
949
943
|
const field = detectContentField(tool);
|
|
950
944
|
if (!field) return args;
|
|
951
945
|
const current = typeof args[field] === "string" ? args[field] : "";
|
|
952
946
|
return { ...args, [field]: stripFooter(current) + footerText(receiptId) };
|
|
953
947
|
}
|
|
954
948
|
|
|
949
|
+
// src/lib/content-binding.ts
|
|
950
|
+
import { createHash } from "crypto";
|
|
951
|
+
import { canonicalize, getProfile as getProfile2 } from "@hap/core";
|
|
952
|
+
function canonicalizeText(input) {
|
|
953
|
+
const nfc = input.normalize("NFC");
|
|
954
|
+
const lf = nfc.replace(/\r\n?/g, "\n");
|
|
955
|
+
const lines = lf.split("\n").map((line) => line.replace(/[ \t]+$/, ""));
|
|
956
|
+
return lines.join("\n").replace(/\n+$/, "");
|
|
957
|
+
}
|
|
958
|
+
function sha256Hex(bytes) {
|
|
959
|
+
return createHash("sha256").update(bytes, "utf8").digest("hex");
|
|
960
|
+
}
|
|
961
|
+
function getContentBinding(profileId) {
|
|
962
|
+
const profile = getProfile2(profileId);
|
|
963
|
+
return profile?.content_binding;
|
|
964
|
+
}
|
|
965
|
+
function computeContentBinding(profileId, tool, toolArgs) {
|
|
966
|
+
const binding = getContentBinding(profileId);
|
|
967
|
+
if (!binding) return void 0;
|
|
968
|
+
let canonicalBytes;
|
|
969
|
+
if (binding.kind === "jcs") {
|
|
970
|
+
canonicalBytes = canonicalize(toolArgs);
|
|
971
|
+
} else {
|
|
972
|
+
const field = tool ? detectContentField(tool) : null;
|
|
973
|
+
if (!field) return void 0;
|
|
974
|
+
const raw = typeof toolArgs[field] === "string" ? toolArgs[field] : "";
|
|
975
|
+
canonicalBytes = canonicalizeText(raw);
|
|
976
|
+
}
|
|
977
|
+
return {
|
|
978
|
+
contentHash: `sha256:${sha256Hex(canonicalBytes)}`,
|
|
979
|
+
contentBinding: { version: binding.version, kind: binding.kind }
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
function attachReceiptId(tool, args, receiptId) {
|
|
983
|
+
const schema = tool.inputSchema;
|
|
984
|
+
if (!schema?.properties || !("receipt_id" in schema.properties)) return args;
|
|
985
|
+
return { ...args, receipt_id: receiptId };
|
|
986
|
+
}
|
|
987
|
+
|
|
955
988
|
// src/lib/tool-proxy.ts
|
|
956
989
|
import { readFile } from "fs/promises";
|
|
957
990
|
import { extname } from "path";
|
|
@@ -1120,6 +1153,7 @@ Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposa
|
|
|
1120
1153
|
`[Suveren MCP] Warning: tool ${tool.namespacedName} has no action_type in staticExecution. Bounds check may be skipped. Fix the integration manifest.`
|
|
1121
1154
|
);
|
|
1122
1155
|
}
|
|
1156
|
+
const binding = computeContentBinding(auth.profileId, tool, args);
|
|
1123
1157
|
const { receipt } = await state2.spClient.postReceipt({
|
|
1124
1158
|
// v0.5: send the bare content address; the AS reconstructs the
|
|
1125
1159
|
// per-user storage key. Fall back to frameHash only for legacy
|
|
@@ -1130,7 +1164,8 @@ Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposa
|
|
|
1130
1164
|
actionType,
|
|
1131
1165
|
executionContext: { ...execution },
|
|
1132
1166
|
amount: typeof execution.amount === "number" ? execution.amount : void 0,
|
|
1133
|
-
idempotencyKey: randomUUID()
|
|
1167
|
+
idempotencyKey: randomUUID(),
|
|
1168
|
+
...binding ?? {}
|
|
1134
1169
|
});
|
|
1135
1170
|
receiptId = typeof receipt?.id === "string" ? receipt.id : void 0;
|
|
1136
1171
|
} catch (err) {
|
|
@@ -1210,7 +1245,8 @@ Proposal ID: ${proposal.id}. Use check-pending-commitments to track status.`
|
|
|
1210
1245
|
execution: { ...execution },
|
|
1211
1246
|
timestamp: Math.floor(Date.now() / 1e3)
|
|
1212
1247
|
});
|
|
1213
|
-
|
|
1248
|
+
let outgoingArgs = shouldAttachFooter() && receiptId ? appendVerificationFooter(tool, args, receiptId) : args;
|
|
1249
|
+
if (receiptId) outgoingArgs = attachReceiptId(tool, outgoingArgs, receiptId);
|
|
1214
1250
|
return integrationManager2.callTool(tool.integrationId, tool.originalName, outgoingArgs);
|
|
1215
1251
|
}
|
|
1216
1252
|
const reasons = result.errors.map((e) => {
|
|
@@ -1287,7 +1323,9 @@ function buildCapabilityMap(profileId, toolGating, integrationManager2) {
|
|
|
1287
1323
|
} else if (override !== void 0) {
|
|
1288
1324
|
const mappingDesc = Object.entries(override.executionMapping ?? {}).map(([arg, mapping]) => {
|
|
1289
1325
|
if (typeof mapping === "string") return `${mapping} from ${arg}`;
|
|
1290
|
-
return `${mapping.field} from ${arg}
|
|
1326
|
+
if (Array.isArray(mapping)) return `${mapping.map((m) => m.field).join("+")} from ${arg}`;
|
|
1327
|
+
if ("divisor" in mapping) return `${mapping.field} from ${arg} (/${mapping.divisor})`;
|
|
1328
|
+
return `${mapping.field} from ${arg}`;
|
|
1291
1329
|
}).join(", ");
|
|
1292
1330
|
const actionType = override.staticExecution?.action_type ?? "unknown";
|
|
1293
1331
|
gated.push(` - ${tool.originalName}: ${actionType}${mappingDesc ? `, ${mappingDesc}` : ""}`);
|
|
@@ -1353,7 +1391,7 @@ function listAuthorizationsHandler(state2, integrationManager2, contextDir) {
|
|
|
1353
1391
|
output2.push("");
|
|
1354
1392
|
output2.push(` Bounds: ${boundsDesc}`);
|
|
1355
1393
|
const shortName = shortProfileName2(auth.profileId);
|
|
1356
|
-
const profile =
|
|
1394
|
+
const profile = getProfile3(auth.profileId) ?? getProfile3(shortName);
|
|
1357
1395
|
if (profile) {
|
|
1358
1396
|
const consumption = getConsumptionState(auth, state2.executionLog, profile);
|
|
1359
1397
|
const consumptionText = formatConsumptionFull(consumption);
|
|
@@ -1406,7 +1444,7 @@ function listAuthorizationsHandler(state2, integrationManager2, contextDir) {
|
|
|
1406
1444
|
const lines = [` [${auth.path}] ${auth.profileId} \u2014 ${remainingMin} min remaining`];
|
|
1407
1445
|
lines.push(` Bounds: ${boundsDesc}`);
|
|
1408
1446
|
const shortName = shortProfileName2(auth.profileId);
|
|
1409
|
-
const profile =
|
|
1447
|
+
const profile = getProfile3(auth.profileId) ?? getProfile3(shortName);
|
|
1410
1448
|
if (profile) {
|
|
1411
1449
|
const consumption = getConsumptionState(auth, state2.executionLog, profile);
|
|
1412
1450
|
const compact = formatConsumptionCompact(consumption);
|
|
@@ -1547,6 +1585,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
1547
1585
|
}
|
|
1548
1586
|
const integrationId = proposal.tool.slice(0, sep);
|
|
1549
1587
|
const toolName = proposal.tool.slice(sep + 2);
|
|
1588
|
+
const discovered = integrationManager2.getAllTools().find((t) => t.integrationId === integrationId && t.originalName === toolName);
|
|
1550
1589
|
const proposalActionType = typeof proposal.executionContext.action_type === "string" ? proposal.executionContext.action_type : void 0;
|
|
1551
1590
|
if (!proposalActionType) {
|
|
1552
1591
|
console.error(
|
|
@@ -1556,6 +1595,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
1556
1595
|
let receiptId;
|
|
1557
1596
|
try {
|
|
1558
1597
|
const boundsHash = proposal.frameHash.split(":").slice(0, 2).join(":");
|
|
1598
|
+
const binding = computeContentBinding(proposal.profileId, discovered, proposal.toolArgs);
|
|
1559
1599
|
const { receipt } = await state2.spClient.postReceipt({
|
|
1560
1600
|
boundsHash,
|
|
1561
1601
|
profileId: proposal.profileId,
|
|
@@ -1564,7 +1604,8 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
1564
1604
|
executionContext: proposal.executionContext,
|
|
1565
1605
|
amount: typeof proposal.executionContext.amount === "number" ? proposal.executionContext.amount : void 0,
|
|
1566
1606
|
proposalId: proposal.id,
|
|
1567
|
-
toolArgs: proposal.toolArgs
|
|
1607
|
+
toolArgs: proposal.toolArgs,
|
|
1608
|
+
...binding ?? {}
|
|
1568
1609
|
});
|
|
1569
1610
|
receiptId = typeof receipt?.id === "string" ? receipt.id : void 0;
|
|
1570
1611
|
} catch (err) {
|
|
@@ -1587,11 +1628,11 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
1587
1628
|
}
|
|
1588
1629
|
try {
|
|
1589
1630
|
let outgoingArgs = proposal.toolArgs;
|
|
1590
|
-
if (
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
outgoingArgs = appendVerificationFooter(discovered, proposal.toolArgs, receiptId);
|
|
1631
|
+
if (discovered && receiptId) {
|
|
1632
|
+
if (shouldAttachFooter()) {
|
|
1633
|
+
outgoingArgs = appendVerificationFooter(discovered, outgoingArgs, receiptId);
|
|
1594
1634
|
}
|
|
1635
|
+
outgoingArgs = attachReceiptId(discovered, outgoingArgs, receiptId);
|
|
1595
1636
|
}
|
|
1596
1637
|
const result = await integrationManager2.callTool(integrationId, toolName, outgoingArgs);
|
|
1597
1638
|
state2.executionLog.record({
|
|
@@ -1825,10 +1866,10 @@ function createMcpServer(state2, integrationManager2) {
|
|
|
1825
1866
|
}
|
|
1826
1867
|
|
|
1827
1868
|
// src/lib/gate-content.ts
|
|
1828
|
-
import { createHash } from "crypto";
|
|
1869
|
+
import { createHash as createHash2 } from "crypto";
|
|
1829
1870
|
import { decodeAttestationBlob } from "@hap/core";
|
|
1830
1871
|
function hashGateContent(text) {
|
|
1831
|
-
const hex =
|
|
1872
|
+
const hex = createHash2("sha256").update(text, "utf-8").digest("hex");
|
|
1832
1873
|
return `sha256:${hex}`;
|
|
1833
1874
|
}
|
|
1834
1875
|
function verifyGateContentHashes(content, auth) {
|
|
@@ -1902,15 +1943,14 @@ var IntegrationRegistry = class {
|
|
|
1902
1943
|
const migrated = data.integrations.map((i) => {
|
|
1903
1944
|
if ("toolGating" in i && !("profile" in i)) {
|
|
1904
1945
|
const old = i;
|
|
1905
|
-
const toolGating = old.toolGating;
|
|
1906
1946
|
const config = {
|
|
1907
|
-
id:
|
|
1908
|
-
name:
|
|
1909
|
-
command:
|
|
1910
|
-
args:
|
|
1911
|
-
envKeys:
|
|
1912
|
-
profile: toolGating?.profile ?? null,
|
|
1913
|
-
enabled:
|
|
1947
|
+
id: old.id,
|
|
1948
|
+
name: old.name,
|
|
1949
|
+
command: old.command,
|
|
1950
|
+
args: old.args,
|
|
1951
|
+
envKeys: old.envKeys,
|
|
1952
|
+
profile: old.toolGating?.profile ?? null,
|
|
1953
|
+
enabled: old.enabled
|
|
1914
1954
|
};
|
|
1915
1955
|
return config;
|
|
1916
1956
|
}
|
|
@@ -1939,7 +1979,7 @@ import { existsSync as existsSync5, writeFileSync as writeFileSync4, mkdirSync a
|
|
|
1939
1979
|
import { execSync } from "child_process";
|
|
1940
1980
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
1941
1981
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
1942
|
-
import { getProfile as
|
|
1982
|
+
import { getProfile as getProfile4 } from "@hap/core";
|
|
1943
1983
|
var DEFAULT_DATA_DIR2 = process.env.SUVEREN_DATA_DIR ?? join5(homedir5(), ".suveren");
|
|
1944
1984
|
var INTEGRATIONS_DIR = process.env.SUVEREN_INTEGRATIONS_DIR ?? join5(DEFAULT_DATA_DIR2, "integrations");
|
|
1945
1985
|
var INTEGRATIONS_BIN = join5(INTEGRATIONS_DIR, "node_modules", ".bin");
|
|
@@ -2024,7 +2064,7 @@ var IntegrationManager = class {
|
|
|
2024
2064
|
await client.connect(transport);
|
|
2025
2065
|
console.error(`[IntegrationManager] Connected to ${config.id} (${config.command} ${config.args.join(" ")})`);
|
|
2026
2066
|
const toolsResult = await client.listTools();
|
|
2027
|
-
const profileGating = config.toolGating ?? (config.profile ?
|
|
2067
|
+
const profileGating = config.toolGating ?? (config.profile ? getProfile4(config.profile)?.toolGating ?? null : null);
|
|
2028
2068
|
const tools = (toolsResult.tools ?? []).map((tool) => {
|
|
2029
2069
|
const gating = this.resolveToolGating(config.profile, profileGating, tool.name);
|
|
2030
2070
|
return {
|
|
@@ -2421,7 +2461,8 @@ app.post("/internal/gate-content", internalOnly, async (req, res) => {
|
|
|
2421
2461
|
const { frameHash, boundsHash, contextHash, context, path: rawPath, gateContent } = req.body;
|
|
2422
2462
|
const storageHash = frameHash ?? boundsHash;
|
|
2423
2463
|
const hasIntent = !!gateContent?.intent;
|
|
2424
|
-
const
|
|
2464
|
+
const legacy = gateContent;
|
|
2465
|
+
const hasLegacy = !!legacy?.problem && !!legacy?.objective && !!legacy?.tradeoffs;
|
|
2425
2466
|
if (!storageHash || !hasIntent && !hasLegacy) {
|
|
2426
2467
|
res.status(400).json({ error: "Missing required fields: frameHash (or boundsHash), gateContent.{intent} or gateContent.{problem,objective,tradeoffs}" });
|
|
2427
2468
|
return;
|
|
@@ -2477,6 +2518,12 @@ app.post("/internal/start-pending-integrations", internalOnly, async (_req, res)
|
|
|
2477
2518
|
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
2478
2519
|
}
|
|
2479
2520
|
});
|
|
2521
|
+
var triggerCommittedExecution = () => {
|
|
2522
|
+
};
|
|
2523
|
+
app.post("/internal/run-committed", internalOnly, (_req, res) => {
|
|
2524
|
+
triggerCommittedExecution();
|
|
2525
|
+
res.json({ ok: true });
|
|
2526
|
+
});
|
|
2480
2527
|
app.post("/internal/resync-gates", internalOnly, async (_req, res) => {
|
|
2481
2528
|
const gates = state.gateStore.getAll();
|
|
2482
2529
|
if (gates.length === 0) {
|
|
@@ -2790,4 +2837,7 @@ app.listen(port, "0.0.0.0", () => {
|
|
|
2790
2837
|
}
|
|
2791
2838
|
}
|
|
2792
2839
|
setInterval(executeCommittedProposals, PROPOSAL_POLL_INTERVAL);
|
|
2840
|
+
triggerCommittedExecution = () => {
|
|
2841
|
+
void executeCommittedProposals();
|
|
2842
|
+
};
|
|
2793
2843
|
});
|
|
@@ -161,6 +161,32 @@ interface ProfileBoundsField {
|
|
|
161
161
|
/** @deprecated v0.4: use boundType: { kind: 'enum', values: [...] }. */
|
|
162
162
|
enum?: string[];
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* v0.5 Content Provenance — how a profile's action content is hashed into a
|
|
166
|
+
* signed receipt (`contentHash`). The ephemeral-content analog of Output
|
|
167
|
+
* Provenance: it binds the *bytes* of the action rather than a location.
|
|
168
|
+
*
|
|
169
|
+
* Profile-bound and OPTIONAL. Absent → no content hash is produced (full
|
|
170
|
+
* backward compatibility). The gateway computes the hash; the SP only ever
|
|
171
|
+
* receives the hash, never the content, so HAP's privacy-minimal design holds.
|
|
172
|
+
*
|
|
173
|
+
* The profile declares only the *policy* — whether to bind and how to
|
|
174
|
+
* canonicalize. It does NOT name the tool field: that is tool-specific and is
|
|
175
|
+
* resolved at runtime (the same content-field resolver the footer uses for
|
|
176
|
+
* `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
177
|
+
*/
|
|
178
|
+
interface ContentBinding {
|
|
179
|
+
/** Canonicalization version. A verifier MUST pin the version named here. */
|
|
180
|
+
version: string;
|
|
181
|
+
/**
|
|
182
|
+
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload.
|
|
183
|
+
* - 'text' → free text: NFC + LF + trailing-whitespace strip (see
|
|
184
|
+
* canonicalizeText), auto-detected content field.
|
|
185
|
+
*/
|
|
186
|
+
kind: 'jcs' | 'text';
|
|
187
|
+
/** text only: hash the content BEFORE any appended Suveren footer. */
|
|
188
|
+
pre_footer?: boolean;
|
|
189
|
+
}
|
|
164
190
|
/**
|
|
165
191
|
* Context field definition within a v0.4 profile.
|
|
166
192
|
*/
|
|
@@ -280,6 +306,13 @@ interface AgentProfile {
|
|
|
280
306
|
max: number;
|
|
281
307
|
};
|
|
282
308
|
retention_minimum: number;
|
|
309
|
+
/**
|
|
310
|
+
* v0.5 Content Provenance (OPTIONAL, profile-bound). When present, the
|
|
311
|
+
* gateway computes a `contentHash` for gated writes under this profile and
|
|
312
|
+
* passes it (hash only) to the SP, which signs it into the receipt. Absent
|
|
313
|
+
* → no content hash. See {@link ContentBinding}.
|
|
314
|
+
*/
|
|
315
|
+
content_binding?: ContentBinding;
|
|
283
316
|
/**
|
|
284
317
|
* Tool gating configuration — how MCP tools map to execution context.
|
|
285
318
|
* @deprecated Tool gating now lives in integration manifests (content/integrations/*.json).
|
|
@@ -436,6 +469,55 @@ type GatekeeperResult = {
|
|
|
436
469
|
*/
|
|
437
470
|
declare function canonicalize(value: unknown): string;
|
|
438
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Content binding — Level 2 content proof (HAP v0.5 Content Provenance).
|
|
474
|
+
*
|
|
475
|
+
* A receipt normally proves who/why/bounds/when but NOT the action's content.
|
|
476
|
+
* Content binding closes that gap: the gateway computes a `content_hash` over
|
|
477
|
+
* the action's content per the profile's {@link ContentBinding} and hands the
|
|
478
|
+
* SP only the hash. The SP signs it into the receipt verbatim — it never sees
|
|
479
|
+
* the content, so HAP's privacy-minimal design is preserved. Anyone holding
|
|
480
|
+
* the content can recompute the hash and check it against the signed receipt.
|
|
481
|
+
*
|
|
482
|
+
* The hash only verifies if the verifier reproduces the EXACT bytes we hashed,
|
|
483
|
+
* so canonicalization is normative and versioned (pin via `ContentBinding.version`):
|
|
484
|
+
*
|
|
485
|
+
* - kind:"jcs" → RFC 8785 JCS of the record payload (see {@link canonicalize}).
|
|
486
|
+
* - kind:"text" → UTF-8 of the string after {@link canonicalizeText}
|
|
487
|
+
* (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
|
|
488
|
+
* trailing blank lines removed), taken pre-footer when `pre_footer` is set.
|
|
489
|
+
*
|
|
490
|
+
* Both Node and the browser produce byte-identical output: JCS relies only on
|
|
491
|
+
* environment-independent primitives, and the text rule uses String.normalize +
|
|
492
|
+
* plain string ops. The SHA-256 is computed with Node `crypto` here (the same
|
|
493
|
+
* pattern as frame.ts); browser callers that need to recompute use their own
|
|
494
|
+
* SubtleCrypto digest over the identical canonical bytes.
|
|
495
|
+
*/
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Canonicalize free text per the v0.5 'text' rule. Idempotent.
|
|
499
|
+
*
|
|
500
|
+
* 1. Unicode NFC normalization.
|
|
501
|
+
* 2. CRLF / CR → LF.
|
|
502
|
+
* 3. Strip trailing spaces/tabs from every line.
|
|
503
|
+
* 4. Remove trailing blank lines.
|
|
504
|
+
*/
|
|
505
|
+
declare function canonicalizeText(input: string): string;
|
|
506
|
+
/**
|
|
507
|
+
* Compute the canonical bytes that a content hash is taken over, WITHOUT
|
|
508
|
+
* hashing — exposed so verifiers can debug a mismatch by inspecting the exact
|
|
509
|
+
* serialization both sides should agree on.
|
|
510
|
+
*/
|
|
511
|
+
declare function contentCanonicalBytes(kind: ContentBinding['kind'], content: Record<string, unknown> | string): string;
|
|
512
|
+
/**
|
|
513
|
+
* Compute a profile-bound content hash, formatted `sha256:<hex>` (matching the
|
|
514
|
+
* frame/bounds/context hash format used elsewhere in HAP).
|
|
515
|
+
*
|
|
516
|
+
* @param binding the profile's content_binding declaration
|
|
517
|
+
* @param content the record payload (jcs) or the resolved text field (text)
|
|
518
|
+
*/
|
|
519
|
+
declare function computeContentHash(binding: ContentBinding, content: Record<string, unknown> | string): string;
|
|
520
|
+
|
|
439
521
|
/**
|
|
440
522
|
* Frame Canonicalization for Agent Profiles
|
|
441
523
|
*
|
|
@@ -633,4 +715,4 @@ declare function listProfiles(): string[];
|
|
|
633
715
|
declare function getAllProfiles(): AgentProfile[];
|
|
634
716
|
declare function clearProfiles(): void;
|
|
635
717
|
|
|
636
|
-
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
718
|
+
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type ContentBinding, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFrameHash, contentCanonicalBytes, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
@@ -161,6 +161,32 @@ interface ProfileBoundsField {
|
|
|
161
161
|
/** @deprecated v0.4: use boundType: { kind: 'enum', values: [...] }. */
|
|
162
162
|
enum?: string[];
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* v0.5 Content Provenance — how a profile's action content is hashed into a
|
|
166
|
+
* signed receipt (`contentHash`). The ephemeral-content analog of Output
|
|
167
|
+
* Provenance: it binds the *bytes* of the action rather than a location.
|
|
168
|
+
*
|
|
169
|
+
* Profile-bound and OPTIONAL. Absent → no content hash is produced (full
|
|
170
|
+
* backward compatibility). The gateway computes the hash; the SP only ever
|
|
171
|
+
* receives the hash, never the content, so HAP's privacy-minimal design holds.
|
|
172
|
+
*
|
|
173
|
+
* The profile declares only the *policy* — whether to bind and how to
|
|
174
|
+
* canonicalize. It does NOT name the tool field: that is tool-specific and is
|
|
175
|
+
* resolved at runtime (the same content-field resolver the footer uses for
|
|
176
|
+
* `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
177
|
+
*/
|
|
178
|
+
interface ContentBinding {
|
|
179
|
+
/** Canonicalization version. A verifier MUST pin the version named here. */
|
|
180
|
+
version: string;
|
|
181
|
+
/**
|
|
182
|
+
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload.
|
|
183
|
+
* - 'text' → free text: NFC + LF + trailing-whitespace strip (see
|
|
184
|
+
* canonicalizeText), auto-detected content field.
|
|
185
|
+
*/
|
|
186
|
+
kind: 'jcs' | 'text';
|
|
187
|
+
/** text only: hash the content BEFORE any appended Suveren footer. */
|
|
188
|
+
pre_footer?: boolean;
|
|
189
|
+
}
|
|
164
190
|
/**
|
|
165
191
|
* Context field definition within a v0.4 profile.
|
|
166
192
|
*/
|
|
@@ -280,6 +306,13 @@ interface AgentProfile {
|
|
|
280
306
|
max: number;
|
|
281
307
|
};
|
|
282
308
|
retention_minimum: number;
|
|
309
|
+
/**
|
|
310
|
+
* v0.5 Content Provenance (OPTIONAL, profile-bound). When present, the
|
|
311
|
+
* gateway computes a `contentHash` for gated writes under this profile and
|
|
312
|
+
* passes it (hash only) to the SP, which signs it into the receipt. Absent
|
|
313
|
+
* → no content hash. See {@link ContentBinding}.
|
|
314
|
+
*/
|
|
315
|
+
content_binding?: ContentBinding;
|
|
283
316
|
/**
|
|
284
317
|
* Tool gating configuration — how MCP tools map to execution context.
|
|
285
318
|
* @deprecated Tool gating now lives in integration manifests (content/integrations/*.json).
|
|
@@ -436,6 +469,55 @@ type GatekeeperResult = {
|
|
|
436
469
|
*/
|
|
437
470
|
declare function canonicalize(value: unknown): string;
|
|
438
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Content binding — Level 2 content proof (HAP v0.5 Content Provenance).
|
|
474
|
+
*
|
|
475
|
+
* A receipt normally proves who/why/bounds/when but NOT the action's content.
|
|
476
|
+
* Content binding closes that gap: the gateway computes a `content_hash` over
|
|
477
|
+
* the action's content per the profile's {@link ContentBinding} and hands the
|
|
478
|
+
* SP only the hash. The SP signs it into the receipt verbatim — it never sees
|
|
479
|
+
* the content, so HAP's privacy-minimal design is preserved. Anyone holding
|
|
480
|
+
* the content can recompute the hash and check it against the signed receipt.
|
|
481
|
+
*
|
|
482
|
+
* The hash only verifies if the verifier reproduces the EXACT bytes we hashed,
|
|
483
|
+
* so canonicalization is normative and versioned (pin via `ContentBinding.version`):
|
|
484
|
+
*
|
|
485
|
+
* - kind:"jcs" → RFC 8785 JCS of the record payload (see {@link canonicalize}).
|
|
486
|
+
* - kind:"text" → UTF-8 of the string after {@link canonicalizeText}
|
|
487
|
+
* (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
|
|
488
|
+
* trailing blank lines removed), taken pre-footer when `pre_footer` is set.
|
|
489
|
+
*
|
|
490
|
+
* Both Node and the browser produce byte-identical output: JCS relies only on
|
|
491
|
+
* environment-independent primitives, and the text rule uses String.normalize +
|
|
492
|
+
* plain string ops. The SHA-256 is computed with Node `crypto` here (the same
|
|
493
|
+
* pattern as frame.ts); browser callers that need to recompute use their own
|
|
494
|
+
* SubtleCrypto digest over the identical canonical bytes.
|
|
495
|
+
*/
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Canonicalize free text per the v0.5 'text' rule. Idempotent.
|
|
499
|
+
*
|
|
500
|
+
* 1. Unicode NFC normalization.
|
|
501
|
+
* 2. CRLF / CR → LF.
|
|
502
|
+
* 3. Strip trailing spaces/tabs from every line.
|
|
503
|
+
* 4. Remove trailing blank lines.
|
|
504
|
+
*/
|
|
505
|
+
declare function canonicalizeText(input: string): string;
|
|
506
|
+
/**
|
|
507
|
+
* Compute the canonical bytes that a content hash is taken over, WITHOUT
|
|
508
|
+
* hashing — exposed so verifiers can debug a mismatch by inspecting the exact
|
|
509
|
+
* serialization both sides should agree on.
|
|
510
|
+
*/
|
|
511
|
+
declare function contentCanonicalBytes(kind: ContentBinding['kind'], content: Record<string, unknown> | string): string;
|
|
512
|
+
/**
|
|
513
|
+
* Compute a profile-bound content hash, formatted `sha256:<hex>` (matching the
|
|
514
|
+
* frame/bounds/context hash format used elsewhere in HAP).
|
|
515
|
+
*
|
|
516
|
+
* @param binding the profile's content_binding declaration
|
|
517
|
+
* @param content the record payload (jcs) or the resolved text field (text)
|
|
518
|
+
*/
|
|
519
|
+
declare function computeContentHash(binding: ContentBinding, content: Record<string, unknown> | string): string;
|
|
520
|
+
|
|
439
521
|
/**
|
|
440
522
|
* Frame Canonicalization for Agent Profiles
|
|
441
523
|
*
|
|
@@ -633,4 +715,4 @@ declare function listProfiles(): string[];
|
|
|
633
715
|
declare function getAllProfiles(): AgentProfile[];
|
|
634
716
|
declare function clearProfiles(): void;
|
|
635
717
|
|
|
636
|
-
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
718
|
+
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type ContentBinding, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFrameHash, contentCanonicalBytes, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
@@ -35,11 +35,14 @@ __export(index_exports, {
|
|
|
35
35
|
canonicalContext: () => canonicalContext,
|
|
36
36
|
canonicalFrame: () => canonicalFrame,
|
|
37
37
|
canonicalize: () => canonicalize,
|
|
38
|
+
canonicalizeText: () => canonicalizeText,
|
|
38
39
|
checkAttestationExpiry: () => checkAttestationExpiry,
|
|
39
40
|
clearProfiles: () => clearProfiles,
|
|
40
41
|
computeBoundsHash: () => computeBoundsHash,
|
|
42
|
+
computeContentHash: () => computeContentHash,
|
|
41
43
|
computeContextHash: () => computeContextHash,
|
|
42
44
|
computeFrameHash: () => computeFrameHash,
|
|
45
|
+
contentCanonicalBytes: () => contentCanonicalBytes,
|
|
43
46
|
decodeAttestationBlob: () => decodeAttestationBlob,
|
|
44
47
|
encodeAttestationBlob: () => encodeAttestationBlob,
|
|
45
48
|
frameHash: () => frameHash,
|
|
@@ -86,8 +89,35 @@ function canonicalize(value) {
|
|
|
86
89
|
return "{" + parts.join(",") + "}";
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
// src/
|
|
92
|
+
// src/content-binding.ts
|
|
90
93
|
var import_crypto = require("crypto");
|
|
94
|
+
function canonicalizeText(input) {
|
|
95
|
+
const nfc = input.normalize("NFC");
|
|
96
|
+
const lf = nfc.replace(/\r\n?/g, "\n");
|
|
97
|
+
const lines = lf.split("\n").map((line) => line.replace(/[ \t]+$/, ""));
|
|
98
|
+
return lines.join("\n").replace(/\n+$/, "");
|
|
99
|
+
}
|
|
100
|
+
function sha256Hex(bytes) {
|
|
101
|
+
return (0, import_crypto.createHash)("sha256").update(bytes, "utf8").digest("hex");
|
|
102
|
+
}
|
|
103
|
+
function contentCanonicalBytes(kind, content) {
|
|
104
|
+
if (kind === "jcs") {
|
|
105
|
+
if (typeof content === "string") {
|
|
106
|
+
throw new Error('content_binding kind="jcs" expects a record payload (object), got a string');
|
|
107
|
+
}
|
|
108
|
+
return canonicalize(content);
|
|
109
|
+
}
|
|
110
|
+
if (typeof content !== "string") {
|
|
111
|
+
throw new Error('content_binding kind="text" expects a string, got an object');
|
|
112
|
+
}
|
|
113
|
+
return canonicalizeText(content);
|
|
114
|
+
}
|
|
115
|
+
function computeContentHash(binding, content) {
|
|
116
|
+
return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/frame.ts
|
|
120
|
+
var import_crypto2 = require("crypto");
|
|
91
121
|
function validateFrameParams(params, profile) {
|
|
92
122
|
const errors = [];
|
|
93
123
|
if (!profile.frameSchema) {
|
|
@@ -124,7 +154,7 @@ function canonicalFrame(params, profile) {
|
|
|
124
154
|
return lines.join("\n");
|
|
125
155
|
}
|
|
126
156
|
function frameHash(canonicalFrameString) {
|
|
127
|
-
const hash = (0,
|
|
157
|
+
const hash = (0, import_crypto2.createHash)("sha256").update(canonicalFrameString, "utf8").digest("hex");
|
|
128
158
|
return `sha256:${hash}`;
|
|
129
159
|
}
|
|
130
160
|
function computeFrameHash(params, profile) {
|
|
@@ -208,17 +238,17 @@ function canonicalContext(params, profile) {
|
|
|
208
238
|
}
|
|
209
239
|
function computeBoundsHash(params, profile) {
|
|
210
240
|
const canonical = canonicalBounds(params, profile);
|
|
211
|
-
const hash = (0,
|
|
241
|
+
const hash = (0, import_crypto2.createHash)("sha256").update(canonical, "utf8").digest("hex");
|
|
212
242
|
return `sha256:${hash}`;
|
|
213
243
|
}
|
|
214
244
|
function computeContextHash(params, profile) {
|
|
215
245
|
const canonical = canonicalContext(params, profile);
|
|
216
|
-
const hash = (0,
|
|
246
|
+
const hash = (0, import_crypto2.createHash)("sha256").update(canonical, "utf8").digest("hex");
|
|
217
247
|
return `sha256:${hash}`;
|
|
218
248
|
}
|
|
219
249
|
|
|
220
250
|
// src/attestation.ts
|
|
221
|
-
var
|
|
251
|
+
var import_crypto3 = require("crypto");
|
|
222
252
|
var ed = __toESM(require("@noble/ed25519"));
|
|
223
253
|
function decodeAttestationBlob(blob) {
|
|
224
254
|
try {
|
|
@@ -236,7 +266,7 @@ function encodeAttestationBlob(attestation) {
|
|
|
236
266
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
237
267
|
}
|
|
238
268
|
function attestationId(blob) {
|
|
239
|
-
const hash = (0,
|
|
269
|
+
const hash = (0, import_crypto3.createHash)("sha256").update(blob, "utf8").digest("hex");
|
|
240
270
|
return `sha256:${hash}`;
|
|
241
271
|
}
|
|
242
272
|
async function verifyAttestationSignature(attestation, publicKeyHex) {
|
|
@@ -746,11 +776,14 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
|
|
|
746
776
|
canonicalContext,
|
|
747
777
|
canonicalFrame,
|
|
748
778
|
canonicalize,
|
|
779
|
+
canonicalizeText,
|
|
749
780
|
checkAttestationExpiry,
|
|
750
781
|
clearProfiles,
|
|
751
782
|
computeBoundsHash,
|
|
783
|
+
computeContentHash,
|
|
752
784
|
computeContextHash,
|
|
753
785
|
computeFrameHash,
|
|
786
|
+
contentCanonicalBytes,
|
|
754
787
|
decodeAttestationBlob,
|
|
755
788
|
encodeAttestationBlob,
|
|
756
789
|
frameHash,
|
|
@@ -23,8 +23,35 @@ function canonicalize(value) {
|
|
|
23
23
|
return "{" + parts.join(",") + "}";
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
// src/
|
|
26
|
+
// src/content-binding.ts
|
|
27
27
|
import { createHash } from "crypto";
|
|
28
|
+
function canonicalizeText(input) {
|
|
29
|
+
const nfc = input.normalize("NFC");
|
|
30
|
+
const lf = nfc.replace(/\r\n?/g, "\n");
|
|
31
|
+
const lines = lf.split("\n").map((line) => line.replace(/[ \t]+$/, ""));
|
|
32
|
+
return lines.join("\n").replace(/\n+$/, "");
|
|
33
|
+
}
|
|
34
|
+
function sha256Hex(bytes) {
|
|
35
|
+
return createHash("sha256").update(bytes, "utf8").digest("hex");
|
|
36
|
+
}
|
|
37
|
+
function contentCanonicalBytes(kind, content) {
|
|
38
|
+
if (kind === "jcs") {
|
|
39
|
+
if (typeof content === "string") {
|
|
40
|
+
throw new Error('content_binding kind="jcs" expects a record payload (object), got a string');
|
|
41
|
+
}
|
|
42
|
+
return canonicalize(content);
|
|
43
|
+
}
|
|
44
|
+
if (typeof content !== "string") {
|
|
45
|
+
throw new Error('content_binding kind="text" expects a string, got an object');
|
|
46
|
+
}
|
|
47
|
+
return canonicalizeText(content);
|
|
48
|
+
}
|
|
49
|
+
function computeContentHash(binding, content) {
|
|
50
|
+
return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/frame.ts
|
|
54
|
+
import { createHash as createHash2 } from "crypto";
|
|
28
55
|
function validateFrameParams(params, profile) {
|
|
29
56
|
const errors = [];
|
|
30
57
|
if (!profile.frameSchema) {
|
|
@@ -61,7 +88,7 @@ function canonicalFrame(params, profile) {
|
|
|
61
88
|
return lines.join("\n");
|
|
62
89
|
}
|
|
63
90
|
function frameHash(canonicalFrameString) {
|
|
64
|
-
const hash =
|
|
91
|
+
const hash = createHash2("sha256").update(canonicalFrameString, "utf8").digest("hex");
|
|
65
92
|
return `sha256:${hash}`;
|
|
66
93
|
}
|
|
67
94
|
function computeFrameHash(params, profile) {
|
|
@@ -145,17 +172,17 @@ function canonicalContext(params, profile) {
|
|
|
145
172
|
}
|
|
146
173
|
function computeBoundsHash(params, profile) {
|
|
147
174
|
const canonical = canonicalBounds(params, profile);
|
|
148
|
-
const hash =
|
|
175
|
+
const hash = createHash2("sha256").update(canonical, "utf8").digest("hex");
|
|
149
176
|
return `sha256:${hash}`;
|
|
150
177
|
}
|
|
151
178
|
function computeContextHash(params, profile) {
|
|
152
179
|
const canonical = canonicalContext(params, profile);
|
|
153
|
-
const hash =
|
|
180
|
+
const hash = createHash2("sha256").update(canonical, "utf8").digest("hex");
|
|
154
181
|
return `sha256:${hash}`;
|
|
155
182
|
}
|
|
156
183
|
|
|
157
184
|
// src/attestation.ts
|
|
158
|
-
import { createHash as
|
|
185
|
+
import { createHash as createHash3 } from "crypto";
|
|
159
186
|
import * as ed from "@noble/ed25519";
|
|
160
187
|
function decodeAttestationBlob(blob) {
|
|
161
188
|
try {
|
|
@@ -173,7 +200,7 @@ function encodeAttestationBlob(attestation) {
|
|
|
173
200
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
174
201
|
}
|
|
175
202
|
function attestationId(blob) {
|
|
176
|
-
const hash =
|
|
203
|
+
const hash = createHash3("sha256").update(blob, "utf8").digest("hex");
|
|
177
204
|
return `sha256:${hash}`;
|
|
178
205
|
}
|
|
179
206
|
async function verifyAttestationSignature(attestation, publicKeyHex) {
|
|
@@ -682,11 +709,14 @@ export {
|
|
|
682
709
|
canonicalContext,
|
|
683
710
|
canonicalFrame,
|
|
684
711
|
canonicalize,
|
|
712
|
+
canonicalizeText,
|
|
685
713
|
checkAttestationExpiry,
|
|
686
714
|
clearProfiles,
|
|
687
715
|
computeBoundsHash,
|
|
716
|
+
computeContentHash,
|
|
688
717
|
computeContextHash,
|
|
689
718
|
computeFrameHash,
|
|
719
|
+
contentCanonicalBytes,
|
|
690
720
|
decodeAttestationBlob,
|
|
691
721
|
encodeAttestationBlob,
|
|
692
722
|
frameHash,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content binding — Level 2 content proof (HAP v0.5 Content Provenance).
|
|
3
|
+
*
|
|
4
|
+
* A receipt normally proves who/why/bounds/when but NOT the action's content.
|
|
5
|
+
* Content binding closes that gap: the gateway computes a `content_hash` over
|
|
6
|
+
* the action's content per the profile's {@link ContentBinding} and hands the
|
|
7
|
+
* SP only the hash. The SP signs it into the receipt verbatim — it never sees
|
|
8
|
+
* the content, so HAP's privacy-minimal design is preserved. Anyone holding
|
|
9
|
+
* the content can recompute the hash and check it against the signed receipt.
|
|
10
|
+
*
|
|
11
|
+
* The hash only verifies if the verifier reproduces the EXACT bytes we hashed,
|
|
12
|
+
* so canonicalization is normative and versioned (pin via `ContentBinding.version`):
|
|
13
|
+
*
|
|
14
|
+
* - kind:"jcs" → RFC 8785 JCS of the record payload (see {@link canonicalize}).
|
|
15
|
+
* - kind:"text" → UTF-8 of the string after {@link canonicalizeText}
|
|
16
|
+
* (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
|
|
17
|
+
* trailing blank lines removed), taken pre-footer when `pre_footer` is set.
|
|
18
|
+
*
|
|
19
|
+
* Both Node and the browser produce byte-identical output: JCS relies only on
|
|
20
|
+
* environment-independent primitives, and the text rule uses String.normalize +
|
|
21
|
+
* plain string ops. The SHA-256 is computed with Node `crypto` here (the same
|
|
22
|
+
* pattern as frame.ts); browser callers that need to recompute use their own
|
|
23
|
+
* SubtleCrypto digest over the identical canonical bytes.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash } from 'crypto';
|
|
27
|
+
import { canonicalize } from './canonicalize';
|
|
28
|
+
import type { ContentBinding } from './types';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Canonicalize free text per the v0.5 'text' rule. Idempotent.
|
|
32
|
+
*
|
|
33
|
+
* 1. Unicode NFC normalization.
|
|
34
|
+
* 2. CRLF / CR → LF.
|
|
35
|
+
* 3. Strip trailing spaces/tabs from every line.
|
|
36
|
+
* 4. Remove trailing blank lines.
|
|
37
|
+
*/
|
|
38
|
+
export function canonicalizeText(input: string): string {
|
|
39
|
+
const nfc = input.normalize('NFC');
|
|
40
|
+
const lf = nfc.replace(/\r\n?/g, '\n');
|
|
41
|
+
const lines = lf.split('\n').map((line) => line.replace(/[ \t]+$/, ''));
|
|
42
|
+
return lines.join('\n').replace(/\n+$/, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** sha256 of a UTF-8 string → 64 hex chars. */
|
|
46
|
+
function sha256Hex(bytes: string): string {
|
|
47
|
+
return createHash('sha256').update(bytes, 'utf8').digest('hex');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compute the canonical bytes that a content hash is taken over, WITHOUT
|
|
52
|
+
* hashing — exposed so verifiers can debug a mismatch by inspecting the exact
|
|
53
|
+
* serialization both sides should agree on.
|
|
54
|
+
*/
|
|
55
|
+
export function contentCanonicalBytes(
|
|
56
|
+
kind: ContentBinding['kind'],
|
|
57
|
+
content: Record<string, unknown> | string,
|
|
58
|
+
): string {
|
|
59
|
+
if (kind === 'jcs') {
|
|
60
|
+
if (typeof content === 'string') {
|
|
61
|
+
throw new Error('content_binding kind="jcs" expects a record payload (object), got a string');
|
|
62
|
+
}
|
|
63
|
+
return canonicalize(content);
|
|
64
|
+
}
|
|
65
|
+
if (typeof content !== 'string') {
|
|
66
|
+
throw new Error('content_binding kind="text" expects a string, got an object');
|
|
67
|
+
}
|
|
68
|
+
return canonicalizeText(content);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compute a profile-bound content hash, formatted `sha256:<hex>` (matching the
|
|
73
|
+
* frame/bounds/context hash format used elsewhere in HAP).
|
|
74
|
+
*
|
|
75
|
+
* @param binding the profile's content_binding declaration
|
|
76
|
+
* @param content the record payload (jcs) or the resolved text field (text)
|
|
77
|
+
*/
|
|
78
|
+
export function computeContentHash(
|
|
79
|
+
binding: ContentBinding,
|
|
80
|
+
content: Record<string, unknown> | string,
|
|
81
|
+
): string {
|
|
82
|
+
return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
|
|
83
|
+
}
|
|
@@ -166,6 +166,33 @@ export interface ProfileBoundsField {
|
|
|
166
166
|
enum?: string[];
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* v0.5 Content Provenance — how a profile's action content is hashed into a
|
|
171
|
+
* signed receipt (`contentHash`). The ephemeral-content analog of Output
|
|
172
|
+
* Provenance: it binds the *bytes* of the action rather than a location.
|
|
173
|
+
*
|
|
174
|
+
* Profile-bound and OPTIONAL. Absent → no content hash is produced (full
|
|
175
|
+
* backward compatibility). The gateway computes the hash; the SP only ever
|
|
176
|
+
* receives the hash, never the content, so HAP's privacy-minimal design holds.
|
|
177
|
+
*
|
|
178
|
+
* The profile declares only the *policy* — whether to bind and how to
|
|
179
|
+
* canonicalize. It does NOT name the tool field: that is tool-specific and is
|
|
180
|
+
* resolved at runtime (the same content-field resolver the footer uses for
|
|
181
|
+
* `kind:"text"`; the whole record payload for `kind:"jcs"`).
|
|
182
|
+
*/
|
|
183
|
+
export interface ContentBinding {
|
|
184
|
+
/** Canonicalization version. A verifier MUST pin the version named here. */
|
|
185
|
+
version: string;
|
|
186
|
+
/**
|
|
187
|
+
* - 'jcs' → structured writes: RFC 8785 JCS over the record payload.
|
|
188
|
+
* - 'text' → free text: NFC + LF + trailing-whitespace strip (see
|
|
189
|
+
* canonicalizeText), auto-detected content field.
|
|
190
|
+
*/
|
|
191
|
+
kind: 'jcs' | 'text';
|
|
192
|
+
/** text only: hash the content BEFORE any appended Suveren footer. */
|
|
193
|
+
pre_footer?: boolean;
|
|
194
|
+
}
|
|
195
|
+
|
|
169
196
|
/**
|
|
170
197
|
* Context field definition within a v0.4 profile.
|
|
171
198
|
*/
|
|
@@ -295,6 +322,14 @@ export interface AgentProfile {
|
|
|
295
322
|
ttl: { default: number; max: number };
|
|
296
323
|
retention_minimum: number;
|
|
297
324
|
|
|
325
|
+
/**
|
|
326
|
+
* v0.5 Content Provenance (OPTIONAL, profile-bound). When present, the
|
|
327
|
+
* gateway computes a `contentHash` for gated writes under this profile and
|
|
328
|
+
* passes it (hash only) to the SP, which signs it into the receipt. Absent
|
|
329
|
+
* → no content hash. See {@link ContentBinding}.
|
|
330
|
+
*/
|
|
331
|
+
content_binding?: ContentBinding;
|
|
332
|
+
|
|
298
333
|
/**
|
|
299
334
|
* Tool gating configuration — how MCP tools map to execution context.
|
|
300
335
|
* @deprecated Tool gating now lives in integration manifests (content/integrations/*.json).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suveren/gateway",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "Suveren gateway — local agent gateway built in compliance with the Human Agency Protocol (HAP). Runs the UI, control plane, and MCP server in one Node process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|