@kavrosai/cli 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kavros OS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # @kavrosai/cli
2
+
3
+ Language-agnostic developer CLI for Kavros workload egress.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 18 or newer
8
+ - A registered Kavros workload
9
+ - Network access to the workload's data-plane endpoint
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install --global @kavrosai/cli
15
+ ```
16
+
17
+ Or run it without a global install:
18
+
19
+ ```bash
20
+ npx @kavrosai/cli doctor
21
+ ```
22
+
23
+ ## Configure
24
+
25
+ ```bash
26
+ export KAVROS_API_URL="https://<data-plane-host>/api/agent/egress"
27
+ export KAVROS_API_KEY="<workload-api-key>"
28
+ export KAVROS_AGENT_ID="<workload-id>"
29
+ ```
30
+
31
+ ## Commands
32
+
33
+ Check connectivity and workload configuration:
34
+
35
+ ```bash
36
+ kavros doctor
37
+ ```
38
+
39
+ Per-command options and examples:
40
+
41
+ ```bash
42
+ kavros help <command>
43
+ ```
44
+
45
+ Send a policy-bound request:
46
+
47
+ ```bash
48
+ kavros request \
49
+ --action post_data \
50
+ --target "https://api.example.com/v1/run" \
51
+ --content '{"input":"hello"}' \
52
+ --header 'content-type:application/json' \
53
+ --json
54
+ ```
55
+
56
+ Fetch an approved URL:
57
+
58
+ ```bash
59
+ kavros request --action get --target "https://docs.example.com" --json
60
+ ```
61
+
62
+ The CLI sends credentials as headers and supports the same JSON contract used by
63
+ native clients. It is useful for smoke tests, scripts, jobs, and applications
64
+ that do not yet have a native Kavros SDK.
65
+
66
+ Forwarded headers are evaluated against the workload policy like any other
67
+ request content; the CLI warns when a header looks like credentials
68
+ (`Authorization`, `Cookie`, or `x-kavros-*`).
69
+
70
+ ### When a request is blocked
71
+
72
+ A block is a governed outcome, not a malfunction. The CLI prints the policy
73
+ reason plus a plain-language explanation: what happened, whether the content
74
+ reached the outside world (blocked requests never do), and what to do next.
75
+ The same explanation is available standalone:
76
+
77
+ ```bash
78
+ kavros explain 403 --body '{"error":"Blocked by Kavros","reason":"…"}'
79
+ ```
80
+
81
+ ### Verify a signed workflow bundle locally
82
+
83
+ Before importing a workflow bundle exported from another deployment, verify its
84
+ Ed25519 signature against the trusted public key (`CP_POLICY_PUBLIC_KEY` from
85
+ provisioning) — the same check the import route performs, run on your machine,
86
+ often offline:
87
+
88
+ ```bash
89
+ kavros verify-bundle workflow.kavros-bundle.json \
90
+ --public-key "$CP_POLICY_PUBLIC_KEY"
91
+ ```
92
+
93
+ The key defaults to `KAVROS_POLICY_PUBLIC_KEY`, then `CP_POLICY_PUBLIC_KEY`,
94
+ from the environment. Output names the workflow, revision, origin deployment,
95
+ pinned capabilities, and a fingerprint of the key that verified the signature —
96
+ suitable for pasting into a change record. A tampered or foreign-signed bundle
97
+ exits non-zero with instructions not to import.
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ npm test
103
+ node bin/kavros.mjs --help
104
+ ```
105
+
106
+ The CLI has no runtime dependencies and supports Node.js 18 and newer. The
107
+ printed version is read from `package.json` at runtime.
108
+
109
+ ## Releasing
110
+
111
+ Releases use npm Trusted Publishing (GitHub Actions OIDC) — no stored npm
112
+ token, and every published package carries a sigstore provenance attestation.
113
+
114
+ One-time setup on npmjs.com under the package's **Settings → Trusted
115
+ Publisher**:
116
+
117
+ - Provider: GitHub Actions
118
+ - Organization or user: `Kavrosai`
119
+ - Repository: `cli`
120
+ - Workflow filename: `publish.yml`
121
+ - Allowed action: npm publish
122
+
123
+ To release a new version, update `version` in `package.json`, commit, then push
124
+ a matching tag — the workflow rejects a tag that does not match:
125
+
126
+ ```bash
127
+ git tag v0.2.3
128
+ git push origin v0.2.3
129
+ ```
130
+
131
+ Fallback until Trusted Publishing is configured: add an npm granular access
132
+ token with publish permission for `@kavrosai/cli` and **bypass 2FA** enabled
133
+ as the `NPM_TOKEN` Actions secret. The workflow then publishes without
134
+ provenance (`EOTP` failures in Actions mean 2FA bypass was not enabled).
135
+ Provenance requires a public source repository, so Trusted Publishing is the
136
+ long-term path. Remove the `NPM_TOKEN` secret once a tagged release has
137
+ published through OIDC.
138
+
139
+ npm does not allow re-publishing an existing package version; if a tag was
140
+ already published successfully, bump the version instead of retrying.
141
+
142
+ ## License
143
+
144
+ [MIT](LICENSE)
package/bin/kavros.mjs ADDED
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+
3
+ import process from "node:process";
4
+ import { readFileSync } from "node:fs";
5
+ import {
6
+ config,
7
+ parseArgs,
8
+ parseContent,
9
+ parseHeaders,
10
+ requireConfig,
11
+ sensitiveHeaders,
12
+ } from "../lib/args.mjs";
13
+ import { verifyBundle } from "../lib/bundle.mjs";
14
+ import { explainFailure } from "../lib/explain.mjs";
15
+ import { readFile } from "node:fs/promises";
16
+
17
+ const { version: VERSION } = JSON.parse(
18
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
19
+ );
20
+
21
+ function usage() {
22
+ console.log(`Kavros CLI ${VERSION}
23
+
24
+ Language-agnostic tools for protected workload egress.
25
+
26
+ Usage:
27
+ kavros request --action <action> --target <url> [--content <json|string>]
28
+ kavros doctor
29
+ kavros verify-bundle <file> [--public-key <base64>]
30
+ kavros explain <http-status> [--body <json>]
31
+ kavros help <command>
32
+ kavros --version
33
+ kavros --help
34
+
35
+ Commands:
36
+ request Send a policy-bound request through the data plane
37
+ doctor Check connectivity and workload configuration
38
+ verify-bundle Verify a signed workflow bundle's Ed25519 signature locally
39
+ explain Plain-language meaning of a governed-request failure
40
+
41
+ Environment:
42
+ KAVROS_API_URL Data-plane egress URL
43
+ KAVROS_API_KEY Workload API key
44
+ KAVROS_AGENT_ID Workload ID
45
+ KAVROS_POLICY_PUBLIC_KEY Trusted public key for verify-bundle (optional)
46
+
47
+ Run 'kavros help <command>' for command-specific options and examples.
48
+
49
+ Examples:
50
+ kavros doctor
51
+ kavros request --action post_data --target https://api.example.com/v1/run \\
52
+ --content '{"input":"hello"}'
53
+ kavros verify-bundle workflow.kavros-bundle.json
54
+ `);
55
+ }
56
+
57
+ function commandHelp(command) {
58
+ const pages = {
59
+ request: `kavros request — send a policy-bound request through the data plane
60
+
61
+ The request is evaluated against the workload's signed policy: identity,
62
+ allowlist, DLP, and metering. A block is a governed outcome, not an error —
63
+ use \`kavros explain\` or the failure footer for what to do next.
64
+
65
+ Required:
66
+ --action <action> e.g. get or post_data (per the workload's policy)
67
+ --target <url> the approved upstream URL
68
+
69
+ Options:
70
+ --content <json|string> request body
71
+ --header <name:value> forward a header (repeatable; credential-like
72
+ headers warn)
73
+ --json formatted JSON output
74
+ --timeout <seconds> request timeout (default: 120)
75
+
76
+ Environment: KAVROS_API_URL, KAVROS_API_KEY, KAVROS_AGENT_ID
77
+
78
+ Examples:
79
+ kavros request --action get --target https://docs.example.com --json
80
+ kavros request --action post_data --target https://api.example.com/v1/run \\
81
+ --content '{"input":"hello"}'
82
+ `,
83
+ doctor: `kavros doctor — check connectivity and workload configuration
84
+
85
+ Verifies that the data plane is reachable and the configured workload
86
+ identity is accepted. Requires the same environment as request.
87
+
88
+ Environment: KAVROS_API_URL, KAVROS_API_KEY, KAVROS_AGENT_ID
89
+
90
+ Example:
91
+ kavros doctor
92
+ `,
93
+ "verify-bundle": `kavros verify-bundle — verify a signed workflow bundle locally
94
+
95
+ Checks the Ed25519 signature on a bundle exported from a control plane
96
+ against a trusted public key. This is the same verification the import
97
+ route performs, run on your machine — useful before importing a bundle
98
+ and for security review. Works offline; no deployment connection needed.
99
+
100
+ Usage:
101
+ kavros verify-bundle <file> [--public-key <base64>]
102
+
103
+ Options:
104
+ --public-key <base64> Trusted public key (SPKI DER or raw32 base64).
105
+ Falls back to KAVROS_POLICY_PUBLIC_KEY, then
106
+ CP_POLICY_PUBLIC_KEY.
107
+ --json Print the verification result as JSON
108
+
109
+ The key to trust is the CP_POLICY_PUBLIC_KEY value provisioning shares
110
+ between the exporting and importing deployments.
111
+
112
+ Examples:
113
+ kavros verify-bundle workflow.kavros-bundle.json
114
+ kavros verify-bundle bundle.json --public-key "MCowBQYDK2VwAyEA…"
115
+ `,
116
+ explain: `kavros explain — plain-language meaning of a governed failure
117
+
118
+ Turns a data-plane status/body into what happened, whether egress was
119
+ reached, and what to do next. Also prints automatically when a request
120
+ fails (unless --json is set).
121
+
122
+ Usage:
123
+ kavros explain <http-status> [--body '<json>']
124
+
125
+ Examples:
126
+ kavros explain 403 --body '{"error":"Blocked by Kavros","reason":"DLP rule matched"}'
127
+ kavros explain 429
128
+ `,
129
+ };
130
+ const page = pages[command];
131
+ if (!page) {
132
+ console.error(`kavros: no help for "${command}". Commands: request, doctor, verify-bundle, explain.`);
133
+ process.exitCode = 2;
134
+ return;
135
+ }
136
+ console.log(page);
137
+ }
138
+
139
+ function fail(message, code = 2) {
140
+ console.error(`kavros: ${message}`);
141
+ process.exitCode = code;
142
+ }
143
+
144
+ async function fetchWithTimeout(url, init, timeoutSeconds) {
145
+ const controller = new AbortController();
146
+ const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
147
+ try {
148
+ return await fetch(url, { ...init, signal: controller.signal });
149
+ } finally {
150
+ clearTimeout(timeout);
151
+ }
152
+ }
153
+
154
+ async function readBody(response) {
155
+ const text = await response.text();
156
+ if (!text) return null;
157
+ try {
158
+ return JSON.parse(text);
159
+ } catch {
160
+ return text;
161
+ }
162
+ }
163
+
164
+ function printBody(body, formatted) {
165
+ if (typeof body === "string") console.log(body);
166
+ else console.log(JSON.stringify(body ?? {}, null, formatted ? 2 : 0));
167
+ }
168
+
169
+ async function request(options) {
170
+ const values = requireConfig();
171
+ if (!options.action) throw new Error("request requires --action");
172
+ if (!options.target) throw new Error("request requires --target");
173
+
174
+ const upstreamHeaders = parseHeaders(options.headers);
175
+ for (const name of sensitiveHeaders(upstreamHeaders)) {
176
+ console.error(`kavros: warning: forwarding sensitive header "${name}" to the upstream target; omit it unless the target requires it`);
177
+ }
178
+ const headers = {
179
+ Authorization: `Bearer ${values.apiKey}`,
180
+ "X-Kavros-Agent-ID": values.agentId,
181
+ "Content-Type": "application/json",
182
+ };
183
+ const payload = {
184
+ action: options.action,
185
+ target: options.target,
186
+ content: parseContent(options.content),
187
+ headers: upstreamHeaders,
188
+ };
189
+ const timeoutSeconds = Number(options.timeout ?? 120);
190
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) throw new Error("--timeout must be a positive number");
191
+
192
+ let response;
193
+ try {
194
+ response = await fetchWithTimeout(values.url, {
195
+ method: "POST",
196
+ headers,
197
+ body: JSON.stringify(payload),
198
+ }, timeoutSeconds);
199
+ } catch (error) {
200
+ throw new Error(`data-plane request failed: ${error.name === "AbortError" ? "timed out" : error.message}`);
201
+ }
202
+
203
+ const body = await readBody(response);
204
+ if (!response.ok) {
205
+ printBody(body, true);
206
+ if (!options.json) {
207
+ const explanation = explainFailure({ status: response.status, body });
208
+ console.error(`\nWhat happened: ${explanation.headline}`);
209
+ console.error(`Egress: ${explanation.egress}`);
210
+ console.error("Next steps:");
211
+ for (const step of explanation.next) console.error(` - ${step}`);
212
+ }
213
+ process.exitCode = 1;
214
+ return;
215
+ }
216
+ printBody(body, options.json);
217
+ }
218
+
219
+ async function verifyBundleCommand(options) {
220
+ const file = options._[1];
221
+ if (!file) throw new Error("verify-bundle requires a bundle file path");
222
+ let raw;
223
+ try {
224
+ raw = await readFile(file, "utf8");
225
+ } catch {
226
+ throw new Error(`cannot read bundle file: ${file}`);
227
+ }
228
+ let signed;
229
+ try {
230
+ signed = JSON.parse(raw);
231
+ } catch {
232
+ throw new Error("the bundle file is not valid JSON");
233
+ }
234
+ const publicKeyBase64 = options["public-key"];
235
+ const result = verifyBundle(signed, { publicKey: publicKeyBase64 });
236
+ if (options.json) {
237
+ console.log(JSON.stringify(result, null, 2));
238
+ } else if (result.ok) {
239
+ const s = result.summary;
240
+ console.log(`Signature valid (Ed25519, key ${s.signature_key_fingerprint}).`);
241
+ console.log(`Workflow: ${s.workflow} (revision ${s.revision ?? "?"})`);
242
+ console.log(`Exported: ${s.exported_at ?? "unknown"}${s.origin_deployment ? ` from deployment ${s.origin_deployment}` : ""}`);
243
+ if (s.capabilities.length === 0) {
244
+ console.log("Capabilities: none");
245
+ } else {
246
+ console.log("Capabilities:");
247
+ for (const c of s.capabilities) {
248
+ const columns = c.approved_columns > 0 ? `, ${c.approved_columns} approved column(s)` : "";
249
+ console.log(` - ${c.key} v${c.version ?? "?"}${c.source ? ` (${c.source}${columns})` : ""}`);
250
+ }
251
+ }
252
+ console.log("Safe to import: the bundle matches the deployment key you trust.");
253
+ } else {
254
+ console.error(`Signature verification FAILED: ${result.reason}`);
255
+ console.error("Do not import this bundle. Re-export from the source deployment or confirm the trusted key with the sender.");
256
+ process.exitCode = 1;
257
+ }
258
+ }
259
+
260
+ function explainCommand(options) {
261
+ const status = Number(options._[1]);
262
+ if (!Number.isInteger(status) || status < 100) throw new Error("explain requires an HTTP status, e.g. kavros explain 403");
263
+ let body;
264
+ if (options.body) {
265
+ try {
266
+ body = JSON.parse(options.body);
267
+ } catch {
268
+ body = options.body;
269
+ }
270
+ }
271
+ const explanation = explainFailure({ status, body });
272
+ console.log(`What happened: ${explanation.headline}`);
273
+ console.log(`Egress: ${explanation.egress}`);
274
+ console.log("Next steps:");
275
+ for (const step of explanation.next) console.log(` - ${step}`);
276
+ }
277
+
278
+ async function doctor() {
279
+ const values = config();
280
+ const missing = [];
281
+ if (!values.url) missing.push("KAVROS_API_URL");
282
+ if (!values.apiKey) missing.push("KAVROS_API_KEY");
283
+ if (!values.agentId) missing.push("KAVROS_AGENT_ID");
284
+ if (missing.length > 0) {
285
+ console.error(`Missing environment variables: ${missing.join(", ")}`);
286
+ process.exitCode = 1;
287
+ return;
288
+ }
289
+
290
+ let response;
291
+ try {
292
+ const healthUrl = new URL(values.url);
293
+ healthUrl.pathname = "/health";
294
+ healthUrl.search = "";
295
+ response = await fetchWithTimeout(healthUrl, {
296
+ headers: {
297
+ Authorization: `Bearer ${values.apiKey}`,
298
+ "X-Kavros-Agent-ID": values.agentId,
299
+ },
300
+ }, 10);
301
+ } catch (error) {
302
+ console.error(`Data plane unreachable: ${error.message}`);
303
+ process.exitCode = 1;
304
+ return;
305
+ }
306
+
307
+ const body = await readBody(response);
308
+ if (!response.ok) {
309
+ console.error(`Data plane health check failed (HTTP ${response.status})`);
310
+ printBody(body, true);
311
+ process.exitCode = 1;
312
+ return;
313
+ }
314
+ console.log(`Data plane reachable${body?.build_sha ? ` (build ${body.build_sha})` : ""}.`);
315
+ console.log(`Workload identity configured: ${values.agentId}`);
316
+ }
317
+
318
+ async function main() {
319
+ try {
320
+ const options = parseArgs(process.argv.slice(2));
321
+ if (options.version) {
322
+ console.log(VERSION);
323
+ return;
324
+ }
325
+ if (options.help || options._[0] === undefined) {
326
+ usage();
327
+ return;
328
+ }
329
+ const [command] = options._;
330
+ if (options.help && command && command !== "help") {
331
+ commandHelp(command);
332
+ return;
333
+ }
334
+ if (command === "request") await request(options);
335
+ else if (command === "doctor") await doctor();
336
+ else if (command === "verify-bundle") await verifyBundleCommand(options);
337
+ else if (command === "explain") explainCommand(options);
338
+ else if (command === "help" && options._[1]) commandHelp(options._[1]);
339
+ else throw new Error(`unknown command ${command ?? ""}; run kavros --help`);
340
+ } catch (error) {
341
+ fail(error.message);
342
+ }
343
+ }
344
+
345
+ await main();
package/lib/args.mjs ADDED
@@ -0,0 +1,84 @@
1
+ // Pure argument and configuration helpers for the Kavros CLI. Kept free of
2
+ // I/O so the test suite can exercise them without network or environment setup.
3
+
4
+ export function parseArgs(argv) {
5
+ const options = { _: [] };
6
+ for (let index = 0; index < argv.length; index += 1) {
7
+ const value = argv[index];
8
+ if (!value.startsWith("-")) {
9
+ options._.push(value);
10
+ continue;
11
+ }
12
+ if (value === "--help" || value === "-h") options.help = true;
13
+ else if (value === "--version" || value === "-v") options.version = true;
14
+ else if (value === "--json") options.json = true;
15
+ else if (value === "--action" || value === "--target" || value === "--content" || value === "--header" || value === "--timeout" || value === "--public-key" || value === "--body") {
16
+ const next = argv[index + 1];
17
+ if (!next || next.startsWith("-")) throw new Error(`${value} requires a value`);
18
+ index += 1;
19
+ if (value === "--header") {
20
+ options.headers ??= [];
21
+ options.headers.push(next);
22
+ } else {
23
+ options[value.slice(2)] = next;
24
+ }
25
+ } else {
26
+ throw new Error(`unknown option: ${value}`);
27
+ }
28
+ }
29
+ return options;
30
+ }
31
+
32
+ export function config() {
33
+ return {
34
+ url: process.env.KAVROS_API_URL,
35
+ apiKey: process.env.KAVROS_API_KEY,
36
+ agentId: process.env.KAVROS_AGENT_ID,
37
+ };
38
+ }
39
+
40
+ export function requireConfig(values = config()) {
41
+ const missing = missingConfig(values);
42
+ if (missing.length > 0) {
43
+ throw new Error(`missing ${missing.join(", ")}; configure the workload environment first`);
44
+ }
45
+ return values;
46
+ }
47
+
48
+ /** Environment names that are unset, in config order — for the explain path. */
49
+ export function missingConfig(values = config()) {
50
+ return Object.entries(values)
51
+ .filter(([, value]) => !value)
52
+ .map(([key]) => key === "url" ? "KAVROS_API_URL" : key === "apiKey" ? "KAVROS_API_KEY" : "KAVROS_AGENT_ID");
53
+ }
54
+
55
+ export function parseHeaders(values = []) {
56
+ const headers = {};
57
+ for (const value of values) {
58
+ const separator = value.indexOf(":");
59
+ if (separator < 1) throw new Error(`invalid --header value ${JSON.stringify(value)}; use name:value`);
60
+ headers[value.slice(0, separator).trim()] = value.slice(separator + 1).trim();
61
+ }
62
+ return headers;
63
+ }
64
+
65
+ export function parseContent(value) {
66
+ if (value === undefined) return "";
67
+ try {
68
+ return JSON.stringify(JSON.parse(value));
69
+ } catch {
70
+ return value;
71
+ }
72
+ }
73
+
74
+ // Headers that should never be blindly forwarded to the upstream target:
75
+ // workload credentials (x-kavros-*), end-user credentials, and the Host
76
+ // header, which the upstream connection must derive itself.
77
+ const SENSITIVE_HEADER_NAMES = new Set(["authorization", "cookie", "host"]);
78
+
79
+ export function sensitiveHeaders(headers) {
80
+ return Object.keys(headers).filter((name) => {
81
+ const lower = name.toLowerCase();
82
+ return SENSITIVE_HEADER_NAMES.has(lower) || lower.startsWith("x-kavros-");
83
+ });
84
+ }
package/lib/bundle.mjs ADDED
@@ -0,0 +1,142 @@
1
+ // Signed workflow-bundle verification for the Kavros CLI. Pure functions,
2
+ // no dependencies beyond Node's crypto — mirrors the control plane's
3
+ // lib/workflowBundle.ts (canonical JSON + Ed25519 over the canonical bytes)
4
+ // so "trust us" becomes "run kavros verify-bundle" locally, including on
5
+ // machines that never talk to a deployment.
6
+
7
+ import crypto from "node:crypto";
8
+
9
+ const BUNDLE_FORMAT = "kavros-workflow-bundle";
10
+ const BUNDLE_FORMAT_VERSION = 2;
11
+
12
+ /** Deterministic JSON with recursively sorted keys — byte-identical to the
13
+ * control plane's canonical form, which is what the signature covers. */
14
+ export function canonicalJson(value) {
15
+ const seen = new WeakSet();
16
+ const walk = (node) => {
17
+ if (node === null || typeof node !== "object") return node;
18
+ if (Array.isArray(node)) return node.map(walk);
19
+ if (seen.has(node)) throw new Error("Cannot canonicalize a bundle with reference cycles.");
20
+ seen.add(node);
21
+ const out = {};
22
+ for (const key of Object.keys(node).sort()) {
23
+ out[key] = walk(node[key]);
24
+ }
25
+ return out;
26
+ };
27
+ return JSON.stringify(walk(value));
28
+ }
29
+
30
+ /**
31
+ * Verify a signed bundle against a trusted public key.
32
+ *
33
+ * @param {object} signed Parsed bundle file: { bundle, signature, algorithm? }
34
+ * @param {object} options
35
+ * @param {string} [options.publicKey] Base64 SPKI DER or raw32 Ed25519 key
36
+ * (the CP_POLICY_PUBLIC_KEY value provisioning shares between deployments).
37
+ * Falls back to KAVROS_POLICY_PUBLIC_KEY / CP_POLICY_PUBLIC_KEY env vars.
38
+ * @param {string} [options.expectedWorkflow] When set, fails if the bundle is
39
+ * for a different workflow name (guards against swapping files).
40
+ * @returns {{ ok: boolean, reason?: string, summary?: object }}
41
+ */
42
+ export function verifyBundle(signed, options = {}) {
43
+ if (!signed || typeof signed !== "object") {
44
+ return { ok: false, reason: "Not a signed bundle file — expected a JSON object." };
45
+ }
46
+ if (signed.algorithm !== "ed25519") {
47
+ return {
48
+ ok: false,
49
+ reason: `Unsupported signature algorithm: ${signed.algorithm ?? "none"}. Kavros bundles are Ed25519-signed; this file may not be a bundle export.`,
50
+ };
51
+ }
52
+ const publicKeyBase64 = options.publicKey ?? process.env.KAVROS_POLICY_PUBLIC_KEY ?? process.env.CP_POLICY_PUBLIC_KEY;
53
+ if (!publicKeyBase64) {
54
+ return {
55
+ ok: false,
56
+ reason: "No trusted public key supplied. Pass --public-key <base64> or set KAVROS_POLICY_PUBLIC_KEY (the CP_POLICY_PUBLIC_KEY value from provisioning).",
57
+ };
58
+ }
59
+ const bundle = signed.bundle;
60
+ if (
61
+ !bundle ||
62
+ typeof bundle !== "object" ||
63
+ bundle.format !== BUNDLE_FORMAT ||
64
+ bundle.format_version !== BUNDLE_FORMAT_VERSION ||
65
+ !bundle.workflow ||
66
+ typeof bundle.workflow.name !== "string"
67
+ ) {
68
+ return { ok: false, reason: "Not a valid Kavros workflow bundle (missing format markers or workflow name)." };
69
+ }
70
+ if (typeof signed.signature !== "string" || signed.signature.length === 0) {
71
+ return { ok: false, reason: "The bundle file has no signature." };
72
+ }
73
+
74
+ let publicKey;
75
+ try {
76
+ const der = Buffer.from(publicKeyBase64, "base64");
77
+ try {
78
+ publicKey = crypto.createPublicKey({ key: der, format: "der", type: "spki" });
79
+ } catch {
80
+ if (der.length !== 32) {
81
+ return { ok: false, reason: `The supplied public key is neither SPKI DER nor raw32 Ed25519 (${der.length} bytes).` };
82
+ }
83
+ publicKey = crypto.createPublicKey({
84
+ key: { kty: "OKP", crv: "Ed25519", x: der.toString("base64url") },
85
+ format: "jwk",
86
+ });
87
+ }
88
+ } catch {
89
+ return { ok: false, reason: "The supplied public key could not be parsed." };
90
+ }
91
+
92
+ let canonical;
93
+ try {
94
+ canonical = canonicalJson(bundle);
95
+ } catch (error) {
96
+ return { ok: false, reason: error instanceof Error ? error.message : "Unserializable bundle." };
97
+ }
98
+
99
+ let ok = false;
100
+ try {
101
+ ok = crypto.verify(null, Buffer.from(canonical, "utf8"), publicKey, Buffer.from(signed.signature, "base64"));
102
+ } catch {
103
+ ok = false;
104
+ }
105
+ if (!ok) {
106
+ return {
107
+ ok: false,
108
+ reason: "Signature verification failed — the bundle was modified after signing or was signed by a different deployment.",
109
+ };
110
+ }
111
+
112
+ if (options.expectedWorkflow && bundle.workflow.name !== options.expectedWorkflow) {
113
+ return {
114
+ ok: false,
115
+ reason: `Signature is valid, but this bundle is for workflow "${bundle.workflow.name}", not "${options.expectedWorkflow}".`,
116
+ };
117
+ }
118
+
119
+ const capabilities = Array.isArray(bundle.capabilities) ? bundle.capabilities : [];
120
+ return {
121
+ ok: true,
122
+ summary: {
123
+ workflow: bundle.workflow.name,
124
+ revision: bundle.workflow.revision,
125
+ exported_at: bundle.exported_at,
126
+ origin_deployment: bundle.origin?.deployment_id ?? null,
127
+ capabilities: capabilities.map((c) => ({
128
+ key: c.key,
129
+ version: c.version,
130
+ source: c.source,
131
+ approved_columns: Array.isArray(c.approved_columns) ? c.approved_columns.length : 0,
132
+ })),
133
+ signature_key_fingerprint: fingerprint(publicKey),
134
+ },
135
+ };
136
+ }
137
+
138
+ /** Short SHA-256 fingerprint of the verifying public key, for audit notes. */
139
+ export function fingerprint(publicKey) {
140
+ const der = publicKey.export({ format: "der", type: "spki" });
141
+ return `sha256:${crypto.createHash("sha256").update(der).digest("hex").slice(0, 16)}`;
142
+ }
@@ -0,0 +1,123 @@
1
+ // Plain-language error explanation for CLI failures — the plan's rule that a
2
+ // failure sentence must say what happened, whether egress was reached, and
3
+ // what to do next. Pure: maps known API/error payloads to human guidance.
4
+
5
+ /**
6
+ * Build an operator-readable explanation for a failed governed request.
7
+ *
8
+ * @param {object} params
9
+ * @param {number} [params.status] HTTP status of the data-plane reply
10
+ * @param {object|string|null} [params.body] Parsed response body (or text)
11
+ * @param {string[]} [params.configMissing] Missing env var names, if config failed
12
+ * @returns {{ headline: string, egress: string, next: string[] }}
13
+ */
14
+ export function explainFailure({ status, body, configMissing = [] } = {}) {
15
+ if (configMissing.length > 0) {
16
+ return {
17
+ headline: "This shell has no workload credentials configured.",
18
+ egress: "Nothing was sent — no egress was attempted.",
19
+ next: [
20
+ "Export the three variables from your workload's provisioning page:",
21
+ " export KAVROS_API_URL=\"https://<data-plane-host>/api/agent/egress\"",
22
+ " export KAVROS_API_KEY=\"<workload-api-key>\"",
23
+ " export KAVROS_AGENT_ID=\"<workload-id>\"",
24
+ "Then run `kavros doctor` to confirm connectivity.",
25
+ ],
26
+ };
27
+ }
28
+
29
+ const reason =
30
+ typeof body === "object" && body !== null
31
+ ? body.reason ?? body.error ?? null
32
+ : typeof body === "string" && body
33
+ ? body
34
+ : null;
35
+ const blocked = typeof body === "object" && body !== null && body.error === "Blocked by Kavros";
36
+
37
+ if (blocked) {
38
+ return {
39
+ headline: `Blocked by policy${reason ? ` — ${reason}` : "."}`,
40
+ egress: "The request was stopped at the data plane. Nothing reached the target, and blocked content is never forwarded or stored.",
41
+ next: [
42
+ "Read the reason above: it names the violated rule (DLP match, unapproved target, or identity state).",
43
+ "If the target is legitimate, ask your admin to add it to the workload's signed policy — do not bypass the data plane.",
44
+ "Run `kavros doctor` to rule out an identity problem (halted or revoked workloads also block).",
45
+ ],
46
+ };
47
+ }
48
+
49
+ if (status === 401 || status === 403) {
50
+ return {
51
+ headline: "Your workload identity was rejected.",
52
+ egress: "The data plane refused the credentials before any policy evaluation — nothing was sent onward.",
53
+ next: [
54
+ "Check that KAVROS_API_KEY and KAVROS_AGENT_ID belong to the same registered workload.",
55
+ "The workload may be halted or revoked — an admin can check its state in the Agent Registry.",
56
+ "If the key was rotated, fetch the current key from the control plane and re-export it.",
57
+ ],
58
+ };
59
+ }
60
+
61
+ if (status === 404) {
62
+ return {
63
+ headline: "The data plane does not recognize this workload or route.",
64
+ egress: "Nothing was forwarded — the request stopped at the data plane.",
65
+ next: [
66
+ "Confirm KAVROS_API_URL points at the data-plane egress endpoint (…/api/agent/egress), not the control plane UI.",
67
+ "Confirm KAVROS_AGENT_ID matches a workload registered on this deployment.",
68
+ ],
69
+ };
70
+ }
71
+
72
+ if (status === 429) {
73
+ return {
74
+ headline: "Rate or quota limit reached.",
75
+ egress: "The request was refused at the data plane; nothing was sent onward.",
76
+ next: [
77
+ "Wait for the current window to reset, or ask your admin to raise the workload's quota.",
78
+ "Repeated 429s are visible in Usage — an admin can identify which workload is consuming the budget.",
79
+ ],
80
+ };
81
+ }
82
+
83
+ if (status === 502 || status === 504) {
84
+ return {
85
+ headline: "The approved upstream target could not be reached.",
86
+ egress: "Policy checks passed, but the target itself failed or timed out after egress was attempted.",
87
+ next: [
88
+ "This is a target-side failure, not a policy denial — retry or check the target service's health.",
89
+ "If it persists, verify the target URL in the workload's policy matches a healthy endpoint.",
90
+ ],
91
+ };
92
+ }
93
+
94
+ if (status === 500 || status === 503) {
95
+ return {
96
+ headline: "The data plane reported an internal error.",
97
+ egress: "The request failed inside the data plane; depending on the stage, egress may not have been attempted.",
98
+ next: [
99
+ "Retry once, then check the deployment's health page or contact the operator.",
100
+ "Include the request timestamp in any report — data-plane logs are audit-linked.",
101
+ ],
102
+ };
103
+ }
104
+
105
+ if (status !== undefined) {
106
+ return {
107
+ headline: `The data plane returned HTTP ${status}.`,
108
+ egress: status >= 500 ? "A server-side failure occurred." : "The request was refused before egress.",
109
+ next: ["Run `kavros doctor` to verify connectivity and identity, then retry."],
110
+ };
111
+ }
112
+
113
+ return {
114
+ headline: reason ? `Request failed — ${reason}` : "Request failed.",
115
+ egress: "The failure occurred before a verdict was reached; treat the target as unreached.",
116
+ next: ["Run `kavros doctor` to verify connectivity and identity, then retry."],
117
+ };
118
+ }
119
+
120
+ /** One-line summary for scripts/CI: headline only, no advice. */
121
+ export function explainOneLine(explanation) {
122
+ return explanation.headline;
123
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@kavrosai/cli",
3
+ "version": "0.3.0",
4
+ "description": "Language-agnostic CLI for Kavros workload egress and diagnostics",
5
+ "type": "module",
6
+ "bin": {
7
+ "kavros": "bin/kavros.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "keywords": [
22
+ "kavros",
23
+ "ai",
24
+ "llm",
25
+ "agents",
26
+ "security",
27
+ "egress",
28
+ "dlp",
29
+ "policy"
30
+ ],
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/Kavrosai/cli.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/Kavrosai/cli/issues"
38
+ },
39
+ "homepage": "https://kavros.xyz"
40
+ }