@codedology/code-forge 0.9.0 → 0.11.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 (62) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +49 -9
  3. package/docs/reference/blocks.json +587 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/SKILL.md +1 -1
  8. package/skill/references/adapters/claude-code.md +1 -1
  9. package/skill/references/adapters/solo.md +2 -2
  10. package/skill/references/adapters/subprocess.md +12 -2
  11. package/skill/references/code.md +14 -7
  12. package/skill/references/ledger.md +2 -2
  13. package/skill/references/proof.md +77 -2
  14. package/skill/references/review.md +39 -7
  15. package/skill/references/security.md +4 -4
  16. package/skill/scripts/forge +1 -1
  17. package/skill/templates/coder-brief.md +6 -6
  18. package/src/autopilot/binnacle.mjs +9 -3
  19. package/src/cli/block.mjs +262 -31
  20. package/src/cli/jev.mjs +22 -3
  21. package/src/cli/keys.mjs +156 -21
  22. package/src/cli/proof.mjs +35 -7
  23. package/src/cli/review-file.mjs +17 -11
  24. package/src/cli/spawn.mjs +44 -3
  25. package/src/config/validate.mjs +320 -5
  26. package/src/doctor/index.mjs +2 -1
  27. package/src/doctor/local.mjs +27 -6
  28. package/src/doctor/probes.mjs +38 -0
  29. package/src/engines/builders/codex.mjs +44 -4
  30. package/src/engines/sandbox-dirs.mjs +257 -0
  31. package/src/keys/store.mjs +128 -21
  32. package/src/proof/junit.mjs +530 -0
  33. package/src/proof/red-green.mjs +57 -9
  34. package/src/proof/red-parse.mjs +2 -0
  35. package/src/proof/runners.mjs +472 -0
  36. package/src/proof/xcresult.mjs +285 -0
  37. package/src/review/engine.mjs +141 -42
  38. package/src/review/fixloop.mjs +75 -23
  39. package/src/review/gate-check.mjs +3 -2
  40. package/src/review/lenses/A.md +2 -0
  41. package/src/review/lenses/B.md +2 -0
  42. package/src/review/lenses/full.md +2 -0
  43. package/src/review/lenses/quick.md +1 -0
  44. package/src/review/lenses/recheck.md +2 -0
  45. package/src/review/packet.mjs +48 -14
  46. package/src/review/paired.mjs +80 -0
  47. package/src/review/reopen.mjs +4 -6
  48. package/src/review/reviewed-lines.mjs +94 -0
  49. package/src/review/route.mjs +232 -0
  50. package/src/review/schema-fallback.mjs +77 -22
  51. package/src/review/session-retry.mjs +44 -9
  52. package/src/review/test-quality.mjs +201 -0
  53. package/src/review/verb-forms.mjs +39 -0
  54. package/src/session/spawn.mjs +39 -2
  55. package/src/state/block-end.mjs +10 -6
  56. package/src/state/block.mjs +13 -3
  57. package/src/state/config-snapshot.mjs +39 -0
  58. package/src/state/run.mjs +4 -0
  59. package/src/util/forbidden.mjs +2 -2
  60. package/src/worker/engine.mjs +43 -15
  61. package/src/worker/loop.mjs +98 -15
  62. package/src/worker/ticket.mjs +9 -1
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * 1. **Schema validation** (Ajv, draft 2020-12, `additionalProperties: false` almost everywhere)
6
6
  * catches shape errors: unknown keys, wrong types, missing required fields.
7
- * 2. **Domain rules** (`DOMAIN_RULES` below, 16 of them as of B32) catch things no JSON Schema can express:
7
+ * 2. **Domain rules** (`DOMAIN_RULES` below, 20 of them as of B93) catch things no JSON Schema can express:
8
8
  * cross-field comparisons (two levels resolving to the same tuple), a value that needs a
9
9
  * second config value to be reachable (`second_levels.L2.provider` vs. the effective L2
10
10
  * provider) and rules that reach outside the config object entirely (is a CLI on PATH, is a
@@ -36,6 +36,10 @@
36
36
  * 14 caps-coders-exceeds-cap WARN (v1.3/R10/B1.1: caps.coders > 2)
37
37
  * 15 effort-not-valid-for-provider ERROR (B29: a level's / fallback's effort vs. its provider)
38
38
  * 16 closed-book-on-openai WARN (B32: Codex has no no-tools mode)
39
+ * 17 proof-runners-invalid ERROR (B87: a proof.runners entry: adapter, match, argv tokens, env, timeout_s)
40
+ * 18 review-routes-invalid ERROR (B90: a review.routes rule: level, duplicate level+min_lines, provider vs. the second reviewer)
41
+ * 19 sandbox-dir-refused ERROR (B93: a coder.sandbox.add_dirs entry opens /, the home dir, a secret dir or the run temp tree)
42
+ * 20 sandbox-dirs-unused WARN (B93: coder.sandbox.add_dirs set under a profile other than toolchain)
39
43
  *
40
44
  * **B1.2 (v1.3, Q16 cut) removed rule:** `proof-tool-absent-for-high-tier` (formerly #13) — the
41
45
  * key it warned about (`proof.tiers.high.tool`) left the schema in the same amendment, along with
@@ -66,6 +70,11 @@ import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
66
70
  import { checkEffort, displayEffort } from '../engines/efforts.mjs';
67
71
  import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
68
72
  import { isKnownId, resolveLevel } from './known-ids.mjs';
73
+ // B90: the routing module's own level list and provider resolution, so rules 16 and 18 check a
74
+ // route with exactly the provider the worker spawns it on (they cannot drift)
75
+ import { ROUTE_LEVELS, ruleProvider } from '../review/route.mjs';
76
+ import { DEFAULT_SANDBOX_PROFILE, SANDBOX_PROFILES, sandboxDirRefusals, SANDBOX_DIR_REFUSED } from '../engines/sandbox-dirs.mjs';
77
+ import { tmpBase } from '../util/tmp.mjs';
69
78
  import { looksLikeSecret, maskSecretTokens, SECRET_MASK, secretTokensIn } from './secret-patterns.mjs';
70
79
 
71
80
  /**
@@ -781,6 +790,21 @@ function checkClosedBookOnOpenai(cfg) {
781
790
  if (cfg?.review?.multimodel === true && isNoClosedBookProvider(effectiveSecondL2Provider(cfg))) {
782
791
  where.push('review.second_levels.L2 (else review.second_provider)');
783
792
  }
793
+ // B90: a review.routes rule replaces an L2/L3 level for a big file — the same closed-book limit
794
+ routeRulesOf(cfg).forEach((rule, i) => {
795
+ if (!isPlainObject(rule)) return;
796
+ if (isNoClosedBookProvider(ruleProvider(cfg, rule))) where.push(`review.routes[${i}]`);
797
+ if (Array.isArray(rule.fallback)) {
798
+ rule.fallback.forEach((fb, j) => {
799
+ if (isNoClosedBookProvider(fb?.provider)) where.push(`review.routes[${i}].fallback[${j}]`);
800
+ });
801
+ }
802
+ // like the second reviewer's own check above: only with multimodel on, and with the same
803
+ // effective second provider (the rule's own, else second_levels.L2, else second_provider)
804
+ if (cfg?.review?.multimodel === true && isPlainObject(rule.second) && isNoClosedBookProvider(rule.second.provider ?? effectiveSecondL2Provider(cfg))) {
805
+ where.push(`review.routes[${i}].second`);
806
+ }
807
+ });
784
808
  // B32 opt-in: the same places, but the text says what the opt-in costs instead of "refused".
785
809
  const text = openBookCodexAllowed(cfg) ? OPEN_BOOK_CODEX_WARNING : CODEX_CLOSED_BOOK_REFUSAL;
786
810
  return where.map((key) => ({
@@ -790,11 +814,298 @@ function checkClosedBookOnOpenai(cfg) {
790
814
  }));
791
815
  }
792
816
 
817
+ // ── 17. proof-runners-invalid (ERROR, B87) ──────────────────────────────────
818
+
819
+ /**
820
+ * B87: the argv tokens each `proof.runners` adapter expands. A token is a whole argv item, never
821
+ * the command. `proof/runners.mjs` expands exactly these (proof may import config; config may not
822
+ * import proof).
823
+ */
824
+ export const RUNNER_ADAPTER_TOKENS = Object.freeze({
825
+ junit: Object.freeze(['{test_file}', '{test_id}', '{test_filter}', '{report}']),
826
+ swift: Object.freeze(['{test_file}', '{test_id}', '{test_filter}', '{report}']),
827
+ // B102: the values come from the entry's `xcode` object; `{only_testing}` names the test and
828
+ // `{result_bundle}` is the fresh result bundle path code-forge reads
829
+ xcodebuild: Object.freeze(['{workspace}', '{project}', '{scheme}', '{destination}', '{only_testing}', '{result_bundle}']),
830
+ });
831
+ /** B87: the tokens that name the test; a `junit` or `swift` runner needs at least one (a proof must name its test). */
832
+ export const RUNNER_TEST_TOKENS = Object.freeze(['{test_file}', '{test_id}', '{test_filter}']);
833
+ /** B87: every token any adapter knows. */
834
+ const ALL_RUNNER_TOKENS = Object.freeze(['{test_file}', '{test_id}', '{test_filter}', '{report}', '{workspace}', '{project}', '{scheme}', '{destination}', '{only_testing}', '{result_bundle}']);
835
+ /** B102: the keys of an `xcodebuild` entry's `xcode` object, and the ones it must have. */
836
+ export const XCODE_KEYS = Object.freeze(['workspace', 'project', 'scheme', 'destination', 'test_target']);
837
+ const XCODE_REQUIRED = Object.freeze(['scheme', 'destination', 'test_target']);
838
+ /** B102: the tokens an `xcodebuild` argv must have, and why. */
839
+ const XCODE_REQUIRED_TOKENS = Object.freeze([
840
+ ['{scheme}', 'the xcodebuild adapter fills it from xcode.scheme'],
841
+ ['{destination}', 'the xcodebuild adapter fills it from xcode.destination, the one the precheck checks'],
842
+ ['{only_testing}', 'a proof must name its test'],
843
+ ['{result_bundle}', 'the xcodebuild adapter reads the result bundle code-forge places there'],
844
+ ]);
845
+ /** B87: an item that is a whole `{word}` is read as a token. */
846
+ const TOKEN_SHAPE = /^\{[a-z_]+\}$/;
847
+ /** B87: the bounds of `proof.runners[].timeout_s`, in seconds. */
848
+ const RUNNER_TIMEOUT_MIN_S = 60;
849
+ const RUNNER_TIMEOUT_MAX_S = 7200;
850
+ /** B87: the keys a runner entry may have (the schema's `additionalProperties: false`, as a rule). */
851
+ const RUNNER_KEYS = Object.freeze(['match', 'adapter', 'argv', 'env', 'timeout_s', 'xcode']);
852
+ /** B87: every token some adapter lists; with a bad adapter, an item outside it is still refused. */
853
+ const ANY_ADAPTER_TOKENS = Object.freeze([...new Set(Object.values(RUNNER_ADAPTER_TOKENS).flat())]);
854
+ /** B102: the report items and the test tokens of every adapter; a bad adapter's argv needs one of each. */
855
+ const ANY_REPORT_TOKENS = Object.freeze(['{report}', '{result_bundle}']);
856
+ const ANY_TEST_TOKENS = Object.freeze([...RUNNER_TEST_TOKENS, '{only_testing}']);
857
+
858
+ /**
859
+ * Rule 17's messages for one runner entry. A message names the key path and the rule; it may name
860
+ * an env variable NAME or an unknown entry key (key paths), never a value (argv items, env values) — only the adapter's
861
+ * own token words, which are code-forge's. The argv checks run even when the adapter is bad, so
862
+ * one pass shows every error. `proof/runners.mjs` checks the snapshot's entries with it again
863
+ * before it runs one.
864
+ * @param {unknown} entry @param {number} i @returns {string[]}
865
+ */
866
+ export function runnerMessages(entry, i) {
867
+ const at = `proof.runners[${i}]`;
868
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return [`${at} must be an object with match, adapter and argv`];
869
+ const runner = /** @type {Record<string, unknown>} */ (entry);
870
+ const messages = [];
871
+ const adapter = typeof runner.adapter === 'string' && Object.hasOwn(RUNNER_ADAPTER_TOKENS, runner.adapter) ? runner.adapter : null;
872
+ if (adapter === null) messages.push(`${at}.adapter must be one of: ${Object.keys(RUNNER_ADAPTER_TOKENS).join(', ')}`);
873
+ for (const key of Object.keys(runner)) {
874
+ if (!RUNNER_KEYS.includes(key)) messages.push(`${at}: ${JSON.stringify(key)} is not a runner key (${RUNNER_KEYS.join(', ')})`);
875
+ }
876
+ const match = runner.match;
877
+ if (!Array.isArray(match) || match.length === 0 || !match.every((glob) => typeof glob === 'string' && glob.length > 0)) {
878
+ messages.push(`${at}.match must be a non-empty list of globs`);
879
+ }
880
+ if (!isArgvArray(runner.argv)) {
881
+ messages.push(`${at}.argv must be a non-empty argv array of strings`);
882
+ } else {
883
+ const argv = /** @type {string[]} */ (runner.argv);
884
+ if (TOKEN_SHAPE.test(argv[0])) messages.push(`${at}.argv[0]: a token cannot be the command`);
885
+ // with a bad adapter, a token-shaped item is checked against every adapter's tokens instead;
886
+ // every other argv check still runs
887
+ const known = adapter === null ? null : RUNNER_ADAPTER_TOKENS[/** @type {keyof typeof RUNNER_ADAPTER_TOKENS} */ (adapter)];
888
+ argv.forEach((item, j) => {
889
+ if (item.includes('\0')) {
890
+ messages.push(`${at}.argv[${j}] contains a NUL character`);
891
+ } else if (j > 0 && TOKEN_SHAPE.test(item) && known !== null && !known.includes(item)) {
892
+ messages.push(`${at}.argv[${j}] is not a token of the ${adapter} adapter (${known.join(', ')})`);
893
+ } else if (j > 0 && TOKEN_SHAPE.test(item) && known === null && !ANY_ADAPTER_TOKENS.includes(item)) {
894
+ messages.push(`${at}.argv[${j}] is not a token of any adapter (${ANY_ADAPTER_TOKENS.join(', ')})`);
895
+ } else if (!TOKEN_SHAPE.test(item) && ALL_RUNNER_TOKENS.some((token) => item.includes(token))) {
896
+ messages.push(`${at}.argv[${j}]: a token must be a whole argv item (give it as its own item)`);
897
+ }
898
+ });
899
+ // the required tokens depend on the adapter; with a bad adapter, every adapter's report items
900
+ // and test tokens count, so an argv that names no report or no test is still refused
901
+ const items = argv.slice(1);
902
+ if (adapter === 'junit' || adapter === 'swift') {
903
+ if (!items.includes('{report}')) messages.push(`${at}.argv needs a {report} item (the ${adapter} adapter reads the report code-forge places there)`);
904
+ if (!items.some((item) => RUNNER_TEST_TOKENS.includes(item))) messages.push(`${at}.argv needs a test token (${RUNNER_TEST_TOKENS.join(', ')}): a proof must name its test`);
905
+ } else if (adapter === 'xcodebuild') {
906
+ for (const [token, why] of XCODE_REQUIRED_TOKENS) if (!items.includes(token)) messages.push(`${at}.argv needs a ${token} item (${why})`);
907
+ } else {
908
+ if (!items.some((item) => ANY_REPORT_TOKENS.includes(item))) messages.push(`${at}.argv needs a report item (${ANY_REPORT_TOKENS.join(', ')}): every adapter reads the report code-forge places there`);
909
+ if (!items.some((item) => ANY_TEST_TOKENS.includes(item))) messages.push(`${at}.argv needs a test token (${ANY_TEST_TOKENS.join(', ')}): a proof must name its test`);
910
+ }
911
+ }
912
+ messages.push(...xcodeMessages(runner, adapter, at));
913
+ if (runner.env !== undefined) {
914
+ if (runner.env === null || typeof runner.env !== 'object' || Array.isArray(runner.env)) {
915
+ messages.push(`${at}.env must be a map of variable names to string values`);
916
+ } else {
917
+ for (const [name, envValue] of Object.entries(runner.env)) {
918
+ if (!ENV_NAME.test(name)) messages.push(`${at}.env: ${JSON.stringify(name)} is not a variable name (letters, digits and _, not starting with a digit)`);
919
+ else if (typeof envValue !== 'string') messages.push(`${at}.env.${name} must be a string`);
920
+ else if (envValue.includes('\0')) messages.push(`${at}.env.${name} contains a NUL character`);
921
+ }
922
+ }
923
+ }
924
+ if (runner.timeout_s !== undefined) {
925
+ const t = runner.timeout_s;
926
+ if (typeof t !== 'number' || !Number.isInteger(t) || t < RUNNER_TIMEOUT_MIN_S || t > RUNNER_TIMEOUT_MAX_S) {
927
+ messages.push(`${at}.timeout_s must be a whole number of seconds from ${RUNNER_TIMEOUT_MIN_S} to ${RUNNER_TIMEOUT_MAX_S}`);
928
+ }
929
+ }
930
+ return messages;
931
+ }
932
+
933
+ /** @param {unknown} value @returns {boolean} */
934
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
935
+
936
+ /**
937
+ * B102: rule 17's messages for an entry's `xcode` object. An `xcodebuild` entry needs one; any other
938
+ * adapter refuses one. Its values are argv items of `xcodebuild`, so each is a non-empty string with
939
+ * no NUL that does not start with `-` (it would be read as an option). Messages name key paths only.
940
+ * @param {Record<string, unknown>} runner @param {string | null} adapter @param {string} at
941
+ * @returns {string[]}
942
+ */
943
+ function xcodeMessages(runner, adapter, at) {
944
+ const xcode = runner.xcode;
945
+ if (xcode === undefined) return adapter === 'xcodebuild' ? [`${at}.xcode is required for the xcodebuild adapter (${XCODE_REQUIRED.join(', ')})`] : [];
946
+ if (adapter !== null && adapter !== 'xcodebuild') return [`${at}.xcode is only for the xcodebuild adapter`];
947
+ if (!isPlainObject(xcode)) return [`${at}.xcode must be an object with ${XCODE_REQUIRED.join(', ')}`];
948
+ const values = /** @type {Record<string, unknown>} */ (xcode);
949
+ const messages = [];
950
+ for (const key of Object.keys(values)) {
951
+ if (!XCODE_KEYS.includes(key)) messages.push(`${at}.xcode: ${JSON.stringify(key)} is not an xcode key (${XCODE_KEYS.join(', ')})`);
952
+ }
953
+ for (const key of XCODE_KEYS) {
954
+ const value = values[key];
955
+ if (value === undefined) {
956
+ if (XCODE_REQUIRED.includes(key)) messages.push(`${at}.xcode.${key} is required`);
957
+ } else if (typeof value !== 'string' || value.length === 0 || value.startsWith('-')) {
958
+ messages.push(`${at}.xcode.${key} must be a non-empty string that does not start with "-"`);
959
+ } else if (value.includes('\0')) {
960
+ messages.push(`${at}.xcode.${key} contains a NUL character`);
961
+ } else if (key === 'test_target' && value.includes('/')) {
962
+ messages.push(`${at}.xcode.test_target must be one target name, without "/"`);
963
+ }
964
+ }
965
+ if (values.workspace !== undefined && values.project !== undefined) messages.push(`${at}.xcode: give workspace or project, not both`);
966
+ // a {workspace} / {project} item and its xcode key come together: one without the other is a
967
+ // value xcodebuild would never see, or an argv item with no value
968
+ const argv = isArgvArray(runner.argv) ? /** @type {string[]} */ (runner.argv).slice(1) : null;
969
+ if (argv !== null) {
970
+ for (const key of ['workspace', 'project']) {
971
+ const token = `{${key}}`;
972
+ if (argv.includes(token) && values[key] === undefined) messages.push(`${at}.argv has ${token} but ${at}.xcode.${key} is not set`);
973
+ if (!argv.includes(token) && values[key] !== undefined) messages.push(`${at}.xcode.${key} is set but ${at}.argv has no ${token} item`);
974
+ }
975
+ }
976
+ return messages;
977
+ }
978
+
979
+ function checkProofRunnersInvalid(cfg) {
980
+ const runners = cfg?.proof?.runners;
981
+ if (runners === undefined) return [];
982
+ const messages = !Array.isArray(runners)
983
+ ? ['proof.runners must be a list of runner entries']
984
+ : runners.length === 0
985
+ ? ['proof.runners must list at least one runner (leave the key out for none)']
986
+ : runners.flatMap((entry, i) => runnerMessages(entry, i));
987
+ return messages.map((message) => ({ rule: 'proof-runners-invalid', severity: /** @type {const} */ ('error'), message }));
988
+ }
989
+
990
+ // ── 18. review-routes-invalid (ERROR, B90) ──────────────────────────────────
991
+
992
+ /** @param {Record<string, any>} cfg @returns {any[]} the `review.routes` list, `[]` when it is not one. */
993
+ function routeRulesOf(cfg) {
994
+ const routes = cfg?.review?.routes;
995
+ return Array.isArray(routes) ? routes : [];
996
+ }
997
+
998
+ /**
999
+ * B90: what the schema cannot say about `review.routes` — a level other than L2 or L3, two rules
1000
+ * for the same level and `min_lines` (which one wins would be order-dependent), and, with
1001
+ * `review.multimodel` on, an L2 rule whose provider (its own, else the top-level one) equals its
1002
+ * own `second.provider`, or the second reviewer's effective L2 provider — the consensus slot B a
1003
+ * routed ticket really runs with — (the consensus needs two providers on a big file too).
1004
+ * Messages name key paths only, never a value.
1005
+ * @param {Record<string, any>} cfg
1006
+ */
1007
+ function checkReviewRoutesInvalid(cfg) {
1008
+ const routes = cfg?.review?.routes;
1009
+ if (routes === undefined || routes === null) return [];
1010
+ /** @type {string[]} */
1011
+ const messages = [];
1012
+ if (!Array.isArray(routes)) {
1013
+ messages.push('review.routes must be a list of rules');
1014
+ } else {
1015
+ /** @type {Map<string, number>} */
1016
+ const seen = new Map();
1017
+ const second = effectiveSecondL2Provider(cfg);
1018
+ routes.forEach((rule, i) => {
1019
+ const at = `review.routes[${i}]`;
1020
+ if (!isPlainObject(rule)) {
1021
+ messages.push(`${at} must be a rule object`);
1022
+ return;
1023
+ }
1024
+ const level = rule.level ?? 'L2';
1025
+ if (!ROUTE_LEVELS.includes(level)) {
1026
+ messages.push(`${at}.level must be L2 or L3`);
1027
+ return;
1028
+ }
1029
+ if (Number.isInteger(rule.min_lines)) {
1030
+ const k = `${level}\0${rule.min_lines}`;
1031
+ const first = seen.get(k);
1032
+ if (first === undefined) seen.set(k, i);
1033
+ else messages.push(`${at} has the same level and min_lines as review.routes[${first}]`);
1034
+ }
1035
+ if (cfg?.review?.multimodel === true && level === 'L2') {
1036
+ // the provider the worker spawns this rule on: its own, else the top-level one (the rule
1037
+ // REPLACES levels.L2, so levels.L2.provider is never inherited)
1038
+ const first = ruleProvider(cfg, rule);
1039
+ const own = isPlainObject(rule.second) ? rule.second.provider : undefined;
1040
+ if (own !== undefined && first === own) {
1041
+ // the rule's own second reviewer
1042
+ messages.push(`${at}.provider (else provider) equals ${at}.second.provider — multimodel needs two different providers`);
1043
+ } else if (second !== undefined && first === second) {
1044
+ // the consensus slot B a routed ticket runs with (it keeps review.second_levels.L2)
1045
+ messages.push(`${at}.provider (else provider) equals the second reviewer's effective L2 provider (review.second_levels.L2.provider, else review.second_provider) — multimodel needs two different providers`);
1046
+ }
1047
+ }
1048
+ });
1049
+ }
1050
+ return messages.map((message) => ({ rule: 'review-routes-invalid', severity: /** @type {const} */ ('error'), message }));
1051
+ }
1052
+
1053
+ // ── 19. sandbox-dir-refused (ERROR, B93) ────────────────────────────────────
1054
+ /**
1055
+ * A `coder.sandbox.add_dirs` entry that is not absolute / `~/`, or that is `/`, the home dir, or
1056
+ * is, holds or sits inside a secret dir (`~/.code-forge`, `~/.codex`, `~/.ssh`, …) or the run
1057
+ * temp base (every run temp root and session CODEX_HOME live under it). One issue per refused
1058
+ * entry; the message names the key path, never the value. The builder checks again at spawn.
1059
+ * @param {Record<string, any>} cfg
1060
+ * @returns {ValidationIssue[]}
1061
+ */
1062
+ function checkSandboxDirRefused(cfg) {
1063
+ const sandbox = isPlainObject(cfg.coder) ? cfg.coder.sandbox : undefined;
1064
+ // a non-list add_dirs, or a non-string entry, is the schema layer's issue (fix round 1)
1065
+ if (!isPlainObject(sandbox) || !Array.isArray(sandbox.add_dirs)) return [];
1066
+ const dirs = /** @type {unknown[]} */ (sandbox.add_dirs);
1067
+ /** @type {string[]} */
1068
+ const extra = [];
1069
+ try {
1070
+ extra.push(tmpBase(isPlainObject(cfg.tmp) ? cfg.tmp.root ?? undefined : undefined));
1071
+ } catch {
1072
+ extra.push(tmpBase()); // a bad tmp.root is the schema's issue; the default base still counts
1073
+ }
1074
+ const nonStrings = new Set(dirs.flatMap((entry, i) => (typeof entry === 'string' ? [] : [`coder.sandbox.add_dirs[${i}]`])));
1075
+ return sandboxDirRefusals(dirs, { extra })
1076
+ .filter((r) => !nonStrings.has(r.keyPath))
1077
+ .map((r) => ({
1078
+ rule: SANDBOX_DIR_REFUSED,
1079
+ severity: /** @type {const} */ ('error'),
1080
+ message: `${r.keyPath} is refused — ${r.why}`,
1081
+ }));
1082
+ }
1083
+
1084
+ // ── 20. sandbox-dirs-unused (WARN, B93 fix round 1) ─────────────────────────
1085
+ /**
1086
+ * `coder.sandbox.add_dirs` is set (a non-empty list) but the profile is not `toolchain`: only
1087
+ * `toolchain` adds `--add-dir`, so the entries change nothing (rule 19 still refuses a bad one).
1088
+ * The message names the key path and the profile, never a dir.
1089
+ * @param {Record<string, any>} cfg
1090
+ * @returns {ValidationIssue[]}
1091
+ */
1092
+ function checkSandboxDirsUnused(cfg) {
1093
+ const sandbox = isPlainObject(cfg.coder) ? cfg.coder.sandbox : undefined;
1094
+ if (!isPlainObject(sandbox) || !Array.isArray(sandbox.add_dirs) || sandbox.add_dirs.length === 0) return [];
1095
+ const profile = sandbox.profile ?? DEFAULT_SANDBOX_PROFILE;
1096
+ if (profile === 'toolchain' || !SANDBOX_PROFILES.includes(profile)) return [];
1097
+ return [{
1098
+ rule: 'sandbox-dirs-unused',
1099
+ severity: /** @type {const} */ ('warning'),
1100
+ message: `coder.sandbox.add_dirs is set but coder.sandbox.profile is ${profile}, which does not use it — only toolchain adds --add-dir`,
1101
+ }];
1102
+ }
1103
+
793
1104
  /**
794
- * The 16 domain rules paired with the exact `rule` id string each one emits — the single source
1105
+ * The 20 domain rules paired with the exact `rule` id string each one emits — the single source
795
1106
  * of truth both `DOMAIN_RULES` (below, what `validateConfig` runs) and the exported
796
1107
  * `DOMAIN_RULE_IDS` are built from. `'schema'` (the ajv layer's own issues) is deliberately not
797
- * one of these 16 (B29 added rule 15, B32 rule 16). (B1.2/v1.3, Q16 cut: `proof-tool-absent-for-high-tier` removed, 15 -> 14.)
1108
+ * one of these 20 (B29 added rule 15, B32 rule 16, B87 rule 17, B90 rule 18, B93 rules 19 and 20). (B1.2/v1.3, Q16 cut: `proof-tool-absent-for-high-tier` removed, 15 -> 14.)
798
1109
  * @type {ReadonlyArray<{fn: (cfg: Record<string, any>, opts?: object) => ValidationIssue[], id: string}>}
799
1110
  */
800
1111
  const RULE_TABLE = Object.freeze([
@@ -814,12 +1125,16 @@ const RULE_TABLE = Object.freeze([
814
1125
  { fn: checkCapsCodersExceedsCap, id: 'caps-coders-exceeds-cap' },
815
1126
  { fn: checkEffortNotValidForProvider, id: 'effort-not-valid-for-provider' },
816
1127
  { fn: checkClosedBookOnOpenai, id: 'closed-book-on-openai' },
1128
+ { fn: checkProofRunnersInvalid, id: 'proof-runners-invalid' },
1129
+ { fn: checkReviewRoutesInvalid, id: 'review-routes-invalid' },
1130
+ { fn: checkSandboxDirRefused, id: 'sandbox-dir-refused' },
1131
+ { fn: checkSandboxDirsUnused, id: 'sandbox-dirs-unused' },
817
1132
  ]);
818
1133
 
819
- /** The 16 domain rules, in the order documented above. */
1134
+ /** The 20 domain rules, in the order documented above. */
820
1135
  const DOMAIN_RULES = Object.freeze(RULE_TABLE.map((entry) => entry.fn));
821
1136
 
822
- /** The 16 rule ids `validateConfig` can emit, in `DOMAIN_RULES` order (see `RULE_TABLE`). */
1137
+ /** The 20 rule ids `validateConfig` can emit, in `DOMAIN_RULES` order (see `RULE_TABLE`). */
823
1138
  export const DOMAIN_RULE_IDS = Object.freeze(RULE_TABLE.map((entry) => entry.id));
824
1139
 
825
1140
  /**
@@ -14,7 +14,7 @@ import { cliNameForProvider } from '../engines/provider-cli.mjs';
14
14
  import { newRunId } from '../state/paths.mjs';
15
15
  import { runRoot } from '../util/tmp.mjs';
16
16
  import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
17
- import { configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
17
+ import { checkCoderSandbox, configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
18
18
  import { row } from './rows.mjs';
19
19
  import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
20
20
 
@@ -70,6 +70,7 @@ export async function runDoctor(opts = {}, deps = {}) {
70
70
  rows.push(...checkTmp(cfg));
71
71
  rows.push(...(await checkReviewState(cfg)));
72
72
  rows.push(checkReviewConcurrency(cfg));
73
+ rows.push(...checkCoderSandbox(cfg)); // B93: no rows without coder.sandbox
73
74
  if (!cfg) {
74
75
  rows.push(row('probes', 'FAIL', 'probes', 'skipped: no valid config'));
75
76
  rows.push(SIGNER_ROW);
@@ -16,7 +16,7 @@ import { commandOnPath } from '../install/detect.mjs';
16
16
  import { installsPath, readInstalls, targetResolves } from '../install/link.mjs';
17
17
  import { missingTools } from '../install/tools.mjs';
18
18
  import { OP_MESSAGES } from '../keys/onepassword.mjs';
19
- import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
19
+ import { cacheHoursFrom, createDefaultKeyStore, isTime, resolveKey } from '../keys/store.mjs';
20
20
  import { ledgerDir } from '../ledger/paths.mjs';
21
21
  import { readAllRows } from '../ledger/write.mjs';
22
22
  import { DEFAULT_PROVIDER_CONCURRENCY, providerLimit } from '../session/spawn.mjs';
@@ -72,16 +72,28 @@ export async function checkLinks(home) {
72
72
 
73
73
  /**
74
74
  * Resolve the Jev key through B2's chain (env → keychain/file → 1Password). The value never
75
- * leaves this function except to the caller that makes the live call.
75
+ * leaves this function except to the caller that makes the live call. A refresh from 1Password is
76
+ * cached for `keys.cache_hours` (B84); a config that failed validation arrives as null ⇒ 8 h.
77
+ * B85: under 1 hour left the row is WARN with a fixed re-link text (never the reference); when
78
+ * `keys.cache_hours` is set the OK text ends `(cache <n> h)`.
76
79
  * @param {Record<string, any> | null} cfg
77
- * @param {{env: NodeJS.ProcessEnv, store?: any, opRead?: any}} deps
80
+ * @param {{env: NodeJS.ProcessEnv, store?: any, opRead?: any, now?: number}} deps
78
81
  * @returns {Promise<{rows: Row[], key: string | null}>}
79
82
  */
80
83
  export async function checkKeys(cfg, deps) {
81
84
  try {
82
85
  const store = deps.store ?? (await createDefaultKeyStore(deps.env));
83
86
  const ref = cfg?.system1?.key ?? cfg?.keys?.jev;
84
- const res = await resolveKey('jev', { store, env: deps.env, ref: typeof ref === 'string' ? ref : undefined, ...(deps.opRead ? { opRead: deps.opRead } : {}) });
87
+ const cache = cacheHoursFrom(cfg);
88
+ const now = deps.now ?? Date.now();
89
+ const res = await resolveKey('jev', {
90
+ store,
91
+ env: deps.env,
92
+ ref: typeof ref === 'string' ? ref : undefined,
93
+ cacheHours: cache.hours,
94
+ now,
95
+ ...(deps.opRead ? { opRead: deps.opRead } : {}),
96
+ });
85
97
  if (res.value === null) {
86
98
  // only fixed texts are shown: a 1Password failure by its classified message (B25), any other
87
99
  // reason as a fixed "key store error" — never a reference, a path or tool output
@@ -92,8 +104,17 @@ export async function checkKeys(cfg, deps) {
92
104
  const why = reasons.length > 0 ? ` (${reasons.join('; ')})` : '';
93
105
  return { rows: [row('keys', 'WARN', 'keys', `jev key not resolved${why}; System 1 falls back to rules`)], key: null };
94
106
  }
95
- const exp = res.exp === null ? 'no expiry' : `expires ${new Date(res.exp).toISOString()}`;
96
- return { rows: [row('keys', 'OK', 'keys', `jev key from ${res.source}, ${exp}`)], key: res.value };
107
+ if (isTime(res.exp) && res.exp - now < 3_600_000) {
108
+ // whole minutes left, never 0 and never 60 (as keys list)
109
+ const minutes = Math.max(1, Math.floor((res.exp - now) / 60_000));
110
+ return {
111
+ rows: [row('keys', 'WARN', 'keys', `jev key from ${res.source} expires in ${minutes} min; re-link with code-forge keys set jev --op <ref from keys.jev>`)],
112
+ key: res.value,
113
+ };
114
+ }
115
+ const exp = res.exp === null ? 'no expiry' : isTime(res.exp) ? `expires ${new Date(res.exp).toISOString()}` : 'expiry unknown';
116
+ const hours = cache.source === 'config' ? ` (cache ${cache.hours} h)` : '';
117
+ return { rows: [row('keys', 'OK', 'keys', `jev key from ${res.source}, ${exp}${hours}`)], key: res.value };
97
118
  } catch (err) {
98
119
  return { rows: [row('keys', 'FAIL', 'keys', `jev key resolution failed (${err?.code ?? err?.name ?? 'error'})`)], key: null };
99
120
  }
@@ -32,6 +32,7 @@ import { mergeForbidden, renderForCodex } from '../util/forbidden.mjs';
32
32
  import { buildArgv } from '../engines/builders/index.mjs';
33
33
  import { probeHelpText } from '../engines/probe.mjs';
34
34
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
35
+ import { DEFAULT_SANDBOX_PROFILE, SANDBOX_PROFILES } from '../engines/sandbox-dirs.mjs';
35
36
  import { commandOnPath } from '../install/detect.mjs';
36
37
  import { classifyUnavailable, readAnswer, spawnSession } from '../session/spawn.mjs';
37
38
  import { S2_SCHEMA } from '../session/s2.mjs';
@@ -98,6 +99,43 @@ export function configuredProviders(cfg) {
98
99
  return [...out];
99
100
  }
100
101
 
102
+ /** B93: the coders `coder.sandbox` never changes, by provider, in the order doctor names them. */
103
+ const SANDBOX_NO_EFFECT = Object.freeze([
104
+ ['anthropic', 'Claude (anthropic)'],
105
+ ['xai', 'Grok (xai)'],
106
+ ]);
107
+
108
+ /**
109
+ * B93: the `coder.sandbox` rows. None without the key, for a null config, or for a value the
110
+ * schema refuses (a non-mapping, an unknown profile: config validation reports those). With it:
111
+ * - one INFO row `coder-sandbox` naming the profile (and, for `toolchain`, how many extra dirs);
112
+ * - one INFO row `coder-sandbox-no-effect` only when a Claude (anthropic) or Grok (xai) coder is
113
+ * configured (any level or fallback): the key has no effect on it, its argv does not change;
114
+ * - one WARN row `coder-sandbox-full` when the profile is `full` (`-s danger-full-access`).
115
+ * Rows name the profile and a count, never a directory.
116
+ * @param {Record<string, any> | null | undefined} cfg
117
+ * @returns {import('./rows.mjs').Row[]}
118
+ */
119
+ export function checkCoderSandbox(cfg) {
120
+ const sandbox = cfg?.coder?.sandbox;
121
+ if (sandbox === null || typeof sandbox !== 'object' || Array.isArray(sandbox)) return [];
122
+ const profile = sandbox.profile ?? DEFAULT_SANDBOX_PROFILE;
123
+ if (!SANDBOX_PROFILES.includes(profile)) return [];
124
+ const dirs = Array.isArray(sandbox.add_dirs) ? sandbox.add_dirs.length : 0;
125
+ const extra = profile === 'toolchain' ? `, ${dirs} extra writable ${dirs === 1 ? 'dir' : 'dirs'}` : '';
126
+ const rows = [row('coder-sandbox', 'INFO', 'coder sandbox', `profile ${profile}${extra}; applies to the Codex (openai) coder only`)];
127
+ const providers = configuredProviders(cfg);
128
+ const others = SANDBOX_NO_EFFECT.filter(([id]) => providers.includes(id)).map(([, name]) => name);
129
+ if (others.length > 0) {
130
+ const who = others.length === 1 ? `the ${others[0]} coder` : `the ${others.join(' and ')} coders`;
131
+ rows.push(row('coder-sandbox-no-effect', 'INFO', 'coder sandbox', `coder.sandbox has no effect on ${who}: the argv does not change`));
132
+ }
133
+ if (profile === 'full') {
134
+ rows.push(row('coder-sandbox-full', 'WARN', 'coder sandbox', 'profile full runs the Codex coder with -s danger-full-access: it can write anywhere you can; only the forbidden list, the transcript grep and the gates limit it'));
135
+ }
136
+ return rows;
137
+ }
138
+
101
139
  /** @param {Record<string, any>} cfg @returns {Record<string, any>} the config with every level's fallback removed. */
102
140
  function withoutFallback(cfg) {
103
141
  /** @type {Record<string, any>} */
@@ -65,6 +65,8 @@ import { CODEX_RULES_FILE_NAME, defaultCodexHome, isInside, prepareCodexHome } f
65
65
  import { hasAnyRenderedRule } from '../render-rules.mjs';
66
66
  import { assertEffortForProvider, PROVIDER_EFFORTS } from '../efforts.mjs';
67
67
  import { assertBaseParams } from './validate-params.mjs';
68
+ import { checkSandboxDirs, DEFAULT_SANDBOX_PROFILE, SandboxDirError, sandboxRefusalMessage, SANDBOX_PROFILES } from '../sandbox-dirs.mjs';
69
+ import { tmpBase } from '../../util/tmp.mjs';
68
70
  import { CODEX_CLOSED_BOOK_REFUSAL, isNeverOpenBookRole, isNoToolRole } from '../../config/closed-book.mjs';
69
71
 
70
72
  export const CLI = 'codex';
@@ -123,6 +125,11 @@ export const VALID_EFFORTS = PROVIDER_EFFORTS.openai;
123
125
  * @property {string} [codexHome] - coder role only: the per-session Codex home to create (absolute,
124
126
  * must not exist as the real `~/.codex` or inside it); defaults to `defaultCodexHome()` under the
125
127
  * run temp root.
128
+ * @property {{profile?: string, add_dirs?: string[]}} [sandbox] - B93, coder role only: the
129
+ * `coder.sandbox` config value. Absent ⇒ today's argv. `toolchain` adds one `--add-dir <abs>`
130
+ * per checked `add_dirs` entry; `full` builds `-s danger-full-access`. A refused entry throws
131
+ * a `SandboxDirError` (code `sandbox-dir-refused`) naming the key path, never the value.
132
+ * @property {string} [runRoot] - B93, coder role only: the run temp root, refused as an `add_dirs` tree.
126
133
  */
127
134
 
128
135
  /**
@@ -158,13 +165,23 @@ function assertValidEffort(effort) {
158
165
  /**
159
166
  * The Codex coder argv itself — pure, no I/O, no rules file (B4.2). `buildCoderArgv` uses it, and
160
167
  * the doctor's flags probe builds it with placeholder values for its static conflict check.
161
- * @param {{model: string, effort?: string, cwd: string, outPath: string, promptPath: string}} p
168
+ * B93: `profile` `full` swaps `-s workspace-write` for `-s danger-full-access`; `addDirs` (already
169
+ * checked and resolved, `toolchain` only) follow `-s workspace-write` as `--add-dir <abs>` pairs.
170
+ * The tmp exclusions stay in every profile: under `danger-full-access` Codex ignores the
171
+ * `sandbox_workspace_write.*` keys, and one argv shape keeps the forbidden-list check the same.
172
+ * @param {{model: string, effort?: string, cwd: string, outPath: string, promptPath: string, profile?: string, addDirs?: ReadonlyArray<string>}} p
162
173
  * @returns {string[]}
163
174
  */
164
- export function coderFlagArgv({ model, effort, cwd, outPath, promptPath }) {
175
+ export function coderFlagArgv({ model, effort, cwd, outPath, promptPath, profile = DEFAULT_SANDBOX_PROFILE, addDirs = [] }) {
176
+ if (!SANDBOX_PROFILES.includes(profile)) throw new TypeError(`coderFlagArgv: profile must be one of ${SANDBOX_PROFILES.join(', ')}`);
165
177
  const argv = ['codex', 'exec', '-m', model];
166
178
  if (effort) argv.push('-c', `model_reasoning_effort=${effort}`);
167
- argv.push('-s', 'workspace-write');
179
+ if (profile === 'full') {
180
+ argv.push('-s', 'danger-full-access');
181
+ } else {
182
+ argv.push('-s', 'workspace-write');
183
+ if (profile === 'toolchain') for (const dir of addDirs) argv.push('--add-dir', dir);
184
+ }
168
185
  // INVARIANT (B4.1 fix round 1): the session CODEX_HOME lives under the run temp root, so the
169
186
  // sandbox must not make $TMPDIR or /tmp writable — the project cwd is then the only writable
170
187
  // root, and a home inside the cwd is refused below. See `../codex-home.mjs`.
@@ -178,6 +195,26 @@ export function coderFlagArgv({ model, effort, cwd, outPath, promptPath }) {
178
195
  return argv;
179
196
  }
180
197
 
198
+ /**
199
+ * B93: the checked sandbox of one coder build. Absent ⇒ the default profile with no extra dir.
200
+ * The `add_dirs` are checked here too (defence in depth: validation checks them first), against
201
+ * the protected home dirs plus `extra` (the session CODEX_HOME, the run temp root and base).
202
+ * @param {unknown} sandbox @param {ReadonlyArray<string | undefined>} extra
203
+ * @returns {{profile: string, addDirs: string[]}}
204
+ */
205
+ function coderSandbox(sandbox, extra) {
206
+ if (sandbox === undefined || sandbox === null) return { profile: DEFAULT_SANDBOX_PROFILE, addDirs: [] };
207
+ if (typeof sandbox !== 'object' || Array.isArray(sandbox)) throw new TypeError('buildCodexArgv: coder.sandbox must be a mapping');
208
+ const s = /** @type {{profile?: unknown, add_dirs?: unknown}} */ (sandbox);
209
+ const profile = s.profile ?? DEFAULT_SANDBOX_PROFILE;
210
+ if (typeof profile !== 'string' || !SANDBOX_PROFILES.includes(profile)) {
211
+ throw new TypeError(`buildCodexArgv: coder.sandbox.profile must be one of ${SANDBOX_PROFILES.join(', ')}`);
212
+ }
213
+ const checked = checkSandboxDirs(s.add_dirs, { extra });
214
+ if ('keyPath' in checked) throw new SandboxDirError(sandboxRefusalMessage(checked));
215
+ return { profile, addDirs: profile === 'toolchain' ? checked.dirs : [] };
216
+ }
217
+
181
218
  /**
182
219
  * @param {CodexBuildParams} params
183
220
  * @returns {{cli: "codex", role: "coder", argv: string[], cwd: string, outPath: string, env: {CODEX_HOME: string}, forbiddenRendered: ReadonlyArray<RenderedCodexEntry>, rulesFile: {fileName: string, path: string, content: string, count: number}}}
@@ -189,7 +226,6 @@ function buildCoderArgv(params) {
189
226
  if (!hasAnyRenderedRule(renderedForbidden)) {
190
227
  throw new Error('buildCodexArgv: coder role requires a non-empty forbidden-list render (got 0 usable patterns)');
191
228
  }
192
- const argv = coderFlagArgv({ model, effort, cwd, outPath, promptPath });
193
229
  const codexHome = params.codexHome ?? defaultCodexHome();
194
230
  // Fix round 4: a non-absolute home is refused here, before any I/O, and the containment check
195
231
  // below then runs EVERY time (no `isAbsolute` short-circuit that could skip it).
@@ -202,6 +238,10 @@ function buildCoderArgv(params) {
202
238
  if (isInside(codexHome, cwd)) {
203
239
  throw new Error('buildCodexArgv: the session CODEX_HOME must not be inside the coder cwd (the sandbox can write there)');
204
240
  }
241
+ // B93 (fix round 1): the sandbox dirs are checked only after the home is known to be absolute
242
+ // and outside the cwd, so the home they are checked against is the one the session will use.
243
+ const sandbox = coderSandbox(params.sandbox, [codexHome, params.runRoot, tmpBase()]);
244
+ const argv = coderFlagArgv({ model, effort, cwd, outPath, promptPath, ...sandbox });
205
245
  const home = prepareCodexHome(codexHome, renderedForbidden);
206
246
  return {
207
247
  cli: CLI,