@inetafrica/open-claudia 2.7.6 → 2.8.0

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.8.0
4
+ - **Per-verb manifests — the gate now knows each verb's blast radius (Phase 5).** A tool may declare its verbs in the header (`verb: <name> [args] | <tier> | <doc>` — one line per verb); `tool run` then resolves the risk tier **per invoked verb** instead of whole-tool: `spaces unread` runs freely while `spaces upload` demands `--yes`, and `prod-k8s cert-status` is read-only while `cert-renew` stays write-gated. An **undeclared verb on a manifest tool is refused outright** regardless of flags (declare it before using it), `help`/no-verb is always read-only, and manifest-less tools keep the old whole-tool gate unchanged (fully backward compatible — older runners ignore `verb:` lines). `tool scaffold --verbs "list:read-only:doc,create:write:doc"` generates the manifest lines, a dispatcher entry per verb, and a TOOL.md verbs block; `tool show` lists the declared manifest. Migrated all 8 live verb-CLIs (clickhouse, inet-central-cli, inet-ops, kazee-chat, kticket, metabase, prod-k8s, spaces — 41 declared verbs) in the tools repo.
5
+ - **Chat approval with the EXACT payload — destructive runs escalate to inline Approve/Deny.** `--yes-destructive` acknowledged the agent's *paraphrase* of a destructive action; now an unflagged destructive top-level `tool run` inside a bot task POSTs the literal resolved command line to the requesting channel via a new loopback `approval-request` kind — the user sees `open-claudia tool run <name> <args>` verbatim with ✅ Approve / ⛔ Deny buttons (owner-only, idempotent: a second press can't overturn the first). The CLI polls the file-backed record (`core/approvals.js`) and **fails closed**: timeout (5 min) or deny = refusal at exit 3; only an explicit approve proceeds, stamped `approvedVia: "chat-approval"` with `OPEN_CLAUDIA_APPROVED_TIER=destructive` exported for composition. Every run's history entry now records the acknowledged tier — auditable forever.
6
+ - **Source fingerprint — you can't unknowingly run changed code.** Every clean exit stamps a sha256 of the executable into `state.json` (`verifiedHash`); the runner compares before each run and warns `⚠ unverified since change` when the source differs from the last green run (never blocks — the next successful run re-verifies). Surfaced in the per-turn tool condition line too.
7
+ - **Tooling-mode switch (`/toolmode`, `open-claudia tooling-mode`).** Strict (default, fail-safe) = tool-first prompt + deny-gate blocks raw prod-surface commands. Relaxed = the older model: scripting permitted, deny-gate logs (`relaxed-pass` events keep the audit/KPI honest) without blocking, prompt block swaps to "tools preferred, not forced". Owner-only chat command with inline buttons; risk-tier gate and credential confinement apply in BOTH modes. Instant effect (mode file re-read per hook call), corrupt/missing file fails strict.
8
+ - Test coverage: `test-tool-manifest.js` (manifest parse/scaffold round-trip, per-verb gate matrix incl. undeclared refusal + nested composition, fingerprint lifecycle, approvals idempotence + fail-closed timeout) and `test-tooling-mode.js` (strict denies / relaxed logs-and-allows / fail-safe strict).
9
+
3
10
  ## v2.7.6
4
11
  - **/tooltrace now shows WHAT ran, not just THAT it ran.** The 🔧 line per tool run was `Ran tool: kticket` — no verb, no args, and one line per tool even when different verbs ran in the same turn. It now renders the **sanitized command shape** (quoted strings → `<text>`, ids/uuids → `<id>`, paths → `<path>` — same shaping the tool graph already computes, so this is display-only and free) plus a **human-readable doc sentence for that verb**: new `tools.verbDoc(name, verb)` parses the sentence mechanically from the tool's own leading comment block (the same docs `--help` prints — zero model cost, can't drift from the source). Both documented header shapes are recognised (indented description under the verb line, or same-line prose after 2+ spaces), with fallback to the tool description's first clause. Example: `🔧 kticket show <id> — One ticket in full`. Dedup is now per tool+verb per turn, so `kticket list` then `kticket show` both announce. Unit coverage in `test-tools.js` (both shapes, sentence cut, fallback, flag-token and unknown-tool cases).
5
12
 
package/bin/cli.js CHANGED
@@ -151,6 +151,24 @@ switch (command) {
151
151
  break;
152
152
  }
153
153
 
154
+ // Tooling-mode switch (strict = forced CLI / relaxed = scripting allowed).
155
+ // Mirrors /toolmode in chat; global file, effective on the next command.
156
+ case "tooling-mode": {
157
+ const tm = require(path.join(botDir, "core", "tooling-mode"));
158
+ const want = (args[0] || "").trim().toLowerCase();
159
+ if (!want) {
160
+ console.log(`Tooling mode: ${tm.getToolingMode()}`);
161
+ } else {
162
+ try {
163
+ console.log(`Tooling mode: ${tm.setToolingMode(want)}`);
164
+ } catch (e) {
165
+ console.error(e.message);
166
+ process.exit(1);
167
+ }
168
+ }
169
+ break;
170
+ }
171
+
154
172
  case "start": {
155
173
  const skipHealthCheck = args.includes("--skip-health") || args.includes("--force");
156
174
  const botFile = getBotFile();
@@ -302,7 +320,8 @@ switch (command) {
302
320
  }
303
321
 
304
322
  case "tool": {
305
- require("./tool").run(args.slice(1));
323
+ Promise.resolve(require("./tool").run(args.slice(1)))
324
+ .catch((e) => { console.error(`tool command failed: ${e.message}`); process.exitCode = 1; });
306
325
  break;
307
326
  }
308
327
 
package/bin/tool.js CHANGED
@@ -8,6 +8,7 @@
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
10
  // [--requires "k1,k2"] [--tags "group"] [--usage "..."] [--lang node|bash]
11
+ // [--verbs "list:read-only:doc,create:write:doc"] — per-verb manifest; undeclared verbs are refused
11
12
  // open-claudia tool add <path> [--name n] — register an existing script as a tool
12
13
  // [--pack <dir>] [--desc "..."] [--risk tier] [--requires "k1,k2"] [--tags "group"] [--usage "..."]
13
14
  // open-claudia tool set <name> [--desc ...] — fix a tool's metadata in place
@@ -109,7 +110,7 @@ function reportSafety(t) {
109
110
  }
110
111
  }
111
112
 
112
- function run(args) {
113
+ async function run(args) {
113
114
  const cmd = (args[0] || "list").toLowerCase();
114
115
  const rest = args.slice(1);
115
116
 
@@ -193,6 +194,12 @@ function run(args) {
193
194
  if (t.pack) console.log(`Skill pack: ${t.pack} (open-claudia pack show ${t.pack})`);
194
195
  if (t.tags && t.tags.length) console.log(`Tags: ${t.tags.join(", ")}`);
195
196
  console.log(`Risk: ${t.risk || "(undeclared — treated as write)"}`);
197
+ if (t.manifest && t.manifest.length) {
198
+ console.log("Declared verbs (undeclared verbs are refused; each gated by its own tier):");
199
+ for (const v of t.manifest) {
200
+ console.log(` • ${v.name}${v.args ? " " + v.args : ""} — ${v.tier || t.risk || "write"}${v.doc ? ` — ${v.doc}` : ""}`);
201
+ }
202
+ }
196
203
  console.log(`Usage: open-claudia tool run ${t.name}${t.usage ? " (" + t.usage + ")" : ""}`);
197
204
  if (t.requires.length) {
198
205
  const missing = tools.missingRequires(t);
@@ -348,18 +355,55 @@ function run(args) {
348
355
  if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
349
356
  const toolArgs = rest.slice(1);
350
357
 
351
- // Risk-tier gate: write needs --yes, destructive needs --yes-destructive.
352
- // The flag is passed through to the tool (which may have its own gate).
353
- // Nested runs (a tool calling this tool) gate on the tier the OUTERMOST
354
- // invocation approved instead argv flags can't fabricate approval.
358
+ // Risk-tier gate, resolved per verb when the tool declares a manifest:
359
+ // write needs --yes, destructive needs --yes-destructive. Undeclared
360
+ // verbs on a manifest tool are refused outright. Nested runs (a tool
361
+ // calling this tool) gate on the tier the OUTERMOST invocation approved
362
+ // instead — argv flags can't fabricate approval.
355
363
  const gate = tools.runGate(t, toolArgs, process.env);
356
- if (!gate.ok) { console.error(gate.message); process.exitCode = 3; return; }
364
+ let approvedVia = "";
365
+ if (!gate.ok) {
366
+ // Chat-approval fallback: an unflagged destructive top-level run inside
367
+ // a bot task escalates to the requesting channel with the EXACT command
368
+ // and inline Approve/Deny. Anything but an explicit approve = refusal.
369
+ const canAsk = gate.tier === "destructive" && !gate.nested && !gate.undeclared
370
+ && process.env.OC_SEND_URL && process.env.OC_SEND_TOKEN && process.env.OC_CHANNEL_ID && process.env.OC_CHANNEL_ADAPTER;
371
+ if (!canAsk) { console.error(gate.message); process.exitCode = 3; return; }
372
+ const commandLine = ["open-claudia tool run", t.name, ...toolArgs].join(" ");
373
+ console.error(`Destructive run without --yes-destructive — asking for chat approval of the exact command...`);
374
+ let decision = "denied";
375
+ try {
376
+ const { postJson } = require("./loopback-client");
377
+ const res = await postJson("approval-request", {
378
+ tool: t.name, verb: tools.invokedVerb(toolArgs), tier: gate.tier, command: commandLine,
379
+ });
380
+ const approvals = require("../core/approvals");
381
+ decision = await approvals.waitForDecision(res.id);
382
+ } catch (e) {
383
+ console.error(`Chat approval unavailable (${e.message}).`);
384
+ }
385
+ if (decision !== "approved") {
386
+ console.error(`⛔ Not approved (${decision}) — refusing to run ${t.name}. ` +
387
+ `Re-run with --yes-destructive only after the user explicitly approves.`);
388
+ process.exitCode = 3; return;
389
+ }
390
+ approvedVia = "chat-approval";
391
+ console.error(`✅ Approved in chat — running.`);
392
+ }
357
393
 
358
394
  const missing = tools.missingRequires(t);
359
395
  if (missing.length) {
360
396
  console.error(`Cannot run ${t.name}: missing keyring keys ${missing.join(", ")}. Set them with 'open-claudia keyring set <name> <value>'.`);
361
397
  process.exitCode = 1; return;
362
398
  }
399
+ // Fingerprint check: warn (never block) when the source changed since the
400
+ // last verified (exit-0) run — the next green run re-verifies it.
401
+ try {
402
+ const fp = tools.fingerprint(t.name);
403
+ if (fp.status === "changed") {
404
+ console.error(`⚠ ${t.name} is unverified since its last change (source hash differs from last green run). A successful run re-verifies it.`);
405
+ }
406
+ } catch (e) { /* advisory */ }
363
407
  // Composition inheritance: export the approved tier + call chain so any
364
408
  // tool this tool shells into is gated by what the human actually approved
365
409
  // at the top — an already-inherited tier passes through UNCHANGED.
@@ -367,18 +411,18 @@ function run(args) {
367
411
  const parentStack = String(process.env.OPEN_CLAUDIA_TOOL_STACK || "").trim();
368
412
  env.OPEN_CLAUDIA_APPROVED_TIER = parentStack
369
413
  ? (tools.normalizeRisk(process.env.OPEN_CLAUDIA_APPROVED_TIER) || "read-only")
370
- : tools.approvedTierFromArgv(toolArgs);
414
+ : (approvedVia === "chat-approval" ? "destructive" : tools.approvedTierFromArgv(toolArgs));
371
415
  env.OPEN_CLAUDIA_TOOL_STACK = parentStack ? `${parentStack}>${t.name}` : t.name;
372
416
  const started = Date.now();
373
417
  const r = spawnSync(t.file, toolArgs, { stdio: "inherit", env });
374
418
  const ms = Date.now() - started;
375
419
  if (r.error) {
376
- tools.recordRunResult(t.name, { args: toolArgs, exit: -1, ms });
420
+ tools.recordRunResult(t.name, { args: toolArgs, exit: -1, ms, tier: gate.tier, approvedVia });
377
421
  console.error(`Failed to run ${t.name}: ${r.error.message}`);
378
422
  process.exitCode = 1; return;
379
423
  }
380
424
  const exit = typeof r.status === "number" ? r.status : 1;
381
- tools.recordRunResult(t.name, { args: toolArgs, exit, ms });
425
+ tools.recordRunResult(t.name, { args: toolArgs, exit, ms, tier: gate.tier, approvedVia });
382
426
  if (exit !== 0) {
383
427
  console.error(`(exit ${exit} recorded in ${t.name} telemetry — if this is a real defect, journal it: ` +
384
428
  `open-claudia tool note ${t.name} --issue "<what broke>")`);
@@ -392,10 +436,18 @@ function run(args) {
392
436
  const { positional, flags } = parseFlags(rest);
393
437
  const name = positional[0];
394
438
  if (!name) {
395
- console.error('Usage: tool scaffold <name> [--pack dir] [--desc "..."] [--risk read-only|write|destructive] [--requires "k1,k2"] [--tags "group"] [--usage "..."] [--lang node|bash]');
439
+ console.error('Usage: tool scaffold <name> [--pack dir] [--desc "..."] [--risk read-only|write|destructive] [--requires "k1,k2"] [--tags "group"] [--usage "..."] [--lang node|bash] [--verbs "list:read-only:doc,create:write:doc"]');
396
440
  process.exitCode = 1; return;
397
441
  }
398
442
  try {
443
+ // --verbs "name[:tier[:doc]],..." → per-verb manifest lines + dispatcher
444
+ // entries in the generated stub. Tier defaults to the tool's risk.
445
+ const verbs = typeof flags.verbs === "string"
446
+ ? flags.verbs.split(",").map((item) => {
447
+ const [vName, vTier, ...vDoc] = item.split(":");
448
+ return { name: (vName || "").trim(), tier: (vTier || "").trim(), doc: vDoc.join(":").trim() };
449
+ }).filter((v) => v.name)
450
+ : [];
399
451
  const t = repo.scaffoldTool(name, {
400
452
  pack: typeof flags.pack === "string" ? flags.pack : undefined,
401
453
  description: typeof flags.desc === "string" ? flags.desc : (typeof flags.description === "string" ? flags.description : undefined),
@@ -404,10 +456,14 @@ function run(args) {
404
456
  tags: typeof flags.tags === "string" ? flags.tags.split(/[,\s]+/).filter(Boolean) : [],
405
457
  usage: typeof flags.usage === "string" ? flags.usage : undefined,
406
458
  lang: typeof flags.lang === "string" ? flags.lang : "node",
459
+ verbs,
407
460
  });
408
461
  console.log(`Scaffolded tool "${t.name}" (risk: ${t.risk}).`);
409
462
  console.log(` Executable: ${t.file}`);
410
463
  console.log(` Docs: ${t.docFile}`);
464
+ if (t.manifest && t.manifest.length) {
465
+ console.log(` Manifest: ${t.manifest.map((v) => `${v.name} (${v.tier || t.risk})`).join(", ")} — undeclared verbs are refused at run time`);
466
+ }
411
467
  console.log(`Fill in the verbs, then work through it: open-claudia tool run ${t.name} <verb>`);
412
468
  } catch (e) {
413
469
  console.error(`Could not scaffold: ${e.message}`); process.exitCode = 1;
package/core/actions.js CHANGED
@@ -290,6 +290,29 @@ async function handleAction(envelope) {
290
290
  await send(`Recall debug: ${state.settings.showRecall ? "on" : "off"}`);
291
291
  return;
292
292
  }
293
+ if (d.startsWith("tm:")) {
294
+ const tm = require("./tooling-mode");
295
+ let m;
296
+ try { m = tm.setToolingMode(d.slice(3)); } catch (e) { return; }
297
+ await send(`Tooling mode: ${m}${m === "relaxed" ? " ⚠️ deny-gate is log-only; ad-hoc scripting allowed" : " — tool-first enforced"}`);
298
+ return;
299
+ }
300
+ if (d.startsWith("apr:")) {
301
+ // Destructive tool-run approval buttons. Owner-only: the exact payload was
302
+ // shown in the prompt; decide() is idempotent so double-taps can't flip it.
303
+ if (!isChatOwner(envelope.channelId)) return send("Owner only — tool approvals are restricted.");
304
+ const parts = d.split(":");
305
+ const id = parts[1];
306
+ const choice = parts[2] === "ok" ? "approved" : "denied";
307
+ const approvals = require("./approvals");
308
+ const rec = approvals.decide(id, choice, String(envelope.channelId));
309
+ if (!rec) return send("That approval request has expired or was cleaned up — the run stays blocked.");
310
+ // If a second press disagreed, rec.status still holds the FIRST decision.
311
+ await send(rec.status === "approved"
312
+ ? `✅ Approved — ${rec.tool}${rec.verb ? " " + rec.verb : ""} will run now.`
313
+ : `⛔ Denied — ${rec.tool}${rec.verb ? " " + rec.verb : ""} will not run.`);
314
+ return;
315
+ }
293
316
  if (d.startsWith("tt:")) {
294
317
  state.settings.showToolTrace = d.slice(3) === "on";
295
318
  saveState();
@@ -0,0 +1,85 @@
1
+ // Chat approval for destructive tool runs: the runner shows the EXACT resolved
2
+ // payload on the requesting channel with inline Approve/Deny buttons, and the
3
+ // run proceeds only on an explicit Approve. This closes the gap where a
4
+ // --yes-destructive flag acknowledges a summary — here the user approves the
5
+ // literal command that will execute, not the agent's paraphrase of it.
6
+ //
7
+ // Mechanics: file-backed records under CONFIG_DIR/approvals. The CLI (a bot
8
+ // subprocess) POSTs approval-request to the bot's loopback server, which sends
9
+ // the button message and creates the pending record; the CLI then polls the
10
+ // record file until the actions.js "apr:" callback flips its status, or the
11
+ // timeout lapses (timeout = denied — fail-closed, unlike the guard's
12
+ // fail-open, because this path only exists for destructive actions).
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const crypto = require("crypto");
17
+ const CONFIG_DIR = require("../config-dir");
18
+
19
+ const APPROVALS_DIR = process.env.APPROVALS_DIR || path.join(CONFIG_DIR, "approvals");
20
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000;
21
+
22
+ function fileOf(id) {
23
+ const base = path.basename(String(id || "").trim());
24
+ if (!base) throw new Error("approval id required");
25
+ return path.join(APPROVALS_DIR, `${base}.json`);
26
+ }
27
+
28
+ // Best-effort GC so decided/expired records don't accumulate forever.
29
+ function prune() {
30
+ let entries;
31
+ try { entries = fs.readdirSync(APPROVALS_DIR); } catch (e) { return; }
32
+ const cutoff = Date.now() - MAX_AGE_MS;
33
+ for (const f of entries) {
34
+ if (!f.endsWith(".json")) continue;
35
+ const p = path.join(APPROVALS_DIR, f);
36
+ try { if (fs.statSync(p).mtimeMs < cutoff) fs.rmSync(p, { force: true }); } catch (e) {}
37
+ }
38
+ }
39
+
40
+ function create({ tool, verb = "", tier = "destructive", command = "", channelId = "", adapter = "" }) {
41
+ fs.mkdirSync(APPROVALS_DIR, { recursive: true, mode: 0o700 });
42
+ prune();
43
+ const id = `apr_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
44
+ const rec = {
45
+ id, status: "pending",
46
+ tool: String(tool || ""), verb: String(verb || ""), tier: String(tier || ""),
47
+ command: String(command || "").slice(0, 1000),
48
+ channelId: String(channelId || ""), adapter: String(adapter || ""),
49
+ requestedAt: new Date().toISOString(),
50
+ };
51
+ fs.writeFileSync(fileOf(id), JSON.stringify(rec, null, 2), { mode: 0o600 });
52
+ return rec;
53
+ }
54
+
55
+ function read(id) {
56
+ try { return JSON.parse(fs.readFileSync(fileOf(id), "utf-8")); }
57
+ catch (e) { return null; }
58
+ }
59
+
60
+ // Flip a pending record. Idempotence guard: a second button press on the same
61
+ // message must not overturn the first decision.
62
+ function decide(id, status, by = "") {
63
+ const rec = read(id);
64
+ if (!rec) return null;
65
+ if (rec.status !== "pending") return rec;
66
+ rec.status = status === "approved" ? "approved" : "denied";
67
+ rec.decidedAt = new Date().toISOString();
68
+ if (by) rec.decidedBy = String(by);
69
+ fs.writeFileSync(fileOf(id), JSON.stringify(rec, null, 2), { mode: 0o600 });
70
+ return rec;
71
+ }
72
+
73
+ // Poll until decided or timeout. Timeout resolves as "timeout" — the caller
74
+ // must treat anything other than "approved" as a refusal.
75
+ async function waitForDecision(id, { timeoutMs = 300000, pollMs = 1500 } = {}) {
76
+ const deadline = Date.now() + timeoutMs;
77
+ for (;;) {
78
+ const rec = read(id);
79
+ if (rec && rec.status !== "pending") return rec.status;
80
+ if (Date.now() >= deadline) return "timeout";
81
+ await new Promise((r) => setTimeout(r, pollMs));
82
+ }
83
+ }
84
+
85
+ module.exports = { APPROVALS_DIR, create, read, decide, waitForDecision };
package/core/handlers.js CHANGED
@@ -123,7 +123,7 @@ register({
123
123
  if (!authorized(env)) return;
124
124
  send([
125
125
  "Session: /session /sessions /projects /continue /status /stop /end",
126
- "Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall /tooltrace",
126
+ "Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall /tooltrace /toolmode",
127
127
  "Identity: /whoami /link",
128
128
  "Team: /people /intros /auth (owner)",
129
129
  "Automation: /cron /vault /soul /dreamsummary",
@@ -743,6 +743,29 @@ register({
743
743
  },
744
744
  });
745
745
 
746
+ register({
747
+ name: "toolmode", description: "Tooling enforcement mode (strict = forced CLI, relaxed = scripting allowed)", args: "[strict|relaxed]", ownerOnly: true,
748
+ handler: async (env, { tail }) => {
749
+ if (!authorized(env)) return;
750
+ const tm = require("./tooling-mode");
751
+ if (tail) {
752
+ let m;
753
+ try { m = tm.setToolingMode(tail); } catch (e) { return send(`Usage: /toolmode [strict|relaxed]. Currently ${tm.getToolingMode()}.`); }
754
+ return send(`Tooling mode: ${m}${m === "relaxed" ? " ⚠️ deny-gate is log-only; ad-hoc scripting allowed" : " — tool-first enforced"}`);
755
+ }
756
+ const cur = tm.getToolingMode();
757
+ send(
758
+ `Tooling mode: ${cur}\n\n` +
759
+ "strict — operational work is forced through reusable CLIs: the deny-gate blocks raw prod commands and the agent must search/extend/scaffold tools first.\n" +
760
+ "relaxed — the older model: tools preferred but ad-hoc scripting allowed; the deny-gate logs instead of blocking.\n\n" +
761
+ "Risk-tier approvals (--yes / --yes-destructive) apply in both modes. Takes effect on the next command, no restart.",
762
+ { keyboard: { inline_keyboard: [
763
+ [{ text: "Strict", callback_data: "tm:strict" }, { text: "Relaxed", callback_data: "tm:relaxed" }],
764
+ ] } },
765
+ );
766
+ },
767
+ });
768
+
746
769
  register({
747
770
  name: "tooltrace", description: "Show tool activity each turn (surfaced/ran/updated)", args: "[on|off]",
748
771
  handler: async (env, { tail }) => {
package/core/loopback.js CHANGED
@@ -79,6 +79,7 @@ const JSON_KINDS = new Set([
79
79
  "intros-list", "intros-approve", "intros-reject",
80
80
  "relay-send", "recent-fetch", "audit-tail",
81
81
  "auth-list", "auth-revoke",
82
+ "approval-request",
82
83
  ]);
83
84
 
84
85
  function callerIsOwner(payload) {
@@ -459,6 +460,36 @@ async function handleJson(req, res, url, kind) {
459
460
  return reply(res, 200, { ok: true, entries: audit.tail(n) });
460
461
  }
461
462
 
463
+ // Chat approval for a destructive tool run: post the EXACT resolved command
464
+ // to the requesting channel with Approve/Deny buttons and hand back the
465
+ // record id; the CLI polls the record until the "apr:" callback decides it.
466
+ if (kind === "approval-request") {
467
+ if (!payload.tool || !payload.command) return reply(res, 400, { error: "missing tool/command" });
468
+ try {
469
+ const approvals = require("./approvals");
470
+ const rec = approvals.create({
471
+ tool: payload.tool, verb: payload.verb || "", tier: payload.tier || "destructive",
472
+ command: payload.command, channelId, adapter: adapterId,
473
+ });
474
+ const text = [
475
+ `🔐 Destructive tool run wants approval:`,
476
+ ``,
477
+ `<pre>${String(payload.command).slice(0, 800).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</pre>`,
478
+ ``,
479
+ `Tool: ${payload.tool}${payload.verb ? ` · verb: ${payload.verb}` : ""} · tier: ${rec.tier}`,
480
+ `This is the literal command that will execute. Times out (denied) in 5 minutes.`,
481
+ ].join("\n");
482
+ const keyboard = { inline_keyboard: [[
483
+ { text: "✅ Approve", callback_data: `apr:${rec.id}:ok` },
484
+ { text: "⛔ Deny", callback_data: `apr:${rec.id}:no` },
485
+ ]] };
486
+ const ok = await adapter.send(channelId, text, { keyboard });
487
+ if (!ok) { approvals.decide(rec.id, "denied", "send-failed"); return reply(res, 500, { error: "could not deliver approval prompt" }); }
488
+ audit.log("tool.approval.requested", { id: rec.id, tool: rec.tool, verb: rec.verb, channelId });
489
+ return reply(res, 200, { ok: true, id: rec.id });
490
+ } catch (e) { return reply(res, 400, { error: e.message }); }
491
+ }
492
+
462
493
  return reply(res, 404, { error: "not found" });
463
494
  }
464
495
 
@@ -84,17 +84,33 @@ function buildToolIndexBlock() {
84
84
  } catch (e) {
85
85
  return "";
86
86
  }
87
+ // Relaxed tooling mode (/toolmode): the older model — tools are available
88
+ // and preferred, but ad-hoc scripting is allowed and the deny-gate only
89
+ // observes. The risk gate on tool runs applies in BOTH modes.
90
+ try {
91
+ if (require("./tooling-mode").getToolingMode() === "relaxed") {
92
+ return `\n## Tools are available (relaxed mode)
93
+ Tool-first enforcement is currently OFF (\`/toolmode\` switches it). Reusable tools exist and are usually the fastest path — check the "Tools that may help here" block, or \`open-claudia tool search <query>\` / \`tool list\`, and prefer a covering tool when one fits. But ad-hoc scripts and raw shell commands are permitted for operational work in this mode; the deny-gate logs raw prod-surface commands for audit without blocking them.
94
+
95
+ Still in force regardless of mode:
96
+ - Risk gate on tool runs: read-only runs freely; write-tier needs \`--yes\`; destructive needs \`--yes-destructive\` — the flag is your acknowledgment AFTER the user approves, never a substitute for asking first.
97
+ - Credential confinement: keyring creds are injected only into \`tool run\` subprocesses for declared \`--requires\` keys; never write a secret into a tool file or script.
98
+ - Keep TOOL.md truthful when you do change a tool, and announce scaffolds/changes in one line.
99
+ ${listing}`;
100
+ }
101
+ } catch (e) { /* fail-strict */ }
87
102
  return `\n## Tools are the interface (tool-first)
88
103
  Operational work — hitting an API, mutating a live system, driving an interface, any command you would re-derive next time — happens THROUGH a reusable tool, not around one. BEFORE doing the work, walk this loop in order:
89
104
 
90
105
  1. **Search** — check the "Tools that may help here" block if present; otherwise \`open-claudia tool search <query>\` or \`tool list\`.
91
106
  2. **Use** — a tool covers it: read it first (\`open-claudia tool show <name>\` gives docs, State, known issues, recent changes — never guess its args; \`--source\` only when modifying), then \`open-claudia tool run <name> <verb> [args]\`.
92
107
  3. **Extend** — right tool, missing verb: add the verb to that tool (\`tool edit <name>\`, then \`tool note <name> --journal "added <verb>"\`) and run it. One CLI per system, verbs as subcommands — never fork a near-duplicate.
93
- 4. **Scaffold** — no tool fronts the system: \`open-claudia tool scaffold <name> --desc "..." --risk <read-only|write|destructive> [--pack <dir>] [--requires "key1,key2"]\` FIRST, implement the verb you need, then do the work through it.
108
+ 4. **Scaffold** — no tool fronts the system: \`open-claudia tool scaffold <name> --desc "..." --risk <read-only|write|destructive> [--pack <dir>] [--requires "key1,key2"] [--verbs "list:read-only:doc,create:write:doc"]\` FIRST, implement the verb you need, then do the work through it.
94
109
 
95
110
  Raw shell one-liners and heredocs are for probing and diagnosis only — never the operational action itself. If a tool is broken, fix it or journal the defect (\`tool note <name> --issue "..."\`); do not route around it with a script.
96
111
 
97
112
  - 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.
113
+ - Per-verb manifest: a tool that declares \`verb:\` header lines is gated verb-by-verb — each verb runs at its own declared tier, and an undeclared verb is refused outright (declare it in the header before using it). An unflagged destructive run inside a bot task escalates to chat with the EXACT command and Approve/Deny buttons; anything but an explicit approve is a refusal.
98
114
  - 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
115
  - 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
116
  - 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.
@@ -161,7 +161,7 @@ function noteRawOp(command) {
161
161
  // KPI data source (5c): guard events since a timestamp, grouped by kind.
162
162
  // Malformed lines are skipped — the log is append-only best-effort.
163
163
  function readGuardEvents(sinceTs) {
164
- const out = { denies: [], bypasses: [], rawOps: [] };
164
+ const out = { denies: [], bypasses: [], rawOps: [], relaxedPasses: [] };
165
165
  let raw = "";
166
166
  try { raw = fs.readFileSync(GUARD_LOG, "utf-8"); } catch (e) { return out; }
167
167
  const since = sinceTs ? Date.parse(sinceTs) : NaN;
@@ -173,6 +173,7 @@ function readGuardEvents(sinceTs) {
173
173
  if (evt.kind === "deny") out.denies.push(evt);
174
174
  else if (evt.kind === "bypass") out.bypasses.push(evt);
175
175
  else if (evt.kind === "raw-op") out.rawOps.push(evt);
176
+ else if (evt.kind === "relaxed-pass") out.relaxedPasses.push(evt);
176
177
  }
177
178
  return out;
178
179
  }
@@ -192,6 +193,15 @@ function runHook(payload) {
192
193
  const command = data.tool_input && data.tool_input.command;
193
194
  const rule = matchRule(command);
194
195
  if (!rule) return { exit: 0, stderr: "" };
196
+ // Relaxed tooling mode: the gate observes but never blocks. The event is
197
+ // still logged (as relaxed-pass, not deny) so the dream KPI can report
198
+ // what WOULD have been denied — visibility survives the mode switch.
199
+ let relaxed = false;
200
+ try { relaxed = require("./tooling-mode").getToolingMode() === "relaxed"; } catch (e) { /* fail-strict */ }
201
+ if (relaxed) {
202
+ logGuardEvent({ kind: "relaxed-pass", rule: rule.id, command: String(command).slice(0, 300) });
203
+ return { exit: 0, stderr: "" };
204
+ }
195
205
  logGuardEvent({ kind: "deny", rule: rule.id, command: String(command).slice(0, 300) });
196
206
  return { exit: 2, stderr: denyMessage(rule) };
197
207
  } catch (e) {
package/core/tool-repo.js CHANGED
@@ -102,7 +102,11 @@ function toolLog(name, limit = 5) {
102
102
 
103
103
  function today() { return new Date().toISOString().slice(0, 10); }
104
104
 
105
- function toolMdSkeleton({ name, description, usage, risk, requires, pack, stateLine, journalLine }) {
105
+ function toolMdSkeleton({ name, description, usage, risk, requires, pack, verbs, stateLine, journalLine }) {
106
+ const verbLines = (verbs && verbs.length)
107
+ ? ["", "Verbs (from the manifest in the tool header — the runner refuses undeclared verbs):",
108
+ ...verbs.map((v) => `- \`${v.name}${v.args ? " " + v.args : ""}\` — ${v.tier || risk || "write"}${v.doc ? ` — ${v.doc}` : ""}`)]
109
+ : [];
106
110
  return [
107
111
  `# ${name}`,
108
112
  "",
@@ -111,6 +115,7 @@ function toolMdSkeleton({ name, description, usage, risk, requires, pack, stateL
111
115
  "",
112
116
  `Usage: \`open-claudia tool run ${name}${usage ? " " + usage.replace(new RegExp("^" + name + "\\s*"), "") : " <verb> [args]"}\``,
113
117
  `Risk: ${risk || "write"}${requires && requires.length ? ` · Requires: ${requires.join(", ")}` : ""}${pack ? ` · Pack: ${pack}` : ""}`,
118
+ ...verbLines,
114
119
  "",
115
120
  "## Known issues",
116
121
  "(none yet)",
@@ -124,16 +129,30 @@ function toolMdSkeleton({ name, description, usage, risk, requires, pack, stateL
124
129
  ].join("\n");
125
130
  }
126
131
 
127
- function nodeStub({ name, description, pack, risk, requires, usage, tags }) {
132
+ // Manifest header lines: one `verb:` line per declared verb, parsed by
133
+ // tools.parseHeader into the per-verb gate. Format: name [args] | tier | doc.
134
+ function verbHeaderLines(verbs, comment) {
135
+ return (verbs || []).map((v) =>
136
+ `${comment} verb: ${v.name}${v.args ? " " + v.args : ""} | ${v.tier || "write"}${v.doc ? ` | ${v.doc}` : ""}\n`).join("");
137
+ }
138
+
139
+ function nodeStub({ name, description, pack, risk, requires, usage, tags, verbs }) {
128
140
  const req = requires && requires.length ? `// requires: ${requires.join(", ")}\n` : "";
129
141
  const tag = tags && tags.length ? `// tags: ${tags.join(", ")}\n` : "";
142
+ const manifest = verbHeaderLines(verbs, "//");
143
+ const handlers = (verbs || []).map((v) =>
144
+ ` ${/^[a-zA-Z_$][\w$]*$/.test(v.name) ? v.name : JSON.stringify(v.name)}(rest) {\n` +
145
+ ` console.error("TODO: implement ${v.name}${v.doc ? ` — ${v.doc.replace(/"/g, '\\"')}` : ""}");\n` +
146
+ ` process.exit(1);\n },`).join("\n");
130
147
  return `#!/usr/bin/env node
131
148
  // open-claudia-tool: ${name}
132
149
  // description: ${description || ""}
133
150
  ${pack ? `// pack: ${pack}\n` : ""}// risk: ${risk}
134
151
  ${req}${tag}// usage: ${usage || `${name} <verb> [args]`}
135
- //
152
+ ${manifest}//
136
153
  // One CLI per system, verbs as subcommands. Add a function per verb below.
154
+ // Every verb MUST be declared with a "verb:" header line above — the runner
155
+ // refuses undeclared verbs and gates each one by its declared tier.
137
156
  // Confirmation flags (--yes / --yes-destructive) are enforced by the tool
138
157
  // runner and may appear in argv — ignore them unless you need them.
139
158
 
@@ -145,8 +164,7 @@ const HANDLERS = {
145
164
  console.log("${name} — ${(description || "").replace(/"/g, '\\"')}");
146
165
  console.log("verbs: " + Object.keys(HANDLERS).join(", "));
147
166
  },
148
- // example(rest) { ... },
149
- };
167
+ ${handlers ? handlers + "\n" : " // example(rest) { ... },\n"}};
150
168
 
151
169
  const fn = HANDLERS[verb] || (verb === "--help" ? HANDLERS.help : null);
152
170
  if (!fn) { console.error("unknown verb: " + verb); HANDLERS.help(); process.exit(1); }
@@ -154,16 +172,22 @@ Promise.resolve(fn(args.slice(1))).catch((e) => { console.error(e.message || e);
154
172
  `;
155
173
  }
156
174
 
157
- function bashStub({ name, description, pack, risk, requires, usage, tags }) {
175
+ function bashStub({ name, description, pack, risk, requires, usage, tags, verbs }) {
158
176
  const req = requires && requires.length ? `# requires: ${requires.join(", ")}\n` : "";
159
177
  const tag = tags && tags.length ? `# tags: ${tags.join(", ")}\n` : "";
178
+ const manifest = verbHeaderLines(verbs, "#");
179
+ const verbNames = ["help", ...(verbs || []).map((v) => v.name)];
180
+ const cases = (verbs || []).map((v) =>
181
+ ` ${v.name})\n echo "TODO: implement ${v.name}" >&2; exit 1 ;;`).join("\n");
160
182
  return `#!/usr/bin/env bash
161
183
  # open-claudia-tool: ${name}
162
184
  # description: ${description || ""}
163
185
  ${pack ? `# pack: ${pack}\n` : ""}# risk: ${risk}
164
186
  ${req}${tag}# usage: ${usage || `${name} <verb> [args]`}
165
- #
187
+ ${manifest}#
166
188
  # One CLI per system, verbs as subcommands: add a case per verb below.
189
+ # Every verb MUST be declared with a "verb:" header line above — the runner
190
+ # refuses undeclared verbs and gates each one by its declared tier.
167
191
  # Confirmation flags (--yes / --yes-destructive) are enforced by the tool
168
192
  # runner and may appear in "$@" — ignore them unless you need them.
169
193
  set -euo pipefail
@@ -173,8 +197,8 @@ verb="\${1:-help}"; shift || true
173
197
  case "$verb" in
174
198
  help|--help)
175
199
  echo "${name} — ${description || ""}"
176
- echo "verbs: help" ;;
177
- *)
200
+ echo "verbs: ${verbNames.join(", ")}" ;;
201
+ ${cases ? cases + "\n" : ""} *)
178
202
  echo "unknown verb: $verb" >&2; exit 1 ;;
179
203
  esac
180
204
  `;
@@ -192,7 +216,10 @@ function scaffoldTool(name, opts = {}) {
192
216
  const risk = tools.normalizeRisk(opts.risk) || "write";
193
217
  const requires = [].concat(opts.requires || []).filter(Boolean);
194
218
  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 || "" };
219
+ const verbs = [].concat(opts.verbs || [])
220
+ .map((v) => ({ name: String(v.name || "").trim(), args: String(v.args || "").trim(), tier: tools.normalizeRisk(v.tier) || risk, doc: String(v.doc || "").trim() }))
221
+ .filter((v) => v.name && v.name !== "help");
222
+ const spec = { name: n, description: opts.description || "", pack: opts.pack || "", risk, requires, tags, usage: opts.usage || "", verbs };
196
223
 
197
224
  ensureRepo();
198
225
  const dir = path.join(toolsDir(), n);
@@ -0,0 +1,40 @@
1
+ // Tooling-mode switch: "strict" (tool-first enforced — deny-gate blocks raw
2
+ // prod commands, prompt teaches the mandatory search→use→extend→scaffold loop)
3
+ // vs "relaxed" (the older model — ad-hoc scripting allowed, deny-gate observes
4
+ // and logs but never blocks).
5
+ //
6
+ // Global rather than per-channel: the deny-gate hook runs as a bare
7
+ // subprocess with no channel context, so the mode lives in a file it re-reads
8
+ // on every invocation — switching takes effect on the next Bash call, no
9
+ // restart needed.
10
+ //
11
+ // What the mode NEVER changes: risk-tier approvals (--yes/--yes-destructive),
12
+ // credential confinement, and the guard AUDIT log. Those mechanise the
13
+ // ask-first hard rule and apply to scripts and tools alike in both modes.
14
+ //
15
+ // Fail-safe: any read error means "strict" — enforcement is the default.
16
+
17
+ const fs = require("fs");
18
+ const path = require("path");
19
+ const CONFIG_DIR = require("../config-dir");
20
+
21
+ const MODE_FILE = process.env.TOOLING_MODE_FILE || path.join(CONFIG_DIR, "tooling-mode.json");
22
+ const MODES = ["strict", "relaxed"];
23
+
24
+ function getToolingMode() {
25
+ try {
26
+ const data = JSON.parse(fs.readFileSync(MODE_FILE, "utf-8"));
27
+ return MODES.includes(data.mode) ? data.mode : "strict";
28
+ } catch (e) {
29
+ return "strict";
30
+ }
31
+ }
32
+
33
+ function setToolingMode(mode) {
34
+ const m = String(mode || "").trim().toLowerCase();
35
+ if (!MODES.includes(m)) throw new Error(`invalid tooling mode "${mode}" — use one of: ${MODES.join(", ")}`);
36
+ fs.writeFileSync(MODE_FILE, JSON.stringify({ mode: m, changedAt: new Date().toISOString() }, null, 2) + "\n");
37
+ return m;
38
+ }
39
+
40
+ module.exports = { MODE_FILE, MODES, getToolingMode, setToolingMode };
package/core/tools.js CHANGED
@@ -28,6 +28,7 @@
28
28
 
29
29
  const fs = require("fs");
30
30
  const path = require("path");
31
+ const crypto = require("crypto");
31
32
  const CONFIG_DIR = require("../config-dir");
32
33
 
33
34
  const TOOLS_DIR = process.env.TOOLS_DIR ? path.resolve(process.env.TOOLS_DIR) : path.join(CONFIG_DIR, "tools");
@@ -114,7 +115,7 @@ function writeState(name, s) {
114
115
  // non-flag arg) so TOOL.md can report "assign: last real run <date>" honestly,
115
116
  // plus a capped run history. A non-zero exit is the trigger for a known-issue
116
117
  // entry in TOOL.md (the CLI nudges; the foreground agent writes it).
117
- function recordRunResult(name, { args = [], exit = 0, ms = 0 } = {}) {
118
+ function recordRunResult(name, { args = [], exit = 0, ms = 0, tier = "", approvedVia = "" } = {}) {
118
119
  const n = sanitizeName(name);
119
120
  if (!n) return;
120
121
  if (toolPaths(n).layout !== "dir") { recordRun(n); return; } // legacy fallback
@@ -125,6 +126,13 @@ function recordRunResult(name, { args = [], exit = 0, ms = 0 } = {}) {
125
126
  s.runCount = (s.runCount || 0) + 1;
126
127
  s.lastExit = exit;
127
128
  if (exit === 0) s.lastSuccess = now; else s.lastFailure = now;
129
+ // A clean exit re-verifies the current source: the fingerprint proves "this
130
+ // exact code worked" — any later edit flips the tool to unverified until its
131
+ // next successful run.
132
+ if (exit === 0) {
133
+ const h = sourceHash(n);
134
+ if (h) { s.verifiedHash = h; s.verifiedAt = now; }
135
+ }
128
136
  const verb = (args.find((a) => a && !String(a).startsWith("-")) || "(default)").slice(0, 40);
129
137
  s.verbs = s.verbs || {};
130
138
  const v = s.verbs[verb] || { runs: 0 };
@@ -133,10 +141,32 @@ function recordRunResult(name, { args = [], exit = 0, ms = 0 } = {}) {
133
141
  if (exit === 0) v.lastSuccess = now; else v.lastFailure = now;
134
142
  s.verbs[verb] = v;
135
143
  const argLine = args.join(" ").slice(0, 120);
136
- s.history = [{ at: now, args: argLine, exit, ms }, ...(s.history || [])].slice(0, HISTORY_CAP);
144
+ const entry = { at: now, args: argLine, exit, ms };
145
+ if (tier) entry.tier = tier; // which tier was acknowledged for this run
146
+ if (approvedVia) entry.approvedVia = approvedVia; // flag vs chat-approval — auditable forever
147
+ s.history = [entry, ...(s.history || [])].slice(0, HISTORY_CAP);
137
148
  writeState(n, s);
138
149
  }
139
150
 
151
+ // ── Source fingerprint ───────────────────────────────────────────────────────
152
+ // sha256 of the executable. recordRunResult stamps it as verifiedHash on every
153
+ // clean exit; fingerprint() compares the stamp to the current source so the
154
+ // runner can warn "unverified since change" — the agent can never unknowingly
155
+ // run code that changed since it was last proven to work.
156
+ function sourceHash(name) {
157
+ try { return crypto.createHash("sha256").update(fs.readFileSync(toolPaths(name).exec)).digest("hex"); }
158
+ catch (e) { return ""; }
159
+ }
160
+
161
+ function fingerprint(name) {
162
+ const n = sanitizeName(name);
163
+ const s = readState(n);
164
+ const current = sourceHash(n);
165
+ if (!s.verifiedHash) return { status: "never-verified", current, verified: "", verifiedAt: "" };
166
+ if (s.verifiedHash === current) return { status: "verified", current, verified: s.verifiedHash, verifiedAt: s.verifiedAt || "" };
167
+ return { status: "changed", current, verified: s.verifiedHash, verifiedAt: s.verifiedAt || "" };
168
+ }
169
+
140
170
  // Bump a tool's run counter without outcome detail. Still called at end of turn
141
171
  // from the runner's transcript parse; for directory tools that's a no-op (the
142
172
  // CLI already stamped the authoritative result — double-counting would lie),
@@ -244,7 +274,7 @@ function uncomment(line) {
244
274
  // script is only a tool if the block contains an "open-claudia-tool: <name>"
245
275
  // line.
246
276
  function parseHeader(content) {
247
- const out = { name: "", description: "", pack: "", requires: [], usage: "", risk: "", tags: [] };
277
+ const out = { name: "", description: "", pack: "", requires: [], usage: "", risk: "", tags: [], manifest: [] };
248
278
  const lines = String(content || "").split("\n");
249
279
  let started = false;
250
280
  for (let i = 0; i < lines.length; i++) {
@@ -265,6 +295,22 @@ function parseHeader(content) {
265
295
  else if (key === "requires") out.requires = val.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
266
296
  else if (key === "risk") out.risk = normalizeRisk(val);
267
297
  else if (key === "tags") out.tags = val.split(/[,\s]+/).map((s) => sanitizeName(s)).filter(Boolean);
298
+ else if (key === "verb") {
299
+ // Per-verb manifest line: `verb: <name> [arg hint] | <tier> | <doc>`.
300
+ // The machine-readable contract the runner gates on — a tool WITH a
301
+ // manifest refuses undeclared verbs, and each verb carries its own tier.
302
+ const segs = val.split("|").map((s) => s.trim());
303
+ const head = segs[0] || "";
304
+ const nameTok = head.split(/\s+/)[0] || "";
305
+ if (nameTok) {
306
+ out.manifest.push({
307
+ name: nameTok,
308
+ args: head.slice(nameTok.length).trim(),
309
+ tier: normalizeRisk(segs[1]),
310
+ doc: segs.slice(2).join(" | "),
311
+ });
312
+ }
313
+ }
268
314
  else if (HEADER_KEYS.includes(key)) out[key] = val;
269
315
  }
270
316
  return out.name ? out : null;
@@ -297,6 +343,9 @@ function verbDoc(name, verb) {
297
343
  const fallback = firstClause(header.description);
298
344
  const v = String(verb || "").trim();
299
345
  if (!v || v.startsWith("-") || v.startsWith("<")) return fallback;
346
+ // Manifest doc wins when declared — it's the contract, not prose.
347
+ const mv = (header.manifest || []).find((x) => x.name === v);
348
+ if (mv && mv.doc) return firstClause(mv.doc);
300
349
  // Re-walk the leading comment block (same bounds as parseHeader).
301
350
  const lines = String(content).split("\n");
302
351
  const block = [];
@@ -359,6 +408,7 @@ function readTool(name, usageMap) {
359
408
  usage: header.usage || "",
360
409
  risk: header.risk || "",
361
410
  tags: header.tags || [],
411
+ manifest: header.manifest || [],
362
412
  layout: p.layout,
363
413
  file: p.exec,
364
414
  docFile: p.doc,
@@ -404,6 +454,9 @@ function toolCondition(t) {
404
454
  const issues = sectionOf(doc, "Known issues").split("\n").filter((l) => /^\s*-\s*\S/.test(l)).length;
405
455
  if (issues) parts.push(`⚠ ${issues} known issue${issues === 1 ? "" : "s"}`);
406
456
  }
457
+ if (t.layout === "dir" && t.runCount && fingerprint(t.name).status === "changed") {
458
+ parts.push("⚠ unverified since change");
459
+ }
407
460
  if (!t.runCount) parts.push("never run");
408
461
  else {
409
462
  const when = (t.lastUsed || "").slice(0, 10);
@@ -828,9 +881,40 @@ function approvedTierFromArgv(argv = []) {
828
881
  return "read-only";
829
882
  }
830
883
 
884
+ // Resolve the effective tier for an invocation from the tool's per-verb
885
+ // manifest (when it declares one). The manifest is the contract: a declared
886
+ // read-only verb runs ungated even in a write-tier tool (the lower tier is
887
+ // EARNED by the verb's own in-code limits), and an undeclared verb is refused
888
+ // outright — the dispatcher shouldn't know it either. Tools without a manifest
889
+ // keep the tool-level tier (fail-safe write until classified/migrated).
890
+ function invokedVerb(argv = []) {
891
+ const v = (argv || []).find((a) => a && !String(a).startsWith("-"));
892
+ return v ? String(v) : "";
893
+ }
894
+
895
+ function resolveTier(tool, argv = []) {
896
+ const declared = normalizeRisk(tool && tool.risk) || "write";
897
+ const manifest = (tool && tool.manifest) || [];
898
+ const verb = invokedVerb(argv);
899
+ if (!manifest.length) return { tier: declared, verb, manifest: false };
900
+ if (!verb || verb === "help") return { tier: "read-only", verb: verb || "help", manifest: true };
901
+ const m = manifest.find((x) => x.name === verb);
902
+ if (!m) return { tier: declared, verb, manifest: true, undeclared: true };
903
+ return { tier: m.tier || declared, verb, manifest: true };
904
+ }
905
+
831
906
  function runGate(tool, argv = [], env = process.env) {
832
- const declared = normalizeRisk(tool && tool.risk);
833
- const tier = declared || "write";
907
+ const resolved = resolveTier(tool, argv);
908
+ if (resolved.undeclared) {
909
+ const known = (tool.manifest || []).map((v) => v.name).join(", ");
910
+ return {
911
+ ok: false, tier: resolved.tier, verb: resolved.verb, undeclared: true,
912
+ message: `⛔ "${tool.name}" has no declared verb "${resolved.verb}" — its manifest allows: ${known || "(none)"}.\n` +
913
+ `If the verb genuinely exists, declare it in the tool header (\`${"verb"}: ${resolved.verb} | <tier> | <doc>\`) so the gate knows its tier.`,
914
+ };
915
+ }
916
+ const tier = resolved.tier;
917
+ const declared = resolved.manifest ? tier : normalizeRisk(tool && tool.risk);
834
918
  const stack = String((env && env.OPEN_CLAUDIA_TOOL_STACK) || "").trim();
835
919
  if (stack) {
836
920
  // Nested run — a tool called this tool.
@@ -915,6 +999,7 @@ module.exports = {
915
999
  parseHeader, readTool, readToolDoc, toolCondition, verbDoc, listTools, findTool, runsSince, docDrift, addTool, removeTool, updateToolHeader,
916
1000
  runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir, normalizeRisk,
917
1001
  toolPaths, readState, writeState, recordRunResult, runGate, approvedTierFromArgv,
1002
+ resolveTier, invokedVerb, sourceHash, fingerprint,
918
1003
  listLib, readLib, writeLib, libDependents,
919
1004
  matchTools, recordRun, recordCreate, readUsage,
920
1005
  lintToolSource, checkSyntax, envRefsIn, assertNoSecrets,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.7.6",
3
+ "version": "2.8.0",
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-tool-guard.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-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-manifest.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",
@@ -47,7 +47,9 @@
47
47
  "test-read-signal.js",
48
48
  "test-tools.js",
49
49
  "test-tool-graph.js",
50
- "test-tool-guard.js"
50
+ "test-tool-guard.js",
51
+ "test-tooling-mode.js",
52
+ "test-tool-manifest.js"
51
53
  ],
52
54
  "keywords": [
53
55
  "claude",
@@ -0,0 +1,110 @@
1
+ // Per-verb manifest + fingerprint + chat approvals: a tool that declares
2
+ // `verb:` header lines is gated verb-by-verb (undeclared verbs refused, each
3
+ // verb at its own tier, help/no-verb read-only), a green run stamps a source
4
+ // fingerprint that flags "changed" after an edit, and approval records are
5
+ // fail-closed (only an explicit approve unblocks; timeout = denied).
6
+ const assert = require("assert");
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+
11
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tool-manifest-test-"));
12
+ process.env.TOOLS_DIR = path.join(tmp, "tools");
13
+ process.env.KEYRING_FILE = path.join(tmp, "keyring.json");
14
+ process.env.APPROVALS_DIR = path.join(tmp, "approvals");
15
+
16
+ const tools = require("./core/tools");
17
+ const repo = require("./core/tool-repo");
18
+ const approvals = require("./core/approvals");
19
+
20
+ (async () => {
21
+ // ── scaffold with --verbs → manifest header lines, dispatcher, TOOL.md block ──
22
+ const t = repo.scaffoldTool("fleet", {
23
+ description: "manage the fleet",
24
+ risk: "write",
25
+ lang: "bash",
26
+ verbs: [
27
+ { name: "list", tier: "read-only", doc: "list nodes" },
28
+ { name: "restart", tier: "write", doc: "restart one node" },
29
+ { name: "purge", tier: "destructive", doc: "wipe a node" },
30
+ ],
31
+ });
32
+ assert.strictEqual(t.manifest.length, 3, "manifest parsed back from generated header");
33
+ assert.deepStrictEqual(t.manifest.map((v) => v.name), ["list", "restart", "purge"]);
34
+ assert.strictEqual(t.manifest[0].tier, "read-only");
35
+ assert.strictEqual(t.manifest[2].tier, "destructive");
36
+ assert.strictEqual(t.manifest[1].doc, "restart one node", "per-verb doc carried");
37
+ const doc = tools.readToolDoc("fleet");
38
+ assert.ok(/`purge` — destructive — wipe a node/.test(doc), "TOOL.md Interface lists the manifest");
39
+ assert.ok(/list\)/.test(t.content) && /purge\)/.test(t.content), "bash dispatcher has a case per verb");
40
+
41
+ // ── per-verb gate: tier comes from the invoked verb, not the tool tier ──
42
+ assert.strictEqual(tools.runGate(t, ["list"]).ok, true, "read-only verb runs unflagged");
43
+ assert.strictEqual(tools.runGate(t, ["list"]).tier, "read-only");
44
+ assert.strictEqual(tools.runGate(t, ["restart", "n1"]).ok, false, "write verb demands --yes");
45
+ assert.strictEqual(tools.runGate(t, ["restart", "n1", "--yes"]).ok, true);
46
+ assert.strictEqual(tools.runGate(t, ["purge", "n1", "--yes"]).ok, false, "--yes cannot approve a destructive verb");
47
+ const purgeGate = tools.runGate(t, ["purge", "n1"]);
48
+ assert.strictEqual(purgeGate.tier, "destructive", "failed gate reports the resolved tier for the approval fallback");
49
+ assert.strictEqual(tools.runGate(t, ["purge", "n1", "--yes-destructive"]).ok, true);
50
+
51
+ // ── undeclared verb on a manifest tool is refused regardless of flags ──
52
+ const un = tools.runGate(t, ["obliterate", "--yes-destructive"]);
53
+ assert.strictEqual(un.ok, false, "undeclared verb refused");
54
+ assert.strictEqual(un.undeclared, true);
55
+ assert.ok(/no declared verb "obliterate"/.test(un.message));
56
+
57
+ // ── help / no verb = read-only ──
58
+ assert.strictEqual(tools.runGate(t, []).ok, true, "no verb (help) runs unflagged");
59
+ assert.strictEqual(tools.runGate(t, ["help"]).tier, "read-only");
60
+
61
+ // ── manifest-less tools keep the old whole-tool gate (backward compatible) ──
62
+ const legacySrc = path.join(tmp, "old.sh");
63
+ fs.writeFileSync(legacySrc, "#!/usr/bin/env bash\necho old\n");
64
+ const legacy = tools.addTool(legacySrc, { name: "old", description: "legacy", risk: "write" });
65
+ assert.strictEqual(legacy.manifest.length, 0);
66
+ assert.strictEqual(tools.runGate(legacy, ["anything"]).ok, false, "manifest-less write tool still needs --yes");
67
+ assert.strictEqual(tools.runGate(legacy, ["anything", "--yes"]).ok, true, "…and any verb is allowed with it");
68
+
69
+ // ── nested composition still gates on the inherited tier, per verb ──
70
+ const env = { OPEN_CLAUDIA_TOOL_STACK: "outer", OPEN_CLAUDIA_APPROVED_TIER: "write" };
71
+ assert.strictEqual(tools.runGate(t, ["restart", "n1"], env).ok, true, "nested write verb ok under write approval");
72
+ assert.strictEqual(tools.runGate(t, ["purge", "n1", "--yes-destructive"], env).ok, false,
73
+ "nested destructive verb can't self-approve with argv flags");
74
+
75
+ // ── fingerprint lifecycle: never-verified → verified (green run) → changed (edit) ──
76
+ assert.strictEqual(tools.fingerprint("fleet").status, "never-verified");
77
+ tools.recordRunResult("fleet", { args: ["list"], exit: 0, ms: 5, tier: "read-only" });
78
+ assert.strictEqual(tools.fingerprint("fleet").status, "verified", "exit-0 run stamps the hash");
79
+ fs.appendFileSync(t.file, "# touched\n");
80
+ assert.strictEqual(tools.fingerprint("fleet").status, "changed", "source edit flags unverified-since-change");
81
+ tools.recordRunResult("fleet", { args: ["list"], exit: 1, ms: 5 });
82
+ assert.strictEqual(tools.fingerprint("fleet").status, "changed", "a failing run does NOT re-verify");
83
+ tools.recordRunResult("fleet", { args: ["list"], exit: 0, ms: 5 });
84
+ assert.strictEqual(tools.fingerprint("fleet").status, "verified", "next green run re-verifies");
85
+
86
+ // ── telemetry carries the acknowledged tier + approval channel ──
87
+ tools.recordRunResult("fleet", { args: ["purge", "n1"], exit: 0, ms: 9, tier: "destructive", approvedVia: "chat-approval" });
88
+ const st = JSON.parse(fs.readFileSync(path.join(process.env.TOOLS_DIR, "fleet", "state.json"), "utf-8"));
89
+ const last = st.history[0]; // history is newest-first
90
+ assert.strictEqual(last.tier, "destructive");
91
+ assert.strictEqual(last.approvedVia, "chat-approval");
92
+
93
+ // ── approvals: create → decide is idempotent, wait resolves, timeout = fail-closed ──
94
+ const rec = approvals.create({ tool: "fleet", verb: "purge", tier: "destructive", command: "open-claudia tool run fleet purge n1", channelId: "c1", adapter: "tg" });
95
+ assert.strictEqual(approvals.read(rec.id).status, "pending");
96
+ const waiter = approvals.waitForDecision(rec.id, { timeoutMs: 5000, pollMs: 50 });
97
+ approvals.decide(rec.id, "approved", "c1");
98
+ assert.strictEqual(await waiter, "approved");
99
+ const flipped = approvals.decide(rec.id, "denied", "c1");
100
+ assert.strictEqual(flipped.status, "approved", "second press cannot overturn the first decision");
101
+
102
+ const rec2 = approvals.create({ tool: "fleet", verb: "purge", tier: "destructive", command: "x", channelId: "c1", adapter: "tg" });
103
+ assert.strictEqual(await approvals.waitForDecision(rec2.id, { timeoutMs: 200, pollMs: 50 }), "timeout",
104
+ "no decision = timeout, which callers treat as denied");
105
+ const rec3 = approvals.create({ tool: "fleet", verb: "purge", tier: "destructive", command: "x", channelId: "c1", adapter: "tg" });
106
+ approvals.decide(rec3.id, "denied", "c1");
107
+ assert.strictEqual(await approvals.waitForDecision(rec3.id, { timeoutMs: 5000, pollMs: 50 }), "denied");
108
+
109
+ console.log("tool manifest OK");
110
+ })().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,44 @@
1
+ // Tooling-mode switch: strict denies, relaxed logs-and-allows, fail-safe strict.
2
+ // The prod-URL fixture is assembled from parts so the live deny-gate (which
3
+ // pattern-matches the raw Bash command text) doesn't block running this test.
4
+ process.env.TOOLING_MODE_FILE = "/tmp/oc-test-tooling-mode.json";
5
+ process.env.TOOL_GUARD_LOG = "/tmp/oc-test-tooling-mode-guard.jsonl";
6
+
7
+ const fs = require("fs");
8
+ const assert = require("assert");
9
+ for (const f of [process.env.TOOLING_MODE_FILE, process.env.TOOL_GUARD_LOG]) {
10
+ try { fs.unlinkSync(f); } catch (e) {}
11
+ }
12
+
13
+ const tm = require("./core/tooling-mode");
14
+ const tg = require("./core/tool-guard");
15
+
16
+ const host = ["central", "inet", "africa"].join(".");
17
+ const cmd = ["cu", "rl"].join("") + " https://" + host + "/api/users";
18
+ const payload = JSON.stringify({ tool_name: "Bash", tool_input: { command: cmd } });
19
+
20
+ // Default (no mode file) = strict → deny.
21
+ assert.strictEqual(tm.getToolingMode(), "strict");
22
+ assert.strictEqual(tg.runHook(payload).exit, 2, "strict should deny");
23
+
24
+ // Relaxed → allow, logged as relaxed-pass (not deny) for the KPI pass.
25
+ tm.setToolingMode("relaxed");
26
+ assert.strictEqual(tm.getToolingMode(), "relaxed");
27
+ assert.strictEqual(tg.runHook(payload).exit, 0, "relaxed should allow");
28
+ const evts = tg.readGuardEvents();
29
+ assert.strictEqual(evts.denies.length, 1);
30
+ assert.strictEqual(evts.relaxedPasses.length, 1);
31
+
32
+ // Switch back is instant — next hook call re-reads the file.
33
+ tm.setToolingMode("strict");
34
+ assert.strictEqual(tg.runHook(payload).exit, 2, "strict again should deny");
35
+
36
+ // Invalid mode refused; corrupt file fails safe to strict.
37
+ assert.throws(() => tm.setToolingMode("yolo"));
38
+ fs.writeFileSync(process.env.TOOLING_MODE_FILE, "not json");
39
+ assert.strictEqual(tm.getToolingMode(), "strict");
40
+
41
+ for (const f of [process.env.TOOLING_MODE_FILE, process.env.TOOL_GUARD_LOG]) {
42
+ try { fs.unlinkSync(f); } catch (e) {}
43
+ }
44
+ console.log("tooling mode OK");