@inetafrica/open-claudia 3.4.24 → 3.5.1

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.
@@ -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,20 @@ 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
- assert.strictEqual(pkg.version, "3.4.24", "release version must remain unchanged without explicit approval");
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
+ // 3.5.1 under the same approval (Sumeet 2026-09-25 14:43 EAT, phase 0 hardening
518
+ // from the adversarial review before any fleet roll): a CLI believes only its
519
+ // own bot (ancestor listener, not started in a staff turn); staff watch checks
520
+ // get an allowlist env and owner checks the bot's env again; a partial chat
521
+ // origin is a non-owner; wider staff shell screen, documented as no boundary.
522
+ assert.strictEqual(pkg.version, "3.5.1", "release version must remain unchanged without explicit approval");
510
523
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
511
524
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
512
525
  }
@@ -459,7 +459,9 @@ function runProviderChild(kind) {
459
459
  function runSupportSuites(provider) {
460
460
  assert.ok(["claude", "codex"].includes(provider));
461
461
  const passed = new Set();
462
- for (const [key, [script, marker, suiteTimeoutMs = 90000]] of Object.entries(SUPPORT_SUITES)) {
462
+ // 180 s per suite by default: on a loaded CI runner a suite that takes 20 s
463
+ // locally overran 90 s (canary-v3.5.0 build job 44463; the retry passed).
464
+ for (const [key, [script, marker, suiteTimeoutMs = 180000]] of Object.entries(SUPPORT_SUITES)) {
463
465
  const root = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-parity-suite-${key}-`));
464
466
  try {
465
467
  fs.mkdirSync(path.join(root, "home"));
@@ -471,7 +473,11 @@ function runSupportSuites(provider) {
471
473
  timeout: suiteTimeoutMs,
472
474
  maxBuffer: 8 * 1024 * 1024,
473
475
  });
474
- assert.ifError(result.error);
476
+ if (result.error) {
477
+ // Name the suite: a bare "spawnSync ETIMEDOUT" does not say which one.
478
+ const tail = `${result.stdout || ""}\n${result.stderr || ""}`.trim().split(/\r?\n/).slice(-20).join("\n");
479
+ assert.fail(`${key} (${script}) did not finish: ${result.error.code || result.error.message} after ${suiteTimeoutMs} ms\n${tail}`);
480
+ }
475
481
  assert.strictEqual(result.status, 0, `${script}\n${result.stderr || result.stdout}`);
476
482
  assert.ok(`${result.stdout}\n${result.stderr}`.includes(marker), `${script}: missing ${marker}`);
477
483
  passed.add(key);
@@ -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);
@@ -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, "non-Bash tools are never gated");
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 || 0, 0, "keyring CLI stays open owner-local (no channel env)");
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");
@@ -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];