@lyupro/codex-bridge 0.5.4 → 0.5.5

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,33 @@ 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
+ ## [0.5.5] - 2026-08-28
8
+
9
+ ### Fixed
10
+
11
+ - The write guard now reads quoting, so a redirect character inside a quoted argument is text rather
12
+ than an operator. While a delegated run held the repository, `sed -E 's#(A ).*#<redacted>#'` was
13
+ refused as a write to a file named `#'`: the scan ran over raw text, so the `>` looked like a
14
+ redirect and the token after it looked like its destination. The file already carried a defence
15
+ against this class, added after a `node -e` expression was misread the same way, but that defence
16
+ filtered candidates holding characters no file name may contain and the one refused here held
17
+ none. The guard still fails closed: when quotes cannot be balanced the scan behaves exactly as
18
+ before, because answering "no write here" to a line it cannot parse would be worse than the false
19
+ refusal being removed.
20
+ - The installation summary no longer reads as a bug. Four `PreToolUse` guards printed as
21
+ `PreToolUse and PreToolUse and PreToolUse and PreToolUse` because the sentence joined every hook's
22
+ event; it now groups by event and counts.
23
+ - The installed-file count agrees with `doctor`. The summary counted the Codex rules file among the
24
+ files while `doctor` reports it on its own line, so `68` sat next to `67` and read as a lost file.
25
+ - `Granted 0 permission rule(s)` no longer reports the good outcome as if it were a failure. When
26
+ the set is complete the summary says so; on a host whose settings were edited by hand it says how
27
+ many rules are missing and how to restore them, instead of claiming all are in place while the
28
+ operator gets permission windows. The already-installed branch reports the set by reading it —
29
+ granting there would have restored a deleted rule under the heading "nothing to do".
30
+ - A contract probe that refuses to judge now carries the host's own words. `The host exited with
31
+ status 1.` was the entire account of a failure on a live host; the last non-empty line the host
32
+ printed is now part of the reason. What counts as honoured, ignored or inconclusive is unchanged.
33
+
7
34
  ## [0.5.4] - 2026-08-28
8
35
 
9
36
  ### Added
package/README.md CHANGED
@@ -60,7 +60,7 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
60
60
  - **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
61
61
  - **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
62
62
 
63
- The current suite contains **809 automated tests: 808 passing and 1 skipped**.
63
+ The current suite contains **817 automated tests: 816 passing and 1 skipped**.
64
64
 
65
65
  ## Verify
66
66
 
package/cli/install.mjs CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  withSettingsRun,
26
26
  } from './settings-merge.mjs';
27
27
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
28
- import { addPermissionRules } from './permissions.mjs';
28
+ import { addPermissionRules, inspectPermissions } from './permissions.mjs';
29
29
  import { contractStatus, detectHostVersion, readHostContract } from './host-contract.mjs';
30
30
  import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
31
31
 
@@ -61,6 +61,36 @@ function contractOutput(status, output) {
61
61
  return status.state === 'verified' ? output : `${output}\n${status.message}`;
62
62
  }
63
63
 
64
+ function naturalList(items) {
65
+ if (items.length < 2) return items.join('');
66
+ return items.length === 2
67
+ ? items.join(' and ')
68
+ : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
69
+ }
70
+
71
+ function hookSummary(targets) {
72
+ const counts = new Map();
73
+ for (const { definition } of targets) {
74
+ counts.set(definition.event, (counts.get(definition.event) || 0) + 1);
75
+ }
76
+ const events = [...counts].map(([event, count]) => `${count} ${event}`);
77
+ return `${targets.length} hooks: ${naturalList(events)}`;
78
+ }
79
+
80
+ function permissionOutput(result, settingsPath) {
81
+ if (result.dryRun) return `Would evaluate permission rules in ${settingsPath}; settings unchanged.`;
82
+ if (result.added > 0) {
83
+ return `Granted ${result.added} of ${result.total} permission rule(s) in ${settingsPath} so the host runs the package command without asking.`;
84
+ }
85
+ // Zero granted is the good outcome only when the whole set is there. Saying "all are in place"
86
+ // off the count of what was added would state the opposite of the truth on a host someone had
87
+ // edited by hand, and permission windows are exactly what the operator would then not understand.
88
+ if (result.present < result.total) {
89
+ return `${result.present} of ${result.total} permission rules are in place in ${settingsPath}; run codex-bridge install --force to restore the rest.`;
90
+ }
91
+ return `All ${result.total} permission rules are already in place in ${settingsPath}, so the host runs the package command without asking.`;
92
+ }
93
+
64
94
  async function migrateLegacySeed(host, seed) {
65
95
  if (await targetExists(seed.target)) return;
66
96
  const legacyName = path.basename(seed.target) === 'config.json' ? 'run-config.json' : path.basename(seed.target);
@@ -140,7 +170,22 @@ async function installInRun({
140
170
  { path: rule.target, fingerprint: ruleState.fingerprint });
141
171
  if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
142
172
  && recordHasHooks(record, targets) && sameRecord) {
143
- if (!dryRun) await addRulesOwner(host);
173
+ if (!dryRun) {
174
+ await addRulesOwner(host);
175
+ // Read, never write: this branch's whole claim is that there is nothing to do, and a
176
+ // sentence about permission rules must not be paid for by quietly restoring one.
177
+ const permissionResult = await inspectPermissions(host.settingsPath);
178
+ return {
179
+ exitCode: 0,
180
+ output: contractOutput(
181
+ hostContract,
182
+ retentionOutput(
183
+ configuredRetentionLine,
184
+ `codex-bridge is already installed; nothing to do.\n${permissionOutput(permissionResult, host.settingsPath)}`,
185
+ ),
186
+ ),
187
+ };
188
+ }
144
189
  return {
145
190
  exitCode: 0,
146
191
  output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.')),
@@ -185,7 +230,9 @@ async function installInRun({
185
230
  // it goes looking for a way around, down to the absolute path to run-codex.mjs that Plan_41
186
231
  // removed. The rule follows the scope of the install, because host.settingsPath already is the
187
232
  // scope — a --scope project install must not reach into the operator's global config.
188
- const permissionResult = dryRun ? { added: 0 } : await addPermissionRules(host.settingsPath);
233
+ const permissionResult = dryRun
234
+ ? { dryRun: true, added: 0, present: 0, total: 0 }
235
+ : await addPermissionRules(host.settingsPath);
189
236
  const fingerprints = {};
190
237
  for (const item of plan) {
191
238
  fingerprints[item.root] ??= {};
@@ -220,8 +267,8 @@ async function installInRun({
220
267
  hostContract,
221
268
  retentionOutput(
222
269
  configuredRetentionLine,
223
- `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`
224
- + `\nGranted ${permissionResult.added} permission rule(s) in ${host.settingsPath} so the host runs the package command without asking.`,
270
+ `Installed ${plan.length} files and the Codex rules file, and registered ${hookSummary(targets)}.`
271
+ + `\n${permissionOutput(permissionResult, host.settingsPath)}`,
225
272
  ),
226
273
  ),
227
274
  };
@@ -108,7 +108,13 @@ export async function addPermissionRules(settingsPath) {
108
108
  added += 1;
109
109
  }
110
110
  }
111
- return { changed: added > 0, added };
111
+ const status = permissionStatus(settings);
112
+ return {
113
+ changed: added > 0,
114
+ added,
115
+ present: status.present,
116
+ total: status.total,
117
+ };
112
118
  });
113
119
  }
114
120
 
@@ -23,6 +23,10 @@ const exists = async (target) => {
23
23
  }
24
24
  };
25
25
 
26
+ function lastNonEmptyLine(output) {
27
+ return String(output ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1);
28
+ }
29
+
26
30
  export function judgeProbe({ markerExists, hookFired, hostResult }) {
27
31
  if (markerExists) {
28
32
  return { result: 'ignored', reason: 'The marker command ran despite the hook refusal.' };
@@ -41,7 +45,9 @@ export function judgeProbe({ markerExists, hookFired, hostResult }) {
41
45
  return { result: null, reason: `The host was terminated by signal ${hostResult.signal}.` };
42
46
  }
43
47
  if (hostResult.status !== 0) {
44
- return { result: null, reason: `The host exited with status ${String(hostResult.status)}.` };
48
+ const output = lastNonEmptyLine(hostResult.stderr) || lastNonEmptyLine(hostResult.stdout);
49
+ const detail = output ? `: ${output.slice(0, 200)}` : '';
50
+ return { result: null, reason: `The host exited with status ${String(hostResult.status)}${detail}.` };
45
51
  }
46
52
  if (!hookFired) {
47
53
  return { result: null, reason: 'The probe hook never received the marker command.' };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.5.4",
4
- "description": "Claude Code-to-Codex delegation with 6 guards, 809 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.5.5",
4
+ "description": "Claude Code-to-Codex delegation with 6 guards, 817 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -32,13 +32,40 @@ function addPath(paths, value) {
32
32
  && !paths.includes(candidate)) paths.push(candidate);
33
33
  }
34
34
 
35
- function tokens(segment) {
36
- return segment.match(/"(?:\\.|[^"])*"|'[^']*'|[^\s]+/g) ?? [];
37
- }
38
-
39
- function positional(args) {
40
- return args.filter((arg) => !arg.startsWith('-'));
41
- }
35
+ function tokens(segment) {
36
+ return segment.match(/"(?:\\.|[^"])*"|'[^']*'|[^\s]+/g) ?? [];
37
+ }
38
+
39
+ // A `>` inside a quoted argument is text, not a redirect. The 2026-08-28 worktree-lock refusal
40
+ // named `#'` from `sed -E 's#(A ).*#\1<redacted>#'` because this scan previously read raw text.
41
+ function quotedCharacters(command) {
42
+ const quoted = new Uint8Array(command.length);
43
+ let quote = null;
44
+ for (let index = 0; index < command.length; index += 1) {
45
+ const character = command[index];
46
+ if (quote === "'") {
47
+ if (character === "'") quote = null;
48
+ else quoted[index] = 1;
49
+ } else if (quote === '"') {
50
+ if (character === '\\' && index + 1 < command.length) {
51
+ quoted[index] = 1;
52
+ quoted[index + 1] = 1;
53
+ index += 1;
54
+ } else if (character === '"') quote = null;
55
+ else quoted[index] = 1;
56
+ } else if (character === '\\' && index + 1 < command.length) {
57
+ index += 1;
58
+ } else if (character === "'" || character === '"') {
59
+ quote = character;
60
+ quoted[index] = 1;
61
+ }
62
+ }
63
+ return quote ? null : quoted;
64
+ }
65
+
66
+ function positional(args) {
67
+ return args.filter((arg) => !arg.startsWith('-'));
68
+ }
42
69
 
43
70
  /**
44
71
  * Strip the directory and the Windows executable suffix before matching the command name.
@@ -83,14 +110,16 @@ function heredoc(command) {
83
110
  export function shellWriteIntent(command) {
84
111
  if (typeof command !== 'string' || !command.trim()) return { writes: false, paths: [] };
85
112
  const paths = [];
86
- const document = heredoc(command);
87
- const shell = document?.header ?? command;
88
- let writes = document?.writes ?? false;
89
-
90
- for (const match of shell.matchAll(/(?<![<>=])(?:\d*)>>?(?![=>&])\s*("(?:\\.|[^"])*"|'[^']*'|[^\s;|&]+)/g)) {
91
- writes = true;
92
- addPath(paths, match[1]);
93
- }
113
+ const document = heredoc(command);
114
+ const shell = document?.header ?? command;
115
+ let writes = document?.writes ?? false;
116
+ const quoted = quotedCharacters(shell);
117
+
118
+ for (const match of shell.matchAll(/(?<![<>=])(?:\d*)>>?(?![=>&])\s*("(?:\\.|[^"])*"|'[^']*'|[^\s;|&]+)/g)) {
119
+ if (quoted && quoted[match.index]) continue;
120
+ writes = true;
121
+ addPath(paths, match[1]);
122
+ }
94
123
 
95
124
  // A real output redirect identifies the writer's destination. Body text is only evidence when
96
125
  // the interpreter itself is the writer, as in the 2026-08-16 python heredoc incident.