@inetafrica/open-claudia 2.7.2 → 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,8 @@
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
+
3
6
  ## v2.7.2
4
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.
5
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.
@@ -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/tools.js CHANGED
@@ -316,6 +316,40 @@ function readToolDoc(name) {
316
316
  try { return fs.readFileSync(p.doc, "utf-8"); } catch (e) { return ""; }
317
317
  }
318
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
+
319
353
  function listTools() {
320
354
  let entries;
321
355
  try { entries = fs.readdirSync(TOOLS_DIR); } catch (e) { return []; }
@@ -678,7 +712,7 @@ function toolNameFromPath(filePath) {
678
712
 
679
713
  module.exports = {
680
714
  TOOLS_DIR, MARKER, USAGE_FILE, RISK_TIERS,
681
- parseHeader, readTool, readToolDoc, listTools, findTool, addTool, removeTool, updateToolHeader,
715
+ parseHeader, readTool, readToolDoc, toolCondition, listTools, findTool, addTool, removeTool, updateToolHeader,
682
716
  runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir, normalizeRisk,
683
717
  toolPaths, readState, writeState, recordRunResult, runGate,
684
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.2",
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": {
@@ -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
package/test-tools.js CHANGED
@@ -309,6 +309,31 @@ try {
309
309
  assert.ok(fs.readFileSync(path.join(process.env.TOOLS_DIR, ".gitignore"), "utf-8").includes("state.json"), "state.json is gitignored");
310
310
  } catch (e) { console.log(" (git not available — history assertions skipped)"); }
311
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
+
312
337
  // ── remove ──
313
338
  assert.ok(tools.removeTool("hello"), "removeTool returns the removed tool");
314
339
  assert.strictEqual(tools.findTool("hello"), null, "removed tool is gone");