@atbash/cli 0.5.15 → 0.6.1-dev.2
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 +389 -2
- package/dist/bin/atbash.js +39 -4
- package/dist/bin/atbash.js.map +1 -1
- package/dist/commands/config-cmd.js +15 -15
- package/dist/commands/config-cmd.js.map +1 -1
- package/dist/commands/connect.d.ts +115 -1
- package/dist/commands/connect.js +292 -73
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/github-scan.d.ts +104 -0
- package/dist/commands/github-scan.js +1054 -0
- package/dist/commands/github-scan.js.map +1 -0
- package/dist/commands/held.js +7 -2
- package/dist/commands/held.js.map +1 -1
- package/dist/commands/history.js +4 -1
- package/dist/commands/history.js.map +1 -1
- package/dist/commands/judge-options.d.ts +21 -0
- package/dist/commands/judge-options.js +24 -0
- package/dist/commands/judge-options.js.map +1 -0
- package/dist/commands/judge.js +5 -2
- package/dist/commands/judge.js.map +1 -1
- package/dist/commands/mcp-cmd.d.ts +13 -0
- package/dist/commands/mcp-cmd.js +216 -0
- package/dist/commands/mcp-cmd.js.map +1 -0
- package/dist/commands/policy.js +4 -1
- package/dist/commands/policy.js.map +1 -1
- package/dist/commands/scan.d.ts +45 -0
- package/dist/commands/scan.js +301 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/setup.d.ts +533 -0
- package/dist/commands/setup.js +2653 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/stats.js +3 -1
- package/dist/commands/stats.js.map +1 -1
- package/dist/commands/status.js +4 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/tier.js +4 -1
- package/dist/commands/tier.js.map +1 -1
- package/dist/commands/tools.js +11 -4
- package/dist/commands/tools.js.map +1 -1
- package/dist/commands/whoami.js +4 -1
- package/dist/commands/whoami.js.map +1 -1
- package/dist/lib/grade-llm.d.ts +38 -0
- package/dist/lib/grade-llm.js +64 -0
- package/dist/lib/grade-llm.js.map +1 -0
- package/dist/lib/llm.d.ts +34 -0
- package/dist/lib/llm.js +101 -0
- package/dist/lib/llm.js.map +1 -0
- package/dist/lib/policy.d.ts +40 -0
- package/dist/lib/policy.js +94 -0
- package/dist/lib/policy.js.map +1 -0
- package/dist/lib/risk.d.ts +112 -0
- package/dist/lib/risk.js +288 -0
- package/dist/lib/risk.js.map +1 -0
- package/dist/lib/threats-llm.d.ts +39 -0
- package/dist/lib/threats-llm.js +72 -0
- package/dist/lib/threats-llm.js.map +1 -0
- package/dist/shared/atbash-targets.d.ts +49 -0
- package/dist/shared/atbash-targets.js +63 -0
- package/dist/shared/atbash-targets.js.map +1 -0
- package/dist/shared/openclaw-runtime.d.ts +221 -0
- package/dist/shared/openclaw-runtime.js +476 -0
- package/dist/shared/openclaw-runtime.js.map +1 -0
- package/dist/shared/win-exec.d.ts +63 -0
- package/dist/shared/win-exec.js +147 -0
- package/dist/shared/win-exec.js.map +1 -0
- package/package.json +4 -3
package/dist/lib/llm.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared OpenRouter transport for the scan's optional LLM passes.
|
|
4
|
+
*
|
|
5
|
+
* Every pass here spends the USER'S OWN key. The hosted dashboard runs equivalent
|
|
6
|
+
* passes against a platform key; running locally there is no platform key, so the
|
|
7
|
+
* user supplies one or the scan stays deterministic.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately small: one request per pass, a hard timeout, and no connection
|
|
10
|
+
* pooling. The dashboard's transport multiplexes waves of batches over an HTTP/2
|
|
11
|
+
* pool because it grades 87-tool surfaces from a server; a terminal scan issues a
|
|
12
|
+
* handful of calls and does not need any of that machinery.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.DEFAULT_MODEL = void 0;
|
|
16
|
+
exports.chatJson = chatJson;
|
|
17
|
+
exports.coerceStr = coerceStr;
|
|
18
|
+
/**
|
|
19
|
+
* Measured default from the dashboard's enrichment pass. Overridable with
|
|
20
|
+
* --model; re-measure before repointing, because the tuning below was tuned
|
|
21
|
+
* against this model.
|
|
22
|
+
*/
|
|
23
|
+
exports.DEFAULT_MODEL = "z-ai/glm-5.3-flash";
|
|
24
|
+
const OPENROUTER_ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
|
|
25
|
+
/**
|
|
26
|
+
* OpenRouter settings that are load-bearing, not cosmetic.
|
|
27
|
+
*
|
|
28
|
+
* `reasoning.effort: "low"` — the default scan model is a reasoning model and
|
|
29
|
+
* otherwise spends ~1000 tokens per call on deliberation we never read, which we
|
|
30
|
+
* pay for twice: in latency, and in max_tokens, which COUNTS reasoning tokens, so
|
|
31
|
+
* the JSON gets truncated mid-object and the whole pass is lost.
|
|
32
|
+
* `provider.sort: "throughput"` — routing spread for the same model and prompt was
|
|
33
|
+
* measured between 9.8s and 64s. Verified harmless for non-reasoning models.
|
|
34
|
+
*/
|
|
35
|
+
const REQUEST_TUNING = {
|
|
36
|
+
reasoning: { effort: "low" },
|
|
37
|
+
provider: { sort: "throughput", allow_fallbacks: true },
|
|
38
|
+
};
|
|
39
|
+
/** Strip a ```json fence if the model wrapped its answer, which not every
|
|
40
|
+
* provider avoids even with response_format set. */
|
|
41
|
+
function unfence(raw) {
|
|
42
|
+
const t = raw.trim();
|
|
43
|
+
if (!t.startsWith("```"))
|
|
44
|
+
return t;
|
|
45
|
+
return t.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim();
|
|
46
|
+
}
|
|
47
|
+
async function chatJson(options) {
|
|
48
|
+
const ctrl = new AbortController();
|
|
49
|
+
// Never unbounded: a hung socket must not hold the terminal open.
|
|
50
|
+
const timer = setTimeout(() => ctrl.abort(), options.timeoutMs ?? 30000);
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(OPENROUTER_ENDPOINT, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
57
|
+
"HTTP-Referer": "https://atbash.ai",
|
|
58
|
+
"X-Title": "ATBASH Local Scan",
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
model: options.model || exports.DEFAULT_MODEL,
|
|
62
|
+
messages: [
|
|
63
|
+
{ role: "system", content: options.system },
|
|
64
|
+
{ role: "user", content: options.user },
|
|
65
|
+
],
|
|
66
|
+
temperature: 0,
|
|
67
|
+
max_tokens: options.maxTokens ?? 4000,
|
|
68
|
+
response_format: { type: "json_object" },
|
|
69
|
+
...REQUEST_TUNING,
|
|
70
|
+
}),
|
|
71
|
+
signal: ctrl.signal,
|
|
72
|
+
redirect: "error",
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
// Status only. A provider error body can echo the request, and the request
|
|
76
|
+
// carries the user's key in a header.
|
|
77
|
+
return { data: null, requests: 1, error: `provider returned ${res.status}` };
|
|
78
|
+
}
|
|
79
|
+
const body = (await res.json());
|
|
80
|
+
const content = body.choices?.[0]?.message?.content;
|
|
81
|
+
if (!content)
|
|
82
|
+
return { data: null, requests: 1, error: "provider returned no content" };
|
|
83
|
+
return { data: JSON.parse(unfence(content)), requests: 1 };
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
87
|
+
return {
|
|
88
|
+
data: null,
|
|
89
|
+
requests: 1,
|
|
90
|
+
// An abort is our own deadline, not a provider fault — say which.
|
|
91
|
+
error: ctrl.signal.aborted ? "timed out" : msg,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function coerceStr(v, cap) {
|
|
99
|
+
return typeof v === "string" ? v.slice(0, cap) : "";
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=llm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"llm.js","sourceRoot":"","sources":["../../src/lib/llm.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan → starter policy.
|
|
3
|
+
*
|
|
4
|
+
* Turns the risk report into a first-draft governance boundary: a mandate line,
|
|
5
|
+
* Red Lines (block/jail paths), Approval boundaries (hold paths), and always the
|
|
6
|
+
* self-modification containment rule. This is what makes a scan ACTIONABLE rather
|
|
7
|
+
* than a read-only inventory. Deterministic, grouped by category, and needs no
|
|
8
|
+
* API key.
|
|
9
|
+
*
|
|
10
|
+
* PROVENANCE: ported from the dashboard's `src/lib/scan/policy-suggest.ts`.
|
|
11
|
+
* Suggesting a boundary is not the same as deploying one — deploying stays a
|
|
12
|
+
* dashboard function, and this command never writes a policy anywhere.
|
|
13
|
+
*
|
|
14
|
+
* Divergences from the dashboard version, all found against real output:
|
|
15
|
+
* - Takes the agent's declared instructions directly instead of a
|
|
16
|
+
* `CapabilityManifest`, because the CLI has no manifest type.
|
|
17
|
+
* - A TOOL that already carries a red line elsewhere in the same category is
|
|
18
|
+
* dropped from that category's approvals, not the whole category. Upstream
|
|
19
|
+
* emits both unconditionally, producing "never perform data access / egress"
|
|
20
|
+
* and "require human approval before data access / egress" for the SAME tool
|
|
21
|
+
* in one boundary. An earlier version of this fix dropped the whole category
|
|
22
|
+
* instead of just that tool — which silently discarded a DIFFERENT tool's
|
|
23
|
+
* real, distinct hold finding whenever it shared a category name with
|
|
24
|
+
* anything blocked (found against a real `export_crm_contacts` +
|
|
25
|
+
* `delete_customer_records` surface: the export tool's approval line vanished
|
|
26
|
+
* entirely, with no trace of it anywhere in the printed boundary). The
|
|
27
|
+
* collision check itself must key on source+name, not name alone — otherwise
|
|
28
|
+
* a blocked `x` on one MCP server can wrongly erase a held `x` on another.
|
|
29
|
+
* - Drops the appended word "actions", which doubled up on every category that
|
|
30
|
+
* already ended in one ("destructive action actions").
|
|
31
|
+
* - Prefers primary paths when picking example tool names.
|
|
32
|
+
*/
|
|
33
|
+
import { type RiskReport } from "./risk";
|
|
34
|
+
export interface PolicySuggestion {
|
|
35
|
+
mandate: string;
|
|
36
|
+
redLines: string[];
|
|
37
|
+
approvals: string[];
|
|
38
|
+
notes: string;
|
|
39
|
+
}
|
|
40
|
+
export declare function suggestPolicy(risk: RiskReport, systemPrompt?: string): PolicySuggestion;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.suggestPolicy = suggestPolicy;
|
|
4
|
+
/**
|
|
5
|
+
* Scan → starter policy.
|
|
6
|
+
*
|
|
7
|
+
* Turns the risk report into a first-draft governance boundary: a mandate line,
|
|
8
|
+
* Red Lines (block/jail paths), Approval boundaries (hold paths), and always the
|
|
9
|
+
* self-modification containment rule. This is what makes a scan ACTIONABLE rather
|
|
10
|
+
* than a read-only inventory. Deterministic, grouped by category, and needs no
|
|
11
|
+
* API key.
|
|
12
|
+
*
|
|
13
|
+
* PROVENANCE: ported from the dashboard's `src/lib/scan/policy-suggest.ts`.
|
|
14
|
+
* Suggesting a boundary is not the same as deploying one — deploying stays a
|
|
15
|
+
* dashboard function, and this command never writes a policy anywhere.
|
|
16
|
+
*
|
|
17
|
+
* Divergences from the dashboard version, all found against real output:
|
|
18
|
+
* - Takes the agent's declared instructions directly instead of a
|
|
19
|
+
* `CapabilityManifest`, because the CLI has no manifest type.
|
|
20
|
+
* - A TOOL that already carries a red line elsewhere in the same category is
|
|
21
|
+
* dropped from that category's approvals, not the whole category. Upstream
|
|
22
|
+
* emits both unconditionally, producing "never perform data access / egress"
|
|
23
|
+
* and "require human approval before data access / egress" for the SAME tool
|
|
24
|
+
* in one boundary. An earlier version of this fix dropped the whole category
|
|
25
|
+
* instead of just that tool — which silently discarded a DIFFERENT tool's
|
|
26
|
+
* real, distinct hold finding whenever it shared a category name with
|
|
27
|
+
* anything blocked (found against a real `export_crm_contacts` +
|
|
28
|
+
* `delete_customer_records` surface: the export tool's approval line vanished
|
|
29
|
+
* entirely, with no trace of it anywhere in the printed boundary). The
|
|
30
|
+
* collision check itself must key on source+name, not name alone — otherwise
|
|
31
|
+
* a blocked `x` on one MCP server can wrongly erase a held `x` on another.
|
|
32
|
+
* - Drops the appended word "actions", which doubled up on every category that
|
|
33
|
+
* already ended in one ("destructive action actions").
|
|
34
|
+
* - Prefers primary paths when picking example tool names.
|
|
35
|
+
*/
|
|
36
|
+
const risk_1 = require("./risk");
|
|
37
|
+
/**
|
|
38
|
+
* Cap and de-duplicate example tool names per category.
|
|
39
|
+
*
|
|
40
|
+
* PRIMARY paths first: a tool that belongs to a category only as a SECONDARY harm
|
|
41
|
+
* is a real member of it, but a confusing example. `delete-customer-records`
|
|
42
|
+
* carries a data-egress harm, yet naming it as the example for data egress when
|
|
43
|
+
* `export-crm-contacts` is right there reads like a misclassification.
|
|
44
|
+
*/
|
|
45
|
+
function examplesFor(paths) {
|
|
46
|
+
const ordered = [...paths].sort((a, b) => Number(!!a.secondary) - Number(!!b.secondary));
|
|
47
|
+
const names = [...new Set(ordered.map((p) => p.tool))];
|
|
48
|
+
return names.slice(0, 3).join(", ") + (names.length > 3 ? ", …" : "");
|
|
49
|
+
}
|
|
50
|
+
function suggestPolicy(risk, systemPrompt) {
|
|
51
|
+
// Prefer the agent's own declared instructions. Otherwise a neutral, editable
|
|
52
|
+
// default — NEVER built from risk category names, which are what the agent must
|
|
53
|
+
// avoid, not its operating scope.
|
|
54
|
+
const mandate = systemPrompt?.trim() ||
|
|
55
|
+
"Use only the connected tools, and only as needed to fulfil the assigned task. (Edit to state the specific outcome this agent owns.)";
|
|
56
|
+
const byCategory = (verdicts) => {
|
|
57
|
+
const groups = new Map();
|
|
58
|
+
for (const p of risk.paths) {
|
|
59
|
+
if (!verdicts.includes(p.recommendedVerdict))
|
|
60
|
+
continue;
|
|
61
|
+
const arr = groups.get(p.category) ?? [];
|
|
62
|
+
arr.push({ tool: p.tool, source: p.source, secondary: p.secondary });
|
|
63
|
+
groups.set(p.category, arr);
|
|
64
|
+
}
|
|
65
|
+
return groups;
|
|
66
|
+
};
|
|
67
|
+
const redLineGroups = byCategory(["block", "jail"]);
|
|
68
|
+
const redLines = Array.from(redLineGroups.entries()).map(([cat, tools]) => `Never perform ${cat} autonomously (e.g. ${examplesFor(tools)}).`);
|
|
69
|
+
// Governance staple — always present.
|
|
70
|
+
redLines.push("Never modify its own mandate, red lines, or authority.");
|
|
71
|
+
const approvals = Array.from(byCategory(["hold"]).entries())
|
|
72
|
+
// Most-restrictive-wins, PER TOOL. A tool's verdict is uniform across every
|
|
73
|
+
// path it appears in (classify() forces every secondary to the primary's
|
|
74
|
+
// verdict), so the only way the SAME tool shows up here AND in redLineGroups
|
|
75
|
+
// for this category is if it can't — it can't. What DOES happen: two
|
|
76
|
+
// DIFFERENT tools land in the same category, one blocked, one merely held.
|
|
77
|
+
// Dropping the whole category then would erase the held tool's real finding
|
|
78
|
+
// with no trace anywhere in the boundary — worse than the "never X"/"ask
|
|
79
|
+
// before X" wording clash this exists to avoid. So only drop the specific
|
|
80
|
+
// tool names the category's red line already names; a different tool's hold
|
|
81
|
+
// finding survives under its own example.
|
|
82
|
+
.map(([cat, tools]) => {
|
|
83
|
+
// Keyed on source+name: a block/jail path in this category belongs to a
|
|
84
|
+
// SPECIFIC tool instance, and only that instance should drop out of the
|
|
85
|
+
// approvals list here — not any other tool that merely shares its name.
|
|
86
|
+
const alreadyRedLined = new Set((redLineGroups.get(cat) ?? []).map((t) => (0, risk_1.toolKey)(t.source, t.tool)));
|
|
87
|
+
const remaining = tools.filter((t) => !alreadyRedLined.has((0, risk_1.toolKey)(t.source, t.tool)));
|
|
88
|
+
return remaining.length > 0 ? `Require human approval before ${cat} (e.g. ${examplesFor(remaining)}).` : null;
|
|
89
|
+
})
|
|
90
|
+
.filter((line) => line !== null);
|
|
91
|
+
const notes = `Draft boundary generated from the ${risk.method === "llm" ? "model-analyzed" : "scanned"} capability surface (${risk.paths.length} paths). Review before enforcing — it reflects authorized exposure, not observed behavior.`;
|
|
92
|
+
return { mandate, redLines, approvals, notes };
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=policy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy.js","sourceRoot":"","sources":["../../src/lib/policy.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic risk / blast-radius derivation for a locally scanned surface.
|
|
3
|
+
*
|
|
4
|
+
* Turns a tool surface into the "what could go wrong" report: each tool is
|
|
5
|
+
* matched to a consequence class, a severity, and a recommended runtime verdict.
|
|
6
|
+
* It reasons only over the DECLARED surface — what the agent is authorized to
|
|
7
|
+
* attempt — which is what a scan authoritatively knows. It never claims
|
|
8
|
+
* certainty, and that framing is what keeps a governance report honest.
|
|
9
|
+
*
|
|
10
|
+
* PROVENANCE: ported from the Atbash dashboard's `src/lib/scan/risk.ts`. The
|
|
11
|
+
* rules table, its ORDER, the severity weights and the scoring band are kept
|
|
12
|
+
* faithful on purpose: a local `atbash scan` that graded a surface differently
|
|
13
|
+
* from the hosted scan would be worse than no local scan at all.
|
|
14
|
+
*
|
|
15
|
+
* DIVERGENCE FROM THE DASHBOARD (deliberate, since the CLI now owns this copy):
|
|
16
|
+
* - `call|calls` removed from external communication, replaced with `phone\w*`.
|
|
17
|
+
* In a tool catalog "calls" overwhelmingly means FUNCTION calls: a real
|
|
18
|
+
* `js_reset` whose description read "bindings created by prior `js` calls"
|
|
19
|
+
* was graded as messaging outsiders. "phone" carries the intent unambiguously.
|
|
20
|
+
* - `add|adds|added|adding` added to state change. A real
|
|
21
|
+
* `js_add_node_module_dir` — which mutates module resolution, and so controls
|
|
22
|
+
* what code can be imported — was graded low/read-only on the word "search"
|
|
23
|
+
* in "module search roots". Spelled out rather than `add\w*`, which would
|
|
24
|
+
* match "address" and "additional".
|
|
25
|
+
* Both were found against a real Codex `node_repl` surface. If these are ported
|
|
26
|
+
* back upstream, delete this block rather than letting the two engines drift.
|
|
27
|
+
*
|
|
28
|
+
* Two further mechanical divergences:
|
|
29
|
+
* - `deriveRisk` takes a tool list rather than a `CapabilityManifest`; the CLI
|
|
30
|
+
* has no manifest type and only `.tools` was ever read.
|
|
31
|
+
* - `ScanTool` is declared here instead of imported, since only two of its
|
|
32
|
+
* fields are used.
|
|
33
|
+
*
|
|
34
|
+
* If the scan logic is ever extracted into a shared package, this file is what
|
|
35
|
+
* gets deleted — nothing else in the CLI depends on its internals.
|
|
36
|
+
*/
|
|
37
|
+
/** One tool/capability the agent can invoke. */
|
|
38
|
+
export interface ScanTool {
|
|
39
|
+
name: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
/** Which config/server surfaced this tool (provenance). */
|
|
42
|
+
source: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Identity key for a specific tool INSTANCE — source + name, not name alone.
|
|
46
|
+
*
|
|
47
|
+
* Two different MCP servers can each expose a tool called e.g. "search": they are
|
|
48
|
+
* different capabilities that happen to share a name. Anything that maps a tool
|
|
49
|
+
* to a per-tool result (which of its paths is unclassified, what an LLM pass
|
|
50
|
+
* graded it) must key on this, or one server's tool silently absorbs another's
|
|
51
|
+
* classification/grade.
|
|
52
|
+
*/
|
|
53
|
+
export declare function toolKey(source: string | undefined, name: string): string;
|
|
54
|
+
export type Verdict = "allow" | "hold" | "block" | "jail";
|
|
55
|
+
export type Severity = "low" | "medium" | "high";
|
|
56
|
+
export interface RiskPath {
|
|
57
|
+
tool: string;
|
|
58
|
+
/** An ADDITIONAL harm this tool matched beyond its primary one. Governed like
|
|
59
|
+
* any other path, but deliberately NOT scored — see summarizePaths. */
|
|
60
|
+
secondary?: boolean;
|
|
61
|
+
category: string;
|
|
62
|
+
severity: Severity;
|
|
63
|
+
recommendedVerdict: Verdict;
|
|
64
|
+
consequence: string;
|
|
65
|
+
/** Where the tool came from, so a report can name the offending config. */
|
|
66
|
+
source?: string;
|
|
67
|
+
}
|
|
68
|
+
export interface RiskReport {
|
|
69
|
+
paths: RiskPath[];
|
|
70
|
+
score: number;
|
|
71
|
+
level: "Low" | "Elevated" | "High";
|
|
72
|
+
summary: string;
|
|
73
|
+
/** How the report was produced. The LLM pass sets "llm"; default deterministic. */
|
|
74
|
+
method?: "deterministic" | "llm";
|
|
75
|
+
}
|
|
76
|
+
export declare const UNCLASSIFIED_CATEGORY = "general action";
|
|
77
|
+
export declare const UNCLASSIFIED_CONSEQUENCE = "no specific high-risk class matched \u2014 allowed and monitored";
|
|
78
|
+
/**
|
|
79
|
+
* Marks the fall-through rule. A tool that matched NOTHING is unclassified — it
|
|
80
|
+
* is not a considered "medium risk" finding, and presenting it as one turns every
|
|
81
|
+
* unrecognized plugin into a false signal.
|
|
82
|
+
*/
|
|
83
|
+
export declare function isUnclassifiedPath(path: {
|
|
84
|
+
category?: string;
|
|
85
|
+
consequence?: string;
|
|
86
|
+
}): boolean;
|
|
87
|
+
/** Score + level + summary from a set of paths — shared by both passes. */
|
|
88
|
+
export declare function summarizePaths(paths: RiskPath[]): Pick<RiskReport, "score" | "level" | "summary">;
|
|
89
|
+
/** Derive the blast-radius report from a scanned tool surface (deterministic). */
|
|
90
|
+
export declare function deriveRisk(tools: ScanTool[]): RiskReport;
|
|
91
|
+
/**
|
|
92
|
+
* Fill in the paths the keyword rules could not classify, using a model's per-tool
|
|
93
|
+
* grade and its plain-English effect.
|
|
94
|
+
*
|
|
95
|
+
* Gap-filling ONLY: a path that matched a real rule is returned untouched, so the
|
|
96
|
+
* deterministic engine stays the auditable floor and an LLM can never downgrade a
|
|
97
|
+
* known-dangerous capability. `critical` folds to `high` because that is the top
|
|
98
|
+
* of this module's scale. The score is recomputed so the level reflects the
|
|
99
|
+
* enriched set.
|
|
100
|
+
*
|
|
101
|
+
* `grades` is keyed by `${source}::${name}` (see `toolKey`), not name alone: two
|
|
102
|
+
* different MCP servers can expose a same-named tool, and a name-only key would
|
|
103
|
+
* apply one tool's grade to the other's unrelated path.
|
|
104
|
+
*
|
|
105
|
+
* Returns the ORIGINAL report object when nothing was filled, so a caller can test
|
|
106
|
+
* `graded !== report` to know whether the pass actually contributed.
|
|
107
|
+
*/
|
|
108
|
+
export declare function gradeUnclassifiedPaths(report: RiskReport, grades: Record<string, {
|
|
109
|
+
severity?: string;
|
|
110
|
+
effect?: string;
|
|
111
|
+
policyGroup?: string;
|
|
112
|
+
}>): RiskReport;
|
package/dist/lib/risk.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic risk / blast-radius derivation for a locally scanned surface.
|
|
4
|
+
*
|
|
5
|
+
* Turns a tool surface into the "what could go wrong" report: each tool is
|
|
6
|
+
* matched to a consequence class, a severity, and a recommended runtime verdict.
|
|
7
|
+
* It reasons only over the DECLARED surface — what the agent is authorized to
|
|
8
|
+
* attempt — which is what a scan authoritatively knows. It never claims
|
|
9
|
+
* certainty, and that framing is what keeps a governance report honest.
|
|
10
|
+
*
|
|
11
|
+
* PROVENANCE: ported from the Atbash dashboard's `src/lib/scan/risk.ts`. The
|
|
12
|
+
* rules table, its ORDER, the severity weights and the scoring band are kept
|
|
13
|
+
* faithful on purpose: a local `atbash scan` that graded a surface differently
|
|
14
|
+
* from the hosted scan would be worse than no local scan at all.
|
|
15
|
+
*
|
|
16
|
+
* DIVERGENCE FROM THE DASHBOARD (deliberate, since the CLI now owns this copy):
|
|
17
|
+
* - `call|calls` removed from external communication, replaced with `phone\w*`.
|
|
18
|
+
* In a tool catalog "calls" overwhelmingly means FUNCTION calls: a real
|
|
19
|
+
* `js_reset` whose description read "bindings created by prior `js` calls"
|
|
20
|
+
* was graded as messaging outsiders. "phone" carries the intent unambiguously.
|
|
21
|
+
* - `add|adds|added|adding` added to state change. A real
|
|
22
|
+
* `js_add_node_module_dir` — which mutates module resolution, and so controls
|
|
23
|
+
* what code can be imported — was graded low/read-only on the word "search"
|
|
24
|
+
* in "module search roots". Spelled out rather than `add\w*`, which would
|
|
25
|
+
* match "address" and "additional".
|
|
26
|
+
* Both were found against a real Codex `node_repl` surface. If these are ported
|
|
27
|
+
* back upstream, delete this block rather than letting the two engines drift.
|
|
28
|
+
*
|
|
29
|
+
* Two further mechanical divergences:
|
|
30
|
+
* - `deriveRisk` takes a tool list rather than a `CapabilityManifest`; the CLI
|
|
31
|
+
* has no manifest type and only `.tools` was ever read.
|
|
32
|
+
* - `ScanTool` is declared here instead of imported, since only two of its
|
|
33
|
+
* fields are used.
|
|
34
|
+
*
|
|
35
|
+
* If the scan logic is ever extracted into a shared package, this file is what
|
|
36
|
+
* gets deleted — nothing else in the CLI depends on its internals.
|
|
37
|
+
*/
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.UNCLASSIFIED_CONSEQUENCE = exports.UNCLASSIFIED_CATEGORY = void 0;
|
|
40
|
+
exports.toolKey = toolKey;
|
|
41
|
+
exports.isUnclassifiedPath = isUnclassifiedPath;
|
|
42
|
+
exports.summarizePaths = summarizePaths;
|
|
43
|
+
exports.deriveRisk = deriveRisk;
|
|
44
|
+
exports.gradeUnclassifiedPaths = gradeUnclassifiedPaths;
|
|
45
|
+
/**
|
|
46
|
+
* Identity key for a specific tool INSTANCE — source + name, not name alone.
|
|
47
|
+
*
|
|
48
|
+
* Two different MCP servers can each expose a tool called e.g. "search": they are
|
|
49
|
+
* different capabilities that happen to share a name. Anything that maps a tool
|
|
50
|
+
* to a per-tool result (which of its paths is unclassified, what an LLM pass
|
|
51
|
+
* graded it) must key on this, or one server's tool silently absorbs another's
|
|
52
|
+
* classification/grade.
|
|
53
|
+
*/
|
|
54
|
+
function toolKey(source, name) {
|
|
55
|
+
return `${source ?? ""}::${name}`;
|
|
56
|
+
}
|
|
57
|
+
// Ordered most-severe first; first match wins. Patterns run against
|
|
58
|
+
// "name description" lowercased and tokenized (see classify()).
|
|
59
|
+
//
|
|
60
|
+
// Every keyword is \b-anchored on BOTH sides, either as a whole token or as an
|
|
61
|
+
// explicit stem (`deploy\w*`). Bare substrings look equivalent but are not: they
|
|
62
|
+
// fire on unrelated words that merely contain the keyword, and because the rules
|
|
63
|
+
// are ordered most-severe-first, a stray substring hit OUTRANKS the correct rule.
|
|
64
|
+
// Real misclassifications this prevents: "design_review"/"assign_reviewer" ->
|
|
65
|
+
// binding commitment (`sign`), "get_executive_summary" -> runtime execution
|
|
66
|
+
// (`exec`), "introduce_yourself" -> containment/jail (`self`), "list_assets" and
|
|
67
|
+
// "get_settings" -> state change (`set`), "postgres_list_tables" -> external
|
|
68
|
+
// communication (`post`), "recall_conversation" -> external communication (`call`).
|
|
69
|
+
const RULES = [
|
|
70
|
+
{
|
|
71
|
+
test: /\b(polic(y|ies)|mandate|red\s?line|guardrail|self|jailbreak|disabl\w*)\b|\b(escalat\w*\s+privilege|privilege\s+escalat\w*)/,
|
|
72
|
+
category: "self-modification / containment",
|
|
73
|
+
severity: "high",
|
|
74
|
+
verdict: "jail",
|
|
75
|
+
consequence: "could weaken or bypass its own authority — loss of control",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
test: /\b(delete\w*|drop|destroy\w*|rm|remove\w*|force\s?push|wipe|truncate|revoke\w*)\b/,
|
|
79
|
+
category: "destructive action",
|
|
80
|
+
severity: "high",
|
|
81
|
+
verdict: "block",
|
|
82
|
+
consequence: "irreversible data/infrastructure loss",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
test: /\b(pay|payment\w*|transfer\w*|refund\w*|reward\w*|charge\w*|invoice\w*|payout\w*|wire|disburse\w*)\b/,
|
|
86
|
+
category: "financial",
|
|
87
|
+
severity: "high",
|
|
88
|
+
verdict: "hold",
|
|
89
|
+
consequence: "direct financial loss",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
test: /\b(shell|exec|execut(e|es|ed|ing|ion)|command|bash|python|code\s?interpreter|git|deploy\w*|terraform)\b/,
|
|
93
|
+
category: "runtime execution",
|
|
94
|
+
severity: "high",
|
|
95
|
+
verdict: "block",
|
|
96
|
+
consequence: "arbitrary code / infra actions at the runtime boundary",
|
|
97
|
+
},
|
|
98
|
+
// The read-proximity clause is bounded to one intervening word: "read customer
|
|
99
|
+
// records" is data egress, but "read a document ... for the user" is not — an
|
|
100
|
+
// unbounded .* made every read tool whose description says "the user" high-severity.
|
|
101
|
+
{
|
|
102
|
+
test: /\b(export\w*|download\w*|scrape\w*|dump|crm|database|sql|quer(y|ies))\b|\bread\w*\s+(\w+\s+)?(customer|user|pii|record)/,
|
|
103
|
+
category: "data access / egress",
|
|
104
|
+
severity: "high",
|
|
105
|
+
verdict: "hold",
|
|
106
|
+
consequence: "data leakage / privacy breach",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
test: /\b(sign|signs|signed|signature|contract\w*|agree\w*|commit|commits|committed|commitment|order|orders|purchase\w*|book)\b|\bapprove\s?terms/,
|
|
110
|
+
category: "binding commitment",
|
|
111
|
+
severity: "high",
|
|
112
|
+
verdict: "hold",
|
|
113
|
+
consequence: "unauthorized commitment / liability",
|
|
114
|
+
},
|
|
115
|
+
// Autonomy-first: BLOCK (auto-deny) and ALLOW (auto-proceed, monitored) are both
|
|
116
|
+
// AUTOMATIC — the agent gets a decision without a human. HOLD is the narrow
|
|
117
|
+
// "pause for a human" set, reserved above for the truly irreversible
|
|
118
|
+
// external-harm categories. Reversible, monitorable actions default to ALLOW so
|
|
119
|
+
// a governed agent stays autonomous instead of routing half its work to a human.
|
|
120
|
+
{
|
|
121
|
+
test: /\b(send\w*|email\w*|message\w*|post|posts|publish\w*|tweet\w*|notif(y|ies|ication\w*)|slack|sms|phone\w*|contact\w*|broadcast\w*)\b/,
|
|
122
|
+
category: "external communication",
|
|
123
|
+
severity: "medium",
|
|
124
|
+
verdict: "allow",
|
|
125
|
+
consequence: "external message sent on the owner's behalf (monitored)",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
test: /\b(updat\w*|modif(y|ies|ied)|edit\w*|writ\w*|creat\w*|set|chang\w*|schedul\w*|move|moves|rename\w*|add|adds|added|adding)\b/,
|
|
129
|
+
category: "state change",
|
|
130
|
+
severity: "medium",
|
|
131
|
+
verdict: "allow",
|
|
132
|
+
consequence: "changes records or state (monitored; reversible in most systems)",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
test: /\b(search\w*|brows\w*|fetch\w*|lookup|look\s?up|retriev\w*|recall\w*|get|list|lists|read|describe\w*|show\w*|view\w*|summar\w*)\b/,
|
|
136
|
+
category: "read / research",
|
|
137
|
+
severity: "low",
|
|
138
|
+
verdict: "allow",
|
|
139
|
+
consequence: "low-consequence information access",
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
/** The read-only class. Meaningful as a primary, never as an added finding. */
|
|
143
|
+
const READ_ONLY_CATEGORY = "read / research";
|
|
144
|
+
exports.UNCLASSIFIED_CATEGORY = "general action";
|
|
145
|
+
exports.UNCLASSIFIED_CONSEQUENCE = "no specific high-risk class matched — allowed and monitored";
|
|
146
|
+
/**
|
|
147
|
+
* Marks the fall-through rule. A tool that matched NOTHING is unclassified — it
|
|
148
|
+
* is not a considered "medium risk" finding, and presenting it as one turns every
|
|
149
|
+
* unrecognized plugin into a false signal.
|
|
150
|
+
*/
|
|
151
|
+
function isUnclassifiedPath(path) {
|
|
152
|
+
return path.category === exports.UNCLASSIFIED_CATEGORY && path.consequence === exports.UNCLASSIFIED_CONSEQUENCE;
|
|
153
|
+
}
|
|
154
|
+
const DEFAULT_RULE = {
|
|
155
|
+
category: exports.UNCLASSIFIED_CATEGORY,
|
|
156
|
+
// Autonomy-first: an unclassified action defaults to ALLOW (auto, monitored)
|
|
157
|
+
// rather than routing every unrecognized tool to a human.
|
|
158
|
+
severity: "medium",
|
|
159
|
+
verdict: "allow",
|
|
160
|
+
consequence: exports.UNCLASSIFIED_CONSEQUENCE,
|
|
161
|
+
};
|
|
162
|
+
const SEV_WEIGHT = { low: 3, medium: 8, high: 16 };
|
|
163
|
+
// Score band per worst-severity-present. The worst severity on the surface picks
|
|
164
|
+
// the BAND; breadth only moves the score within it. Summing weights across all
|
|
165
|
+
// paths instead lets count outrank severity: 20 read-only tools (20x3=60) used to
|
|
166
|
+
// outscore two tools that permanently delete data (2x16=32), so a read-only agent
|
|
167
|
+
// read "High" while a destructive one read "Elevated".
|
|
168
|
+
const SEV_BAND = { low: [0, 32], medium: [33, 65], high: [66, 100] };
|
|
169
|
+
function pathFromRule(tool, rule, secondary) {
|
|
170
|
+
return {
|
|
171
|
+
tool: tool.name,
|
|
172
|
+
category: rule.category,
|
|
173
|
+
severity: rule.severity,
|
|
174
|
+
recommendedVerdict: rule.verdict,
|
|
175
|
+
consequence: rule.consequence,
|
|
176
|
+
source: tool.source,
|
|
177
|
+
...(secondary ? { secondary: true } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Every consequence class this tool belongs to, most important first.
|
|
182
|
+
*
|
|
183
|
+
* The first match is the PRIMARY path and is what the score is built on. The rest
|
|
184
|
+
* are harms that would otherwise be dropped — a tool is rarely one kind of danger
|
|
185
|
+
* (`delete_repo_and_notify` destroys data AND messages outsiders). Every secondary
|
|
186
|
+
* is emitted at the PRIMARY's verdict: the rules are ordered by importance, not by
|
|
187
|
+
* verdict, so letting a secondary keep its own could hand a tool a BLOCK its
|
|
188
|
+
* primary never gave it — and letting it come in weaker would list the same tool
|
|
189
|
+
* as both a red line and an allowed limit.
|
|
190
|
+
*/
|
|
191
|
+
function classify(tool) {
|
|
192
|
+
// Normalize non-alphanumerics to spaces so snake_case/camel/kebab tool names
|
|
193
|
+
// tokenize ("issue_refund" -> "issue refund"). This lets the \b anchors match
|
|
194
|
+
// keywords inside identifiers while keeping short tokens like "rm"/"git" safe
|
|
195
|
+
// (they only match as standalone words, not inside "form").
|
|
196
|
+
const hay = ` ${`${tool.name} ${tool.description ?? ""}`.toLowerCase().replace(/[^a-z0-9]+/g, " ")} `;
|
|
197
|
+
const matched = RULES.filter((r) => r.test.test(hay));
|
|
198
|
+
const primaryRule = matched[0] ?? DEFAULT_RULE;
|
|
199
|
+
const primary = pathFromRule(tool, primaryRule);
|
|
200
|
+
const seen = new Set([primary.category]);
|
|
201
|
+
const secondary = [];
|
|
202
|
+
for (const rule of matched.slice(1)) {
|
|
203
|
+
// One statement per consequence class.
|
|
204
|
+
if (seen.has(rule.category))
|
|
205
|
+
continue;
|
|
206
|
+
// "Also reads" is not a finding: every tool that writes, sends or deletes also
|
|
207
|
+
// matches the read rule, so as a SECONDARY it covers most of the surface and
|
|
208
|
+
// tells the owner nothing.
|
|
209
|
+
if (rule.category === READ_ONLY_CATEGORY)
|
|
210
|
+
continue;
|
|
211
|
+
seen.add(rule.category);
|
|
212
|
+
secondary.push(pathFromRule(tool, { ...rule, verdict: primaryRule.verdict }, true));
|
|
213
|
+
}
|
|
214
|
+
return [primary, ...secondary];
|
|
215
|
+
}
|
|
216
|
+
/** Score + level + summary from a set of paths — shared by both passes. */
|
|
217
|
+
function summarizePaths(paths) {
|
|
218
|
+
// Secondary paths describe a tool more precisely; they do not add capability.
|
|
219
|
+
// Counting them would raise the score of an unchanged runtime purely because
|
|
220
|
+
// the classifier learned to name a second harm.
|
|
221
|
+
const scored = paths.filter((p) => !p.secondary);
|
|
222
|
+
const sorted = [...scored].sort((a, b) => SEV_WEIGHT[b.severity] - SEV_WEIGHT[a.severity]);
|
|
223
|
+
const worst = sorted[0]?.severity;
|
|
224
|
+
const raw = sorted.reduce((sum, p) => sum + SEV_WEIGHT[p.severity], 0);
|
|
225
|
+
const [floor, ceil] = worst ? SEV_BAND[worst] : [0, 0];
|
|
226
|
+
const score = Math.min(100, Math.round(floor + (ceil - floor) * (1 - Math.exp(-raw / 40))));
|
|
227
|
+
const level = score >= 66 ? "High" : score >= 33 ? "Elevated" : "Low";
|
|
228
|
+
const highs = sorted.filter((p) => p.severity === "high").length;
|
|
229
|
+
const summary = sorted.length === 0
|
|
230
|
+
? "No tool surface was resolved, so no blast radius could be derived. Behavior must be observed at the runtime boundary."
|
|
231
|
+
: `${sorted.length} authorized action path${sorted.length === 1 ? "" : "s"} (${highs} high-severity). This is what the agent is authorized to attempt before any boundary — not a prediction of intent.`;
|
|
232
|
+
return { score, level, summary };
|
|
233
|
+
}
|
|
234
|
+
/** Derive the blast-radius report from a scanned tool surface (deterministic). */
|
|
235
|
+
function deriveRisk(tools) {
|
|
236
|
+
const paths = tools
|
|
237
|
+
.flatMap(classify)
|
|
238
|
+
// Stable: equal severities keep the order the tools were scanned in, so a
|
|
239
|
+
// rescan of an unchanged runtime produces an identical report.
|
|
240
|
+
.sort((a, b) => SEV_WEIGHT[b.severity] - SEV_WEIGHT[a.severity]);
|
|
241
|
+
return { paths, ...summarizePaths(paths), method: "deterministic" };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Fill in the paths the keyword rules could not classify, using a model's per-tool
|
|
245
|
+
* grade and its plain-English effect.
|
|
246
|
+
*
|
|
247
|
+
* Gap-filling ONLY: a path that matched a real rule is returned untouched, so the
|
|
248
|
+
* deterministic engine stays the auditable floor and an LLM can never downgrade a
|
|
249
|
+
* known-dangerous capability. `critical` folds to `high` because that is the top
|
|
250
|
+
* of this module's scale. The score is recomputed so the level reflects the
|
|
251
|
+
* enriched set.
|
|
252
|
+
*
|
|
253
|
+
* `grades` is keyed by `${source}::${name}` (see `toolKey`), not name alone: two
|
|
254
|
+
* different MCP servers can expose a same-named tool, and a name-only key would
|
|
255
|
+
* apply one tool's grade to the other's unrelated path.
|
|
256
|
+
*
|
|
257
|
+
* Returns the ORIGINAL report object when nothing was filled, so a caller can test
|
|
258
|
+
* `graded !== report` to know whether the pass actually contributed.
|
|
259
|
+
*/
|
|
260
|
+
function gradeUnclassifiedPaths(report, grades) {
|
|
261
|
+
let filled = 0;
|
|
262
|
+
const paths = report.paths.map((path) => {
|
|
263
|
+
if (!isUnclassifiedPath(path))
|
|
264
|
+
return path;
|
|
265
|
+
const hint = grades[toolKey(path.source, path.tool)];
|
|
266
|
+
const graded = (hint?.severity ?? "").toLowerCase();
|
|
267
|
+
const severity = graded === "critical" || graded === "high"
|
|
268
|
+
? "high"
|
|
269
|
+
: graded === "medium"
|
|
270
|
+
? "medium"
|
|
271
|
+
: graded === "low"
|
|
272
|
+
? "low"
|
|
273
|
+
: undefined;
|
|
274
|
+
if (!severity)
|
|
275
|
+
return path;
|
|
276
|
+
filled++;
|
|
277
|
+
return {
|
|
278
|
+
...path,
|
|
279
|
+
severity,
|
|
280
|
+
category: hint?.policyGroup?.trim() || path.category,
|
|
281
|
+
consequence: hint?.effect?.trim() || path.consequence,
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
if (!filled)
|
|
285
|
+
return report;
|
|
286
|
+
return { ...report, paths, ...summarizePaths(paths), method: "llm" };
|
|
287
|
+
}
|
|
288
|
+
//# sourceMappingURL=risk.js.map
|