witnora 0.10.3 → 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,20 @@ 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
+
29
43
  After the connection self-test, Hosted shows one template-specific next step.
30
44
  Place the generated boundary in one meaningful sandbox workflow and run it
31
45
  normally. The generated local adapter reuses the saved restricted connection;
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,6 +4,7 @@ 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 { 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) {
@@ -43,13 +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");
50
59
  output(starterInstructions(repository.template, repository.name));
51
60
  return { projectId: token.projectId, connectionName: token.connectionName, credentialsPath, template: repository.template,
52
- repositoryKind: repository.kind, generatedFiles, receiptPath };
61
+ repositoryKind: repository.kind, generatedFiles, receiptPath, discovery };
53
62
  }
54
63
  async function waitForToken(requestFetch, server, device, codeVerifier, sleep, timeoutMs) {
55
64
  const deadline = Date.now() + timeoutMs;
@@ -66,7 +75,7 @@ async function waitForToken(requestFetch, server, device, codeVerifier, sleep, t
66
75
  }
67
76
  throw new Error("Device authorization timed out. Run witnora onboard again to create a new one-time request.");
68
77
  }
69
- async function inspectRepository(repositoryPath, explicitTemplate) {
78
+ export async function inspectRepository(repositoryPath, explicitTemplate) {
70
79
  const entries = await readdir(repositoryPath, { withFileTypes: true });
71
80
  const names = entries.map((entry) => entry.name).sort();
72
81
  const packageJson = await optionalJson(join(repositoryPath, "package.json"));
@@ -82,7 +91,13 @@ async function inspectRepository(repositoryPath, explicitTemplate) {
82
91
  const name = rawName.replace(/^@[^/]+\//, "") || "agent-repository";
83
92
  const slug = (name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "agent-repository").slice(0, 64);
84
93
  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 };
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 };
86
101
  }
87
102
  async function generateRepositoryConfig(repositoryPath, template, subject) {
88
103
  const files = new Map();
@@ -131,3 +146,6 @@ catch {
131
146
  return false;
132
147
  } }
133
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
+ }
@@ -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.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",