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