@codedology/code-forge 0.8.0 → 0.10.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 (64) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +46 -7
  3. package/docs/reference/blocks.json +618 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +2 -2
  9. package/skill/references/adapters/subprocess.md +12 -2
  10. package/skill/references/code.md +21 -7
  11. package/skill/references/ledger.md +2 -2
  12. package/skill/references/proof.md +77 -2
  13. package/skill/references/review.md +29 -4
  14. package/skill/references/security.md +5 -5
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +4 -2
  17. package/src/autopilot/binnacle.mjs +9 -3
  18. package/src/cli/block.mjs +300 -33
  19. package/src/cli/jev.mjs +22 -3
  20. package/src/cli/keys.mjs +156 -21
  21. package/src/cli/proof.mjs +35 -7
  22. package/src/cli/review-file.mjs +264 -13
  23. package/src/cli/spawn.mjs +44 -3
  24. package/src/config/validate.mjs +320 -5
  25. package/src/doctor/index.mjs +7 -5
  26. package/src/doctor/local.mjs +116 -8
  27. package/src/doctor/probes.mjs +38 -0
  28. package/src/engines/builders/codex.mjs +44 -4
  29. package/src/engines/sandbox-dirs.mjs +257 -0
  30. package/src/keys/store.mjs +128 -21
  31. package/src/ledger/write.mjs +7 -1
  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/answers.mjs +445 -0
  38. package/src/review/deletion.mjs +106 -0
  39. package/src/review/engine.mjs +117 -31
  40. package/src/review/fixloop.mjs +70 -28
  41. package/src/review/gate-check.mjs +378 -80
  42. package/src/review/lenses/A.md +1 -0
  43. package/src/review/lenses/B.md +1 -0
  44. package/src/review/lenses/full.md +1 -0
  45. package/src/review/lenses/quick.md +1 -0
  46. package/src/review/lenses/recheck.md +1 -0
  47. package/src/review/reopen.mjs +256 -0
  48. package/src/review/route.mjs +232 -0
  49. package/src/review/schema-fallback.mjs +77 -22
  50. package/src/review/session-retry.mjs +44 -9
  51. package/src/review/stop-exits.mjs +15 -0
  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.mjs +13 -3
  56. package/src/state/config-snapshot.mjs +39 -0
  57. package/src/state/run.mjs +4 -0
  58. package/src/util/forbidden.mjs +11 -1
  59. package/src/util/tmp.mjs +16 -4
  60. package/src/worker/engine.mjs +75 -9
  61. package/src/worker/loop.mjs +82 -15
  62. package/src/worker/queue.mjs +265 -10
  63. package/src/worker/review-state.mjs +346 -51
  64. package/src/worker/ticket.mjs +9 -1
@@ -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,
@@ -0,0 +1,257 @@
1
+ /**
2
+ * The Codex coder sandbox profile (block B93): `coder.sandbox.{profile, add_dirs}`.
3
+ *
4
+ * Facts (Codex 0.155.1, `test/fixtures/help/codex-0.155.1.txt`): `--add-dir <DIR>` = "Additional
5
+ * directories that should be writable alongside the primary workspace"; `-s` takes `read-only`,
6
+ * `workspace-write` or `danger-full-access`.
7
+ *
8
+ * Profiles:
9
+ * - `workspace` (the default, and what no `coder` key means): today's argv, `-s workspace-write`
10
+ * with the project `-C <cwd>` as the only writable root.
11
+ * - `toolchain`: the same, plus one `--add-dir <abs>` per `add_dirs` entry (a build cache, a
12
+ * package store), right after `-s workspace-write`.
13
+ * - `full`: `-s danger-full-access`. Codex writes anywhere the user can; like the Claude coder
14
+ * (bypassPermissions), the forbidden list, the transcript grep and the gates are the limits.
15
+ *
16
+ * Every `add_dirs` entry is absolute or starts with `~/` (expanded against the process HOME). It is
17
+ * resolved component by component: each symlink is replaced by its target (bounded; a loop is
18
+ * refused), `..` steps up from the resolved dir, a missing tail is kept as written, and the
19
+ * existing part is the native realpath (true case). So a symlink, even a dangling one, is judged
20
+ * by its target. Matching folds case on macOS and Windows. An entry is REFUSED (`sandbox-dir-refused`) when it is `/`, the home dir itself, or is,
21
+ * contains or sits inside: `~/.code-forge`, `~/.codex` (and `$CODEX_HOME`), `~/.ssh`, `~/.gnupg`,
22
+ * `~/.aws`, `~/.config`, `~/Library/Keychains`, the run temp base / run temp root, or the session
23
+ * CODEX_HOME. A refusal names the key path (`coder.sandbox.add_dirs[<i>]`), never the value.
24
+ *
25
+ * Pure apart from `lstat`/`readlink`/`realpath`; it never creates, lists or writes a directory.
26
+ */
27
+
28
+ import { lstatSync, readlinkSync, realpathSync } from 'node:fs';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+
32
+ /** The profiles, in the order the docs list them. */
33
+ export const SANDBOX_PROFILES = Object.freeze(['workspace', 'toolchain', 'full']);
34
+
35
+ /** The default profile: today's argv. */
36
+ export const DEFAULT_SANDBOX_PROFILE = 'workspace';
37
+
38
+ /** The error code a refused `add_dirs` entry carries (validation rule and builder). */
39
+ export const SANDBOX_DIR_REFUSED = 'sandbox-dir-refused';
40
+
41
+ /** Home-relative directories no coder sandbox may open (nor any dir that holds or sits in one). */
42
+ export const PROTECTED_HOME_DIRS = Object.freeze(['.code-forge', '.codex', '.ssh', '.gnupg', '.aws', '.config', path.join('Library', 'Keychains')]);
43
+
44
+ /** An `Error` with a `code` of {@link SANDBOX_DIR_REFUSED}. */
45
+ export class SandboxDirError extends Error {
46
+ /** @param {string} message */
47
+ constructor(message) {
48
+ super(message);
49
+ this.name = 'SandboxDirError';
50
+ this.code = SANDBOX_DIR_REFUSED;
51
+ }
52
+ }
53
+
54
+ /** The most symlinks one entry may pass through before it is refused (as the kernel's ELOOP). */
55
+ const MAX_LINK_HOPS = 40;
56
+
57
+ /** A path whose symlinks cannot be followed (a loop, too many hops, an unreadable link). */
58
+ class Unresolvable extends Error {}
59
+
60
+ /** @param {string} p @returns {string} the native realpath (true case), or `p` when it fails. */
61
+ function native(p) {
62
+ try {
63
+ return realpathSync.native(p);
64
+ } catch {
65
+ return p;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Resolve `p` the way the kernel would: walk it one component at a time with `lstat`; a symlink is
71
+ * replaced by its target (read with `readlink`, followed again, bounded); `..` steps up from the
72
+ * resolved dir, never lexically. A missing component ends the walk: the existing part is the
73
+ * native realpath, the rest is kept as written. So a dangling symlink is judged by its target.
74
+ * @param {string} p - absolute.
75
+ * @param {{n: number}} [hops]
76
+ * @returns {string}
77
+ * @throws {Unresolvable} on a loop, too many hops, or a component that cannot be read.
78
+ */
79
+ function resolvePath(p, hops = { n: 0 }) {
80
+ const { root } = path.parse(p);
81
+ const parts = p.slice(root.length).split(path.sep).filter((c) => c.length > 0 && c !== '.');
82
+ let cur = root;
83
+ for (let i = 0; i < parts.length; i += 1) {
84
+ if (parts[i] === '..') {
85
+ cur = path.dirname(cur);
86
+ continue;
87
+ }
88
+ const next = path.join(cur, parts[i]);
89
+ let st;
90
+ try {
91
+ st = lstatSync(next);
92
+ } catch (err) {
93
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
94
+ if (code === 'ENOENT' || code === 'ENOTDIR') return path.join(native(cur), ...parts.slice(i));
95
+ throw new Unresolvable();
96
+ }
97
+ if (!st.isSymbolicLink()) {
98
+ cur = next;
99
+ continue;
100
+ }
101
+ hops.n += 1;
102
+ if (hops.n > MAX_LINK_HOPS) throw new Unresolvable();
103
+ let target;
104
+ try {
105
+ target = readlinkSync(next);
106
+ } catch {
107
+ throw new Unresolvable();
108
+ }
109
+ cur = resolvePath(path.isAbsolute(target) ? target : `${cur}${path.sep}${target}`, hops);
110
+ }
111
+ return native(cur);
112
+ }
113
+
114
+ /** @param {string} p @returns {string} a refused tree, resolved; a tree that cannot be is kept as written. */
115
+ function resolveTree(p) {
116
+ try {
117
+ return resolvePath(path.resolve(p));
118
+ } catch {
119
+ return path.resolve(p);
120
+ }
121
+ }
122
+
123
+ /** Case-insensitive by default on macOS and Windows: compare folded there, so a missing tree is matched too. */
124
+ const FOLD_CASE = process.platform === 'darwin' || process.platform === 'win32';
125
+
126
+ /** @param {string} p @returns {string} */
127
+ function fold(p) {
128
+ return FOLD_CASE ? p.toLowerCase() : p;
129
+ }
130
+
131
+ /** @param {string} a @param {string} b @returns {boolean} `a` is `b` (both resolved; case folded where the FS is). */
132
+ function same(a, b) {
133
+ return fold(a) === fold(b);
134
+ }
135
+
136
+ /** @param {string} a @param {string} b @returns {boolean} `a` is `b` or inside it (both resolved). */
137
+ function within(a, b) {
138
+ const rel = path.relative(fold(b), fold(a));
139
+ if (rel === '') return true;
140
+ return !(rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel));
141
+ }
142
+
143
+ /** @param {NodeJS.ProcessEnv} env @returns {string} */
144
+ function homeOf(env) {
145
+ return typeof env.HOME === 'string' && env.HOME.length > 0 ? env.HOME : os.homedir();
146
+ }
147
+
148
+ /**
149
+ * Expand one entry: absolute, or `~/…` against `home`. Anything else is `null`. `.` and `..` are
150
+ * kept as written: the resolver steps through them after following symlinks.
151
+ * @param {unknown} entry @param {string} home @returns {string | null}
152
+ */
153
+ export function expandSandboxDir(entry, home) {
154
+ if (typeof entry !== 'string' || entry.length === 0 || entry.includes('\0')) return null;
155
+ if (entry.startsWith('~/')) return `${path.resolve(home)}${path.sep}${entry.slice(2)}`;
156
+ if (path.isAbsolute(entry)) return entry;
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * @typedef {object} SandboxGuards
162
+ * @property {NodeJS.ProcessEnv} [env] - the env whose HOME / CODEX_HOME count (default `process.env`).
163
+ * @property {ReadonlyArray<string | null | undefined>} [extra] - more refused trees: the run temp
164
+ * base or root, the session CODEX_HOME (absolute paths; `null`/`undefined` are skipped).
165
+ */
166
+
167
+ /**
168
+ * The refused trees for `guards`, each resolved once.
169
+ * @param {SandboxGuards} guards
170
+ * @returns {{home: string, realHome: string, protectedTrees: string[], runTrees: string[]}}
171
+ */
172
+ function refusedTrees(guards) {
173
+ const env = guards.env ?? process.env;
174
+ const home = homeOf(env);
175
+ const protectedTrees = PROTECTED_HOME_DIRS.map((d) => resolveTree(path.join(home, d)));
176
+ if (typeof env.CODEX_HOME === 'string' && path.isAbsolute(env.CODEX_HOME)) protectedTrees.push(resolveTree(env.CODEX_HOME));
177
+ /** @type {string[]} */
178
+ const runTrees = [];
179
+ for (const p of guards.extra ?? []) {
180
+ if (typeof p === 'string' && path.isAbsolute(p)) runTrees.push(resolveTree(p));
181
+ }
182
+ return { home, realHome: resolveTree(home), protectedTrees, runTrees };
183
+ }
184
+
185
+ /**
186
+ * Check one `add_dirs` entry against resolved trees.
187
+ * @param {unknown} entry @param {number} index @param {ReturnType<typeof refusedTrees>} trees
188
+ * @returns {{ok: true, dir: string} | {ok: false, keyPath: string, why: string}}
189
+ */
190
+ function checkOne(entry, index, trees) {
191
+ const keyPath = `coder.sandbox.add_dirs[${index}]`;
192
+ const expanded = expandSandboxDir(entry, trees.home);
193
+ if (expanded === null) return { ok: false, keyPath, why: 'it must be an absolute path or start with ~/' };
194
+ let real;
195
+ try {
196
+ real = resolvePath(expanded);
197
+ } catch {
198
+ return { ok: false, keyPath, why: 'its symlinks cannot be resolved' };
199
+ }
200
+ if (real === path.parse(real).root) return { ok: false, keyPath, why: 'it is the filesystem root' };
201
+ if (same(real, trees.realHome)) return { ok: false, keyPath, why: 'it is the home directory' };
202
+ if (within(trees.realHome, real)) return { ok: false, keyPath, why: 'it holds the home directory' };
203
+ if (trees.protectedTrees.some((t) => within(real, t) || within(t, real))) {
204
+ return { ok: false, keyPath, why: 'it is, holds or sits inside a protected directory (~/.code-forge, ~/.codex, ~/.ssh, ~/.gnupg, ~/.aws, ~/.config, ~/Library/Keychains)' };
205
+ }
206
+ if (trees.runTrees.some((t) => within(real, t) || within(t, real))) {
207
+ return { ok: false, keyPath, why: 'it is, holds or sits inside the run temp root or the session CODEX_HOME' };
208
+ }
209
+ return { ok: true, dir: real };
210
+ }
211
+
212
+ /**
213
+ * Every refused `add_dirs` entry (validation reports each one).
214
+ * @param {unknown} dirs @param {SandboxGuards} [guards]
215
+ * @returns {Array<{keyPath: string, why: string}>}
216
+ */
217
+ export function sandboxDirRefusals(dirs, guards = {}) {
218
+ if (dirs === undefined) return [];
219
+ if (!Array.isArray(dirs)) return [{ keyPath: 'coder.sandbox.add_dirs', why: 'it must be a list of directories' }];
220
+ const trees = refusedTrees(guards);
221
+ /** @type {Array<{keyPath: string, why: string}>} */
222
+ const out = [];
223
+ dirs.forEach((entry, i) => {
224
+ const one = checkOne(entry, i, trees);
225
+ if ('keyPath' in one) out.push({ keyPath: one.keyPath, why: one.why });
226
+ });
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * Check `add_dirs` and resolve each entry; the first refusal wins (the builder fails closed).
232
+ * @param {unknown} dirs - the `coder.sandbox.add_dirs` value.
233
+ * @param {SandboxGuards} [guards]
234
+ * @returns {{ok: true, dirs: string[]} | {ok: false, keyPath: string, why: string}} `why` names a
235
+ * category, never the value.
236
+ */
237
+ export function checkSandboxDirs(dirs, guards = {}) {
238
+ if (dirs === undefined) return { ok: true, dirs: [] };
239
+ if (!Array.isArray(dirs)) return { ok: false, keyPath: 'coder.sandbox.add_dirs', why: 'it must be a list of directories' };
240
+ const trees = refusedTrees(guards);
241
+ /** @type {string[]} */
242
+ const out = [];
243
+ for (let i = 0; i < dirs.length; i += 1) {
244
+ const one = checkOne(dirs[i], i, trees);
245
+ if ('keyPath' in one) return { ok: false, keyPath: one.keyPath, why: one.why };
246
+ out.push(one.dir);
247
+ }
248
+ return { ok: true, dirs: out };
249
+ }
250
+
251
+ /**
252
+ * The message of a refusal: the key path and the category, never the value.
253
+ * @param {{keyPath: string, why: string}} refusal @returns {string}
254
+ */
255
+ export function sandboxRefusalMessage(refusal) {
256
+ return `${SANDBOX_DIR_REFUSED}: ${refusal.keyPath} is refused — ${refusal.why}`;
257
+ }
@@ -5,11 +5,15 @@
5
5
  * 1. env var — `env:NAME` from the reference, else `CODE_FORGE_KEY_<NAME>`
6
6
  * 2. OS keychain — this store (`@napi-rs/keyring` → macOS `security` CLI → 0600 file)
7
7
  * 3. 1Password — `op read <op://…>` (retry once on a first-call timeout); a hit is cached
8
- * into the store as `{"v", "exp"}` for `cacheHours` (default 8)
8
+ * into the store as `{"v", "exp"}` for `cacheHours` (whole hours 1–24,
9
+ * default 8; `keys.cache_hours` / `keys set --cache-hours`, B84)
9
10
  * 4. ask — hidden input, only when the caller passes `ask` (setup only)
10
11
  * An entry whose `exp` has passed is deleted on that first read and treated as absent, so the
11
- * chain moves on to 1Password and refreshes it. A backend that fails to read is skipped with a
12
- * non-secret reason (backend name + error code) in `errors`; the chain never aborts on it.
12
+ * chain moves on to 1Password and refreshes it. A cached entry is never trusted for longer than
13
+ * the reader's hours (B84, D2): a read whose entry has `exp > now + hours` moves the expiry
14
+ * earlier to `now + hours` (entry and index row rewritten, value kept, 1Password not asked); an
15
+ * expiry is never moved later, and a read with no hours caps at `MAX_CACHE_HOURS`. A backend that
16
+ * fails to read is skipped with a non-secret reason (backend name + error code) in `errors`; the chain never aborts on it.
13
17
  *
14
18
  * `source` names where the value came from: `env`, the store backend that held it (`keychain`,
15
19
  * `security-cli`, `file`), `op`, or `ask`.
@@ -18,7 +22,11 @@
18
22
  * redacted output path (`util/log`, `util/redact`) masks it from then on. Nothing here prints.
19
23
  *
20
24
  * Alongside the secrets the store keeps `index.json` (0600, no values): name → source, backend,
21
- * exp — what `keys list` shows.
25
+ * exp, and (B85) the `op://` reference a 1Password read came from — what `keys list` shows and
26
+ * what its re-link hint names. A row written before B85 has no `ref` and still lists.
27
+ *
28
+ * An entry dropped as expired is reported to the reader (B85): `resolveKey` returns its old `exp`
29
+ * as `expired` when nothing else resolved, so `jev ask` can print the exact re-link command.
22
30
  *
23
31
  * @typedef {object} Backend
24
32
  * @property {string} name
@@ -28,8 +36,8 @@
28
36
  * @property {(name: string, value: string) => Promise<void>} set
29
37
  * @property {(name: string) => Promise<boolean>} delete
30
38
  *
31
- * @typedef {{name: string, source: string, backend: string, exp: number|null}} IndexRow
32
- * @typedef {{value: string|null, source: string|null, exp: number|null, errors: string[]}} Resolution
39
+ * @typedef {{name: string, source: string, backend: string, exp: number|null, ref?: string}} IndexRow
40
+ * @typedef {{value: string|null, source: string|null, exp: number|null, errors: string[], expired?: number|null}} Resolution
33
41
  */
34
42
 
35
43
  import { readFile } from 'node:fs/promises';
@@ -42,6 +50,11 @@ import { createSecurityCliBackend } from './backends/security-cli.mjs';
42
50
  import { isOpRef, opRead as realOpRead } from './onepassword.mjs';
43
51
 
44
52
  export const DEFAULT_CACHE_HOURS = 8;
53
+ /** The bounds of `keys.cache_hours` / `--cache-hours`; the schema carries the same numbers. */
54
+ export const MIN_CACHE_HOURS = 1;
55
+ export const MAX_CACHE_HOURS = 24;
56
+ /** The one text for an out-of-range cache time; it never carries the value. */
57
+ export const CACHE_HOURS_RULE = `a whole number of hours from ${MIN_CACHE_HOURS} to ${MAX_CACHE_HOURS}`;
45
58
  const HOUR_MS = 3_600_000;
46
59
  const NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
47
60
 
@@ -74,6 +87,62 @@ export function parseRef(name, ref) {
74
87
  return { envName, storeName, opRef: ref?.startsWith('op://') ? ref : null };
75
88
  }
76
89
 
90
+ /**
91
+ * @param {unknown} exp
92
+ * @returns {exp is number} a time in ms that `new Date(exp)` can print: finite, |exp| <= 8.64e15.
93
+ */
94
+ export function isTime(exp) {
95
+ return typeof exp === 'number' && Number.isFinite(exp) && Math.abs(exp) <= 8.64e15;
96
+ }
97
+
98
+ /**
99
+ * A reference as one POSIX shell word for a re-link command a human copies (B85): unchanged when
100
+ * every character is in `[A-Za-z0-9_./:@%+=-]`, else wrapped in single quotes with each `'` written
101
+ * as `'\''`.
102
+ * @param {string} ref
103
+ * @returns {string}
104
+ */
105
+ export function shellWord(ref) {
106
+ return /^[A-Za-z0-9_./:@%+=-]+$/.test(ref) ? ref : `'${ref.replace(/'/g, "'\\''")}'`;
107
+ }
108
+
109
+ /**
110
+ * @param {unknown} hours
111
+ * @returns {hours is number} a whole number of hours within the bounds.
112
+ */
113
+ export function isCacheHours(hours) {
114
+ return Number.isInteger(hours) && /** @type {number} */ (hours) >= MIN_CACHE_HOURS && /** @type {number} */ (hours) <= MAX_CACHE_HOURS;
115
+ }
116
+
117
+ /**
118
+ * @param {unknown} hours
119
+ * @returns {number} the hours, when they are within the bounds.
120
+ * @throws {RangeError} otherwise — no path may cache for longer than the maximum.
121
+ */
122
+ function assertCacheHours(hours) {
123
+ if (!isCacheHours(hours)) {
124
+ throw new RangeError(`cache hours must be ${CACHE_HOURS_RULE}`);
125
+ }
126
+ return hours;
127
+ }
128
+
129
+ /**
130
+ * The cache time a project asks for (`keys.cache_hours`). Absent ⇒ the default; present and valid
131
+ * ⇒ that value; anything else ⇒ the default with a problem text (never the value itself).
132
+ * @param {Record<string, any> | null | undefined} cfg
133
+ * @returns {{hours: number, source: 'config'|'default', problem: string|null}}
134
+ */
135
+ export function cacheHoursFrom(cfg) {
136
+ const keys = cfg?.keys;
137
+ if (keys === null || typeof keys !== 'object' || !Object.hasOwn(keys, 'cache_hours')) {
138
+ return { hours: DEFAULT_CACHE_HOURS, source: 'default', problem: null };
139
+ }
140
+ if (isCacheHours(keys.cache_hours)) {
141
+ return { hours: keys.cache_hours, source: 'config', problem: null };
142
+ }
143
+ return { hours: DEFAULT_CACHE_HOURS, source: 'default', problem: `keys.cache_hours must be ${CACHE_HOURS_RULE}` };
144
+ }
145
+
77
146
  /**
78
147
  * @param {string} value
79
148
  * @param {number|null} exp
@@ -122,8 +191,9 @@ export function codeForgeHome(env = process.env) {
122
191
  * @param {object} opts
123
192
  * @param {Backend[]} opts.backends - candidates, most preferred first; unavailable ones are dropped.
124
193
  * @param {string} opts.dir - where `index.json` lives.
194
+ * @param {typeof writePrivateFile} [opts.writeIndexFile] - writes `index.json` (tests inject a failure).
125
195
  */
126
- export async function createKeyStore({ backends, dir }) {
196
+ export async function createKeyStore({ backends, dir, writeIndexFile = writePrivateFile }) {
127
197
  /** @type {Backend[]} */
128
198
  const live = [];
129
199
  for (const backend of backends) {
@@ -153,7 +223,7 @@ export async function createKeyStore({ backends, dir }) {
153
223
  const updateIndex = async (change) => {
154
224
  const index = await readIndex();
155
225
  change(index);
156
- await writePrivateFile(indexFile, JSON.stringify(index, null, 2));
226
+ await writeIndexFile(indexFile, JSON.stringify(index, null, 2));
157
227
  };
158
228
 
159
229
  return {
@@ -165,10 +235,14 @@ export async function createKeyStore({ backends, dir }) {
165
235
  * @param {string} name
166
236
  * @param {number} now
167
237
  * @param {string[]} [errors] - receives one non-secret reason per backend that failed.
238
+ * @param {{maxHours?: number, onExpired?: (exp: number) => void}} [opts] - the reader's cache
239
+ * time; a longer `exp` is cut to `now + maxHours` (default `MAX_CACHE_HOURS`). `onExpired`
240
+ * gets the old `exp` of an entry this read dropped as expired (B85).
168
241
  * @returns {Promise<{value: string, exp: number|null, backend: string}|null>}
169
242
  */
170
- async read(name, now, errors = []) {
243
+ async read(name, now, errors = [], { maxHours = MAX_CACHE_HOURS, onExpired } = {}) {
171
244
  assertKeyName(name);
245
+ const latest = now + assertCacheHours(maxHours) * HOUR_MS;
172
246
  for (const backend of readOrder) {
173
247
  let raw;
174
248
  try {
@@ -186,11 +260,36 @@ export async function createKeyStore({ backends, dir }) {
186
260
  await backend.delete(name).catch((err) => {
187
261
  errors.push(`${backend.name}: delete of expired entry failed (${reasonOf(err)})`);
188
262
  });
263
+ // reported first, and a failed index update is a reason, never a reject: the reader still
264
+ // learns the key expired
265
+ onExpired?.(entry.exp);
189
266
  await updateIndex((index) => {
190
267
  delete index[name];
268
+ }).catch((err) => {
269
+ errors.push(`index: removal of the expired entry failed (${reasonOf(err)})`);
191
270
  });
192
271
  return null;
193
272
  }
273
+ if (entry.exp !== null && entry.exp > latest) {
274
+ // D2: the shorter expiry applies now; the value stays and 1Password is not asked
275
+ // a failed rewrite leaves the index alone (it still matches the stored entry);
276
+ // the index row is only adjusted when one exists, never invented
277
+ const rewritten = await backend.set(name, encodeEntry(entry.value, latest)).then(
278
+ () => true,
279
+ (err) => {
280
+ errors.push(`${backend.name}: rewrite of the shortened expiry failed (${reasonOf(err)})`);
281
+ return false;
282
+ },
283
+ );
284
+ if (rewritten) {
285
+ await updateIndex((index) => {
286
+ if (index[name]) index[name] = { ...index[name], backend: backend.name, exp: latest };
287
+ }).catch((err) => {
288
+ errors.push(`index: rewrite of the shortened expiry failed (${reasonOf(err)})`);
289
+ });
290
+ }
291
+ return { value: entry.value, exp: latest, backend: backend.name };
292
+ }
194
293
  return { ...entry, backend: backend.name };
195
294
  }
196
295
  return null;
@@ -199,9 +298,10 @@ export async function createKeyStore({ backends, dir }) {
199
298
  /**
200
299
  * @param {string} name
201
300
  * @param {string} value
202
- * @param {{source: string, exp: number|null}} meta
301
+ * @param {{source: string, exp: number|null, ref?: string}} meta - `ref` (B85): the `op://`
302
+ * reference the value was read from; it goes to the index, never a value.
203
303
  */
204
- async put(name, value, { source, exp }) {
304
+ async put(name, value, { source, exp, ref }) {
205
305
  assertKeyName(name);
206
306
  if (!writer) {
207
307
  throw new Error('keys: no writable backend');
@@ -209,7 +309,7 @@ export async function createKeyStore({ backends, dir }) {
209
309
  registerSecret(value);
210
310
  await writer.set(name, encodeEntry(value, exp));
211
311
  await updateIndex((index) => {
212
- index[name] = { source, backend: writer.name, exp };
312
+ index[name] = { source, backend: writer.name, exp, ...(typeof ref === 'string' ? { ref } : {}) };
213
313
  });
214
314
  },
215
315
 
@@ -238,6 +338,7 @@ export async function createKeyStore({ backends, dir }) {
238
338
  source: String(index[name].source),
239
339
  backend: String(index[name].backend),
240
340
  exp: index[name].exp ?? null,
341
+ ...(typeof index[name].ref === 'string' ? { ref: index[name].ref } : {}),
241
342
  }));
242
343
  },
243
344
  };
@@ -267,8 +368,8 @@ export function createDefaultKeyStore(
267
368
  }
268
369
 
269
370
  /**
270
- * Read a key from 1Password and cache it into the store as `{v, exp}` for `cacheHours`. A cache
271
- * write failure is reported in `errors` but does not lose the value just read.
371
+ * Read a key from 1Password and cache it into the store as `{v, exp}` for `cacheHours`; the index
372
+ * row records `opRef` (B85). A cache write failure is reported in `errors` but does not lose the value just read.
272
373
  *
273
374
  * @param {string} storeName
274
375
  * @param {string} opRef
@@ -280,6 +381,7 @@ export function createDefaultKeyStore(
280
381
  * @returns {Promise<Resolution>}
281
382
  */
282
383
  export async function readOpAndCache(storeName, opRef, { store, opRead = realOpRead, now = Date.now(), cacheHours = DEFAULT_CACHE_HOURS }) {
384
+ assertCacheHours(cacheHours);
283
385
  const res = await opRead(opRef);
284
386
  if (res.value === null) {
285
387
  return { value: null, source: null, exp: null, errors: [res.error ?? 'op read failed'] };
@@ -288,7 +390,7 @@ export async function readOpAndCache(storeName, opRef, { store, opRead = realOpR
288
390
  const exp = now + cacheHours * HOUR_MS;
289
391
  /** @type {string[]} */
290
392
  const errors = [];
291
- await store.put(storeName, res.value, { source: 'op', exp }).catch((err) => {
393
+ await store.put(storeName, res.value, { source: 'op', exp, ref: opRef }).catch((err) => {
292
394
  errors.push(`cache write failed (${reasonOf(err)})`);
293
395
  });
294
396
  return { value: res.value, source: 'op', exp, errors };
@@ -305,11 +407,14 @@ export async function readOpAndCache(storeName, opRef, { store, opRead = realOpR
305
407
  * @param {typeof realOpRead} [deps.opRead]
306
408
  * @param {(name: string) => Promise<string|null>} [deps.ask] - setup only; omitted everywhere else.
307
409
  * @param {number} [deps.now]
308
- * @param {number} [deps.cacheHours]
309
- * @returns {Promise<Resolution>}
410
+ * @param {number} [deps.cacheHours] - how long a 1Password read is cached, and the longest a cached
411
+ * entry is trusted. Omitted: cached for `DEFAULT_CACHE_HOURS`, trusted up to `MAX_CACHE_HOURS`.
412
+ * @returns {Promise<Resolution>} when nothing resolved, `expired` is the old `exp` of a cached
413
+ * entry this call dropped as expired, else null (B85).
310
414
  */
311
- export async function resolveKey(name, { store, ref, env = process.env, opRead = realOpRead, ask, now = Date.now(), cacheHours = DEFAULT_CACHE_HOURS }) {
415
+ export async function resolveKey(name, { store, ref, env = process.env, opRead = realOpRead, ask, now = Date.now(), cacheHours }) {
312
416
  const { envName, storeName, opRef } = parseRef(name, ref);
417
+ const maxHours = cacheHours === undefined ? MAX_CACHE_HOURS : assertCacheHours(cacheHours);
313
418
  /** @type {string[]} */
314
419
  const errors = [];
315
420
 
@@ -319,13 +424,15 @@ export async function resolveKey(name, { store, ref, env = process.env, opRead =
319
424
  return { value: fromEnv, source: 'env', exp: null, errors };
320
425
  }
321
426
 
322
- const cached = await store.read(storeName, now, errors);
427
+ /** @type {number|null} */
428
+ let expired = null;
429
+ const cached = await store.read(storeName, now, errors, { maxHours, onExpired: (exp) => { expired = exp; } });
323
430
  if (cached) {
324
431
  return { value: cached.value, source: cached.backend, exp: cached.exp, errors };
325
432
  }
326
433
 
327
434
  if (opRef) {
328
- const fromOp = await readOpAndCache(storeName, opRef, { store, opRead, now, cacheHours });
435
+ const fromOp = await readOpAndCache(storeName, opRef, { store, opRead, now, cacheHours: cacheHours ?? DEFAULT_CACHE_HOURS });
329
436
  errors.push(...fromOp.errors);
330
437
  if (fromOp.value !== null) {
331
438
  return { ...fromOp, errors };
@@ -343,5 +450,5 @@ export async function resolveKey(name, { store, ref, env = process.env, opRead =
343
450
  }
344
451
  }
345
452
 
346
- return { value: null, source: null, exp: null, errors };
453
+ return { value: null, source: null, exp: null, errors, expired };
347
454
  }
@@ -53,9 +53,14 @@ function normalizeRow(row) {
53
53
  /**
54
54
  * Claim `<base>.<n>.jsonl` for `n = 1, 2, ...` atomically via `link()` (hardlink the still-full
55
55
  * file under the rotated name, then unlink the original) instead of `rename()`.
56
+ *
57
+ * `hooks.afterLink` is a test seam only: when given, it is awaited with the claimed candidate path
58
+ * right after a successful `link()` and before this call's `unlink()` of the source, so a test can
59
+ * force another racer's unlink into exactly that window. Production callers pass nothing.
56
60
  * @param {string} file @param {string} dir @param {string} base
61
+ * @param {{ afterLink?: (candidate: string) => Promise<void> | void }} [hooks]
57
62
  */
58
- export async function claimRotatedName(file, dir, base) {
63
+ export async function claimRotatedName(file, dir, base, hooks = {}) {
59
64
  let n = 1;
60
65
  for (;;) {
61
66
  const candidate = path.join(dir, `${base}.${n}.jsonl`);
@@ -70,6 +75,7 @@ export async function claimRotatedName(file, dir, base) {
70
75
  if (err.code === 'ENOENT') return; // `file` vanished before we could link it — a racer already rotated it out
71
76
  throw err;
72
77
  }
78
+ if (hooks.afterLink) await hooks.afterLink(candidate);
73
79
  try {
74
80
  await unlink(file);
75
81
  return;