@lyupro/codex-bridge 0.3.1 → 0.4.0

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 CHANGED
@@ -4,6 +4,54 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.4.0] - 2026-08-09
10
+
11
+ ### Added
12
+
13
+ - Scope patterns are checked before Codex starts. A pattern that cannot match anything — an
14
+ absolute or drive-qualified path, backslash separators, a `..` segment, or a spelling no
15
+ repository file answers to — is refused with no run folder and no quota spent, for all three
16
+ dispatchers. The file list comes from `git ls-files --cached --others --exclude-standard`, so
17
+ ignored output does not count as repository content. A file the run is meant to create is
18
+ declared with `--scope-new`, accepted only by `codex-build` and recorded in `worker.json`.
19
+ - `codex-bridge permissions` reports, adds, and removes the optional shell permission rules for
20
+ both CLI names and the clone entry point. The exact allow and deny strings merge into
21
+ `settings.json`, preserve foreign rules, and are also removed by `uninstall`; `doctor` reports
22
+ an absent or partial set as an optional warning.
23
+
24
+ ### Changed
25
+
26
+ - `codex-bridge sweep` is now `codex-bridge unlock`. The default scope is the current repository;
27
+ a bare project name remains addressable, and `--all` is now required for the whole store. The
28
+ report shows age, silence, and `alive` / `dead` / `foreign` / `unverified` identity verdicts;
29
+ confirmed-alive records are refused with `codex-bridge stop <run>` instead of being closed.
30
+
31
+ ### Fixed
32
+
33
+ - Run records now verify that a pid still belongs to the process that started the run, using the
34
+ process start time and a fresh heartbeat before falling back to an OS probe. A reused or
35
+ unverified pid is never signalled by `stop`; `codex-bridge unlock` remains the non-killing way to
36
+ close a record whose process identity cannot be confirmed.
37
+ - JSON files written by PowerShell with a UTF-8 BOM are now read consistently by the CLI and
38
+ installed hooks, and one command run creates at most one settings backup without deleting older
39
+ backups.
40
+ - A run a guard refused before Codex ever started no longer costs the next attempt a round trip. Such a folder now closes as `aborted_pre_start` instead of the same `failed` a twenty-minute paid run gets, and the chain gate, the continuation limit and the tree baseline all skip it: the same order id starts as a first run, silently, with no `continue:` grant. Reported from a parallel `codex-build` launch — the second run was correctly refused with "two writing runs in one tree are prohibited", and its one-millisecond folder then made the retry demand a grant for work that never happened. Folders written before this release are recognised by what `meta.json` records — `exit`, `session_id`, `events_bytes`, `stderr_bytes`, `tokens_reported` — rather than by whether `events.jsonl` is still on disk, because retention deletes that file from paid runs too. The folder itself is kept: it is the audit trail of who was refused and why.
41
+ - `chainBaseline()` takes the first run of the chain that actually started, so a pre-start folder sorting first no longer leaves the next pass without the `state-before.txt` its work is measured against.
42
+ - `uninstall --dry-run` names each hook by its own matcher. Three of the four hooks are PreToolUse,
43
+ and a lookup by event alone reported `Agent|Task` for the worktree lock and the prune guard too —
44
+ in the one line an operator reads to see what a removal is about to touch. The removal itself was
45
+ never affected: hooks are found by command, not by matcher.
46
+ - The order gate's refusal now shows the line it found. A caller who wrote
47
+ `scope (you may create/modify ONLY these):` was told scope was missing, re-read their own prompt,
48
+ saw it written there, and had nothing to act on; an autonomous run rewrites the same text forever.
49
+ The refusal names the candidate line under the input it belongs to and says why it was not
50
+ accepted — a qualifier between the label and the colon, a value that is still a placeholder, or no
51
+ separator at all — and stays silent when no candidate exists, because then "the input is missing"
52
+ is the truth. The parser accepts exactly what it accepted before: deriving scope from prose would
53
+ give away the only guarantee that a run cannot touch files nobody listed.
54
+
7
55
  ## [0.3.1] - 2026-08-07
8
56
 
9
57
  ### Added
package/README.md CHANGED
@@ -14,13 +14,16 @@ codexb install # short name, same command
14
14
  node bin/codex-bridge.mjs install # from a clone, same thing
15
15
  node bin/codex-bridge.mjs install --scope project # into <repo>/.claude
16
16
  node bin/codex-bridge.mjs update # bring an installation up to the current version
17
+ node bin/codex-bridge.mjs permissions # show optional shell permission rules
18
+ node bin/codex-bridge.mjs permissions add # add the complete allow/deny rule set
19
+ node bin/codex-bridge.mjs permissions remove # remove only codex-bridge's rule strings
17
20
  node bin/codex-bridge.mjs doctor # what is installed, where it points, is codex alive
18
21
  node bin/codex-bridge.mjs uninstall # removes exactly what was installed
19
22
  node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without hunting for a pid
20
23
  node bin/codex-bridge.mjs read <run> # read a run: its events rendered as text, on demand
21
24
  node bin/codex-bridge.mjs projects # what the run store holds: projects, runs, weight, tokens
22
25
  node bin/codex-bridge.mjs prune <project> # reclaim disk space, transport only unless told otherwise
23
- node bin/codex-bridge.mjs sweep [<project>] # close running records whose runner is gone
26
+ node bin/codex-bridge.mjs unlock [<project>|--all] # close records whose runner is gone
24
27
  ```
25
28
 
26
29
  When a global install and a clone coexist, there are two copies of the package. `codex-bridge update`
@@ -38,6 +41,13 @@ version: outdated files are refreshed silently, files you changed by hand stop t
38
41
  named, and `--force` is what overwrites them. A file the package no longer ships is removed only
39
42
  if you never touched it.
40
43
 
44
+ `permissions` manages the optional shell rules in the selected host's `settings.json`. `add`
45
+ merges the exact allow and deny strings for both CLI names, the clone entry point, Bash and
46
+ PowerShell; it preserves foreign rules and is safe to repeat. `remove` takes back those exact
47
+ strings from `allow`, `deny`, or `ask`, so an operator-authored lookalike is left alone. With no
48
+ action it reports whether the rule set is installed, partial, or absent. The command accepts the
49
+ same `--scope` and `--host` selectors as `install`, and `uninstall` removes the same strings too.
50
+
41
51
  `uninstall` removes only what the install recorded: a file you put in `agents/codex/` yourself
42
52
  stays, and `codex-runs/` is never touched — those are your run artifacts, not the package.
43
53
 
@@ -62,11 +72,14 @@ accounting, reports and worktree snapshots alone; `--purge` is what takes a fold
62
72
  The same transport is dropped automatically from runs older than 30 days when a new run starts,
63
73
  which the reply says out loud; `retention` in `run-config.json` changes the age or turns it off.
64
74
 
65
- `sweep` is housekeeping for records left by a dead runner. With no project it scans the whole
66
- store; with one project it limits the scan to that folder. It uses the same dead-pid decision as
67
- start-of-run cleanup, never closes a live pid even when its heartbeat is stale, and never deletes
68
- run artifacts. A stale live-pid record is named with the exact `codex-bridge stop <run>` command
69
- needed to close it.
75
+ `unlock` is housekeeping for records left by a dead runner. With no argument it acts on the current
76
+ repository; a bare project name limits the scan to that folder, and `--all` explicitly scans the
77
+ whole store. It uses the same process-identity decision as start-of-run cleanup, closes only
78
+ `dead` or `foreign` records, and reports each record's age, silence duration, and identity verdict.
79
+ A confirmed `alive` record is refused rather than closed and is named with the exact
80
+ `codex-bridge stop <run>` command needed to end it; `unverified` records stay untouched with the
81
+ reason visible. The command never deletes run artifacts. The old spelling `sweep` is recognized and
82
+ refused with the rename to `unlock`, rather than treated as an unknown command.
70
83
 
71
84
  ## What it gives you
72
85
 
@@ -8,9 +8,10 @@ import { install } from '../cli/install.mjs';
8
8
  import { projects } from '../cli/projects.mjs';
9
9
  import { read } from '../cli/read.mjs';
10
10
  import { packageInfo } from '../cli/manifest.mjs';
11
+ import { permissions } from '../cli/permissions.mjs';
11
12
  import { prune } from '../cli/prune.mjs';
12
13
  import { stop } from '../cli/stop.mjs';
13
- import { sweep } from '../cli/sweep.mjs';
14
+ import { unlock } from '../cli/unlock.mjs';
14
15
  import { uninstall } from '../cli/uninstall.mjs';
15
16
  import { update } from '../cli/update.mjs';
16
17
 
@@ -19,12 +20,13 @@ export const HELP = `codex-bridge — Claude Code dispatchers for Codex
19
20
  Usage:
20
21
  codex-bridge install [--scope user|project] [--host <path>] [--dry-run] [--force]
21
22
  codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
23
+ codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]
22
24
  codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
23
25
  codex-bridge doctor [--scope user|project] [--host <path>]
24
26
  codex-bridge projects [<name>] [--json]
25
27
  codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
26
28
  codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
27
- codex-bridge sweep [<project>]
29
+ codex-bridge unlock [<project>|--all]
28
30
  codex-bridge read <run>
29
31
  codex-bridge stop <run>
30
32
  codexb <same command forms as codex-bridge>
@@ -34,11 +36,12 @@ Usage:
34
36
  Commands:
35
37
  install Install codex-bridge into the selected Claude Code host
36
38
  update Update a recorded codex-bridge installation
39
+ permissions Show, add, or remove optional shell permission rules
37
40
  uninstall Remove installed files while preserving run artifacts
38
41
  doctor Diagnose the selected Claude Code host
39
42
  projects List projects or runs from the run store
40
43
  prune Remove archived transport, or purge selected run folders
41
- sweep Close running records whose runner is gone
44
+ unlock Close running records whose runner is gone
42
45
  read Render a run's structured event stream
43
46
  stop Stop a running Codex run and record FAIL`;
44
47
 
@@ -110,7 +113,27 @@ export async function main(argv, io = console) {
110
113
  return result.exitCode;
111
114
  }
112
115
  if (command === 'sweep') {
113
- const result = sweep(rest);
116
+ io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
117
+ return 2;
118
+ }
119
+ if (command === 'unlock') {
120
+ const result = unlock(rest);
121
+ io.log(result.output);
122
+ return result.exitCode;
123
+ }
124
+ if (command === 'permissions') {
125
+ let action;
126
+ let optionArgs = rest;
127
+ if (rest[0] && !rest[0].startsWith('-')) {
128
+ action = rest[0];
129
+ optionArgs = rest.slice(1);
130
+ }
131
+ if (action && !['add', 'remove'].includes(action)) {
132
+ throw new Error(`unknown permissions action "${action}"`);
133
+ }
134
+ const options = commandOptions(command, optionArgs);
135
+ const host = resolveHost(options);
136
+ const result = await permissions({ host, action });
114
137
  io.log(result.output);
115
138
  return result.exitCode;
116
139
  }
package/cli/doctor.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  readInstallRecord,
10
10
  packageInfo,
11
11
  } from './manifest.mjs';
12
+ import { inspectPermissions } from './permissions.mjs';
12
13
  import { commandFor, inspectHook } from './settings-merge.mjs';
13
14
  import { readRulesRegistry } from './rules-owners.mjs';
14
15
  import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
@@ -162,6 +163,21 @@ async function rulesCheck(host, record) {
162
163
  : check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
163
164
  }
164
165
 
166
+ async function permissionsCheck(host) {
167
+ try {
168
+ const status = await inspectPermissions(host.settingsPath);
169
+ // Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
170
+ // doctor does not turn a healthy installation red merely because hardening was not requested.
171
+ // The ask count is part of the line because a full set shadowed by `ask` reads as working and
172
+ // is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
173
+ const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
174
+ return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
175
+ `${status.state} (${status.present}/${status.total} own strings in allow/deny${shadow})`);
176
+ } catch (err) {
177
+ return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
178
+ }
179
+ }
180
+
165
181
  export async function diagnose({ host, codexProbe = probeCodex, currentPackage } = {}) {
166
182
  const checks = [sourceCheck()];
167
183
  const hostExists = await exists(host.root);
@@ -199,6 +215,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
199
215
  ));
200
216
  const rules = await rulesCheck(host, record);
201
217
  checks.push(rules);
218
+ checks.push(await permissionsCheck(host));
202
219
  checks.push(...await hookChecks(host, record));
203
220
  const retention = retentionCheck(host);
204
221
  checks.push(retention);
@@ -225,7 +242,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
225
242
  export function renderDoctor(result) {
226
243
  return result.checks.map(({ key, status, value }) => {
227
244
  const rendered = `[${status}] ${key}: ${value}`;
228
- return ['retention', 'conventions'].includes(key) && status === 'warn'
245
+ return ['retention', 'conventions', 'permissions'].includes(key) && status === 'warn'
229
246
  ? `${WARNING}${rendered}${RESET}`
230
247
  : rendered;
231
248
  }).join('\n');
package/cli/install.mjs CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  writeInstallRecord,
15
15
  } from './manifest.mjs';
16
16
  import { copyPlannedFile, targetMatches } from './copy.mjs';
17
- import { commandFor, inspectHook, mergeHook } from './settings-merge.mjs';
17
+ import { commandFor, inspectHook, mergeHook, withSettingsRun } from './settings-merge.mjs';
18
18
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
19
19
  import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
20
20
 
@@ -58,7 +58,7 @@ function retentionOutput(line, output) {
58
58
  return `${output}\n${line}`;
59
59
  }
60
60
 
61
- export async function install({ host, dryRun = false, force = false, packageRoot } = {}) {
61
+ async function installInRun({ host, dryRun = false, force = false, packageRoot } = {}) {
62
62
  // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
63
63
  await readRulesRegistry(host);
64
64
  const configuredRetentionLine = retentionLine(host);
@@ -163,3 +163,9 @@ export async function install({ host, dryRun = false, force = false, packageRoot
163
163
  output: retentionOutput(configuredRetentionLine, `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`),
164
164
  };
165
165
  }
166
+
167
+ export async function install(options = {}) {
168
+ const host = options?.host;
169
+ if (!host?.settingsPath) return installInRun(options);
170
+ return withSettingsRun(host.settingsPath, () => installInRun(options));
171
+ }
package/cli/manifest.mjs CHANGED
@@ -11,9 +11,23 @@ import {
11
11
  renderRequiredInputs,
12
12
  } from '../src/required-inputs.mjs';
13
13
  import { renderNoSelfExecution } from '../src/no-self-execution.mjs';
14
+ import { readJsonFile } from '../src/json-file.mjs';
14
15
 
15
16
  export { HOOK_DEFINITIONS };
16
17
 
18
+ /**
19
+ * Finds the definition a recorded hook belongs to. The event alone does not identify one: three of
20
+ * the four hooks are PreToolUse, so `find(d => d.event === hook.event)` returns the order gate for
21
+ * all three. `doctor` was fixed for exactly this and kept its own inline matching; `uninstall` was
22
+ * not, and its dry-run announced matcher `Agent|Task` for the worktree lock and the prune guard —
23
+ * the operator's only view of what a removal is about to touch. One function, so a third caller
24
+ * cannot repeat it.
25
+ */
26
+ export function definitionForRecordedHook(hook) {
27
+ return HOOK_DEFINITIONS.find((definition) => definition.event === hook?.event
28
+ && path.basename(String(hook?.path ?? '')) === definition.file);
29
+ }
30
+
17
31
  const HERE = path.dirname(fileURLToPath(import.meta.url));
18
32
  export const PACKAGE_ROOT = path.resolve(HERE, '..');
19
33
  export const INSTALL_RECORD_NAME = '.codex-bridge-install.json';
@@ -49,7 +63,7 @@ export function replacePlaceholders(content, agentsDir) {
49
63
  }
50
64
 
51
65
  export async function packageInfo(packageRoot = PACKAGE_ROOT) {
52
- const parsed = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8'));
66
+ const parsed = await readJsonFile(path.join(packageRoot, 'package.json'));
53
67
  return { name: parsed.name, version: parsed.version };
54
68
  }
55
69
 
@@ -191,19 +205,16 @@ export function normalizeInstallRecord(record) {
191
205
  }
192
206
 
193
207
  export async function readInstallRecord(host) {
194
- let raw;
208
+ let parsed;
195
209
  try {
196
- raw = await fs.readFile(installRecordPath(host), 'utf8');
210
+ parsed = await readJsonFile(installRecordPath(host));
197
211
  } catch (err) {
198
212
  if (err.code === 'ENOENT') return null;
213
+ if (err.cause) {
214
+ throw new Error(`invalid installation record JSON: ${err.cause.message}`, { cause: err.cause });
215
+ }
199
216
  throw err;
200
217
  }
201
- let parsed;
202
- try {
203
- parsed = JSON.parse(raw);
204
- } catch (err) {
205
- throw new Error(`invalid installation record JSON: ${err.message}`);
206
- }
207
218
  const record = normalizeInstallRecord(parsed);
208
219
  // Earlier versions installed the config as a package file and recorded it. Dropping it here
209
220
  // is what stops the next update from calling the operator's edited config an orphan and
@@ -0,0 +1,179 @@
1
+ /** Manages the optional shell permission rules for every codex-bridge command spelling. */
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { CLI_NAMES } from '../src/cli-names.mjs';
5
+ import { SHELL_TOOLS } from '../src/hook-definitions.mjs';
6
+ import { readSettings, updateSettings, withSettingsRun } from './settings-merge.mjs';
7
+
8
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const entryPoint = fileURLToPath(new URL('../bin/codex-bridge.mjs', import.meta.url));
10
+ const nodeCommand = `node ${path.relative(packageRoot, entryPoint).split(path.sep).join('/')}`;
11
+ const commandNames = Object.freeze([...CLI_NAMES, nodeCommand]);
12
+ const formsFor = (command) => [command, `${command}:*`];
13
+
14
+ function buildRules(commandTransform) {
15
+ return Object.freeze(SHELL_TOOLS.flatMap((tool) => commandNames.flatMap((command) =>
16
+ formsFor(commandTransform(command)).map((form) => `${tool}(${form})`))));
17
+ }
18
+
19
+ /**
20
+ * The executable names stay owned by cli-names.mjs; only the clone entry point is derived here.
21
+ * Plan_22 records the incident because repeating the node spelling in CLI_NAMES would make the
22
+ * source list answer two different questions and let a future alias bypass one of them.
23
+ *
24
+ * The deny half subtracts `prune` from the allow half rather than guarding it: `Bash(codex-bridge:*)`
25
+ * would otherwise hand out the deletion this package refuses to give an agent. Its refusal is never
26
+ * seen in a live session — a PreToolUse hook answers before permissions are consulted, and
27
+ * prune-guard is installed always while these rules are optional. The 2026-08-09 run held that
28
+ * observation open as a checklist item until it turned out to be the answer, not the obstacle.
29
+ */
30
+ export const PERMISSION_RULES = Object.freeze({
31
+ allow: buildRules((command) => command),
32
+ deny: buildRules((command) => `${command} prune`),
33
+ });
34
+
35
+ const allRules = new Set([...PERMISSION_RULES.allow, ...PERMISSION_RULES.deny]);
36
+ const ruleLists = Object.freeze(['allow', 'deny', 'ask']);
37
+ const activeLists = Object.freeze(['allow', 'deny']);
38
+ const totalRules = PERMISSION_RULES.allow.length + PERMISSION_RULES.deny.length;
39
+
40
+ function listValue(settings, name) {
41
+ const list = settings?.permissions?.[name];
42
+ return Array.isArray(list) ? list : [];
43
+ }
44
+
45
+ function matchingRules(list, rules) {
46
+ return rules.filter((rule) => list.includes(rule)).length;
47
+ }
48
+
49
+ /**
50
+ * A count of strings in allow and deny is not the state of the set, because `ask` outranks `allow`:
51
+ * a copy sitting there keeps asking the question the rule was added to end. The live run of
52
+ * Plan_22-1 caught doctor reporting `installed (24/24)` over exactly that arrangement — the number
53
+ * was true and the conclusion drawn from it was false. Every consumer reads the state, so the
54
+ * shadow has to live in the state rather than in one command's wording.
55
+ */
56
+ function permissionStatus(settings) {
57
+ const counts = Object.fromEntries(activeLists.map((name) => [
58
+ name,
59
+ matchingRules(listValue(settings, name), PERMISSION_RULES[name]),
60
+ ]));
61
+ const askCount = matchingRules(listValue(settings, 'ask'), [...allRules]);
62
+ const present = counts.allow + counts.deny;
63
+ const complete = present === totalRules;
64
+ const state = askCount
65
+ ? (complete ? 'shadowed by ask' : 'partially installed, shadowed by ask')
66
+ : complete ? 'installed' : present === 0 ? 'absent' : 'partially installed';
67
+ return {
68
+ state,
69
+ complete,
70
+ counts,
71
+ askCount,
72
+ present,
73
+ total: totalRules,
74
+ };
75
+ }
76
+
77
+ function ensureList(settings, name) {
78
+ if (settings.permissions === undefined) settings.permissions = {};
79
+ if (!settings.permissions || typeof settings.permissions !== 'object'
80
+ || Array.isArray(settings.permissions)) {
81
+ throw new TypeError('settings.permissions must be an object');
82
+ }
83
+ if (settings.permissions[name] === undefined) settings.permissions[name] = [];
84
+ if (!Array.isArray(settings.permissions[name])) {
85
+ throw new TypeError(`settings.permissions.${name} must be an array`);
86
+ }
87
+ return settings.permissions[name];
88
+ }
89
+
90
+ function countRemovable(settings) {
91
+ return ruleLists.reduce((total, name) => total + listValue(settings, name)
92
+ .filter((entry) => allRules.has(entry)).length, 0);
93
+ }
94
+
95
+ export async function inspectPermissions(settingsPath) {
96
+ const state = await readSettings(settingsPath);
97
+ return { ...state, ...permissionStatus(state.settings) };
98
+ }
99
+
100
+ export async function addPermissionRules(settingsPath) {
101
+ return updateSettings(settingsPath, (settings) => {
102
+ let added = 0;
103
+ for (const name of activeLists) {
104
+ const list = ensureList(settings, name);
105
+ for (const rule of PERMISSION_RULES[name]) {
106
+ if (list.includes(rule)) continue;
107
+ list.push(rule);
108
+ added += 1;
109
+ }
110
+ }
111
+ return { changed: added > 0, added };
112
+ });
113
+ }
114
+
115
+ export async function removePermissionRules(settingsPath, { dryRun = false } = {}) {
116
+ if (dryRun) {
117
+ const state = await readSettings(settingsPath);
118
+ return { changed: false, removed: countRemovable(state.settings) };
119
+ }
120
+ return updateSettings(settingsPath, (settings) => {
121
+ let removed = 0;
122
+ for (const name of ruleLists) {
123
+ const list = settings?.permissions?.[name];
124
+ if (!Array.isArray(list)) continue;
125
+ settings.permissions[name] = list.filter((entry) => {
126
+ if (!allRules.has(entry)) return true;
127
+ removed += 1;
128
+ return false;
129
+ });
130
+ }
131
+ return { changed: removed > 0, removed };
132
+ });
133
+ }
134
+
135
+ function statusOutput(status) {
136
+ const moved = status.askCount
137
+ ? `; ${status.askCount} own string(s) in ask, which outranks allow`
138
+ : '';
139
+ return `Permissions: ${status.state} (${status.present}/${status.total} own strings in allow/deny${moved}).`;
140
+ }
141
+
142
+ async function permissionsInRun({ host, action } = {}) {
143
+ if (!host?.settingsPath) throw new TypeError('permissions requires a resolved host');
144
+ if (!action) {
145
+ const status = await inspectPermissions(host.settingsPath);
146
+ return { exitCode: 0, output: statusOutput(status), ...status };
147
+ }
148
+ if (action === 'add') {
149
+ const result = await addPermissionRules(host.settingsPath);
150
+ // A string the operator moved into `ask` outranks the copy `add` just put into `allow`, so the
151
+ // command would report a complete set while the questions it was run to end keep appearing.
152
+ // `ask` stays untouched — it is a hand-made decision — but silence about it is a false success.
153
+ const status = await inspectPermissions(host.settingsPath);
154
+ const conflict = status.askCount
155
+ ? ` ${status.askCount} own string(s) also sit in ask, which outranks allow; they were left there.`
156
+ : '';
157
+ return {
158
+ exitCode: 0,
159
+ output: `Added ${result.added} permission rule string${result.added === 1 ? '' : 's'}.${conflict}`,
160
+ ...result,
161
+ askCount: status.askCount,
162
+ };
163
+ }
164
+ if (action === 'remove') {
165
+ const result = await removePermissionRules(host.settingsPath);
166
+ return {
167
+ exitCode: 0,
168
+ output: `Removed ${result.removed} permission rule string${result.removed === 1 ? '' : 's'}.`,
169
+ ...result,
170
+ };
171
+ }
172
+ throw new Error(`unknown permissions action "${action}"`);
173
+ }
174
+
175
+ export async function permissions(options = {}) {
176
+ const host = options?.host;
177
+ if (!host?.settingsPath) return permissionsInRun(options);
178
+ return withSettingsRun(host.settingsPath, () => permissionsInRun(options));
179
+ }
@@ -112,7 +112,7 @@ export function parsePruneArgs(argv = []) {
112
112
  if (invalid) return invalid;
113
113
  }
114
114
 
115
- // The 30-day default belongs to the gentle sweep, which searches history for weight to shed.
115
+ // The 30-day default belongs to the gentle cleanup, which searches history for weight to shed.
116
116
  // A purge is never implicitly aged: `prune sbx2 --purge` means that folder, now. Defaulting it
117
117
  // made the plan's own fourth scenario unreachable — a young project answered "nothing to
118
118
  // remove", which reads as a broken command, not as a refusal.
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import path from 'node:path';
5
5
  import { normalizeRepoPath } from '../src/runner/project-dir.mjs';
6
+ import { readJsonFile } from '../src/json-file.mjs';
6
7
 
7
8
  export const RULES_REGISTRY_NAME = '.codex-bridge-rules.json';
8
9
  export const RULES_REGISTRY_VERSION = 1;
@@ -40,19 +41,16 @@ function validateRegistry(parsed) {
40
41
  }
41
42
 
42
43
  async function loadRulesRegistry(host) {
43
- let raw;
44
+ let parsed;
44
45
  try {
45
- raw = await fs.readFile(rulesRegistryPath(host), 'utf8');
46
+ parsed = await readJsonFile(rulesRegistryPath(host));
46
47
  } catch (err) {
47
48
  if (err.code === 'ENOENT') return null;
49
+ if (err.cause) {
50
+ throw new Error(`invalid rules ownership registry JSON: ${err.cause.message}`, { cause: err.cause });
51
+ }
48
52
  throw err;
49
53
  }
50
- let parsed;
51
- try {
52
- parsed = JSON.parse(raw);
53
- } catch (err) {
54
- throw new Error(`invalid rules ownership registry JSON: ${err.message}`);
55
- }
56
54
  return { raw: parsed, registry: validateRegistry(parsed) };
57
55
  }
58
56
 
@@ -2,13 +2,14 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { liveRuns } from '../src/hooks/live-runs.mjs';
5
+ import { readJsonFileSync } from '../src/json-file.mjs';
5
6
  import { runsRoot } from '../src/runner/runs-root.mjs';
6
7
 
7
8
  const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
8
9
 
9
10
  function readRecord(file) {
10
11
  try {
11
- const value = JSON.parse(fs.readFileSync(file, 'utf8'));
12
+ const value = readJsonFileSync(file);
12
13
  return record(value) ? value : null;
13
14
  } catch {
14
15
  return null;