diff --git a/docs/claims/codex-loop-bash-retirement-allowlist-integrity-20260526.md b/docs/claims/codex-loop-bash-retirement-allowlist-integrity-20260526.md new file mode 100644 index 0000000000..3dc55335b6 --- /dev/null +++ b/docs/claims/codex-loop-bash-retirement-allowlist-integrity-20260526.md @@ -0,0 +1,36 @@ +# Claim: codex-loop-bash-retirement-allowlist-integrity-20260526 + +claimed-at: 2026-05-26T23:34:00Z +agent: Codex +session: codex/launchd-loop +surface: codex-background-service +origin: codex-launchd-loop +run-id: 20260526T232949Z +branch: claim/codex-loop-bash-retirement-allowlist-integrity-20260526 +worktree: /Users/acehack/.local/share/zeta-codex-loop/Zeta-worktrees/codex-loop-bash-retirement-allowlist-integrity-20260526 + +## Scope + +Trajectory: TypeScript / Bun migration. + +Bounded step: harden the bash-retirement inventory guard so the retained shell +allowlist is itself checked for duplicate or unsorted entries before it is used +to classify repo `.sh` drift. + +## Paths + +- tools/hygiene/check-bash-retirement-inventory.ts +- tools/hygiene/check-bash-retirement-inventory.test.ts +- docs/claims/codex-loop-bash-retirement-allowlist-integrity-20260526.md + +## Non-Scope + +- No shell-script porting or deletion. +- No changes to the retained shell allowlist membership. +- No edits in the contested root checkout. + +## Acceptance Check + +- `bun test tools/hygiene/check-bash-retirement-inventory.test.ts` +- `bun run hygiene:check-bash-retirement-inventory` +- `node_modules/.bin/tsc --noEmit -p tsconfig.json` diff --git a/tools/hygiene/check-bash-retirement-inventory.test.ts b/tools/hygiene/check-bash-retirement-inventory.test.ts index 356fec1cb0..77bda93007 100644 --- a/tools/hygiene/check-bash-retirement-inventory.test.ts +++ b/tools/hygiene/check-bash-retirement-inventory.test.ts @@ -15,11 +15,21 @@ function splitExpectedRetained(): readonly [string, readonly string[]] { return [missing, rest]; } +function firstTwoExpectedRetained(): readonly [string, string, readonly string[]] { + const [first, second, ...rest] = EXPECTED_RETAINED_SHELL; + if (first === undefined || second === undefined) { + throw new Error("expected retained shell allowlist must contain at least two entries"); + } + return [first, second, rest]; +} + describe("buildInventoryReport", () => { test("accepts the retained shell allowlist", () => { const report = buildInventoryReport(EXPECTED_RETAINED_SHELL); expect(hasDrift(report)).toBe(false); + expect(report.allowlistIntegrity.duplicateEntries).toEqual([]); + expect(report.allowlistIntegrity.orderViolations).toEqual([]); expect(report.retained).toHaveLength(EXPECTED_RETAINED_SHELL.length); expect(report.drift.unexpected).toEqual([]); expect(report.drift.missingRetained).toEqual([]); @@ -53,6 +63,28 @@ describe("buildInventoryReport", () => { expect(report.drift.missingRetained).toEqual([missing]); }); + test("flags duplicate allowlist entries before classifying repo shell drift", () => { + const [duplicate, rest] = splitExpectedRetained(); + const report = buildInventoryReport(EXPECTED_RETAINED_SHELL, [duplicate, duplicate, ...rest]); + + expect(hasDrift(report)).toBe(true); + expect(report.allowlistIntegrity.duplicateEntries).toEqual([duplicate]); + expect(report.allowlistIntegrity.orderViolations).toEqual([]); + expect(report.drift.unexpected).toEqual([]); + expect(report.drift.missingRetained).toEqual([]); + }); + + test("flags unsorted allowlist entries before classifying repo shell drift", () => { + const [first, second, rest] = firstTwoExpectedRetained(); + const report = buildInventoryReport(EXPECTED_RETAINED_SHELL, [second, first, ...rest]); + + expect(hasDrift(report)).toBe(true); + expect(report.allowlistIntegrity.duplicateEntries).toEqual([]); + expect(report.allowlistIntegrity.orderViolations).toEqual([{ index: 1, previous: second, current: first }]); + expect(report.drift.unexpected).toEqual([]); + expect(report.drift.missingRetained).toEqual([]); + }); + test("matches the current tracked repo shell inventory", () => { const report = buildInventoryReport(trackedNonLeanShellFilesFromGit()); @@ -78,4 +110,14 @@ describe("renderReport", () => { expect(rendered).toContain(`## Missing retained ${RETAINED_SHELL_SCOPE} files`); expect(rendered).toContain(missing); }); + + test("renders allowlist integrity errors before drift sections", () => { + const [duplicate, rest] = splitExpectedRetained(); + const rendered = renderReport(buildInventoryReport(EXPECTED_RETAINED_SHELL, [duplicate, duplicate, ...rest])); + + expect(rendered).toContain("## Retained shell allowlist integrity errors"); + expect(rendered).toContain("### Duplicate entries"); + expect(rendered).toContain(duplicate); + expect(rendered).not.toContain("## Unexpected non-Lean shell files"); + }); }); diff --git a/tools/hygiene/check-bash-retirement-inventory.ts b/tools/hygiene/check-bash-retirement-inventory.ts index 540d896e0b..0432b2c797 100644 --- a/tools/hygiene/check-bash-retirement-inventory.ts +++ b/tools/hygiene/check-bash-retirement-inventory.ts @@ -30,15 +30,26 @@ interface InventoryDrift { readonly missingRetained: readonly string[]; } +interface AllowlistOrderViolation { + readonly index: number; + readonly previous: string; + readonly current: string; +} + +interface AllowlistIntegrity { + readonly duplicateEntries: readonly string[]; + readonly orderViolations: readonly AllowlistOrderViolation[]; +} + export interface InventoryReport { readonly retained: readonly string[]; readonly expectedRetained: readonly string[]; + readonly allowlistIntegrity: AllowlistIntegrity; readonly drift: InventoryDrift; } const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; -export const RETAINED_SHELL_SCOPE = - "repo-wide setup/bootstrap/service-wrapper/installer/dev-cluster allowlist"; +export const RETAINED_SHELL_SCOPE = "repo-wide setup/bootstrap/service-wrapper/installer/dev-cluster allowlist"; export const EXPECTED_RETAINED_SHELL: readonly string[] = [ ".gemini/service/install-lior-service.sh", @@ -119,15 +130,56 @@ export function trackedNonLeanShellFilesFromGit(): readonly string[] { export const trackedNonLeanBashFilesFromGit = trackedNonLeanShellFilesFromGit; +function inspectAllowlistIntegrity(expectedRetained: readonly string[]): AllowlistIntegrity { + const counts = new Map(); + const orderViolations: AllowlistOrderViolation[] = []; + + for (let index = 0; index < expectedRetained.length; index += 1) { + const current = expectedRetained[index]; + if (current === undefined) continue; + counts.set(current, (counts.get(current) ?? 0) + 1); + + const previous = expectedRetained[index - 1]; + if (previous !== undefined && previous.localeCompare(current) > 0) { + orderViolations.push({ index, previous, current }); + } + } + + const duplicateEntries = [...counts.entries()] + .filter(([, count]) => count > 1) + .map(([file]) => file) + .sort((a, b) => a.localeCompare(b)); + + return { duplicateEntries, orderViolations }; +} + +function hasAllowlistIntegrityDrift(integrity: AllowlistIntegrity): boolean { + return integrity.duplicateEntries.length > 0 || integrity.orderViolations.length > 0; +} + export function buildInventoryReport( retained: readonly string[], expectedRetained: readonly string[] = EXPECTED_RETAINED_SHELL, ): InventoryReport { + const allowlistIntegrity = inspectAllowlistIntegrity(expectedRetained); + if (hasAllowlistIntegrityDrift(allowlistIntegrity)) { + return { + retained: [...retained].sort((a, b) => a.localeCompare(b)), + expectedRetained: [...expectedRetained], + allowlistIntegrity, + drift: { + unexpected: [], + missingRetained: [], + }, + }; + } + const retainedSet = new Set(retained); const expectedSet = new Set(expectedRetained); return { retained: [...retained].sort((a, b) => a.localeCompare(b)), - expectedRetained: [...expectedRetained].sort((a, b) => a.localeCompare(b)), + expectedRetained: [...expectedRetained], + allowlistIntegrity, drift: { unexpected: retained.filter((file) => !expectedSet.has(file)).sort((a, b) => a.localeCompare(b)), missingRetained: expectedRetained.filter((file) => !retainedSet.has(file)).sort((a, b) => a.localeCompare(b)), @@ -136,7 +188,11 @@ export function buildInventoryReport( } export function hasDrift(report: InventoryReport): boolean { - return report.drift.unexpected.length > 0 || report.drift.missingRetained.length > 0; + return ( + hasAllowlistIntegrityDrift(report.allowlistIntegrity) || + report.drift.unexpected.length > 0 || + report.drift.missingRetained.length > 0 + ); } export function renderReport(report: InventoryReport): string { @@ -145,6 +201,8 @@ export function renderReport(report: InventoryReport): string { lines.push(""); lines.push(`retained_non_lean_shell: ${String(report.retained.length)}`); lines.push(`expected_retained: ${String(report.expectedRetained.length)}`); + lines.push(`allowlist_duplicates: ${String(report.allowlistIntegrity.duplicateEntries.length)}`); + lines.push(`allowlist_order_violations: ${String(report.allowlistIntegrity.orderViolations.length)}`); lines.push(`unexpected: ${String(report.drift.unexpected.length)}`); lines.push(`missing_retained: ${String(report.drift.missingRetained.length)}`); lines.push(""); @@ -152,6 +210,27 @@ export function renderReport(report: InventoryReport): string { lines.push(`OK: retained non-Lean shell surface matches ${RETAINED_SHELL_SCOPE}.`); return `${lines.join("\n")}\n`; } + if (hasAllowlistIntegrityDrift(report.allowlistIntegrity)) { + lines.push("## Retained shell allowlist integrity errors"); + lines.push(""); + lines.push("The retained shell allowlist must be unique and sorted before repo shell drift is classified."); + lines.push(""); + if (report.allowlistIntegrity.duplicateEntries.length > 0) { + lines.push("### Duplicate entries"); + lines.push(""); + for (const file of report.allowlistIntegrity.duplicateEntries) lines.push(`- ${file}`); + lines.push(""); + } + if (report.allowlistIntegrity.orderViolations.length > 0) { + lines.push("### Out-of-order entries"); + lines.push(""); + for (const violation of report.allowlistIntegrity.orderViolations) { + lines.push(`- index ${String(violation.index)}: ${violation.previous} > ${violation.current}`); + } + lines.push(""); + } + return `${lines.join("\n")}\n`; + } if (report.drift.unexpected.length > 0) { lines.push("## Unexpected non-Lean shell files"); lines.push("");