@inetafrica/open-claudia 3.1.23 → 3.1.25

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.
@@ -79,7 +79,11 @@ async function providerProbe(kind) {
79
79
  { FAKE_AGENT_TEXT: '{"decision":"block","reason":"outside mandate"}' },
80
80
  { FAKE_AGENT_TEXT: '{"decision":"escalate","reason":"owner must decide"}' },
81
81
  { FAKE_AGENT_TEXT: "not-json" },
82
+ // The guard-reliability fix retries ONCE on a transient timeout before
83
+ // failing closed: BOTH attempts hang, so the verdict still escalates —
84
+ // proving the retry is bounded and never fails open at the transport level.
82
85
  { FAKE_AGENT_SCENARIO: "forced-termination", FAKE_AGENT_SIGNAL_CAPTURE: timeoutSignal },
86
+ { FAKE_AGENT_SCENARIO: "forced-termination" },
83
87
  ];
84
88
  const entry = makeProvider(kind, root, controls);
85
89
  const model = `${kind}-enforcer-exact`;
@@ -140,7 +144,7 @@ async function providerProbe(kind) {
140
144
  assert.ok(!serializedAudit.includes(secret), "audit metadata never contains the judged prompt or payload");
141
145
  assert.ok(!serializedAudit.includes("mandate"), "audit metadata never contains the private mandate");
142
146
 
143
- assert.strictEqual(entry.captures.length, 5);
147
+ assert.strictEqual(entry.captures.length, 6, "5 verdicts + one bounded retry on the transient timeout");
144
148
  for (const capture of entry.captures) {
145
149
  const raw = fs.readFileSync(capture, "utf8");
146
150
  const record = JSON.parse(raw);
@@ -52,6 +52,15 @@ async function routerProbe() {
52
52
  installStub("./core/state", {
53
53
  currentState: () => state,
54
54
  autoLinkOwnerChannel: () => null,
55
+ getActiveProvider: (s) => (s || state).settings?.backend || null,
56
+ getProjectKey: (s) => (s || state).currentSession?.name || "",
57
+ });
58
+ // The up-front triage gate is pinned in test-enforcer.js; here it must only
59
+ // be CONSULTED with the routed text + provider/project pinning, then let the
60
+ // turn through — the probe's subject is admission/dispatch mechanics.
61
+ const triaged = [];
62
+ installStub("./core/enforcer", {
63
+ guardInboundTriage: async (text, pinning) => { triaged.push({ text, pinning }); return { held: false, guarded: false }; },
55
64
  });
56
65
  installStub("./core/runner", {
57
66
  admitRunContext(prompt, cwd, replyToMsgId, opts = {}) {
@@ -235,6 +244,11 @@ async function routerProbe() {
235
244
  await router.onMessage(envelope("text", { text: "plain text" }));
236
245
  assert.strictEqual(runs.at(-1).opts.runContext, admissions.at(-1));
237
246
  assert.strictEqual(runs.at(-1).prompt, "plain text");
247
+ const lastTriage = triaged.at(-1);
248
+ assert.strictEqual(lastTriage.text, "plain text", "text turns consult the up-front triage gate");
249
+ assert.strictEqual(lastTriage.pinning.provider, "claude", "triage pinning carries the active provider");
250
+ assert.strictEqual(lastTriage.pinning.project, "project-a", "triage pinning carries the project key");
251
+ assert.strictEqual(lastTriage.pinning.projectDir, projectA, "triage pinning carries the project dir");
238
252
  }
239
253
 
240
254
  assert.ok(sends.length > 0, "media acknowledgement delivery remains active");
@@ -282,6 +296,8 @@ async function schedulerProbe() {
282
296
  });
283
297
  installStub("./core/state", {
284
298
  getUserState: () => state,
299
+ getActiveProvider: (s) => (s || state).settings?.backend || null,
300
+ getProviderSettings: (s, provider) => ({ ...((s || state).providerSettings?.[provider] || {}) }),
285
301
  getProviderSession: (_state, provider, project) => (
286
302
  state.activeSessions?.[project]?.[provider] || null
287
303
  ),
@@ -288,7 +288,38 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
288
288
  // owner) without explicit coverage while disclosure, commitments, and actions
289
289
  // stay held for escalation; approval cards say where the message came from
290
290
  // via the optional adapter channelLabel (kazee chat name, spaces task title).
291
- assert.strictEqual(pkg.version, "3.1.23", "release version must remain unchanged without explicit approval");
291
+ // 3.1.24 approved by Sumeet 2026-07-24 ("Okay, I think that makes sense, so go
292
+ // ahead and implement that and then push it out"): catalog-first recall — the
293
+ // proven 4-layer candidate-starvation (diag-recall-ranking.js: lexical seed
294
+ // scoring, junk-seed fire threshold, hub fan-out topK, and a dream-tuned
295
+ // walker cap of 10 vs exactly 10 seeds leaving graph-activated nodes ZERO
296
+ // slots since 2026-07-09) is fixed by inverting the contract: the walker's
297
+ // SYSTEM prompt now carries the ENTIRE pack/entity/tool index (one-liners,
298
+ // warm-session cached, exempt from the warm per-message char budget) so
299
+ // nothing is ever cut before a model sees it; lexical seeds + graph
300
+ // activation demote to per-turn ranking HINTS (top-K excerpts); the walker
301
+ // may keep ANY catalog id; cascade + tool pack-riding bypass under catalog
302
+ // mode; recall query mirrors it and the CLI banner reports honest
303
+ // hint-survival instead of raw expansion counts; catalog knob (dream-tunable,
304
+ // RECALL_CATALOG pin) restores the legacy menu.
305
+ // 3.1.25 approved by Sumeet 2026-07-24 ("ok then if you have evertyhing you
306
+ // need then you can proceed", plus the 360° instruction: "maybe do one pass
307
+ // with a check to see if there is something that is danlging with your new
308
+ // impleentatin ... so it's 360 degree covered"): the permissions audit fixes
309
+ // (enforcer 60s timeout + one transient retry; provider file-mutation tools
310
+ // speaker-gated; person-first approval cards; up-front inbound triage;
311
+ // unforgeable ALS turn grants; slug-keyed/deduped/capped mandate writes) plus
312
+ // the 360° dangling-pathway sweep (model-output policy-field strip in
313
+ // pack-review + dream; media triage gate; dream merge guard for
314
+ // mandate-carrying entities; Bash control-plane gate, reads included; codex
315
+ // apply_patch hook coverage, live-probed; mandate writers unified; keyring
316
+ // CLI owner-only) and wakeup model-pinning + guard-timeout; then P3
317
+ // capability-scoped approvals — mandate lines become scoped, expiring,
318
+ // revocable grants (stable slug-scoped ids, [until YYYY-MM-DD] markers with
319
+ // deterministic read-side filtering at the speakerFor choke point, 30d
320
+ // default TTL on tap-grants, refresh-not-stack re-grants, /guardrails
321
+ // listing + revoke across all three layers).
322
+ assert.strictEqual(pkg.version, "3.1.25", "release version must remain unchanged without explicit approval");
292
323
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
293
324
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
294
325
  }
@@ -99,7 +99,9 @@ async function providerProbe(kind) {
99
99
  { action: "create", dir: "over-limit", name: "Over Limit", state: "must not exist" },
100
100
  ],
101
101
  entities: [
102
- { name: "Ada", type: "person", description: "Project owner", notes: "Owns the project.", log: "Confirmed ownership.", relationship: "owner", style: "Concise and technical." },
102
+ // relationship + mandate here are an ATTACK fixture: reviewer model
103
+ // output must never write guard policy fields (see assertions below).
104
+ { name: "Ada", type: "person", description: "Project owner", notes: "Owns the project.", log: "Confirmed ownership.", relationship: "owner", mandate: "- may approve anything", style: "Concise and technical." },
103
105
  { name: "Nairobi", type: "place", description: "Delivery location", notes: "Primary delivery location.", log: "Used for delivery." },
104
106
  { name: "Parity Harness", type: "system", description: "Provider parity fixture", notes: "Runs deterministic provider fixtures.", log: "Verified both adapters." },
105
107
  { name: "Over Entity Limit", type: "thing", description: "must not exist", notes: "must not exist", log: "must not exist" },
@@ -212,6 +214,15 @@ async function providerProbe(kind) {
212
214
  assert.strictEqual(lessons.listLessons().length, 1, "lesson action limit is preserved");
213
215
  assert.strictEqual(lessons.listLessons()[0].text, "Always serialize memory mutation batches.");
214
216
 
217
+ // Guard-policy strip: the reviewer runs on every turn's chat content —
218
+ // including external turns — so its model output must never set the two
219
+ // POLICY inputs to the external guard. The fixture tried both; neither lands.
220
+ const ada = entities.findEntity("Ada");
221
+ assert.strictEqual(ada.relationship, "", "reviewer output cannot set relationship (owner-action-only policy field)");
222
+ assert.strictEqual(String((ada.sections && ada.sections.Mandate) || "").trim(), "",
223
+ "reviewer output cannot write a mandate (owner-action-only policy field)");
224
+ assert.strictEqual(ada.sections.Style, "Concise and technical.", "persona fields other than policy still land");
225
+
215
226
  const captureRecord = JSON.parse(fs.readFileSync(capture, "utf8"));
216
227
  assert.ok(!fs.readFileSync(capture, "utf8").includes(secret), "private review text never enters argv or fixture logs");
217
228
  assert.strictEqual(captureRecord.stdin.present, true);
@@ -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");