@inetafrica/open-claudia 2.7.1 → 2.7.3

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.7.3
4
+ - **Surfaced tools now carry their condition (tiered surfacing, Phase 2/item 6).** The per-turn "Tools that may help here" block rendered one identity line per tool — name, description, keyring needs — so the agent knew a tool *existed* but not what state it was in, and had to spend a `tool show` (or just run it blind) to find out it was broken, never proven, or failing since Tuesday. Each surfaced tool is now a **2-line headline**: line 1 is identity (name — description, risk tier, keyring needs, why it surfaced), line 2 is condition — the TOOL.md **State** headline (first line, truncated), the open **known-issue count** (⚠ n), and **last-run health** from telemetry (`last run OK/FAILED (exit n) <date>, n runs`, or `never run`; legacy flat tools without exit telemetry degrade to `last used`). This completes the tiering ladder: 2-line headline always → TOOL.md via `tool show` → source only via `--source` — you read the manual, not the disassembly, and now the cover tells you if the manual has errata. New `tools.toolCondition()` with unit coverage in `test-tools.js` (headline extraction, `(none yet)` filtering, health formats, clean degrade) and end-to-end render assertions in `test-recall-discoverer.js`.
5
+
6
+ ## v2.7.2
7
+ - **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.
8
+ - **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.
9
+ - **`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.
10
+
3
11
  ## v2.7.1
4
12
  - **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
13
  - **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) — available to the agent as env vars:\n`);
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 runs with the operational keyring merged into its env, so
38
- // reference creds as $NAME inside the script. `open-claudia keyring list` shows
39
- // what's available; never hardcode secrets in a tool.
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
- const base = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
223
- // Merge operational keyring creds so the agent can *use* what it remembered.
224
- // Read fresh each spawn so a cred set mid-session is live next turn without a
225
- // restart. process.env wins on conflict bot infra config is authoritative.
226
- try {
227
- const keyring = require("./keyring");
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 to the agent (no
1516
- // password, no lock) so it can repeat work you've shown it once. Use it for
1517
- // creds the agent is allowed to use unattended; use /vault for personal
1518
- // secrets it must not. Values are injected into the agent's env each turn.
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 (available to the agent as env vars):\n\n" + names.map((k) => `${k}: ${entries[k]}`).join("\n") + "\n\nGet a value: /keyring get <name>");
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. It's available to the agent from the next turn.`);
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
- // Values set here flow to every agent subprocess via botSubprocessEnv()
16
- // (config.js), so a credential stored once is present as an env var on the
17
- // next turnno restart needed.
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");
@@ -515,12 +515,21 @@ async function run(ctx) {
515
515
  }
516
516
  let toolBlock = "";
517
517
  if (toolMatches.length) {
518
+ // Tiered injection (Phase 2): a 2-line headline per tool — identity line
519
+ // (name, description, risk tier, keyring needs, why surfaced) plus a
520
+ // condition line (TOOL.md State headline, open issues, last-run health) so
521
+ // the agent knows the tool's shape AND state without loading the manual.
518
522
  const lines = toolMatches.map((m) => {
519
523
  const t = toolsLib.findTool(m.name);
520
- const needs = t && t.requires && t.requires.length ? ` (keyring: ${t.requires.join(", ")})` : "";
521
- return `- ${m.name} — ${m.description || "(no description)"}${needs}${m.why ? ` — ${m.why}` : ""}`;
524
+ const meta = [];
525
+ if (t && t.risk) meta.push(t.risk);
526
+ if (t && t.requires && t.requires.length) meta.push(`keyring: ${t.requires.join(", ")}`);
527
+ const head = `- ${m.name} — ${m.description || "(no description)"}${meta.length ? ` (${meta.join("; ")})` : ""}${m.why ? ` — ${m.why}` : ""}`;
528
+ let cond = "";
529
+ try { cond = toolsLib.toolCondition(t); } catch (e) {}
530
+ return cond ? `${head}\n ↳ ${cond}` : head;
522
531
  });
523
- toolBlock = `\n\n## Tools that may help here\nSurfaced for this turn — run with \`open-claudia tool run <name> [args]\`; read docs/source with \`open-claudia tool show <name>\`:\n${lines.join("\n")}\n`;
532
+ toolBlock = `\n\n## Tools that may help here\nSurfaced for this turn — read the manual with \`open-claudia tool show <name>\`, then run with \`open-claudia tool run <name> [args]\`:\n${lines.join("\n")}\n`;
524
533
  }
525
534
 
526
535
  // 5a tool-scout: replace the silence of an empty/partial tool match with an
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",
@@ -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: the operational keyring is merged into tool runs and into your own Bash. Reference creds as \`$inet_central_user\` etc. (\`open-claudia keyring list\` shows names); declare a tool's needs via \`--requires\` so a missing key reports before the run; never write a secret into a tool file (saves are refused).
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: tools run with the operational keyring merged into their env (see
19
- // runEnv()). A tool references a credential as $inet_central_user and never
20
- // hardcodes a secret addTool/updateToolHeader REFUSE sources that match
21
- // secret patterns, because the auto-commit history is forever.
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
@@ -315,6 +316,40 @@ function readToolDoc(name) {
315
316
  try { return fs.readFileSync(p.doc, "utf-8"); } catch (e) { return ""; }
316
317
  }
317
318
 
319
+ function sectionOf(doc, heading) {
320
+ const m = String(doc || "").match(new RegExp(`##\\s*${heading}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, "i"));
321
+ return m ? m[1] : "";
322
+ }
323
+
324
+ // One-line condition digest for the per-turn surfacing block (tiered injection:
325
+ // headline always → TOOL.md via `tool show` → source only via --source). Rides
326
+ // under the tool's headline so the agent knows its condition BEFORE running it:
327
+ // TOOL.md's State headline, open known-issue count, last-run health from
328
+ // telemetry. "" when there is nothing worth saying.
329
+ function toolCondition(t) {
330
+ if (!t || !t.name) return "";
331
+ const parts = [];
332
+ const doc = readToolDoc(t.name);
333
+ if (doc) {
334
+ const headline = sectionOf(doc, "State").split("\n")
335
+ .map((l) => l.trim())
336
+ .find((l) => l && !/^\(none/i.test(l) && !l.startsWith("#"));
337
+ if (headline) parts.push(headline.length > 120 ? `${headline.slice(0, 117)}…` : headline);
338
+ const issues = sectionOf(doc, "Known issues").split("\n").filter((l) => /^\s*-\s*\S/.test(l)).length;
339
+ if (issues) parts.push(`⚠ ${issues} known issue${issues === 1 ? "" : "s"}`);
340
+ }
341
+ if (!t.runCount) parts.push("never run");
342
+ else {
343
+ const when = (t.lastUsed || "").slice(0, 10);
344
+ // Legacy flat tools have no lastExit — usage count is all we can say.
345
+ const health = typeof t.lastExit === "number"
346
+ ? (t.lastExit === 0 ? "last run OK" : `last run FAILED (exit ${t.lastExit})`)
347
+ : "last used";
348
+ parts.push(`${health}${when ? ` ${when}` : ""}, ${t.runCount} run${t.runCount === 1 ? "" : "s"}`);
349
+ }
350
+ return parts.join(" · ");
351
+ }
352
+
318
353
  function listTools() {
319
354
  let entries;
320
355
  try { entries = fs.readdirSync(TOOLS_DIR); } catch (e) { return []; }
@@ -628,17 +663,26 @@ function runGate(tool, argv = []) {
628
663
  };
629
664
  }
630
665
 
631
- // Env for running a tool: the bot's standard subprocess env (PATH + keyring
632
- // creds merged) so tools are pre-authed the same way the agent is. Falls back to
633
- // a plain keyring merge if config isn't importable (e.g. standalone test).
634
- function runEnv() {
635
- try { return require("./config").botSubprocessEnv(); }
636
- catch (e) {
666
+ // Env for running a tool: the bot's cred-free subprocess env plus ONLY the
667
+ // keyring keys this tool declares via --requires least-privilege credential
668
+ // injection (5b). Undeclared keys never reach the subprocess; process.env wins
669
+ // on conflict (bot infra config stays authoritative). Keys read fresh each run
670
+ // so a cred set mid-session is live immediately.
671
+ function runEnv(tool) {
672
+ let base;
673
+ try { base = require("./config").botSubprocessEnv(); }
674
+ catch (e) { base = { ...process.env }; }
675
+ const requires = (tool && tool.requires) || [];
676
+ if (requires.length) {
637
677
  try {
638
678
  const keyring = require("./keyring");
639
- return { ...keyring.all(), ...process.env };
640
- } catch (e2) { return { ...process.env }; }
679
+ for (const k of requires) {
680
+ const v = keyring.get(k);
681
+ if (v !== null && base[k] === undefined) base[k] = v;
682
+ }
683
+ } catch (e) { /* keyring unavailable — the tool runs without creds */ }
641
684
  }
685
+ return base;
642
686
  }
643
687
 
644
688
  // Which of a tool's required keyring keys are actually present right now — used
@@ -668,7 +712,7 @@ function toolNameFromPath(filePath) {
668
712
 
669
713
  module.exports = {
670
714
  TOOLS_DIR, MARKER, USAGE_FILE, RISK_TIERS,
671
- parseHeader, readTool, readToolDoc, listTools, findTool, addTool, removeTool, updateToolHeader,
715
+ parseHeader, readTool, readToolDoc, toolCondition, listTools, findTool, addTool, removeTool, updateToolHeader,
672
716
  runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir, normalizeRisk,
673
717
  toolPaths, readState, writeState, recordRunResult, runGate,
674
718
  matchTools, recordRun, recordCreate, readUsage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.7.1",
3
+ "version": "2.7.3",
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",
@@ -26,6 +26,16 @@ toolsLib.addTool((() => {
26
26
  fs.writeFileSync(s, "#!/usr/bin/env bash\necho audit\n");
27
27
  return s;
28
28
  })(), { name: "mikrotik-audit", description: "audit the mikrotik router fleet inventory" });
29
+ // Give the fixture a condition (Phase 2 tiered surfacing): TOOL.md State
30
+ // headline + a known issue + failed-run telemetry, so the 2-line headline can
31
+ // be asserted end-to-end through the renderer.
32
+ fs.writeFileSync(path.join(process.env.TOOLS_DIR, "mikrotik-audit", "TOOL.md"), [
33
+ "# mikrotik-audit", "", "## Interface", "audit the mikrotik router fleet inventory", "",
34
+ "## Known issues", "- [2026-07-01] R2 times out on list", "",
35
+ "## State", "Covers 12 routers; R2 flaky.", "",
36
+ "## Journal", "- [2026-07-01] Created.", "",
37
+ ].join("\n"));
38
+ toolsLib.writeState("mikrotik-audit", { createdAt: "2026-07-01T00:00:00Z", lastUsed: "2026-07-01T10:00:00.000Z", runCount: 3, lastExit: 1 });
29
39
 
30
40
  // --- tiered pre-gate ---
31
41
  assert.strictEqual(disc.needsRecall("", 0), false, "empty → no recall");
@@ -114,6 +124,12 @@ const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEnti
114
124
  assert.ok(outTool.toolMatches.some((m) => m.name === "mikrotik-audit"), "matching tool is kept (seeds-tier user seed)");
115
125
  assert.ok(/mikrotik-audit/.test(outTool.toolBlock), "kept tool renders into the toolBlock");
116
126
  assert.ok(/Tools that may help here/.test(outTool.toolBlock), "toolBlock carries its heading");
127
+ // Phase 2 tiered surfacing: headline line 1 carries identity (risk tier),
128
+ // line 2 carries condition — State headline, issue count, last-run health.
129
+ assert.ok(/\(write\)/.test(outTool.toolBlock), "identity line carries the risk tier");
130
+ assert.ok(/↳ Covers 12 routers; R2 flaky\./.test(outTool.toolBlock), "condition line leads with the TOOL.md State headline");
131
+ assert.ok(/⚠ 1 known issue/.test(outTool.toolBlock), "condition line counts open known issues");
132
+ assert.ok(/last run FAILED \(exit 1\) 2026-07-01, 3 runs/.test(outTool.toolBlock), "condition line reports last-run health");
117
133
 
118
134
  // full-tier fail-open: with no judge, an unjudged TOOL is a guess — dropped.
119
135
  // (Measured before the fix: a failed walk kept 15 unjudged seeds including
@@ -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 the keyring merged into its env (preauth), be risk-gated, and be
4
- // findable by the always-on tool index. Exercises core/tools.js + core/tool-repo.js
5
- // in an isolated TOOLS_DIR so nothing touches the real ~/.open-claudia/tools.
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 returns an env object (keyring merged or graceful fallback) ──
105
- const env = tools.runEnv();
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 ──
@@ -293,6 +309,31 @@ try {
293
309
  assert.ok(fs.readFileSync(path.join(process.env.TOOLS_DIR, ".gitignore"), "utf-8").includes("state.json"), "state.json is gitignored");
294
310
  } catch (e) { console.log(" (git not available — history assertions skipped)"); }
295
311
 
312
+ // ── toolCondition: the surfacing digest (Phase 2 tiered injection) — TOOL.md
313
+ // State headline + open-issue count + last-run health; degrades cleanly ──
314
+ const condSrc = path.join(tmp, "condtool.sh");
315
+ fs.writeFileSync(condSrc, "#!/usr/bin/env bash\necho ok\n");
316
+ tools.addTool(condSrc, { name: "condtool", description: "probes widgets" });
317
+ const freshCond = tools.toolCondition(tools.findTool("condtool"));
318
+ assert.ok(/Scaffolded .* — not yet proven/.test(freshCond), "fresh tool leads with the skeleton State line");
319
+ assert.ok(/never run/.test(freshCond), "zero runs read as never run");
320
+ assert.ok(!/known issue/.test(freshCond), "\"(none yet)\" placeholder is not an issue");
321
+ fs.writeFileSync(path.join(process.env.TOOLS_DIR, "condtool", "TOOL.md"), [
322
+ "# condtool", "", "## Interface", "probes widgets", "",
323
+ "## Known issues", "- [2026-07-01] list verb times out on slow links", "- [2026-07-02] json flag ignored", "",
324
+ "## State", "Covers 12 widget hosts; watch host-7.", "More detail that must not surface.", "",
325
+ "## Journal", "- [2026-07-01] Created.", "",
326
+ ].join("\n"));
327
+ tools.writeState("condtool", { createdAt: "2026-07-01T00:00:00Z", lastUsed: "2026-07-02T10:00:00.000Z", runCount: 3, lastExit: 1 });
328
+ const cond = tools.toolCondition(tools.findTool("condtool"));
329
+ assert.ok(cond.startsWith("Covers 12 widget hosts; watch host-7."), "State headline (first line only) leads the digest");
330
+ assert.ok(!/must not surface/.test(cond), "only the State headline surfaces, not the whole section");
331
+ assert.ok(/⚠ 2 known issues/.test(cond), "open known-issue count rides along");
332
+ assert.ok(/last run FAILED \(exit 1\) 2026-07-02, 3 runs/.test(cond), "failed last run carries exit, date, and run count");
333
+ tools.writeState("condtool", { createdAt: "2026-07-01T00:00:00Z", lastUsed: "2026-07-03T09:00:00.000Z", runCount: 4, lastExit: 0 });
334
+ assert.ok(/last run OK 2026-07-03, 4 runs/.test(tools.toolCondition(tools.findTool("condtool"))), "healthy last run reads OK");
335
+ assert.strictEqual(tools.toolCondition(null), "", "no tool → empty digest");
336
+
296
337
  // ── remove ──
297
338
  assert.ok(tools.removeTool("hello"), "removeTool returns the removed tool");
298
339
  assert.strictEqual(tools.findTool("hello"), null, "removed tool is gone");