@inetafrica/open-claudia 3.1.24 → 3.1.26

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.
@@ -379,7 +379,8 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
379
379
  const state = options.state || {
380
380
  userId: "fixture-user",
381
381
  currentSession: { name: "other-project", dir: "/workspace/other-project" },
382
- settings: { backend: "codex" },
382
+ settings: { backend: "claude" },
383
+ providerSettings: { claude: { model: "claude-current", effort: "medium" } },
383
384
  activeSessions: { alpha: { claude: "claude-after-compact" } },
384
385
  completedCompactions: {
385
386
  compacted: {
@@ -436,27 +437,28 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
436
437
  return { service, jobsStore, scheduled, admissions, runCalls, sends, state };
437
438
  }
438
439
 
439
- async function immutableTerminalProbe(createSchedulerService) {
440
+ async function firetimeSelectionProbe(createSchedulerService) {
441
+ // Same provider as origin: the occurrence resumes the recorded (compaction-
442
+ // rebased) session, but its MODEL tracks the owner's CURRENT selection — a
443
+ // wakeup pinned to a since-changed or quota-exhausted model must not keep
444
+ // firing that stale model.
440
445
  const terminal = deferred();
441
- const h = schedulerHarness(createSchedulerService, [baseJob()], {
446
+ const same = schedulerHarness(createSchedulerService, [baseJob()], {
442
447
  outcomes: [terminal.promise],
443
- onSend(state) {
444
- state.settings.backend = "codex";
445
- state.currentSession = { name: "switched", dir: "/workspace/switched" };
446
- },
447
448
  });
448
- const firing = h.service.fireJob("wake-1");
449
+ const firing = same.service.fireJob("wake-1");
449
450
  await nextTurn();
450
- assert.strictEqual(h.admissions.length, 1);
451
- assert.strictEqual(h.admissions[0].opts.provider, "claude");
452
- assert.strictEqual(h.admissions[0].opts.project.name, "alpha");
453
- assert.strictEqual(h.admissions[0].opts.resumeSessionId, "claude-after-compact", "compaction lineage should rebase the saved session");
454
- assert.deepStrictEqual(h.admissions[0].opts.providerSettings, { model: "claude-scheduled", effort: "high" });
455
- assert.strictEqual(h.admissions[0].opts.occurrenceId, "occ-wake-1");
456
- assert.strictEqual(h.admissions[0].opts.scheduledJobId, "wake-1");
457
- assert.strictEqual(h.admissions[0].opts.attachSession, true);
458
- assert.strictEqual(h.admissions[0].opts.requiredDelivery, true);
459
- assert.strictEqual(h.jobsStore.calls.remove.length, 0, "one-shot must survive until terminal settlement");
451
+ assert.strictEqual(same.admissions.length, 1);
452
+ assert.strictEqual(same.admissions[0].opts.provider, "claude");
453
+ assert.strictEqual(same.admissions[0].opts.project.name, "alpha");
454
+ assert.strictEqual(same.admissions[0].opts.resumeSessionId, "claude-after-compact", "compaction lineage should rebase the saved session");
455
+ assert.strictEqual(same.admissions[0].opts.providerSettings.model, "claude-current", "model must track the current selection, not the value pinned at creation");
456
+ assert.notStrictEqual(same.admissions[0].opts.providerSettings.model, "claude-scheduled", "the stale pinned model must not be reused");
457
+ assert.strictEqual(same.admissions[0].opts.occurrenceId, "occ-wake-1");
458
+ assert.strictEqual(same.admissions[0].opts.scheduledJobId, "wake-1");
459
+ assert.strictEqual(same.admissions[0].opts.attachSession, true);
460
+ assert.strictEqual(same.admissions[0].opts.requiredDelivery, true);
461
+ assert.strictEqual(same.jobsStore.calls.remove.length, 0, "one-shot must survive until terminal settlement");
460
462
  terminal.resolve({
461
463
  ok: true,
462
464
  status: "succeeded",
@@ -465,7 +467,38 @@ async function immutableTerminalProbe(createSchedulerService) {
465
467
  });
466
468
  const result = await firing;
467
469
  assert.strictEqual(result.ok, true);
468
- assert.deepStrictEqual(h.jobsStore.calls.remove, ["wake-1"]);
470
+ assert.deepStrictEqual(same.jobsStore.calls.remove, ["wake-1"]);
471
+
472
+ // Provider switched since creation: the occurrence runs under the CURRENT
473
+ // provider on a FRESH thread — the origin provider's native session cannot
474
+ // cross providers, and we deliberately do not hijack the new provider's own
475
+ // live session — and the fired prompt says so.
476
+ const switched = schedulerHarness(createSchedulerService, [baseJob({ id: "switched-wake" })], {
477
+ state: {
478
+ userId: "fixture-user",
479
+ currentSession: { name: "other-project", dir: "/workspace/other-project" },
480
+ settings: { backend: "codex" },
481
+ providerSettings: { codex: { model: "codex-current", effort: "low" } },
482
+ activeSessions: { alpha: { claude: "claude-after-compact", codex: "codex-live" } },
483
+ completedCompactions: {},
484
+ runningProcess: null,
485
+ preparingRun: false,
486
+ },
487
+ outcomes: [{
488
+ ok: true,
489
+ status: "succeeded",
490
+ persistence: { transcript: { ok: true }, usage: { ok: true }, session: { ok: true } },
491
+ delivery: { ok: true },
492
+ }],
493
+ });
494
+ const switchedResult = await switched.service.fireJob("switched-wake");
495
+ assert.strictEqual(switchedResult.ok, true);
496
+ assert.strictEqual(switched.admissions[0].opts.provider, "codex", "a since-switched provider selection wins over the pinned origin provider");
497
+ assert.strictEqual(switched.admissions[0].opts.fresh, true);
498
+ assert.ok(!switched.admissions[0].opts.resumeSessionId, "a provider switch starts a fresh thread, resuming neither provider's session");
499
+ assert.strictEqual(switched.admissions[0].opts.providerSettings.model, "codex-current", "the switched-to provider's current model applies");
500
+ assert.match(switched.runCalls[0].prompt, /tracks your current selection/i);
501
+ assert.match(switched.runCalls[0].prompt, /claude/i);
469
502
  }
470
503
 
471
504
  async function duplicateFireProbe(createSchedulerService) {
@@ -550,7 +583,7 @@ async function detachedHistoricalBranchProbe(createSchedulerService) {
550
583
  const state = {
551
584
  userId: "fixture-user",
552
585
  currentSession: { name: "other", dir: "/workspace/other" },
553
- settings: { backend: "codex" },
586
+ settings: { backend: "claude" },
554
587
  activeSessions: { alpha: { claude: "unrelated-live-session" } },
555
588
  completedCompactions: {},
556
589
  runningProcess: null,
@@ -822,7 +855,7 @@ function staticSchedulingProbe() {
822
855
  if (file === "core/jobs.js") continue;
823
856
  assert.ok(!/\bsessionKey\b/.test(source[file]), `${file} must not use the removed sessionKey scheduling field`);
824
857
  }
825
- assert.match(source["bin/cli.js"], /selected provider session/i);
858
+ assert.match(source["bin/cli.js"], /current provider\/model at fire time/i);
826
859
 
827
860
  // Watch feature must be wired end to end: gate → loopback → CLI surfaces.
828
861
  assert.match(source["core/scheduler.js"], /job\.check/, "scheduler must gate firing on a watch check");
@@ -843,7 +876,7 @@ async function main() {
843
876
  migrationProbe(jobsApi);
844
877
  legacyDefaultsProbe(jobsApi);
845
878
  migrationIntegrationProbe(jobsApi, migrationApi);
846
- await immutableTerminalProbe(schedulerApi.createSchedulerService);
879
+ await firetimeSelectionProbe(schedulerApi.createSchedulerService);
847
880
  await duplicateFireProbe(schedulerApi.createSchedulerService);
848
881
  await failureAndRetryProbe(schedulerApi.createSchedulerService);
849
882
  await detachedHistoricalBranchProbe(schedulerApi.createSchedulerService);
@@ -146,6 +146,10 @@ try {
146
146
  assert.match(overrides.projectTrust, /^projects=\{.+trust_level="untrusted".+\}$/);
147
147
  assert.match(overrides.preToolUse, /^hooks\.PreToolUse=/);
148
148
  assert.match(overrides.preToolUse, /deny-gate-hook/);
149
+ // Probed live 2026-07-24: Codex file edits arrive as tool_name "apply_patch"
150
+ // (shell as "Bash"). The matcher must cover both, or guarded external turns
151
+ // could write files unhooked — sandbox is bypassed on normal runs.
152
+ assert.match(overrides.preToolUse, /\^\(Bash\|apply_patch\)\$/, "matcher covers Codex's native file-edit tool");
149
153
 
150
154
  const prepared = prepareCodexHook({ cwd: projectDir });
151
155
  assert.strictEqual(verifyPreparedCodexHook(prepared), true);
@@ -14,6 +14,16 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tool-guard-"));
14
14
  process.env.TOOL_GUARD_LOG = path.join(tmp, "guard.jsonl");
15
15
  process.env.DENY_GATE_SETTINGS = path.join(tmp, "deny-gate.settings.json");
16
16
  process.env.KEYRING_FILE = path.join(tmp, "keyring.json");
17
+ // Isolated identity stores for the external file-mutation gate cases: the gate
18
+ // lazily resolves relationship→people→config, so these must precede requires.
19
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = tmp;
20
+ process.env.PEOPLE_FILE = path.join(tmp, "people.json");
21
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
22
+ // This suite may itself run inside a bot-spawned shell that carries real
23
+ // channel env — clear it so the dormant-gate baseline is deterministic.
24
+ delete process.env.OC_CHANNEL_ADAPTER;
25
+ delete process.env.OC_CHANNEL_ID;
26
+ delete process.env.OC_CHANNEL_USER_ID;
17
27
 
18
28
  const guard = require("./core/tool-guard");
19
29
 
@@ -117,10 +127,114 @@ assert.strictEqual(guard.runHook("not json{{{").exit, 0, "unparseable payload fa
117
127
  assert.strictEqual(guard.runHook(null).exit, 0, "null payload fails OPEN");
118
128
  assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash" })).exit, 0, "missing tool_input fails OPEN");
119
129
 
130
+ // ── external file-mutation gate: Write/Edit blocked on guarded turns only ──
131
+ // Dormant without channel env — owner-local turns keep full file access (R9/R14).
132
+ assert.strictEqual(guard.externalFileMutationDenial("Write"), null, "no OC_CHANNEL_* env → gate dormant");
133
+ for (const tool of ["Write", "Edit", "MultiEdit", "NotebookEdit", "apply_patch"]) {
134
+ assert.strictEqual(
135
+ guard.runHook(JSON.stringify({ tool_name: tool, tool_input: { file_path: "/tmp/x" } })).exit, 0,
136
+ `${tool} without channel context stays ungated (owner-local)`,
137
+ );
138
+ }
139
+
140
+ // Guarded turn: an id unknown to the (isolated, empty) people store resolves
141
+ // external → file mutations are denied with a redirect, reads/benign Bash stay free.
142
+ process.env.OC_CHANNEL_ADAPTER = "telegram";
143
+ process.env.OC_CHANNEL_ID = "999000111";
144
+ process.env.OC_CHANNEL_USER_ID = "999000111";
145
+ const extDeny = guard.runHook(JSON.stringify({ tool_name: "Write", tool_input: { file_path: "/tmp/evil.txt" } }));
146
+ assert.strictEqual(extDeny.exit, 2, "external speaker → Write denied");
147
+ assert.ok(/External-speaker guard/.test(extDeny.stderr), "denial names the guard");
148
+ assert.ok(/write-tier tool/.test(extDeny.stderr), "denial redirects to the owner-approval channel");
149
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Edit", tool_input: { file_path: "/tmp/x.js" } })).exit, 2,
150
+ "Edit equally gated on guarded turns");
151
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "NotebookEdit", tool_input: { notebook_path: "/tmp/x.ipynb" } })).exit, 2,
152
+ "NotebookEdit equally gated on guarded turns");
153
+ assert.strictEqual(
154
+ 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
+ "Codex apply_patch equally gated on guarded turns (probed live: file edits arrive under this tool name)");
156
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Read", tool_input: { file_path: "/etc/hosts" } })).exit, 0,
157
+ "reads stay free on guarded turns");
158
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls -la" } })).exit, 0,
159
+ "benign Bash unaffected by the file gate (deny-gate rules unchanged)");
160
+
161
+ // The owner on the same transport is never gated: guardActive is a strict no-op.
162
+ {
163
+ const people = require("./core/people");
164
+ const owner = people.add({ name: "Owner", isOwner: true });
165
+ people.linkHandle(owner.id, { adapter: "telegram", channelId: "111222333", approvedBy: "test" });
166
+ process.env.OC_CHANNEL_ID = "111222333";
167
+ process.env.OC_CHANNEL_USER_ID = "111222333";
168
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Write", tool_input: { file_path: "/tmp/ok.txt" } })).exit, 0,
169
+ "owner turns keep full file access even with channel env present");
170
+ }
171
+ delete process.env.OC_CHANNEL_ADAPTER;
172
+ delete process.env.OC_CHANNEL_ID;
173
+ delete process.env.OC_CHANNEL_USER_ID;
174
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Write", tool_input: { file_path: "/tmp/x" } })).exit, 0,
175
+ "gate re-dormant once channel env is gone");
176
+
177
+ // ── external control-plane gate: Bash touching config/package denied on guarded turns ──
178
+ // F2's Write/Edit denial must not be routable-around with one Bash heredoc: on
179
+ // guarded turns any shell command referencing the config dir, the installed
180
+ // package source, or a .open-claudia path is denied (reads included — policy
181
+ // text must not leak into the vetted surface). Dormant without channel env;
182
+ // the authorised channels stay exempt (bin/tool.js speaker-gates them itself).
183
+ const cpWrite = `printf '* * * * * evil\\n' >> ${tmp}/crons.json`;
184
+ assert.strictEqual(guard.externalControlPlaneDenial(cpWrite), null, "no OC_CHANNEL_* env → control-plane gate dormant");
185
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: cpWrite } })).exit, 0,
186
+ "owner-local shells keep full config access");
187
+ process.exitCode = 0;
188
+ require("./bin/keyring").run(["path"]);
189
+ assert.strictEqual(process.exitCode || 0, 0, "keyring CLI stays open owner-local (no channel env)");
190
+ process.env.OC_CHANNEL_ADAPTER = "telegram";
191
+ process.env.OC_CHANNEL_ID = "999000111";
192
+ process.env.OC_CHANNEL_USER_ID = "999000111";
193
+ {
194
+ const denyWrite = guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: cpWrite } }));
195
+ assert.strictEqual(denyWrite.exit, 2, "external speaker → config-dir write denied");
196
+ assert.ok(/External-speaker guard/.test(denyWrite.stderr), "denial names the guard");
197
+ assert.ok(/control plane/.test(denyWrite.stderr), "denial says what was protected");
198
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: `cat ${tmp}/entities/sharon.md` } })).exit, 2,
199
+ "config reads equally denied on guarded turns (read-then-quote leaks policy)");
200
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: "cat ~/.open-claudia/lessons.md" } })).exit, 2,
201
+ ".open-claudia paths denied regardless of resolved config dir");
202
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: `sed -i '' 's/a/b/' ${__dirname}/core/enforcer.js` } })).exit, 2,
203
+ "installed package source is control plane too");
204
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls -la /tmp" } })).exit, 0,
205
+ "unrelated shell work stays free on guarded turns");
206
+ assert.strictEqual(guard.externalControlPlaneDenial(`open-claudia tool run oc-admin crons list --file ${tmp}/crons.json`), null,
207
+ "authorised channel (tool run) stays exempt — bin/tool.js gates externals itself");
208
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: `open-claudia tool run oc-admin ping && cat ${tmp}/crons.json` } })).exit, 2,
209
+ "an authorised prefix cannot smuggle a raw control-plane suffix");
210
+ // keyring exec is exempt above as an authorised channel, so the speaker
211
+ // gate lives inside the keyring CLI itself — every verb is owner-only
212
+ // (exec carries the FULL keyring into an arbitrary command's env).
213
+ const keyringCli = require("./bin/keyring");
214
+ process.exitCode = 0;
215
+ keyringCli.run(["path"]);
216
+ assert.strictEqual(process.exitCode, 1, "keyring CLI refuses guarded external speakers outright");
217
+ process.exitCode = 0;
218
+ }
219
+ // The owner on a linked channel is never gated.
220
+ process.env.OC_CHANNEL_ID = "111222333";
221
+ process.env.OC_CHANNEL_USER_ID = "111222333";
222
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: cpWrite } })).exit, 0,
223
+ "owner turns keep full control-plane access with channel env present");
224
+ delete process.env.OC_CHANNEL_ADAPTER;
225
+ delete process.env.OC_CHANNEL_ID;
226
+ delete process.env.OC_CHANNEL_USER_ID;
227
+
120
228
  // ── audit trail: denies + bypasses land in the guard log (5c KPI source) ──
121
229
  guard.logGuardEvent({ kind: "bypass", reason: "no tool yet", command: "curl x" });
122
230
  const lines = fs.readFileSync(process.env.TOOL_GUARD_LOG, "utf-8").trim().split("\n").map((l) => JSON.parse(l));
123
231
  assert.ok(lines.some((l) => l.kind === "deny" && l.rule === "inet-central-api"), "runHook deny is logged");
232
+ assert.ok(lines.some((l) => l.kind === "deny" && l.rule === "external-file-write" && l.tool === "Write"),
233
+ "external file-mutation deny is audited with tool + rule");
234
+ assert.ok(lines.some((l) => l.kind === "deny" && l.rule === "external-control-plane" && /crons\.json/.test(l.command || "")),
235
+ "external control-plane deny is audited with the command");
236
+ assert.ok(lines.some((l) => l.kind === "deny" && l.rule === "external-keyring"),
237
+ "external keyring refusal is audited");
124
238
  assert.ok(lines.some((l) => l.kind === "bypass" && l.reason === "no tool yet"), "keyring exec bypass is logged");
125
239
  assert.ok(lines.every((l) => l.ts), "every guard event is timestamped");
126
240
 
@@ -170,6 +284,10 @@ assert.strictEqual(pre[0].matcher, "Bash", "hook is scoped to the Bash tool");
170
284
  assert.ok(/deny-gate-hook/.test(pre[0].hooks[0].command), "hook invokes the CLI hook entry");
171
285
  assert.ok(pre[0].hooks[0].command.includes(process.execPath), "hook uses an absolute node path (subprocess PATH independent)");
172
286
  assert.ok(pre[0].hooks[0].timeout > 0, "hook carries a timeout so a hung gate fails open");
287
+ assert.strictEqual(pre[1].matcher, "^(Write|Edit|MultiEdit|NotebookEdit)$",
288
+ "file-mutation tools are hooked for the external-speaker gate");
289
+ assert.strictEqual(pre[1].hooks[0].command, pre[0].hooks[0].command,
290
+ "file gate rides the same deny-gate-hook entry (one CLI, one audit path)");
173
291
  // second call with the file read-only: content-compare must skip the write
174
292
  fs.chmodSync(sPath, 0o444);
175
293
  assert.strictEqual(guard.ensureHookSettings(), sPath, "unchanged settings are not rewritten");