@inetafrica/open-claudia 2.6.59 → 2.7.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/core/tools.js CHANGED
@@ -1,18 +1,29 @@
1
- // Reusable tools: executable scripts the agent crystallises as it works, so a
2
- // procedure it figured out once (hit an API, drive an interface, transform a
3
- // file) becomes a re-runnable command next time instead of a throwaway heredoc.
1
+ // Reusable tools: the interface operational work happens THROUGH not
2
+ // souvenirs saved after the fact. The agent searches for a tool first, extends
3
+ // a missing verb, or scaffolds; scripts are for probing unknown APIs only.
4
4
  //
5
- // This is the executable sibling of context packs. A pack's Procedure section
6
- // documents *how* in prose; a tool *is* the how — a real file you run. Tools
7
- // are surfaced in the system prompt as an always-on index (like skill packs)
8
- // and read on demand via `open-claudia tool show <name>` (progressive
9
- // disclosure). Each tool can name an owning skill pack so the two stay linked.
5
+ // A tool is a memory object, not a file a directory:
6
+ // tools/<name>/tool executable (any language, shebang)
7
+ // tools/<name>/TOOL.md Interface · Known issues · State · Journal (prose)
8
+ // tools/<name>/state.json machine telemetry, stamped by the runtime only
9
+ // The tools dir is git-initialised (core/tool-repo.js); every add/change
10
+ // commits, so git log is the upgrade history. Pre-2.7 flat-file tools are
11
+ // still readable and are migrated to directories on first CLI use.
12
+ //
13
+ // This is the executable sibling of context packs: packs hold judgment
14
+ // (when/why/what needs approval), tools hold mechanics (how). Tools surface in
15
+ // the system prompt as an always-on index and are read on demand via
16
+ // `open-claudia tool show <name>` (progressive disclosure).
10
17
  //
11
18
  // Preauth: tools run with the operational keyring merged into their env (see
12
- // runEnv()), exactly like the agent's own subprocess. So a tool references a
13
- // credential as $inet_central_user and never hardcodes a secret. The keyring is
14
- // already a managed .env (plaintext + chmod 600 + log redaction); tools inherit
15
- // that model rather than inventing a second one.
19
+ // runEnv()). A tool references a credential as $inet_central_user and never
20
+ // hardcodes a secret addTool/updateToolHeader REFUSE sources that match
21
+ // secret patterns, because the auto-commit history is forever.
22
+ //
23
+ // Risk tiers mechanise the ask-first rule: every tool declares read-only /
24
+ // write / destructive. runGate() refuses write-tier runs without --yes and
25
+ // destructive-tier runs without --yes-destructive; approval itself always
26
+ // comes from the user — the flag is the agent's deliberate confirmation step.
16
27
 
17
28
  const fs = require("fs");
18
29
  const path = require("path");
@@ -24,7 +35,20 @@ const TOOLS_DIR = process.env.TOOLS_DIR ? path.resolve(process.env.TOOLS_DIR) :
24
35
  // parse from the leading comment block. Language-agnostic: we accept "#" (sh,
25
36
  // python, ruby) and "//" (js, ts, go) comment prefixes.
26
37
  const MARKER = "open-claudia-tool";
27
- const HEADER_KEYS = ["description", "pack", "requires", "usage"];
38
+ const HEADER_KEYS = ["description", "pack", "requires", "usage", "risk"];
39
+ const RISK_TIERS = ["read-only", "write", "destructive"];
40
+
41
+ // Normalise a risk value to one of RISK_TIERS ("" when unrecognised). Blast
42
+ // radius on LIVE systems is what counts: writing a local output file is still
43
+ // read-only; mutating an API/DB/cluster is write; deleting or irreversible is
44
+ // destructive.
45
+ function normalizeRisk(val) {
46
+ const v = String(val || "").toLowerCase().trim().replace(/[_\s]+/g, "-");
47
+ if (["read-only", "readonly", "read", "ro"].includes(v)) return "read-only";
48
+ if (["write", "w", "mutating"].includes(v)) return "write";
49
+ if (["destructive", "dangerous"].includes(v)) return "destructive";
50
+ return "";
51
+ }
28
52
 
29
53
  function ensureDir() {
30
54
  fs.mkdirSync(TOOLS_DIR, { recursive: true, mode: 0o700 });
@@ -35,12 +59,28 @@ function sanitizeName(name) {
35
59
  .replace(/[^a-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 60);
36
60
  }
37
61
 
62
+ // ── Layout resolution ────────────────────────────────────────────────────────
63
+ // Directory layout (2.7+) vs legacy flat file. A name resolves to the directory
64
+ // shape when tools/<name>/tool exists; otherwise to the flat file if present;
65
+ // otherwise (for creation) to the directory shape.
66
+ function toolPaths(name) {
67
+ const n = sanitizeName(name);
68
+ const dir = path.join(TOOLS_DIR, n);
69
+ const exec = path.join(dir, "tool");
70
+ const isDir = (() => { try { return fs.statSync(exec).isFile(); } catch (e) { return false; } })();
71
+ if (isDir) return { name: n, layout: "dir", dir, exec, doc: path.join(dir, "TOOL.md"), state: path.join(dir, "state.json") };
72
+ const isFile = (() => { try { return fs.statSync(dir).isFile(); } catch (e) { return false; } })();
73
+ if (isFile) return { name: n, layout: "file", dir: TOOLS_DIR, exec: dir, doc: null, state: null };
74
+ return { name: n, layout: "new", dir, exec, doc: path.join(dir, "TOOL.md"), state: path.join(dir, "state.json") };
75
+ }
76
+
38
77
  // ── Run telemetry ────────────────────────────────────────────────────────────
39
- // A tiny JSON sidecar (name -> { runCount, lastUsed }) kept beside the tools.
40
- // Deliberately NOT a SQLite graph: telemetry must work on the node:20 pods where
41
- // node:sqlite is absent, and it's a flat counter, not a graph. Dotfile so
42
- // listTools (which skips ".") never mistakes it for a tool.
78
+ // Directory tools: per-tool state.json, written ONLY by the runtime (never
79
+ // hand-edited, never committed gitignored; TOOL.md's State section is the
80
+ // curated prose view). Plain JSON, not SQLite: must work on node:20 pods.
81
+ // Legacy flat tools keep the old .usage.json sidecar until migrated.
43
82
  const USAGE_FILE = path.join(TOOLS_DIR, ".usage.json");
83
+ const HISTORY_CAP = 20;
44
84
 
45
85
  function readUsage() {
46
86
  try { return JSON.parse(fs.readFileSync(USAGE_FILE, "utf-8")) || {}; }
@@ -52,12 +92,58 @@ function writeUsage(u) {
52
92
  catch (e) { /* best-effort */ }
53
93
  }
54
94
 
55
- // Bump a tool's run counter. Called at end of turn from the runner when it parses
56
- // an `open-claudia tool run <name>` out of the shell. Feeds matchTools' tiebreak
57
- // (popular tools rank up) and the dream's zero-run hygiene flag.
95
+ function readState(name) {
96
+ const p = toolPaths(name);
97
+ if (!p.state) return {};
98
+ try { return JSON.parse(fs.readFileSync(p.state, "utf-8")) || {}; }
99
+ catch (e) { return {}; }
100
+ }
101
+
102
+ function writeState(name, s) {
103
+ const p = toolPaths(name);
104
+ const file = p.state || path.join(TOOLS_DIR, sanitizeName(name), "state.json");
105
+ try {
106
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
107
+ fs.writeFileSync(file, JSON.stringify(s, null, 1), { mode: 0o600 });
108
+ } catch (e) { /* best-effort */ }
109
+ }
110
+
111
+ // Authoritative telemetry stamp — called by `tool run` itself with the real
112
+ // outcome (exit code, duration, argv). Tracks per-verb health (verb = first
113
+ // non-flag arg) so TOOL.md can report "assign: last real run <date>" honestly,
114
+ // plus a capped run history. A non-zero exit is the trigger for a known-issue
115
+ // entry in TOOL.md (the CLI nudges; the foreground agent writes it).
116
+ function recordRunResult(name, { args = [], exit = 0, ms = 0 } = {}) {
117
+ const n = sanitizeName(name);
118
+ if (!n) return;
119
+ if (toolPaths(n).layout !== "dir") { recordRun(n); return; } // legacy fallback
120
+ const now = new Date().toISOString();
121
+ const s = readState(n);
122
+ if (!s.createdAt) s.createdAt = now;
123
+ s.lastUsed = now;
124
+ s.runCount = (s.runCount || 0) + 1;
125
+ s.lastExit = exit;
126
+ if (exit === 0) s.lastSuccess = now; else s.lastFailure = now;
127
+ const verb = (args.find((a) => a && !String(a).startsWith("-")) || "(default)").slice(0, 40);
128
+ s.verbs = s.verbs || {};
129
+ const v = s.verbs[verb] || { runs: 0 };
130
+ v.runs += 1;
131
+ v.lastExit = exit;
132
+ if (exit === 0) v.lastSuccess = now; else v.lastFailure = now;
133
+ s.verbs[verb] = v;
134
+ const argLine = args.join(" ").slice(0, 120);
135
+ s.history = [{ at: now, args: argLine, exit, ms }, ...(s.history || [])].slice(0, HISTORY_CAP);
136
+ writeState(n, s);
137
+ }
138
+
139
+ // Bump a tool's run counter without outcome detail. Still called at end of turn
140
+ // from the runner's transcript parse; for directory tools that's a no-op (the
141
+ // CLI already stamped the authoritative result — double-counting would lie),
142
+ // so this only feeds the legacy sidecar for unmigrated flat tools.
58
143
  function recordRun(name) {
59
144
  const n = sanitizeName(name);
60
145
  if (!n) return;
146
+ if (toolPaths(n).layout === "dir") return; // CLI stamps state.json directly
61
147
  const u = readUsage();
62
148
  const cur = u[n] || { runCount: 0, lastUsed: "" };
63
149
  cur.runCount = (cur.runCount || 0) + 1;
@@ -66,13 +152,18 @@ function recordRun(name) {
66
152
  writeUsage(u);
67
153
  }
68
154
 
69
- // Stamp an immutable createdAt the first time a tool is registered. Kept in the
70
- // sidecar (not the header) so editing the file never disturbs it — unlike file
71
- // mtime, which resets on every edit and would make an edited-but-never-run tool
72
- // look freshly minted. The dream's zero-run hygiene ages tools from this.
155
+ // Stamp an immutable createdAt the first time a tool is registered. Kept in
156
+ // telemetry (not the header) so editing the file never disturbs it — unlike
157
+ // file mtime, which resets on every edit and would make an edited-but-never-run
158
+ // tool look freshly minted. The dream's zero-run hygiene ages tools from this.
73
159
  function recordCreate(name) {
74
160
  const n = sanitizeName(name);
75
161
  if (!n) return;
162
+ if (toolPaths(n).layout === "dir") {
163
+ const s = readState(n);
164
+ if (!s.createdAt) { s.createdAt = new Date().toISOString(); writeState(n, s); }
165
+ return;
166
+ }
76
167
  const u = readUsage();
77
168
  const cur = u[n] || { runCount: 0, lastUsed: "" };
78
169
  if (!cur.createdAt) cur.createdAt = new Date().toISOString();
@@ -152,7 +243,7 @@ function uncomment(line) {
152
243
  // script is only a tool if the block contains an "open-claudia-tool: <name>"
153
244
  // line.
154
245
  function parseHeader(content) {
155
- const out = { name: "", description: "", pack: "", requires: [], usage: "" };
246
+ const out = { name: "", description: "", pack: "", requires: [], usage: "", risk: "" };
156
247
  const lines = String(content || "").split("\n");
157
248
  let started = false;
158
249
  for (let i = 0; i < lines.length; i++) {
@@ -171,40 +262,59 @@ function parseHeader(content) {
171
262
  const val = kv[2].trim();
172
263
  if (key === MARKER) out.name = sanitizeName(val);
173
264
  else if (key === "requires") out.requires = val.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
265
+ else if (key === "risk") out.risk = normalizeRisk(val);
174
266
  else if (HEADER_KEYS.includes(key)) out[key] = val;
175
267
  }
176
268
  return out.name ? out : null;
177
269
  }
178
270
 
271
+ // Path of a tool's executable (legacy signature kept: several callers treat
272
+ // "the tool file" as the thing you run/edit).
179
273
  function toolFile(name) {
180
- return path.join(TOOLS_DIR, sanitizeName(name));
274
+ return toolPaths(name).exec;
181
275
  }
182
276
 
183
277
  function readTool(name, usageMap) {
184
- const file = toolFile(name);
278
+ const p = toolPaths(name);
185
279
  let content;
186
- try { content = fs.readFileSync(file, "utf-8"); } catch (e) { return null; }
280
+ try { content = fs.readFileSync(p.exec, "utf-8"); } catch (e) { return null; }
187
281
  const header = parseHeader(content);
188
282
  if (!header) return null;
189
283
  let stat = null;
190
- try { stat = fs.statSync(file); } catch (e) {}
191
- const usage = (usageMap || readUsage())[header.name || sanitizeName(name)] || {};
284
+ try { stat = fs.statSync(p.exec); } catch (e) {}
285
+ const telemetry = p.layout === "dir"
286
+ ? readState(header.name || p.name)
287
+ : (usageMap || readUsage())[header.name || p.name] || {};
192
288
  return {
193
- name: header.name || sanitizeName(name),
289
+ name: header.name || p.name,
194
290
  description: header.description || "",
195
291
  pack: header.pack || "",
196
292
  requires: header.requires || [],
197
293
  usage: header.usage || "",
198
- file,
294
+ risk: header.risk || "",
295
+ layout: p.layout,
296
+ file: p.exec,
297
+ docFile: p.doc,
199
298
  executable: stat ? !!(stat.mode & 0o111) : false,
200
299
  updatedAt: stat ? stat.mtime.toISOString() : "",
201
- createdAt: usage.createdAt || "",
202
- runCount: usage.runCount || 0,
203
- lastUsed: usage.lastUsed || "",
300
+ createdAt: telemetry.createdAt || "",
301
+ runCount: telemetry.runCount || 0,
302
+ lastUsed: telemetry.lastUsed || "",
303
+ lastExit: telemetry.lastExit,
304
+ verbs: telemetry.verbs || null,
305
+ history: telemetry.history || null,
204
306
  content,
205
307
  };
206
308
  }
207
309
 
310
+ // A tool's TOOL.md prose (Interface · Known issues · State · Journal), "" when
311
+ // absent — loaded on demand (tool show), never in the always-on index.
312
+ function readToolDoc(name) {
313
+ const p = toolPaths(name);
314
+ if (!p.doc) return "";
315
+ try { return fs.readFileSync(p.doc, "utf-8"); } catch (e) { return ""; }
316
+ }
317
+
208
318
  function listTools() {
209
319
  let entries;
210
320
  try { entries = fs.readdirSync(TOOLS_DIR); } catch (e) { return []; }
@@ -212,8 +322,11 @@ function listTools() {
212
322
  const tools = [];
213
323
  for (const name of entries) {
214
324
  if (name.startsWith(".")) continue;
215
- try { if (!fs.statSync(path.join(TOOLS_DIR, name)).isFile()) continue; }
216
- catch (e) { continue; }
325
+ const full = path.join(TOOLS_DIR, name);
326
+ let st;
327
+ try { st = fs.statSync(full); } catch (e) { continue; }
328
+ if (st.isDirectory() && !fs.existsSync(path.join(full, "tool"))) continue; // junk dir, not a tool
329
+ if (!st.isDirectory() && !st.isFile()) continue;
217
330
  const t = readTool(name, usage);
218
331
  if (t) tools.push(t);
219
332
  }
@@ -229,10 +342,11 @@ function findTool(name) {
229
342
 
230
343
  // Build the header comment block for a script that doesn't already declare one.
231
344
  // Uses the comment prefix that matches the script's shebang/extension.
232
- function buildHeader(prefix, { name, description, pack, requires, usage }) {
345
+ function buildHeader(prefix, { name, description, pack, requires, usage, risk }) {
233
346
  const lines = [`${prefix} ${MARKER}: ${name}`];
234
347
  if (description) lines.push(`${prefix} description: ${description}`);
235
348
  if (pack) lines.push(`${prefix} pack: ${pack}`);
349
+ if (risk) lines.push(`${prefix} risk: ${risk}`);
236
350
  if (requires && requires.length) lines.push(`${prefix} requires: ${[].concat(requires).join(", ")}`);
237
351
  if (usage) lines.push(`${prefix} usage: ${usage}`);
238
352
  return lines.join("\n");
@@ -320,26 +434,47 @@ function checkSyntax(file, content) {
320
434
  } catch (e) { return null; }
321
435
  }
322
436
 
323
- // Register a script as a tool. Copies it into TOOLS_DIR under a sanitized name,
324
- // ensures it carries a header (synthesising one from opts if absent), and makes
325
- // it executable. Returns the stored tool.
437
+ // Refuse content carrying a hardcoded credential. The tools repo auto-commits,
438
+ // and git history is forever so unlike the advisory lints, secrets BLOCK.
439
+ function assertNoSecrets(content, what) {
440
+ const hits = [];
441
+ for (const [re, label] of SECRET_PATTERNS) if (re.test(String(content || ""))) hits.push(label);
442
+ if (hits.length) {
443
+ throw new Error(`${what || "content"} contains hardcoded secret(s): ${hits.join(", ")}. ` +
444
+ "Reference credentials as $KEYRING_VAR (declare via --requires) — refusing to save into the git-tracked tools repo.");
445
+ }
446
+ }
447
+
448
+ // Register a script as a tool. New tools land in the directory layout (tool +
449
+ // TOOL.md + state.json); re-adding over a legacy flat file upgrades it. The
450
+ // source must carry a header (synthesised from opts if absent), gets a
451
+ // fail-safe `risk: write` tier when none is declared, is refused if it embeds
452
+ // a secret, and the save is git-committed. Returns the stored tool.
326
453
  function addTool(srcPath, opts = {}) {
327
454
  ensureDir();
328
455
  let content;
329
456
  try { content = fs.readFileSync(srcPath, "utf-8"); }
330
457
  catch (e) { throw new Error(`cannot read ${srcPath}: ${e.message}`); }
331
458
 
459
+ assertNoSecrets(content, path.basename(srcPath));
460
+
332
461
  let header = parseHeader(content);
333
462
  const name = sanitizeName(opts.name || (header && header.name) || path.basename(srcPath).replace(/\.[^.]+$/, ""));
334
463
  if (!name) throw new Error("tool needs a name (pass --name or add an 'open-claudia-tool:' header line)");
335
464
 
465
+ const optRisk = normalizeRisk(opts.risk);
466
+ let defaultedRisk = false;
467
+
336
468
  if (!header) {
337
469
  // No header in the source — synthesise one so the tool is self-documenting.
338
470
  const prefix = commentPrefixFor(content, srcPath);
471
+ let risk = optRisk;
472
+ if (!risk) { risk = "write"; defaultedRisk = true; } // fail-safe: ungated only when declared read-only
339
473
  const block = buildHeader(prefix, {
340
474
  name,
341
475
  description: opts.description || "",
342
476
  pack: opts.pack || "",
477
+ risk,
343
478
  requires: opts.requires || [],
344
479
  usage: opts.usage || "",
345
480
  });
@@ -351,26 +486,59 @@ function addTool(srcPath, opts = {}) {
351
486
  content = block + "\n" + content;
352
487
  }
353
488
  header = parseHeader(content);
354
- } else if (opts.pack && !header.pack) {
355
- // Source had a header but no pack link and the caller supplied one — add it.
489
+ } else {
356
490
  const prefix = commentPrefixFor(content, srcPath);
357
- content = content.replace(
358
- new RegExp(`((#|//)\\s*${MARKER}:.*\\n)`),
359
- `$1${prefix} pack: ${opts.pack}\n`
360
- );
491
+ const injectAfterMarker = (line) => {
492
+ content = content.replace(new RegExp(`((#|//)\\s*${MARKER}:.*\\n)`), `$1${line}\n`);
493
+ };
494
+ if (opts.pack && !header.pack) injectAfterMarker(`${prefix} pack: ${opts.pack}`);
495
+ if (!header.risk) {
496
+ const risk = optRisk || "write";
497
+ if (!optRisk) defaultedRisk = true;
498
+ injectAfterMarker(`${prefix} risk: ${risk}`);
499
+ }
500
+ header = parseHeader(content);
361
501
  }
362
502
 
363
- const dest = toolFile(name);
364
- fs.writeFileSync(dest, content, { mode: 0o700 });
365
- try { fs.chmodSync(dest, 0o700); } catch (e) {}
503
+ // Upgrade a legacy flat file in place: the new content replaces it in the
504
+ // directory layout, carrying the old sidecar telemetry across.
505
+ const pre = toolPaths(name);
506
+ if (pre.layout === "file") {
507
+ const u = readUsage();
508
+ const old = u[name] || {};
509
+ try { fs.rmSync(pre.exec, { force: true }); } catch (e) {}
510
+ delete u[name];
511
+ writeUsage(u);
512
+ fs.mkdirSync(path.join(TOOLS_DIR, name), { recursive: true, mode: 0o700 });
513
+ writeState(name, { createdAt: old.createdAt || new Date().toISOString(), lastUsed: old.lastUsed || "", runCount: old.runCount || 0 });
514
+ }
515
+
516
+ const p = toolPaths(name);
517
+ fs.mkdirSync(path.dirname(p.exec), { recursive: true, mode: 0o700 });
518
+ fs.writeFileSync(p.exec, content, { mode: 0o700 });
519
+ try { fs.chmodSync(p.exec, 0o700); } catch (e) {}
520
+ if (p.doc && !fs.existsSync(p.doc)) {
521
+ try {
522
+ const repo = require("./tool-repo");
523
+ fs.writeFileSync(p.doc, repo.toolMdSkeleton({ ...header, journalLine: "Registered via tool add." }), { mode: 0o600 });
524
+ } catch (e) { /* skeleton optional */ }
525
+ }
366
526
  recordCreate(name);
367
- return readTool(name);
527
+ try { require("./tool-repo").commitTool(name, `${pre.layout === "new" ? "add" : "update"} ${name}`); } catch (e) {}
528
+ const stored = readTool(name);
529
+ if (stored) stored.defaultedRisk = defaultedRisk;
530
+ return stored;
368
531
  }
369
532
 
370
533
  function removeTool(name) {
371
534
  const tool = findTool(name);
372
535
  if (!tool) return null;
373
- try { fs.rmSync(tool.file, { force: true }); } catch (e) { return null; }
536
+ const p = toolPaths(name);
537
+ try {
538
+ if (p.layout === "dir") fs.rmSync(path.join(TOOLS_DIR, p.name), { recursive: true, force: true });
539
+ else fs.rmSync(p.exec, { force: true });
540
+ } catch (e) { return null; }
541
+ try { require("./tool-repo").commitTool(p.name, `remove ${p.name}`); } catch (e) {}
374
542
  return tool;
375
543
  }
376
544
 
@@ -387,6 +555,11 @@ function updateToolHeader(name, opts = {}) {
387
555
  const description = opts.description !== undefined ? opts.description : t.description;
388
556
  const pack = opts.pack !== undefined ? opts.pack : t.pack;
389
557
  const usage = opts.usage !== undefined ? opts.usage : t.usage;
558
+ let risk = t.risk;
559
+ if (opts.risk !== undefined) {
560
+ risk = normalizeRisk(opts.risk);
561
+ if (!risk) throw new Error(`invalid risk tier "${opts.risk}" — use one of: ${RISK_TIERS.join(", ")}`);
562
+ }
390
563
  let requires = opts.requires !== undefined ? opts.requires : t.requires;
391
564
  if (!Array.isArray(requires)) requires = String(requires || "").split(/[,\s]+/).filter(Boolean);
392
565
 
@@ -412,6 +585,7 @@ function updateToolHeader(name, opts = {}) {
412
585
  const fieldLines = [];
413
586
  if (description) fieldLines.push(`${prefix} description: ${description}`);
414
587
  if (pack) fieldLines.push(`${prefix} pack: ${pack}`);
588
+ if (risk) fieldLines.push(`${prefix} risk: ${risk}`);
415
589
  if (requires.length) fieldLines.push(`${prefix} requires: ${requires.join(", ")}`);
416
590
  if (usage) fieldLines.push(`${prefix} usage: ${usage}`);
417
591
 
@@ -420,11 +594,40 @@ function updateToolHeader(name, opts = {}) {
420
594
  else newBlock = [`${prefix} ${MARKER}: ${t.name}`, ...fieldLines, ...kept];
421
595
 
422
596
  const next = [...lines.slice(0, headStart), ...newBlock, ...lines.slice(blockEnd)].join("\n");
597
+ assertNoSecrets(next, `tool ${t.name}`);
423
598
  fs.writeFileSync(t.file, next, { mode: 0o700 });
424
599
  try { fs.chmodSync(t.file, 0o700); } catch (e) {}
600
+ try { require("./tool-repo").commitTool(t.name, `set ${t.name} metadata`); } catch (e) {}
425
601
  return readTool(t.name);
426
602
  }
427
603
 
604
+ // ── Risk-tier run gate ───────────────────────────────────────────────────────
605
+ // Mechanises ask-first: read-only runs freely; write-tier requires --yes;
606
+ // destructive-tier requires --yes-destructive (a plain --yes habit must not
607
+ // unlock it) and gets a spelled-out warning. An undeclared tier is treated as
608
+ // write — fail-safe until classified. The flag is the agent's deliberate
609
+ // confirmation step AFTER user approval; it never replaces asking the user.
610
+ function runGate(tool, argv = []) {
611
+ const declared = normalizeRisk(tool && tool.risk);
612
+ const tier = declared || "write";
613
+ const note = declared ? "" : ` (no risk tier declared — treated as write; classify: open-claudia tool set ${tool.name} --risk read-only|write|destructive)`;
614
+ if (tier === "read-only") return { ok: true, tier };
615
+ if (tier === "destructive") {
616
+ if (argv.includes("--yes-destructive")) return { ok: true, tier };
617
+ return {
618
+ ok: false, tier,
619
+ message: `⛔ DESTRUCTIVE tool "${tool.name}" — this can delete or irreversibly change live state.\n` +
620
+ `Warn the user what exactly will happen and get explicit approval, then re-run with --yes-destructive.`,
621
+ };
622
+ }
623
+ if (argv.includes("--yes")) return { ok: true, tier };
624
+ return {
625
+ ok: false, tier,
626
+ message: `⛔ "${tool.name}" is write-tier${note} — it changes live system state.\n` +
627
+ `Confirm the action with the user first, then re-run with --yes.`,
628
+ };
629
+ }
630
+
428
631
  // Env for running a tool: the bot's standard subprocess env (PATH + keyring
429
632
  // creds merged) so tools are pre-authed the same way the agent is. Falls back to
430
633
  // a plain keyring merge if config isn't importable (e.g. standalone test).
@@ -448,20 +651,26 @@ function missingRequires(tool) {
448
651
  return tool.requires.filter((k) => !present.has(k) && process.env[k] === undefined);
449
652
  }
450
653
 
451
- // Recognise a Write/Edit aimed at a tool file (for chat announcements).
654
+ // Recognise a Write/Edit aimed at a tool (for chat announcements). Legacy flat
655
+ // file (tools/<name>) or the directory layout's agent-editable members
656
+ // (tools/<name>/tool, tools/<name>/TOOL.md). state.json is machine telemetry —
657
+ // hand edits to it are not a tool change, so it maps to null.
452
658
  function toolNameFromPath(filePath) {
453
659
  if (!filePath) return null;
454
660
  const resolved = path.resolve(String(filePath));
455
661
  const rel = path.relative(TOOLS_DIR, resolved);
456
662
  if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
457
- if (rel.split(path.sep).length !== 1) return null;
458
- return rel;
663
+ const parts = rel.split(path.sep);
664
+ if (parts.length === 1) return parts[0] || null;
665
+ if (parts.length === 2 && (parts[1] === "tool" || parts[1] === "TOOL.md")) return parts[0];
666
+ return null;
459
667
  }
460
668
 
461
669
  module.exports = {
462
- TOOLS_DIR, MARKER, USAGE_FILE,
463
- parseHeader, readTool, listTools, findTool, addTool, removeTool, updateToolHeader,
464
- runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir,
670
+ TOOLS_DIR, MARKER, USAGE_FILE, RISK_TIERS,
671
+ parseHeader, readTool, readToolDoc, listTools, findTool, addTool, removeTool, updateToolHeader,
672
+ runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir, normalizeRisk,
673
+ toolPaths, readState, writeState, recordRunResult, runGate,
465
674
  matchTools, recordRun, recordCreate, readUsage,
466
- lintToolSource, checkSyntax, envRefsIn,
675
+ lintToolSource, checkSyntax, envRefsIn, assertNoSecrets,
467
676
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.59",
3
+ "version": "2.7.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": {