@inetafrica/open-claudia 3.4.24 → 3.5.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 +40 -0
- package/bin/agent.js +4 -0
- package/bin/cli.js +10 -1
- package/bin/entity.js +17 -15
- package/bin/ideas.js +10 -1
- package/bin/keyring.js +19 -18
- package/bin/lessons.js +10 -1
- package/bin/loopback-client.js +46 -1
- package/bin/owner-gate.js +38 -0
- package/bin/pack.js +11 -1
- package/bin/tool.js +77 -49
- package/bin/transcript-search.js +42 -8
- package/bin/transcript-window.js +31 -2
- package/core/actions.js +15 -4
- package/core/approvals.js +28 -2
- package/core/grants.js +156 -27
- package/core/handlers.js +8 -2
- package/core/loopback.js +224 -69
- package/core/providers/claude-hook.js +5 -0
- package/core/runner.js +45 -0
- package/core/scheduler.js +110 -2
- package/core/speaker-attest.js +82 -1
- package/core/tool-guard.js +380 -11
- package/package.json +2 -2
- package/project-transcripts.js +25 -0
- package/test-approval-async.js +22 -0
- package/test-approval-cards.js +40 -0
- package/test-provider-language.js +9 -1
- package/test-provider-scheduler.js +14 -0
- package/test-provider-tool-hooks.js +11 -0
- package/test-tool-guard.js +12 -3
- package/test-tool-manifest.js +31 -0
package/project-transcripts.js
CHANGED
|
@@ -136,9 +136,34 @@ class ProjectTranscripts {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// Who a transcript file belongs to, for the room-scoping guard in the search
|
|
140
|
+
// CLIs. Every project transcript is keyed by (canonicalUserId, projectPath) and
|
|
141
|
+
// records the owning canonicalUserId in its sidecar `.json` (and on each JSONL
|
|
142
|
+
// entry). Reading it back lets a non-owner CLI confirm a transcript it was
|
|
143
|
+
// pointed at is actually the caller's own — an env-forged OC_TRANSCRIPT_PATH
|
|
144
|
+
// aimed at someone else's file resolves to a different canonicalUserId and is
|
|
145
|
+
// refused. Returns null when ownership cannot be established (fail closed).
|
|
146
|
+
function transcriptOwnerOf(transcriptPath) {
|
|
147
|
+
const file = String(transcriptPath || "");
|
|
148
|
+
if (!file) return null;
|
|
149
|
+
const sidecar = file.endsWith(".jsonl") ? `${file.slice(0, -6)}.json` : `${file}.json`;
|
|
150
|
+
try {
|
|
151
|
+
const meta = JSON.parse(fs.readFileSync(sidecar, "utf8"));
|
|
152
|
+
if (meta && meta.canonicalUserId) return String(meta.canonicalUserId);
|
|
153
|
+
} catch (e) { /* fall through to the JSONL header */ }
|
|
154
|
+
try {
|
|
155
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
156
|
+
const newline = raw.indexOf("\n");
|
|
157
|
+
const first = JSON.parse(newline === -1 ? raw : raw.slice(0, newline));
|
|
158
|
+
if (first && first.canonicalUserId) return String(first.canonicalUserId);
|
|
159
|
+
} catch (e) { /* unreadable → unknown owner */ }
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
139
163
|
module.exports = {
|
|
140
164
|
ProjectTranscripts,
|
|
141
165
|
normalizeProjectPath,
|
|
142
166
|
projectHash,
|
|
143
167
|
truthy,
|
|
168
|
+
transcriptOwnerOf,
|
|
144
169
|
};
|
package/test-approval-async.js
CHANGED
|
@@ -124,6 +124,28 @@ const approvals = require("./core/approvals");
|
|
|
124
124
|
"legacy approvals with an undisplayed suffix require a fresh decision",
|
|
125
125
|
);
|
|
126
126
|
|
|
127
|
+
// ── an approval carries EXACTLY the tier on the owner's card ──
|
|
128
|
+
// A write approval never redeems for a run that now resolves destructive
|
|
129
|
+
// (and the refusal leaves the token intact); the tier a redemption exports
|
|
130
|
+
// is the record's own, and a record without one falls back to the run's
|
|
131
|
+
// tier — never to "destructive" by default.
|
|
132
|
+
const writeRec = approvals.create({ tool: "fleet", verb: "restart", tier: "write", command: CMD, channelId: "c1", adapter: "tg" });
|
|
133
|
+
approvals.decide(writeRec.id, "approved", "c1");
|
|
134
|
+
const stretched = approvals.consume(writeRec.id, CMD, { tier: "destructive" });
|
|
135
|
+
assert.strictEqual(stretched.ok, false, "a write approval cannot redeem a destructive run");
|
|
136
|
+
assert.ok(/covers a write run, but this run is destructive/.test(stretched.reason));
|
|
137
|
+
assert.strictEqual(approvals.read(writeRec.id).consumedAt, undefined, "the tier refusal does not burn the token");
|
|
138
|
+
const fits = approvals.consume(writeRec.id, CMD, { tier: "write" });
|
|
139
|
+
assert.strictEqual(fits.ok, true, "the approved tier redeems");
|
|
140
|
+
assert.strictEqual(approvals.approvedTier(fits.record, "destructive"), "write", "the record's tier is what the run carries");
|
|
141
|
+
const untiered = approvals.create({ tool: "fleet", command: CMD, channelId: "c1", adapter: "tg" });
|
|
142
|
+
assert.strictEqual(untiered.tier, "", "no tier given → none recorded (not destructive)");
|
|
143
|
+
approvals.decide(untiered.id, "approved", "c1");
|
|
144
|
+
const legacyRun = approvals.consume(untiered.id, CMD, { tier: "destructive" });
|
|
145
|
+
assert.strictEqual(legacyRun.ok, true, "a tierless record binds nothing extra");
|
|
146
|
+
assert.strictEqual(approvals.approvedTier(legacyRun.record, "write"), "write", "a tierless record exports the run's own tier");
|
|
147
|
+
assert.strictEqual(approvals.approvedTier({ tier: "reply" }, "read-only"), "read-only", "non-tool tiers never widen a run");
|
|
148
|
+
|
|
127
149
|
const quotedCommand = approvals.formatApprovedCommand(["open-claudia", "tool", "run", "fleet", "purge", "node one", "it's-safe"]);
|
|
128
150
|
assert.strictEqual(quotedCommand, "open-claudia tool run fleet purge 'node one' 'it'\"'\"'s-safe'");
|
|
129
151
|
|
package/test-approval-cards.js
CHANGED
|
@@ -176,6 +176,46 @@ const lastSend = () => sends[sends.length - 1]?.text || "";
|
|
|
176
176
|
assert.ok(rows.includes(`grl:${ruled.id}:channel`), "channel scope offered");
|
|
177
177
|
assert.ok(rows.includes(`grl:${ruled.id}:global`), "global scope offered");
|
|
178
178
|
|
|
179
|
+
// ── grn: standing grants from a card ("Always — here / everywhere") ──
|
|
180
|
+
const grants = require("./core/grants");
|
|
181
|
+
const pair = { speakerId: "user-lucy", room: "kazee:ext-channel" };
|
|
182
|
+
|
|
183
|
+
// Destructive is never standing: the tap is refused and nothing is written.
|
|
184
|
+
const destructive = approvals.create({ ...base, ...pair, kind: "action", tier: "destructive", command: "open-claudia tool run infra delete-vm vm-1" });
|
|
185
|
+
await handleAction(envelopeFor(`grn:${destructive.id}:all`));
|
|
186
|
+
assert.ok(/^Destructive actions always ask; no standing permission/.test(lastSend()), "owner is told destructive always asks");
|
|
187
|
+
await handleAction(envelopeFor(`grn:${destructive.id}:room`));
|
|
188
|
+
assert.ok(/^Destructive actions always ask/.test(lastSend()));
|
|
189
|
+
assert.strictEqual(grants.list().length, 0, "no grant written from a destructive card");
|
|
190
|
+
|
|
191
|
+
// Anonymous or unverifiable principals are refused at the button.
|
|
192
|
+
const anon = approvals.create({
|
|
193
|
+
...base, adapter: "telegram", targetAdapter: "telegram", channelId: "-100555", targetChannel: "-100555",
|
|
194
|
+
kind: "action", tier: "write", command: "open-claudia tool run kticket create",
|
|
195
|
+
speakerId: "1087968824", room: "telegram:-100555",
|
|
196
|
+
});
|
|
197
|
+
await handleAction(envelopeFor(`grn:${anon.id}:room`));
|
|
198
|
+
assert.ok(/^No standing permission for an anonymous group admin.*Use Approve once/.test(lastSend()), "anonymous admin refused at the button");
|
|
199
|
+
const roomAsSender = approvals.create({ ...base, kind: "reply", tier: "reply", payloadText: "Hi.", speakerId: "ext-channel", room: "kazee:ext-channel" });
|
|
200
|
+
await handleAction(envelopeFor(`grn:${roomAsSender.id}:all`));
|
|
201
|
+
assert.ok(/^No standing permission for the room itself/.test(lastSend()), "a sender that is really the room is refused");
|
|
202
|
+
assert.strictEqual(grants.list().length, 0, "no grant written for an unverifiable principal");
|
|
203
|
+
|
|
204
|
+
// Only an owner can grant.
|
|
205
|
+
const write = approvals.create({ ...base, ...pair, kind: "action", tier: "write", command: "open-claudia tool run kticket create" });
|
|
206
|
+
await handleAction(envelopeFor(`grn:${write.id}:room`, { channelId: "intruder-chat" }));
|
|
207
|
+
assert.ok(/Owner only/.test(lastSend()));
|
|
208
|
+
assert.strictEqual(grants.list().length, 0);
|
|
209
|
+
|
|
210
|
+
// A write card from one identifiable person grants `act` for that pair.
|
|
211
|
+
await handleAction(envelopeFor(`grn:${write.id}:room`));
|
|
212
|
+
assert.ok(/Granted Lucy `act`/.test(lastSend()), "write card grants act");
|
|
213
|
+
const issued = grants.list();
|
|
214
|
+
assert.strictEqual(issued.length, 1);
|
|
215
|
+
assert.strictEqual(issued[0].capability, "act");
|
|
216
|
+
assert.strictEqual(issued[0].principal.channelId, "user-lucy");
|
|
217
|
+
assert.strictEqual(issued[0].room, "kazee:ext-channel");
|
|
218
|
+
|
|
179
219
|
// ── expired card: nothing to decide ──
|
|
180
220
|
await handleAction(envelopeFor("apr:apr_0000_dead:ok"));
|
|
181
221
|
assert.ok(/expired|cleaned up/.test(lastEdit()), "stale card gets a clear ack");
|
|
@@ -506,7 +506,15 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
506
506
|
// wakeups sleep in chunks instead of firing at once; a failed jobs-store write
|
|
507
507
|
// keeps the retry armed; listings say where a fired job stands; loopback says
|
|
508
508
|
// which step failed; the pack-review release gate no longer flakes on 30 s.
|
|
509
|
-
|
|
509
|
+
// 3.5.0 approved by Sumeet 2026-09-25 14:43 EAT ("yes work through it end to end",
|
|
510
|
+
// the permission redesign, phase 0 security fixes first): the bot decides who a
|
|
511
|
+
// process acts for (run attestation, whoami); staff tool writes are decided and
|
|
512
|
+
// escalated in the bot; undeclared verbs refused; approvals carry their tier;
|
|
513
|
+
// tool/note/keyring/sub-agent changes owner-only; staff follow-ups run at the
|
|
514
|
+
// setter's level; notes/tasks owner-only and search stays in the room; reads
|
|
515
|
+
// screened and the hook fails closed on staff turns; no standing destructive
|
|
516
|
+
// or anonymous grants; canary release tags. Shipped first as canary-v3.5.0.
|
|
517
|
+
assert.strictEqual(pkg.version, "3.5.0", "release version must remain unchanged without explicit approval");
|
|
510
518
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
511
519
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
512
520
|
}
|
|
@@ -1544,6 +1544,18 @@ function staticSchedulingProbe() {
|
|
|
1544
1544
|
assert.match(source["core/scheduler.js"], /watchExpired/, "scheduler must mark expiry fires distinctly");
|
|
1545
1545
|
assert.match(source["core/loopback.js"], /expiresAt/, "loopback must pass the watch expiry through");
|
|
1546
1546
|
assert.match(source["bin/schedule.js"], /--timeout/, "watch-add CLI must expose --timeout");
|
|
1547
|
+
|
|
1548
|
+
// Phase 0 (B2): a watch check must NOT inherit the bot's whole environment,
|
|
1549
|
+
// must build an explicit hardened env, and must register a creator-scoped
|
|
1550
|
+
// run around the poll.
|
|
1551
|
+
assert.ok(!/env:\s*process\.env/.test(source["core/scheduler.js"]), "watch check must not spawn with the bot's whole process env");
|
|
1552
|
+
assert.match(source["core/scheduler.js"], /buildWatchCheckEnv\(process\.env/, "watch check builds an explicit hardened env");
|
|
1553
|
+
assert.match(source["core/scheduler.js"], /registerRun\(checkRunId/, "watch check registers a creator-scoped run");
|
|
1554
|
+
assert.match(source["core/scheduler.js"], /endRun\(checkRunId\)/, "watch check ends its run when the check exits");
|
|
1555
|
+
// Phase 0 (B2): the loopback binds a job's identity to the run-attested
|
|
1556
|
+
// creator, not the payload's claims, and refuses an unattributed caller.
|
|
1557
|
+
assert.match(source["core/loopback.js"], /JOB_CREATE_KINDS/, "loopback must bind job creation to the run-attested caller");
|
|
1558
|
+
assert.match(source["core/loopback.js"], /createdBy/, "jobs must record their creator principal");
|
|
1547
1559
|
}
|
|
1548
1560
|
|
|
1549
1561
|
async function main() {
|
|
@@ -1553,6 +1565,8 @@ async function main() {
|
|
|
1553
1565
|
assert.strictEqual(typeof jobsApi.migrateJobsDocument, "function");
|
|
1554
1566
|
assert.strictEqual(typeof jobsApi.createJobsStore, "function");
|
|
1555
1567
|
assert.strictEqual(typeof schedulerApi.createSchedulerService, "function");
|
|
1568
|
+
assert.strictEqual(typeof schedulerApi.buildWatchCheckEnv, "function", "scheduler exports the watch-check env builder");
|
|
1569
|
+
assert.strictEqual(typeof schedulerApi.watchCheckPrincipal, "function", "scheduler exports the watch-check principal resolver");
|
|
1556
1570
|
|
|
1557
1571
|
migrationProbe(jobsApi);
|
|
1558
1572
|
legacyDefaultsProbe(jobsApi);
|
|
@@ -95,6 +95,17 @@ try {
|
|
|
95
95
|
const claudeSettings = buildClaudeHookSettings();
|
|
96
96
|
assert.strictEqual(claudeSettings.hooks.PreToolUse[0].matcher, "Bash");
|
|
97
97
|
assert.match(claudeSettings.hooks.PreToolUse[0].hooks[0].command, /deny-gate-hook/);
|
|
98
|
+
// Claude's file-reading tools never pass through a shell, so the guarded
|
|
99
|
+
// read gate needs its own matcher on the same deny-gate entry.
|
|
100
|
+
const readHook = claudeSettings.hooks.PreToolUse.find((entry) => entry.matcher === "^(Read|Grep|Glob|NotebookRead|LS)$");
|
|
101
|
+
assert.ok(readHook, "Claude read tools are hooked");
|
|
102
|
+
assert.strictEqual(readHook.hooks[0].command, claudeSettings.hooks.PreToolUse[0].hooks[0].command);
|
|
103
|
+
for (const tool of ["Read", "Grep", "Glob", "NotebookRead", "LS"]) {
|
|
104
|
+
assert.ok(new RegExp(readHook.matcher).test(tool), `${tool} matches the read-gate matcher`);
|
|
105
|
+
}
|
|
106
|
+
for (const tool of ["Bash", "Write", "WebFetch", "ReadMcpResourceTool", "Task"]) {
|
|
107
|
+
assert.ok(!new RegExp(readHook.matcher).test(tool), `${tool} stays out of the read-gate matcher`);
|
|
108
|
+
}
|
|
98
109
|
assert.match(claudeSettings.hooks.PreToolUse[0].hooks[0].command, new RegExp(process.execPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
99
110
|
assert.strictEqual(ensureClaudeHookSettings({ settingsFile: claudeSettingsFile }), claudeSettingsFile);
|
|
100
111
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(claudeSettingsFile, "utf8")), claudeSettings);
|
package/test-tool-guard.js
CHANGED
|
@@ -122,7 +122,9 @@ const deny = guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { com
|
|
|
122
122
|
assert.strictEqual(deny.exit, 2, "deny → exit 2 (blocking)");
|
|
123
123
|
assert.ok(/deny-gate/.test(deny.stderr), "deny → stderr message for the model");
|
|
124
124
|
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls -la" } })).exit, 0, "benign Bash → allow");
|
|
125
|
-
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Read", tool_input: { file_path: "/etc/hosts" } })).exit, 0, "
|
|
125
|
+
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Read", tool_input: { file_path: "/etc/hosts" } })).exit, 0, "reads are never gated without channel context");
|
|
126
|
+
// Owner-local (no channel context) fails OPEN; guarded turns fail closed
|
|
127
|
+
// instead (pinned in test-read-guard.js).
|
|
126
128
|
assert.strictEqual(guard.runHook("not json{{{").exit, 0, "unparseable payload fails OPEN");
|
|
127
129
|
assert.strictEqual(guard.runHook(null).exit, 0, "null payload fails OPEN");
|
|
128
130
|
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash" })).exit, 0, "missing tool_input fails OPEN");
|
|
@@ -154,7 +156,7 @@ assert.strictEqual(
|
|
|
154
156
|
guard.runHook(JSON.stringify({ tool_name: "apply_patch", tool_input: { command: "*** Begin Patch\n*** Add File: /tmp/evil.txt\n+x\n*** End Patch\n" } })).exit, 2,
|
|
155
157
|
"Codex apply_patch equally gated on guarded turns (probed live: file edits arrive under this tool name)");
|
|
156
158
|
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Read", tool_input: { file_path: "/etc/hosts" } })).exit, 0,
|
|
157
|
-
"reads stay free on guarded turns");
|
|
159
|
+
"ordinary reads stay free on guarded turns (protected roots: test-read-guard.js)");
|
|
158
160
|
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls -la" } })).exit, 0,
|
|
159
161
|
"benign Bash unaffected by the file gate (deny-gate rules unchanged)");
|
|
160
162
|
|
|
@@ -185,8 +187,11 @@ assert.strictEqual(guard.externalControlPlaneDenial(cpWrite), null, "no OC_CHANN
|
|
|
185
187
|
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: cpWrite } })).exit, 0,
|
|
186
188
|
"owner-local shells keep full config access");
|
|
187
189
|
process.exitCode = 0;
|
|
190
|
+
delete process.env.OC_RUN_ATTEST; // hermetic even when the suite runs inside a live bot turn
|
|
188
191
|
require("./bin/keyring").run(["path"]);
|
|
189
|
-
assert.strictEqual(process.exitCode
|
|
192
|
+
assert.strictEqual(process.exitCode, 1,
|
|
193
|
+
"keyring CLI is owner-only even with no channel env: no live owner run behind the process = unattributed (test-cli-owner-gates.js)");
|
|
194
|
+
process.exitCode = 0;
|
|
190
195
|
process.env.OC_CHANNEL_ADAPTER = "telegram";
|
|
191
196
|
process.env.OC_CHANNEL_ID = "999000111";
|
|
192
197
|
process.env.OC_CHANNEL_USER_ID = "999000111";
|
|
@@ -288,6 +293,10 @@ assert.strictEqual(pre[1].matcher, "^(Write|Edit|MultiEdit|NotebookEdit)$",
|
|
|
288
293
|
"file-mutation tools are hooked for the external-speaker gate");
|
|
289
294
|
assert.strictEqual(pre[1].hooks[0].command, pre[0].hooks[0].command,
|
|
290
295
|
"file gate rides the same deny-gate-hook entry (one CLI, one audit path)");
|
|
296
|
+
assert.strictEqual(pre[2].matcher, "^(Read|Grep|Glob|NotebookRead|LS)$",
|
|
297
|
+
"file-reading tools are hooked for the guarded read gate");
|
|
298
|
+
assert.strictEqual(pre[2].hooks[0].command, pre[0].hooks[0].command,
|
|
299
|
+
"read gate rides the same deny-gate-hook entry");
|
|
291
300
|
// second call with the file read-only: content-compare must skip the write
|
|
292
301
|
fs.chmodSync(sPath, 0o444);
|
|
293
302
|
assert.strictEqual(guard.ensureHookSettings(), sPath, "unchanged settings are not rewritten");
|
package/test-tool-manifest.js
CHANGED
|
@@ -8,7 +8,12 @@ const fs = require("fs");
|
|
|
8
8
|
const os = require("os");
|
|
9
9
|
const path = require("path");
|
|
10
10
|
|
|
11
|
+
// The CLI asks the running bot who it acts for; never let this suite (which
|
|
12
|
+
// may run inside a bot turn) reach that bot.
|
|
13
|
+
for (const key of Object.keys(process.env)) if (key.startsWith("OC_")) delete process.env[key];
|
|
14
|
+
|
|
11
15
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tool-manifest-test-"));
|
|
16
|
+
process.env.OPEN_CLAUDIA_CONFIG_DIR = path.join(tmp, "config");
|
|
12
17
|
process.env.TOOLS_DIR = path.join(tmp, "tools");
|
|
13
18
|
process.env.KEYRING_FILE = path.join(tmp, "keyring.json");
|
|
14
19
|
process.env.APPROVALS_DIR = path.join(tmp, "approvals");
|
|
@@ -142,6 +147,32 @@ const topLevelEnv = {};
|
|
|
142
147
|
await toolCli.run(["run", "vaulted", "go", "--approval", b10rec.id]);
|
|
143
148
|
assert.strictEqual(process.exitCode, 0, "the same token redeems once the key exists");
|
|
144
149
|
assert.ok(approvals.read(b10rec.id).consumedAt, "token consumed exactly by the real run");
|
|
150
|
+
|
|
151
|
+
// ── an undeclared verb is refused at the CLI before anything else — even
|
|
152
|
+
// with an approved token for that exact command, which stays unconsumed
|
|
153
|
+
// (the smart-josephine `whatsapp chats` case used to reach the owner).
|
|
154
|
+
const chatsCmd = approvals.formatApprovedCommand(["open-claudia", "tool", "run", "fleet", "chats"]);
|
|
155
|
+
const chatsRec = approvals.create({ tool: "fleet", tier: "write", command: chatsCmd, channelId: "c1", adapter: "tg" });
|
|
156
|
+
approvals.decide(chatsRec.id, "approved", "c1");
|
|
157
|
+
errs.length = 0;
|
|
158
|
+
process.exitCode = 0;
|
|
159
|
+
await toolCli.run(["run", "fleet", "chats", "--approval", chatsRec.id]);
|
|
160
|
+
assert.strictEqual(process.exitCode, 3, "undeclared verb refused (exit 3)");
|
|
161
|
+
assert.ok(errs.some((l) => /no declared verb "chats" — its manifest allows: list, restart, purge/.test(l)), "the refusal lists the declared verbs");
|
|
162
|
+
assert.strictEqual(approvals.read(chatsRec.id).consumedAt, undefined, "the token is never redeemed for an undeclared verb");
|
|
163
|
+
|
|
164
|
+
// ── a redeemed approval exports exactly the approved tier to the tool (a
|
|
165
|
+
// write approval no longer hands nested tools destructive access) ──
|
|
166
|
+
const tierSrc = path.join(tmp, "tiered.sh");
|
|
167
|
+
fs.writeFileSync(tierSrc, `#!/usr/bin/env bash\necho "$OPEN_CLAUDIA_APPROVED_TIER" > ${JSON.stringify(path.join(tmp, "tier.out"))}\n`);
|
|
168
|
+
tools.addTool(tierSrc, { name: "tiered", description: "reports its approved tier", risk: "write" });
|
|
169
|
+
const tierCmd = approvals.formatApprovedCommand(["open-claudia", "tool", "run", "tiered", "go"]);
|
|
170
|
+
const tierRec = approvals.create({ tool: "tiered", tier: "write", command: tierCmd, channelId: "c1", adapter: "tg" });
|
|
171
|
+
approvals.decide(tierRec.id, "approved", "c1");
|
|
172
|
+
process.exitCode = 0;
|
|
173
|
+
await toolCli.run(["run", "tiered", "go", "--approval", tierRec.id]);
|
|
174
|
+
assert.strictEqual(process.exitCode, 0, "the write approval redeems");
|
|
175
|
+
assert.strictEqual(fs.readFileSync(path.join(tmp, "tier.out"), "utf-8").trim(), "write", "the run carries the approved tier, not destructive");
|
|
145
176
|
} finally {
|
|
146
177
|
for (const key of approvalKeys) {
|
|
147
178
|
if (savedApprovalEnv[key] === undefined) delete process.env[key];
|