@codedology/code-forge 0.1.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.
Files changed (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Grok 1.0.34 argv builder (plan §5.2, pinned against `test/fixtures/help/grok-1.0.34.txt`). Pure:
3
+ * no I/O, no CLI spawning.
4
+ *
5
+ * Like Codex, Grok's flag table has only two command shapes — coder, and "reviewer / judge / S2 /
6
+ * author / facts" — so `facts` routes through the same closed-book branch as the other four roles
7
+ * (Claude is the only provider with a facts-specific carve-out).
8
+ *
9
+ * The coder command has **no `-p`**: Grok's `-p`/`--single` flag is a different, single-turn mode
10
+ * the coder role must not use (the plan calls this out explicitly). The forbidden list arrives the
11
+ * same way Claude's does — as a `renderedForbidden` parameter, defaulting to
12
+ * `renderForGrok(FORBIDDEN)` from B8's landed extensions, never a hard dependency baked in.
13
+ *
14
+ * Grok's `--deny <RULE>` fixture help shows a single value, not a variadic `<RULE...>` — unlike
15
+ * Claude's documented `<tools...>` for `--disallowedTools`. This builder repeats `--deny <rule>`
16
+ * once per rendered rule string — a scalar (single-value) flag repeated, never a variadic-list
17
+ * swallow risk the way `claude.mjs`'s BLOCKER fix had to guard against, since each `--deny`
18
+ * occurrence's value slot is exactly one token wide.
19
+ *
20
+ * **Return contract (fix round 3):** `{cli, role, argv, cwd, forbiddenRendered?}` — never a
21
+ * `stdinFile`. Unlike Claude/Codex closed-book runs, Grok's `--prompt-file <PATH>` ("Single-turn
22
+ * prompt from a file", pinned help) makes the CLI itself read the packet and send its CONTENT as
23
+ * the prompt, so even a closed-book run with every tool denied receives the packet text; the
24
+ * spawner pipes nothing to stdin.
25
+ *
26
+ * Effort is sent as the canonical `--reasoning-effort <EFFORT>` (the pinned help lists `--effort`
27
+ * only as an alias), so the flag the builder emits is the exact one `probe.mjs` checks (fix round 3,
28
+ * found by the widened all-roles probe-coverage test).
29
+ *
30
+ * **Fix round 1 (isolated per-file review):**
31
+ * - Unknown `role` now throws (was silently routed to closed-book) — `assertBaseParams`.
32
+ * - `cwd` is now required and validated, same as `model`/`promptPath` — `assertBaseParams`.
33
+ * - A coder-role render with 0 usable rules now throws (see `claude.mjs`'s identical guard).
34
+ * - `effort`, when given, must be a non-empty string (was pushed into argv unchecked).
35
+ */
36
+
37
+ import { mergeForbidden, renderForGrok } from '../../util/forbidden.mjs';
38
+ import { flattenRuleStrings, hasAnyRenderedRule } from '../render-rules.mjs';
39
+ import { assertBaseParams } from './validate-params.mjs';
40
+
41
+ export const CLI = 'grok';
42
+
43
+ /**
44
+ * Built-in tool names the closed-book command denies wholesale via `--disallowed-tools` (in
45
+ * addition to the wildcard `--deny "*"`, per plan §5.2). Not independently verified against a
46
+ * machine-readable Grok tool list (none is documented in the pinned `--help`) — a reasonable,
47
+ * conservative superset, flagged in the facts diff.
48
+ * @type {ReadonlyArray<string>}
49
+ */
50
+ export const GROK_ALL_BUILTIN_TOOLS = Object.freeze(['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebFetch', 'WebSearch']);
51
+
52
+ /**
53
+ * @typedef {{id: string, rules: ReadonlyArray<string>, enforced: boolean}} RenderedGrokEntry
54
+ */
55
+
56
+ /**
57
+ * @typedef {object} GrokBuildParams
58
+ * @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
59
+ * @property {string} model
60
+ * @property {string} [effort] - a non-empty string when given.
61
+ * @property {string} promptPath - `--prompt-file` value (brief for coder, packet for closed-book).
62
+ * @property {string} cwd
63
+ * @property {ReadonlyArray<RenderedGrokEntry>} [renderedForbidden] - coder role only; defaults to
64
+ * `renderForGrok(mergeForbidden())` (B4.2).
65
+ * @property {Record<string, any>} [schema] - closed-book only; sent inline like Claude's.
66
+ * @property {string} [systemPromptText] - closed-book only (`--system-prompt-override`).
67
+ */
68
+
69
+ /**
70
+ * @param {unknown} effort
71
+ * @throws {TypeError} unless `effort` is `undefined` or a non-empty string.
72
+ */
73
+ function assertValidEffort(effort) {
74
+ if (effort !== undefined && (typeof effort !== 'string' || effort.length === 0)) {
75
+ throw new TypeError(`buildGrokArgv: effort must be a non-empty string when given, got ${JSON.stringify(effort)}`);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @param {GrokBuildParams} params
81
+ * @returns {{cli: "grok", role: "coder", argv: string[], cwd: string, forbiddenRendered: ReadonlyArray<RenderedGrokEntry>}}
82
+ */
83
+ function buildCoderArgv(params) {
84
+ // B4.2: the coder's deny list is `mergeForbidden()` (FORBIDDEN + the coder-only entries). A
85
+ // `contains` entry renders 0 `--deny` rules; the pre-spawn check and the transcript grep keep it.
86
+ const { model, effort, promptPath, cwd, renderedForbidden = renderForGrok(mergeForbidden()) } = params;
87
+ assertValidEffort(effort);
88
+ if (!hasAnyRenderedRule(renderedForbidden)) {
89
+ throw new Error('buildGrokArgv: coder role requires a non-empty forbidden-list render (got 0 usable rules)');
90
+ }
91
+ const argv = ['grok', '--prompt-file', promptPath, '-m', model];
92
+ if (effort) argv.push('--reasoning-effort', effort);
93
+ argv.push('--permission-mode', 'bypassPermissions');
94
+ argv.push('--cwd', cwd);
95
+ for (const rule of flattenRuleStrings(renderedForbidden)) {
96
+ argv.push('--deny', rule);
97
+ }
98
+ return { cli: CLI, role: 'coder', argv, cwd, forbiddenRendered: renderedForbidden };
99
+ }
100
+
101
+ /**
102
+ * @param {GrokBuildParams} params
103
+ * @returns {{cli: "grok", role: "reviewer"|"judge"|"s2"|"author"|"facts", argv: string[], cwd: string}}
104
+ */
105
+ function buildClosedBookArgv(params) {
106
+ const { model, effort, promptPath, cwd, schema, systemPromptText } = params;
107
+ assertValidEffort(effort);
108
+ // See `builders/claude.mjs`'s identical cast: the dispatch below never reaches this branch
109
+ // with role 'coder', but that is a runtime invariant, not provable from the parameter type.
110
+ const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
111
+ const argv = ['grok', '--prompt-file', promptPath, '-m', model];
112
+ if (effort) argv.push('--reasoning-effort', effort);
113
+ if (schema !== undefined) argv.push('--json-schema', JSON.stringify(schema));
114
+ argv.push('--disallowed-tools', GROK_ALL_BUILTIN_TOOLS.join(','));
115
+ argv.push('--deny', '*');
116
+ argv.push('--no-plan');
117
+ argv.push('--no-subagents');
118
+ argv.push('--max-turns', '1');
119
+ argv.push('--permission-mode', 'dontAsk');
120
+ argv.push('--cwd', cwd);
121
+ if (systemPromptText !== undefined) argv.push('--system-prompt-override', systemPromptText);
122
+ return { cli: CLI, role, argv, cwd };
123
+ }
124
+
125
+ /**
126
+ * @param {GrokBuildParams} params
127
+ * @returns {ReturnType<typeof buildCoderArgv> | ReturnType<typeof buildClosedBookArgv>}
128
+ */
129
+ export function buildGrokArgv(params) {
130
+ assertBaseParams('buildGrokArgv', params);
131
+ return params.role === 'coder' ? buildCoderArgv(params) : buildClosedBookArgv(params);
132
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Generic per-provider argv-builder dispatch (plan §5.2). A later block (B9's `spawn.mjs`) calls
3
+ * `buildArgv({provider, ...})` rather than importing all three provider builders and branching
4
+ * itself.
5
+ *
6
+ * Every builder returns `{cli, role, argv, cwd, stdinFile?, outPath?, ...}` (fix round 3). The
7
+ * spawner MUST honour two fields: when `stdinFile` is set, pipe that file's CONTENT to the child's
8
+ * stdin (Claude and Codex closed-book roles — their argv carries no prompt pointer); when
9
+ * `outPath` is set (Codex, always), read the result from it and delete it afterwards.
10
+ */
11
+
12
+ import { buildClaudeArgv } from './claude.mjs';
13
+ import { buildCodexArgv } from './codex.mjs';
14
+ import { buildGrokArgv } from './grok.mjs';
15
+
16
+ export { buildClaudeArgv } from './claude.mjs';
17
+ export { buildCodexArgv } from './codex.mjs';
18
+ export { buildGrokArgv } from './grok.mjs';
19
+
20
+ /** @type {Readonly<Record<"anthropic"|"openai"|"xai", (params: any) => any>>} */
21
+ const BUILDERS = Object.freeze({ anthropic: buildClaudeArgv, openai: buildCodexArgv, xai: buildGrokArgv });
22
+
23
+ /**
24
+ * @param {{provider: "anthropic"|"openai"|"xai"} & Record<string, any>} params
25
+ * @returns {any} whichever provider builder's return shape (`{cli, role, argv, cwd, stdinFile?, outPath?, ...}`).
26
+ */
27
+ export function buildArgv(params) {
28
+ if (!params || !Object.hasOwn(BUILDERS, params.provider ?? '')) {
29
+ // Fix round 1 (MINOR): the message is now built from BUILDERS itself — the same object the
30
+ // check above uses — so it can never name a provider the check doesn't actually accept (the
31
+ // old version imported a SEPARATE `PROVIDER_CLI_NAMES` map for the message, which could drift).
32
+ throw new TypeError(`buildArgv: unknown provider "${params?.provider}" (expected one of ${Object.keys(BUILDERS).join(', ')})`);
33
+ }
34
+ return BUILDERS[/** @type {"anthropic"|"openai"|"xai"} */ (params.provider)](params);
35
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared parameter validation for the three provider builders (fix round 1, findings #2 MAJOR ×2
3
+ * + cwd MAJOR). Written once so `claude.mjs`/`codex.mjs`/`grok.mjs` cannot drift from each other on
4
+ * what counts as a valid `role`/`model`/`promptPath`/`cwd` — the review found `codex.mjs` and
5
+ * `grok.mjs` silently routed ANY non-'coder' string (a typo, `undefined`, a stray level name like
6
+ * `'L2'`) into the closed-book branch instead of refusing it.
7
+ */
8
+
9
+ /**
10
+ * The six roles the plan's engine adapters section (§5.2) declares.
11
+ * @type {ReadonlyArray<"coder"|"reviewer"|"judge"|"s2"|"author"|"facts">}
12
+ */
13
+ export const VALID_ROLES = Object.freeze(/** @type {const} */ (['coder', 'reviewer', 'judge', 's2', 'author', 'facts']));
14
+
15
+ /**
16
+ * @param {string} builderName - for the thrown message, e.g. `"buildClaudeArgv"`.
17
+ * @param {unknown} params
18
+ * @throws {TypeError} for a missing/invalid `role`, `model`, `promptPath` or `cwd`.
19
+ */
20
+ export function assertBaseParams(builderName, params) {
21
+ const p = /** @type {Record<string, unknown> | null | undefined} */ (params);
22
+ if (!p || typeof p !== 'object' || !VALID_ROLES.includes(/** @type {any} */ (p.role))) {
23
+ throw new TypeError(`${builderName}: params.role must be one of ${VALID_ROLES.join(', ')}, got ${JSON.stringify(p?.role)}`);
24
+ }
25
+ if (typeof p.model !== 'string' || p.model.length === 0) {
26
+ throw new TypeError(`${builderName}: params.model is required (non-empty string)`);
27
+ }
28
+ if (typeof p.promptPath !== 'string' || p.promptPath.length === 0) {
29
+ throw new TypeError(`${builderName}: params.promptPath is required (non-empty string)`);
30
+ }
31
+ if (typeof p.cwd !== 'string' || p.cwd.length === 0) {
32
+ throw new TypeError(`${builderName}: params.cwd is required (non-empty string)`);
33
+ }
34
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Per-session `CODEX_HOME` for the Codex coder (block B4.1, plan §5.2 Codex row, §8.4, [A19]).
3
+ *
4
+ * Facts (Codex 0.155.1, read-only checks on this Mac):
5
+ * - `codex exec --help`: `--ignore-rules Do not load user or project execpolicy \`.rules\` files`
6
+ * — `exec` loads execpolicy rules, but has NO flag naming a rules file, and `ConfigToml` has no
7
+ * rules-path key, so `-c` cannot name one either.
8
+ * - The "user" rules are `$CODEX_HOME/rules/*.rules` (`~/.codex/rules/default.rules` on this Mac;
9
+ * the binary logs "loaded … .rules files in …"). `CODEX_HOME` is therefore the one per-run
10
+ * handle: the coder runs with `CODEX_HOME=<run temp root>/…/codex-home`, whose
11
+ * `rules/code-forge.rules` holds the rendered forbidden list. The user's real `~/.codex` (and
12
+ * its own `rules/`) is never written.
13
+ * - The file format is execpolicy Starlark, one `prefix_rule(pattern=[…], decision="forbidden",
14
+ * justification="…")` per token-array prefix (`codex execpolicy check --rules <PATH> <COMMAND>…`
15
+ * parses the same files; the doctor/facts evidence ran it against a rendered file).
16
+ * - `--ignore-user-config`'s help says "auth still uses `CODEX_HOME`", so a fresh home has no
17
+ * login. The real home's `auth.json` is COPIED in (0600; the bytes pass through one Buffer that
18
+ * is never decoded, logged or kept), never symlinked: a symlink would let a write inside the
19
+ * session home reach the real file. Both files the session home receives (the rules file and
20
+ * the auth copy) are written with `writeFreshFile`: the destination is `lstat`ed and refused
21
+ * when ANYTHING already exists there (a planted symlink included), then created with
22
+ * `O_CREAT|O_EXCL|O_NOFOLLOW`, so a token can never be written through a link (L3 patch).
23
+ * Tradeoff: a token refresh inside the session is NOT written back to the real
24
+ * `auth.json`; if Codex rotates the refresh token, the user's own Codex may have to log in
25
+ * again. With no `auth.json` present, Codex uses its env auth (`CODEX_API_KEY` /
26
+ * `OPENAI_API_KEY`). The user's Codex TOML config is NOT linked: the coder argv carries every
27
+ * setting it needs, and the user's own allow-rules stay out of a coder session.
28
+ *
29
+ * INVARIANT (fix round 1): the coder's sandbox can never write the session home. The home lives
30
+ * under the run temp root; the builder runs the coder with `-s workspace-write` PLUS
31
+ * `-c sandbox_workspace_write.exclude_tmpdir_env_var=true` and
32
+ * `-c sandbox_workspace_write.exclude_slash_tmp=true` (both keys are in the 0.155.1 binary), so the
33
+ * only writable root is the project `-C <cwd>` — and a home inside that cwd is refused. On top, the
34
+ * rules file is 0444. The directories stay 0700, not 0555: a 0555 directory makes the run-root
35
+ * sweep's `rmSync(…, {recursive, force})` fail (ENOTEMPTY, checked on this Mac), which would leave
36
+ * the whole run root — the auth copy included — behind. Tradeoff of the tmp exclusion: the coder's
37
+ * own commands cannot write `$TMPDIR` or `/tmp` either.
38
+ */
39
+
40
+ import { randomBytes } from 'node:crypto';
41
+ import { closeSync, constants, existsSync, fchmodSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeSync } from 'node:fs';
42
+ import os from 'node:os';
43
+ import path from 'node:path';
44
+ import { currentRunRoot } from '../util/tmp.mjs';
45
+
46
+ /** The rules file name inside `<codexHome>/rules/`. Codex loads every `*.rules` file there. */
47
+ export const CODEX_RULES_FILE_NAME = 'code-forge.rules';
48
+
49
+ /**
50
+ * @typedef {{id: string, patterns: ReadonlyArray<ReadonlyArray<string>>, enforced: boolean, description?: string}} CodexRenderedEntry
51
+ */
52
+
53
+ /**
54
+ * The user's real Codex home: `$CODEX_HOME` when set, else `$HOME/.codex` (`os.homedir()` without `HOME`).
55
+ * @param {NodeJS.ProcessEnv} [env]
56
+ * @returns {string} an absolute path.
57
+ */
58
+ export function realCodexHome(env = process.env) {
59
+ const fromEnv = env.CODEX_HOME;
60
+ const home = typeof env.HOME === 'string' && env.HOME.length > 0 ? env.HOME : os.homedir();
61
+ return path.resolve(typeof fromEnv === 'string' && fromEnv.length > 0 ? fromEnv : path.join(home, '.codex'));
62
+ }
63
+
64
+ /** @param {string} p @returns {string} the realpath of the longest existing ancestor + the rest. */
65
+ function resolveExisting(p) {
66
+ let head = path.resolve(p);
67
+ const tail = [];
68
+ while (!existsSync(head)) {
69
+ const parent = path.dirname(head);
70
+ if (parent === head) break;
71
+ tail.unshift(path.basename(head));
72
+ head = parent;
73
+ }
74
+ return path.join(realpathSync(head), ...tail);
75
+ }
76
+
77
+ /**
78
+ * Whether `candidate` is `dir` or inside it (both sides symlink-resolved).
79
+ * @param {string} candidate @param {string} dir @returns {boolean}
80
+ */
81
+ export function isInside(candidate, dir) {
82
+ const rel = path.relative(resolveExisting(dir), resolveExisting(candidate));
83
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
84
+ }
85
+
86
+ /**
87
+ * Whether `candidate` is the real Codex home or inside it (both sides symlink-resolved).
88
+ * @param {string} candidate @param {NodeJS.ProcessEnv} [env] @returns {boolean}
89
+ */
90
+ export function isInsideRealCodexHome(candidate, env = process.env) {
91
+ return isInside(candidate, realCodexHome(env));
92
+ }
93
+
94
+ /**
95
+ * Execpolicy Starlark for a `renderForCodex` render: one `prefix_rule` per pattern, in entry
96
+ * order. `JSON.stringify` of a string is a valid Starlark string literal for these ASCII tokens.
97
+ * @param {ReadonlyArray<CodexRenderedEntry>} rendered
98
+ * @returns {{content: string, count: number}}
99
+ */
100
+ export function renderCodexRules(rendered) {
101
+ const lines = ['# code-forge forbidden list (src/util/forbidden.mjs). Generated per session; do not edit.'];
102
+ let count = 0;
103
+ for (const entry of rendered) {
104
+ for (const pattern of entry.patterns) {
105
+ const tokens = pattern.map((t) => JSON.stringify(String(t))).join(', ');
106
+ lines.push(`prefix_rule(pattern=[${tokens}], decision="forbidden", justification=${JSON.stringify(`code-forge: ${entry.id}`)})`);
107
+ count += 1;
108
+ }
109
+ }
110
+ return { content: `${lines.join('\n')}\n`, count };
111
+ }
112
+
113
+ /** The name of the run-root directory every session home lives in. */
114
+ export const CODEX_HOMES_DIR_NAME = 'codex-homes';
115
+
116
+ /** @returns {string} `<currentRunRoot()>/codex-homes`, the one parent of every session home. */
117
+ export function sessionCodexHomesDir() {
118
+ return path.join(currentRunRoot(), CODEX_HOMES_DIR_NAME);
119
+ }
120
+
121
+ /**
122
+ * Whether `candidate` is STRICTLY inside `<currentRunRoot()>/codex-homes/` (both sides
123
+ * symlink-resolved through their nearest existing ancestor). The directory itself, the run root,
124
+ * the real Codex home, `$HOME` and any sibling are all outside.
125
+ * @param {string} candidate @returns {boolean}
126
+ */
127
+ export function isSessionCodexHome(candidate) {
128
+ if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) return false;
129
+ const rel = path.relative(resolveExisting(sessionCodexHomesDir()), resolveExisting(candidate));
130
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
131
+ }
132
+
133
+ /**
134
+ * A fresh per-session Codex home path under the run temp root.
135
+ * @returns {string}
136
+ */
137
+ export function defaultCodexHome() {
138
+ return path.join(sessionCodexHomesDir(), `coder-${randomBytes(6).toString('hex')}`);
139
+ }
140
+
141
+ /**
142
+ * Create `<codexHome>/rules/code-forge.rules` (dirs 0700, file 0444) and copy the real home's
143
+ * `auth.json` (0600) when it exists.
144
+ * @param {string} codexHome - absolute; never the real Codex home or inside it.
145
+ * @param {ReadonlyArray<CodexRenderedEntry>} rendered
146
+ * @param {{env?: NodeJS.ProcessEnv}} [opts]
147
+ * @returns {{codexHome: string, rulesPath: string, count: number, content: string}}
148
+ * @throws {Error} when `codexHome` is relative, is (or is inside) the real Codex home, or the
149
+ * render yields 0 rules.
150
+ */
151
+ export function prepareCodexHome(codexHome, rendered, opts = {}) {
152
+ const env = opts.env ?? process.env;
153
+ if (typeof codexHome !== 'string' || !path.isAbsolute(codexHome)) {
154
+ throw new TypeError('prepareCodexHome: codexHome must be an absolute path');
155
+ }
156
+ if (isInsideRealCodexHome(codexHome, env)) {
157
+ throw new Error('prepareCodexHome: codexHome must not be the real Codex home or inside it');
158
+ }
159
+ // Fix round 4: an ANCESTOR of the real home (`$HOME`, `/`) is not "inside" it, yet a home there
160
+ // would put `rules/` and an auth copy where the user lives. The only place a session home may be
161
+ // is strictly inside `<run root>/codex-homes/` — the same confinement `removeCodexHome` enforces.
162
+ if (!isSessionCodexHome(codexHome)) {
163
+ throw new Error(`prepareCodexHome: codexHome must be strictly inside <run root>/${CODEX_HOMES_DIR_NAME}/`);
164
+ }
165
+ const { content, count } = renderCodexRules(rendered);
166
+ if (count === 0) throw new Error('prepareCodexHome: the forbidden-list render has 0 rules');
167
+ const rulesDir = path.join(codexHome, 'rules');
168
+ const rulesPath = path.join(rulesDir, CODEX_RULES_FILE_NAME);
169
+ const auth = path.join(realCodexHome(env), 'auth.json');
170
+ const copy = path.join(codexHome, 'auth.json');
171
+ // L3 patch: both destinations are checked BEFORE anything is written, so a planted entry (a
172
+ // symlink at either path) makes this throw with the home left exactly as found.
173
+ refuseExisting(rulesPath);
174
+ refuseExisting(copy);
175
+ mkdirSync(rulesDir, { recursive: true, mode: 0o700 });
176
+ if (lstatSync(rulesDir).isSymbolicLink()) throw new Error('prepareCodexHome: refusing a symlinked rules directory');
177
+ writeFreshFile(rulesPath, Buffer.from(content, 'utf8'), 0o444);
178
+ if (existsSync(auth)) {
179
+ writeFreshFile(copy, readFileSync(auth), 0o600);
180
+ }
181
+ return { codexHome, rulesPath, count, content };
182
+ }
183
+
184
+ /**
185
+ * Throw when anything at all (file, directory, symlink — dangling or not) already exists at `dest`.
186
+ * @param {string} dest
187
+ */
188
+ function refuseExisting(dest) {
189
+ try {
190
+ lstatSync(dest);
191
+ } catch (err) {
192
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return;
193
+ throw err;
194
+ }
195
+ throw new Error(`prepareCodexHome: refusing to write ${path.basename(dest)}: something already exists at the destination`);
196
+ }
197
+
198
+ /**
199
+ * Create `dest` exclusively, never following a link (`O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW`, mode
200
+ * 0600), write `bytes`, set the final `mode` on the open descriptor, close. A path that gained an
201
+ * entry between `refuseExisting` and this open fails on `O_EXCL` — nothing is ever written through it.
202
+ * @param {string} dest @param {Buffer} bytes @param {number} mode
203
+ */
204
+ function writeFreshFile(dest, bytes, mode) {
205
+ const { O_WRONLY, O_CREAT, O_EXCL, O_NOFOLLOW } = constants;
206
+ const fd = openSync(dest, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600);
207
+ try {
208
+ let offset = 0;
209
+ while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset);
210
+ fchmodSync(fd, mode);
211
+ } finally {
212
+ closeSync(fd);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Remove a session home (the auth copy with it). The ONLY recursive delete of this module, and it
218
+ * is confined (fix round 3): `codexHome` must be strictly inside `<currentRunRoot()>/codex-homes/`
219
+ * after realpath resolution, or this throws and deletes nothing — the real `~/.codex`, `$HOME`,
220
+ * the run root itself and any sibling directory are refused. No-op when the (confined) path does
221
+ * not exist.
222
+ * @param {string} codexHome
223
+ * @throws {Error} when `codexHome` is not strictly inside the run root's `codex-homes/`.
224
+ */
225
+ export function removeCodexHome(codexHome) {
226
+ if (!isSessionCodexHome(codexHome)) {
227
+ throw new Error(`removeCodexHome: refusing to delete a path outside <run root>/${CODEX_HOMES_DIR_NAME}/`);
228
+ }
229
+ rmSync(resolveExisting(codexHome), { recursive: true, force: true });
230
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The empty-cwd temp dir every closed-book session (reviewer, judge, S2, author, facts) runs in
3
+ * (plan §4.3, §5.2). A fresh `os.tmpdir()`-rooted directory holding EXACTLY the packet/brief file
4
+ * a caller writes into it — no project source, no `CLAUDE.md`/`AGENTS.md`, nothing a closed-book
5
+ * session could read besides what it was explicitly handed. `doctor`'s isolation probe (B13, §2.3)
6
+ * asks a closed-book session to echo the first line of the project's `CLAUDE.md` — this module is
7
+ * why that should come back empty: the session's cwd never contained one.
8
+ *
9
+ * **Fix round 1 (isolated per-file review):**
10
+ * - `fileName` now also refuses `.` and `..` (previously only a `/`/`\` check — `path.join(dir,
11
+ * '..')` points OUTSIDE the fresh dir; the old code didn't escape, since `writeFile` on a
12
+ * directory path fails with `EISDIR`, but the caller got a vague filesystem error instead of
13
+ * the documented `TypeError`).
14
+ * - A `writeFile` failure (bad content type, disk full, …) after `mkdtemp` no longer leaks an
15
+ * empty `code-forge-isolated-*` directory: the dir is removed before rethrowing.
16
+ * - `cleanupIsolatedWorkspace` now also requires `dir`'s PARENT to be the temp root (or the
17
+ * `baseDir` the caller passes, matching `createIsolatedWorkspace`'s own override) — the old
18
+ * basename-prefix-only check would recursively delete ANY directory anywhere on disk that
19
+ * happened to be named `code-forge-isolated-*`, not only ones this module actually created.
20
+ */
21
+
22
+ import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+
26
+ /** Directory name prefix for every isolated workspace this module creates. */
27
+ const DIR_PREFIX = 'code-forge-isolated-';
28
+
29
+ /**
30
+ * @param {string} fileName - a plain basename, e.g. `"packet.json"` — never a path (a caller
31
+ * trying to escape the fresh temp dir is a bug in the caller, not something to silently "fix").
32
+ * @param {string} content
33
+ * @param {{baseDir?: string}} [opts] - `baseDir` overrides `os.tmpdir()` (tests only).
34
+ * @returns {Promise<{dir: string, filePath: string}>}
35
+ * @throws {TypeError} if `fileName` contains a path separator, is empty, or is `.`/`..`.
36
+ */
37
+ export async function createIsolatedWorkspace(fileName, content, opts = {}) {
38
+ if (
39
+ typeof fileName !== 'string' ||
40
+ fileName.length === 0 ||
41
+ fileName.includes('/') ||
42
+ fileName.includes('\\') ||
43
+ fileName === '.' ||
44
+ fileName === '..'
45
+ ) {
46
+ throw new TypeError(`createIsolatedWorkspace: fileName must be a plain basename, got ${JSON.stringify(fileName)}`);
47
+ }
48
+ const dir = await mkdtemp(path.join(opts.baseDir ?? os.tmpdir(), DIR_PREFIX));
49
+ const filePath = path.join(dir, fileName);
50
+ try {
51
+ await writeFile(filePath, content, 'utf8');
52
+ } catch (err) {
53
+ await rm(dir, { recursive: true, force: true });
54
+ throw err;
55
+ }
56
+ return { dir, filePath };
57
+ }
58
+
59
+ /**
60
+ * @param {string} dir
61
+ * @returns {Promise<number>} how many entries `dir` currently holds (doctor / tests use this to
62
+ * prove isolation: a freshly created workspace must list exactly 1).
63
+ */
64
+ export async function countWorkspaceEntries(dir) {
65
+ const entries = await readdir(dir);
66
+ return entries.length;
67
+ }
68
+
69
+ /**
70
+ * @param {string} dir - MUST be one this module created: its basename must start with
71
+ * {@link DIR_PREFIX} AND its parent directory must be `baseDir` (default `os.tmpdir()`).
72
+ * @param {{baseDir?: string}} [opts] - MUST match whatever `baseDir` `createIsolatedWorkspace` was
73
+ * called with, when it was overridden.
74
+ * @returns {Promise<void>}
75
+ * @throws {TypeError} if `dir` does not satisfy both checks above.
76
+ */
77
+ export async function cleanupIsolatedWorkspace(dir, opts = {}) {
78
+ const expectedParent = path.resolve(opts.baseDir ?? os.tmpdir());
79
+ const actualParent = path.dirname(path.resolve(dir));
80
+ if (!path.basename(dir).startsWith(DIR_PREFIX) || actualParent !== expectedParent) {
81
+ throw new TypeError(`cleanupIsolatedWorkspace: refusing to remove a directory this module did not create: ${dir}`);
82
+ }
83
+ await rm(dir, { recursive: true, force: true });
84
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The flag probe (plan §5.2, §2.3): the literal flags every builder in `builders/{claude,codex,
3
+ * grok}.mjs` depends on, checked as literal substrings of a `--help` text, WITH a word-boundary
4
+ * check so a shorter required flag can never be satisfied by a longer one that merely starts with
5
+ * it. No pinned CLI documents a machine-readable `--help` format, so this is a substring probe,
6
+ * not a parser — the same standard B0's own header check used ("the Claude fixture contains
7
+ * `--safe-mode` and does not contain `--max-turns`", plan header, B0 acceptance (7)).
8
+ *
9
+ * `doctor` (B13, §2.3) re-runs this probe against the REAL installed CLI's live `--help` output at
10
+ * runtime — this module is what it calls; this block only proves the probe itself works, against
11
+ * the pinned fixtures (which must all pass) and against a deliberately mutilated copy of one
12
+ * (which must fail, naming exactly the flag that was removed).
13
+ *
14
+ * **Fix round 1 (isolated per-file review, MINOR ×2):**
15
+ * - `REQUIRED_FLAGS[provider]` is now `Object.hasOwn`-guarded — `probeHelpText('constructor', …)`
16
+ * used to fall through to `Object.prototype.constructor` (truthy) instead of the documented
17
+ * "unknown provider" `Error`, then crash on `.filter` not being a function.
18
+ * - A match is only counted when the character immediately before and after it is NOT part of an
19
+ * identifier or another flag token (`[A-Za-z0-9-]`) — previously `--json` was satisfied by the
20
+ * unrelated, LONGER `--json-schema`/`--jsonl`, so removing the real `--json` flag while a longer
21
+ * one remained went undetected.
22
+ */
23
+
24
+ /**
25
+ * The flags each builder actually emits, one literal phrase per flag as it appears in the pinned
26
+ * `--help` fixture (the exact spelling `probeHelpText` checks with a boundary-aware substring
27
+ * search).
28
+ * @type {Readonly<Record<"claude"|"codex"|"grok", ReadonlyArray<string>>>}
29
+ */
30
+ export const REQUIRED_FLAGS = Object.freeze({
31
+ claude: Object.freeze([
32
+ '-p, --print',
33
+ '--model <model>',
34
+ '--effort <level>',
35
+ '--permission-mode <mode>',
36
+ '--output-format <format>',
37
+ '--no-session-persistence',
38
+ '--max-budget-usd <amount>',
39
+ '--disallowedTools, --disallowed-tools <tools...>',
40
+ '--fallback-model <model>',
41
+ '--safe-mode',
42
+ '--tools <tools...>',
43
+ '--strict-mcp-config',
44
+ '--json-schema <schema>',
45
+ '--system-prompt <prompt>',
46
+ '--restricted',
47
+ ]),
48
+ codex: Object.freeze([
49
+ '-m, --model <MODEL>',
50
+ '-c, --config <key=value>',
51
+ '-s, --sandbox <SANDBOX_MODE>',
52
+ '-C, --cd <DIR>',
53
+ '--json',
54
+ '-o, --output-last-message <FILE>',
55
+ '--ephemeral',
56
+ '--ignore-rules',
57
+ '--ignore-user-config',
58
+ '--skip-git-repo-check',
59
+ '--output-schema <FILE>',
60
+ ]),
61
+ grok: Object.freeze([
62
+ '--prompt-file <PATH>',
63
+ '-m, --model <MODEL>',
64
+ '--reasoning-effort <EFFORT>',
65
+ '--permission-mode <MODE>',
66
+ '--cwd <CWD>',
67
+ '--deny <RULE>',
68
+ '--json-schema <SCHEMA>',
69
+ '--disallowed-tools <TOOLS>',
70
+ '--no-plan',
71
+ '--no-subagents',
72
+ '--max-turns <N>',
73
+ '--system-prompt-override <PROMPT>',
74
+ ]),
75
+ });
76
+
77
+ /** @typedef {{ok: boolean, missing: string[]}} ProbeResult */
78
+
79
+ /** A char that would extend an identifier/flag token if it sat right next to a match. */
80
+ const WORD_CHAR_RE = /[A-Za-z0-9-]/;
81
+
82
+ /**
83
+ * @param {string} haystack
84
+ * @param {string} needle
85
+ * @returns {boolean} true only if `needle` occurs in `haystack` with a non-word character (or
86
+ * string start/end) on BOTH sides — so `--json` does not match inside `--json-schema`.
87
+ */
88
+ function containsAsWholeToken(haystack, needle) {
89
+ let fromIndex = 0;
90
+ for (;;) {
91
+ const at = haystack.indexOf(needle, fromIndex);
92
+ if (at === -1) return false;
93
+ const before = at > 0 ? haystack[at - 1] : '';
94
+ const after = at + needle.length < haystack.length ? haystack[at + needle.length] : '';
95
+ if (!WORD_CHAR_RE.test(before) && !WORD_CHAR_RE.test(after)) return true;
96
+ fromIndex = at + 1;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * @param {"claude"|"codex"|"grok"} provider
102
+ * @param {string} helpText - the CLI's own `--help` output (or a pinned fixture's contents).
103
+ * @returns {ProbeResult}
104
+ * @throws {Error} for an unknown provider.
105
+ */
106
+ export function probeHelpText(provider, helpText) {
107
+ if (typeof provider !== 'string' || !Object.hasOwn(REQUIRED_FLAGS, provider)) {
108
+ throw new Error(`probeHelpText: unknown provider "${provider}"`);
109
+ }
110
+ if (typeof helpText !== 'string') {
111
+ throw new TypeError('probeHelpText: helpText must be a string');
112
+ }
113
+ const required = REQUIRED_FLAGS[/** @type {"claude"|"codex"|"grok"} */ (provider)];
114
+ const missing = required.filter((flag) => !containsAsWholeToken(helpText, flag));
115
+ return { ok: missing.length === 0, missing };
116
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The one place that maps a config `provider` to the CLI binary `engine: subprocess` spawns for
3
+ * it (plan §5.2). `src/cli/validate.mjs` (B1) keeps its own private copy for `hasCliOnPath` — this
4
+ * module is the shared, exported version every `src/engines/**` builder and `src/cli/resolve.mjs`
5
+ * use, so the mapping is written once on this side of the seam (B1 landed first; B4 does not edit
6
+ * B1's file to add an export it didn't already have).
7
+ */
8
+
9
+ /** @typedef {"anthropic" | "openai" | "xai"} Provider */
10
+
11
+ /** @type {Readonly<Record<Provider, string>>} */
12
+ export const PROVIDER_CLI_NAMES = Object.freeze({ anthropic: 'claude', openai: 'codex', xai: 'grok' });
13
+
14
+ /**
15
+ * `Object.hasOwn`-guarded (see `known-ids.mjs`'s `knownIdsForProvider` for why a bare index lookup
16
+ * is unsafe for a string that might be `"constructor"` or another `Object.prototype` member).
17
+ * @param {string} provider
18
+ * @returns {string | undefined} the CLI binary name, or `undefined` for an unknown provider.
19
+ */
20
+ export function cliNameForProvider(provider) {
21
+ return typeof provider === 'string' && Object.hasOwn(PROVIDER_CLI_NAMES, provider)
22
+ ? PROVIDER_CLI_NAMES[/** @type {Provider} */ (provider)]
23
+ : undefined;
24
+ }