witnora 0.10.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,6 +26,44 @@ boundary, writes only missing configuration, and verifies the local evidence
26
26
  path. `.witnora/onboarding/receipt.json` is synthetic: it cannot create a run,
27
27
  evidence object, release decision, or `CURRENT` assurance.
28
28
 
29
+ Onboarding also performs the default private capability discovery locally. It
30
+ does not read source-file contents or upload source code, prompt text,
31
+ credentials, tool inputs, tool outputs, database rows, or file contents. It
32
+ uploads a signed snapshot containing generalized capability metadata and
33
+ digests. Refresh it later with:
34
+
35
+ ```bash
36
+ npx witnora@latest discover
37
+ ```
38
+
39
+ Customers that do not permit repository metadata inspection can choose
40
+ runtime-only discovery in Hosted. Source-assisted analysis is a separate,
41
+ explicit GitHub authorization and is never enabled implicitly.
42
+
43
+ ### Customer-owned Gateway
44
+
45
+ For a durable recorder that runs beside the Agent in the customer environment,
46
+ choose **Customer-owned Gateway** in Hosted and run:
47
+
48
+ ```bash
49
+ npx witnora@latest gateway init --project your-project-id
50
+ npx witnora@latest gateway doctor
51
+ npx witnora@latest gateway run
52
+ ```
53
+
54
+ Initialization writes a reusable `.witnora/gateway/client.mjs`. Import its
55
+ `witnoraGateway.start`, `event`, and `complete` methods at one existing sandbox
56
+ workflow boundary. The generated README contains the exact code and privacy
57
+ boundary; no Hosted key or source-signing key is written into the repository.
58
+
59
+ The browser approval issues only `runs:read`, `events:write`, and
60
+ `collector:manage`. The Hosted key is stored in the user's Witnora credential
61
+ directory; the repository contains only metadata-only config plus ignored local
62
+ secrets and queue data. This reference path establishes **RECORDED** evidence.
63
+ It does not claim **ENFORCED** until target write credentials are placed behind a
64
+ controlled execution adapter, or **OUTCOME VERIFIED** until an independent
65
+ read-only probe checks the target state.
66
+
29
67
  After the connection self-test, Hosted shows one template-specific next step.
30
68
  Place the generated boundary in one meaningful sandbox workflow and run it
31
69
  normally. The generated local adapter reuses the saved restricted connection;
package/dist/cli.js CHANGED
@@ -36,6 +36,9 @@ import { runAssuranceLoopDemo } from "./assurance-loop-demo.js";
36
36
  import { importGenericEval, renderGenericEvalReport } from "./generic-eval.js";
37
37
  import { runDesignPartnerCommand } from "./design-partner-v02.js";
38
38
  import { runOnboard } from "./onboard.js";
39
+ import { inspectRepository } from "./onboard.js";
40
+ import { runPrivateCapabilityDiscovery } from "./private-discovery.js";
41
+ import { doctorCustomerGateway, initializeCustomerGateway, renderGatewayDoctor, runCustomerGateway } from "./gateway.js";
39
42
  import { verifyEvidencePacketV02 } from "./evidence-v02.js";
40
43
  process.on("uncaughtException", reportFatalError);
41
44
  process.on("unhandledRejection", reportFatalError);
@@ -155,6 +158,50 @@ else if (command === "onboard") {
155
158
  openBrowser: readBoolFlag("--no-browser") ? async () => { } : undefined,
156
159
  });
157
160
  }
161
+ else if (command === "gateway") {
162
+ const action = process.argv[3] ?? "help";
163
+ if (action === "init") {
164
+ const projectId = readFlag("--project") ?? brandedEnvironment("PROJECT_ID");
165
+ if (!projectId)
166
+ throw new Error("--project <project-id> is required.");
167
+ await initializeCustomerGateway({
168
+ projectId,
169
+ server: readFlag("--server") ?? brandedEnvironment("BASE_URL") ?? DEFAULT_WITNORA_SERVER,
170
+ repository: readFlag("--repo") ?? process.cwd(),
171
+ outDir: readFlag("--dir"),
172
+ connectionName: readFlag("--name"),
173
+ force: readBoolFlag("--force"),
174
+ openBrowser: readBoolFlag("--no-browser") ? async () => { } : undefined,
175
+ });
176
+ }
177
+ else if (action === "doctor") {
178
+ const result = await doctorCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
179
+ process.stdout.write(readBoolFlag("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderGatewayDoctor(result));
180
+ if (result.overall !== "READY_TO_RECORD")
181
+ process.exitCode = 1;
182
+ }
183
+ else if (action === "run") {
184
+ await runCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
185
+ }
186
+ else {
187
+ throw new Error("Use witnora gateway init|doctor|run.");
188
+ }
189
+ }
190
+ else if (command === "discover") {
191
+ const connectionName = readFlag("--connection");
192
+ const connection = await resolveConnection({
193
+ name: connectionName,
194
+ server: readFlag("--server"),
195
+ projectId: readFlag("--project"),
196
+ apiKey: readFlag("--api-key"),
197
+ });
198
+ const repositoryPath = resolve(readFlag("--repo") ?? process.cwd());
199
+ const repository = await inspectRepository(repositoryPath, readFlag("--template") ? parseAgentTemplate(readFlag("--template")) : undefined);
200
+ const result = await runPrivateCapabilityDiscovery({ connection, repository, connectionName: connectionName ?? repository.slug });
201
+ process.stdout.write(`Private capability discovery completed.\n`);
202
+ process.stdout.write(`Capability groups: ${result.capabilityCount}\nUnknown groups awaiting review: ${result.unknownCapabilityCount}\n`);
203
+ process.stdout.write("Uploaded: signed capability metadata and digests only.\nStayed local: source code, prompts, credentials, tool inputs and outputs, database rows, and file contents.\n");
204
+ }
158
205
  else if (command === "try") {
159
206
  const template = parseAgentTemplate(readFlag("--template") ?? "workflow");
160
207
  const subject = readFlag("--subject") ?? `witnora-sample-${template}`;
@@ -1,6 +1,21 @@
1
1
  export function renderCommandHelp(command) {
2
2
  if (command === "sandbox" || command === "browser-adapter")
3
3
  return undefined;
4
+ if (command === "discover")
5
+ return `Usage:
6
+ witnora discover [--connection <name>] [--repo <directory>]
7
+
8
+ Runs capability discovery inside the customer environment and uploads only a signed,
9
+ metadata-only snapshot. Source code, prompt text, credentials, tool inputs and outputs,
10
+ database rows, and file contents are not uploaded. New capability groups remain pending
11
+ until reviewed or observed through a controlled runtime boundary.
12
+
13
+ Options:
14
+ --connection <name> Saved Hosted connection (default: current connection)
15
+ --repo <directory> Repository whose local metadata is inspected (default: current directory)
16
+ --template <type> Optional repository-type hint
17
+ --help, -h Show this help
18
+ `;
4
19
  if (command === "onboard")
5
20
  return `Usage:
6
21
  witnora onboard --project <project-id>
@@ -17,6 +32,31 @@ Options:
17
32
  --repo <directory> Repository to configure (default: current directory)
18
33
  --template <type> Override automatic repository detection
19
34
  --no-browser Print the approval URL without opening it
35
+ `;
36
+ if (command === "gateway")
37
+ return `Usage:
38
+ witnora gateway init --project <project-id>
39
+ witnora gateway doctor
40
+ witnora gateway run
41
+
42
+ Initializes and runs a customer-owned, metadata-only collector beside the Agent.
43
+ The browser approval issues a collector-scoped credential; no API key is copied into
44
+ the repository or exposed to the Agent. The local queue and source signing key remain
45
+ under customer control.
46
+
47
+ The reference Gateway establishes RECORDED evidence only. ENFORCED requires target
48
+ write credentials behind a controlled execution adapter. OUTCOME VERIFIED requires a
49
+ separate read-only probe.
50
+
51
+ Options:
52
+ --server <url> Hosted server (default: https://witnora.com)
53
+ --project <id> Project to authorize (required for init)
54
+ --repo <directory> Agent repository (default: current directory)
55
+ --dir <directory> Gateway directory (default: .witnora/gateway)
56
+ --name <name> Saved collector connection name
57
+ --no-browser Print the approval URL without opening it
58
+ --force Replace an existing reviewed local setup
59
+ --json JSON doctor output
20
60
  `;
21
61
  if (command === "design-partner")
22
62
  return `Usage:
@@ -0,0 +1,69 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { DEFAULT_WITNORA_SERVER, saveConnection } from "./credentials.js";
4
+ export async function authorizeProjectConnection(options) {
5
+ const requestFetch = options.fetch ?? fetch;
6
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)));
7
+ const output = options.output ?? ((message) => process.stdout.write(message));
8
+ const server = normalizeServer(options.server ?? DEFAULT_WITNORA_SERVER);
9
+ const codeVerifier = randomBytes(48).toString("base64url");
10
+ const codeChallengeSha256 = createHash("sha256").update(codeVerifier).digest("hex");
11
+ const device = await jsonRequest(requestFetch, `${server}/v1/onboarding/device-authorizations`, {
12
+ method: "POST",
13
+ headers: { "content-type": "application/json" },
14
+ body: JSON.stringify({
15
+ projectId: options.projectId,
16
+ connectionName: options.connectionName,
17
+ credentialProfile: options.credentialProfile ?? "repository",
18
+ codeChallengeSha256,
19
+ }),
20
+ });
21
+ output(`Authorize Witnora in your browser:\n ${device.verificationUriComplete}\nCode: ${device.userCode}\n`);
22
+ await (options.openBrowser ?? openSystemBrowser)(device.verificationUriComplete);
23
+ const token = await waitForToken(requestFetch, server, device, codeVerifier, sleep, options.timeoutMs ?? device.expiresIn * 1_000);
24
+ const credentialsPath = await saveConnection(token.connectionName, {
25
+ server,
26
+ projectId: token.projectId,
27
+ apiKey: token.apiKey,
28
+ }, { configHome: options.configHome });
29
+ return { ...token, server, credentialsPath };
30
+ }
31
+ async function waitForToken(requestFetch, server, device, codeVerifier, sleep, timeoutMs) {
32
+ const deadline = Date.now() + timeoutMs;
33
+ while (Date.now() < deadline) {
34
+ const response = await requestFetch(`${server}/v1/onboarding/device-authorizations/token`, {
35
+ method: "POST",
36
+ headers: { "content-type": "application/json" },
37
+ body: JSON.stringify({ deviceCode: device.deviceCode, codeVerifier }),
38
+ });
39
+ const body = await response.json().catch(() => ({}));
40
+ if (response.ok)
41
+ return body;
42
+ if (response.status !== 428 || body.code !== "authorization_pending") {
43
+ throw new Error(String(body.error ?? `Device authorization failed with HTTP ${response.status}.`));
44
+ }
45
+ await sleep(Math.max(1, device.interval) * 1_000);
46
+ }
47
+ throw new Error("Device authorization timed out. Run the command again to create a new one-time request.");
48
+ }
49
+ async function jsonRequest(requestFetch, url, init) {
50
+ const response = await requestFetch(url, init);
51
+ const body = await response.json().catch(() => ({}));
52
+ if (!response.ok)
53
+ throw new Error(String(body.error ?? `${init.method ?? "GET"} ${url} failed with HTTP ${response.status}.`));
54
+ return body;
55
+ }
56
+ async function openSystemBrowser(url) {
57
+ const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
58
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
59
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
60
+ child.unref();
61
+ }
62
+ function normalizeServer(value) {
63
+ const url = new URL(value);
64
+ const loopback = new Set(["localhost", "127.0.0.1", "[::1]"]).has(url.hostname);
65
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
66
+ throw new Error("Witnora server must use HTTPS. Plain HTTP is allowed only for localhost development.");
67
+ }
68
+ return url.toString().replace(/\/$/, "");
69
+ }
@@ -0,0 +1,209 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { loadConnection } from "./credentials.js";
5
+ import { authorizeProjectConnection } from "./device-authorization.js";
6
+ const CONFIG_SCHEMA = "witnora.customer_gateway_setup.v0.1";
7
+ const SECRETS_SCHEMA = "witnora.customer_gateway_local_secrets.v0.1";
8
+ export async function initializeCustomerGateway(options) {
9
+ const repository = resolve(options.repository ?? process.cwd());
10
+ const outDir = resolve(repository, options.outDir ?? ".witnora/gateway");
11
+ const output = options.output ?? ((message) => process.stdout.write(message));
12
+ const slug = safeSlug(basename(repository));
13
+ const connectionName = options.connectionName ?? `${slug}-gateway`;
14
+ const configPath = join(outDir, "gateway.json");
15
+ const secretsPath = join(outDir, "secrets.json");
16
+ const gitignorePath = join(outDir, ".gitignore");
17
+ const readmePath = join(outDir, "README.md");
18
+ const clientPath = join(outDir, "client.mjs");
19
+ if (!(options.force ?? false)) {
20
+ const existing = (await Promise.all([configPath, secretsPath, gitignorePath, readmePath, clientPath].map(async (path) => await exists(path) ? path : undefined)))
21
+ .filter((path) => path !== undefined);
22
+ if (existing.length > 0) {
23
+ throw new Error(`Gateway setup already exists at ${existing.join(", ")}. Use --force only after reviewing the existing local setup.`);
24
+ }
25
+ }
26
+ const authorization = await authorizeProjectConnection({
27
+ projectId: options.projectId,
28
+ connectionName,
29
+ credentialProfile: "customer_gateway",
30
+ server: options.server,
31
+ openBrowser: options.openBrowser,
32
+ fetch: options.fetch,
33
+ sleep: options.sleep,
34
+ timeoutMs: options.timeoutMs,
35
+ output,
36
+ configHome: options.configHome,
37
+ });
38
+ const expectedScopes = ["runs:read", "events:write", "collector:manage"];
39
+ const missingScopes = expectedScopes.filter((scope) => !authorization.scopes.includes(scope));
40
+ if (missingScopes.length > 0)
41
+ throw new Error(`Gateway authorization is missing required scope(s): ${missingScopes.join(", ")}.`);
42
+ const config = {
43
+ schemaVersion: CONFIG_SCHEMA,
44
+ projectId: authorization.projectId,
45
+ server: authorization.server,
46
+ connectionName: authorization.connectionName,
47
+ collectorId: `${slug}-collector`,
48
+ host: "127.0.0.1",
49
+ port: 8787,
50
+ storageDirectory: "data",
51
+ privacyMode: "metadata_only",
52
+ coverage: { recorded: "configured", enforced: "not_configured", outcomeVerified: "not_configured" },
53
+ };
54
+ const secrets = {
55
+ schemaVersion: SECRETS_SCHEMA,
56
+ gatewayToken: randomBytes(32).toString("base64url"),
57
+ };
58
+ await mkdir(outDir, { recursive: true });
59
+ await writeExclusive(configPath, `${JSON.stringify(config, null, 2)}\n`, options.force ?? false, 0o644);
60
+ await writeExclusive(secretsPath, `${JSON.stringify(secrets, null, 2)}\n`, options.force ?? false, 0o600);
61
+ await writeExclusive(gitignorePath, "secrets.json\ndata/\n", options.force ?? false, 0o644);
62
+ await writeExclusive(clientPath, gatewayClient(config), options.force ?? false, 0o644);
63
+ await writeExclusive(readmePath, gatewayReadme(config), options.force ?? false, 0o644);
64
+ output("\nCustomer-owned Gateway initialized.\n");
65
+ output(`Configuration: ${configPath}\nLocal secret: ${secretsPath} (never commit or upload)\n`);
66
+ output("Next:\n");
67
+ output(" 1. Run: npx witnora@latest gateway doctor\n");
68
+ output(" 2. Run: npx witnora@latest gateway run\n");
69
+ output(" 3. Import .witnora/gateway/client.mjs at one sandbox workflow boundary.\n");
70
+ output(" 4. Run that workflow normally; start, event, and complete are recorded locally.\n");
71
+ output("This reference path establishes RECORDED evidence only. ENFORCED requires target write credentials behind a controlled execution adapter; OUTCOME VERIFIED requires a separate read-only probe.\n");
72
+ return { configPath, secretsPath, config };
73
+ }
74
+ export async function doctorCustomerGateway(options) {
75
+ const directory = resolve(options.repository ?? process.cwd(), options.dir ?? ".witnora/gateway");
76
+ const checks = [];
77
+ let config;
78
+ let secrets;
79
+ try {
80
+ config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
81
+ checks.push({ id: "configuration", status: "PASS", message: "Gateway configuration is valid and metadata-only." });
82
+ }
83
+ catch (error) {
84
+ checks.push({ id: "configuration", status: "FAIL", message: message(error) });
85
+ }
86
+ try {
87
+ secrets = parseSecrets(await readFile(join(directory, "secrets.json"), "utf8"));
88
+ checks.push({ id: "local_auth", status: "PASS", message: "A local Gateway token is present in the ignored customer-owned secret file." });
89
+ }
90
+ catch (error) {
91
+ checks.push({ id: "local_auth", status: "FAIL", message: message(error) });
92
+ }
93
+ if (config) {
94
+ try {
95
+ const connection = await loadConnection(config.connectionName, { configHome: options.configHome });
96
+ if (!connection || connection.projectId !== config.projectId || connection.server !== config.server)
97
+ throw new Error("The saved Gateway credential does not match gateway.json.");
98
+ checks.push({ id: "hosted_credential", status: "PASS", message: "The collector-scoped Hosted credential is stored outside the repository." });
99
+ }
100
+ catch (error) {
101
+ checks.push({ id: "hosted_credential", status: "FAIL", message: message(error) });
102
+ }
103
+ try {
104
+ const response = await (options.fetch ?? fetch)(`http://${config.host}:${config.port}/healthz`, { signal: AbortSignal.timeout(800) });
105
+ if (!response.ok)
106
+ throw new Error(`Gateway health returned HTTP ${response.status}.`);
107
+ checks.push({ id: "process", status: "PASS", message: `Gateway is listening at http://${config.host}:${config.port}.` });
108
+ }
109
+ catch {
110
+ checks.push({ id: "process", status: "WARN", message: "Gateway is not running yet. Start it with `witnora gateway run`." });
111
+ }
112
+ }
113
+ if (config && secrets) {
114
+ checks.push({ id: "enforcement", status: "WARN", message: "Write-credential mediation is not configured; current evidence ceiling is RECORDED." });
115
+ checks.push({ id: "outcome_probe", status: "WARN", message: "An independent read-only outcome probe is not configured." });
116
+ }
117
+ const failed = checks.some((check) => check.status === "FAIL");
118
+ return {
119
+ schemaVersion: "witnora.customer_gateway_doctor.v0.1",
120
+ overall: failed ? "SETUP_INCOMPLETE" : "READY_TO_RECORD",
121
+ checks,
122
+ evidenceCeiling: "recorded",
123
+ nextAction: failed ? "Run `witnora gateway init --project <project-id>` again after resolving failed checks." : "Start the Gateway, then send one sandbox run through its local event API.",
124
+ };
125
+ }
126
+ export async function runCustomerGateway(options) {
127
+ const { CustomerSourceKeyRing, RemoteCollectorClient, startCustomerOwnedCollectorGateway, } = await import("agentcert-sdk");
128
+ const directory = resolve(options.repository ?? process.cwd(), options.dir ?? ".witnora/gateway");
129
+ const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
130
+ const secrets = parseSecrets(await readFile(join(directory, "secrets.json"), "utf8"));
131
+ const connection = await loadConnection(config.connectionName, { configHome: options.configHome });
132
+ if (!connection || connection.projectId !== config.projectId || connection.server !== config.server) {
133
+ throw new Error("The saved Gateway credential does not match gateway.json. Run gateway init again.");
134
+ }
135
+ const dataDirectory = resolve(directory, config.storageDirectory);
136
+ const keyRingPath = join(dataDirectory, "source-keys.json");
137
+ const keyRing = await (await exists(keyRingPath)
138
+ ? CustomerSourceKeyRing.open(keyRingPath)
139
+ : CustomerSourceKeyRing.create(keyRingPath, config.collectorId));
140
+ const gateway = await startCustomerOwnedCollectorGateway({
141
+ client: new RemoteCollectorClient({ baseUrl: connection.server, projectId: connection.projectId, apiKey: connection.apiKey }),
142
+ keyRing,
143
+ gatewayToken: secrets.gatewayToken,
144
+ storageDirectory: dataDirectory,
145
+ host: config.host,
146
+ port: config.port,
147
+ environment: "customer-owned",
148
+ });
149
+ process.stdout.write(`Witnora customer-owned Gateway listening on ${gateway.baseUrl}\n`);
150
+ process.stdout.write("Evidence ceiling: RECORDED. No target write credential or outcome-probe credential is loaded by this reference process.\n");
151
+ for (const signal of ["SIGINT", "SIGTERM"]) {
152
+ process.once(signal, () => void gateway.close().finally(() => process.exit(0)));
153
+ }
154
+ }
155
+ export function renderGatewayDoctor(result) {
156
+ return [
157
+ `Customer-owned Gateway: ${result.overall}`,
158
+ ...result.checks.map((check) => `${check.status.padEnd(4)} ${check.id}: ${check.message}`),
159
+ `Evidence ceiling: ${result.evidenceCeiling.toUpperCase()}`,
160
+ `Next: ${result.nextAction}`,
161
+ "",
162
+ ].join("\n");
163
+ }
164
+ function parseConfig(raw) {
165
+ const value = JSON.parse(raw);
166
+ if (value.schemaVersion !== CONFIG_SCHEMA || !value.projectId || !value.server || !value.connectionName || !value.collectorId) {
167
+ throw new Error("gateway.json is missing required Witnora Gateway fields.");
168
+ }
169
+ if (value.privacyMode !== "metadata_only")
170
+ throw new Error("Gateway privacyMode must remain metadata_only.");
171
+ if (!value.host || !Number.isSafeInteger(value.port) || Number(value.port) < 1 || Number(value.port) > 65_535)
172
+ throw new Error("Gateway host and port are invalid.");
173
+ return value;
174
+ }
175
+ function parseSecrets(raw) {
176
+ const value = JSON.parse(raw);
177
+ if (value.schemaVersion !== SECRETS_SCHEMA || typeof value.gatewayToken !== "string" || value.gatewayToken.length < 32) {
178
+ throw new Error("secrets.json is missing a valid local Gateway token.");
179
+ }
180
+ return value;
181
+ }
182
+ function gatewayReadme(config) {
183
+ return `# Witnora customer-owned Gateway\n\nThis directory configures a metadata-only Gateway for project \`${config.projectId}\`.\n\n## Start the Gateway\n\n\`\`\`bash\nnpx witnora@latest gateway doctor\nnpx witnora@latest gateway run\n\`\`\`\n\nKeep \`gateway run\` open. 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\` or \`data/\`.\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`;
184
+ }
185
+ function gatewayClient(config) {
186
+ 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`;
187
+ }
188
+ async function writeExclusive(path, content, force, mode) {
189
+ if (!force && await exists(path))
190
+ throw new Error(`${path} already exists. Use --force only after reviewing the existing local Gateway setup.`);
191
+ await mkdir(dirname(path), { recursive: true });
192
+ await writeFile(path, content, { encoding: "utf8", mode });
193
+ await chmod(path, mode).catch(() => undefined);
194
+ }
195
+ async function exists(path) {
196
+ try {
197
+ await access(path);
198
+ return true;
199
+ }
200
+ catch {
201
+ return false;
202
+ }
203
+ }
204
+ function safeSlug(value) {
205
+ return (value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "agent-repository").slice(0, 48);
206
+ }
207
+ function message(error) {
208
+ return error instanceof Error ? error.message : String(error);
209
+ }
package/dist/onboard.js CHANGED
@@ -1,29 +1,32 @@
1
- import { createHash, randomBytes } from "node:crypto";
2
- import { spawn } from "node:child_process";
1
+ import { createHash } from "node:crypto";
3
2
  import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
3
  import { basename, dirname, join, resolve } from "node:path";
5
- import { DEFAULT_WITNORA_SERVER, saveConnection } from "./credentials.js";
4
+ import { DEFAULT_WITNORA_SERVER } from "./credentials.js";
5
+ import { authorizeProjectConnection } from "./device-authorization.js";
6
6
  import { verifyControlPlaneConnection } from "./control-plane.js";
7
+ import { inferPrivateCapabilities, runPrivateCapabilityDiscovery } from "./private-discovery.js";
7
8
  import { parseAgentTemplate, starterAdapter, starterInstructions, starterProfile, starterTripwireConfig } from "./onboarding-templates.js";
8
9
  import { writeTryEvidence } from "./try.js";
9
10
  export async function runOnboard(options) {
10
11
  const requestFetch = options.fetch ?? fetch;
11
- const sleep = options.sleep ?? ((milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)));
12
12
  const output = options.output ?? ((message) => process.stdout.write(message));
13
13
  const server = normalizeServer(options.server ?? DEFAULT_WITNORA_SERVER);
14
14
  const repositoryPath = resolve(options.repository ?? process.cwd());
15
15
  const repository = await inspectRepository(repositoryPath, options.template);
16
16
  const connectionName = options.name ?? repository.slug;
17
- const codeVerifier = randomBytes(48).toString("base64url");
18
- const codeChallengeSha256 = createHash("sha256").update(codeVerifier).digest("hex");
19
- const device = await jsonRequest(requestFetch, `${server}/v1/onboarding/device-authorizations`, {
20
- method: "POST", headers: { "content-type": "application/json" },
21
- body: JSON.stringify({ projectId: options.projectId, connectionName, codeChallengeSha256 }),
17
+ const token = await authorizeProjectConnection({
18
+ projectId: options.projectId,
19
+ connectionName,
20
+ credentialProfile: "repository",
21
+ server,
22
+ openBrowser: options.openBrowser,
23
+ fetch: requestFetch,
24
+ sleep: options.sleep,
25
+ timeoutMs: options.timeoutMs,
26
+ output,
27
+ configHome: options.configHome,
22
28
  });
23
- output(`Authorize Witnora in your browser:\n ${device.verificationUriComplete}\nCode: ${device.userCode}\n`);
24
- await (options.openBrowser ?? openSystemBrowser)(device.verificationUriComplete);
25
- const token = await waitForToken(requestFetch, server, device, codeVerifier, sleep, options.timeoutMs ?? device.expiresIn * 1_000);
26
- const credentialsPath = await saveConnection(token.connectionName, { server, projectId: token.projectId, apiKey: token.apiKey }, { configHome: options.configHome });
29
+ const credentialsPath = token.credentialsPath;
27
30
  await verifyControlPlaneConnection({ baseUrl: server, projectId: token.projectId, apiKey: token.apiKey, fetch: requestFetch });
28
31
  const generatedFiles = await generateRepositoryConfig(repositoryPath, repository.template, repository.name);
29
32
  const selfTest = await writeTryEvidence({
@@ -43,30 +46,23 @@ export async function runOnboard(options) {
43
46
  const receiptPath = join(repositoryPath, ".witnora", "onboarding", "receipt.json");
44
47
  await mkdir(dirname(receiptPath), { recursive: true });
45
48
  await writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
49
+ const discovery = await runPrivateCapabilityDiscovery({
50
+ connection: { server, projectId: token.projectId, apiKey: token.apiKey },
51
+ repository,
52
+ connectionName: token.connectionName,
53
+ configHome: options.configHome,
54
+ fetch: requestFetch,
55
+ });
46
56
  output(`\nConnected ${repository.name} to Witnora.\n`);
47
57
  output(`Project: ${token.projectId}\nTemplate: ${repository.template} (${repository.kind})\n`);
48
58
  output(`Credentials: ${credentialsPath}\nSelf-test receipt: ${receiptPath}\n`);
59
+ output(`Private discovery: ${discovery.capabilityCount} capability group(s); source code, prompts, credentials, and payloads stayed local.\n`);
49
60
  output("The synthetic self-test verified the local evidence path. It did not create a run, evidence object, release decision, or CURRENT assurance.\n");
50
61
  output(starterInstructions(repository.template, repository.name));
51
62
  return { projectId: token.projectId, connectionName: token.connectionName, credentialsPath, template: repository.template,
52
- repositoryKind: repository.kind, generatedFiles, receiptPath };
53
- }
54
- async function waitForToken(requestFetch, server, device, codeVerifier, sleep, timeoutMs) {
55
- const deadline = Date.now() + timeoutMs;
56
- while (Date.now() < deadline) {
57
- const response = await requestFetch(`${server}/v1/onboarding/device-authorizations/token`, {
58
- method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deviceCode: device.deviceCode, codeVerifier }),
59
- });
60
- const body = await response.json().catch(() => ({}));
61
- if (response.ok)
62
- return body;
63
- if (response.status !== 428 || body.code !== "authorization_pending")
64
- throw new Error(String(body.error ?? `Device authorization failed with HTTP ${response.status}.`));
65
- await sleep(Math.max(1, device.interval) * 1_000);
66
- }
67
- throw new Error("Device authorization timed out. Run witnora onboard again to create a new one-time request.");
63
+ repositoryKind: repository.kind, generatedFiles, receiptPath, discovery };
68
64
  }
69
- async function inspectRepository(repositoryPath, explicitTemplate) {
65
+ export async function inspectRepository(repositoryPath, explicitTemplate) {
70
66
  const entries = await readdir(repositoryPath, { withFileTypes: true });
71
67
  const names = entries.map((entry) => entry.name).sort();
72
68
  const packageJson = await optionalJson(join(repositoryPath, "package.json"));
@@ -82,7 +78,13 @@ async function inspectRepository(repositoryPath, explicitTemplate) {
82
78
  const name = rawName.replace(/^@[^/]+\//, "") || "agent-repository";
83
79
  const slug = (name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "agent-repository").slice(0, 64);
84
80
  const fingerprintSha256 = createHash("sha256").update(JSON.stringify({ kind, name, files: names.filter((value) => !value.startsWith(".")).slice(0, 200) })).digest("hex");
85
- return { kind, name, slug, template, fingerprintSha256 };
81
+ const dependencyNames = packageJson ? [
82
+ ...Object.keys(record(packageJson.dependencies)),
83
+ ...Object.keys(record(packageJson.devDependencies)),
84
+ ...Object.keys(record(packageJson.peerDependencies)),
85
+ ] : [];
86
+ const capabilities = inferPrivateCapabilities({ dependencyNames, topLevelNames: names });
87
+ return { kind, name, slug, template, fingerprintSha256, capabilities };
86
88
  }
87
89
  async function generateRepositoryConfig(repositoryPath, template, subject) {
88
90
  const files = new Map();
@@ -109,12 +111,6 @@ async function jsonRequest(requestFetch, url, init) {
109
111
  throw new Error(String(body.error ?? `${init.method ?? "GET"} ${url} failed with HTTP ${response.status}.`));
110
112
  return body;
111
113
  }
112
- async function openSystemBrowser(url) {
113
- const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
114
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
115
- const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
116
- child.unref();
117
- }
118
114
  async function optionalJson(path) {
119
115
  try {
120
116
  return JSON.parse(await readFile(path, "utf8"));
@@ -131,3 +127,6 @@ catch {
131
127
  return false;
132
128
  } }
133
129
  function normalizeServer(value) { return new URL(value).toString().replace(/\/$/, ""); }
130
+ function record(value) {
131
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
132
+ }
@@ -0,0 +1,146 @@
1
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ export async function runPrivateCapabilityDiscovery(input) {
5
+ const requestFetch = input.fetch ?? fetch;
6
+ const request = await jsonRequest(requestFetch, `${input.connection.server}/v1/projects/${encodeURIComponent(input.connection.projectId)}/private-discovery`, {
7
+ method: "POST",
8
+ headers: authorization(input.connection.apiKey),
9
+ body: JSON.stringify({ mode: "private_discovery" }),
10
+ });
11
+ const identityPath = discoveryIdentityPath(input.connection.projectId, input.configHome);
12
+ const identity = await loadOrCreateIdentity(identityPath, input.connection.projectId, input.connectionName);
13
+ const payload = {
14
+ schemaVersion: "witnora.private_capability_snapshot.v0.1",
15
+ requestId: request.id,
16
+ projectId: input.connection.projectId,
17
+ challenge: request.challenge,
18
+ collectorId: `private-discovery:${safeIdentifier(input.connectionName)}`,
19
+ mode: "private_discovery",
20
+ generatedAt: new Date().toISOString(),
21
+ repository: {
22
+ kind: input.repository.kind,
23
+ name: `repository-${input.repository.fingerprintSha256.slice(0, 12)}`,
24
+ fingerprintSha256: input.repository.fingerprintSha256,
25
+ },
26
+ privacy: {
27
+ sourceCodeUploaded: false,
28
+ rawPromptsUploaded: false,
29
+ credentialsUploaded: false,
30
+ payloadsUploaded: false,
31
+ localRedactionApplied: true,
32
+ },
33
+ capabilities: input.repository.capabilities,
34
+ unknownCapabilityCount: input.repository.capabilities.length,
35
+ };
36
+ const payloadSha256 = sha256(canonicalJson(payload));
37
+ const privateKey = createPrivateKey(identity.privateKeyPem);
38
+ const publicKey = createPublicKey(identity.publicKeyPem);
39
+ const snapshot = {
40
+ payload,
41
+ payloadSha256,
42
+ source: {
43
+ algorithm: "Ed25519",
44
+ keyId: identity.keyId,
45
+ publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
46
+ publicKeySha256: sha256(publicKey.export({ type: "spki", format: "der" })),
47
+ signature: sign(null, Buffer.from(payloadSha256), privateKey).toString("base64url"),
48
+ },
49
+ };
50
+ const completed = await jsonRequest(requestFetch, `${input.connection.server}/v1/projects/${encodeURIComponent(input.connection.projectId)}/private-discovery/${encodeURIComponent(request.id)}/complete`, {
51
+ method: "POST",
52
+ headers: authorization(input.connection.apiKey),
53
+ body: JSON.stringify(snapshot),
54
+ });
55
+ return {
56
+ requestId: completed.id,
57
+ capabilityCount: input.repository.capabilities.length,
58
+ unknownCapabilityCount: completed.snapshot?.payload?.unknownCapabilityCount ?? input.repository.capabilities.length,
59
+ identityPath,
60
+ };
61
+ }
62
+ export function inferPrivateCapabilities(input) {
63
+ const searchable = [...input.dependencyNames, ...input.topLevelNames].join(" ").toLowerCase();
64
+ const detected = new Map();
65
+ const add = (key, observedName, transport) => detected.set(key, {
66
+ key,
67
+ observedName,
68
+ transport,
69
+ disposition: "pending",
70
+ schemaSha256: sha256(canonicalJson({ key, observedName, transport, source: "repository_metadata" })),
71
+ source: "repository_metadata",
72
+ });
73
+ add("workflow:orchestration", "Workflow orchestration", "workflow");
74
+ if (/playwright|puppeteer|browser-use|stagehand|selenium|computer-use/.test(searchable))
75
+ add("browser:automation", "Browser automation", "browser");
76
+ if (/modelcontextprotocol|(^|[^a-z])mcp([^a-z]|$)/.test(searchable))
77
+ add("mcp:tool-calling", "MCP tool calling", "mcp");
78
+ if (/axios|undici|node-fetch|httpx|requests|express|fastapi|fetch/.test(searchable))
79
+ add("http:tool-calling", "HTTP tool calling", "http");
80
+ if (/codex|aider|coding-agent|swe-agent|openhands/.test(searchable))
81
+ add("coding:workspace", "Coding workspace", "coding");
82
+ if (/pandas|duckdb|prisma|sequelize|sqlalchemy|postgres|sqlite|(^|[^a-z])(pg|sql)([^a-z]|$)/.test(searchable))
83
+ add("data:structured", "Structured data access", "data");
84
+ if (/slack|discord|twilio|resend|sendgrid|postmark|nodemailer|email/.test(searchable))
85
+ add("messaging:external", "External messaging", "messaging");
86
+ return [...detected.values()].sort((left, right) => left.key.localeCompare(right.key));
87
+ }
88
+ function discoveryIdentityPath(projectId, configHome) {
89
+ const root = configHome ?? process.env.WITNORA_CONFIG_HOME ?? process.env.AGENTCERT_CONFIG_HOME
90
+ ?? join(process.env.USERPROFILE ?? process.env.HOME ?? ".", ".witnora");
91
+ return join(root, "private-discovery", `${safeIdentifier(projectId)}.json`);
92
+ }
93
+ async function loadOrCreateIdentity(path, projectId, connectionName) {
94
+ try {
95
+ const parsed = JSON.parse(await readFile(path, "utf8"));
96
+ if (!parsed.keyId || !parsed.privateKeyPem || !parsed.publicKeyPem)
97
+ throw new Error("identity fields are missing");
98
+ return parsed;
99
+ }
100
+ catch (error) {
101
+ if (error.code !== "ENOENT")
102
+ throw new Error(`Witnora private discovery identity is invalid: ${path}`);
103
+ }
104
+ const pair = generateKeyPairSync("ed25519");
105
+ const identity = {
106
+ keyId: `discovery:${safeIdentifier(connectionName)}:${sha256(projectId).slice(0, 12)}`,
107
+ privateKeyPem: pair.privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
108
+ publicKeyPem: pair.publicKey.export({ type: "spki", format: "pem" }).toString(),
109
+ };
110
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
111
+ const temporary = `${path}.${randomUUID()}.tmp`;
112
+ await writeFile(temporary, `${JSON.stringify(identity, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
113
+ await rename(temporary, path);
114
+ await chmod(path, 0o600).catch(() => undefined);
115
+ return identity;
116
+ }
117
+ function authorization(apiKey) {
118
+ return { authorization: `Bearer ${apiKey}`, "content-type": "application/json" };
119
+ }
120
+ async function jsonRequest(requestFetch, url, init) {
121
+ const response = await requestFetch(url, init);
122
+ const body = await response.json().catch(() => ({}));
123
+ if (!response.ok)
124
+ throw new Error(String(body.error ?? `${init.method ?? "GET"} ${url} failed with HTTP ${response.status}.`));
125
+ return body;
126
+ }
127
+ function safeIdentifier(value) {
128
+ return value.trim().replace(/[^A-Za-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 120) || "local";
129
+ }
130
+ function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
131
+ function canonicalJson(value) {
132
+ if (value === null || typeof value === "boolean" || typeof value === "string")
133
+ return JSON.stringify(value);
134
+ if (typeof value === "number") {
135
+ if (!Number.isFinite(value))
136
+ throw new Error("Canonical JSON does not support non-finite numbers.");
137
+ return JSON.stringify(value);
138
+ }
139
+ if (Array.isArray(value))
140
+ return `[${value.map(canonicalJson).join(",")}]`;
141
+ if (value && typeof value === "object")
142
+ return `{${Object.entries(value)
143
+ .filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right))
144
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
145
+ throw new Error("Canonical JSON contains an unsupported value.");
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "witnora",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "Independent assurance for covered agent action paths across models and frameworks.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -66,5 +66,8 @@
66
66
  },
67
67
  "optionalDependencies": {
68
68
  "pg": "^8.16.3"
69
+ },
70
+ "dependencies": {
71
+ "agentcert-sdk": "0.5.0"
69
72
  }
70
73
  }