@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 +27 -0
- package/README.md +1 -1
- package/cli/install.mjs +52 -5
- package/cli/permissions.mjs +7 -1
- package/cli/probe-contract.mjs +7 -1
- package/package.json +2 -2
- package/src/home/hooks/shell-write-intent.mjs +44 -15
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 **
|
|
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)
|
|
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
|
|
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
|
|
224
|
-
+ `\
|
|
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
|
};
|
package/cli/permissions.mjs
CHANGED
|
@@ -108,7 +108,13 @@ export async function addPermissionRules(settingsPath) {
|
|
|
108
108
|
added += 1;
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
-
|
|
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
|
|
package/cli/probe-contract.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"description": "Claude Code-to-Codex delegation with 6 guards,
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|