witnora 0.13.2 → 0.13.3

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/README.md CHANGED
@@ -353,7 +353,7 @@ Control semantics and attestation format:
353
353
  [release gate checklist](https://github.com/Kakarottoooo/agentcert/blob/main/docs/release-gate-checklist.md).
354
354
 
355
355
  CI users can run Tripwire and Witnora together with
356
- `Kakarottoooo/agentcert/actions/tripwire@v0`.
356
+ `Kakarottoooo/witnora/actions/tripwire@v0`.
357
357
 
358
358
  The public Real Agent Robustness Lab compares browser-use, Stagehand, and
359
359
  Playwright-based agents over the same fault suite:
@@ -10,7 +10,7 @@ const SUPPORTED_TOP_LEVEL_FIELDS = new Set([
10
10
  export async function runEvidenceConformance(input, options) {
11
11
  const checks = [];
12
12
  const schema = validateAgentCertSchema("evidence-bundle", input);
13
- checks.push(check("schema", "Evidence bundle satisfies the AgentCert v0.1 semantic contract.", schema.errors));
13
+ checks.push(check("schema", "Witnora evidence satisfies the stable agentcert v0.1 protocol contract.", schema.errors));
14
14
  const bundle = object(input);
15
15
  const compatibilityErrors = bundle
16
16
  ? Object.keys(bundle).filter((key) => !SUPPORTED_TOP_LEVEL_FIELDS.has(key)).map((key) => `Unsupported top-level field: ${key}.`)
@@ -397,7 +397,7 @@ async function requestJson(request, url, init) {
397
397
  }
398
398
  catch (error) {
399
399
  const message = error instanceof Error ? error.message : String(error);
400
- throw new ControlPlaneRequestError(`AgentCert control plane request failed: ${message}`);
400
+ throw new ControlPlaneRequestError(`Witnora control plane request failed: ${message}`);
401
401
  }
402
402
  const text = await response.text();
403
403
  let value = {};
@@ -407,12 +407,12 @@ async function requestJson(request, url, init) {
407
407
  }
408
408
  catch {
409
409
  if (!response.ok)
410
- throw new ControlPlaneRequestError(`AgentCert control plane returned HTTP ${response.status}.`, response.status);
411
- throw new ControlPlaneRequestError("AgentCert control plane returned invalid JSON.", response.status);
410
+ throw new ControlPlaneRequestError(`Witnora control plane returned HTTP ${response.status}.`, response.status);
411
+ throw new ControlPlaneRequestError("Witnora control plane returned invalid JSON.", response.status);
412
412
  }
413
413
  }
414
414
  if (!response.ok) {
415
- throw new ControlPlaneRequestError([typeof value.error === "string" ? value.error : `AgentCert control plane returned HTTP ${response.status}.`,
415
+ throw new ControlPlaneRequestError([typeof value.error === "string" ? value.error : `Witnora control plane returned HTTP ${response.status}.`,
416
416
  typeof value.recovery === "string" ? value.recovery : undefined,
417
417
  typeof value.requestId === "string" ? `Request ID: ${value.requestId}.` : undefined].filter(Boolean).join(" "), response.status, typeof value.code === "string" ? value.code : undefined, typeof value.requestId === "string" ? value.requestId : response.headers.get("x-request-id") ?? undefined, typeof value.recovery === "string" ? value.recovery : undefined);
418
418
  }
package/dist/gateway.js CHANGED
@@ -36,7 +36,7 @@ export async function initializeCustomerGateway(options) {
36
36
  output,
37
37
  configHome: options.configHome,
38
38
  });
39
- const expectedScopes = ["runs:read", "events:write", "collector:manage"];
39
+ const expectedScopes = ["runs:read", "events:write", "collector:manage", "actions:read", "actions:propose", "actions:execute"];
40
40
  const missingScopes = expectedScopes.filter((scope) => !authorization.scopes.includes(scope));
41
41
  if (missingScopes.length > 0)
42
42
  throw new Error(`Gateway authorization is missing required scope(s): ${missingScopes.join(", ")}.`);
@@ -156,7 +156,11 @@ export async function doctorCustomerGateway(options) {
156
156
  };
157
157
  }
158
158
  export async function runCustomerGateway(options) {
159
- const { CustomerSourceKeyRing, RemoteCollectorClient, startCustomerOwnedCollectorGateway, } = await import("agentcert-sdk");
159
+ const remoteCollector = await import(new URL("./internal/control-client/remote-collector.js", import.meta.url).href);
160
+ const collectorGateway = await import(new URL("./internal/control-client/collector-gateway.js", import.meta.url).href);
161
+ const CustomerSourceKeyRing = remoteCollector.CustomerSourceKeyRing;
162
+ const RemoteCollectorClient = remoteCollector.RemoteCollectorClient;
163
+ const startCustomerOwnedCollectorGateway = collectorGateway.startCustomerOwnedCollectorGateway;
160
164
  const directory = resolve(options.repository ?? process.cwd(), options.dir ?? ".witnora/gateway");
161
165
  const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
162
166
  const secrets = parseSecrets(await readFile(join(directory, "secrets.json"), "utf8"));
@@ -405,6 +409,13 @@ function gatewayReadme(config) {
405
409
  return `# Witnora customer-owned Gateway\n\nThis directory configures a metadata-only Gateway for project \`${config.projectId}\`. The Setup Autopilot starts it in the background after browser authorization.\n\n## Operations\n\n\`\`\`bash\nnpx witnora@latest gateway status\nnpx witnora@latest gateway logs\nnpx witnora@latest gateway restart\nnpx witnora@latest gateway stop\n\`\`\`\n\n\`gateway run\` remains available as a foreground debugging command. In the Agent repository, import the generated client at one meaningful sandbox workflow boundary:\n\n\`\`\`js\nimport { randomUUID } from "node:crypto";\nimport { witnoraGateway } from "./.witnora/gateway/client.mjs";\n\nconst runId = randomUUID();\nawait witnoraGateway.start(runId, { workflow: "sandbox-workflow" });\ntry {\n // Run the existing customer workflow here. Do not add raw inputs or outputs.\n await witnoraGateway.event(runId, "workflow.step.completed", { step: "meaningful-boundary" });\n await witnoraGateway.complete(runId, { status: "completed" });\n} catch (error) {\n await witnoraGateway.event(runId, "workflow.failed", { errorType: error?.name ?? "Error" });\n await witnoraGateway.complete(runId, { status: "failed" });\n throw error;\n}\n\`\`\`\n\nThe generated client reads only the local ignored Gateway token and sends metadata to \`http://${config.host}:${config.port}\`. The Hosted API key and source-signing key stay in the Gateway process. Do not commit \`secrets.json\`, \`data/\`, or \`runtime/\`.\n\nThis reference process creates source-signed, durable **RECORDED** evidence. It does not claim complete mediation. **ENFORCED** requires the target write credential to be removed from the Agent and placed behind a controlled execution adapter. **OUTCOME VERIFIED** requires a separate read-only credential and independent probe.\n`;
406
410
  }
407
411
  function gatewayClient(config) {
412
+ const requestHelper = `async function actionRequest(path, init = {}) {\n const headers = new Headers(init.headers);\n headers.set("authorization", \`Bearer \${await token()}\`);\n if (init.body) headers.set("content-type", "application/json");\n const response = await fetch(\`\${baseUrl}\${path}\`, { ...init, headers });\n const result = await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(result.error ?? \`Witnora Gateway returned HTTP \${response.status}.\`);\n return result;\n}\n`;
413
+ const actionMethods = ` proposeAction(proposal, idempotencyKey = proposal?.externalId) {\n if (typeof idempotencyKey !== "string" || !idempotencyKey) throw new Error("Witnora action proposal requires an idempotency key or externalId.");\n return actionRequest("/v1/actions", { method: "POST", body: JSON.stringify({ proposal, idempotencyKey }) });\n },\n getAction(actionId) {\n if (!/^[A-Za-z0-9._:-]+$/.test(actionId)) throw new Error("Witnora actionId contains unsupported characters.");\n return actionRequest(\`/v1/actions/\${encodeURIComponent(actionId)}\`);\n },\n issueExecutionGrant(actionId, grant, idempotencyKey = \`grant:\${actionId}\`) {\n if (!/^[A-Za-z0-9._:-]+$/.test(actionId)) throw new Error("Witnora actionId contains unsupported characters.");\n return actionRequest(\`/v1/actions/\${encodeURIComponent(actionId)}/execution-grant\`, { method: "POST", body: JSON.stringify({ grant, idempotencyKey }) });\n },\n`;
414
+ return recordedGatewayClient(config)
415
+ .replace("export const witnoraGateway = {", `${requestHelper}\nexport const witnoraGateway = {`)
416
+ .replace(/\n};\n$/, `\n${actionMethods}};\n`);
417
+ }
418
+ function recordedGatewayClient(config) {
408
419
  return `import { readFile } from "node:fs/promises";\n\nconst baseUrl = "http://${config.host}:${config.port}";\nlet gatewayToken;\n\nasync function token() {\n if (gatewayToken) return gatewayToken;\n const secrets = JSON.parse(await readFile(new URL("./secrets.json", import.meta.url), "utf8"));\n if (typeof secrets.gatewayToken !== "string" || secrets.gatewayToken.length < 32) {\n throw new Error("Witnora local Gateway token is missing or invalid.");\n }\n gatewayToken = secrets.gatewayToken;\n return gatewayToken;\n}\n\nasync function post(runId, operation, body) {\n if (!/^[A-Za-z0-9._:-]+$/.test(runId)) throw new Error("Witnora runId contains unsupported characters.");\n const response = await fetch(\`\${baseUrl}/v1/runs/\${encodeURIComponent(runId)}/\${operation}\`, {\n method: "POST",\n headers: { authorization: \`Bearer \${await token()}\`, "content-type": "application/json" },\n body: JSON.stringify(body),\n });\n const result = await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(result.error ?? \`Witnora Gateway returned HTTP \${response.status}.\`);\n return result;\n}\n\nexport const witnoraGateway = {\n start(runId, metadata = {}) {\n return post(runId, "start", { payload: metadata, idempotencyKey: "run-start" });\n },\n event(runId, type, metadata = {}, idempotencyKey = \`\${type}-\${crypto.randomUUID()}\`) {\n return post(runId, "events", { type, payload: metadata, idempotencyKey });\n },\n complete(runId, metadata = {}) {\n return post(runId, "complete", {\n payload: metadata,\n evidenceStrength: {\n schemaVersion: "agentcert.evidence_strength.v0.1",\n level: "recorded",\n claims: [],\n limitations: ["No write-credential mediation or independent outcome probe is configured."],\n },\n idempotencyKey: "run-complete",\n });\n },\n};\n`;
409
420
  }
410
421
  async function writeExclusive(path, content, force, mode) {
@@ -0,0 +1,2 @@
1
+ export declare function canonicalJson(value: unknown): string;
2
+ //# sourceMappingURL=canonical.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canonical.d.ts","sourceRoot":"","sources":["../../../../agentcert-sdk/src/canonical.ts"],"names":[],"mappings":"AAAA,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEpD"}
@@ -0,0 +1,19 @@
1
+ export function canonicalJson(value) {
2
+ return JSON.stringify(canonicalValue(value));
3
+ }
4
+ function canonicalValue(value) {
5
+ if (value === null || typeof value === "string" || typeof value === "boolean")
6
+ return value;
7
+ if (typeof value === "number") {
8
+ if (!Number.isFinite(value))
9
+ throw new Error("Canonical JSON does not support non-finite numbers.");
10
+ return Object.is(value, -0) ? 0 : value;
11
+ }
12
+ if (Array.isArray(value))
13
+ return value.map(canonicalValue);
14
+ if (value && typeof value === "object") {
15
+ const record = value;
16
+ return Object.fromEntries(Object.keys(record).sort().filter((key) => record[key] !== undefined).map((key) => [key, canonicalValue(record[key])]));
17
+ }
18
+ throw new Error(`Canonical JSON does not support ${typeof value}.`);
19
+ }
@@ -0,0 +1,55 @@
1
+ import { CustomerSourceKeyRing, type CustomerSourceSigner, type RemoteCollectorAck, type RemoteTrustedSourceRecord } from "./remote-collector.js";
2
+ export interface RemoteCollectorTransport {
3
+ registerSourceKey(input: {
4
+ collectorId: string;
5
+ keyId: string;
6
+ publicKeyPem: string;
7
+ previousKeyId?: string;
8
+ }): Promise<Record<string, unknown>>;
9
+ append(runId: string, records: RemoteTrustedSourceRecord[], idempotencyKey?: string): Promise<RemoteCollectorAck>;
10
+ heartbeat(input: {
11
+ collectorId: string;
12
+ signer: CustomerSourceSigner;
13
+ pendingRecordCount: number;
14
+ lastAckSequence?: number;
15
+ }): Promise<Record<string, unknown>>;
16
+ reconcile(runId: string, receipt: Record<string, unknown>): Promise<Record<string, unknown>>;
17
+ proposeAction(proposal: Record<string, unknown>, idempotencyKey: string): Promise<Record<string, unknown>>;
18
+ getAction(actionId: string): Promise<Record<string, unknown>>;
19
+ issueExecutionGrant(actionId: string, grant: Record<string, unknown>, idempotencyKey: string): Promise<Record<string, unknown>>;
20
+ }
21
+ export interface CustomerOwnedCollectorGatewayOptions {
22
+ client: RemoteCollectorTransport;
23
+ keyRing: CustomerSourceKeyRing;
24
+ gatewayToken: string;
25
+ storageDirectory: string;
26
+ collectorVersion?: string;
27
+ environment?: string;
28
+ host?: string;
29
+ port?: number;
30
+ flushIntervalMs?: number;
31
+ heartbeatIntervalMs?: number;
32
+ maxBodyBytes?: number;
33
+ }
34
+ export interface CustomerOwnedCollectorGateway {
35
+ baseUrl: string;
36
+ close(): Promise<void>;
37
+ flush(): Promise<{
38
+ delivered: number;
39
+ reconciled: number;
40
+ pending: number;
41
+ }>;
42
+ status(): Promise<CollectorGatewayStatus>;
43
+ }
44
+ export interface CollectorGatewayStatus {
45
+ schemaVersion: "agentcert.customer_collector_gateway_status.v0.2";
46
+ collectorId: string;
47
+ sourceKeyId: string;
48
+ runCount: number;
49
+ pendingRecordCount: number;
50
+ lastAckSequence?: number;
51
+ lastRemoteSuccessAt?: string;
52
+ lastRemoteError?: string;
53
+ }
54
+ export declare function startCustomerOwnedCollectorGateway(options: CustomerOwnedCollectorGatewayOptions): Promise<CustomerOwnedCollectorGateway>;
55
+ //# sourceMappingURL=collector-gateway.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"collector-gateway.d.ts","sourceRoot":"","sources":["../../../../agentcert-sdk/src/collector-gateway.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,386 @@
1
+ import { createHash, createPublicKey, sign, timingSafeEqual } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
5
+ import { canonicalJson } from "./canonical.js";
6
+ import { DurableRemoteCollectorQueue, RemoteCollectorApiError, } from "./remote-collector.js";
7
+ export async function startCustomerOwnedCollectorGateway(options) {
8
+ if (options.gatewayToken.length < 24)
9
+ throw new Error("gatewayToken must contain at least 24 characters.");
10
+ const storageDirectory = resolve(options.storageDirectory);
11
+ await mkdir(storageDirectory, { recursive: true });
12
+ const signer = options.keyRing.activeSigner();
13
+ let sourceKeyRegistered = false;
14
+ const journals = new Map();
15
+ const activeCollector = {
16
+ id: options.keyRing.collectorId,
17
+ version: options.collectorVersion ?? "0.2.0",
18
+ environment: options.environment ?? "customer-owned",
19
+ keyId: signer.keyId,
20
+ publicKeySha256: publicKeyFingerprint(signer.publicKeyPem),
21
+ };
22
+ let lastRemoteSuccessAt;
23
+ let lastRemoteError;
24
+ const ensureSourceKeyRegistered = async () => {
25
+ if (sourceKeyRegistered)
26
+ return;
27
+ await options.client.registerSourceKey(options.keyRing.registration());
28
+ sourceKeyRegistered = true;
29
+ };
30
+ const journalFor = async (runId) => {
31
+ identifier(runId, "runId");
32
+ const existing = journals.get(runId);
33
+ if (existing)
34
+ return existing;
35
+ const queue = new DurableRemoteCollectorQueue(storageDirectory, runId);
36
+ const first = (await queue.all())[0];
37
+ const runSigner = first ? options.keyRing.signerFor(first.collector.keyId) : signer;
38
+ const journal = await GatewayRunJournal.open(storageDirectory, runId, first?.collector ?? activeCollector, runSigner);
39
+ journals.set(runId, journal);
40
+ return journal;
41
+ };
42
+ const flush = async () => {
43
+ let delivered = 0;
44
+ let reconciled = 0;
45
+ let pending = 0;
46
+ try {
47
+ await ensureSourceKeyRegistered();
48
+ }
49
+ catch (error) {
50
+ lastRemoteError = message(error);
51
+ }
52
+ for (const journal of journals.values()) {
53
+ try {
54
+ if (!sourceKeyRegistered)
55
+ throw new Error(lastRemoteError ?? "Collector source key is not registered.");
56
+ delivered += (await journal.queue.replay(options.client)).delivered;
57
+ if (await journal.reconcileIfReady(options.client))
58
+ reconciled += 1;
59
+ lastRemoteSuccessAt = new Date().toISOString();
60
+ lastRemoteError = undefined;
61
+ }
62
+ catch (error) {
63
+ lastRemoteError = message(error);
64
+ }
65
+ pending += (await journal.queue.pending()).length;
66
+ }
67
+ return { delivered, reconciled, pending };
68
+ };
69
+ const status = async () => {
70
+ let pendingRecordCount = 0;
71
+ let lastAckSequence;
72
+ for (const journal of journals.values()) {
73
+ pendingRecordCount += (await journal.queue.pending()).length;
74
+ const ack = await journal.queue.currentAck();
75
+ if (ack.sequence >= 0)
76
+ lastAckSequence = Math.max(lastAckSequence ?? -1, ack.sequence);
77
+ }
78
+ return {
79
+ schemaVersion: "agentcert.customer_collector_gateway_status.v0.2",
80
+ collectorId: activeCollector.id,
81
+ sourceKeyId: activeCollector.keyId,
82
+ runCount: journals.size,
83
+ pendingRecordCount,
84
+ lastAckSequence,
85
+ lastRemoteSuccessAt,
86
+ lastRemoteError,
87
+ };
88
+ };
89
+ const server = createServer(async (request, response) => {
90
+ try {
91
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
92
+ if (request.method === "GET" && url.pathname === "/healthz")
93
+ return json(response, 200, await status());
94
+ authenticate(request, options.gatewayToken);
95
+ if (request.method === "POST" && url.pathname === "/v1/flush")
96
+ return json(response, 200, await flush());
97
+ if (request.method === "POST" && url.pathname === "/v1/actions") {
98
+ const body = await readJson(request, options.maxBodyBytes ?? 1_048_576);
99
+ return json(response, 202, await options.client.proposeAction(object(body.proposal), identifier(body.idempotencyKey, "idempotencyKey")));
100
+ }
101
+ const actionRoute = url.pathname.match(/^\/v1\/actions\/([A-Za-z0-9._:-]+)$/);
102
+ if (request.method === "GET" && actionRoute) {
103
+ return json(response, 200, await options.client.getAction(identifier(actionRoute[1], "actionId")));
104
+ }
105
+ const grantRoute = url.pathname.match(/^\/v1\/actions\/([A-Za-z0-9._:-]+)\/execution-grant$/);
106
+ if (request.method === "POST" && grantRoute) {
107
+ const actionId = identifier(grantRoute[1], "actionId");
108
+ const action = await options.client.getAction(actionId);
109
+ if (action.status !== "APPROVED" && action.status !== "ALLOWED") {
110
+ throw new GatewayRequestError(409, `Action ${actionId} is not approved for execution.`);
111
+ }
112
+ const body = await readJson(request, options.maxBodyBytes ?? 1_048_576);
113
+ return json(response, 201, await options.client.issueExecutionGrant(actionId, object(body.grant), identifier(body.idempotencyKey, "idempotencyKey")));
114
+ }
115
+ const route = url.pathname.match(/^\/v1\/runs\/([A-Za-z0-9._:-]+)\/(start|events|drops|complete)$/);
116
+ if (!route)
117
+ return json(response, 404, { error: "not found" });
118
+ const runId = route[1];
119
+ const operation = route[2];
120
+ const body = await readJson(request, options.maxBodyBytes ?? 1_048_576);
121
+ const journal = await journalFor(runId);
122
+ let record;
123
+ if (operation === "start") {
124
+ record = await journal.append("RUN_STARTED", object(body.payload), String(body.idempotencyKey ?? "run-start"));
125
+ }
126
+ else if (operation === "events") {
127
+ record = await journal.append(identifier(body.type, "type"), object(body.payload), identifier(body.idempotencyKey, "idempotencyKey"));
128
+ }
129
+ else if (operation === "drops") {
130
+ const count = positiveInteger(body.count, "count");
131
+ record = await journal.append("EVENTS_DROPPED", { count, reason: required(body.reason, "reason") }, identifier(body.idempotencyKey, "idempotencyKey"), count);
132
+ }
133
+ else {
134
+ const input = body;
135
+ record = await journal.append("RUN_COMPLETED", object(input.payload), String(input.idempotencyKey ?? "run-complete"));
136
+ await journal.writeReceipt({
137
+ mandateDigests: strings(input.mandateDigests),
138
+ actionIds: strings(input.actionIds),
139
+ evidenceStrength: object(input.evidenceStrength),
140
+ });
141
+ }
142
+ const remote = await flush();
143
+ return json(response, 202, { durable: true, record, remote });
144
+ }
145
+ catch (error) {
146
+ if (error instanceof RemoteCollectorApiError) {
147
+ const status = Number.isInteger(error.status) && error.status >= 400 && error.status <= 599 ? error.status : 502;
148
+ return json(response, status, status === error.status ? {
149
+ error: error.message,
150
+ code: error.code,
151
+ ...(error.recovery ? { recovery: error.recovery } : {}),
152
+ } : { error: "Hosted API returned an invalid error response.", code: "remote_collector_invalid_status" });
153
+ }
154
+ const statusCode = error instanceof GatewayRequestError ? error.status : 500;
155
+ return json(response, statusCode, { error: message(error) });
156
+ }
157
+ });
158
+ await listen(server, options.port ?? 0, options.host ?? "127.0.0.1");
159
+ const address = server.address();
160
+ if (!address || typeof address === "string")
161
+ throw new Error("Collector gateway did not bind a TCP port.");
162
+ const flushTimer = setInterval(() => void flush(), options.flushIntervalMs ?? 5_000);
163
+ const heartbeatTimer = setInterval(async () => {
164
+ const current = await status();
165
+ try {
166
+ await ensureSourceKeyRegistered();
167
+ await options.client.heartbeat({
168
+ collectorId: activeCollector.id,
169
+ signer,
170
+ pendingRecordCount: current.pendingRecordCount,
171
+ lastAckSequence: current.lastAckSequence,
172
+ });
173
+ lastRemoteSuccessAt = new Date().toISOString();
174
+ lastRemoteError = undefined;
175
+ }
176
+ catch (error) {
177
+ lastRemoteError = message(error);
178
+ }
179
+ }, options.heartbeatIntervalMs ?? 30_000);
180
+ flushTimer.unref();
181
+ heartbeatTimer.unref();
182
+ for (const runId of await discoverRunIds(storageDirectory))
183
+ await journalFor(runId);
184
+ await flush();
185
+ return {
186
+ baseUrl: `http://${options.host ?? "127.0.0.1"}:${address.port}`,
187
+ close: async () => {
188
+ clearInterval(flushTimer);
189
+ clearInterval(heartbeatTimer);
190
+ await flush();
191
+ await close(server);
192
+ },
193
+ flush,
194
+ status,
195
+ };
196
+ }
197
+ class GatewayRunJournal {
198
+ runId;
199
+ collector;
200
+ signer;
201
+ queue;
202
+ records;
203
+ chain = Promise.resolve();
204
+ receiptPath;
205
+ reconciliationPath;
206
+ constructor(directory, runId, collector, signer, records) {
207
+ this.runId = runId;
208
+ this.collector = collector;
209
+ this.signer = signer;
210
+ this.queue = new DurableRemoteCollectorQueue(directory, runId);
211
+ this.records = records;
212
+ const base = safeFileName(runId);
213
+ this.receiptPath = join(resolve(directory), `${base}.receipt.json`);
214
+ this.reconciliationPath = join(resolve(directory), `${base}.reconciled.json`);
215
+ }
216
+ static async open(directory, runId, collector, signer) {
217
+ const queue = new DurableRemoteCollectorQueue(directory, runId);
218
+ const records = await queue.all();
219
+ for (let index = 0; index < records.length; index += 1) {
220
+ const record = records[index];
221
+ if (record.runId !== runId || canonicalJson(record.collector) !== canonicalJson(collector))
222
+ throw new Error(`Stored gateway journal for ${runId} has a different collector identity.`);
223
+ if (index > 0 && record.previousEventHash !== records[index - 1].eventHash)
224
+ throw new Error(`Stored gateway journal for ${runId} has a broken hash chain.`);
225
+ }
226
+ return new GatewayRunJournal(directory, runId, collector, signer, records);
227
+ }
228
+ async append(type, payload, idempotencyKey, skippedSequences = 0) {
229
+ identifier(idempotencyKey, "idempotencyKey");
230
+ let result;
231
+ const operation = this.chain.then(async () => {
232
+ const recordId = sha256(`${this.runId}:${idempotencyKey}`);
233
+ const replay = this.records.find((record) => record.recordId === recordId);
234
+ if (replay) {
235
+ if (replay.type !== type || canonicalJson(replay.payload) !== canonicalJson(payload))
236
+ throw new GatewayRequestError(409, `idempotencyKey ${idempotencyKey} was already used with different content.`);
237
+ result = replay;
238
+ return;
239
+ }
240
+ if (this.records.at(-1)?.type === "RUN_COMPLETED")
241
+ throw new GatewayRequestError(409, `Run ${this.runId} is already complete.`);
242
+ if (this.records.length === 0 && type !== "RUN_STARTED")
243
+ throw new GatewayRequestError(409, `Run ${this.runId} must start before appending records.`);
244
+ const previous = this.records.at(-1);
245
+ const occurredAt = new Date().toISOString();
246
+ const payloadSha256 = sha256(canonicalJson(payload));
247
+ const unsigned = {
248
+ schemaVersion: "agentcert.trusted_action_record.v0.1",
249
+ recordId,
250
+ runId: this.runId,
251
+ sequence: (previous?.sequence ?? -1) + 1 + skippedSequences,
252
+ occurredAt,
253
+ type,
254
+ collector: this.collector,
255
+ previousEventHash: previous?.eventHash,
256
+ payload,
257
+ payloadSha256,
258
+ };
259
+ const eventHash = sha256(canonicalJson(unsigned));
260
+ result = { ...unsigned, eventHash, sourceSignature: signDigest(eventHash, this.signer) };
261
+ await this.queue.enqueue(result);
262
+ this.records.push(result);
263
+ });
264
+ this.chain = operation.catch(() => undefined);
265
+ await operation;
266
+ return structuredClone(result);
267
+ }
268
+ async writeReceipt(input) {
269
+ const first = this.records[0];
270
+ const last = this.records.at(-1);
271
+ if (!first || last?.type !== "RUN_COMPLETED")
272
+ throw new Error("A completed run is required before writing a receipt.");
273
+ const droppedEventCount = this.records.filter((record) => record.type === "EVENTS_DROPPED")
274
+ .reduce((total, record) => total + Number(record.payload.count ?? 0), 0);
275
+ const payload = {
276
+ schemaVersion: "agentcert.trusted_run_receipt.v0.1",
277
+ runId: this.runId,
278
+ collector: this.collector,
279
+ startedAt: first.occurredAt,
280
+ completedAt: last.occurredAt,
281
+ eventCount: this.records.length,
282
+ droppedEventCount,
283
+ firstEventHash: first.eventHash,
284
+ lastEventHash: last.eventHash,
285
+ mandateDigests: [...new Set(input.mandateDigests)].sort(),
286
+ actionIds: [...new Set(input.actionIds)].sort(),
287
+ journal: { valid: true, complete: true, sourceSigned: true, gaps: [], duplicateSequences: [], duplicateRecordIds: [], hashMismatches: [], signatureFailures: [], droppedEventCount, recoveredTailBytes: 0, errors: [] },
288
+ evidenceStrength: input.evidenceStrength,
289
+ sourcePublicKeyPem: this.signer.publicKeyPem,
290
+ };
291
+ const receiptSha256 = sha256(canonicalJson(payload));
292
+ await writeFile(this.receiptPath, `${JSON.stringify({ ...payload, receiptSha256, sourceSignature: signDigest(receiptSha256, this.signer) }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
293
+ }
294
+ async reconcileIfReady(client) {
295
+ if ((await this.queue.pending()).length > 0)
296
+ return false;
297
+ try {
298
+ await readFile(this.reconciliationPath, "utf8");
299
+ return false;
300
+ }
301
+ catch (error) {
302
+ if (error.code !== "ENOENT")
303
+ throw error;
304
+ }
305
+ let receipt;
306
+ try {
307
+ receipt = JSON.parse(await readFile(this.receiptPath, "utf8"));
308
+ }
309
+ catch (error) {
310
+ if (error.code === "ENOENT")
311
+ return false;
312
+ throw error;
313
+ }
314
+ const reconciliation = await client.reconcile(this.runId, receipt);
315
+ await writeFile(this.reconciliationPath, `${JSON.stringify(reconciliation)}\n`, { encoding: "utf8", mode: 0o600 });
316
+ return true;
317
+ }
318
+ }
319
+ class GatewayRequestError extends Error {
320
+ status;
321
+ constructor(status, messageText) {
322
+ super(messageText);
323
+ this.status = status;
324
+ }
325
+ }
326
+ function authenticate(request, expected) {
327
+ const actual = request.headers.authorization?.replace(/^Bearer\s+/i, "") ?? "";
328
+ const left = Buffer.from(actual);
329
+ const right = Buffer.from(expected);
330
+ if (left.length !== right.length || !timingSafeEqual(left, right))
331
+ throw new GatewayRequestError(401, "Gateway authentication failed.");
332
+ }
333
+ async function readJson(request, maxBytes) {
334
+ const chunks = [];
335
+ let size = 0;
336
+ for await (const chunk of request) {
337
+ const value = Buffer.from(chunk);
338
+ size += value.length;
339
+ if (size > maxBytes)
340
+ throw new GatewayRequestError(413, `Request body exceeds ${maxBytes} bytes.`);
341
+ chunks.push(value);
342
+ }
343
+ try {
344
+ return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
345
+ }
346
+ catch {
347
+ throw new GatewayRequestError(400, "Request body must be valid JSON.");
348
+ }
349
+ }
350
+ function listen(server, port, host) {
351
+ return new Promise((resolvePromise, reject) => server.once("error", reject).listen(port, host, resolvePromise));
352
+ }
353
+ function close(server) { return new Promise((resolvePromise, reject) => server.close((error) => error ? reject(error) : resolvePromise())); }
354
+ function json(response, status, body) { response.statusCode = status; response.setHeader("content-type", "application/json"); response.end(JSON.stringify(body)); }
355
+ function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
356
+ function publicKeyFingerprint(publicKeyPem) { return sha256(createPublicKey(publicKeyPem).export({ type: "spki", format: "der" })); }
357
+ function signDigest(digest, signer) {
358
+ return { algorithm: "Ed25519", keyId: signer.keyId, signature: sign(null, Buffer.from(digest, "hex"), signer.privateKeyPem).toString("base64url") };
359
+ }
360
+ function identifier(value, field) { const parsed = required(value, field); if (parsed.length > 160 || !/^[A-Za-z0-9._:-]+$/.test(parsed))
361
+ throw new GatewayRequestError(400, `${field} must use URL-safe identifier characters.`); return parsed; }
362
+ function required(value, field) { if (typeof value !== "string" || !value.trim())
363
+ throw new GatewayRequestError(400, `${field} is required.`); return value; }
364
+ function object(value) { if (value === undefined)
365
+ return {}; if (!value || typeof value !== "object" || Array.isArray(value))
366
+ throw new GatewayRequestError(400, "Expected a JSON object."); return value; }
367
+ function strings(value) { if (value === undefined)
368
+ return []; if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
369
+ throw new GatewayRequestError(400, "Expected an array of strings."); return value; }
370
+ function positiveInteger(value, field) { if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
371
+ throw new GatewayRequestError(400, `${field} must be a positive integer.`); return value; }
372
+ function safeFileName(value) { return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 160); }
373
+ function message(error) { return error instanceof Error ? error.message : String(error); }
374
+ async function discoverRunIds(directory) {
375
+ const runIds = new Set();
376
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
377
+ if (!entry.isFile() || !entry.name.endsWith(".remote.jsonl"))
378
+ continue;
379
+ const firstLine = (await readFile(join(directory, entry.name), "utf8")).split(/\r?\n/, 1)[0];
380
+ if (!firstLine)
381
+ continue;
382
+ const record = JSON.parse(firstLine);
383
+ runIds.add(identifier(record.runId, "stored runId"));
384
+ }
385
+ return [...runIds];
386
+ }
@@ -0,0 +1,132 @@
1
+ export interface CustomerSourceSigner {
2
+ keyId: string;
3
+ privateKeyPem: string;
4
+ publicKeyPem: string;
5
+ }
6
+ export interface RemoteTrustedSourceRecord {
7
+ schemaVersion: "agentcert.trusted_action_record.v0.1";
8
+ recordId: string;
9
+ runId: string;
10
+ sequence: number;
11
+ occurredAt: string;
12
+ type: string;
13
+ collector: {
14
+ id: string;
15
+ version: string;
16
+ environment: string;
17
+ keyId: string;
18
+ publicKeySha256: string;
19
+ };
20
+ previousEventHash?: string;
21
+ payload: Record<string, unknown>;
22
+ payloadSha256: string;
23
+ eventHash: string;
24
+ sourceSignature: {
25
+ algorithm: "Ed25519";
26
+ keyId: string;
27
+ signature: string;
28
+ };
29
+ }
30
+ export interface RemoteCollectorAck {
31
+ schemaVersion: "agentcert.remote_collector_ack.v0.2";
32
+ accepted: number;
33
+ replayed: number;
34
+ ack: {
35
+ sequence: number;
36
+ eventHash: string;
37
+ };
38
+ alerts: Array<Record<string, unknown>>;
39
+ run: Record<string, unknown>;
40
+ }
41
+ export interface RemoteCollectorClientOptions {
42
+ baseUrl: string;
43
+ projectId: string;
44
+ apiKey: string;
45
+ fetch?: typeof fetch;
46
+ }
47
+ export declare class CustomerSourceKeyRing {
48
+ readonly filePath: string;
49
+ private value;
50
+ private constructor();
51
+ static create(filePath: string, collectorId: string, keyId?: string): Promise<CustomerSourceKeyRing>;
52
+ static open(filePath: string): Promise<CustomerSourceKeyRing>;
53
+ get collectorId(): string;
54
+ activeSigner(): CustomerSourceSigner;
55
+ signerFor(keyId: string): CustomerSourceSigner;
56
+ registration(previousKeyId?: string): {
57
+ collectorId: string;
58
+ keyId: string;
59
+ publicKeyPem: string;
60
+ previousKeyId?: string;
61
+ };
62
+ rotate(keyId?: string): Promise<{
63
+ previousKeyId: string;
64
+ signer: CustomerSourceSigner;
65
+ }>;
66
+ private persist;
67
+ }
68
+ export declare class RemoteCollectorClient {
69
+ readonly baseUrl: string;
70
+ readonly projectId: string;
71
+ private readonly apiKey;
72
+ private readonly requestFetch;
73
+ constructor(options: RemoteCollectorClientOptions);
74
+ registerSourceKey(input: {
75
+ collectorId: string;
76
+ keyId: string;
77
+ publicKeyPem: string;
78
+ previousKeyId?: string;
79
+ }): Promise<Record<string, unknown>>;
80
+ append(runId: string, records: RemoteTrustedSourceRecord[], idempotencyKey?: string): Promise<RemoteCollectorAck>;
81
+ heartbeat(input: {
82
+ collectorId: string;
83
+ signer: CustomerSourceSigner;
84
+ runId?: string;
85
+ pendingRecordCount: number;
86
+ lastAckSequence?: number;
87
+ occurredAt?: string;
88
+ }): Promise<Record<string, unknown>>;
89
+ reconcile(runId: string, receipt: Record<string, unknown>): Promise<Record<string, unknown>>;
90
+ proposeAction(proposal: Record<string, unknown>, idempotencyKey: string): Promise<Record<string, unknown>>;
91
+ getAction(actionId: string): Promise<Record<string, unknown>>;
92
+ issueExecutionGrant(actionId: string, grant: Record<string, unknown>, idempotencyKey: string): Promise<Record<string, unknown>>;
93
+ status(): Promise<Record<string, unknown>>;
94
+ revokeSourceKey(keyId: string): Promise<Record<string, unknown>>;
95
+ sink(): {
96
+ name: string;
97
+ write(record: RemoteTrustedSourceRecord): Promise<void>;
98
+ };
99
+ private json;
100
+ }
101
+ export declare class DurableRemoteCollectorQueue {
102
+ readonly runId: string;
103
+ readonly journalPath: string;
104
+ readonly ackPath: string;
105
+ private replayChain;
106
+ constructor(directory: string, runId: string);
107
+ enqueue(record: RemoteTrustedSourceRecord): Promise<void>;
108
+ pending(): Promise<RemoteTrustedSourceRecord[]>;
109
+ all(): Promise<RemoteTrustedSourceRecord[]>;
110
+ currentAck(): Promise<{
111
+ sequence: number;
112
+ eventHash?: string;
113
+ }>;
114
+ replay(client: {
115
+ append(runId: string, records: RemoteTrustedSourceRecord[], idempotencyKey?: string): Promise<RemoteCollectorAck>;
116
+ }): Promise<{
117
+ delivered: number;
118
+ ack?: {
119
+ sequence: number;
120
+ eventHash: string;
121
+ };
122
+ }>;
123
+ private readAck;
124
+ private writeAck;
125
+ }
126
+ export declare class RemoteCollectorApiError extends Error {
127
+ readonly status: number;
128
+ readonly code: string;
129
+ readonly recovery?: string | undefined;
130
+ constructor(status: number, code: string, message: string, recovery?: string | undefined);
131
+ }
132
+ //# sourceMappingURL=remote-collector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-collector.d.ts","sourceRoot":"","sources":["../../../../agentcert-sdk/src/remote-collector.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,318 @@
1
+ import { chmod, mkdir, open, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
4
+ import { canonicalJson } from "./canonical.js";
5
+ export class CustomerSourceKeyRing {
6
+ filePath;
7
+ value;
8
+ constructor(filePath, value) {
9
+ this.filePath = filePath;
10
+ this.value = value;
11
+ }
12
+ static async create(filePath, collectorId, keyId = `${collectorId}-${new Date().toISOString().slice(0, 10)}-${randomUUID().slice(0, 8)}`) {
13
+ identifier(collectorId, "collectorId");
14
+ identifier(keyId, "keyId");
15
+ const target = resolve(filePath);
16
+ try {
17
+ await readFile(target);
18
+ throw new Error(`Customer source key ring already exists at ${target}.`);
19
+ }
20
+ catch (error) {
21
+ if (error.code !== "ENOENT")
22
+ throw error;
23
+ }
24
+ const key = generatedKey(keyId, new Date().toISOString());
25
+ const ring = new CustomerSourceKeyRing(target, { schemaVersion: "agentcert.customer_source_keyring.v0.2", collectorId, activeKeyId: keyId, keys: [key] });
26
+ await ring.persist();
27
+ return ring;
28
+ }
29
+ static async open(filePath) {
30
+ const target = resolve(filePath);
31
+ const parsed = JSON.parse(await readFile(target, "utf8"));
32
+ validateRing(parsed);
33
+ return new CustomerSourceKeyRing(target, parsed);
34
+ }
35
+ get collectorId() { return this.value.collectorId; }
36
+ activeSigner() {
37
+ const key = this.value.keys.find((item) => item.keyId === this.value.activeKeyId && item.status === "active");
38
+ if (!key)
39
+ throw new Error("Customer source key ring has no active signing key.");
40
+ return { keyId: key.keyId, privateKeyPem: key.privateKeyPem, publicKeyPem: key.publicKeyPem };
41
+ }
42
+ signerFor(keyId) {
43
+ const key = this.value.keys.find((item) => item.keyId === keyId);
44
+ if (!key)
45
+ throw new Error(`Customer source key ${keyId} is not present in this key ring.`);
46
+ return { keyId: key.keyId, privateKeyPem: key.privateKeyPem, publicKeyPem: key.publicKeyPem };
47
+ }
48
+ registration(previousKeyId) {
49
+ const signer = this.activeSigner();
50
+ const previous = previousKeyId ?? [...this.value.keys].reverse().find((item) => item.status === "retired")?.keyId;
51
+ return { collectorId: this.collectorId, keyId: signer.keyId, publicKeyPem: signer.publicKeyPem, previousKeyId: previous };
52
+ }
53
+ async rotate(keyId = `${this.collectorId}-${new Date().toISOString().slice(0, 10)}-${randomUUID().slice(0, 8)}`) {
54
+ identifier(keyId, "keyId");
55
+ if (this.value.keys.some((item) => item.keyId === keyId))
56
+ throw new Error(`Customer source key ${keyId} already exists.`);
57
+ const now = new Date().toISOString();
58
+ const previousKeyId = this.value.activeKeyId;
59
+ this.value = {
60
+ ...this.value,
61
+ activeKeyId: keyId,
62
+ keys: [...this.value.keys.map((item) => item.keyId === previousKeyId ? { ...item, status: "retired", retiredAt: now } : item), generatedKey(keyId, now)],
63
+ };
64
+ await this.persist();
65
+ return { previousKeyId, signer: this.activeSigner() };
66
+ }
67
+ async persist() {
68
+ await mkdir(dirname(this.filePath), { recursive: true });
69
+ const temporary = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
70
+ await writeFile(temporary, `${JSON.stringify(this.value, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
71
+ await chmod(temporary, 0o600);
72
+ await rename(temporary, this.filePath);
73
+ }
74
+ }
75
+ export class RemoteCollectorClient {
76
+ baseUrl;
77
+ projectId;
78
+ apiKey;
79
+ requestFetch;
80
+ constructor(options) {
81
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
82
+ this.projectId = options.projectId;
83
+ this.apiKey = options.apiKey;
84
+ this.requestFetch = options.fetch ?? fetch;
85
+ if (!this.baseUrl || !this.projectId || !this.apiKey)
86
+ throw new Error("baseUrl, projectId, and apiKey are required.");
87
+ }
88
+ registerSourceKey(input) {
89
+ return this.json("collector-keys", { method: "POST", body: JSON.stringify(input) });
90
+ }
91
+ append(runId, records, idempotencyKey = `record-${records[0]?.eventHash ?? randomUUID()}`) {
92
+ return this.json(`trusted-runs/${encodeURIComponent(runId)}/records`, {
93
+ method: "POST",
94
+ headers: { "idempotency-key": idempotencyKey },
95
+ body: JSON.stringify({ records }),
96
+ });
97
+ }
98
+ heartbeat(input) {
99
+ const payload = {
100
+ schemaVersion: "agentcert.collector_heartbeat.v0.2",
101
+ collectorId: input.collectorId,
102
+ sourceKeyId: input.signer.keyId,
103
+ runId: input.runId,
104
+ occurredAt: input.occurredAt ?? new Date().toISOString(),
105
+ pendingRecordCount: input.pendingRecordCount,
106
+ lastAckSequence: input.lastAckSequence,
107
+ };
108
+ const payloadSha256 = sha256(canonicalJson(payload));
109
+ return this.json("collector-heartbeats", {
110
+ method: "POST",
111
+ body: JSON.stringify({ payload, payloadSha256, signature: signSourceDigest(payloadSha256, input.signer) }),
112
+ });
113
+ }
114
+ reconcile(runId, receipt) {
115
+ return this.json(`trusted-runs/${encodeURIComponent(runId)}/reconcile`, {
116
+ method: "POST",
117
+ headers: { "idempotency-key": `reconcile-${String(receipt.receiptSha256 ?? randomUUID())}` },
118
+ body: JSON.stringify(receipt),
119
+ });
120
+ }
121
+ proposeAction(proposal, idempotencyKey) {
122
+ return this.json("actions", {
123
+ method: "POST",
124
+ headers: { "idempotency-key": idempotencyKey },
125
+ body: JSON.stringify(proposal),
126
+ });
127
+ }
128
+ getAction(actionId) {
129
+ return this.json(`actions/${encodeURIComponent(actionId)}`);
130
+ }
131
+ issueExecutionGrant(actionId, grant, idempotencyKey) {
132
+ return this.json(`actions/${encodeURIComponent(actionId)}/execution-grant`, {
133
+ method: "POST",
134
+ headers: { "idempotency-key": idempotencyKey },
135
+ body: JSON.stringify(grant),
136
+ });
137
+ }
138
+ status() { return this.json("collector-status"); }
139
+ async revokeSourceKey(keyId) {
140
+ return this.json(`collector-keys/${encodeURIComponent(keyId)}`, { method: "DELETE" });
141
+ }
142
+ sink() {
143
+ return {
144
+ name: "agentcert-remote-collector-v0.2",
145
+ write: async (record) => { await this.append(record.runId, [record], `record-${record.eventHash}`); },
146
+ };
147
+ }
148
+ async json(suffix, init = {}) {
149
+ const response = await this.requestFetch(`${this.baseUrl}/v1/projects/${encodeURIComponent(this.projectId)}/${suffix}`, {
150
+ ...init,
151
+ headers: { authorization: `Bearer ${this.apiKey}`, ...(init.body ? { "content-type": "application/json" } : {}), ...init.headers },
152
+ });
153
+ const body = await response.json().catch(() => ({}));
154
+ if (!response.ok)
155
+ throw new RemoteCollectorApiError(response.status, String(body.code ?? "remote_collector_error"), String(body.error ?? `Witnora API request failed (${response.status}).`), typeof body.recovery === "string" ? body.recovery : undefined);
156
+ return body;
157
+ }
158
+ }
159
+ export class DurableRemoteCollectorQueue {
160
+ runId;
161
+ journalPath;
162
+ ackPath;
163
+ replayChain = Promise.resolve();
164
+ constructor(directory, runId) {
165
+ this.runId = runId;
166
+ this.journalPath = resolve(directory, `${safeFileName(runId)}.remote.jsonl`);
167
+ this.ackPath = resolve(directory, `${safeFileName(runId)}.remote-ack.json`);
168
+ }
169
+ async enqueue(record) {
170
+ if (record.runId !== this.runId)
171
+ throw new Error("Queued record runId does not match the durable queue runId.");
172
+ await mkdir(dirname(this.journalPath), { recursive: true });
173
+ const handle = await open(this.journalPath, "a");
174
+ try {
175
+ await handle.write(`${JSON.stringify(record)}\n`);
176
+ await handle.sync();
177
+ }
178
+ finally {
179
+ await handle.close();
180
+ }
181
+ }
182
+ async pending() {
183
+ const records = await this.all();
184
+ const ack = await this.readAck();
185
+ validateAck(records, ack);
186
+ return records.filter((record) => record.sequence > ack.sequence);
187
+ }
188
+ async all() {
189
+ let raw = "";
190
+ try {
191
+ raw = await readFile(this.journalPath, "utf8");
192
+ }
193
+ catch (error) {
194
+ if (error.code === "ENOENT")
195
+ return [];
196
+ throw error;
197
+ }
198
+ return raw.split(/\r?\n/).filter(Boolean).map((line, index) => {
199
+ try {
200
+ return JSON.parse(line);
201
+ }
202
+ catch {
203
+ throw new Error(`Remote collector queue contains invalid JSON at line ${index + 1}.`);
204
+ }
205
+ });
206
+ }
207
+ async currentAck() {
208
+ const records = await this.all();
209
+ const ack = await this.readAck();
210
+ validateAck(records, ack);
211
+ return ack;
212
+ }
213
+ async replay(client) {
214
+ let result;
215
+ const operation = this.replayChain.then(async () => {
216
+ let delivered = 0;
217
+ let latest;
218
+ for (const record of await this.pending()) {
219
+ const response = await client.append(record.runId, [record], `record-${record.eventHash}`);
220
+ latest = response.ack;
221
+ await this.writeAck(latest);
222
+ delivered += 1;
223
+ }
224
+ result = { delivered, ack: latest };
225
+ });
226
+ this.replayChain = operation.catch(() => undefined);
227
+ await operation;
228
+ return result;
229
+ }
230
+ async readAck() {
231
+ try {
232
+ const value = JSON.parse(await readFile(this.ackPath, "utf8"));
233
+ if (!Number.isSafeInteger(value.sequence) || Number(value.sequence) < 0 || typeof value.eventHash !== "string" || !/^[a-f0-9]{64}$/.test(value.eventHash)) {
234
+ throw new Error("Remote collector ACK file is invalid.");
235
+ }
236
+ return { sequence: Number(value.sequence), eventHash: value.eventHash };
237
+ }
238
+ catch (error) {
239
+ if (error.code === "ENOENT")
240
+ return { sequence: -1 };
241
+ throw error;
242
+ }
243
+ }
244
+ async writeAck(ack) {
245
+ const records = await this.all();
246
+ validateAck(records, ack);
247
+ const current = await this.readAck();
248
+ if (current.sequence > ack.sequence)
249
+ return;
250
+ if (current.sequence === ack.sequence) {
251
+ if (current.eventHash !== ack.eventHash)
252
+ throw new Error(`Remote collector ACK hash conflicts at sequence ${ack.sequence}.`);
253
+ return;
254
+ }
255
+ await mkdir(dirname(this.ackPath), { recursive: true });
256
+ const temporary = `${this.ackPath}.${process.pid}.${randomUUID()}.tmp`;
257
+ await writeFile(temporary, JSON.stringify(ack), { encoding: "utf8", mode: 0o600 });
258
+ await rename(temporary, this.ackPath);
259
+ }
260
+ }
261
+ function validateAck(records, ack) {
262
+ if (ack.sequence === -1)
263
+ return;
264
+ const acknowledged = records.find((record) => record.sequence === ack.sequence);
265
+ if (!acknowledged || acknowledged.eventHash !== ack.eventHash) {
266
+ throw new Error(`Remote collector ACK does not match the local journal at sequence ${ack.sequence}.`);
267
+ }
268
+ }
269
+ export class RemoteCollectorApiError extends Error {
270
+ status;
271
+ code;
272
+ recovery;
273
+ constructor(status, code, message, recovery) {
274
+ super(message);
275
+ this.status = status;
276
+ this.code = code;
277
+ this.recovery = recovery;
278
+ this.name = "RemoteCollectorApiError";
279
+ }
280
+ }
281
+ function generatedKey(keyId, createdAt) {
282
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
283
+ return {
284
+ keyId,
285
+ privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
286
+ publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
287
+ createdAt,
288
+ status: "active",
289
+ };
290
+ }
291
+ function signSourceDigest(digest, signer) {
292
+ if (!/^[a-f0-9]{64}$/.test(digest))
293
+ throw new Error("A SHA-256 digest is required for source signing.");
294
+ return { algorithm: "Ed25519", keyId: signer.keyId, signature: sign(null, Buffer.from(digest, "hex"), createPrivateKey(signer.privateKeyPem)).toString("base64url") };
295
+ }
296
+ function validateRing(value) {
297
+ if (value.schemaVersion !== "agentcert.customer_source_keyring.v0.2")
298
+ throw new Error("Customer source key ring schemaVersion is not supported.");
299
+ identifier(value.collectorId, "collectorId");
300
+ identifier(value.activeKeyId, "activeKeyId");
301
+ if (!Array.isArray(value.keys) || value.keys.length === 0)
302
+ throw new Error("Customer source key ring has no keys.");
303
+ for (const key of value.keys) {
304
+ identifier(key.keyId, "keys[].keyId");
305
+ const privateKey = createPrivateKey(key.privateKeyPem);
306
+ const derivedPublic = createPublicKey(privateKey).export({ type: "spki", format: "pem" }).toString();
307
+ if (privateKey.asymmetricKeyType !== "ed25519" || derivedPublic !== createPublicKey(key.publicKeyPem).export({ type: "spki", format: "pem" }).toString()) {
308
+ throw new Error(`Customer source key ${key.keyId} is invalid.`);
309
+ }
310
+ }
311
+ }
312
+ function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
313
+ function identifier(value, field) {
314
+ if (!value || value.length > 160 || !/^[A-Za-z0-9._:-]+$/.test(value))
315
+ throw new Error(`${field} must use 1 to 160 URL-safe identifier characters.`);
316
+ return value;
317
+ }
318
+ function safeFileName(value) { return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 160); }
@@ -131,7 +131,7 @@ export function starterInstructions(template, subject) {
131
131
  return `
132
132
  Next:
133
133
  1. Edit tripwire.yml so startUrl and agent.command/agent.args match your app and browser agent.
134
- 2. Run in CI with Kakarottoooo/agentcert/actions/tripwire@v0, or re-run init with --github-action.
134
+ 2. Run in CI with Kakarottoooo/witnora/actions/tripwire@v0, or re-run init with --github-action.
135
135
  3. Run: npx witnora@latest run --tripwire .tripwire/latest/tripwire-result.json --subject ${JSON.stringify(subject)} --fail-on-verdict --push
136
136
  `;
137
137
  if (template === "mcp")
@@ -203,7 +203,7 @@ jobs:
203
203
  with:
204
204
  node-version: "20"
205
205
  - id: agentcert
206
- uses: Kakarottoooo/agentcert/actions/tripwire@v0
206
+ uses: Kakarottoooo/witnora/actions/tripwire@v0
207
207
  with:
208
208
  config: tripwire.yml
209
209
  out: .tripwire/latest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "witnora",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
4
4
  "description": "Independent assurance for covered agent action paths across models and frameworks.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -41,8 +41,8 @@
41
41
  "import": "./dist/vendor/onegent-runtime/browser-adapter-kit.js"
42
42
  },
43
43
  "./deployment-enforcement": {
44
- "types": "./dist/vendor/agentcert-sdk/deployment-enforcement.d.ts",
45
- "import": "./dist/vendor/agentcert-sdk/deployment-enforcement.js"
44
+ "types": "./dist/internal/control-client/deployment-enforcement.d.ts",
45
+ "import": "./dist/internal/control-client/deployment-enforcement.js"
46
46
  }
47
47
  },
48
48
  "files": [
@@ -66,8 +66,5 @@
66
66
  },
67
67
  "optionalDependencies": {
68
68
  "pg": "^8.16.3"
69
- },
70
- "dependencies": {
71
- "agentcert-sdk": "0.5.0"
72
69
  }
73
70
  }