@lyupro/codex-bridge 0.6.8 → 0.6.9
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 +109 -0
- package/README.md +6 -4
- package/bin/codex-bridge.mjs +35 -177
- package/cli/command-help.mjs +49 -0
- package/cli/command-registry.mjs +284 -0
- package/cli/copy.mjs +42 -5
- package/cli/dispatcher-contract-record.mjs +3 -2
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor.mjs +17 -1
- package/cli/home-inspection.mjs +122 -0
- package/cli/home-plan-execute.mjs +140 -0
- package/cli/home-removal-plan.mjs +61 -0
- package/cli/hook-recognizer.mjs +78 -0
- package/cli/host-contract.mjs +11 -5
- package/cli/host-inspection.mjs +156 -0
- package/cli/host-removal.mjs +91 -0
- package/cli/image-evidence.mjs +44 -0
- package/cli/install-owners.mjs +244 -0
- package/cli/install-record.mjs +71 -9
- package/cli/install.mjs +88 -75
- package/cli/inventory-removal.mjs +84 -0
- package/cli/inventory-transition.mjs +66 -0
- package/cli/lifecycle-lock-clear.mjs +104 -0
- package/cli/lifecycle-lock-inspect.mjs +91 -0
- package/cli/lifecycle-lock.mjs +335 -0
- package/cli/lifecycle-transaction.mjs +57 -0
- package/cli/link-segments.mjs +45 -0
- package/cli/model-set.mjs +4 -3
- package/cli/model-speed.mjs +9 -2
- package/cli/model.mjs +5 -3
- package/cli/owner-record.mjs +40 -0
- package/cli/owner-sync-apply.mjs +108 -0
- package/cli/owner-sync.mjs +115 -0
- package/cli/projects.mjs +5 -2
- package/cli/prune-args.mjs +9 -4
- package/cli/prune.mjs +23 -72
- package/cli/purge-live-runs.mjs +98 -0
- package/cli/purge-preflight.mjs +103 -0
- package/cli/read.mjs +2 -1
- package/cli/record-removal.mjs +43 -0
- package/cli/removal-outcomes.mjs +115 -0
- package/cli/removal-plan.mjs +187 -0
- package/cli/remove-layout.mjs +40 -1
- package/cli/rules-owners.mjs +4 -2
- package/cli/terminal-question.mjs +41 -0
- package/cli/uninstall-purge.mjs +139 -0
- package/cli/uninstall-rules.mjs +72 -0
- package/cli/uninstall.mjs +208 -127
- package/cli/unlock-lifecycle.mjs +102 -0
- package/cli/unlock.mjs +7 -4
- package/cli/update.mjs +102 -27
- package/package.json +4 -3
- package/scripts/home-write-audit.mjs +286 -0
- package/scripts/home-write-inventory.mjs +49 -0
- package/scripts/home-write-lexer.mjs +182 -0
- package/scripts/isolated-roots.mjs +6 -1
- package/scripts/open-work/check.mjs +180 -0
- package/scripts/open-work/live-thresholds.mjs +155 -0
- package/scripts/open-work/register.mjs +56 -0
- package/scripts/root-guard/root-entries.mjs +15 -0
- package/scripts/run-tests.mjs +26 -1
- package/src/agents/codex-advisor.md +5 -2
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/guard-tries.mjs +6 -4
- package/src/home/hooks/order-gate.mjs +2 -8
- package/src/home/hooks/reply-guard.mjs +2 -7
- package/src/home/hooks/worktree-witness.mjs +6 -6
- package/src/home/lib/agents.mjs +1 -1
- package/src/home/lib/atomic-json.mjs +24 -6
- package/src/home/lib/config-edit.mjs +25 -10
- package/src/home/lib/dispatcher-state.mjs +25 -10
- package/src/home/lib/file-lock.mjs +41 -10
- package/src/home/lib/handback-witness.mjs +7 -4
- package/src/home/lib/home-registry.mjs +211 -0
- package/src/home/lib/home-write.mjs +189 -0
- package/src/home/lib/hook-diagnostics.mjs +20 -0
- package/src/home/lib/host-observations.mjs +7 -4
- package/src/home/lib/meta/advice-status.mjs +2 -2
- package/src/home/lib/meta/advice-verdict.mjs +30 -1
- package/src/home/lib/meta/events.mjs +2 -1
- package/src/home/lib/meta/paths.mjs +24 -24
- package/src/home/lib/meta/run-snapshots.mjs +29 -0
- package/src/home/lib/meta/snapshot-format.mjs +96 -0
- package/src/home/lib/meta/verdict.mjs +14 -16
- package/src/home/lib/run-config.mjs +10 -1
- package/src/home/lib/runner/git-paths.mjs +74 -0
- package/src/home/lib/runner/git-state.mjs +38 -34
- package/src/home/lib/runner/launcher.mjs +2 -2
- package/src/home/lib/runner/preflight.mjs +7 -0
- package/src/home/lib/runner/prompts.mjs +35 -2
- package/src/home/lib/runner/schemas.mjs +4 -1
- package/src/home/lib/runner/scope-check.mjs +29 -20
- package/src/home/lib/runner/worker.mjs +3 -3
package/cli/update.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { packageSource, sourceAt, checkoutAt } from './package-source.mjs';
|
|
5
5
|
import { install } from './install.mjs';
|
|
6
|
+
import { syncOtherOwners } from './owner-sync-apply.mjs';
|
|
6
7
|
import {
|
|
7
8
|
buildInstallPlan,
|
|
8
9
|
fileFingerprint,
|
|
@@ -18,15 +19,20 @@ import {
|
|
|
18
19
|
} from './manifest.mjs';
|
|
19
20
|
import { targetMatches } from './copy.mjs';
|
|
20
21
|
import { hookTargets, recordHasHooks } from './hook-targets.mjs';
|
|
21
|
-
import { definitionForRecordedHook, fileEntry, fingerprintFor } from './install-record.mjs';
|
|
22
|
+
import { definitionForRecordedHook, fileEntry, fingerprintFor, readInstallRecordFile } from './install-record.mjs';
|
|
23
|
+
import { ownImageStampCurrent } from './install-owners.mjs';
|
|
22
24
|
import {
|
|
23
25
|
commandFor,
|
|
24
26
|
inspectHook,
|
|
25
27
|
removeHook,
|
|
26
28
|
withSettingsRun,
|
|
27
29
|
} from './settings-merge.mjs';
|
|
30
|
+
import { withLifecycle } from './lifecycle-transaction.mjs';
|
|
28
31
|
import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
|
|
29
|
-
import {
|
|
32
|
+
import { removeEmptyLayout } from './remove-layout.mjs';
|
|
33
|
+
import { recordHomeWriter, removeOutside, removeRecordedFile } from './record-removal.mjs';
|
|
34
|
+
import { askTransition, detectTransition, transitionOutcome } from './inventory-transition.mjs';
|
|
35
|
+
import { hasPackageMarks, inspectHost } from './host-inspection.mjs';
|
|
30
36
|
|
|
31
37
|
const LEGACY_GUARD_LOG_FILES = [
|
|
32
38
|
'codex-reply-guard.blocked.json',
|
|
@@ -44,19 +50,19 @@ async function exists(target) {
|
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/** Drops the previous layout's record and its emptied directories; safe to run when both are gone. */
|
|
47
|
-
async function retireLegacyLayout(host) {
|
|
48
|
-
await
|
|
53
|
+
async function retireLegacyLayout(host, writer) {
|
|
54
|
+
await removeOutside(writer, legacyInstallRecordPath(host));
|
|
49
55
|
await removeEmptyLayout(host.legacyAgentsDir);
|
|
50
56
|
await removeEmptyLayout(host.legacyCommandsDir);
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
async function retireLegacyGuardLogs(host, { dryRun = false } = {}) {
|
|
59
|
+
async function retireLegacyGuardLogs(host, writer, { dryRun = false } = {}) {
|
|
54
60
|
const logDir = path.join(path.dirname(host.settingsPath), 'logs');
|
|
55
61
|
const lines = [];
|
|
56
62
|
for (const name of LEGACY_GUARD_LOG_FILES) {
|
|
57
63
|
const target = path.join(logDir, name);
|
|
58
64
|
if (!await exists(target)) continue;
|
|
59
|
-
if (!dryRun) await
|
|
65
|
+
if (!dryRun) await removeOutside(writer, target);
|
|
60
66
|
lines.push(`${dryRun ? 'Would remove' : 'Removed'} ${path.join('logs', name)}.`);
|
|
61
67
|
}
|
|
62
68
|
return lines.join('\n');
|
|
@@ -153,12 +159,47 @@ function previousHooks(host, record, targets) {
|
|
|
153
159
|
});
|
|
154
160
|
}
|
|
155
161
|
|
|
156
|
-
async function updateInRun({
|
|
162
|
+
async function updateInRun({
|
|
163
|
+
host,
|
|
164
|
+
dryRun = false,
|
|
165
|
+
force = false,
|
|
166
|
+
packageRoot,
|
|
167
|
+
env = process.env,
|
|
168
|
+
lifecycleTicket,
|
|
169
|
+
isTTY,
|
|
170
|
+
prompt,
|
|
171
|
+
stdin,
|
|
172
|
+
promptOutput,
|
|
173
|
+
} = {}) {
|
|
157
174
|
await readRulesRegistry(host);
|
|
158
175
|
const record = await readInstallRecord(host);
|
|
176
|
+
const rawRecord = await readInstallRecordFile(host);
|
|
159
177
|
if (!record) {
|
|
160
|
-
|
|
178
|
+
// Plan_65 D10 item 6: a host that carries our files or hooks but has no row (legacy is nobody's view
|
|
179
|
+
// since H5) is recorded by what is actually there. Install owns that — conflicts for edited files, the
|
|
180
|
+
// transition question — and update has changed nothing yet, so the question still precedes any change.
|
|
181
|
+
const inspection = await inspectHost(host);
|
|
182
|
+
if (!hasPackageMarks(inspection)) {
|
|
183
|
+
return { exitCode: 1, output: 'codex-bridge is not installed. Run codex-bridge install first.' };
|
|
184
|
+
}
|
|
185
|
+
const installed = await install({
|
|
186
|
+
host,
|
|
187
|
+
dryRun,
|
|
188
|
+
force,
|
|
189
|
+
packageRoot,
|
|
190
|
+
env,
|
|
191
|
+
lifecycleTicket,
|
|
192
|
+
isTTY,
|
|
193
|
+
prompt,
|
|
194
|
+
stdin,
|
|
195
|
+
promptOutput,
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
exitCode: installed.exitCode,
|
|
199
|
+
output: `No installation record names ${host.root}; recording what is installed.\n${installed.output}`,
|
|
200
|
+
};
|
|
161
201
|
}
|
|
202
|
+
const writer = recordHomeWriter(host, record.files);
|
|
162
203
|
|
|
163
204
|
const plan = await buildInstallPlan(host, packageRoot);
|
|
164
205
|
const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
|
|
@@ -210,6 +251,17 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
|
|
|
210
251
|
return { exitCode: 1, output: conflictOutput(conflicts, legacy, dryRun) };
|
|
211
252
|
}
|
|
212
253
|
|
|
254
|
+
// Plan_65 D9: ask before the first change (orphans, hooks, the legacy layout) and hand install the one
|
|
255
|
+
// answer: after that cleanup the nested run could neither ask honestly nor recount homeHadImage.
|
|
256
|
+
const { transition, homeHadImage } = await detectTransition(host);
|
|
257
|
+
let inventory;
|
|
258
|
+
if (transition && !dryRun) {
|
|
259
|
+
inventory = await askTransition(host, { isTTY, prompt, stdin, promptOutput });
|
|
260
|
+
if (inventory === 'cancel') {
|
|
261
|
+
return { exitCode: 130, output: 'Cancelled; nothing was changed.' };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
213
265
|
const currentPackage = await packageInfo(packageRoot);
|
|
214
266
|
// The 2026-09-07, 0.6.0 install incident hid which package's files update actually compared.
|
|
215
267
|
const resolved = packageRoot ? sourceAt(packageRoot) : packageSource();
|
|
@@ -232,51 +284,67 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
|
|
|
232
284
|
const recordCurrent = recordMatchesPackage(record, plan, currentPackage,
|
|
233
285
|
new Map(plannedStates.map((state) => [recordFileKey(state.item), state.fingerprint])),
|
|
234
286
|
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
235
|
-
|
|
287
|
+
// Plan_65 D9: a format-1 migration remains work when every installed file is current.
|
|
288
|
+
const changed = transition || states.some((state) => state.status !== 'up-to-date') || oldHooks.length > 0;
|
|
289
|
+
// D11: the command doctor recommends must clear its warning about this host's image stamp.
|
|
236
290
|
if (!changed && inspectedHooks.every(({ state }) => state.current)
|
|
237
|
-
&& recordHasHooks(record, targets) && recordCurrent) {
|
|
238
|
-
const legacyLogOutput = await retireLegacyGuardLogs(host, { dryRun });
|
|
291
|
+
&& recordHasHooks(record, targets) && recordCurrent && ownImageStampCurrent(rawRecord, host)) {
|
|
292
|
+
const legacyLogOutput = await retireLegacyGuardLogs(host, writer, { dryRun });
|
|
239
293
|
if (!dryRun) {
|
|
240
294
|
await addRulesOwner(host);
|
|
241
295
|
// An update that installed the new layout but stopped before retiring the old one used to
|
|
242
296
|
// land here forever after: everything it compares is current, so it reported "up to date"
|
|
243
297
|
// while agents/codex sat next to agents/codex-bridge. Retirement is idempotent, so running
|
|
244
298
|
// it on this path costs nothing when there is nothing left to retire.
|
|
245
|
-
await retireLegacyLayout(host);
|
|
299
|
+
await retireLegacyLayout(host, writer);
|
|
246
300
|
}
|
|
301
|
+
const ownerSync = await syncOtherOwners(host, { packageRoot, env, dryRun });
|
|
247
302
|
return {
|
|
248
|
-
exitCode: 0,
|
|
249
|
-
output: [`codex-bridge is up to date with ${source}${mismatch}`, legacyLogOutput]
|
|
303
|
+
exitCode: ownerSync.complete ? 0 : 1,
|
|
304
|
+
output: [`codex-bridge is up to date with ${source}${mismatch}`, legacyLogOutput, ...ownerSync.lines]
|
|
250
305
|
.filter(Boolean).join('\n'),
|
|
251
306
|
};
|
|
252
307
|
}
|
|
253
308
|
if (dryRun) {
|
|
254
|
-
const legacyLogOutput = await retireLegacyGuardLogs(host, { dryRun: true });
|
|
309
|
+
const legacyLogOutput = await retireLegacyGuardLogs(host, writer, { dryRun: true });
|
|
310
|
+
const ownerSync = await syncOtherOwners(host, { packageRoot, env, dryRun });
|
|
255
311
|
return {
|
|
256
312
|
exitCode: 0,
|
|
257
313
|
output: [`Would update codex-bridge with ${source}.${mismatch}`,
|
|
258
|
-
|
|
314
|
+
...(transition ? [`A real run would ask whether this host is the only one using ${host.brandRoot}.`] : []),
|
|
315
|
+
dryRunOutput(states, inspectedHooks, legacy, oldHooks), legacyLogOutput, ...ownerSync.lines]
|
|
259
316
|
.filter(Boolean).join('\n'),
|
|
260
317
|
};
|
|
261
318
|
}
|
|
262
319
|
|
|
263
320
|
for (const state of orphanStates) {
|
|
264
321
|
if (state.status !== 'orphaned' || !state.exists) continue;
|
|
265
|
-
|
|
266
|
-
await fs.rm(target, { force: true });
|
|
267
|
-
const boundary = state.entry.root === 'brand' ? host.brandRoot : claudeBoundary(host, target);
|
|
268
|
-
await removeEmptyParents(target, boundary);
|
|
322
|
+
await removeRecordedFile(host, writer, state.entry);
|
|
269
323
|
}
|
|
270
324
|
for (const { spec, hook } of oldHooks) {
|
|
271
325
|
await removeHook(host.settingsPath, spec, { createdGroup: hook.createdGroup === true });
|
|
272
326
|
}
|
|
273
|
-
const installed = await install({
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
327
|
+
const installed = await install({
|
|
328
|
+
host,
|
|
329
|
+
force: true,
|
|
330
|
+
packageRoot,
|
|
331
|
+
env,
|
|
332
|
+
lifecycleTicket,
|
|
333
|
+
isTTY,
|
|
334
|
+
prompt,
|
|
335
|
+
stdin,
|
|
336
|
+
promptOutput,
|
|
337
|
+
inventoryTransition: { transition, homeHadImage, inventory },
|
|
338
|
+
});
|
|
339
|
+
// Plan_65 D7: another owner left behind must not prevent this host's retirement and summary.
|
|
340
|
+
if (installed.exitCode !== 0 && installed.ownerSync?.complete !== false) return installed;
|
|
341
|
+
await retireLegacyLayout(host, writer);
|
|
342
|
+
const legacyLogOutput = await retireLegacyGuardLogs(host, writer);
|
|
343
|
+
const outcome = transition ? transitionOutcome(host, inventory) : null;
|
|
277
344
|
return {
|
|
278
|
-
exitCode:
|
|
279
|
-
output: [`${appliedOutput(states)}\nSource: ${source}${mismatch}`, legacyLogOutput
|
|
345
|
+
exitCode: installed.exitCode,
|
|
346
|
+
output: [`${appliedOutput(states)}\nSource: ${source}${mismatch}`, outcome, legacyLogOutput,
|
|
347
|
+
...installed.ownerSync.lines]
|
|
280
348
|
.filter(Boolean).join('\n'),
|
|
281
349
|
};
|
|
282
350
|
}
|
|
@@ -284,5 +352,12 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
|
|
|
284
352
|
export async function update(options = {}) {
|
|
285
353
|
const host = options?.host;
|
|
286
354
|
if (!host?.settingsPath) return updateInRun(options);
|
|
287
|
-
|
|
355
|
+
// A dry run only reads; like install's, it must not wait on or create anything.
|
|
356
|
+
if (options.dryRun === true) return withSettingsRun(host.settingsPath, () => updateInRun(options));
|
|
357
|
+
return withLifecycle(
|
|
358
|
+
host,
|
|
359
|
+
'update',
|
|
360
|
+
(ticket) => withSettingsRun(host.settingsPath, () => updateInRun({ ...options, lifecycleTicket: ticket })),
|
|
361
|
+
{ ticket: options.lifecycleTicket, waitMs: options.lifecycleWaitMs },
|
|
362
|
+
);
|
|
288
363
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Claude Code-to-Codex delegation with 7 guards,
|
|
3
|
+
"version": "0.6.9",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 7 guards, 2570 tests, zero runtime dependencies, and zero build steps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"codex",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"scripts": {
|
|
42
42
|
"dev:install": "node bin/codex-bridge.mjs update --force",
|
|
43
43
|
"test": "node scripts/run-tests.mjs",
|
|
44
|
-
"check:size": "node scripts/check-file-size.mjs"
|
|
44
|
+
"check:size": "node scripts/check-file-size.mjs",
|
|
45
|
+
"check:open-work": "node scripts/open-work/check.mjs --strict"
|
|
45
46
|
},
|
|
46
47
|
"publishConfig": {
|
|
47
48
|
"access": "public"
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks filesystem write boundaries before tests start. Plan_62 D22 made package-home writes
|
|
3
|
+
* removable only when they pass through the artifact adapter; Plan_65 B1b keeps that boundary
|
|
4
|
+
* enforceable while existing writers are migrated.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { lexSource } from './home-write-lexer.mjs';
|
|
9
|
+
|
|
10
|
+
const FILESYSTEM_MODULES = new Set(['node:fs', 'node:fs/promises', 'fs', 'fs/promises']);
|
|
11
|
+
const FILESYSTEM_WRITES = [
|
|
12
|
+
'writeFile', 'writeFileSync', 'appendFile', 'appendFileSync', 'rename', 'renameSync',
|
|
13
|
+
'copyFile', 'copyFileSync', 'cp', 'cpSync', 'mkdir', 'mkdirSync', 'mkdtemp', 'mkdtempSync',
|
|
14
|
+
'rm', 'rmSync', 'rmdir', 'rmdirSync', 'unlink', 'unlinkSync', 'open', 'openSync',
|
|
15
|
+
'createWriteStream', 'symlink', 'symlinkSync', 'link', 'linkSync', 'truncate', 'truncateSync',
|
|
16
|
+
'utimes', 'utimesSync',
|
|
17
|
+
];
|
|
18
|
+
const GENERIC_WRITES = ['withOutsideFileLock'];
|
|
19
|
+
const ADAPTER_MODULE = 'src/home/lib/home-write.mjs';
|
|
20
|
+
|
|
21
|
+
function modulePaths(repositoryRoot) {
|
|
22
|
+
const modules = [];
|
|
23
|
+
const visit = (directory) => {
|
|
24
|
+
if (!fs.existsSync(directory)) return;
|
|
25
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
26
|
+
const fullPath = path.join(directory, entry.name);
|
|
27
|
+
if (entry.isDirectory()) visit(fullPath);
|
|
28
|
+
else if (entry.isFile() && entry.name.endsWith('.mjs')) {
|
|
29
|
+
modules.push(path.relative(repositoryRoot, fullPath).split(path.sep).join('/'));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
for (const directory of ['src', 'cli', 'bin']) visit(path.join(repositoryRoot, directory));
|
|
34
|
+
return modules.sort();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function importBindings(source, code, module, violations) {
|
|
38
|
+
const bindings = [];
|
|
39
|
+
const importPattern = /\bimport\s+([\s\S]*?)\s+from\s+(['"])([^'"]+)\2/g;
|
|
40
|
+
for (const match of source.matchAll(importPattern)) {
|
|
41
|
+
if (!/^import\b/.test(code.slice(match.index))) continue;
|
|
42
|
+
if (!FILESYSTEM_MODULES.has(match[3])) continue;
|
|
43
|
+
const clauseOffset = match[0].indexOf(match[1]);
|
|
44
|
+
const clause = match[1].trim();
|
|
45
|
+
const leading = match[1].length - match[1].trimStart().length;
|
|
46
|
+
const defaultBinding = clause.match(/^([A-Za-z_$][\w$]*)\b/);
|
|
47
|
+
if (defaultBinding) {
|
|
48
|
+
bindings.push({
|
|
49
|
+
name: defaultBinding[1],
|
|
50
|
+
declarationIndex: match.index + clauseOffset + leading,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const namespaceBinding = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
54
|
+
if (namespaceBinding) {
|
|
55
|
+
const localOffset = clause.indexOf(namespaceBinding[1], namespaceBinding.index);
|
|
56
|
+
bindings.push({
|
|
57
|
+
name: namespaceBinding[1],
|
|
58
|
+
declarationIndex: match.index + clauseOffset + leading + localOffset,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
for (const named of clause.matchAll(/\{([^}]*)\}/g)) {
|
|
62
|
+
for (const specifier of named[1].split(',')) {
|
|
63
|
+
const parts = specifier.trim().split(/\s+as\s+/);
|
|
64
|
+
const imported = parts[0].trim();
|
|
65
|
+
if (FILESYSTEM_WRITES.includes(imported)) {
|
|
66
|
+
violations.push({
|
|
67
|
+
module,
|
|
68
|
+
sink: `named-import:${imported}`,
|
|
69
|
+
problem: `named filesystem write import${parts[1] ? ` as ${parts[1].trim()}` : ''} is forbidden`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return bindings;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function assignedBinding(source, index) {
|
|
79
|
+
const prefix = source.slice(Math.max(0, index - 100), index);
|
|
80
|
+
const match = prefix.match(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*$/);
|
|
81
|
+
if (!match) return null;
|
|
82
|
+
return {
|
|
83
|
+
name: match[1],
|
|
84
|
+
declarationIndex: Math.max(0, index - 100) + match.index + match[0].indexOf(match[1]),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function collectLoaderBindings(source, code, module, violations) {
|
|
89
|
+
const bindings = [];
|
|
90
|
+
const requirePattern = /\brequire\s*\(\s*(['"])(node:)?fs(?:\/promises)?\1\s*\)/g;
|
|
91
|
+
for (const match of source.matchAll(requirePattern)) {
|
|
92
|
+
if (!/^require\b/.test(code.slice(match.index))) continue;
|
|
93
|
+
violations.push({ module, sink: 'require:fs', problem: 'filesystem module loaded with require()' });
|
|
94
|
+
const binding = assignedBinding(source, match.index);
|
|
95
|
+
if (binding) bindings.push(binding);
|
|
96
|
+
}
|
|
97
|
+
const dynamicPattern = /\bimport\s*\(\s*(['"])(node:)?fs(?:\/promises)?\1\s*\)/g;
|
|
98
|
+
for (const match of source.matchAll(dynamicPattern)) {
|
|
99
|
+
if (!/^import\b/.test(code.slice(match.index))) continue;
|
|
100
|
+
violations.push({ module, sink: 'dynamic-import:fs', problem: 'filesystem module loaded with dynamic import()' });
|
|
101
|
+
const binding = assignedBinding(source, match.index);
|
|
102
|
+
if (binding) bindings.push(binding);
|
|
103
|
+
}
|
|
104
|
+
for (const match of source.matchAll(/\bcreateRequire\s*\(/g)) {
|
|
105
|
+
if (!/^createRequire\b/.test(code.slice(match.index))) continue;
|
|
106
|
+
violations.push({ module, sink: 'createRequire', problem: 'createRequire() can bypass filesystem binding checks' });
|
|
107
|
+
}
|
|
108
|
+
return bindings;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function valueUses(code, name) {
|
|
112
|
+
const safe = escaped(name);
|
|
113
|
+
const pattern = new RegExp(`\\b${safe}\\b`, 'g');
|
|
114
|
+
for (const match of code.matchAll(pattern)) {
|
|
115
|
+
const before = code.slice(0, match.index).match(/(?:\\?\.|\.)\s*$/);
|
|
116
|
+
const after = code.slice(match.index + match[0].length).match(/^\s*(?:\?\.|\.)/);
|
|
117
|
+
if (!before && !after) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function collectBindings(source, code, module, violations) {
|
|
123
|
+
const bindings = [
|
|
124
|
+
...importBindings(source, code, module, violations),
|
|
125
|
+
...collectLoaderBindings(source, code, module, violations),
|
|
126
|
+
];
|
|
127
|
+
const byName = new Map();
|
|
128
|
+
const add = (binding) => {
|
|
129
|
+
const record = byName.get(binding.name) ?? { name: binding.name, declarations: new Set() };
|
|
130
|
+
if (binding.declarationIndex !== undefined) record.declarations.add(binding.declarationIndex);
|
|
131
|
+
byName.set(binding.name, record);
|
|
132
|
+
};
|
|
133
|
+
for (const binding of bindings) {
|
|
134
|
+
add(binding);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let added;
|
|
138
|
+
do {
|
|
139
|
+
added = false;
|
|
140
|
+
const declarations = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\r\n]+)/g;
|
|
141
|
+
for (const match of code.matchAll(declarations)) {
|
|
142
|
+
if (byName.has(match[1])) continue;
|
|
143
|
+
if (![...byName.keys()].some((name) => valueUses(match[2], name))) continue;
|
|
144
|
+
add({ name: match[1], declarationIndex: match.index + match[0].indexOf(match[1]) });
|
|
145
|
+
added = true;
|
|
146
|
+
}
|
|
147
|
+
} while (added);
|
|
148
|
+
return [...byName.values()];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function escaped(value) {
|
|
152
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function hasDeclaration(code, name) {
|
|
156
|
+
const safe = escaped(name);
|
|
157
|
+
return new RegExp(`\\bfunction\\s+${safe}\\s*\\(|\\b(?:const|let|var)\\s+${safe}\\s*=`).test(code);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function callCounts(code, bindings, module, helperDefiners) {
|
|
161
|
+
const counts = new Map();
|
|
162
|
+
const writeAlternation = FILESYSTEM_WRITES.join('|');
|
|
163
|
+
for (const { name: binding } of bindings) {
|
|
164
|
+
const safe = escaped(binding);
|
|
165
|
+
const calls = new RegExp(`\\b${safe}\\s*(?:\\?\\.\\s*|\\.\\s*)(?:promises\\s*\\.\\s*)?(${writeAlternation})\\s*(?:\\?\\.)?\\s*\\(`, 'g');
|
|
166
|
+
for (const match of code.matchAll(calls)) counts.set(match[1], (counts.get(match[1]) ?? 0) + 1);
|
|
167
|
+
}
|
|
168
|
+
for (const name of GENERIC_WRITES) {
|
|
169
|
+
if (helperDefiners.get(name)?.has(module)) continue;
|
|
170
|
+
const calls = new RegExp(`\\b${name}\\s*\\(`, 'g');
|
|
171
|
+
for (const _match of code.matchAll(calls)) counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
172
|
+
}
|
|
173
|
+
return counts;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function scanModule({ module, original, actualCounts, violations, helperDefiners }) {
|
|
177
|
+
if (module === ADAPTER_MODULE) return;
|
|
178
|
+
const { code, withoutComments } = lexSource(original);
|
|
179
|
+
const bindings = collectBindings(withoutComments, code, module, violations);
|
|
180
|
+
const writeAlternation = FILESYSTEM_WRITES.join('|');
|
|
181
|
+
for (const { name: binding, declarations } of bindings) {
|
|
182
|
+
const safe = escaped(binding);
|
|
183
|
+
const computed = new RegExp(`\\b${safe}\\s*(?:\\.\\s*promises\\s*)?\\s*\\[`, 'g');
|
|
184
|
+
for (const match of code.matchAll(computed)) {
|
|
185
|
+
violations.push({ module, sink: 'computed-fs-access', problem: 'computed access on filesystem binding is forbidden' });
|
|
186
|
+
}
|
|
187
|
+
const calls = new RegExp(`\\b${safe}\\s*(?:\\?\\.\\s*|\\.\\s*)(?:promises\\s*\\.\\s*)?(${writeAlternation})\\s*(?:\\?\\.)?\\s*\\(`, 'g');
|
|
188
|
+
for (const match of code.matchAll(calls)) {
|
|
189
|
+
const key = `${module}\0${match[1]}`;
|
|
190
|
+
actualCounts.set(key, (actualCounts.get(key) ?? 0) + 1);
|
|
191
|
+
}
|
|
192
|
+
const references = new RegExp(`\\b${safe}\\s*(?:\\.\\s*promises\\s*)?\\.\\s*(${writeAlternation})\\b(?!\\s*(?:\\?\\.)?\\s*\\()`, 'g');
|
|
193
|
+
for (const match of code.matchAll(references)) {
|
|
194
|
+
violations.push({ module, sink: `write-reference:${match[1]}`, problem: 'filesystem write function is referenced without a call' });
|
|
195
|
+
}
|
|
196
|
+
const assignment = new RegExp(`(?<![\\w$.])${safe}\\s*(?:=(?!=|>)|\\+=|-=|\\*=|\\/=|\\+\\+|--)`, 'g');
|
|
197
|
+
for (const match of code.matchAll(assignment)) {
|
|
198
|
+
if (declarations.has(match.index)) continue;
|
|
199
|
+
const prefix = code.slice(Math.max(0, match.index - 30), match.index);
|
|
200
|
+
if (!/\b(?:const|let|var)\s+$/.test(prefix)) {
|
|
201
|
+
violations.push({ module, sink: 'fs-binding-reassigned', problem: 'filesystem binding is reassigned' });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const bindingUses = new RegExp(`\\b${safe}\\b`, 'g');
|
|
205
|
+
for (const match of code.matchAll(bindingUses)) {
|
|
206
|
+
if (declarations.has(match.index)) continue;
|
|
207
|
+
if (/(?:\\?\.|\.)\s*$/.test(code.slice(0, match.index))) continue;
|
|
208
|
+
if (/^\s*(?:\?\.|\.)/.test(code.slice(match.index + match[0].length))) continue;
|
|
209
|
+
const key = `${module}\0fs-binding-escape`;
|
|
210
|
+
actualCounts.set(key, (actualCounts.get(key) ?? 0) + 1);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const [sink, count] of callCounts(code, bindings, module, helperDefiners)) {
|
|
214
|
+
actualCounts.set(`${module}\0${sink}`, count);
|
|
215
|
+
}
|
|
216
|
+
const rawCounts = callCounts(withoutComments, bindings, module, helperDefiners);
|
|
217
|
+
const maskedCounts = callCounts(code, bindings, module, helperDefiners);
|
|
218
|
+
// A literal that swallowed code and a literal that is code (cli/probe-rig.mjs writes a hook's
|
|
219
|
+
// source as a template) look the same to a lexer. Both are counted as `literal:<sink>` and must be
|
|
220
|
+
// inventoried with their exact count, so a literal can be explained but never grow unnoticed.
|
|
221
|
+
for (const [sink, rawCount] of rawCounts) {
|
|
222
|
+
const hidden = rawCount - (maskedCounts.get(sink) ?? 0);
|
|
223
|
+
if (hidden > 0) actualCounts.set(`${module}literal:${sink}`, hidden);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function discoverHelperDefiners(modules, repositoryRoot) {
|
|
228
|
+
const definers = new Map(GENERIC_WRITES.map((name) => [name, new Set()]));
|
|
229
|
+
for (const module of modules) {
|
|
230
|
+
const code = lexSource(fs.readFileSync(path.join(repositoryRoot, module), 'utf8')).code;
|
|
231
|
+
for (const name of GENERIC_WRITES) {
|
|
232
|
+
if (hasDeclaration(code, name)) definers.get(name).add(module);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return definers;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Returns unaccounted writes, inventory count drift, and fail-closed binding violations. */
|
|
239
|
+
export function auditWrites({ repositoryRoot, inventory }) {
|
|
240
|
+
const modules = modulePaths(repositoryRoot);
|
|
241
|
+
const moduleSet = new Set(modules);
|
|
242
|
+
const violations = [];
|
|
243
|
+
const actualCounts = new Map();
|
|
244
|
+
const inventoryCounts = new Map();
|
|
245
|
+
for (const entry of inventory) {
|
|
246
|
+
const key = `${entry.module}\0${entry.sink}`;
|
|
247
|
+
if (inventoryCounts.has(key)) {
|
|
248
|
+
violations.push({ module: entry.module, sink: entry.sink, problem: 'duplicate inventory entry' });
|
|
249
|
+
}
|
|
250
|
+
inventoryCounts.set(key, entry.count);
|
|
251
|
+
if (!moduleSet.has(entry.module)) {
|
|
252
|
+
violations.push({ module: entry.module, sink: entry.sink, problem: 'inventory entry names a missing module' });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const helperDefiners = discoverHelperDefiners(modules, repositoryRoot);
|
|
256
|
+
for (const module of modules) {
|
|
257
|
+
scanModule({
|
|
258
|
+
module,
|
|
259
|
+
original: fs.readFileSync(path.join(repositoryRoot, module), 'utf8'),
|
|
260
|
+
actualCounts,
|
|
261
|
+
violations,
|
|
262
|
+
helperDefiners,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
for (const [key, count] of actualCounts) {
|
|
266
|
+
const [module, sink] = key.split('\0');
|
|
267
|
+
const expected = inventoryCounts.get(key);
|
|
268
|
+
if (expected === undefined) {
|
|
269
|
+
violations.push({ module, sink, problem: `not inventoried (actual count ${count})` });
|
|
270
|
+
} else if (count > expected) {
|
|
271
|
+
violations.push({ module, sink, problem: `actual count ${count} exceeds inventory count ${expected}` });
|
|
272
|
+
} else if (count < expected) {
|
|
273
|
+
violations.push({ module, sink, problem: `actual count ${count} is below inventory count ${expected}` });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
for (const [key, expected] of inventoryCounts) {
|
|
277
|
+
if (actualCounts.has(key)) continue;
|
|
278
|
+
const [module, sink] = key.split('\0');
|
|
279
|
+
if (moduleSet.has(module)) {
|
|
280
|
+
violations.push({ module, sink, problem: `actual count 0 is below inventory count ${expected}` });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
violations.sort((left, right) => left.module.localeCompare(right.module)
|
|
284
|
+
|| left.sink.localeCompare(right.sink) || left.problem.localeCompare(right.problem));
|
|
285
|
+
return { violations };
|
|
286
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freezes the writes that predate Plan_65 B1b so each new write or stale exception is visible.
|
|
3
|
+
* Home entries name their registered artifact and the Plan_65 order that removes the exception.
|
|
4
|
+
*/
|
|
5
|
+
const outside = (module, sink, count, reason) => ({ module, sink, count, kind: 'outside-home', reason });
|
|
6
|
+
|
|
7
|
+
export const HOME_WRITE_INVENTORY = Object.freeze([
|
|
8
|
+
outside('cli/copy.mjs', 'mkdir', 1, 'Raw route of the planned-file copier, taken only after writer.assertOutside proves the target is beyond the home (Claude host files, Codex rules).'),
|
|
9
|
+
outside('cli/copy.mjs', 'rename', 1, 'Raw route of the planned-file copier, taken only after writer.assertOutside proves the target is beyond the home (Claude host files, Codex rules).'),
|
|
10
|
+
outside('cli/copy.mjs', 'rm', 1, 'Raw route of the planned-file copier, taken only after writer.assertOutside proves the target is beyond the home (Claude host files, Codex rules).'),
|
|
11
|
+
outside('cli/copy.mjs', 'writeFile', 1, 'Raw route of the planned-file copier, taken only after writer.assertOutside proves the target is beyond the home (Claude host files, Codex rules).'),
|
|
12
|
+
outside('cli/probe-contract.mjs', 'mkdir', 1, 'Creates the throwaway probe rig root.'),
|
|
13
|
+
outside('cli/probe-contract.mjs', 'mkdtemp', 1, 'Creates a unique throwaway probe rig.'),
|
|
14
|
+
outside('cli/probe-contract.mjs', 'rm', 1, 'Removes the throwaway probe rig after the contract probe.'),
|
|
15
|
+
outside('cli/probe-rig.mjs', 'literal:appendFileSync', 2, 'Source text of the probe hook, written into and run only inside the throwaway probe rig.'),
|
|
16
|
+
outside('cli/probe-rig.mjs', 'mkdir', 2, 'Creates hook and agent directories inside the throwaway probe rig.'),
|
|
17
|
+
outside('cli/probe-rig.mjs', 'writeFile', 3, 'Writes hook, agent, and settings fixtures inside the throwaway probe rig.'),
|
|
18
|
+
outside('cli/prune.mjs', 'rmSync', 1, 'Deletes the explicitly selected run or archived transport target.'),
|
|
19
|
+
outside('cli/remove-layout.mjs', 'rmdir', 1, 'Removes empty host package directories (agents, commands, their legacy layout); home folders go through the adapter (Plan_65 B15b2).'),
|
|
20
|
+
outside('cli/rules-owners.mjs', 'rename', 1, 'Publishes the shared host rules ownership registry.'),
|
|
21
|
+
outside('cli/rules-owners.mjs', 'rm', 2, 'Removes a failed temporary copy or the host rules ownership registry.'),
|
|
22
|
+
outside('cli/rules-owners.mjs', 'withOutsideFileLock', 1, 'Locks the shared registry under the host Codex rules directory.'),
|
|
23
|
+
outside('cli/rules-owners.mjs', 'writeFile', 1, 'Writes a temporary shared host rules ownership registry.'),
|
|
24
|
+
outside('cli/settings-merge.mjs', 'mkdir', 1, 'Creates the host settings.json parent directory.'),
|
|
25
|
+
outside('cli/settings-merge.mjs', 'rename', 1, 'Atomically publishes the host settings.json update.'),
|
|
26
|
+
outside('cli/settings-merge.mjs', 'rm', 1, 'Removes a failed temporary host settings.json update.'),
|
|
27
|
+
outside('cli/settings-merge.mjs', 'writeFile', 2, 'Writes the host settings.json backup and temporary replacement.'),
|
|
28
|
+
outside('cli/record-removal.mjs', 'rm', 1, 'Raw removal taken only after writer.assertOutside proves the target is beyond the home (Claude host files, Codex rules, legacy records, logs and migrated seeds).'),
|
|
29
|
+
outside('src/home/lib/config-edit.mjs', 'rmdir', 1, 'Removes empty ancestors of the home root that this edit created, above the home.'),
|
|
30
|
+
outside('src/home/lib/file-lock.mjs', 'mkdir', 1, 'Raw route of the lock, taken only after writer.assertOutside proves the lock is beyond the home (Codex rules registry).'),
|
|
31
|
+
outside('src/home/lib/file-lock.mjs', 'open', 1, 'Raw route of the lock, taken only after writer.assertOutside proves the lock is beyond the home (Codex rules registry).'),
|
|
32
|
+
outside('src/home/lib/file-lock.mjs', 'rm', 1, 'Raw route of the lock, taken only after writer.assertOutside proves the lock is beyond the home (Codex rules registry).'),
|
|
33
|
+
outside('src/home/lib/heartbeat.mjs', 'writeFileSync', 1, 'Heartbeat stamp inside a run folder under the runs root.'),
|
|
34
|
+
outside('src/home/lib/host-version.mjs', 'open', 1, 'Read-only open of a run transcript tail with the r flag.'),
|
|
35
|
+
outside('src/home/lib/meta/run-state.mjs', 'writeFileSync', 3, 'Writes status.json and meta.json inside run folders under the runs root.'),
|
|
36
|
+
outside('src/home/lib/run-codex.mjs', 'appendFileSync', 1, 'Appends a crash diagnostic to stderr.log inside a run folder.'),
|
|
37
|
+
outside('src/home/lib/retention.mjs', 'fs-binding-escape', 1, 'The injected filesystem falls back to fs while cleaning run folders under the runs root, outside package home.'),
|
|
38
|
+
outside('src/home/lib/retention.mjs', 'unlinkSync', 1, 'Removes transport files inside run folders under the runs root, outside package home.'),
|
|
39
|
+
outside('src/home/lib/runner/git-state.mjs', 'openSync', 1, 'Read-only open with the r flag, hashing an untracked file of the worked repository in chunks (Plan_73 B4b).'),
|
|
40
|
+
outside('src/home/lib/runner/codex-cmd.mjs', 'createWriteStream', 2, 'Appends stderr.log and events.jsonl inside a run folder.'),
|
|
41
|
+
outside('src/home/lib/runner/launcher.mjs', 'mkdirSync', 2, 'Creates project and run folders under the runs root.'),
|
|
42
|
+
outside('src/home/lib/runner/launcher.mjs', 'writeFileSync', 11, 'Writes run prompt, environment, scope, schema, and launch evidence into a run folder.'),
|
|
43
|
+
outside('src/home/lib/runner/project-dir.mjs', 'mkdirSync', 2, 'Creates the runs root and a project folder under the runs root.'),
|
|
44
|
+
outside('src/home/lib/runner/project-dir.mjs', 'writeFileSync', 1, 'Writes the project ownership marker under the runs root.'),
|
|
45
|
+
outside('src/home/lib/runner/run-context.mjs', 'writeFileSync', 1, 'Writes a worker reply inside a run folder.'),
|
|
46
|
+
outside('src/home/lib/runner/worker-order.mjs', 'writeFileSync', 1, 'Writes worker.json inside a run folder.'),
|
|
47
|
+
outside('src/home/lib/runner/worker.mjs', 'writeFileSync', 7, 'Writes worker git snapshots and report.md inside a run folder.'),
|
|
48
|
+
outside('src/home/lib/write-meta.mjs', 'writeFileSync', 1, 'Writes meta.json inside a run folder under the runs root.'),
|
|
49
|
+
].map((entry) => Object.freeze(entry)));
|