@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.
- package/CHANGELOG.md +55 -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 +62 -3
- package/bin/owner-gate.js +39 -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/bot-endpoint.js +194 -0
- 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 +50 -0
- package/core/scheduler.js +100 -2
- package/core/speaker-attest.js +82 -1
- package/core/tool-guard.js +395 -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 +14 -1
- package/test-provider-parity-e2e.js +8 -2
- 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/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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
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];
|