@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/CHANGELOG.md +7 -0
- package/bin/cli.js +1 -1
- package/bin/tool.js +177 -23
- package/core/tool-repo.js +270 -0
- package/core/tools.js +268 -59
- package/package.json +1 -1
- package/test-tools.js +139 -19
package/core/tools.js
CHANGED
|
@@ -1,18 +1,29 @@
|
|
|
1
|
-
// Reusable tools:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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())
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
70
|
-
//
|
|
71
|
-
// mtime, which resets on every edit and would make an edited-but-never-run
|
|
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
|
|
274
|
+
return toolPaths(name).exec;
|
|
181
275
|
}
|
|
182
276
|
|
|
183
277
|
function readTool(name, usageMap) {
|
|
184
|
-
const
|
|
278
|
+
const p = toolPaths(name);
|
|
185
279
|
let content;
|
|
186
|
-
try { content = fs.readFileSync(
|
|
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(
|
|
191
|
-
const
|
|
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 ||
|
|
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
|
-
|
|
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:
|
|
202
|
-
runCount:
|
|
203
|
-
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
|
-
|
|
216
|
-
|
|
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
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
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
|
|
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
|
-
|
|
358
|
-
new RegExp(`((#|//)\\s*${MARKER}:.*\\n)`),
|
|
359
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
458
|
-
return
|
|
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