@inetafrica/open-claudia 2.7.4 โ 2.7.6
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 -1
- package/bin/tool.js +110 -11
- package/core/runner.js +8 -3
- package/core/system-prompt.js +2 -1
- package/core/tool-repo.js +35 -6
- package/core/tools.js +172 -9
- package/package.json +1 -1
- package/test-tools.js +104 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## v2.7.
|
|
3
|
+
## v2.7.6
|
|
4
|
+
- **/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
|
+
|
|
6
|
+
## v2.7.5
|
|
7
|
+
- **Tool composition with inherited approval โ a composing tool cannot self-approve (Phase 4).** Higher-order tools may shell into lower tools (report โ kticket + kchat), but the callee's risk gate stays intact: `tool run` now exports the tier the **outermost** invocation's flags acknowledged (`OPEN_CLAUDIA_APPROVED_TIER`) plus the call chain (`OPEN_CLAUDIA_TOOL_STACK`) into the child env, and a nested run gates on that inherited tier while **ignoring its own argv flags** โ so a composing tool that hardcodes `--yes-destructive` in its source is refused with a message pointing at the outer re-run. An inherited tier passes through unchanged (depth-safe), a tool already in the chain is refused (cycle guard), and each nested run still stamps its own telemetry. Verified live: fabricated approval blocked at exit 3, legit outer `--yes-destructive` flowing through, self-call cycle caught at depth 1.
|
|
8
|
+
- **`_lib/` shared plumbing with dependent journaling (Phase 4).** Genuinely shared code (central auth, mongo-exec) now lives in `tools/_lib` โ tiny modules tools import via a literal `_lib/<name>` path, never tools themselves (the registry, surfacing index, and legacy migration all skip the `_` prefix). New `tool lib list|show|set`: `set` writes the module (secret-blocking, traversal-safe), finds every dependent by a boundary-safe source grep (`central-auth` matches `_lib/central-auth.js` and the `path.join` form, **not** `central-auth-v2`), journals each dependent `unverified since _lib change โ re-verified by next real use`, and commits the lot in ONE commit naming the dependents โ the PLAN's blast-radius rule, since a lib change can break several tools at once and health is verified by use, not smoke tests.
|
|
9
|
+
- **Umbrella grouping from registry tags (Phase 4).** Tools gain an optional `tags:` header field (`--tags` on `scaffold`/`add`/`set`, sanitised, strong-match in `tool search`); `tool list` renders the first tag as an umbrella group โ `kazee/{kchat,kticket}`, `infra/{prod-k8s}` โ with untagged tools flat below. Purely presentational: "Kazee" stays a registry grouping, never a binary. The fixed system-prompt tools section gains one composition bullet (approval is inherited, never fabricated; `_lib` marks dependents unverified). Test coverage: tags round-trip, lib dependent grep boundaries, setLib journaling, and the full nested-gate matrix including the fabrication and cycle refusals.
|
|
4
10
|
- **The dream now audits tool usage โ raw-op detector + usage KPI (Phase 3/item 9 + enforcement 5c).** The guard log knew about *blocked* commands (denies) and *authorised* one-offs (`keyring exec` bypasses), but operational work done raw that the gate didn't cover โ inline heredoc scripts fed to an interpreter, raw DB clients, mutating `kubectl` โ left no trace, so the tool-usage KPI had a blind spot exactly where the "repeated heredocs slip through" failure lives. New deterministic detector in `core/tool-guard.js` (`matchRawOp`/`noteRawOp`): high-precision patterns (`heredoc-interpreter`, `db-client`, `kubectl-mutate`), wired into the runner's Bash-stream chokepoint so every command is observed โ **observation only, never blocking**; the authorised channels and anything the deny-gate already owns are skipped so one command never yields two guard events. Reads (`kubectl get/logs`, `rollout status`, `cat <<EOF` file writes, mentioning a client in grep) stay unflagged.
|
|
5
11
|
- **Doc/code drift flag completes the dream tool-pass.** Alongside the existing hygiene flags (dangling pack links, cold never-run tools, near-duplicate pairs), the pass now flags tools whose source was edited after TOOL.md was last touched, and verbs that telemetry proves are in real use but the docs never mention (`tools.docDrift()`; `help`/`(default)` ignored). Flag only โ the fix is a `tool note`. Zero false flags on the live 12-tool registry at ship time.
|
|
6
12
|
- **Nightly KPI in the dream report, computed from stamps โ never prose.** The dream's tool pass now appends `๐ Tool usage since last dream`: % of operational actions via `tool run` (target >90%, denominator = tool runs + escape-hatch bypasses + raw-op sightings; gate denies reported separately since blocked โ completed), bypass count **with reasons**, raw-ops by pattern, and failure-at-use counts naming the failing tools. Sources are `state.json` run-history stamps (new `tools.runsSince(sinceTs)`) + `tool-guard.jsonl` (new `readGuardEvents(sinceTs)`) โ per the plan, the KPI is never parsed out of conversation prose. Escape-hatch bypasses surface in the dream report only (no immediate chat pings โ pings would train us both to ignore them). Test coverage: raw-op matrix incl. no-double-count with denies, event grouping/windowing, `runsSince` window math.
|
package/bin/tool.js
CHANGED
|
@@ -2,20 +2,21 @@
|
|
|
2
2
|
// through. Tool-first loop: search โ exists: use it โ missing a verb: extend โ
|
|
3
3
|
// nothing: scaffold, then work through the tool.
|
|
4
4
|
//
|
|
5
|
-
// open-claudia tool list โ index (
|
|
5
|
+
// open-claudia tool list โ index, grouped by first tag (kazee/, infra/, โฆ)
|
|
6
6
|
// open-claudia tool search <query> โ lexical match (find before adding a dup)
|
|
7
7
|
// open-claudia tool show <name> [--source] โ TOOL.md, telemetry, history (source with --source)
|
|
8
8
|
// open-claudia tool scaffold <name> โ create a new tool skeleton (dir + stub + TOOL.md)
|
|
9
9
|
// [--pack <dir>] [--desc "..."] [--risk read-only|write|destructive]
|
|
10
|
-
// [--requires "k1,k2"] [--usage "..."] [--lang node|bash]
|
|
10
|
+
// [--requires "k1,k2"] [--tags "group"] [--usage "..."] [--lang node|bash]
|
|
11
11
|
// open-claudia tool add <path> [--name n] โ register an existing script as a tool
|
|
12
|
-
// [--pack <dir>] [--desc "..."] [--risk tier] [--requires "k1,k2"] [--usage "..."]
|
|
12
|
+
// [--pack <dir>] [--desc "..."] [--risk tier] [--requires "k1,k2"] [--tags "group"] [--usage "..."]
|
|
13
13
|
// open-claudia tool set <name> [--desc ...] โ fix a tool's metadata in place
|
|
14
|
-
// [--pack <dir>] [--risk tier] [--requires "k1,k2"] [--usage "..."]
|
|
14
|
+
// [--pack <dir>] [--risk tier] [--requires "k1,k2"] [--tags "group"] [--usage "..."]
|
|
15
15
|
// open-claudia tool note <name> โ append to TOOL.md (auto-committed)
|
|
16
16
|
// [--issue "..."] [--state "..."] [--journal "..."]
|
|
17
17
|
// open-claudia tool edit <name> โ print the file path to open & edit
|
|
18
18
|
// open-claudia tool run <name> [args...] โ run it (keyring pre-loaded, risk-gated)
|
|
19
|
+
// open-claudia tool lib [list|show|set] ... โ shared _lib plumbing tools import (see below)
|
|
19
20
|
// open-claudia tool migrate โ convert legacy flat-file tools to directories
|
|
20
21
|
// open-claudia tool remove <name> โ delete one
|
|
21
22
|
// open-claudia tool path โ print the tools directory
|
|
@@ -38,6 +39,13 @@
|
|
|
38
39
|
// it declares via --requires โ reference them as $NAME inside the script and
|
|
39
40
|
// declare every one. `open-claudia keyring list` shows what exists; never
|
|
40
41
|
// hardcode secrets in a tool. Undeclared keys simply won't be in the env.
|
|
42
|
+
//
|
|
43
|
+
// Composition (Phase 4): a tool may shell into other tools (report โ kticket).
|
|
44
|
+
// Approval is inherited, never fabricated: the runner exports the tier the
|
|
45
|
+
// OUTER flags acknowledged, and a nested `tool run` gates on that inherited
|
|
46
|
+
// tier while ignoring its own argv flags. Shared plumbing lives in _lib/
|
|
47
|
+
// (`tool lib set <name> <src>`); a lib change journals every dependent tool
|
|
48
|
+
// "unverified since _lib change" โ re-verified by its next real use.
|
|
41
49
|
|
|
42
50
|
const { spawnSync } = require("child_process");
|
|
43
51
|
const tools = require("../core/tools");
|
|
@@ -124,10 +132,31 @@ function run(args) {
|
|
|
124
132
|
return console.log(`No tools yet (${tools.TOOLS_DIR}).\nSave one with: open-claudia tool add <script-path> --pack <skill>`);
|
|
125
133
|
}
|
|
126
134
|
console.log(`${all.length} reusable tool(s) โ run via 'open-claudia tool run <name>':\n`);
|
|
127
|
-
|
|
135
|
+
const bullet = (t, indent = "") => {
|
|
128
136
|
const skill = t.pack ? ` [skill: ${t.pack}]` : "";
|
|
129
137
|
const risk = t.risk === "read-only" ? "" : ` [${t.risk || "write?"}]`;
|
|
130
|
-
console.log(
|
|
138
|
+
console.log(`${indent}โข ${t.name} โ ${t.description || "(no description)"}${skill}${risk}`);
|
|
139
|
+
};
|
|
140
|
+
// Umbrella grouping from registry tags: first tag is the group
|
|
141
|
+
// (kazee/{kticket,โฆ}); untagged tools render flat. Purely presentational โ
|
|
142
|
+
// groups are tags, never binaries.
|
|
143
|
+
const groups = new Map();
|
|
144
|
+
for (const t of all) {
|
|
145
|
+
const g = (t.tags && t.tags[0]) || "";
|
|
146
|
+
if (!groups.has(g)) groups.set(g, []);
|
|
147
|
+
groups.get(g).push(t);
|
|
148
|
+
}
|
|
149
|
+
if (groups.size === 1 && groups.has("")) {
|
|
150
|
+
for (const t of all) bullet(t);
|
|
151
|
+
} else {
|
|
152
|
+
for (const g of [...groups.keys()].filter(Boolean).sort()) {
|
|
153
|
+
console.log(`${g}/`);
|
|
154
|
+
for (const t of groups.get(g)) bullet(t, " ");
|
|
155
|
+
}
|
|
156
|
+
if (groups.has("")) {
|
|
157
|
+
console.log("");
|
|
158
|
+
for (const t of groups.get("")) bullet(t);
|
|
159
|
+
}
|
|
131
160
|
}
|
|
132
161
|
console.log(`\nDocs: open-claudia tool show <name> ยท Dir: ${tools.TOOLS_DIR}`);
|
|
133
162
|
break;
|
|
@@ -162,6 +191,7 @@ function run(args) {
|
|
|
162
191
|
console.log(`Tool: ${t.name}`);
|
|
163
192
|
if (t.description) console.log(`Description: ${t.description}`);
|
|
164
193
|
if (t.pack) console.log(`Skill pack: ${t.pack} (open-claudia pack show ${t.pack})`);
|
|
194
|
+
if (t.tags && t.tags.length) console.log(`Tags: ${t.tags.join(", ")}`);
|
|
165
195
|
console.log(`Risk: ${t.risk || "(undeclared โ treated as write)"}`);
|
|
166
196
|
console.log(`Usage: open-claudia tool run ${t.name}${t.usage ? " (" + t.usage + ")" : ""}`);
|
|
167
197
|
if (t.requires.length) {
|
|
@@ -228,6 +258,7 @@ function run(args) {
|
|
|
228
258
|
description: typeof flags.desc === "string" ? flags.desc : (typeof flags.description === "string" ? flags.description : undefined),
|
|
229
259
|
risk: typeof flags.risk === "string" ? flags.risk : undefined,
|
|
230
260
|
requires: typeof flags.requires === "string" ? flags.requires.split(/[,\s]+/).filter(Boolean) : undefined,
|
|
261
|
+
tags: typeof flags.tags === "string" ? flags.tags.split(/[,\s]+/).filter(Boolean) : undefined,
|
|
231
262
|
usage: typeof flags.usage === "string" ? flags.usage : undefined,
|
|
232
263
|
});
|
|
233
264
|
console.log(`Saved tool "${t.name}"${t.pack ? ` (skill: ${t.pack})` : ""} โ risk: ${t.risk}.`);
|
|
@@ -276,9 +307,10 @@ function run(args) {
|
|
|
276
307
|
if (typeof flags.pack === "string") patch.pack = flags.pack;
|
|
277
308
|
if (typeof flags.risk === "string") patch.risk = flags.risk;
|
|
278
309
|
if (typeof flags.requires === "string") patch.requires = flags.requires.split(/[,\s]+/).filter(Boolean);
|
|
310
|
+
if (typeof flags.tags === "string") patch.tags = flags.tags.split(/[,\s]+/).filter(Boolean);
|
|
279
311
|
if (typeof flags.usage === "string") patch.usage = flags.usage;
|
|
280
312
|
if (Object.keys(patch).length === 0) {
|
|
281
|
-
console.error('Nothing to set. Pass at least one of --desc / --pack / --risk / --requires / --usage. Body edits: open-claudia tool edit ' + name);
|
|
313
|
+
console.error('Nothing to set. Pass at least one of --desc / --pack / --risk / --requires / --tags / --usage. Body edits: open-claudia tool edit ' + name);
|
|
282
314
|
process.exitCode = 1; return;
|
|
283
315
|
}
|
|
284
316
|
let updated;
|
|
@@ -290,6 +322,7 @@ function run(args) {
|
|
|
290
322
|
if (updated.pack) console.log(` Skill pack: ${updated.pack}`);
|
|
291
323
|
console.log(` Risk: ${updated.risk || "(undeclared โ treated as write)"}`);
|
|
292
324
|
if (updated.requires.length) console.log(` Requires: ${updated.requires.join(", ")}`);
|
|
325
|
+
if (updated.tags && updated.tags.length) console.log(` Tags: ${updated.tags.join(", ")}`);
|
|
293
326
|
if (updated.usage) console.log(` Usage: ${updated.usage}`);
|
|
294
327
|
reportSafety(updated);
|
|
295
328
|
break;
|
|
@@ -317,7 +350,9 @@ function run(args) {
|
|
|
317
350
|
|
|
318
351
|
// Risk-tier gate: write needs --yes, destructive needs --yes-destructive.
|
|
319
352
|
// The flag is passed through to the tool (which may have its own gate).
|
|
320
|
-
|
|
353
|
+
// Nested runs (a tool calling this tool) gate on the tier the OUTERMOST
|
|
354
|
+
// invocation approved instead โ argv flags can't fabricate approval.
|
|
355
|
+
const gate = tools.runGate(t, toolArgs, process.env);
|
|
321
356
|
if (!gate.ok) { console.error(gate.message); process.exitCode = 3; return; }
|
|
322
357
|
|
|
323
358
|
const missing = tools.missingRequires(t);
|
|
@@ -325,8 +360,17 @@ function run(args) {
|
|
|
325
360
|
console.error(`Cannot run ${t.name}: missing keyring keys ${missing.join(", ")}. Set them with 'open-claudia keyring set <name> <value>'.`);
|
|
326
361
|
process.exitCode = 1; return;
|
|
327
362
|
}
|
|
363
|
+
// Composition inheritance: export the approved tier + call chain so any
|
|
364
|
+
// tool this tool shells into is gated by what the human actually approved
|
|
365
|
+
// at the top โ an already-inherited tier passes through UNCHANGED.
|
|
366
|
+
const env = tools.runEnv(t);
|
|
367
|
+
const parentStack = String(process.env.OPEN_CLAUDIA_TOOL_STACK || "").trim();
|
|
368
|
+
env.OPEN_CLAUDIA_APPROVED_TIER = parentStack
|
|
369
|
+
? (tools.normalizeRisk(process.env.OPEN_CLAUDIA_APPROVED_TIER) || "read-only")
|
|
370
|
+
: tools.approvedTierFromArgv(toolArgs);
|
|
371
|
+
env.OPEN_CLAUDIA_TOOL_STACK = parentStack ? `${parentStack}>${t.name}` : t.name;
|
|
328
372
|
const started = Date.now();
|
|
329
|
-
const r = spawnSync(t.file, toolArgs, { stdio: "inherit", env
|
|
373
|
+
const r = spawnSync(t.file, toolArgs, { stdio: "inherit", env });
|
|
330
374
|
const ms = Date.now() - started;
|
|
331
375
|
if (r.error) {
|
|
332
376
|
tools.recordRunResult(t.name, { args: toolArgs, exit: -1, ms });
|
|
@@ -348,7 +392,7 @@ function run(args) {
|
|
|
348
392
|
const { positional, flags } = parseFlags(rest);
|
|
349
393
|
const name = positional[0];
|
|
350
394
|
if (!name) {
|
|
351
|
-
console.error('Usage: tool scaffold <name> [--pack dir] [--desc "..."] [--risk read-only|write|destructive] [--requires "k1,k2"] [--usage "..."] [--lang node|bash]');
|
|
395
|
+
console.error('Usage: tool scaffold <name> [--pack dir] [--desc "..."] [--risk read-only|write|destructive] [--requires "k1,k2"] [--tags "group"] [--usage "..."] [--lang node|bash]');
|
|
352
396
|
process.exitCode = 1; return;
|
|
353
397
|
}
|
|
354
398
|
try {
|
|
@@ -357,6 +401,7 @@ function run(args) {
|
|
|
357
401
|
description: typeof flags.desc === "string" ? flags.desc : (typeof flags.description === "string" ? flags.description : undefined),
|
|
358
402
|
risk: typeof flags.risk === "string" ? flags.risk : undefined,
|
|
359
403
|
requires: typeof flags.requires === "string" ? flags.requires.split(/[,\s]+/).filter(Boolean) : [],
|
|
404
|
+
tags: typeof flags.tags === "string" ? flags.tags.split(/[,\s]+/).filter(Boolean) : [],
|
|
360
405
|
usage: typeof flags.usage === "string" ? flags.usage : undefined,
|
|
361
406
|
lang: typeof flags.lang === "string" ? flags.lang : "node",
|
|
362
407
|
});
|
|
@@ -410,6 +455,60 @@ function run(args) {
|
|
|
410
455
|
break;
|
|
411
456
|
}
|
|
412
457
|
|
|
458
|
+
case "lib": {
|
|
459
|
+
const sub = (rest[0] || "list").toLowerCase();
|
|
460
|
+
const libArgs = rest.slice(1);
|
|
461
|
+
if (sub === "list" || sub === "ls") {
|
|
462
|
+
const libs = tools.listLib();
|
|
463
|
+
if (!libs.length) {
|
|
464
|
+
console.log(`No shared _lib modules yet (${tools.LIB_DIR}).`);
|
|
465
|
+
console.log('Add one: open-claudia tool lib set <name.js> <src-path> โ for plumbing 2+ tools share (central auth, mongo-exec).');
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
console.log(`${libs.length} shared _lib module(s) โ imported by tools via a literal "_lib/<name>" path:\n`);
|
|
469
|
+
for (const l of libs) {
|
|
470
|
+
const deps = tools.libDependents(l.name);
|
|
471
|
+
console.log(`โข ${l.name} (${l.size} bytes, ${l.updatedAt.slice(0, 10)})${deps.length ? ` โ used by: ${deps.join(", ")}` : " โ no dependents yet"}`);
|
|
472
|
+
}
|
|
473
|
+
console.log(`\nnode: require(\`\${__dirname}/../_lib/<name>.js\`) ยท bash: source "$(dirname "$0")/../_lib/<name>.sh"`);
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
if (sub === "show" || sub === "cat") {
|
|
477
|
+
const name = libArgs[0];
|
|
478
|
+
if (!name) { console.error("Usage: tool lib show <name>"); process.exitCode = 1; return; }
|
|
479
|
+
const content = tools.readLib(name);
|
|
480
|
+
if (content === null) { console.error(`No _lib module "${name}". Run: open-claudia tool lib list`); process.exitCode = 1; return; }
|
|
481
|
+
const deps = tools.libDependents(name);
|
|
482
|
+
console.log(`_lib/${name} โ used by: ${deps.length ? deps.join(", ") : "(no dependents yet)"}\n`);
|
|
483
|
+
console.log(content);
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
if (sub === "set" || sub === "add") {
|
|
487
|
+
const name = libArgs[0];
|
|
488
|
+
const srcPath = libArgs[1];
|
|
489
|
+
if (!name || !srcPath) { console.error("Usage: tool lib set <name.js> <src-path>"); process.exitCode = 1; return; }
|
|
490
|
+
let content;
|
|
491
|
+
try { content = require("fs").readFileSync(srcPath, "utf-8"); }
|
|
492
|
+
catch (e) { console.error(`Cannot read ${srcPath}: ${e.message}`); process.exitCode = 1; return; }
|
|
493
|
+
try {
|
|
494
|
+
const r = repo.setLib(name, content);
|
|
495
|
+
console.log(`${r.existed ? "Updated" : "Added"} _lib/${r.name}.`);
|
|
496
|
+
if (r.dependents.length) {
|
|
497
|
+
console.log(`Dependents journaled as unverified until their next real use: ${r.dependents.join(", ")}.`);
|
|
498
|
+
console.log("Re-verify each by simply using it โ a green run at point of use is the health check.");
|
|
499
|
+
} else {
|
|
500
|
+
console.log("No dependents yet โ tools import it via a literal \"_lib/" + r.name.replace(/\.[^.]+$/, "") + "\" path.");
|
|
501
|
+
}
|
|
502
|
+
} catch (e) {
|
|
503
|
+
console.error(`Could not set _lib module: ${e.message}`); process.exitCode = 1;
|
|
504
|
+
}
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
console.error("Usage: tool lib [list | show <name> | set <name.js> <src-path>]");
|
|
508
|
+
process.exitCode = 1;
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
|
|
413
512
|
case "migrate": {
|
|
414
513
|
const { migrated, skipped } = repo.migrateLegacyTools();
|
|
415
514
|
if (!migrated.length && !skipped.length) console.log("Nothing to migrate โ all tools already use the directory layout.");
|
|
@@ -432,7 +531,7 @@ function run(args) {
|
|
|
432
531
|
break;
|
|
433
532
|
|
|
434
533
|
default:
|
|
435
|
-
console.log('Usage: open-claudia tool [list | search <query> | show <name> [--source] | scaffold <name> [flags] | add <path> [flags] | set <name> [flags] | note <name> [flags] | edit <name> | run <name> [args] | migrate | remove <name> | path]');
|
|
534
|
+
console.log('Usage: open-claudia tool [list | search <query> | show <name> [--source] | scaffold <name> [flags] | add <path> [flags] | set <name> [flags] | note <name> [flags] | edit <name> | run <name> [args] | lib [list|show|set] | migrate | remove <name> | path]');
|
|
436
535
|
}
|
|
437
536
|
}
|
|
438
537
|
|
package/core/runner.js
CHANGED
|
@@ -988,9 +988,14 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
988
988
|
try {
|
|
989
989
|
for (const run of require("./tool-graph").parseToolRuns(cmd)) {
|
|
990
990
|
toolRunsThisTurn.push(run);
|
|
991
|
-
// Visibility (gated by /tooltrace): one ๐ง line per distinct
|
|
992
|
-
// this turn
|
|
993
|
-
|
|
991
|
+
// Visibility (gated by /tooltrace): one ๐ง line per distinct
|
|
992
|
+
// tool+verb this turn โ sanitized command shape plus the verb's own
|
|
993
|
+
// doc sentence (verbDoc: parsed mechanically from the tool header,
|
|
994
|
+
// zero model cost). Telemetry (recordRun) fires at end-of-turn.
|
|
995
|
+
const verb = (String(run.shape || "").split(/\s+/)[1] || "");
|
|
996
|
+
let doc = "";
|
|
997
|
+
try { doc = toolsLib.verbDoc(run.name, verb); } catch (e) {}
|
|
998
|
+
notifyToolTrace(`ran:${run.name}:${verb}`, `๐ง ${run.shape || run.name}${doc ? ` โ ${doc}` : ""}`);
|
|
994
999
|
}
|
|
995
1000
|
} catch (e) { /* graph optional (old node) */ }
|
|
996
1001
|
// `open-claudia tool add <src> [--name X]` โ a tool being saved/updated.
|
package/core/system-prompt.js
CHANGED
|
@@ -98,7 +98,8 @@ Raw shell one-liners and heredocs are for probing and diagnosis only โ never t
|
|
|
98
98
|
- Tools carry their own memory: TOOL.md (Interface ยท Known issues ยท State ยท Journal) is the manual, state.json records every run (per-verb health), and every change is a git commit. Keep TOOL.md truthful as you work; update the tool in place when behaviour changes.
|
|
99
99
|
- Preauth, least-privilege: keyring creds are injected ONLY into \`tool run\` subprocesses, and only the keys a tool declares via \`--requires\` (\`open-claudia keyring list\` shows names) โ your own shell and sub-agents hold NO creds. Reference creds as \`$inet_central_user\` etc. inside tool sources and declare every one; never write a secret into a tool file (saves are refused).
|
|
100
100
|
- Deny-gate: raw shell commands that act on known production surfaces (platform APIs, prod hosts, in-cluster mongo, GitLab host shell) are blocked mid-flight with a redirect to the covering tool โ that block is the system working, not an obstacle to route around. Genuine one-off no tool covers yet? \`open-claudia keyring exec --reason "<why>" -- <cmd>\` runs with full creds and logs the bypass for audit; scaffold the tool if you'd ever run it twice.
|
|
101
|
-
-
|
|
101
|
+
- Composition: a tool may shell into other tools (report โ kticket + kchat). Approval is inherited, never fabricated โ a nested callee runs at most at the tier YOUR outer \`tool run\` flags acknowledged, so pass --yes/--yes-destructive on the outer call (after user approval) when the composition writes. Shared plumbing lives in \`_lib/\` (\`tool lib list|show|set\`); a lib change marks each dependent "unverified since _lib change" until its next real use.
|
|
102
|
+
- Announce in one line when you scaffold or change a tool, same as packs. Full CLI: \`open-claudia tool list|search|show|scaffold|add|set|note|edit|run|lib|migrate|remove\`.
|
|
102
103
|
${listing}`;
|
|
103
104
|
}
|
|
104
105
|
|
package/core/tool-repo.js
CHANGED
|
@@ -64,6 +64,32 @@ function commitTool(name, message) {
|
|
|
64
64
|
return git(["commit", "-q", "-m", message], { ident: true }).ok;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Write/update a shared _lib module, then journal every dependent tool as
|
|
68
|
+
// "unverified since _lib change" and commit the lot in ONE commit whose message
|
|
69
|
+
// names the dependents (PLAN ยง3.5: a _lib change can break several tools at
|
|
70
|
+
// once; each is re-verified by its next real use, not by smoke tests).
|
|
71
|
+
// Commits with target "." โ sanitizeName would mangle "_lib", and the commit
|
|
72
|
+
// must carry the dependents' TOOL.md marks anyway.
|
|
73
|
+
function setLib(name, content) {
|
|
74
|
+
const tools = require("./tools");
|
|
75
|
+
ensureRepo();
|
|
76
|
+
const existed = tools.readLib(name) !== null;
|
|
77
|
+
const { name: libName, file } = tools.writeLib(name, content);
|
|
78
|
+
const dependents = tools.libDependents(libName);
|
|
79
|
+
for (const dep of dependents) {
|
|
80
|
+
const t = tools.findTool(dep);
|
|
81
|
+
if (!t || !t.docFile) continue;
|
|
82
|
+
let doc = tools.readToolDoc(dep) || toolMdSkeleton(t);
|
|
83
|
+
const line = `- [${today()}] unverified since _lib change (${libName}) โ re-verified by next real use.`;
|
|
84
|
+
const re = /(##\s*Journal[^\n]*\n)/i;
|
|
85
|
+
doc = re.test(doc) ? doc.replace(re, `$1${line}\n`) : doc + `\n## Journal\n${line}\n`;
|
|
86
|
+
try { fs.writeFileSync(t.docFile, doc, { mode: 0o600 }); } catch (e) { /* journal is best-effort */ }
|
|
87
|
+
}
|
|
88
|
+
const depNote = dependents.length ? `dependents: ${dependents.join(", ")} (unverified until next use)` : "no dependents yet";
|
|
89
|
+
commitTool(null, `lib ${libName}: ${existed ? "update" : "add"} โ ${depNote}`);
|
|
90
|
+
return { name: libName, file, dependents, existed };
|
|
91
|
+
}
|
|
92
|
+
|
|
67
93
|
// Recent history for one tool โ surfaced by `tool show` as the upgrade log.
|
|
68
94
|
function toolLog(name, limit = 5) {
|
|
69
95
|
const n = require("./tools").sanitizeName(name);
|
|
@@ -98,13 +124,14 @@ function toolMdSkeleton({ name, description, usage, risk, requires, pack, stateL
|
|
|
98
124
|
].join("\n");
|
|
99
125
|
}
|
|
100
126
|
|
|
101
|
-
function nodeStub({ name, description, pack, risk, requires, usage }) {
|
|
127
|
+
function nodeStub({ name, description, pack, risk, requires, usage, tags }) {
|
|
102
128
|
const req = requires && requires.length ? `// requires: ${requires.join(", ")}\n` : "";
|
|
129
|
+
const tag = tags && tags.length ? `// tags: ${tags.join(", ")}\n` : "";
|
|
103
130
|
return `#!/usr/bin/env node
|
|
104
131
|
// open-claudia-tool: ${name}
|
|
105
132
|
// description: ${description || ""}
|
|
106
133
|
${pack ? `// pack: ${pack}\n` : ""}// risk: ${risk}
|
|
107
|
-
${req}// usage: ${usage || `${name} <verb> [args]`}
|
|
134
|
+
${req}${tag}// usage: ${usage || `${name} <verb> [args]`}
|
|
108
135
|
//
|
|
109
136
|
// One CLI per system, verbs as subcommands. Add a function per verb below.
|
|
110
137
|
// Confirmation flags (--yes / --yes-destructive) are enforced by the tool
|
|
@@ -127,13 +154,14 @@ Promise.resolve(fn(args.slice(1))).catch((e) => { console.error(e.message || e);
|
|
|
127
154
|
`;
|
|
128
155
|
}
|
|
129
156
|
|
|
130
|
-
function bashStub({ name, description, pack, risk, requires, usage }) {
|
|
157
|
+
function bashStub({ name, description, pack, risk, requires, usage, tags }) {
|
|
131
158
|
const req = requires && requires.length ? `# requires: ${requires.join(", ")}\n` : "";
|
|
159
|
+
const tag = tags && tags.length ? `# tags: ${tags.join(", ")}\n` : "";
|
|
132
160
|
return `#!/usr/bin/env bash
|
|
133
161
|
# open-claudia-tool: ${name}
|
|
134
162
|
# description: ${description || ""}
|
|
135
163
|
${pack ? `# pack: ${pack}\n` : ""}# risk: ${risk}
|
|
136
|
-
${req}# usage: ${usage || `${name} <verb> [args]`}
|
|
164
|
+
${req}${tag}# usage: ${usage || `${name} <verb> [args]`}
|
|
137
165
|
#
|
|
138
166
|
# One CLI per system, verbs as subcommands: add a case per verb below.
|
|
139
167
|
# Confirmation flags (--yes / --yes-destructive) are enforced by the tool
|
|
@@ -163,7 +191,8 @@ function scaffoldTool(name, opts = {}) {
|
|
|
163
191
|
if (tools.findTool(n)) throw new Error(`tool "${n}" already exists โ extend it (tool edit ${n}) instead of re-scaffolding`);
|
|
164
192
|
const risk = tools.normalizeRisk(opts.risk) || "write";
|
|
165
193
|
const requires = [].concat(opts.requires || []).filter(Boolean);
|
|
166
|
-
const
|
|
194
|
+
const tags = [].concat(opts.tags || []).map((s) => tools.sanitizeName(s)).filter(Boolean);
|
|
195
|
+
const spec = { name: n, description: opts.description || "", pack: opts.pack || "", risk, requires, tags, usage: opts.usage || "" };
|
|
167
196
|
|
|
168
197
|
ensureRepo();
|
|
169
198
|
const dir = path.join(toolsDir(), n);
|
|
@@ -267,4 +296,4 @@ function hasLegacyTools() {
|
|
|
267
296
|
return false;
|
|
268
297
|
}
|
|
269
298
|
|
|
270
|
-
module.exports = { ensureRepo, commitTool, toolLog, scaffoldTool, migrateLegacyTools, hasLegacyTools, toolMdSkeleton };
|
|
299
|
+
module.exports = { ensureRepo, commitTool, setLib, toolLog, scaffoldTool, migrateLegacyTools, hasLegacyTools, toolMdSkeleton };
|
package/core/tools.js
CHANGED
|
@@ -36,7 +36,7 @@ const TOOLS_DIR = process.env.TOOLS_DIR ? path.resolve(process.env.TOOLS_DIR) :
|
|
|
36
36
|
// parse from the leading comment block. Language-agnostic: we accept "#" (sh,
|
|
37
37
|
// python, ruby) and "//" (js, ts, go) comment prefixes.
|
|
38
38
|
const MARKER = "open-claudia-tool";
|
|
39
|
-
const HEADER_KEYS = ["description", "pack", "requires", "usage", "risk"];
|
|
39
|
+
const HEADER_KEYS = ["description", "pack", "requires", "usage", "risk", "tags"];
|
|
40
40
|
const RISK_TIERS = ["read-only", "write", "destructive"];
|
|
41
41
|
|
|
42
42
|
// Normalise a risk value to one of RISK_TIERS ("" when unrecognised). Blast
|
|
@@ -219,7 +219,7 @@ function matchTools(text, { limit = 5, threshold = null } = {}) {
|
|
|
219
219
|
const min = threshold ?? Number(process.env.TOOL_MATCH_THRESHOLD || 2);
|
|
220
220
|
const out = [];
|
|
221
221
|
for (const t of listTools()) {
|
|
222
|
-
const strong = wordsOf([t.name, t.description, t.usage].filter(Boolean).join(" "));
|
|
222
|
+
const strong = wordsOf([t.name, t.description, t.usage, (t.tags || []).join(" ")].filter(Boolean).join(" "));
|
|
223
223
|
const weak = wordsOf([(t.requires || []).join(" "), t.pack, t.content].filter(Boolean).join(" "));
|
|
224
224
|
let score = 0;
|
|
225
225
|
for (const term of terms) {
|
|
@@ -244,7 +244,7 @@ function uncomment(line) {
|
|
|
244
244
|
// script is only a tool if the block contains an "open-claudia-tool: <name>"
|
|
245
245
|
// line.
|
|
246
246
|
function parseHeader(content) {
|
|
247
|
-
const out = { name: "", description: "", pack: "", requires: [], usage: "", risk: "" };
|
|
247
|
+
const out = { name: "", description: "", pack: "", requires: [], usage: "", risk: "", tags: [] };
|
|
248
248
|
const lines = String(content || "").split("\n");
|
|
249
249
|
let started = false;
|
|
250
250
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -264,11 +264,76 @@ function parseHeader(content) {
|
|
|
264
264
|
if (key === MARKER) out.name = sanitizeName(val);
|
|
265
265
|
else if (key === "requires") out.requires = val.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
266
266
|
else if (key === "risk") out.risk = normalizeRisk(val);
|
|
267
|
+
else if (key === "tags") out.tags = val.split(/[,\s]+/).map((s) => sanitizeName(s)).filter(Boolean);
|
|
267
268
|
else if (HEADER_KEYS.includes(key)) out[key] = val;
|
|
268
269
|
}
|
|
269
270
|
return out.name ? out : null;
|
|
270
271
|
}
|
|
271
272
|
|
|
273
|
+
// First readable clause of a doc string: cut at sentence end or " โ ",
|
|
274
|
+
// collapse whitespace, cap length. Used for one-line verb docs in chat.
|
|
275
|
+
function firstClause(s) {
|
|
276
|
+
let t = String(s || "").replace(/\s+/g, " ").trim();
|
|
277
|
+
if (!t) return "";
|
|
278
|
+
const m = t.match(/^(.{10,}?[.!?])(?:\s|$)/);
|
|
279
|
+
if (m) t = m[1].replace(/[.!?]+$/, "");
|
|
280
|
+
else t = t.split(" โ ")[0];
|
|
281
|
+
if (t.length > 90) t = t.slice(0, 87).trimEnd() + "โฆ";
|
|
282
|
+
return t;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// One human-readable sentence for a tool verb, parsed mechanically from the
|
|
286
|
+
// tool's own leading comment block (the same docs --help prints) โ zero model
|
|
287
|
+
// cost, always in sync with the source. Two documented shapes are recognised:
|
|
288
|
+
// // kticket list [--stage a,b] โ verb line; description on the
|
|
289
|
+
// // List tickets. --q matches โฆ deeper-indented line(s) below
|
|
290
|
+
// // inet-central users [regex] list users; same-line prose after 2+ spaces
|
|
291
|
+
// Falls back to the tool description's first clause; "" when the tool is unknown.
|
|
292
|
+
function verbDoc(name, verb) {
|
|
293
|
+
let content;
|
|
294
|
+
try { content = fs.readFileSync(toolPaths(name).exec, "utf-8"); } catch (e) { return ""; }
|
|
295
|
+
const header = parseHeader(content);
|
|
296
|
+
if (!header) return "";
|
|
297
|
+
const fallback = firstClause(header.description);
|
|
298
|
+
const v = String(verb || "").trim();
|
|
299
|
+
if (!v || v.startsWith("-") || v.startsWith("<")) return fallback;
|
|
300
|
+
// Re-walk the leading comment block (same bounds as parseHeader).
|
|
301
|
+
const lines = String(content).split("\n");
|
|
302
|
+
const block = [];
|
|
303
|
+
let started = false;
|
|
304
|
+
for (let i = 0; i < lines.length; i++) {
|
|
305
|
+
if (i === 0 && lines[i].startsWith("#!")) continue;
|
|
306
|
+
const body = uncomment(lines[i]);
|
|
307
|
+
if (body === null) {
|
|
308
|
+
if (started) break;
|
|
309
|
+
if (lines[i].trim() === "") continue;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
started = true;
|
|
313
|
+
block.push(body);
|
|
314
|
+
}
|
|
315
|
+
const vEsc = v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
316
|
+
const verbLine = new RegExp(`^(\\s+)\\S+\\s+${vEsc}\\b(.*)$`);
|
|
317
|
+
for (let i = 0; i < block.length; i++) {
|
|
318
|
+
const m = block[i].match(verbLine);
|
|
319
|
+
if (!m) continue;
|
|
320
|
+
const same = m[2].match(/\s{2,}([A-Za-z].*)$/);
|
|
321
|
+
if (same) return firstClause(same[1]);
|
|
322
|
+
const indent = m[1].length;
|
|
323
|
+
let desc = "";
|
|
324
|
+
for (let j = i + 1; j < block.length; j++) {
|
|
325
|
+
const ind = (block[j].match(/^\s*/) || [""])[0].length;
|
|
326
|
+
const text = block[j].trim();
|
|
327
|
+
if (!text || ind <= indent) break;
|
|
328
|
+
desc += (desc ? " " : "") + text;
|
|
329
|
+
if (/[.!?]$/.test(text)) break;
|
|
330
|
+
}
|
|
331
|
+
if (desc) return firstClause(desc);
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
return fallback;
|
|
335
|
+
}
|
|
336
|
+
|
|
272
337
|
// Path of a tool's executable (legacy signature kept: several callers treat
|
|
273
338
|
// "the tool file" as the thing you run/edit).
|
|
274
339
|
function toolFile(name) {
|
|
@@ -293,6 +358,7 @@ function readTool(name, usageMap) {
|
|
|
293
358
|
requires: header.requires || [],
|
|
294
359
|
usage: header.usage || "",
|
|
295
360
|
risk: header.risk || "",
|
|
361
|
+
tags: header.tags || [],
|
|
296
362
|
layout: p.layout,
|
|
297
363
|
file: p.exec,
|
|
298
364
|
docFile: p.doc,
|
|
@@ -356,7 +422,7 @@ function listTools() {
|
|
|
356
422
|
const usage = readUsage();
|
|
357
423
|
const tools = [];
|
|
358
424
|
for (const name of entries) {
|
|
359
|
-
if (name.startsWith(".")) continue;
|
|
425
|
+
if (name.startsWith(".") || name.startsWith("_")) continue; // _lib etc. โ plumbing, not tools
|
|
360
426
|
const full = path.join(TOOLS_DIR, name);
|
|
361
427
|
let st;
|
|
362
428
|
try { st = fs.statSync(full); } catch (e) { continue; }
|
|
@@ -413,14 +479,75 @@ function docDrift(t) {
|
|
|
413
479
|
return null;
|
|
414
480
|
}
|
|
415
481
|
|
|
482
|
+
// โโ _lib shared plumbing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
483
|
+
// Genuinely shared code (central auth, mongo-exec) lives in tools/_lib โ tiny
|
|
484
|
+
// modules IMPORTED by tools, never tools themselves (listTools skips the "_"
|
|
485
|
+
// prefix, so _lib never appears in the registry or surfacing index). Import
|
|
486
|
+
// with a literal "_lib/<name>" path so dependents stay grep-detectable:
|
|
487
|
+
// node: require(`${__dirname}/../_lib/<name>.js`)
|
|
488
|
+
// bash: source "$(dirname "$0")/../_lib/<name>.sh"
|
|
489
|
+
// A _lib change can break several tools at once, so every change is committed
|
|
490
|
+
// with its dependents named and each dependent journaled "unverified since
|
|
491
|
+
// _lib change" โ re-verified by its next real use (no smoke tests, by design).
|
|
492
|
+
const LIB_DIR = path.join(TOOLS_DIR, "_lib");
|
|
493
|
+
|
|
494
|
+
function libPath(name) {
|
|
495
|
+
const base = path.basename(String(name || "").trim());
|
|
496
|
+
if (!base || base.startsWith(".")) throw new Error(`invalid lib name "${name}"`);
|
|
497
|
+
return path.join(LIB_DIR, base);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function listLib() {
|
|
501
|
+
let entries;
|
|
502
|
+
try { entries = fs.readdirSync(LIB_DIR); } catch (e) { return []; }
|
|
503
|
+
const out = [];
|
|
504
|
+
for (const name of entries) {
|
|
505
|
+
if (name.startsWith(".")) continue;
|
|
506
|
+
try {
|
|
507
|
+
const st = fs.statSync(path.join(LIB_DIR, name));
|
|
508
|
+
if (st.isFile()) out.push({ name, file: path.join(LIB_DIR, name), size: st.size, updatedAt: st.mtime.toISOString() });
|
|
509
|
+
} catch (e) { /* unreadable entry โ skip */ }
|
|
510
|
+
}
|
|
511
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function readLib(name) {
|
|
516
|
+
try { return fs.readFileSync(libPath(name), "utf-8"); } catch (e) { return null; }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function writeLib(name, content) {
|
|
520
|
+
const file = libPath(name);
|
|
521
|
+
assertNoSecrets(content, `_lib/${path.basename(file)}`);
|
|
522
|
+
fs.mkdirSync(LIB_DIR, { recursive: true, mode: 0o700 });
|
|
523
|
+
fs.writeFileSync(file, content, { mode: 0o600 });
|
|
524
|
+
return { name: path.basename(file), file };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Which registered tools import a given lib โ a deterministic source grep for
|
|
528
|
+
// the "_lib/<stem>" reference shape (tolerates path.join'd segments), so a lib
|
|
529
|
+
// change can name exactly the tools it may have broken. Extension-agnostic but
|
|
530
|
+
// boundary-safe: "central-auth" matches _lib/central-auth.js, not
|
|
531
|
+
// _lib/central-auth-v2.js.
|
|
532
|
+
function libDependents(name) {
|
|
533
|
+
const base = path.basename(String(name || "").trim());
|
|
534
|
+
if (!base) return [];
|
|
535
|
+
const stem = base.replace(/\.[^.]+$/, "");
|
|
536
|
+
const esc = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
537
|
+
const re = new RegExp(`_lib[/\\\\"'\\s,]{1,5}["']?${esc}(?:\\.[A-Za-z0-9]+)?(?![A-Za-z0-9._-])`);
|
|
538
|
+
return listTools().filter((t) => re.test(t.content || "")).map((t) => t.name);
|
|
539
|
+
}
|
|
540
|
+
|
|
416
541
|
// Build the header comment block for a script that doesn't already declare one.
|
|
417
542
|
// Uses the comment prefix that matches the script's shebang/extension.
|
|
418
|
-
function buildHeader(prefix, { name, description, pack, requires, usage, risk }) {
|
|
543
|
+
function buildHeader(prefix, { name, description, pack, requires, usage, risk, tags }) {
|
|
419
544
|
const lines = [`${prefix} ${MARKER}: ${name}`];
|
|
420
545
|
if (description) lines.push(`${prefix} description: ${description}`);
|
|
421
546
|
if (pack) lines.push(`${prefix} pack: ${pack}`);
|
|
422
547
|
if (risk) lines.push(`${prefix} risk: ${risk}`);
|
|
423
548
|
if (requires && requires.length) lines.push(`${prefix} requires: ${[].concat(requires).join(", ")}`);
|
|
549
|
+
const cleanTags = [].concat(tags || []).map((s) => sanitizeName(s)).filter(Boolean);
|
|
550
|
+
if (cleanTags.length) lines.push(`${prefix} tags: ${cleanTags.join(", ")}`);
|
|
424
551
|
if (usage) lines.push(`${prefix} usage: ${usage}`);
|
|
425
552
|
return lines.join("\n");
|
|
426
553
|
}
|
|
@@ -549,6 +676,7 @@ function addTool(srcPath, opts = {}) {
|
|
|
549
676
|
pack: opts.pack || "",
|
|
550
677
|
risk,
|
|
551
678
|
requires: opts.requires || [],
|
|
679
|
+
tags: opts.tags || [],
|
|
552
680
|
usage: opts.usage || "",
|
|
553
681
|
});
|
|
554
682
|
const hasShebang = content.startsWith("#!");
|
|
@@ -565,6 +693,7 @@ function addTool(srcPath, opts = {}) {
|
|
|
565
693
|
content = content.replace(new RegExp(`((#|//)\\s*${MARKER}:.*\\n)`), `$1${line}\n`);
|
|
566
694
|
};
|
|
567
695
|
if (opts.pack && !header.pack) injectAfterMarker(`${prefix} pack: ${opts.pack}`);
|
|
696
|
+
if (opts.tags && opts.tags.length && !header.tags.length) injectAfterMarker(`${prefix} tags: ${[].concat(opts.tags).map((s) => sanitizeName(s)).filter(Boolean).join(", ")}`);
|
|
568
697
|
if (!header.risk) {
|
|
569
698
|
const risk = optRisk || "write";
|
|
570
699
|
if (!optRisk) defaultedRisk = true;
|
|
@@ -635,6 +764,9 @@ function updateToolHeader(name, opts = {}) {
|
|
|
635
764
|
}
|
|
636
765
|
let requires = opts.requires !== undefined ? opts.requires : t.requires;
|
|
637
766
|
if (!Array.isArray(requires)) requires = String(requires || "").split(/[,\s]+/).filter(Boolean);
|
|
767
|
+
let tags = opts.tags !== undefined ? opts.tags : t.tags;
|
|
768
|
+
if (!Array.isArray(tags)) tags = String(tags || "").split(/[,\s]+/).filter(Boolean);
|
|
769
|
+
tags = tags.map((s) => sanitizeName(s)).filter(Boolean);
|
|
638
770
|
|
|
639
771
|
const lines = t.content.split("\n");
|
|
640
772
|
let headStart = 0;
|
|
@@ -660,6 +792,7 @@ function updateToolHeader(name, opts = {}) {
|
|
|
660
792
|
if (pack) fieldLines.push(`${prefix} pack: ${pack}`);
|
|
661
793
|
if (risk) fieldLines.push(`${prefix} risk: ${risk}`);
|
|
662
794
|
if (requires.length) fieldLines.push(`${prefix} requires: ${requires.join(", ")}`);
|
|
795
|
+
if (tags.length) fieldLines.push(`${prefix} tags: ${tags.join(", ")}`);
|
|
663
796
|
if (usage) fieldLines.push(`${prefix} usage: ${usage}`);
|
|
664
797
|
|
|
665
798
|
let newBlock;
|
|
@@ -680,9 +813,38 @@ function updateToolHeader(name, opts = {}) {
|
|
|
680
813
|
// unlock it) and gets a spelled-out warning. An undeclared tier is treated as
|
|
681
814
|
// write โ fail-safe until classified. The flag is the agent's deliberate
|
|
682
815
|
// confirmation step AFTER user approval; it never replaces asking the user.
|
|
683
|
-
|
|
816
|
+
//
|
|
817
|
+
// Composition (Phase 4): tools may shell into other tools. Approval flows ONLY
|
|
818
|
+
// from the outermost, human-approved invocation โ the runner exports the tier
|
|
819
|
+
// its flags acknowledged (OPEN_CLAUDIA_APPROVED_TIER) plus the call chain
|
|
820
|
+
// (OPEN_CLAUDIA_TOOL_STACK) into the child env, and a nested run gates on that
|
|
821
|
+
// inherited tier while IGNORING its own argv flags, so a composing tool cannot
|
|
822
|
+
// fabricate approval by hardcoding --yes-destructive in its own source.
|
|
823
|
+
const TIER_RANK = { "read-only": 0, write: 1, destructive: 2 };
|
|
824
|
+
|
|
825
|
+
function approvedTierFromArgv(argv = []) {
|
|
826
|
+
if (argv.includes("--yes-destructive")) return "destructive";
|
|
827
|
+
if (argv.includes("--yes")) return "write";
|
|
828
|
+
return "read-only";
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function runGate(tool, argv = [], env = process.env) {
|
|
684
832
|
const declared = normalizeRisk(tool && tool.risk);
|
|
685
833
|
const tier = declared || "write";
|
|
834
|
+
const stack = String((env && env.OPEN_CLAUDIA_TOOL_STACK) || "").trim();
|
|
835
|
+
if (stack) {
|
|
836
|
+
// Nested run โ a tool called this tool.
|
|
837
|
+
if (stack.split(">").includes(tool.name)) {
|
|
838
|
+
return { ok: false, tier, nested: true, message: `โ Tool-call cycle: "${tool.name}" is already running in this chain (${stack}).` };
|
|
839
|
+
}
|
|
840
|
+
const approved = normalizeRisk(env.OPEN_CLAUDIA_APPROVED_TIER) || "read-only";
|
|
841
|
+
if (TIER_RANK[tier] <= TIER_RANK[approved]) return { ok: true, tier, nested: true };
|
|
842
|
+
return {
|
|
843
|
+
ok: false, tier, nested: true,
|
|
844
|
+
message: `โ "${tool.name}" is ${tier}-tier but was called by "${stack}", approved only for ${approved} actions.\n` +
|
|
845
|
+
`A composing tool cannot self-approve. Get the user's approval, then re-run the OUTER tool with ${tier === "destructive" ? "--yes-destructive" : "--yes"}.`,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
686
848
|
const note = declared ? "" : ` (no risk tier declared โ treated as write; classify: open-claudia tool set ${tool.name} --risk read-only|write|destructive)`;
|
|
687
849
|
if (tier === "read-only") return { ok: true, tier };
|
|
688
850
|
if (tier === "destructive") {
|
|
@@ -749,10 +911,11 @@ function toolNameFromPath(filePath) {
|
|
|
749
911
|
}
|
|
750
912
|
|
|
751
913
|
module.exports = {
|
|
752
|
-
TOOLS_DIR, MARKER, USAGE_FILE, RISK_TIERS,
|
|
753
|
-
parseHeader, readTool, readToolDoc, toolCondition, listTools, findTool, runsSince, docDrift, addTool, removeTool, updateToolHeader,
|
|
914
|
+
TOOLS_DIR, MARKER, USAGE_FILE, RISK_TIERS, LIB_DIR,
|
|
915
|
+
parseHeader, readTool, readToolDoc, toolCondition, verbDoc, listTools, findTool, runsSince, docDrift, addTool, removeTool, updateToolHeader,
|
|
754
916
|
runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir, normalizeRisk,
|
|
755
|
-
toolPaths, readState, writeState, recordRunResult, runGate,
|
|
917
|
+
toolPaths, readState, writeState, recordRunResult, runGate, approvedTierFromArgv,
|
|
918
|
+
listLib, readLib, writeLib, libDependents,
|
|
756
919
|
matchTools, recordRun, recordCreate, readUsage,
|
|
757
920
|
lintToolSource, checkSyntax, envRefsIn, assertNoSecrets,
|
|
758
921
|
};
|
package/package.json
CHANGED
package/test-tools.js
CHANGED
|
@@ -362,6 +362,110 @@ fs.utimesSync(tools.findTool("condtool").file, future, future);
|
|
|
362
362
|
assert.strictEqual(tools.docDrift(tools.findTool("condtool")), "source newer than TOOL.md", "source edited after docs is drift");
|
|
363
363
|
assert.strictEqual(tools.docDrift(null), null, "no tool โ no drift");
|
|
364
364
|
|
|
365
|
+
// โโ tags (Phase 4 umbrella grouping): round-trip through the header, settable,
|
|
366
|
+
// sanitised, and part of the STRONG match haystack โโ
|
|
367
|
+
const tagSrc = path.join(tmp, "kgadget.sh");
|
|
368
|
+
fs.writeFileSync(tagSrc, "#!/usr/bin/env bash\necho g\n");
|
|
369
|
+
const tagged = tools.addTool(tagSrc, { name: "kgadget", description: "gadget ops", risk: "read-only", tags: ["Kazee"] });
|
|
370
|
+
assert.deepStrictEqual(tagged.tags, ["kazee"], "tags parse back from the header, sanitised to lowercase");
|
|
371
|
+
assert.ok(/# tags: kazee/.test(tagged.content), "tags land as a header line");
|
|
372
|
+
assert.ok(tools.matchTools("kazee").some((m) => m.name === "kgadget"), "a tag term matches strongly");
|
|
373
|
+
const retagged = tools.updateToolHeader("kgadget", { tags: "kazee, infra" });
|
|
374
|
+
assert.deepStrictEqual(retagged.tags, ["kazee", "infra"], "updateToolHeader sets tags from a comma string");
|
|
375
|
+
assert.strictEqual((retagged.content.match(/# tags:/g) || []).length, 1, "tags line survives exactly once");
|
|
376
|
+
const scTagged = repo.scaffoldTool("ktagged", { description: "tagged scaffold", risk: "read-only", tags: ["infra"] });
|
|
377
|
+
assert.deepStrictEqual(scTagged.tags, ["infra"], "scaffold carries tags into the stub header");
|
|
378
|
+
|
|
379
|
+
// โโ _lib shared plumbing (Phase 4): invisible to the registry, secret-guarded,
|
|
380
|
+
// dependents found by a boundary-safe source grep โโ
|
|
381
|
+
tools.writeLib("central-auth.js", "module.exports = { login() { return process.env.inet_central_user; } };\n");
|
|
382
|
+
assert.ok(tools.readLib("central-auth.js").includes("login()"), "writeLib/readLib round-trip");
|
|
383
|
+
assert.ok(tools.listLib().some((l) => l.name === "central-auth.js"), "listLib sees the module");
|
|
384
|
+
assert.ok(!tools.listTools().some((t) => t.name.startsWith("_") || t.name === "lib"), "_lib never appears in the tool registry");
|
|
385
|
+
assert.strictEqual(tools.readLib("nope.js"), null, "missing lib reads as null");
|
|
386
|
+
assert.throws(() => tools.writeLib("leak.js", 'const k = "sk-ant-abcdefgh12345678";\n'), /secret/i, "a hardcoded secret blocks a lib save");
|
|
387
|
+
const trav = tools.writeLib("../escape.js", "// harmless\n");
|
|
388
|
+
assert.strictEqual(path.dirname(trav.file), tools.LIB_DIR, "path traversal is neutralised โ basename only, stays inside _lib");
|
|
389
|
+
assert.throws(() => tools.writeLib(".hidden", "x"), /invalid lib name/i, "dot-prefixed lib names are refused");
|
|
390
|
+
|
|
391
|
+
const depA = path.join(tmp, "authuser.sh");
|
|
392
|
+
fs.writeFileSync(depA, '#!/usr/bin/env bash\nsource "$(dirname "$0")/../_lib/central-auth.js"\n');
|
|
393
|
+
tools.addTool(depA, { name: "authuser", description: "uses central auth", risk: "read-only" });
|
|
394
|
+
const depB = path.join(tmp, "authjoin.js");
|
|
395
|
+
fs.writeFileSync(depB, '#!/usr/bin/env node\nconst lib = require(require("path").join(__dirname, "..", "_lib", "central-auth.js"));\n');
|
|
396
|
+
tools.addTool(depB, { name: "authjoin", description: "joins via central auth", risk: "read-only" });
|
|
397
|
+
const depC = path.join(tmp, "othertool.sh");
|
|
398
|
+
fs.writeFileSync(depC, '#!/usr/bin/env bash\nsource "$(dirname "$0")/../_lib/central-auth-v2.js"\n');
|
|
399
|
+
tools.addTool(depC, { name: "othertool", description: "uses the v2 lib", risk: "read-only" });
|
|
400
|
+
const deps = tools.libDependents("central-auth.js");
|
|
401
|
+
assert.ok(deps.includes("authuser"), "a literal _lib/<name> reference is a dependent");
|
|
402
|
+
assert.ok(deps.includes("authjoin"), "a path.join'd _lib reference is a dependent");
|
|
403
|
+
assert.ok(!deps.includes("othertool"), "central-auth-v2 is NOT a dependent of central-auth (boundary-safe)");
|
|
404
|
+
assert.deepStrictEqual(tools.libDependents("unused-lib.js"), [], "a lib nothing imports has no dependents");
|
|
405
|
+
|
|
406
|
+
// โโ setLib: one commit, every dependent journaled "unverified since _lib change" โโ
|
|
407
|
+
const libRes = repo.setLib("central-auth.js", "module.exports = { login() { return { user: process.env.inet_central_user }; } };\n");
|
|
408
|
+
assert.strictEqual(libRes.name, "central-auth.js", "setLib resolves the lib name");
|
|
409
|
+
assert.ok(libRes.existed, "setLib reports an update over an existing lib");
|
|
410
|
+
assert.deepStrictEqual(libRes.dependents.sort(), ["authjoin", "authuser"], "setLib names exactly the dependents");
|
|
411
|
+
assert.ok(/unverified since _lib change \(central-auth\.js\)/.test(tools.readToolDoc("authuser")), "dependent journaled as unverified");
|
|
412
|
+
assert.ok(/unverified since _lib change/.test(tools.readToolDoc("authjoin")), "second dependent journaled too");
|
|
413
|
+
assert.ok(!/unverified since _lib change/.test(tools.readToolDoc("othertool")), "a non-dependent is left alone");
|
|
414
|
+
|
|
415
|
+
// โโ nested tool-calls-tool gate (Phase 4): approval is inherited from the
|
|
416
|
+
// outermost human-approved invocation; a composing tool cannot fabricate it โโ
|
|
417
|
+
assert.strictEqual(tools.approvedTierFromArgv([]), "read-only", "no flags โ read-only approval");
|
|
418
|
+
assert.strictEqual(tools.approvedTierFromArgv(["--yes"]), "write", "--yes โ write approval");
|
|
419
|
+
assert.strictEqual(tools.approvedTierFromArgv(["--yes", "--yes-destructive"]), "destructive", "--yes-destructive wins");
|
|
420
|
+
const nestedEnv = (approved, stack) => ({ OPEN_CLAUDIA_APPROVED_TIER: approved, OPEN_CLAUDIA_TOOL_STACK: stack });
|
|
421
|
+
assert.ok(tools.runGate({ name: "kticket", risk: "write" }, [], nestedEnv("write", "report")).ok,
|
|
422
|
+
"a write callee runs under an inherited write approval โ no argv flag needed");
|
|
423
|
+
assert.ok(tools.runGate({ name: "kticket", risk: "read-only" }, [], nestedEnv("read-only", "report")).ok,
|
|
424
|
+
"a read-only callee always runs nested");
|
|
425
|
+
const fabricated = tools.runGate({ name: "wipe", risk: "destructive" }, ["--yes-destructive"], nestedEnv("write", "report"));
|
|
426
|
+
assert.ok(!fabricated.ok, "a composing tool cannot self-approve destructive with its own hardcoded flag");
|
|
427
|
+
assert.ok(/cannot self-approve/i.test(fabricated.message), "the refusal explains approval must come from the outer run");
|
|
428
|
+
assert.ok(!tools.runGate({ name: "kticket", risk: "write" }, ["--yes"], nestedEnv("read-only", "report")).ok,
|
|
429
|
+
"an outer read-only approval does not unlock a nested write, argv flags ignored");
|
|
430
|
+
assert.ok(tools.runGate({ name: "wipe", risk: "destructive" }, [], nestedEnv("destructive", "report")).ok,
|
|
431
|
+
"an outer --yes-destructive approval flows through to a destructive callee");
|
|
432
|
+
assert.ok(tools.runGate({ name: "wipe", risk: "" }, [], nestedEnv("write", "report")).ok,
|
|
433
|
+
"an undeclared callee tier is treated as write โ runs under an inherited write approval");
|
|
434
|
+
assert.ok(!tools.runGate({ name: "wipe", risk: "" }, [], nestedEnv("read-only", "report")).ok,
|
|
435
|
+
"an undeclared callee tier is refused under a read-only approval (fail-safe when nested too)");
|
|
436
|
+
const cycle = tools.runGate({ name: "report", risk: "read-only" }, [], nestedEnv("read-only", "weekly>report"));
|
|
437
|
+
assert.ok(!cycle.ok && /cycle/i.test(cycle.message), "a tool already in the call chain is refused (cycle guard)");
|
|
438
|
+
const topLevel = tools.runGate({ name: "kticket", risk: "write" }, ["--yes"], {});
|
|
439
|
+
assert.ok(topLevel.ok && !topLevel.nested, "no stack env โ the classic argv-flag gate applies");
|
|
440
|
+
|
|
441
|
+
// โโ verbDoc: one mechanical sentence per verb, parsed from the header docs โโ
|
|
442
|
+
// Covers both documented shapes (indented-description-below and same-line
|
|
443
|
+
// prose after 2+ spaces), the description fallback, and the unknown-tool "".
|
|
444
|
+
const srcV = path.join(tmp, "vdoc.js");
|
|
445
|
+
fs.writeFileSync(srcV, [
|
|
446
|
+
"#!/usr/bin/env node",
|
|
447
|
+
"// open-claudia-tool: vdoc",
|
|
448
|
+
"// description: Query the demo platform (demo.example) as operator โ login, list things, show one thing",
|
|
449
|
+
"// risk: read-only",
|
|
450
|
+
"// usage: vdoc <verb> [args]",
|
|
451
|
+
"//",
|
|
452
|
+
"// vdoc list [--stage a,b] [--json]",
|
|
453
|
+
"// List things. Filters are regexes and",
|
|
454
|
+
"// combine with AND.",
|
|
455
|
+
"// vdoc peek [regex] look at things; optional case-insensitive filter",
|
|
456
|
+
"// vdoc stages",
|
|
457
|
+
"console.log('hi');",
|
|
458
|
+
].join("\n"));
|
|
459
|
+
tools.addTool(srcV, { name: "vdoc" });
|
|
460
|
+
assert.strictEqual(tools.verbDoc("vdoc", "list"), "List things", "indented description below the verb line, cut at first sentence");
|
|
461
|
+
assert.strictEqual(tools.verbDoc("vdoc", "peek"), "look at things; optional case-insensitive filter", "same-line prose after 2+ spaces");
|
|
462
|
+
assert.strictEqual(tools.verbDoc("vdoc", "stages"), "Query the demo platform (demo.example) as operator",
|
|
463
|
+
"undocumented-body verb falls back to the description's first clause");
|
|
464
|
+
assert.strictEqual(tools.verbDoc("vdoc", ""), "Query the demo platform (demo.example) as operator", "no verb โ description clause");
|
|
465
|
+
assert.strictEqual(tools.verbDoc("vdoc", "--json"), "Query the demo platform (demo.example) as operator", "flag token is not a verb");
|
|
466
|
+
assert.strictEqual(tools.verbDoc("no-such-tool", "list"), "", "unknown tool โ empty string");
|
|
467
|
+
tools.removeTool("vdoc");
|
|
468
|
+
|
|
365
469
|
// โโ remove โโ
|
|
366
470
|
assert.ok(tools.removeTool("hello"), "removeTool returns the removed tool");
|
|
367
471
|
assert.strictEqual(tools.findTool("hello"), null, "removed tool is gone");
|