witnora 0.10.2 → 0.10.4

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,27 @@ 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
+ After the connection self-test, Hosted shows one template-specific next step.
44
+ Place the generated boundary in one meaningful sandbox workflow and run it
45
+ normally. The generated local adapter reuses the saved restricted connection;
46
+ CI and production should use the same project-scoped values from a secret
47
+ manager. The first non-synthetic run completes onboarding, but remains
48
+ `reported` evidence and does not establish a release decision or `CURRENT`.
49
+
29
50
  Without an agent repository, run `npx witnora@latest try --template workflow`
30
51
  for an offline-only sample.
31
52
 
package/dist/cli.js CHANGED
@@ -36,6 +36,8 @@ 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";
39
41
  import { verifyEvidencePacketV02 } from "./evidence-v02.js";
40
42
  process.on("uncaughtException", reportFatalError);
41
43
  process.on("unhandledRejection", reportFatalError);
@@ -155,6 +157,21 @@ else if (command === "onboard") {
155
157
  openBrowser: readBoolFlag("--no-browser") ? async () => { } : undefined,
156
158
  });
157
159
  }
160
+ else if (command === "discover") {
161
+ const connectionName = readFlag("--connection");
162
+ const connection = await resolveConnection({
163
+ name: connectionName,
164
+ server: readFlag("--server"),
165
+ projectId: readFlag("--project"),
166
+ apiKey: readFlag("--api-key"),
167
+ });
168
+ const repositoryPath = resolve(readFlag("--repo") ?? process.cwd());
169
+ const repository = await inspectRepository(repositoryPath, readFlag("--template") ? parseAgentTemplate(readFlag("--template")) : undefined);
170
+ const result = await runPrivateCapabilityDiscovery({ connection, repository, connectionName: connectionName ?? repository.slug });
171
+ process.stdout.write(`Private capability discovery completed.\n`);
172
+ process.stdout.write(`Capability groups: ${result.capabilityCount}\nUnknown groups awaiting review: ${result.unknownCapabilityCount}\n`);
173
+ 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");
174
+ }
158
175
  else if (command === "try") {
159
176
  const template = parseAgentTemplate(readFlag("--template") ?? "workflow");
160
177
  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>
package/dist/onboard.js CHANGED
@@ -4,7 +4,8 @@ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { DEFAULT_WITNORA_SERVER, saveConnection } from "./credentials.js";
6
6
  import { verifyControlPlaneConnection } from "./control-plane.js";
7
- import { parseAgentTemplate, starterAdapter, starterProfile, starterTripwireConfig } from "./onboarding-templates.js";
7
+ import { inferPrivateCapabilities, runPrivateCapabilityDiscovery } from "./private-discovery.js";
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;
@@ -43,12 +44,21 @@ export async function runOnboard(options) {
43
44
  const receiptPath = join(repositoryPath, ".witnora", "onboarding", "receipt.json");
44
45
  await mkdir(dirname(receiptPath), { recursive: true });
45
46
  await writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
47
+ const discovery = await runPrivateCapabilityDiscovery({
48
+ connection: { server, projectId: token.projectId, apiKey: token.apiKey },
49
+ repository,
50
+ connectionName: token.connectionName,
51
+ configHome: options.configHome,
52
+ fetch: requestFetch,
53
+ });
46
54
  output(`\nConnected ${repository.name} to Witnora.\n`);
47
55
  output(`Project: ${token.projectId}\nTemplate: ${repository.template} (${repository.kind})\n`);
48
56
  output(`Credentials: ${credentialsPath}\nSelf-test receipt: ${receiptPath}\n`);
57
+ output(`Private discovery: ${discovery.capabilityCount} capability group(s); source code, prompts, credentials, and payloads stayed local.\n`);
49
58
  output("The synthetic self-test verified the local evidence path. It did not create a run, evidence object, release decision, or CURRENT assurance.\n");
59
+ output(starterInstructions(repository.template, repository.name));
50
60
  return { projectId: token.projectId, connectionName: token.connectionName, credentialsPath, template: repository.template,
51
- repositoryKind: repository.kind, generatedFiles, receiptPath };
61
+ repositoryKind: repository.kind, generatedFiles, receiptPath, discovery };
52
62
  }
53
63
  async function waitForToken(requestFetch, server, device, codeVerifier, sleep, timeoutMs) {
54
64
  const deadline = Date.now() + timeoutMs;
@@ -65,7 +75,7 @@ async function waitForToken(requestFetch, server, device, codeVerifier, sleep, t
65
75
  }
66
76
  throw new Error("Device authorization timed out. Run witnora onboard again to create a new one-time request.");
67
77
  }
68
- async function inspectRepository(repositoryPath, explicitTemplate) {
78
+ export async function inspectRepository(repositoryPath, explicitTemplate) {
69
79
  const entries = await readdir(repositoryPath, { withFileTypes: true });
70
80
  const names = entries.map((entry) => entry.name).sort();
71
81
  const packageJson = await optionalJson(join(repositoryPath, "package.json"));
@@ -81,7 +91,13 @@ async function inspectRepository(repositoryPath, explicitTemplate) {
81
91
  const name = rawName.replace(/^@[^/]+\//, "") || "agent-repository";
82
92
  const slug = (name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "agent-repository").slice(0, 64);
83
93
  const fingerprintSha256 = createHash("sha256").update(JSON.stringify({ kind, name, files: names.filter((value) => !value.startsWith(".")).slice(0, 200) })).digest("hex");
84
- return { kind, name, slug, template, fingerprintSha256 };
94
+ const dependencyNames = packageJson ? [
95
+ ...Object.keys(record(packageJson.dependencies)),
96
+ ...Object.keys(record(packageJson.devDependencies)),
97
+ ...Object.keys(record(packageJson.peerDependencies)),
98
+ ] : [];
99
+ const capabilities = inferPrivateCapabilities({ dependencyNames, topLevelNames: names });
100
+ return { kind, name, slug, template, fingerprintSha256, capabilities };
85
101
  }
86
102
  async function generateRepositoryConfig(repositoryPath, template, subject) {
87
103
  const files = new Map();
@@ -130,3 +146,6 @@ catch {
130
146
  return false;
131
147
  } }
132
148
  function normalizeServer(value) { return new URL(value).toString().replace(/\/$/, ""); }
149
+ function record(value) {
150
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
151
+ }
@@ -33,10 +33,13 @@ export function starterAdapter(template, subject) {
33
33
  return `#!/usr/bin/env node
34
34
  import { randomBytes, randomUUID } from "node:crypto";
35
35
  import { readFile } from "node:fs/promises";
36
+ import { homedir } from "node:os";
37
+ import { join } from "node:path";
36
38
 
37
- const baseUrl = brandedRequired("BASE_URL").replace(/\\\/$/, "");
38
- const projectId = brandedRequired("PROJECT_ID");
39
- const apiKey = brandedRequired("API_KEY");
39
+ const connection = await resolveHostedConnection();
40
+ const baseUrl = connection.baseUrl.replace(/\\\/$/, "");
41
+ const projectId = connection.projectId;
42
+ const apiKey = connection.apiKey;
40
43
  const agentId = branded("AGENT_ID") ?? ${JSON.stringify(subject)};
41
44
  const agentName = branded("AGENT_NAME") ?? humanize(agentId);
42
45
  const agentVersion = await discoverAgentVersion();
@@ -65,17 +68,29 @@ if (!response.ok) {
65
68
  }
66
69
  process.stdout.write(\`Recorded ${eventType} for ${subject}.\\n\`);
67
70
 
68
- function brandedRequired(suffix) {
69
- const name = \`WITNORA_\${suffix}\`;
70
- const value = process.env[name] ?? process.env[\`AGENTCERT_\${suffix}\`];
71
- if (!value) throw new Error(\`\${name} is required. Connect the Witnora CLI or set the hosted project variables. Legacy AGENTCERT_* names remain supported.\`);
72
- return value;
73
- }
74
-
75
71
  function branded(suffix) {
76
72
  return process.env[\`WITNORA_\${suffix}\`] ?? process.env[\`AGENTCERT_\${suffix}\`];
77
73
  }
78
74
 
75
+ async function resolveHostedConnection() {
76
+ const environment = {
77
+ baseUrl: branded("BASE_URL"),
78
+ projectId: branded("PROJECT_ID"),
79
+ apiKey: branded("API_KEY"),
80
+ };
81
+ if (environment.baseUrl && environment.projectId && environment.apiKey) return environment;
82
+ const configHome = process.env.WITNORA_CONFIG_HOME ?? process.env.AGENTCERT_CONFIG_HOME ?? join(homedir(), ".witnora");
83
+ try {
84
+ const credentials = JSON.parse(await readFile(join(configHome, "credentials.json"), "utf8"));
85
+ const name = process.env.WITNORA_CONNECTION ?? credentials.defaultConnection;
86
+ const saved = credentials.connections?.[name];
87
+ if (saved?.server && saved?.projectId && saved?.apiKey) {
88
+ return { baseUrl: saved.server, projectId: saved.projectId, apiKey: saved.apiKey };
89
+ }
90
+ } catch {}
91
+ throw new Error("No Witnora connection is available. Run witnora onboard --project <project-id> locally, or set WITNORA_BASE_URL, WITNORA_PROJECT_ID, and WITNORA_API_KEY in the runtime secret manager.");
92
+ }
93
+
79
94
  function humanize(value) {
80
95
  const unscoped = value.includes("/") ? value.slice(value.lastIndexOf("/") + 1) : value;
81
96
  return unscoped.replace(/[-_.]+/g, " ").replace(/\\b\\w/g, (character) => character.toUpperCase()).trim();
@@ -117,19 +132,19 @@ export function starterInstructions(template, subject) {
117
132
  Next:
118
133
  1. Edit tripwire.yml so startUrl and agent.command/agent.args match your app and browser agent.
119
134
  2. Run in CI with Kakarottoooo/agentcert/actions/tripwire@v0, or re-run init with --github-action.
120
- 3. Run: npx witnora@latest run --tripwire .tripwire/latest/tripwire-result.json --subject ${JSON.stringify(subject)} --fail-on-verdict
135
+ 3. Run: npx witnora@latest run --tripwire .tripwire/latest/tripwire-result.json --subject ${JSON.stringify(subject)} --fail-on-verdict --push
121
136
  `;
122
137
  if (template === "mcp")
123
138
  return `
124
139
  Next:
125
140
  1. Run MCPBench and write its JSON result to .mcpbench/latest/results.json.
126
- 2. Run: npx witnora@latest run --mcpbench .mcpbench/latest/results.json --subject ${JSON.stringify(subject)} --fail-on-verdict
141
+ 2. Run: npx witnora@latest run --mcpbench .mcpbench/latest/results.json --subject ${JSON.stringify(subject)} --fail-on-verdict --push
127
142
  `;
128
143
  return `
129
144
  Next:
130
145
  1. Wrap the meaningful ${template} agent boundary with witnora.adapter.mjs or copy its envelope call into your framework hook.
131
- 2. Set WITNORA_BASE_URL, WITNORA_PROJECT_ID, and WITNORA_API_KEY in your secret manager. Legacy AGENTCERT_* names remain supported.
132
- 3. Run: node witnora.adapter.mjs
146
+ 2. Run one sandbox workflow normally. For a local boundary check, run: node witnora.adapter.mjs
147
+ 3. Local runs reuse the restricted connection saved by onboard. In CI or production, set WITNORA_BASE_URL, WITNORA_PROJECT_ID, and WITNORA_API_KEY in your secret manager.
133
148
  4. Generate and push a full evidence bundle when the workflow reaches a deterministic verification point.
134
149
  `;
135
150
  }
@@ -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.2",
3
+ "version": "0.10.4",
4
4
  "description": "Independent assurance for covered agent action paths across models and frameworks.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",