@inetafrica/open-claudia 2.7.3 → 2.7.5

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,6 +1,12 @@
1
1
  # Changelog
2
2
 
3
- ## v2.7.3
3
+ ## v2.7.5
4
+ - **Tool composition with inherited approval — a composing tool cannot self-approve (Phase 4).** Higher-order tools may shell into lower tools (report → kticket + kchat), but the callee's risk gate stays intact: `tool run` now exports the tier the **outermost** invocation's flags acknowledged (`OPEN_CLAUDIA_APPROVED_TIER`) plus the call chain (`OPEN_CLAUDIA_TOOL_STACK`) into the child env, and a nested run gates on that inherited tier while **ignoring its own argv flags** — so a composing tool that hardcodes `--yes-destructive` in its source is refused with a message pointing at the outer re-run. An inherited tier passes through unchanged (depth-safe), a tool already in the chain is refused (cycle guard), and each nested run still stamps its own telemetry. Verified live: fabricated approval blocked at exit 3, legit outer `--yes-destructive` flowing through, self-call cycle caught at depth 1.
5
+ - **`_lib/` shared plumbing with dependent journaling (Phase 4).** Genuinely shared code (central auth, mongo-exec) now lives in `tools/_lib` — tiny modules tools import via a literal `_lib/<name>` path, never tools themselves (the registry, surfacing index, and legacy migration all skip the `_` prefix). New `tool lib list|show|set`: `set` writes the module (secret-blocking, traversal-safe), finds every dependent by a boundary-safe source grep (`central-auth` matches `_lib/central-auth.js` and the `path.join` form, **not** `central-auth-v2`), journals each dependent `unverified since _lib change — re-verified by next real use`, and commits the lot in ONE commit naming the dependents — the PLAN's blast-radius rule, since a lib change can break several tools at once and health is verified by use, not smoke tests.
6
+ - **Umbrella grouping from registry tags (Phase 4).** Tools gain an optional `tags:` header field (`--tags` on `scaffold`/`add`/`set`, sanitised, strong-match in `tool search`); `tool list` renders the first tag as an umbrella group — `kazee/{kchat,kticket}`, `infra/{prod-k8s}` — with untagged tools flat below. Purely presentational: "Kazee" stays a registry grouping, never a binary. The fixed system-prompt tools section gains one composition bullet (approval is inherited, never fabricated; `_lib` marks dependents unverified). Test coverage: tags round-trip, lib dependent grep boundaries, setLib journaling, and the full nested-gate matrix including the fabrication and cycle refusals.
7
+ - **The dream now audits tool usage — raw-op detector + usage KPI (Phase 3/item 9 + enforcement 5c).** The guard log knew about *blocked* commands (denies) and *authorised* one-offs (`keyring exec` bypasses), but operational work done raw that the gate didn't cover — inline heredoc scripts fed to an interpreter, raw DB clients, mutating `kubectl` — left no trace, so the tool-usage KPI had a blind spot exactly where the "repeated heredocs slip through" failure lives. New deterministic detector in `core/tool-guard.js` (`matchRawOp`/`noteRawOp`): high-precision patterns (`heredoc-interpreter`, `db-client`, `kubectl-mutate`), wired into the runner's Bash-stream chokepoint so every command is observed — **observation only, never blocking**; the authorised channels and anything the deny-gate already owns are skipped so one command never yields two guard events. Reads (`kubectl get/logs`, `rollout status`, `cat <<EOF` file writes, mentioning a client in grep) stay unflagged.
8
+ - **Doc/code drift flag completes the dream tool-pass.** Alongside the existing hygiene flags (dangling pack links, cold never-run tools, near-duplicate pairs), the pass now flags tools whose source was edited after TOOL.md was last touched, and verbs that telemetry proves are in real use but the docs never mention (`tools.docDrift()`; `help`/`(default)` ignored). Flag only — the fix is a `tool note`. Zero false flags on the live 12-tool registry at ship time.
9
+ - **Nightly KPI in the dream report, computed from stamps — never prose.** The dream's tool pass now appends `📈 Tool usage since last dream`: % of operational actions via `tool run` (target >90%, denominator = tool runs + escape-hatch bypasses + raw-op sightings; gate denies reported separately since blocked ≠ completed), bypass count **with reasons**, raw-ops by pattern, and failure-at-use counts naming the failing tools. Sources are `state.json` run-history stamps (new `tools.runsSince(sinceTs)`) + `tool-guard.jsonl` (new `readGuardEvents(sinceTs)`) — per the plan, the KPI is never parsed out of conversation prose. Escape-hatch bypasses surface in the dream report only (no immediate chat pings — pings would train us both to ignore them). Test coverage: raw-op matrix incl. no-double-count with denies, event grouping/windowing, `runsSince` window math.
4
10
  - **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
11
 
6
12
  ## v2.7.2
package/bin/tool.js CHANGED
@@ -2,20 +2,21 @@
2
2
  // through. Tool-first loop: search → exists: use it → missing a verb: extend →
3
3
  // nothing: scaffold, then work through the tool.
4
4
  //
5
- // open-claudia tool list — index (name description [skill])
5
+ // open-claudia tool list — index, grouped by first tag (kazee/, infra/, )
6
6
  // open-claudia tool search <query> — lexical match (find before adding a dup)
7
7
  // open-claudia tool show <name> [--source] — TOOL.md, telemetry, history (source with --source)
8
8
  // open-claudia tool scaffold <name> — create a new tool skeleton (dir + stub + TOOL.md)
9
9
  // [--pack <dir>] [--desc "..."] [--risk read-only|write|destructive]
10
- // [--requires "k1,k2"] [--usage "..."] [--lang node|bash]
10
+ // [--requires "k1,k2"] [--tags "group"] [--usage "..."] [--lang node|bash]
11
11
  // open-claudia tool add <path> [--name n] — register an existing script as a tool
12
- // [--pack <dir>] [--desc "..."] [--risk tier] [--requires "k1,k2"] [--usage "..."]
12
+ // [--pack <dir>] [--desc "..."] [--risk tier] [--requires "k1,k2"] [--tags "group"] [--usage "..."]
13
13
  // open-claudia tool set <name> [--desc ...] — fix a tool's metadata in place
14
- // [--pack <dir>] [--risk tier] [--requires "k1,k2"] [--usage "..."]
14
+ // [--pack <dir>] [--risk tier] [--requires "k1,k2"] [--tags "group"] [--usage "..."]
15
15
  // open-claudia tool note <name> — append to TOOL.md (auto-committed)
16
16
  // [--issue "..."] [--state "..."] [--journal "..."]
17
17
  // open-claudia tool edit <name> — print the file path to open & edit
18
18
  // open-claudia tool run <name> [args...] — run it (keyring pre-loaded, risk-gated)
19
+ // open-claudia tool lib [list|show|set] ... — shared _lib plumbing tools import (see below)
19
20
  // open-claudia tool migrate — convert legacy flat-file tools to directories
20
21
  // open-claudia tool remove <name> — delete one
21
22
  // open-claudia tool path — print the tools directory
@@ -38,6 +39,13 @@
38
39
  // it declares via --requires — reference them as $NAME inside the script and
39
40
  // declare every one. `open-claudia keyring list` shows what exists; never
40
41
  // hardcode secrets in a tool. Undeclared keys simply won't be in the env.
42
+ //
43
+ // Composition (Phase 4): a tool may shell into other tools (report → kticket).
44
+ // Approval is inherited, never fabricated: the runner exports the tier the
45
+ // OUTER flags acknowledged, and a nested `tool run` gates on that inherited
46
+ // tier while ignoring its own argv flags. Shared plumbing lives in _lib/
47
+ // (`tool lib set <name> <src>`); a lib change journals every dependent tool
48
+ // "unverified since _lib change" — re-verified by its next real use.
41
49
 
42
50
  const { spawnSync } = require("child_process");
43
51
  const tools = require("../core/tools");
@@ -124,10 +132,31 @@ function run(args) {
124
132
  return console.log(`No tools yet (${tools.TOOLS_DIR}).\nSave one with: open-claudia tool add <script-path> --pack <skill>`);
125
133
  }
126
134
  console.log(`${all.length} reusable tool(s) — run via 'open-claudia tool run <name>':\n`);
127
- for (const t of all) {
135
+ const bullet = (t, indent = "") => {
128
136
  const skill = t.pack ? ` [skill: ${t.pack}]` : "";
129
137
  const risk = t.risk === "read-only" ? "" : ` [${t.risk || "write?"}]`;
130
- console.log(`• ${t.name} — ${t.description || "(no description)"}${skill}${risk}`);
138
+ console.log(`${indent}• ${t.name} — ${t.description || "(no description)"}${skill}${risk}`);
139
+ };
140
+ // Umbrella grouping from registry tags: first tag is the group
141
+ // (kazee/{kticket,…}); untagged tools render flat. Purely presentational —
142
+ // groups are tags, never binaries.
143
+ const groups = new Map();
144
+ for (const t of all) {
145
+ const g = (t.tags && t.tags[0]) || "";
146
+ if (!groups.has(g)) groups.set(g, []);
147
+ groups.get(g).push(t);
148
+ }
149
+ if (groups.size === 1 && groups.has("")) {
150
+ for (const t of all) bullet(t);
151
+ } else {
152
+ for (const g of [...groups.keys()].filter(Boolean).sort()) {
153
+ console.log(`${g}/`);
154
+ for (const t of groups.get(g)) bullet(t, " ");
155
+ }
156
+ if (groups.has("")) {
157
+ console.log("");
158
+ for (const t of groups.get("")) bullet(t);
159
+ }
131
160
  }
132
161
  console.log(`\nDocs: open-claudia tool show <name> · Dir: ${tools.TOOLS_DIR}`);
133
162
  break;
@@ -162,6 +191,7 @@ function run(args) {
162
191
  console.log(`Tool: ${t.name}`);
163
192
  if (t.description) console.log(`Description: ${t.description}`);
164
193
  if (t.pack) console.log(`Skill pack: ${t.pack} (open-claudia pack show ${t.pack})`);
194
+ if (t.tags && t.tags.length) console.log(`Tags: ${t.tags.join(", ")}`);
165
195
  console.log(`Risk: ${t.risk || "(undeclared — treated as write)"}`);
166
196
  console.log(`Usage: open-claudia tool run ${t.name}${t.usage ? " (" + t.usage + ")" : ""}`);
167
197
  if (t.requires.length) {
@@ -228,6 +258,7 @@ function run(args) {
228
258
  description: typeof flags.desc === "string" ? flags.desc : (typeof flags.description === "string" ? flags.description : undefined),
229
259
  risk: typeof flags.risk === "string" ? flags.risk : undefined,
230
260
  requires: typeof flags.requires === "string" ? flags.requires.split(/[,\s]+/).filter(Boolean) : undefined,
261
+ tags: typeof flags.tags === "string" ? flags.tags.split(/[,\s]+/).filter(Boolean) : undefined,
231
262
  usage: typeof flags.usage === "string" ? flags.usage : undefined,
232
263
  });
233
264
  console.log(`Saved tool "${t.name}"${t.pack ? ` (skill: ${t.pack})` : ""} — risk: ${t.risk}.`);
@@ -276,9 +307,10 @@ function run(args) {
276
307
  if (typeof flags.pack === "string") patch.pack = flags.pack;
277
308
  if (typeof flags.risk === "string") patch.risk = flags.risk;
278
309
  if (typeof flags.requires === "string") patch.requires = flags.requires.split(/[,\s]+/).filter(Boolean);
310
+ if (typeof flags.tags === "string") patch.tags = flags.tags.split(/[,\s]+/).filter(Boolean);
279
311
  if (typeof flags.usage === "string") patch.usage = flags.usage;
280
312
  if (Object.keys(patch).length === 0) {
281
- console.error('Nothing to set. Pass at least one of --desc / --pack / --risk / --requires / --usage. Body edits: open-claudia tool edit ' + name);
313
+ console.error('Nothing to set. Pass at least one of --desc / --pack / --risk / --requires / --tags / --usage. Body edits: open-claudia tool edit ' + name);
282
314
  process.exitCode = 1; return;
283
315
  }
284
316
  let updated;
@@ -290,6 +322,7 @@ function run(args) {
290
322
  if (updated.pack) console.log(` Skill pack: ${updated.pack}`);
291
323
  console.log(` Risk: ${updated.risk || "(undeclared — treated as write)"}`);
292
324
  if (updated.requires.length) console.log(` Requires: ${updated.requires.join(", ")}`);
325
+ if (updated.tags && updated.tags.length) console.log(` Tags: ${updated.tags.join(", ")}`);
293
326
  if (updated.usage) console.log(` Usage: ${updated.usage}`);
294
327
  reportSafety(updated);
295
328
  break;
@@ -317,7 +350,9 @@ function run(args) {
317
350
 
318
351
  // Risk-tier gate: write needs --yes, destructive needs --yes-destructive.
319
352
  // The flag is passed through to the tool (which may have its own gate).
320
- const gate = tools.runGate(t, toolArgs);
353
+ // Nested runs (a tool calling this tool) gate on the tier the OUTERMOST
354
+ // invocation approved instead — argv flags can't fabricate approval.
355
+ const gate = tools.runGate(t, toolArgs, process.env);
321
356
  if (!gate.ok) { console.error(gate.message); process.exitCode = 3; return; }
322
357
 
323
358
  const missing = tools.missingRequires(t);
@@ -325,8 +360,17 @@ function run(args) {
325
360
  console.error(`Cannot run ${t.name}: missing keyring keys ${missing.join(", ")}. Set them with 'open-claudia keyring set <name> <value>'.`);
326
361
  process.exitCode = 1; return;
327
362
  }
363
+ // Composition inheritance: export the approved tier + call chain so any
364
+ // tool this tool shells into is gated by what the human actually approved
365
+ // at the top — an already-inherited tier passes through UNCHANGED.
366
+ const env = tools.runEnv(t);
367
+ const parentStack = String(process.env.OPEN_CLAUDIA_TOOL_STACK || "").trim();
368
+ env.OPEN_CLAUDIA_APPROVED_TIER = parentStack
369
+ ? (tools.normalizeRisk(process.env.OPEN_CLAUDIA_APPROVED_TIER) || "read-only")
370
+ : tools.approvedTierFromArgv(toolArgs);
371
+ env.OPEN_CLAUDIA_TOOL_STACK = parentStack ? `${parentStack}>${t.name}` : t.name;
328
372
  const started = Date.now();
329
- const r = spawnSync(t.file, toolArgs, { stdio: "inherit", env: tools.runEnv(t) });
373
+ const r = spawnSync(t.file, toolArgs, { stdio: "inherit", env });
330
374
  const ms = Date.now() - started;
331
375
  if (r.error) {
332
376
  tools.recordRunResult(t.name, { args: toolArgs, exit: -1, ms });
@@ -348,7 +392,7 @@ function run(args) {
348
392
  const { positional, flags } = parseFlags(rest);
349
393
  const name = positional[0];
350
394
  if (!name) {
351
- console.error('Usage: tool scaffold <name> [--pack dir] [--desc "..."] [--risk read-only|write|destructive] [--requires "k1,k2"] [--usage "..."] [--lang node|bash]');
395
+ console.error('Usage: tool scaffold <name> [--pack dir] [--desc "..."] [--risk read-only|write|destructive] [--requires "k1,k2"] [--tags "group"] [--usage "..."] [--lang node|bash]');
352
396
  process.exitCode = 1; return;
353
397
  }
354
398
  try {
@@ -357,6 +401,7 @@ function run(args) {
357
401
  description: typeof flags.desc === "string" ? flags.desc : (typeof flags.description === "string" ? flags.description : undefined),
358
402
  risk: typeof flags.risk === "string" ? flags.risk : undefined,
359
403
  requires: typeof flags.requires === "string" ? flags.requires.split(/[,\s]+/).filter(Boolean) : [],
404
+ tags: typeof flags.tags === "string" ? flags.tags.split(/[,\s]+/).filter(Boolean) : [],
360
405
  usage: typeof flags.usage === "string" ? flags.usage : undefined,
361
406
  lang: typeof flags.lang === "string" ? flags.lang : "node",
362
407
  });
@@ -410,6 +455,60 @@ function run(args) {
410
455
  break;
411
456
  }
412
457
 
458
+ case "lib": {
459
+ const sub = (rest[0] || "list").toLowerCase();
460
+ const libArgs = rest.slice(1);
461
+ if (sub === "list" || sub === "ls") {
462
+ const libs = tools.listLib();
463
+ if (!libs.length) {
464
+ console.log(`No shared _lib modules yet (${tools.LIB_DIR}).`);
465
+ console.log('Add one: open-claudia tool lib set <name.js> <src-path> — for plumbing 2+ tools share (central auth, mongo-exec).');
466
+ return;
467
+ }
468
+ console.log(`${libs.length} shared _lib module(s) — imported by tools via a literal "_lib/<name>" path:\n`);
469
+ for (const l of libs) {
470
+ const deps = tools.libDependents(l.name);
471
+ console.log(`• ${l.name} (${l.size} bytes, ${l.updatedAt.slice(0, 10)})${deps.length ? ` — used by: ${deps.join(", ")}` : " — no dependents yet"}`);
472
+ }
473
+ console.log(`\nnode: require(\`\${__dirname}/../_lib/<name>.js\`) · bash: source "$(dirname "$0")/../_lib/<name>.sh"`);
474
+ break;
475
+ }
476
+ if (sub === "show" || sub === "cat") {
477
+ const name = libArgs[0];
478
+ if (!name) { console.error("Usage: tool lib show <name>"); process.exitCode = 1; return; }
479
+ const content = tools.readLib(name);
480
+ if (content === null) { console.error(`No _lib module "${name}". Run: open-claudia tool lib list`); process.exitCode = 1; return; }
481
+ const deps = tools.libDependents(name);
482
+ console.log(`_lib/${name} — used by: ${deps.length ? deps.join(", ") : "(no dependents yet)"}\n`);
483
+ console.log(content);
484
+ break;
485
+ }
486
+ if (sub === "set" || sub === "add") {
487
+ const name = libArgs[0];
488
+ const srcPath = libArgs[1];
489
+ if (!name || !srcPath) { console.error("Usage: tool lib set <name.js> <src-path>"); process.exitCode = 1; return; }
490
+ let content;
491
+ try { content = require("fs").readFileSync(srcPath, "utf-8"); }
492
+ catch (e) { console.error(`Cannot read ${srcPath}: ${e.message}`); process.exitCode = 1; return; }
493
+ try {
494
+ const r = repo.setLib(name, content);
495
+ console.log(`${r.existed ? "Updated" : "Added"} _lib/${r.name}.`);
496
+ if (r.dependents.length) {
497
+ console.log(`Dependents journaled as unverified until their next real use: ${r.dependents.join(", ")}.`);
498
+ console.log("Re-verify each by simply using it — a green run at point of use is the health check.");
499
+ } else {
500
+ console.log("No dependents yet — tools import it via a literal \"_lib/" + r.name.replace(/\.[^.]+$/, "") + "\" path.");
501
+ }
502
+ } catch (e) {
503
+ console.error(`Could not set _lib module: ${e.message}`); process.exitCode = 1;
504
+ }
505
+ break;
506
+ }
507
+ console.error("Usage: tool lib [list | show <name> | set <name.js> <src-path>]");
508
+ process.exitCode = 1;
509
+ break;
510
+ }
511
+
413
512
  case "migrate": {
414
513
  const { migrated, skipped } = repo.migrateLegacyTools();
415
514
  if (!migrated.length && !skipped.length) console.log("Nothing to migrate — all tools already use the directory layout.");
@@ -432,7 +531,7 @@ function run(args) {
432
531
  break;
433
532
 
434
533
  default:
435
- console.log('Usage: open-claudia tool [list | search <query> | show <name> [--source] | scaffold <name> [flags] | add <path> [flags] | set <name> [flags] | note <name> [flags] | edit <name> | run <name> [args] | migrate | remove <name> | path]');
534
+ console.log('Usage: open-claudia tool [list | search <query> | show <name> [--source] | scaffold <name> [flags] | add <path> [flags] | set <name> [flags] | note <name> [flags] | edit <name> | run <name> [args] | lib [list|show|set] | migrate | remove <name> | path]');
436
535
  }
437
536
  }
438
537
 
package/core/dream.js CHANGED
@@ -1192,6 +1192,42 @@ async function runDream({ trigger = "manual" } = {}) {
1192
1192
  if (dupPairs.size) {
1193
1193
  bits.push(`👯 ${dupPairs.size} near-duplicate tool pair(s): ${[...dupPairs].join(", ")} — consider merging into one tool with subcommands.`);
1194
1194
  }
1195
+ // Doc/code drift: source edited after TOOL.md, or verbs in real use that
1196
+ // the docs never mention. Flag only — the fix is a tool note.
1197
+ const drifted = allTools
1198
+ .map((t) => { const d = toolsLib.docDrift(t); return d ? `${t.name} (${d})` : null; })
1199
+ .filter(Boolean);
1200
+ if (drifted.length) {
1201
+ bits.push(`📝 ${drifted.length} tool(s) with doc/code drift: ${drifted.join("; ")} — refresh the docs with tool note.`);
1202
+ }
1203
+ // 5c usage KPI: what fraction of operational actions went through the
1204
+ // authorised channel, computed from telemetry stamps (state.json run
1205
+ // history) + the guard log — never prose parsing. Denominator counts
1206
+ // COMPLETED actions: tool runs + logged escape-hatch bypasses + raw-op
1207
+ // sightings; gate denies are reported separately (blocked ≠ completed).
1208
+ try {
1209
+ const guardLib = require("./tool-guard");
1210
+ const sinceTs = dreamState.lastRun || new Date(Date.now() - 7 * 86400000).toISOString();
1211
+ const g = guardLib.readGuardEvents(sinceTs);
1212
+ const r = toolsLib.runsSince(sinceTs);
1213
+ const ops = r.runs + g.bypasses.length + g.rawOps.length;
1214
+ if (ops) {
1215
+ const pct = Math.round((100 * r.runs) / ops);
1216
+ const kpiBits = [`${pct}% of operational actions via tool run (${r.runs}/${ops}, target >90%)`];
1217
+ if (g.bypasses.length) {
1218
+ const reasons = g.bypasses.map((b) => b.reason).filter(Boolean).slice(0, 3).join("; ");
1219
+ kpiBits.push(`${g.bypasses.length} escape-hatch bypass(es)${reasons ? ` — ${reasons}` : ""}`);
1220
+ }
1221
+ if (g.rawOps.length) {
1222
+ const byPattern = {};
1223
+ for (const evt of g.rawOps) byPattern[evt.pattern || "?"] = (byPattern[evt.pattern || "?"] || 0) + 1;
1224
+ kpiBits.push(`${g.rawOps.length} raw op(s) outside tools: ${Object.entries(byPattern).map(([k, v]) => `${k} ×${v}`).join(", ")}`);
1225
+ }
1226
+ if (g.denies.length) kpiBits.push(`${g.denies.length} denied by the gate`);
1227
+ if (r.failures) kpiBits.push(`${r.failures} failed run(s) at use: ${r.failedTools.join(", ")}`);
1228
+ bits.push(`📈 Tool usage since last dream: ${kpiBits.join(" · ")}.`);
1229
+ }
1230
+ } catch (e) { /* KPI is best-effort */ }
1195
1231
  toolNote = bits.join("\n");
1196
1232
  } catch (e) { /* tool graph is best-effort (old node) */ }
1197
1233
 
package/core/runner.js CHANGED
@@ -958,6 +958,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
958
958
  const noteRecallFromShell = (command) => {
959
959
  try {
960
960
  const cmd = String(command || "");
961
+ // 5c raw-op audit: every Bash command in the stream passes here, so
962
+ // operational work done raw — heredoc scripts, DB clients, mutating
963
+ // kubectl — gets stamped to the guard log for the dream's KPI pass.
964
+ // Observation only; blocking is the deny-gate hook's job, not this.
965
+ try { require("./tool-guard").noteRawOp(cmd); } catch (e) { /* best-effort */ }
961
966
  if (!cmd.includes("open-claudia")) return;
962
967
  let m;
963
968
  const packRe = /\bpack\s+show\s+["']?([a-z0-9][\w.-]*)/gi;
@@ -98,7 +98,8 @@ Raw shell one-liners and heredocs are for probing and diagnosis only — never t
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
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
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.
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
+ - Composition: a tool may shell into other tools (report → kticket + kchat). Approval is inherited, never fabricated — a nested callee runs at most at the tier YOUR outer \`tool run\` flags acknowledged, so pass --yes/--yes-destructive on the outer call (after user approval) when the composition writes. Shared plumbing lives in \`_lib/\` (\`tool lib list|show|set\`); a lib change marks each dependent "unverified since _lib change" until its next real use.
102
+ - 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|lib|migrate|remove\`.
102
103
  ${listing}`;
103
104
  }
104
105
 
@@ -107,6 +107,76 @@ function logGuardEvent(evt) {
107
107
  } catch (e) { /* best effort */ }
108
108
  }
109
109
 
110
+ // ── 5c raw-op audit (observation, never blocking) ──────────────────────────
111
+ // The deny-gate above catches acting commands aimed at KNOWN prod surfaces;
112
+ // this detector catches the SHAPE of operational work done raw that should
113
+ // have been a tool — inline heredoc scripts fed to an interpreter, raw DB
114
+ // clients, mutating kubectl. Same high-precision-over-recall doctrine as the
115
+ // gate: reads and probes (kubectl get/logs, grep, ping) stay unflagged, so
116
+ // the dream's KPI measures real operational bypassing, not diagnosis noise.
117
+ const RAW_OP_PATTERNS = [
118
+ {
119
+ // An interpreter or DB shell fed an inline heredoc script — the exact
120
+ // "re-derive the script instead of reusing a tool" smell. cat/tee heredocs
121
+ // (file writes) deliberately don't match.
122
+ id: "heredoc-interpreter",
123
+ re: /(?:^|[|;&(`'"]|\$\()\s*(?:sudo\s+)?(?:node|python3?|ruby|perl|php|deno|bun|mongo|mongosh|psql|mysql|redis-cli)\b[^\n]*<</,
124
+ },
125
+ {
126
+ id: "db-client",
127
+ re: /(?:^|[|;&(`'"]|\$\()\s*(?:sudo\s+)?(?:mongo|mongosh|psql|mysql|redis-cli)\b/,
128
+ },
129
+ {
130
+ // Cluster mutations raw from the shell (GitOps or the prod-k8s tool are
131
+ // the channels). kubectl get/describe/logs/top/events/port-forward and
132
+ // rollout status/history are reads — always free per the hard rules.
133
+ id: "kubectl-mutate",
134
+ re: /\bkubectl\s+(?:exec|apply|delete|scale|patch|edit|create|replace|cordon|uncordon|drain|taint|label|annotate|set)\b|\bkubectl\s+rollout\s+(?:restart|undo|pause|resume)\b/,
135
+ },
136
+ ];
137
+
138
+ // Pure: null, or the matched raw-op pattern. The authorised channels and
139
+ // anything the deny-gate owns (already logged as a deny by the hook) are
140
+ // skipped so one command never produces two guard events.
141
+ function matchRawOp(command) {
142
+ const cmd = String(command || "");
143
+ if (!cmd.trim()) return null;
144
+ if (ALLOW_RE.test(cmd)) return null;
145
+ if (matchRule(cmd)) return null;
146
+ for (const p of RAW_OP_PATTERNS) if (p.re.test(cmd)) return p;
147
+ return null;
148
+ }
149
+
150
+ // Stamp a sighting to the guard log. Called from the runner's Bash-stream
151
+ // chokepoint on every command; must never throw into the stream parser.
152
+ function noteRawOp(command) {
153
+ try {
154
+ const p = matchRawOp(command);
155
+ if (!p) return null;
156
+ logGuardEvent({ kind: "raw-op", pattern: p.id, command: String(command).slice(0, 300) });
157
+ return p;
158
+ } catch (e) { return null; }
159
+ }
160
+
161
+ // KPI data source (5c): guard events since a timestamp, grouped by kind.
162
+ // Malformed lines are skipped — the log is append-only best-effort.
163
+ function readGuardEvents(sinceTs) {
164
+ const out = { denies: [], bypasses: [], rawOps: [] };
165
+ let raw = "";
166
+ try { raw = fs.readFileSync(GUARD_LOG, "utf-8"); } catch (e) { return out; }
167
+ const since = sinceTs ? Date.parse(sinceTs) : NaN;
168
+ for (const line of raw.split("\n")) {
169
+ if (!line.trim()) continue;
170
+ let evt;
171
+ try { evt = JSON.parse(line); } catch (e) { continue; }
172
+ if (!Number.isNaN(since) && (!evt.ts || Date.parse(evt.ts) < since)) continue;
173
+ if (evt.kind === "deny") out.denies.push(evt);
174
+ else if (evt.kind === "bypass") out.bypasses.push(evt);
175
+ else if (evt.kind === "raw-op") out.rawOps.push(evt);
176
+ }
177
+ return out;
178
+ }
179
+
110
180
  // Hook entry, pure enough to test: takes the PreToolUse stdin payload (object
111
181
  // or JSON string), returns { exit, stderr }. exit 2 = deny (stderr is fed back
112
182
  // to the model); exit 0 = allow. Anything unexpected → allow (fail-open).
@@ -160,4 +230,8 @@ function ensureHookSettings() {
160
230
  return SETTINGS_FILE;
161
231
  }
162
232
 
163
- module.exports = { RULES, GUARD_LOG, SETTINGS_FILE, matchRule, denyMessage, logGuardEvent, runHook, hookSettings, ensureHookSettings };
233
+ module.exports = {
234
+ RULES, RAW_OP_PATTERNS, GUARD_LOG, SETTINGS_FILE,
235
+ matchRule, denyMessage, logGuardEvent, runHook, hookSettings, ensureHookSettings,
236
+ matchRawOp, noteRawOp, readGuardEvents,
237
+ };
package/core/tool-repo.js CHANGED
@@ -64,6 +64,32 @@ function commitTool(name, message) {
64
64
  return git(["commit", "-q", "-m", message], { ident: true }).ok;
65
65
  }
66
66
 
67
+ // Write/update a shared _lib module, then journal every dependent tool as
68
+ // "unverified since _lib change" and commit the lot in ONE commit whose message
69
+ // names the dependents (PLAN §3.5: a _lib change can break several tools at
70
+ // once; each is re-verified by its next real use, not by smoke tests).
71
+ // Commits with target "." — sanitizeName would mangle "_lib", and the commit
72
+ // must carry the dependents' TOOL.md marks anyway.
73
+ function setLib(name, content) {
74
+ const tools = require("./tools");
75
+ ensureRepo();
76
+ const existed = tools.readLib(name) !== null;
77
+ const { name: libName, file } = tools.writeLib(name, content);
78
+ const dependents = tools.libDependents(libName);
79
+ for (const dep of dependents) {
80
+ const t = tools.findTool(dep);
81
+ if (!t || !t.docFile) continue;
82
+ let doc = tools.readToolDoc(dep) || toolMdSkeleton(t);
83
+ const line = `- [${today()}] unverified since _lib change (${libName}) — re-verified by next real use.`;
84
+ const re = /(##\s*Journal[^\n]*\n)/i;
85
+ doc = re.test(doc) ? doc.replace(re, `$1${line}\n`) : doc + `\n## Journal\n${line}\n`;
86
+ try { fs.writeFileSync(t.docFile, doc, { mode: 0o600 }); } catch (e) { /* journal is best-effort */ }
87
+ }
88
+ const depNote = dependents.length ? `dependents: ${dependents.join(", ")} (unverified until next use)` : "no dependents yet";
89
+ commitTool(null, `lib ${libName}: ${existed ? "update" : "add"} — ${depNote}`);
90
+ return { name: libName, file, dependents, existed };
91
+ }
92
+
67
93
  // Recent history for one tool — surfaced by `tool show` as the upgrade log.
68
94
  function toolLog(name, limit = 5) {
69
95
  const n = require("./tools").sanitizeName(name);
@@ -98,13 +124,14 @@ function toolMdSkeleton({ name, description, usage, risk, requires, pack, stateL
98
124
  ].join("\n");
99
125
  }
100
126
 
101
- function nodeStub({ name, description, pack, risk, requires, usage }) {
127
+ function nodeStub({ name, description, pack, risk, requires, usage, tags }) {
102
128
  const req = requires && requires.length ? `// requires: ${requires.join(", ")}\n` : "";
129
+ const tag = tags && tags.length ? `// tags: ${tags.join(", ")}\n` : "";
103
130
  return `#!/usr/bin/env node
104
131
  // open-claudia-tool: ${name}
105
132
  // description: ${description || ""}
106
133
  ${pack ? `// pack: ${pack}\n` : ""}// risk: ${risk}
107
- ${req}// usage: ${usage || `${name} <verb> [args]`}
134
+ ${req}${tag}// usage: ${usage || `${name} <verb> [args]`}
108
135
  //
109
136
  // One CLI per system, verbs as subcommands. Add a function per verb below.
110
137
  // Confirmation flags (--yes / --yes-destructive) are enforced by the tool
@@ -127,13 +154,14 @@ Promise.resolve(fn(args.slice(1))).catch((e) => { console.error(e.message || e);
127
154
  `;
128
155
  }
129
156
 
130
- function bashStub({ name, description, pack, risk, requires, usage }) {
157
+ function bashStub({ name, description, pack, risk, requires, usage, tags }) {
131
158
  const req = requires && requires.length ? `# requires: ${requires.join(", ")}\n` : "";
159
+ const tag = tags && tags.length ? `# tags: ${tags.join(", ")}\n` : "";
132
160
  return `#!/usr/bin/env bash
133
161
  # open-claudia-tool: ${name}
134
162
  # description: ${description || ""}
135
163
  ${pack ? `# pack: ${pack}\n` : ""}# risk: ${risk}
136
- ${req}# usage: ${usage || `${name} <verb> [args]`}
164
+ ${req}${tag}# usage: ${usage || `${name} <verb> [args]`}
137
165
  #
138
166
  # One CLI per system, verbs as subcommands: add a case per verb below.
139
167
  # Confirmation flags (--yes / --yes-destructive) are enforced by the tool
@@ -163,7 +191,8 @@ function scaffoldTool(name, opts = {}) {
163
191
  if (tools.findTool(n)) throw new Error(`tool "${n}" already exists — extend it (tool edit ${n}) instead of re-scaffolding`);
164
192
  const risk = tools.normalizeRisk(opts.risk) || "write";
165
193
  const requires = [].concat(opts.requires || []).filter(Boolean);
166
- const spec = { name: n, description: opts.description || "", pack: opts.pack || "", risk, requires, usage: opts.usage || "" };
194
+ const tags = [].concat(opts.tags || []).map((s) => tools.sanitizeName(s)).filter(Boolean);
195
+ const spec = { name: n, description: opts.description || "", pack: opts.pack || "", risk, requires, tags, usage: opts.usage || "" };
167
196
 
168
197
  ensureRepo();
169
198
  const dir = path.join(toolsDir(), n);
@@ -267,4 +296,4 @@ function hasLegacyTools() {
267
296
  return false;
268
297
  }
269
298
 
270
- module.exports = { ensureRepo, commitTool, toolLog, scaffoldTool, migrateLegacyTools, hasLegacyTools, toolMdSkeleton };
299
+ module.exports = { ensureRepo, commitTool, setLib, toolLog, scaffoldTool, migrateLegacyTools, hasLegacyTools, toolMdSkeleton };
package/core/tools.js CHANGED
@@ -36,7 +36,7 @@ const TOOLS_DIR = process.env.TOOLS_DIR ? path.resolve(process.env.TOOLS_DIR) :
36
36
  // parse from the leading comment block. Language-agnostic: we accept "#" (sh,
37
37
  // python, ruby) and "//" (js, ts, go) comment prefixes.
38
38
  const MARKER = "open-claudia-tool";
39
- const HEADER_KEYS = ["description", "pack", "requires", "usage", "risk"];
39
+ const HEADER_KEYS = ["description", "pack", "requires", "usage", "risk", "tags"];
40
40
  const RISK_TIERS = ["read-only", "write", "destructive"];
41
41
 
42
42
  // Normalise a risk value to one of RISK_TIERS ("" when unrecognised). Blast
@@ -219,7 +219,7 @@ function matchTools(text, { limit = 5, threshold = null } = {}) {
219
219
  const min = threshold ?? Number(process.env.TOOL_MATCH_THRESHOLD || 2);
220
220
  const out = [];
221
221
  for (const t of listTools()) {
222
- const strong = wordsOf([t.name, t.description, t.usage].filter(Boolean).join(" "));
222
+ const strong = wordsOf([t.name, t.description, t.usage, (t.tags || []).join(" ")].filter(Boolean).join(" "));
223
223
  const weak = wordsOf([(t.requires || []).join(" "), t.pack, t.content].filter(Boolean).join(" "));
224
224
  let score = 0;
225
225
  for (const term of terms) {
@@ -244,7 +244,7 @@ function uncomment(line) {
244
244
  // script is only a tool if the block contains an "open-claudia-tool: <name>"
245
245
  // line.
246
246
  function parseHeader(content) {
247
- const out = { name: "", description: "", pack: "", requires: [], usage: "", risk: "" };
247
+ const out = { name: "", description: "", pack: "", requires: [], usage: "", risk: "", tags: [] };
248
248
  const lines = String(content || "").split("\n");
249
249
  let started = false;
250
250
  for (let i = 0; i < lines.length; i++) {
@@ -264,6 +264,7 @@ function parseHeader(content) {
264
264
  if (key === MARKER) out.name = sanitizeName(val);
265
265
  else if (key === "requires") out.requires = val.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
266
266
  else if (key === "risk") out.risk = normalizeRisk(val);
267
+ else if (key === "tags") out.tags = val.split(/[,\s]+/).map((s) => sanitizeName(s)).filter(Boolean);
267
268
  else if (HEADER_KEYS.includes(key)) out[key] = val;
268
269
  }
269
270
  return out.name ? out : null;
@@ -293,6 +294,7 @@ function readTool(name, usageMap) {
293
294
  requires: header.requires || [],
294
295
  usage: header.usage || "",
295
296
  risk: header.risk || "",
297
+ tags: header.tags || [],
296
298
  layout: p.layout,
297
299
  file: p.exec,
298
300
  docFile: p.doc,
@@ -356,7 +358,7 @@ function listTools() {
356
358
  const usage = readUsage();
357
359
  const tools = [];
358
360
  for (const name of entries) {
359
- if (name.startsWith(".")) continue;
361
+ if (name.startsWith(".") || name.startsWith("_")) continue; // _lib etc. — plumbing, not tools
360
362
  const full = path.join(TOOLS_DIR, name);
361
363
  let st;
362
364
  try { st = fs.statSync(full); } catch (e) { continue; }
@@ -375,14 +377,113 @@ function findTool(name) {
375
377
  return readTool(needle);
376
378
  }
377
379
 
380
+ // Window telemetry for the 5c usage KPI: `tool run` executions since a
381
+ // timestamp, counted from state.json history entries — the CLI's authoritative
382
+ // per-run stamps, never prose parsing. History is capped per tool, so a tool
383
+ // that ran more than HISTORY_CAP times in the window undercounts slightly;
384
+ // acceptable for a nightly ratio.
385
+ function runsSince(sinceTs) {
386
+ const since = sinceTs ? Date.parse(sinceTs) : NaN;
387
+ const out = { runs: 0, failures: 0, byTool: {}, failedTools: [] };
388
+ for (const t of listTools()) {
389
+ const hist = Array.isArray(t.history) ? t.history : [];
390
+ const inWindow = hist.filter((h) => h && h.at && (Number.isNaN(since) || Date.parse(h.at) >= since));
391
+ if (!inWindow.length) continue;
392
+ const fails = inWindow.filter((h) => h.exit !== 0).length;
393
+ out.runs += inWindow.length;
394
+ out.failures += fails;
395
+ out.byTool[t.name] = { runs: inWindow.length, failures: fails };
396
+ if (fails) out.failedTools.push(`${t.name} ×${fails}`);
397
+ }
398
+ return out;
399
+ }
400
+
401
+ // Doc/code drift check for the dream's tool pass: the source was edited after
402
+ // TOOL.md was last touched (docs describe an older tool), or a verb telemetry
403
+ // proves is in real use never appears in the docs. Returns a short reason or
404
+ // null. The 60s slack absorbs creation-time write ordering (source + doc
405
+ // skeleton land moments apart).
406
+ function docDrift(t) {
407
+ if (!t || t.layout !== "dir" || !t.docFile) return null;
408
+ try {
409
+ if (fs.statSync(t.file).mtimeMs > fs.statSync(t.docFile).mtimeMs + 60000) return "source newer than TOOL.md";
410
+ } catch (e) { /* doc or source unreadable → fall through to verb check */ }
411
+ const doc = readToolDoc(t.name) || "";
412
+ const verbs = Object.keys(t.verbs || {}).filter((v) => v && v !== "(default)" && v !== "help" && !v.startsWith("-"));
413
+ const undocd = verbs.filter((v) => !doc.includes(v)).slice(0, 3);
414
+ if (undocd.length) return `verb${undocd.length === 1 ? "" : "s"} ${undocd.join(", ")} used but undocumented`;
415
+ return null;
416
+ }
417
+
418
+ // ── _lib shared plumbing ─────────────────────────────────────────────────────
419
+ // Genuinely shared code (central auth, mongo-exec) lives in tools/_lib — tiny
420
+ // modules IMPORTED by tools, never tools themselves (listTools skips the "_"
421
+ // prefix, so _lib never appears in the registry or surfacing index). Import
422
+ // with a literal "_lib/<name>" path so dependents stay grep-detectable:
423
+ // node: require(`${__dirname}/../_lib/<name>.js`)
424
+ // bash: source "$(dirname "$0")/../_lib/<name>.sh"
425
+ // A _lib change can break several tools at once, so every change is committed
426
+ // with its dependents named and each dependent journaled "unverified since
427
+ // _lib change" — re-verified by its next real use (no smoke tests, by design).
428
+ const LIB_DIR = path.join(TOOLS_DIR, "_lib");
429
+
430
+ function libPath(name) {
431
+ const base = path.basename(String(name || "").trim());
432
+ if (!base || base.startsWith(".")) throw new Error(`invalid lib name "${name}"`);
433
+ return path.join(LIB_DIR, base);
434
+ }
435
+
436
+ function listLib() {
437
+ let entries;
438
+ try { entries = fs.readdirSync(LIB_DIR); } catch (e) { return []; }
439
+ const out = [];
440
+ for (const name of entries) {
441
+ if (name.startsWith(".")) continue;
442
+ try {
443
+ const st = fs.statSync(path.join(LIB_DIR, name));
444
+ if (st.isFile()) out.push({ name, file: path.join(LIB_DIR, name), size: st.size, updatedAt: st.mtime.toISOString() });
445
+ } catch (e) { /* unreadable entry — skip */ }
446
+ }
447
+ out.sort((a, b) => a.name.localeCompare(b.name));
448
+ return out;
449
+ }
450
+
451
+ function readLib(name) {
452
+ try { return fs.readFileSync(libPath(name), "utf-8"); } catch (e) { return null; }
453
+ }
454
+
455
+ function writeLib(name, content) {
456
+ const file = libPath(name);
457
+ assertNoSecrets(content, `_lib/${path.basename(file)}`);
458
+ fs.mkdirSync(LIB_DIR, { recursive: true, mode: 0o700 });
459
+ fs.writeFileSync(file, content, { mode: 0o600 });
460
+ return { name: path.basename(file), file };
461
+ }
462
+
463
+ // Which registered tools import a given lib — a deterministic source grep for
464
+ // the "_lib/<stem>" reference shape (tolerates path.join'd segments), so a lib
465
+ // change can name exactly the tools it may have broken. Extension-agnostic but
466
+ // boundary-safe: "central-auth" matches _lib/central-auth.js, not
467
+ // _lib/central-auth-v2.js.
468
+ function libDependents(name) {
469
+ const base = path.basename(String(name || "").trim());
470
+ if (!base) return [];
471
+ const stem = base.replace(/\.[^.]+$/, "");
472
+ const esc = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
473
+ const re = new RegExp(`_lib[/\\\\"'\\s,]{1,5}["']?${esc}(?:\\.[A-Za-z0-9]+)?(?![A-Za-z0-9._-])`);
474
+ return listTools().filter((t) => re.test(t.content || "")).map((t) => t.name);
475
+ }
476
+
378
477
  // Build the header comment block for a script that doesn't already declare one.
379
478
  // Uses the comment prefix that matches the script's shebang/extension.
380
- function buildHeader(prefix, { name, description, pack, requires, usage, risk }) {
479
+ function buildHeader(prefix, { name, description, pack, requires, usage, risk, tags }) {
381
480
  const lines = [`${prefix} ${MARKER}: ${name}`];
382
481
  if (description) lines.push(`${prefix} description: ${description}`);
383
482
  if (pack) lines.push(`${prefix} pack: ${pack}`);
384
483
  if (risk) lines.push(`${prefix} risk: ${risk}`);
385
484
  if (requires && requires.length) lines.push(`${prefix} requires: ${[].concat(requires).join(", ")}`);
485
+ const cleanTags = [].concat(tags || []).map((s) => sanitizeName(s)).filter(Boolean);
486
+ if (cleanTags.length) lines.push(`${prefix} tags: ${cleanTags.join(", ")}`);
386
487
  if (usage) lines.push(`${prefix} usage: ${usage}`);
387
488
  return lines.join("\n");
388
489
  }
@@ -511,6 +612,7 @@ function addTool(srcPath, opts = {}) {
511
612
  pack: opts.pack || "",
512
613
  risk,
513
614
  requires: opts.requires || [],
615
+ tags: opts.tags || [],
514
616
  usage: opts.usage || "",
515
617
  });
516
618
  const hasShebang = content.startsWith("#!");
@@ -527,6 +629,7 @@ function addTool(srcPath, opts = {}) {
527
629
  content = content.replace(new RegExp(`((#|//)\\s*${MARKER}:.*\\n)`), `$1${line}\n`);
528
630
  };
529
631
  if (opts.pack && !header.pack) injectAfterMarker(`${prefix} pack: ${opts.pack}`);
632
+ if (opts.tags && opts.tags.length && !header.tags.length) injectAfterMarker(`${prefix} tags: ${[].concat(opts.tags).map((s) => sanitizeName(s)).filter(Boolean).join(", ")}`);
530
633
  if (!header.risk) {
531
634
  const risk = optRisk || "write";
532
635
  if (!optRisk) defaultedRisk = true;
@@ -597,6 +700,9 @@ function updateToolHeader(name, opts = {}) {
597
700
  }
598
701
  let requires = opts.requires !== undefined ? opts.requires : t.requires;
599
702
  if (!Array.isArray(requires)) requires = String(requires || "").split(/[,\s]+/).filter(Boolean);
703
+ let tags = opts.tags !== undefined ? opts.tags : t.tags;
704
+ if (!Array.isArray(tags)) tags = String(tags || "").split(/[,\s]+/).filter(Boolean);
705
+ tags = tags.map((s) => sanitizeName(s)).filter(Boolean);
600
706
 
601
707
  const lines = t.content.split("\n");
602
708
  let headStart = 0;
@@ -622,6 +728,7 @@ function updateToolHeader(name, opts = {}) {
622
728
  if (pack) fieldLines.push(`${prefix} pack: ${pack}`);
623
729
  if (risk) fieldLines.push(`${prefix} risk: ${risk}`);
624
730
  if (requires.length) fieldLines.push(`${prefix} requires: ${requires.join(", ")}`);
731
+ if (tags.length) fieldLines.push(`${prefix} tags: ${tags.join(", ")}`);
625
732
  if (usage) fieldLines.push(`${prefix} usage: ${usage}`);
626
733
 
627
734
  let newBlock;
@@ -642,9 +749,38 @@ function updateToolHeader(name, opts = {}) {
642
749
  // unlock it) and gets a spelled-out warning. An undeclared tier is treated as
643
750
  // write — fail-safe until classified. The flag is the agent's deliberate
644
751
  // confirmation step AFTER user approval; it never replaces asking the user.
645
- function runGate(tool, argv = []) {
752
+ //
753
+ // Composition (Phase 4): tools may shell into other tools. Approval flows ONLY
754
+ // from the outermost, human-approved invocation — the runner exports the tier
755
+ // its flags acknowledged (OPEN_CLAUDIA_APPROVED_TIER) plus the call chain
756
+ // (OPEN_CLAUDIA_TOOL_STACK) into the child env, and a nested run gates on that
757
+ // inherited tier while IGNORING its own argv flags, so a composing tool cannot
758
+ // fabricate approval by hardcoding --yes-destructive in its own source.
759
+ const TIER_RANK = { "read-only": 0, write: 1, destructive: 2 };
760
+
761
+ function approvedTierFromArgv(argv = []) {
762
+ if (argv.includes("--yes-destructive")) return "destructive";
763
+ if (argv.includes("--yes")) return "write";
764
+ return "read-only";
765
+ }
766
+
767
+ function runGate(tool, argv = [], env = process.env) {
646
768
  const declared = normalizeRisk(tool && tool.risk);
647
769
  const tier = declared || "write";
770
+ const stack = String((env && env.OPEN_CLAUDIA_TOOL_STACK) || "").trim();
771
+ if (stack) {
772
+ // Nested run — a tool called this tool.
773
+ if (stack.split(">").includes(tool.name)) {
774
+ return { ok: false, tier, nested: true, message: `⛔ Tool-call cycle: "${tool.name}" is already running in this chain (${stack}).` };
775
+ }
776
+ const approved = normalizeRisk(env.OPEN_CLAUDIA_APPROVED_TIER) || "read-only";
777
+ if (TIER_RANK[tier] <= TIER_RANK[approved]) return { ok: true, tier, nested: true };
778
+ return {
779
+ ok: false, tier, nested: true,
780
+ message: `⛔ "${tool.name}" is ${tier}-tier but was called by "${stack}", approved only for ${approved} actions.\n` +
781
+ `A composing tool cannot self-approve. Get the user's approval, then re-run the OUTER tool with ${tier === "destructive" ? "--yes-destructive" : "--yes"}.`,
782
+ };
783
+ }
648
784
  const note = declared ? "" : ` (no risk tier declared — treated as write; classify: open-claudia tool set ${tool.name} --risk read-only|write|destructive)`;
649
785
  if (tier === "read-only") return { ok: true, tier };
650
786
  if (tier === "destructive") {
@@ -711,10 +847,11 @@ function toolNameFromPath(filePath) {
711
847
  }
712
848
 
713
849
  module.exports = {
714
- TOOLS_DIR, MARKER, USAGE_FILE, RISK_TIERS,
715
- parseHeader, readTool, readToolDoc, toolCondition, listTools, findTool, addTool, removeTool, updateToolHeader,
850
+ TOOLS_DIR, MARKER, USAGE_FILE, RISK_TIERS, LIB_DIR,
851
+ parseHeader, readTool, readToolDoc, toolCondition, listTools, findTool, runsSince, docDrift, addTool, removeTool, updateToolHeader,
716
852
  runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir, normalizeRisk,
717
- toolPaths, readState, writeState, recordRunResult, runGate,
853
+ toolPaths, readState, writeState, recordRunResult, runGate, approvedTierFromArgv,
854
+ listLib, readLib, writeLib, libDependents,
718
855
  matchTools, recordRun, recordCreate, readUsage,
719
856
  lintToolSource, checkSyntax, envRefsIn, assertNoSecrets,
720
857
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.7.3",
3
+ "version": "2.7.5",
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": {
@@ -68,6 +68,43 @@ assert.ok(lines.some((l) => l.kind === "deny" && l.rule === "inet-central-api"),
68
68
  assert.ok(lines.some((l) => l.kind === "bypass" && l.reason === "no tool yet"), "keyring exec bypass is logged");
69
69
  assert.ok(lines.every((l) => l.ts), "every guard event is timestamped");
70
70
 
71
+ // ── raw-op audit (5c): operational work done raw is observed, never blocked ──
72
+ assert.strictEqual(guard.matchRawOp("node <<'EOF'\nfetch('https://api.example.com/x')\nEOF").id, "heredoc-interpreter",
73
+ "inline node heredoc is the canonical re-derived-script smell");
74
+ assert.strictEqual(guard.matchRawOp("python3 <<PY\nprint(1)\nPY").id, "heredoc-interpreter");
75
+ assert.strictEqual(guard.matchRawOp('echo start && mongosh <<JS\ndb.x.find()\nJS').id, "heredoc-interpreter",
76
+ "heredoc after && still detected");
77
+ assert.strictEqual(guard.matchRawOp('mongosh "mongodb://db.internal/app" --eval "db.users.count()"').id, "db-client");
78
+ assert.strictEqual(guard.matchRawOp("psql -h db.internal -c 'select 1'").id, "db-client");
79
+ assert.strictEqual(guard.matchRawOp("kubectl delete pod api-0 -n prod").id, "kubectl-mutate");
80
+ assert.strictEqual(guard.matchRawOp("kubectl apply -f deploy.yaml").id, "kubectl-mutate");
81
+ assert.strictEqual(guard.matchRawOp("kubectl rollout restart deploy/api").id, "kubectl-mutate");
82
+ assert.strictEqual(guard.matchRawOp("kubectl exec api-0 -- cat /etc/config").id, "kubectl-mutate",
83
+ "raw kubectl exec is an op vector even for reads inside the pod");
84
+ assert.strictEqual(guard.matchRawOp("cat <<'EOF' > notes.md\nhello\nEOF"), null, "heredoc into a file write is not an op");
85
+ assert.strictEqual(guard.matchRawOp("kubectl get pods -A"), null, "read-only kubectl stays unflagged");
86
+ assert.strictEqual(guard.matchRawOp("kubectl rollout status deploy/api"), null, "rollout status is a read");
87
+ assert.strictEqual(guard.matchRawOp("kubectl logs api-7f9 -n prod"), null, "kubectl logs is a read");
88
+ assert.strictEqual(guard.matchRawOp("node script.js"), null, "running a checked-in script is not the heredoc smell");
89
+ assert.strictEqual(guard.matchRawOp("grep -rn mongosh docs/"), null, "mentioning a client binary mid-line is not acting");
90
+ assert.strictEqual(guard.matchRawOp("open-claudia tool run prod-k8s exec api-0 'cat /etc/config'"), null,
91
+ "authorised channel never flagged");
92
+ assert.strictEqual(guard.matchRawOp('open-claudia keyring exec --reason "x" -- mongosh mongodb://db/x'), null,
93
+ "escape hatch logs its own bypass, not a raw-op");
94
+ assert.strictEqual(guard.matchRawOp("kubectl exec mongo-0 -- mongosh"), null,
95
+ "deny-rule matches belong to the gate — one command never yields two guard events");
96
+ assert.strictEqual(guard.noteRawOp("kubectl delete pod api-1 -n prod").id, "kubectl-mutate", "noteRawOp returns the pattern");
97
+ assert.strictEqual(guard.noteRawOp("ls -la"), null, "benign commands log nothing");
98
+
99
+ // ── readGuardEvents: since-filtered grouping (the KPI's data source) ──
100
+ const evts = guard.readGuardEvents("2000-01-01T00:00:00Z");
101
+ assert.ok(evts.denies.length >= 1, "denies grouped");
102
+ assert.ok(evts.bypasses.length >= 1, "bypasses grouped");
103
+ assert.ok(evts.rawOps.some((e) => e.pattern === "kubectl-mutate"), "raw-ops grouped with their pattern");
104
+ assert.strictEqual(guard.readGuardEvents(new Date(Date.now() + 86400000).toISOString()).rawOps.length, 0,
105
+ "future since → empty window");
106
+ assert.ok(guard.readGuardEvents(null).denies.length >= 1, "null since → whole log");
107
+
71
108
  // ── hook settings: a valid claude --settings payload, idempotent writer ──
72
109
  const sPath = guard.ensureHookSettings();
73
110
  assert.strictEqual(sPath, process.env.DENY_GATE_SETTINGS, "settings path is env-overridable (test isolation)");
package/test-tools.js CHANGED
@@ -334,6 +334,110 @@ tools.writeState("condtool", { createdAt: "2026-07-01T00:00:00Z", lastUsed: "202
334
334
  assert.ok(/last run OK 2026-07-03, 4 runs/.test(tools.toolCondition(tools.findTool("condtool"))), "healthy last run reads OK");
335
335
  assert.strictEqual(tools.toolCondition(null), "", "no tool → empty digest");
336
336
 
337
+ // ── runsSince: windowed run counts from state.json history (5c KPI source) ──
338
+ tools.writeState("condtool", {
339
+ createdAt: "2026-07-01T00:00:00Z", lastUsed: "2099-01-02T00:00:00Z", runCount: 7, lastExit: 1,
340
+ history: [
341
+ { at: "2099-01-02T00:00:00Z", args: "list --json", exit: 1, ms: 40 },
342
+ { at: "2099-01-01T00:00:00Z", args: "list", exit: 0, ms: 35 },
343
+ { at: "2000-01-01T00:00:00Z", args: "list", exit: 0, ms: 30 },
344
+ ],
345
+ });
346
+ const win = tools.runsSince("2098-12-31T00:00:00Z");
347
+ assert.strictEqual(win.byTool.condtool.runs, 2, "only history entries inside the window count");
348
+ assert.strictEqual(win.byTool.condtool.failures, 1, "non-zero exits count as failures at use");
349
+ assert.ok(win.failedTools.includes("condtool ×1"), "failing tools are named for the dream report");
350
+ assert.strictEqual(tools.runsSince("2099-06-01T00:00:00Z").runs, 0, "empty window → zero runs");
351
+
352
+ // ── docDrift: docs stale vs source/telemetry (dream tool-pass flag) ──
353
+ assert.strictEqual(tools.docDrift(tools.findTool("condtool")), null, "fresh docs → no drift");
354
+ tools.writeState("condtool", {
355
+ createdAt: "2026-07-01T00:00:00Z", lastUsed: "2099-01-02T00:00:00Z", runCount: 7, lastExit: 0,
356
+ verbs: { list: { runs: 3 }, stages: { runs: 1 }, help: { runs: 1 }, "(default)": { runs: 1 } },
357
+ });
358
+ assert.strictEqual(tools.docDrift(tools.findTool("condtool")), "verb stages used but undocumented",
359
+ "a verb in real use that TOOL.md never mentions is drift (help/(default) ignored)");
360
+ const future = new Date(Date.now() + 3600000);
361
+ fs.utimesSync(tools.findTool("condtool").file, future, future);
362
+ assert.strictEqual(tools.docDrift(tools.findTool("condtool")), "source newer than TOOL.md", "source edited after docs is drift");
363
+ assert.strictEqual(tools.docDrift(null), null, "no tool → no drift");
364
+
365
+ // ── tags (Phase 4 umbrella grouping): round-trip through the header, settable,
366
+ // sanitised, and part of the STRONG match haystack ──
367
+ const tagSrc = path.join(tmp, "kgadget.sh");
368
+ fs.writeFileSync(tagSrc, "#!/usr/bin/env bash\necho g\n");
369
+ const tagged = tools.addTool(tagSrc, { name: "kgadget", description: "gadget ops", risk: "read-only", tags: ["Kazee"] });
370
+ assert.deepStrictEqual(tagged.tags, ["kazee"], "tags parse back from the header, sanitised to lowercase");
371
+ assert.ok(/# tags: kazee/.test(tagged.content), "tags land as a header line");
372
+ assert.ok(tools.matchTools("kazee").some((m) => m.name === "kgadget"), "a tag term matches strongly");
373
+ const retagged = tools.updateToolHeader("kgadget", { tags: "kazee, infra" });
374
+ assert.deepStrictEqual(retagged.tags, ["kazee", "infra"], "updateToolHeader sets tags from a comma string");
375
+ assert.strictEqual((retagged.content.match(/# tags:/g) || []).length, 1, "tags line survives exactly once");
376
+ const scTagged = repo.scaffoldTool("ktagged", { description: "tagged scaffold", risk: "read-only", tags: ["infra"] });
377
+ assert.deepStrictEqual(scTagged.tags, ["infra"], "scaffold carries tags into the stub header");
378
+
379
+ // ── _lib shared plumbing (Phase 4): invisible to the registry, secret-guarded,
380
+ // dependents found by a boundary-safe source grep ──
381
+ tools.writeLib("central-auth.js", "module.exports = { login() { return process.env.inet_central_user; } };\n");
382
+ assert.ok(tools.readLib("central-auth.js").includes("login()"), "writeLib/readLib round-trip");
383
+ assert.ok(tools.listLib().some((l) => l.name === "central-auth.js"), "listLib sees the module");
384
+ assert.ok(!tools.listTools().some((t) => t.name.startsWith("_") || t.name === "lib"), "_lib never appears in the tool registry");
385
+ assert.strictEqual(tools.readLib("nope.js"), null, "missing lib reads as null");
386
+ assert.throws(() => tools.writeLib("leak.js", 'const k = "sk-ant-abcdefgh12345678";\n'), /secret/i, "a hardcoded secret blocks a lib save");
387
+ const trav = tools.writeLib("../escape.js", "// harmless\n");
388
+ assert.strictEqual(path.dirname(trav.file), tools.LIB_DIR, "path traversal is neutralised — basename only, stays inside _lib");
389
+ assert.throws(() => tools.writeLib(".hidden", "x"), /invalid lib name/i, "dot-prefixed lib names are refused");
390
+
391
+ const depA = path.join(tmp, "authuser.sh");
392
+ fs.writeFileSync(depA, '#!/usr/bin/env bash\nsource "$(dirname "$0")/../_lib/central-auth.js"\n');
393
+ tools.addTool(depA, { name: "authuser", description: "uses central auth", risk: "read-only" });
394
+ const depB = path.join(tmp, "authjoin.js");
395
+ fs.writeFileSync(depB, '#!/usr/bin/env node\nconst lib = require(require("path").join(__dirname, "..", "_lib", "central-auth.js"));\n');
396
+ tools.addTool(depB, { name: "authjoin", description: "joins via central auth", risk: "read-only" });
397
+ const depC = path.join(tmp, "othertool.sh");
398
+ fs.writeFileSync(depC, '#!/usr/bin/env bash\nsource "$(dirname "$0")/../_lib/central-auth-v2.js"\n');
399
+ tools.addTool(depC, { name: "othertool", description: "uses the v2 lib", risk: "read-only" });
400
+ const deps = tools.libDependents("central-auth.js");
401
+ assert.ok(deps.includes("authuser"), "a literal _lib/<name> reference is a dependent");
402
+ assert.ok(deps.includes("authjoin"), "a path.join'd _lib reference is a dependent");
403
+ assert.ok(!deps.includes("othertool"), "central-auth-v2 is NOT a dependent of central-auth (boundary-safe)");
404
+ assert.deepStrictEqual(tools.libDependents("unused-lib.js"), [], "a lib nothing imports has no dependents");
405
+
406
+ // ── setLib: one commit, every dependent journaled "unverified since _lib change" ──
407
+ const libRes = repo.setLib("central-auth.js", "module.exports = { login() { return { user: process.env.inet_central_user }; } };\n");
408
+ assert.strictEqual(libRes.name, "central-auth.js", "setLib resolves the lib name");
409
+ assert.ok(libRes.existed, "setLib reports an update over an existing lib");
410
+ assert.deepStrictEqual(libRes.dependents.sort(), ["authjoin", "authuser"], "setLib names exactly the dependents");
411
+ assert.ok(/unverified since _lib change \(central-auth\.js\)/.test(tools.readToolDoc("authuser")), "dependent journaled as unverified");
412
+ assert.ok(/unverified since _lib change/.test(tools.readToolDoc("authjoin")), "second dependent journaled too");
413
+ assert.ok(!/unverified since _lib change/.test(tools.readToolDoc("othertool")), "a non-dependent is left alone");
414
+
415
+ // ── nested tool-calls-tool gate (Phase 4): approval is inherited from the
416
+ // outermost human-approved invocation; a composing tool cannot fabricate it ──
417
+ assert.strictEqual(tools.approvedTierFromArgv([]), "read-only", "no flags → read-only approval");
418
+ assert.strictEqual(tools.approvedTierFromArgv(["--yes"]), "write", "--yes → write approval");
419
+ assert.strictEqual(tools.approvedTierFromArgv(["--yes", "--yes-destructive"]), "destructive", "--yes-destructive wins");
420
+ const nestedEnv = (approved, stack) => ({ OPEN_CLAUDIA_APPROVED_TIER: approved, OPEN_CLAUDIA_TOOL_STACK: stack });
421
+ assert.ok(tools.runGate({ name: "kticket", risk: "write" }, [], nestedEnv("write", "report")).ok,
422
+ "a write callee runs under an inherited write approval — no argv flag needed");
423
+ assert.ok(tools.runGate({ name: "kticket", risk: "read-only" }, [], nestedEnv("read-only", "report")).ok,
424
+ "a read-only callee always runs nested");
425
+ const fabricated = tools.runGate({ name: "wipe", risk: "destructive" }, ["--yes-destructive"], nestedEnv("write", "report"));
426
+ assert.ok(!fabricated.ok, "a composing tool cannot self-approve destructive with its own hardcoded flag");
427
+ assert.ok(/cannot self-approve/i.test(fabricated.message), "the refusal explains approval must come from the outer run");
428
+ assert.ok(!tools.runGate({ name: "kticket", risk: "write" }, ["--yes"], nestedEnv("read-only", "report")).ok,
429
+ "an outer read-only approval does not unlock a nested write, argv flags ignored");
430
+ assert.ok(tools.runGate({ name: "wipe", risk: "destructive" }, [], nestedEnv("destructive", "report")).ok,
431
+ "an outer --yes-destructive approval flows through to a destructive callee");
432
+ assert.ok(tools.runGate({ name: "wipe", risk: "" }, [], nestedEnv("write", "report")).ok,
433
+ "an undeclared callee tier is treated as write — runs under an inherited write approval");
434
+ assert.ok(!tools.runGate({ name: "wipe", risk: "" }, [], nestedEnv("read-only", "report")).ok,
435
+ "an undeclared callee tier is refused under a read-only approval (fail-safe when nested too)");
436
+ const cycle = tools.runGate({ name: "report", risk: "read-only" }, [], nestedEnv("read-only", "weekly>report"));
437
+ assert.ok(!cycle.ok && /cycle/i.test(cycle.message), "a tool already in the call chain is refused (cycle guard)");
438
+ const topLevel = tools.runGate({ name: "kticket", risk: "write" }, ["--yes"], {});
439
+ assert.ok(topLevel.ok && !topLevel.nested, "no stack env → the classic argv-flag gate applies");
440
+
337
441
  // ── remove ──
338
442
  assert.ok(tools.removeTool("hello"), "removeTool returns the removed tool");
339
443
  assert.strictEqual(tools.findTool("hello"), null, "removed tool is gone");