@inetafrica/open-claudia 2.7.1 → 2.7.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/CHANGELOG.md +5 -0
- package/bin/cli.js +17 -0
- package/bin/keyring.js +45 -2
- package/bin/tool.js +5 -4
- package/core/config.js +6 -10
- package/core/handlers.js +8 -6
- package/core/keyring.js +5 -3
- package/core/runner.js +3 -0
- package/core/subagent.js +3 -0
- package/core/system-prompt.js +2 -1
- package/core/tool-guard.js +163 -0
- package/core/tools.js +22 -12
- package/package.json +4 -3
- package/test-tool-guard.js +92 -0
- package/test-tools.js +21 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.7.2
|
|
4
|
+
- **Credential confinement — keyring creds now live ONLY inside tool runs (enforcement 5b).** Until now `botSubprocessEnv()` merged the whole operational keyring into *every* agent subprocess, so the agent's own Bash (and any sub-agent) could `curl` a prod API with `$inet_central_user` directly — the tool layer was optional. That merge is gone: agent shells and sub-agents run cred-free, and `tools.runEnv(tool)` injects **only the keys a tool declares via `--requires`**, read fresh from the keyring at run time (least privilege; `process.env` still wins on conflict). Audited all 11 live tools before shipping: every keyring key read is already declared, so nothing breaks. The system prompt's Preauth bullet now tells the truth, and `keyring list` no longer claims env-wide availability.
|
|
5
|
+
- **Tool-first deny-gate on the agent's shell (enforcement 5b).** A PreToolUse hook (wired via `--settings` into the main runner *and* sub-agent spawns; new `core/tool-guard.js` + `open-claudia deny-gate-hook`) blocks raw operational commands against known prod surfaces and redirects to the covering tool by name — or to the exact `tool scaffold` command when nothing covers the surface yet. v1 rules are deliberately high-precision: an **acting** binary (curl/wget/ssh/mongosh/…) aimed at `central.inet.africa`, `ticketcentral`, `10.200.0.0/16`, `kubectl exec … mongo(sh)`, or `ssh codersafrica@` — while `grep`/`cat`/`ping` mentioning the same surfaces, read-only `kubectl`, and plain `git push` stay free (reading is always allowed; acting goes through tools). Quotes count as command separators so `bash -c "curl …"` can't dodge the gate. Every failure mode in the gate fails OPEN — a broken guard must never brick the shell. This gate is the honest enforcement for surfaces where confinement is toothless (kubectl/ssh use ambient creds, not keyring keys); the accepted residual dodge is writing the command into a script file first.
|
|
6
|
+
- **`keyring exec --reason "<why>" -- <cmd>` — the logged escape hatch.** Confinement without a pressure valve just breeds invisible workarounds (`keyring get` + copy-paste). A genuine one-off no tool covers yet runs through `keyring exec`: full keyring in env for exactly one command, reason mandatory, and the bypass recorded to `~/.open-claudia/tool-guard.jsonl` alongside every deny — the audit stream the 5c KPI pass will read to decide which tools to scaffold next. New `test-tool-guard.js` covers the rule matrix, hook protocol (exit 2/stderr deny, fail-open), audit trail, settings writer idempotency, and env confinement; `test-tools.js` extends to least-privilege injection.
|
|
7
|
+
|
|
3
8
|
## v2.7.1
|
|
4
9
|
- **The system prompt now teaches tool-FIRST, not tool-after (enforcement 5d).** The fixed "Reusable tools" section in `core/system-prompt.js` was the root cause of the baseline failure: it taught "crystallise the script you just wrote" — do the work raw, save a tool afterwards. Rewritten as "Tools are the interface": before operational work, walk the loop **search → use (read TOOL.md first, never guess args) → extend the missing verb → scaffold first** — and only then do the work *through* the tool. Raw one-liners and heredocs are demoted to probing/diagnosis; a broken tool gets fixed or journaled (`tool note --issue`), never routed around. The section also documents the risk gate honestly: the `--yes`/`--yes-destructive` flag is the agent's mechanical acknowledgment *after* the user approves — it never replaces asking first.
|
|
5
10
|
- **Tool-scout: operational turns can no longer meet silence (enforcement 5a).** Today an empty tool match is silent, and silence reads as permission to improvise. The discoverer now runs a pattern-based scout (no LLM, ~zero cost) over each seeds/full-tier turn: a table of high-precision operational domains — named hosts (central.inet.africa, ticket-central, chat-central, git.coders.africa…), prod IP ranges, infra commands (kubectl, argocd, mongosh…) — each with a `covers` matcher against the tool registry. When a turn trips a domain: if a registry tool covers it but wasn't auto-surfaced, the block points at it by name (`tool show X`, then work through it); if **nothing** covers it, the block says so and hands over the exact scaffold command (`tool scaffold <suggested-name> --risk write`). Domains already covered by a surfaced tool stay quiet, and non-operational chatter never trips the patterns (precision over recall — they expand from audit evidence later, per 5c). Gap signals flow into recall metrics (`toolGaps`) and the engine result for future `/tooltrace` surfacing. Extends `test-recall-discoverer.js` with pure-function and through-the-engine scout coverage.
|
package/bin/cli.js
CHANGED
|
@@ -134,6 +134,23 @@ switch (command) {
|
|
|
134
134
|
require(path.join(botDir, "setup.js"));
|
|
135
135
|
break;
|
|
136
136
|
|
|
137
|
+
// PreToolUse hook target (tool-first deny-gate, 5b). Claude pipes the tool
|
|
138
|
+
// payload on stdin; exit 2 + stderr = deny (message fed back to the model),
|
|
139
|
+
// exit 0 = allow. Every failure mode fails OPEN — a broken gate must never
|
|
140
|
+
// brick the agent's shell.
|
|
141
|
+
case "deny-gate-hook": {
|
|
142
|
+
let code = 0;
|
|
143
|
+
try {
|
|
144
|
+
const payload = fs.readFileSync(0, "utf-8");
|
|
145
|
+
const { runHook } = require(path.join(botDir, "core", "tool-guard"));
|
|
146
|
+
const res = runHook(payload);
|
|
147
|
+
if (res.stderr) console.error(res.stderr);
|
|
148
|
+
code = res.exit;
|
|
149
|
+
} catch (e) { code = 0; }
|
|
150
|
+
process.exit(code);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
|
|
137
154
|
case "start": {
|
|
138
155
|
const skipHealthCheck = args.includes("--skip-health") || args.includes("--force");
|
|
139
156
|
const botFile = getBotFile();
|
package/bin/keyring.js
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
// open-claudia keyring set <name> <value> — store/replace a credential
|
|
6
6
|
// open-claudia keyring remove <name> — delete one
|
|
7
7
|
// open-claudia keyring path — print the file path
|
|
8
|
+
// open-claudia keyring exec --reason "<why>" -- <cmd> [args...]
|
|
9
|
+
// — logged escape hatch (5b): run ONE command with the full keyring in
|
|
10
|
+
// env. Confinement means the agent shell holds no creds and tool runs
|
|
11
|
+
// get only their --requires keys; when a genuine one-off needs a cred
|
|
12
|
+
// and no tool covers it yet, this is the honest path — the reason is
|
|
13
|
+
// mandatory and every use lands in the guard log for the KPI pass.
|
|
14
|
+
// Shell features need an explicit shell: ... -- bash -c "curl … | jq".
|
|
8
15
|
//
|
|
9
16
|
// For genuine personal secrets the bot should NOT use unattended, use the
|
|
10
17
|
// vault (/vault) instead — it stays encrypted and locked.
|
|
@@ -20,12 +27,48 @@ function run(args) {
|
|
|
20
27
|
const entries = keyring.list();
|
|
21
28
|
const names = Object.keys(entries);
|
|
22
29
|
if (names.length === 0) return console.log(`Keyring is empty (${keyring.KEYRING_FILE}).`);
|
|
23
|
-
console.log(`${names.length} credential(s) —
|
|
30
|
+
console.log(`${names.length} credential(s) — injected only into tool runs that declare them (--requires):\n`);
|
|
24
31
|
for (const k of names) console.log(`${k} = ${entries[k]}`);
|
|
25
32
|
console.log(`\nFile: ${keyring.KEYRING_FILE}`);
|
|
26
33
|
break;
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
// Logged escape hatch (5b): full-keyring env for ONE command, reason
|
|
37
|
+
// mandatory, bypass recorded to the guard log. This is the pressure valve
|
|
38
|
+
// that keeps confinement honest — without it, the workaround would be
|
|
39
|
+
// `keyring get` + copy-paste, which is worse and invisible.
|
|
40
|
+
case "exec": {
|
|
41
|
+
const sep = rest.indexOf("--");
|
|
42
|
+
const flagArgs = sep === -1 ? rest : rest.slice(0, sep);
|
|
43
|
+
const cmdArgv = sep === -1 ? [] : rest.slice(sep + 1);
|
|
44
|
+
let reason = "";
|
|
45
|
+
for (let i = 0; i < flagArgs.length; i++) {
|
|
46
|
+
if (flagArgs[i] === "--reason") reason = flagArgs[i + 1] || "";
|
|
47
|
+
else if (flagArgs[i].startsWith("--reason=")) reason = flagArgs[i].slice(9);
|
|
48
|
+
}
|
|
49
|
+
if (!reason.trim() || cmdArgv.length === 0) {
|
|
50
|
+
console.error('Usage: keyring exec --reason "<why no tool covers this>" -- <cmd> [args...]');
|
|
51
|
+
console.error("The reason is mandatory — every exec is logged as a confinement bypass.");
|
|
52
|
+
process.exitCode = 1; return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
require("../core/tool-guard").logGuardEvent({
|
|
56
|
+
kind: "bypass", reason: reason.trim(), command: cmdArgv.join(" ").slice(0, 300),
|
|
57
|
+
});
|
|
58
|
+
} catch (e) { /* logging is best-effort */ }
|
|
59
|
+
const { spawnSync } = require("child_process");
|
|
60
|
+
let base;
|
|
61
|
+
try { base = require("../core/config").botSubprocessEnv(); }
|
|
62
|
+
catch (e) { base = { ...process.env }; }
|
|
63
|
+
const r = spawnSync(cmdArgv[0], cmdArgv.slice(1), {
|
|
64
|
+
stdio: "inherit",
|
|
65
|
+
env: { ...keyring.all(), ...base },
|
|
66
|
+
});
|
|
67
|
+
if (r.error) { console.error(`keyring exec failed: ${r.error.message}`); process.exitCode = 1; return; }
|
|
68
|
+
process.exitCode = typeof r.status === "number" ? r.status : 1;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
29
72
|
case "get": {
|
|
30
73
|
const key = rest[0];
|
|
31
74
|
if (!key) { console.error("Usage: keyring get <name>"); process.exitCode = 1; return; }
|
|
@@ -57,7 +100,7 @@ function run(args) {
|
|
|
57
100
|
break;
|
|
58
101
|
|
|
59
102
|
default:
|
|
60
|
-
console.log('Usage: open-claudia keyring [list|get <name>|set <name> <value>|remove <name>|path]');
|
|
103
|
+
console.log('Usage: open-claudia keyring [list|get <name>|set <name> <value>|remove <name>|path|exec --reason "<why>" -- <cmd>]');
|
|
61
104
|
}
|
|
62
105
|
}
|
|
63
106
|
|
package/bin/tool.js
CHANGED
|
@@ -34,9 +34,10 @@
|
|
|
34
34
|
// secrets BLOCK a save; other lints (requires/keyring mismatch, syntax,
|
|
35
35
|
// dangling pack link) stay advisory.
|
|
36
36
|
//
|
|
37
|
-
// Credentials: a tool
|
|
38
|
-
// reference
|
|
39
|
-
//
|
|
37
|
+
// Credentials (least-privilege, 5b): a tool run receives ONLY the keyring keys
|
|
38
|
+
// it declares via --requires — reference them as $NAME inside the script and
|
|
39
|
+
// declare every one. `open-claudia keyring list` shows what exists; never
|
|
40
|
+
// hardcode secrets in a tool. Undeclared keys simply won't be in the env.
|
|
40
41
|
|
|
41
42
|
const { spawnSync } = require("child_process");
|
|
42
43
|
const tools = require("../core/tools");
|
|
@@ -325,7 +326,7 @@ function run(args) {
|
|
|
325
326
|
process.exitCode = 1; return;
|
|
326
327
|
}
|
|
327
328
|
const started = Date.now();
|
|
328
|
-
const r = spawnSync(t.file, toolArgs, { stdio: "inherit", env: tools.runEnv() });
|
|
329
|
+
const r = spawnSync(t.file, toolArgs, { stdio: "inherit", env: tools.runEnv(t) });
|
|
329
330
|
const ms = Date.now() - started;
|
|
330
331
|
if (r.error) {
|
|
331
332
|
tools.recordRunResult(t.name, { args: toolArgs, exit: -1, ms });
|
package/core/config.js
CHANGED
|
@@ -219,16 +219,12 @@ function loadChannels() {
|
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
function botSubprocessEnv() {
|
|
222
|
-
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
return { ...keyring.all(), ...base };
|
|
229
|
-
} catch (e) {
|
|
230
|
-
return base;
|
|
231
|
-
}
|
|
222
|
+
// Credential confinement (5b): the operational keyring is deliberately NOT
|
|
223
|
+
// merged here. Agent shells, sub-agents, and every other bot subprocess run
|
|
224
|
+
// cred-free; keyring values enter ONLY tool-run subprocesses (tools.runEnv —
|
|
225
|
+
// the keys a tool declares via --requires) and the logged escape hatch
|
|
226
|
+
// (`keyring exec --reason`). See core/tool-guard.js for the deny-gate half.
|
|
227
|
+
return { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
|
|
232
228
|
}
|
|
233
229
|
|
|
234
230
|
module.exports = {
|
package/core/handlers.js
CHANGED
|
@@ -1512,10 +1512,12 @@ register({
|
|
|
1512
1512
|
module.exports.PENDING_VAULT_TTL_MS = PENDING_VAULT_TTL_MS;
|
|
1513
1513
|
|
|
1514
1514
|
// ── Keyring (operational, plaintext) ───────────────────────────────
|
|
1515
|
-
// Unlike the vault, the keyring is always available
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1515
|
+
// Unlike the vault, the keyring is always available (no password, no lock) so
|
|
1516
|
+
// the agent can repeat work you've shown it once. Use it for creds the agent
|
|
1517
|
+
// is allowed to use unattended; use /vault for personal secrets it must not.
|
|
1518
|
+
// Confinement (5b): values are injected only into tool runs that declare them
|
|
1519
|
+
// via --requires (plus the logged `keyring exec` escape hatch) — never into
|
|
1520
|
+
// the agent's own shell.
|
|
1519
1521
|
|
|
1520
1522
|
register({
|
|
1521
1523
|
name: "keyring", description: "Manage operational credentials (plaintext, agent-usable)", args: "[set|get|remove] ...",
|
|
@@ -1528,7 +1530,7 @@ register({
|
|
|
1528
1530
|
if (names.length === 0) {
|
|
1529
1531
|
return send("Keyring is empty.\n\nStore a credential the agent can reuse:\n/keyring set <name> <value>\n\n(For personal secrets the agent should NOT use on its own, use /vault instead.)");
|
|
1530
1532
|
}
|
|
1531
|
-
return send("Keyring (
|
|
1533
|
+
return send("Keyring (injected into tool runs that declare them via --requires):\n\n" + names.map((k) => `${k}: ${entries[k]}`).join("\n") + "\n\nGet a value: /keyring get <name>");
|
|
1532
1534
|
}
|
|
1533
1535
|
|
|
1534
1536
|
const setMatch = tail.match(/^set\s+(\S+)\s+([\s\S]+)$/);
|
|
@@ -1538,7 +1540,7 @@ register({
|
|
|
1538
1540
|
const value = setMatch[2].trim();
|
|
1539
1541
|
keyring.set(setMatch[1], value);
|
|
1540
1542
|
registerSecrets([value]);
|
|
1541
|
-
return send(`Stored ${setMatch[1]}. I deleted your message.
|
|
1543
|
+
return send(`Stored ${setMatch[1]}. I deleted your message. Tools that declare it via --requires can use it from their next run.`);
|
|
1542
1544
|
}
|
|
1543
1545
|
|
|
1544
1546
|
const getMatch = tail.match(/^get\s+(\S+)$/);
|
package/core/keyring.js
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
// (chmod 600) + log redaction, not encryption. Keep genuine personal secrets
|
|
13
13
|
// in the vault; keep "creds the agent is allowed to use on its own" here.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// Confinement (5b): values do NOT flow into agent shells or sub-agents.
|
|
16
|
+
// They are injected only into `tool run` subprocesses — and only the keys a
|
|
17
|
+
// tool declares via --requires (tools.runEnv) — plus the logged escape hatch
|
|
18
|
+
// `keyring exec --reason`. Read fresh each run, so a credential stored once
|
|
19
|
+
// is live for the next tool run with no restart.
|
|
18
20
|
|
|
19
21
|
const fs = require("fs");
|
|
20
22
|
const path = require("path");
|
package/core/runner.js
CHANGED
|
@@ -296,6 +296,9 @@ async function buildClaudeArgs(prompt, opts = {}) {
|
|
|
296
296
|
if (settings.backend === "codex") return buildCodexArgs(prompt, opts);
|
|
297
297
|
const args = ["-p", "--verbose", "--output-format", "stream-json",
|
|
298
298
|
"--append-system-prompt", buildSystemPrompt()];
|
|
299
|
+
// Tool-first deny-gate (5b): PreToolUse hook on Bash via --settings. A
|
|
300
|
+
// failure to write the settings file just means no gate this turn (fail-open).
|
|
301
|
+
try { args.push("--settings", require("./tool-guard").ensureHookSettings()); } catch (e) {}
|
|
299
302
|
const transcriptInfo = transcriptProjectInfo(state);
|
|
300
303
|
if (transcriptInfo) args.push("--add-dir", transcriptInfo.transcriptsDir);
|
|
301
304
|
if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId);
|
package/core/subagent.js
CHANGED
|
@@ -64,6 +64,9 @@ async function spawnSubagent(prompt, opts = {}) {
|
|
|
64
64
|
// to make a sub-agent genuinely read-only (e.g. the dream's introspection).
|
|
65
65
|
if (opts.allowedTools) args.push("--allowedTools", [].concat(opts.allowedTools).join(","));
|
|
66
66
|
if (opts.disallowedTools) args.push("--disallowedTools", [].concat(opts.disallowedTools).join(","));
|
|
67
|
+
// Sub-agents get the same tool-first deny-gate as the main agent (5b) —
|
|
68
|
+
// they also hold no keyring creds (botSubprocessEnv is cred-free).
|
|
69
|
+
try { args.push("--settings", require("./tool-guard").ensureHookSettings()); } catch (e) {}
|
|
67
70
|
args.push(
|
|
68
71
|
"--output-format", opts.json ? "json" : "text",
|
|
69
72
|
"--verbose",
|
package/core/system-prompt.js
CHANGED
|
@@ -96,7 +96,8 @@ Raw shell one-liners and heredocs are for probing and diagnosis only — never t
|
|
|
96
96
|
|
|
97
97
|
- Risk gate: read-only tools run freely; write-tier needs \`--yes\`; destructive needs \`--yes-destructive\`. The flag is your mechanical acknowledgment AFTER the user approves the action — it never replaces asking first.
|
|
98
98
|
- Tools carry their own memory: TOOL.md (Interface · Known issues · State · Journal) is the manual, state.json records every run (per-verb health), and every change is a git commit. Keep TOOL.md truthful as you work; update the tool in place when behaviour changes.
|
|
99
|
-
- Preauth:
|
|
99
|
+
- Preauth, least-privilege: keyring creds are injected ONLY into \`tool run\` subprocesses, and only the keys a tool declares via \`--requires\` (\`open-claudia keyring list\` shows names) — your own shell and sub-agents hold NO creds. Reference creds as \`$inet_central_user\` etc. inside tool sources and declare every one; never write a secret into a tool file (saves are refused).
|
|
100
|
+
- Deny-gate: raw shell commands that act on known production surfaces (platform APIs, prod hosts, in-cluster mongo, GitLab host shell) are blocked mid-flight with a redirect to the covering tool — that block is the system working, not an obstacle to route around. Genuine one-off no tool covers yet? \`open-claudia keyring exec --reason "<why>" -- <cmd>\` runs with full creds and logs the bypass for audit; scaffold the tool if you'd ever run it twice.
|
|
100
101
|
- Announce in one line when you scaffold or change a tool, same as packs. Full CLI: \`open-claudia tool list|search|show|scaffold|add|set|note|edit|run|migrate|remove\`.
|
|
101
102
|
${listing}`;
|
|
102
103
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Tool-first deny-gate (enforcement 5b): a PreToolUse hook on the agent's Bash
|
|
2
|
+
// that blocks RAW operational commands against known production surfaces and
|
|
3
|
+
// redirects to the reusable tool (or a scaffold, or the logged escape hatch).
|
|
4
|
+
//
|
|
5
|
+
// Why a hook and not prompt text: prompt guidance is advice the model can
|
|
6
|
+
// drift past; a hook is fixed code behaviour. Why these patterns: v1 is
|
|
7
|
+
// deliberately high-precision — only an ACTING network/exec binary aimed at a
|
|
8
|
+
// known prod surface trips it, so `grep central.inet.africa src/api.js` (read/
|
|
9
|
+
// diagnosis) stays free while `curl central.inet.africa/...` (the operational
|
|
10
|
+
// action) is denied. Reading is always allowed; acting goes through tools.
|
|
11
|
+
//
|
|
12
|
+
// Honest coverage: this gate + the audit log ARE the enforcement for surfaces
|
|
13
|
+
// where credential confinement is toothless (kubectl/ssh use ambient creds,
|
|
14
|
+
// not keyring keys). A script-file dodge (write the curl into a .sh and run
|
|
15
|
+
// it) slips through — accepted as best-effort; the dream's KPI pass reads the
|
|
16
|
+
// guard log to spot chronic bypassing.
|
|
17
|
+
//
|
|
18
|
+
// Fail-open doctrine: any error in the gate itself must NEVER brick the
|
|
19
|
+
// agent's shell — parse failures, fs errors, unknown payloads all allow.
|
|
20
|
+
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const CONFIG_DIR = require("../config-dir");
|
|
24
|
+
|
|
25
|
+
const GUARD_LOG = process.env.TOOL_GUARD_LOG || path.join(CONFIG_DIR, "tool-guard.jsonl");
|
|
26
|
+
const SETTINGS_FILE = process.env.DENY_GATE_SETTINGS || path.join(CONFIG_DIR, "deny-gate.settings.json");
|
|
27
|
+
|
|
28
|
+
// The authorised channels themselves must never be denied.
|
|
29
|
+
const ALLOW_RE = /^\s*open-claudia\s+(?:tool\s+run|keyring\s+exec)\b/;
|
|
30
|
+
|
|
31
|
+
// An acting network/DB/shell-access binary at a command position (start of
|
|
32
|
+
// string or after a separator). Deliberately excludes read-only diagnosis
|
|
33
|
+
// (ping, dig, nslookup, traceroute) and non-network tools — mentioning a host
|
|
34
|
+
// in grep/cat/echo is probing, not operating. Quotes count as separators so
|
|
35
|
+
// `bash -c "curl …"` doesn't dodge the gate (cost: `echo "curl <prod-url>"`
|
|
36
|
+
// is a rare false positive — the deny message explains the way through).
|
|
37
|
+
const NET_CMD_RE = /(?:^|[|;&(`'"]|\$\()\s*(?:sudo\s+)?(?:curl|wget|http|https|httpie|xh|aria2c|ssh|scp|sftp|rsync|nc|ncat|telnet|socat|mongo|mongosh|psql|mysql|redis-cli)\b/;
|
|
38
|
+
|
|
39
|
+
// v1 rules — each fires on (needsNetCmd ? NET_CMD_RE && target : raw).
|
|
40
|
+
// `redirect` is what the model sees instead of silence: the covering tool by
|
|
41
|
+
// name, or the exact scaffold command when nothing covers the surface yet.
|
|
42
|
+
const RULES = [
|
|
43
|
+
{
|
|
44
|
+
id: "ticket-central-api",
|
|
45
|
+
label: "ticket-central API",
|
|
46
|
+
target: /ticketcentral/i,
|
|
47
|
+
needsNetCmd: true,
|
|
48
|
+
redirect: 'No tool fronts ticket-central yet — scaffold first: open-claudia tool scaffold kticket --desc "ticket-central API" --risk write',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "inet-central-api",
|
|
52
|
+
label: "inet-central platform API",
|
|
53
|
+
target: /(?:^|[^a-z0-9.-])central\.inet\.africa/i,
|
|
54
|
+
needsNetCmd: true,
|
|
55
|
+
redirect: "Use the reusable tool: open-claudia tool show inet-central-cli, then tool run inet-central-cli …",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "prod-network",
|
|
59
|
+
label: "production network host (10.200.0.0/16)",
|
|
60
|
+
target: /\b10\.200\.\d{1,3}\.\d{1,3}\b/,
|
|
61
|
+
needsNetCmd: true,
|
|
62
|
+
redirect: "Front prod-network access with a reusable tool (open-claudia tool search <device/system>), or scaffold one.",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "in-cluster-mongo",
|
|
66
|
+
label: "in-cluster MongoDB via kubectl exec",
|
|
67
|
+
raw: /\bkubectl\s+exec\b[^\n]*\bmongo(?:sh)?\b/i,
|
|
68
|
+
redirect: "Use the reusable tool: open-claudia tool show prod-k8s (it fronts in-cluster mongosh), then tool run prod-k8s …",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "gitlab-ssh",
|
|
72
|
+
label: "GitLab host shell (ssh codersafrica@)",
|
|
73
|
+
raw: /\bssh\s+\S*codersafrica@/i,
|
|
74
|
+
redirect: 'No tool fronts GitLab host ops yet — scaffold first: open-claudia tool scaffold gitlab-ops --desc "GitLab / coders.africa host ops" --risk write. (Plain git push/pull is unaffected.)',
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// Pure: null (allow) or the matched rule.
|
|
79
|
+
function matchRule(command) {
|
|
80
|
+
const cmd = String(command || "");
|
|
81
|
+
if (!cmd.trim()) return null;
|
|
82
|
+
if (ALLOW_RE.test(cmd)) return null;
|
|
83
|
+
for (const rule of RULES) {
|
|
84
|
+
if (rule.raw) {
|
|
85
|
+
if (rule.raw.test(cmd)) return rule;
|
|
86
|
+
} else if (rule.target.test(cmd) && NET_CMD_RE.test(cmd)) {
|
|
87
|
+
return rule;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function denyMessage(rule) {
|
|
94
|
+
return (
|
|
95
|
+
`⛔ Tool-first deny-gate: raw ${rule.label} access from the agent shell is blocked.\n` +
|
|
96
|
+
`${rule.redirect}\n` +
|
|
97
|
+
`Genuine one-off with no tool? Escape hatch (logged): open-claudia keyring exec --reason "<why>" -- <command>`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Append-only audit trail — read by the 5c KPI pass. Best-effort: a logging
|
|
102
|
+
// failure must not turn into a denial or a crash.
|
|
103
|
+
function logGuardEvent(evt) {
|
|
104
|
+
try {
|
|
105
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...evt });
|
|
106
|
+
fs.appendFileSync(GUARD_LOG, line + "\n");
|
|
107
|
+
} catch (e) { /* best effort */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Hook entry, pure enough to test: takes the PreToolUse stdin payload (object
|
|
111
|
+
// or JSON string), returns { exit, stderr }. exit 2 = deny (stderr is fed back
|
|
112
|
+
// to the model); exit 0 = allow. Anything unexpected → allow (fail-open).
|
|
113
|
+
function runHook(payload) {
|
|
114
|
+
let data = payload;
|
|
115
|
+
try {
|
|
116
|
+
if (typeof payload === "string") data = JSON.parse(payload);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return { exit: 0, stderr: "" };
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
if (!data || data.tool_name !== "Bash") return { exit: 0, stderr: "" };
|
|
122
|
+
const command = data.tool_input && data.tool_input.command;
|
|
123
|
+
const rule = matchRule(command);
|
|
124
|
+
if (!rule) return { exit: 0, stderr: "" };
|
|
125
|
+
logGuardEvent({ kind: "deny", rule: rule.id, command: String(command).slice(0, 300) });
|
|
126
|
+
return { exit: 2, stderr: denyMessage(rule) };
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return { exit: 0, stderr: "" };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The claude --settings payload wiring the hook in. Absolute node + cli paths
|
|
133
|
+
// so it works regardless of the subprocess PATH; 10s timeout so a hung gate
|
|
134
|
+
// can't stall a turn (hook timeout → claude proceeds, i.e. fail-open).
|
|
135
|
+
function hookSettings() {
|
|
136
|
+
const cli = path.resolve(__dirname, "..", "bin", "cli.js");
|
|
137
|
+
return {
|
|
138
|
+
hooks: {
|
|
139
|
+
PreToolUse: [
|
|
140
|
+
{
|
|
141
|
+
matcher: "Bash",
|
|
142
|
+
hooks: [
|
|
143
|
+
{ type: "command", command: `${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} deny-gate-hook`, timeout: 10 },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Write (or refresh) the settings file and return its path for --settings.
|
|
152
|
+
// Content-compared so repeated spawns don't churn the file. Throws only up to
|
|
153
|
+
// the caller's try/catch — a failure there just means no gate this turn.
|
|
154
|
+
function ensureHookSettings() {
|
|
155
|
+
const body = JSON.stringify(hookSettings(), null, 2) + "\n";
|
|
156
|
+
try {
|
|
157
|
+
if (fs.readFileSync(SETTINGS_FILE, "utf-8") === body) return SETTINGS_FILE;
|
|
158
|
+
} catch (e) { /* missing/unreadable → write */ }
|
|
159
|
+
fs.writeFileSync(SETTINGS_FILE, body);
|
|
160
|
+
return SETTINGS_FILE;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { RULES, GUARD_LOG, SETTINGS_FILE, matchRule, denyMessage, logGuardEvent, runHook, hookSettings, ensureHookSettings };
|
package/core/tools.js
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
// the system prompt as an always-on index and are read on demand via
|
|
16
16
|
// `open-claudia tool show <name>` (progressive disclosure).
|
|
17
17
|
//
|
|
18
|
-
// Preauth:
|
|
19
|
-
// runEnv())
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// Preauth, least-privilege (5b): a tool run receives ONLY the keyring keys it
|
|
19
|
+
// declares via --requires (see runEnv(tool)) — the agent's own shell holds no
|
|
20
|
+
// keyring creds at all. A tool references a credential as $inet_central_user
|
|
21
|
+
// and never hardcodes a secret — addTool/updateToolHeader REFUSE sources that
|
|
22
|
+
// match secret patterns, because the auto-commit history is forever.
|
|
22
23
|
//
|
|
23
24
|
// Risk tiers mechanise the ask-first rule: every tool declares read-only /
|
|
24
25
|
// write / destructive. runGate() refuses write-tier runs without --yes and
|
|
@@ -628,17 +629,26 @@ function runGate(tool, argv = []) {
|
|
|
628
629
|
};
|
|
629
630
|
}
|
|
630
631
|
|
|
631
|
-
// Env for running a tool: the bot's
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
632
|
+
// Env for running a tool: the bot's cred-free subprocess env plus ONLY the
|
|
633
|
+
// keyring keys this tool declares via --requires — least-privilege credential
|
|
634
|
+
// injection (5b). Undeclared keys never reach the subprocess; process.env wins
|
|
635
|
+
// on conflict (bot infra config stays authoritative). Keys read fresh each run
|
|
636
|
+
// so a cred set mid-session is live immediately.
|
|
637
|
+
function runEnv(tool) {
|
|
638
|
+
let base;
|
|
639
|
+
try { base = require("./config").botSubprocessEnv(); }
|
|
640
|
+
catch (e) { base = { ...process.env }; }
|
|
641
|
+
const requires = (tool && tool.requires) || [];
|
|
642
|
+
if (requires.length) {
|
|
637
643
|
try {
|
|
638
644
|
const keyring = require("./keyring");
|
|
639
|
-
|
|
640
|
-
|
|
645
|
+
for (const k of requires) {
|
|
646
|
+
const v = keyring.get(k);
|
|
647
|
+
if (v !== null && base[k] === undefined) base[k] = v;
|
|
648
|
+
}
|
|
649
|
+
} catch (e) { /* keyring unavailable — the tool runs without creds */ }
|
|
641
650
|
}
|
|
651
|
+
return base;
|
|
642
652
|
}
|
|
643
653
|
|
|
644
654
|
// Which of a tool's required keyring keys are actually present right now — used
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.2",
|
|
4
4
|
"description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
12
|
-
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js"
|
|
12
|
+
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"bot.js",
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"test-pack-nesting.js",
|
|
47
47
|
"test-read-signal.js",
|
|
48
48
|
"test-tools.js",
|
|
49
|
-
"test-tool-graph.js"
|
|
49
|
+
"test-tool-graph.js",
|
|
50
|
+
"test-tool-guard.js"
|
|
50
51
|
],
|
|
51
52
|
"keywords": [
|
|
52
53
|
"claude",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Tool-first deny-gate + credential confinement (5b). Contract: an ACTING
|
|
2
|
+
// network/DB/shell command aimed at a known prod surface is denied with a
|
|
3
|
+
// redirect to the covering tool (or a scaffold command, plus the logged escape
|
|
4
|
+
// hatch); probing/diagnosis that merely MENTIONS a surface stays free; the
|
|
5
|
+
// authorised channels (tool run / keyring exec) are never denied; every guard
|
|
6
|
+
// failure mode fails OPEN. Confinement: botSubprocessEnv carries NO keyring
|
|
7
|
+
// values (least-privilege runEnv injection is covered in test-tools.js).
|
|
8
|
+
const assert = require("assert");
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const os = require("os");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
|
|
13
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tool-guard-"));
|
|
14
|
+
process.env.TOOL_GUARD_LOG = path.join(tmp, "guard.jsonl");
|
|
15
|
+
process.env.DENY_GATE_SETTINGS = path.join(tmp, "deny-gate.settings.json");
|
|
16
|
+
process.env.KEYRING_FILE = path.join(tmp, "keyring.json");
|
|
17
|
+
|
|
18
|
+
const guard = require("./core/tool-guard");
|
|
19
|
+
|
|
20
|
+
// ── denies: acting command × prod surface ──
|
|
21
|
+
assert.strictEqual(guard.matchRule("curl -s https://central.inet.africa/api/v1/users").id, "inet-central-api");
|
|
22
|
+
assert.strictEqual(guard.matchRule("wget http://ticketcentral.inet.africa/tickets").id, "ticket-central-api");
|
|
23
|
+
assert.strictEqual(guard.matchRule("ssh admin@10.200.3.14").id, "prod-network");
|
|
24
|
+
assert.strictEqual(guard.matchRule('kubectl exec -it mongo-0 -- mongosh --eval "db.users.find()"').id, "in-cluster-mongo");
|
|
25
|
+
assert.strictEqual(guard.matchRule("ssh codersafrica@git.coders.africa").id, "gitlab-ssh");
|
|
26
|
+
assert.strictEqual(guard.matchRule("echo start && curl https://central.inet.africa/x").id, "inet-central-api",
|
|
27
|
+
"acting binary after && still trips the gate");
|
|
28
|
+
assert.strictEqual(guard.matchRule('bash -c "curl https://central.inet.africa/x"').id, "inet-central-api",
|
|
29
|
+
"bash -c wrapping does not dodge the gate (quotes count as separators)");
|
|
30
|
+
|
|
31
|
+
// ── allows: probing, diagnosis, unrelated work, the authorised channels ──
|
|
32
|
+
assert.strictEqual(guard.matchRule("grep -rn central.inet.africa src/"), null, "grep mentioning a host is probing, not operating");
|
|
33
|
+
assert.strictEqual(guard.matchRule("cat notes/10.200.1.1.md"), null, "no acting net command → allow");
|
|
34
|
+
assert.strictEqual(guard.matchRule("ping 10.200.1.1"), null, "ping is read-only diagnosis");
|
|
35
|
+
assert.strictEqual(guard.matchRule("kubectl get pods -A"), null, "read-only kubectl is not gated");
|
|
36
|
+
assert.strictEqual(guard.matchRule("kubectl logs api-7f9 -n prod"), null, "kubectl logs is not gated");
|
|
37
|
+
assert.strictEqual(guard.matchRule("git push origin main"), null, "GitOps flow unaffected");
|
|
38
|
+
assert.strictEqual(guard.matchRule("curl https://api.github.com/repos"), null, "non-prod hosts are not gated");
|
|
39
|
+
assert.strictEqual(guard.matchRule("open-claudia tool run inet-central-cli users list"), null, "tool run is the authorised channel");
|
|
40
|
+
assert.strictEqual(guard.matchRule('open-claudia keyring exec --reason "x" -- curl https://central.inet.africa/x'), null,
|
|
41
|
+
"keyring exec is the logged escape hatch, never denied");
|
|
42
|
+
assert.strictEqual(guard.matchRule(""), null, "empty command → allow");
|
|
43
|
+
assert.strictEqual(guard.matchRule("see the ticketcentral docs for details"), null, "mentioning a surface without acting → allow");
|
|
44
|
+
|
|
45
|
+
// ── deny message: names the covering tool / scaffold command + escape hatch ──
|
|
46
|
+
const msg = guard.denyMessage(guard.matchRule("curl https://central.inet.africa/x"));
|
|
47
|
+
assert.ok(/inet-central-cli/.test(msg), "deny names the covering tool");
|
|
48
|
+
assert.ok(/keyring exec --reason/.test(msg), "deny names the escape hatch");
|
|
49
|
+
assert.ok(/tool scaffold kticket/.test(guard.denyMessage(guard.matchRule("curl http://ticketcentral.inet.africa/x"))),
|
|
50
|
+
"no covering tool → scaffold-first redirect with the exact command");
|
|
51
|
+
assert.ok(/prod-k8s/.test(guard.denyMessage(guard.matchRule("kubectl exec mongo-0 -- mongosh"))),
|
|
52
|
+
"in-cluster mongo redirects to prod-k8s");
|
|
53
|
+
|
|
54
|
+
// ── runHook protocol: exit 2 + stderr on deny, exit 0 on allow, fail-open ──
|
|
55
|
+
const deny = guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: "curl https://central.inet.africa/x" } }));
|
|
56
|
+
assert.strictEqual(deny.exit, 2, "deny → exit 2 (blocking)");
|
|
57
|
+
assert.ok(/deny-gate/.test(deny.stderr), "deny → stderr message for the model");
|
|
58
|
+
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls -la" } })).exit, 0, "benign Bash → allow");
|
|
59
|
+
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Read", tool_input: { file_path: "/etc/hosts" } })).exit, 0, "non-Bash tools are never gated");
|
|
60
|
+
assert.strictEqual(guard.runHook("not json{{{").exit, 0, "unparseable payload fails OPEN");
|
|
61
|
+
assert.strictEqual(guard.runHook(null).exit, 0, "null payload fails OPEN");
|
|
62
|
+
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash" })).exit, 0, "missing tool_input fails OPEN");
|
|
63
|
+
|
|
64
|
+
// ── audit trail: denies + bypasses land in the guard log (5c KPI source) ──
|
|
65
|
+
guard.logGuardEvent({ kind: "bypass", reason: "no tool yet", command: "curl x" });
|
|
66
|
+
const lines = fs.readFileSync(process.env.TOOL_GUARD_LOG, "utf-8").trim().split("\n").map((l) => JSON.parse(l));
|
|
67
|
+
assert.ok(lines.some((l) => l.kind === "deny" && l.rule === "inet-central-api"), "runHook deny is logged");
|
|
68
|
+
assert.ok(lines.some((l) => l.kind === "bypass" && l.reason === "no tool yet"), "keyring exec bypass is logged");
|
|
69
|
+
assert.ok(lines.every((l) => l.ts), "every guard event is timestamped");
|
|
70
|
+
|
|
71
|
+
// ── hook settings: a valid claude --settings payload, idempotent writer ──
|
|
72
|
+
const sPath = guard.ensureHookSettings();
|
|
73
|
+
assert.strictEqual(sPath, process.env.DENY_GATE_SETTINGS, "settings path is env-overridable (test isolation)");
|
|
74
|
+
const settings = JSON.parse(fs.readFileSync(sPath, "utf-8"));
|
|
75
|
+
const pre = settings.hooks.PreToolUse;
|
|
76
|
+
assert.strictEqual(pre[0].matcher, "Bash", "hook is scoped to the Bash tool");
|
|
77
|
+
assert.ok(/deny-gate-hook/.test(pre[0].hooks[0].command), "hook invokes the CLI hook entry");
|
|
78
|
+
assert.ok(pre[0].hooks[0].command.includes(process.execPath), "hook uses an absolute node path (subprocess PATH independent)");
|
|
79
|
+
assert.ok(pre[0].hooks[0].timeout > 0, "hook carries a timeout so a hung gate fails open");
|
|
80
|
+
// second call with the file read-only: content-compare must skip the write
|
|
81
|
+
fs.chmodSync(sPath, 0o444);
|
|
82
|
+
assert.strictEqual(guard.ensureHookSettings(), sPath, "unchanged settings are not rewritten");
|
|
83
|
+
fs.chmodSync(sPath, 0o644);
|
|
84
|
+
|
|
85
|
+
// ── credential confinement: agent subprocess env carries NO keyring values ──
|
|
86
|
+
fs.writeFileSync(process.env.KEYRING_FILE, JSON.stringify({ confined_secret: "must-not-leak" }));
|
|
87
|
+
const { botSubprocessEnv } = require("./core/config");
|
|
88
|
+
const env = botSubprocessEnv();
|
|
89
|
+
assert.strictEqual(env.confined_secret, undefined, "keyring values never reach agent/sub-agent shells (5b)");
|
|
90
|
+
assert.ok(env.PATH, "subprocess env still carries PATH");
|
|
91
|
+
|
|
92
|
+
console.log("tool guard OK");
|
package/test-tools.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Reusable tools: a script the agent crystallises must register with a parseable
|
|
2
2
|
// header, live in the git-tracked directory layout (tool + TOOL.md + state.json),
|
|
3
|
-
// run with
|
|
4
|
-
// findable by the always-on tool index.
|
|
5
|
-
// in an isolated TOOLS_DIR
|
|
3
|
+
// run with ONLY its declared --requires keyring keys injected (least-privilege
|
|
4
|
+
// preauth, 5b), be risk-gated, and be findable by the always-on tool index.
|
|
5
|
+
// Exercises core/tools.js + core/tool-repo.js in an isolated TOOLS_DIR +
|
|
6
|
+
// KEYRING_FILE so nothing touches the real ~/.open-claudia.
|
|
6
7
|
const assert = require("assert");
|
|
7
8
|
const fs = require("fs");
|
|
8
9
|
const os = require("os");
|
|
@@ -10,6 +11,7 @@ const path = require("path");
|
|
|
10
11
|
|
|
11
12
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tools-test-"));
|
|
12
13
|
process.env.TOOLS_DIR = path.join(tmp, "tools");
|
|
14
|
+
process.env.KEYRING_FILE = path.join(tmp, "keyring.json");
|
|
13
15
|
|
|
14
16
|
const tools = require("./core/tools");
|
|
15
17
|
const repo = require("./core/tool-repo");
|
|
@@ -101,9 +103,23 @@ assert.ok(!missing.includes("api_key"), "a key present in env is not 'missing'")
|
|
|
101
103
|
assert.ok(missing.includes("api_secret"), "a key absent everywhere is 'missing'");
|
|
102
104
|
delete process.env.api_key;
|
|
103
105
|
|
|
104
|
-
// ── runEnv
|
|
105
|
-
|
|
106
|
+
// ── runEnv (5b least-privilege): only the tool's declared --requires keys are
|
|
107
|
+
// injected from the keyring; undeclared keys stay OUT; process.env wins on
|
|
108
|
+
// conflict; a tool with no requires gets no keyring keys at all ──
|
|
109
|
+
fs.writeFileSync(process.env.KEYRING_FILE, JSON.stringify({
|
|
110
|
+
api_key: "from-keyring", api_secret: "s3cret-from-keyring", unrelated_key: "never-injected",
|
|
111
|
+
}));
|
|
112
|
+
process.env.api_key = "env-wins";
|
|
113
|
+
const env = tools.runEnv(tools.findTool("hello")); // requires: api_key, api_secret
|
|
106
114
|
assert.ok(env && typeof env === "object" && env.PATH, "runEnv yields an env object carrying PATH");
|
|
115
|
+
assert.strictEqual(env.api_secret, "s3cret-from-keyring", "a declared --requires key is injected from the keyring");
|
|
116
|
+
assert.strictEqual(env.api_key, "env-wins", "process.env wins over the keyring on conflict");
|
|
117
|
+
assert.strictEqual(env.unrelated_key, undefined, "an undeclared keyring key never reaches a tool run");
|
|
118
|
+
const envNoReq = tools.runEnv(tools.findTool("peek")); // no requires declared
|
|
119
|
+
assert.strictEqual(envNoReq.api_secret, undefined, "a tool with no requires gets no keyring keys");
|
|
120
|
+
assert.strictEqual(tools.runEnv().unrelated_key, undefined, "runEnv without a tool injects nothing");
|
|
121
|
+
delete process.env.api_key;
|
|
122
|
+
fs.writeFileSync(process.env.KEYRING_FILE, "{}");
|
|
107
123
|
|
|
108
124
|
// ── toolNameFromPath recognises legacy flat files and the directory layout's
|
|
109
125
|
// agent-editable members; state.json and outside paths do not resolve ──
|