@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.
- package/CHANGELOG.md +12 -0
- package/bin/cli.js +2 -2
- package/bin/keyring.js +19 -0
- package/bin/schedule.js +4 -3
- package/core/actions.js +140 -15
- package/core/context.js +22 -2
- package/core/dream.js +23 -3
- package/core/enforcer.js +184 -72
- package/core/entities.js +5 -1
- package/core/handlers.js +16 -6
- package/core/mandates.js +160 -0
- package/core/pack-review.js +8 -6
- package/core/providers/claude-hook.js +10 -10
- package/core/providers/claude.js +1 -0
- package/core/providers/codex-hook.js +5 -1
- package/core/relationship.js +4 -1
- package/core/router.js +51 -13
- package/core/scheduler.js +62 -2
- package/core/tool-guard.js +100 -1
- package/docs/permissions-audit-2026-07.md +324 -0
- package/package.json +4 -2
- package/test-approval-cards.js +184 -0
- package/test-enforcer.js +137 -0
- package/test-external-rule-scopes.js +8 -0
- package/test-fixtures/fake-agent-cli.js +1 -1
- package/test-mandate-hygiene.js +147 -0
- package/test-persona-pipeline.js +22 -8
- package/test-provider-dream.js +14 -2
- package/test-provider-enforcer.js +5 -1
- package/test-provider-gateway.js +16 -0
- package/test-provider-language.js +21 -1
- package/test-provider-pack-review.js +12 -1
- package/test-provider-scheduler.js +55 -22
- package/test-provider-tool-hooks.js +4 -0
- package/test-tool-guard.js +118 -0
|
@@ -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: "
|
|
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
|
|
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
|
|
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 =
|
|
449
|
+
const firing = same.service.fireJob("wake-1");
|
|
449
450
|
await nextTurn();
|
|
450
|
-
assert.strictEqual(
|
|
451
|
-
assert.strictEqual(
|
|
452
|
-
assert.strictEqual(
|
|
453
|
-
assert.strictEqual(
|
|
454
|
-
assert.
|
|
455
|
-
assert.
|
|
456
|
-
assert.strictEqual(
|
|
457
|
-
assert.strictEqual(
|
|
458
|
-
assert.strictEqual(
|
|
459
|
-
assert.strictEqual(
|
|
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(
|
|
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: "
|
|
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"], /
|
|
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
|
|
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);
|
package/test-tool-guard.js
CHANGED
|
@@ -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");
|