iterate-plugin 3.5.7 → 3.7.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 (79) hide show
  1. package/README.md +97 -22
  2. package/README.zh-CN.md +79 -19
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +516 -35
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +193 -0
  9. package/dist/index.js +61 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +54 -14
  12. package/dist/meta-review.js +132 -28
  13. package/dist/method-scope.js +31 -8
  14. package/dist/review-scope.js +32 -9
  15. package/dist/review.js +96 -17
  16. package/dist/session-hooks.js +61 -14
  17. package/dist/skill-prompt.js +396 -66
  18. package/dist/tools/checkpoint.js +85 -19
  19. package/dist/tools/config.js +44 -2
  20. package/dist/tools/context.js +22 -5
  21. package/dist/tools/decision-log.js +106 -28
  22. package/dist/tools/defense-events.js +90 -31
  23. package/dist/tools/defense-store.js +28 -3
  24. package/dist/tools/experience-bank.js +108 -43
  25. package/dist/tools/experience-store.js +54 -4
  26. package/dist/tools/fix.js +347 -119
  27. package/dist/tools/history.js +68 -14
  28. package/dist/tools/present.js +80 -0
  29. package/dist/tools/prune.js +150 -53
  30. package/dist/tools/quality-gate.js +52 -2
  31. package/dist/tools/quality-store.js +17 -1
  32. package/dist/tools/review.js +158 -7
  33. package/dist/tools/transcript.js +173 -36
  34. package/dist/tools/triage.js +67 -12
  35. package/dist/tools/validate.js +115 -24
  36. package/dist/transcript.js +267 -4
  37. package/dist/working-directory.js +114 -0
  38. package/lib/client.js +1912 -512
  39. package/lib/parse.js +1001 -103
  40. package/package.json +16 -9
  41. package/src/approval-gate.ts +160 -23
  42. package/src/atomic-fs.ts +152 -17
  43. package/src/client/i18n.ts +517 -0
  44. package/src/client/index.ts +1031 -369
  45. package/src/client/session-cache.ts +54 -0
  46. package/src/config-loader.ts +549 -36
  47. package/src/config-write.ts +108 -26
  48. package/src/evidence.ts +194 -92
  49. package/src/file-lock.ts +211 -0
  50. package/src/index.ts +60 -29
  51. package/src/jobs.ts +63 -12
  52. package/src/live.ts +50 -12
  53. package/src/meta-review.ts +151 -34
  54. package/src/method-scope.ts +31 -8
  55. package/src/review-scope.ts +33 -10
  56. package/src/review.ts +96 -17
  57. package/src/session-hooks.ts +60 -13
  58. package/src/skill-prompt.ts +396 -66
  59. package/src/tools/checkpoint.ts +81 -19
  60. package/src/tools/config.ts +44 -2
  61. package/src/tools/context.ts +25 -5
  62. package/src/tools/decision-log.ts +114 -23
  63. package/src/tools/defense-events.ts +87 -31
  64. package/src/tools/defense-store.ts +30 -3
  65. package/src/tools/experience-bank.ts +112 -44
  66. package/src/tools/experience-store.ts +54 -5
  67. package/src/tools/fix.ts +258 -20
  68. package/src/tools/history.ts +74 -15
  69. package/src/tools/present.ts +89 -0
  70. package/src/tools/prune.ts +154 -51
  71. package/src/tools/quality-gate.ts +57 -2
  72. package/src/tools/quality-store.ts +18 -1
  73. package/src/tools/review.ts +155 -6
  74. package/src/tools/transcript.ts +178 -36
  75. package/src/tools/triage.ts +70 -13
  76. package/src/tools/validate.ts +138 -32
  77. package/src/transcript.ts +279 -4
  78. package/src/types.ts +29 -1
  79. package/src/working-directory.ts +126 -0
@@ -1,7 +1,8 @@
1
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { join, resolve, sep } from 'node:path';
3
+ import { basename, dirname, join, resolve, sep } from 'node:path';
4
4
  import yaml from 'js-yaml';
5
+ import { sessionCwdOf } from "./working-directory.js";
5
6
  /**
6
7
  * Load and parse iterate.config.yaml from the project root.
7
8
  * Returns null if the file is missing or invalid.
@@ -104,6 +105,10 @@ export function mergeConfig(base, override) {
104
105
  * of the built-in defaults ("Master + Overrides"). Never returns null — a
105
106
  * project without a config file simply runs on the defaults (with an empty
106
107
  * validation command set, so nothing untrusted can ever execute).
108
+ *
109
+ * The merged result is FIELD-COERCED (see {@link coerceKnownFields}): YAML has
110
+ * no schema, so a hand-edited `max_rounds: "ten"` falls back to the default
111
+ * per field instead of flowing into the review loop as garbage.
107
112
  */
108
113
  export function loadEffectiveConfig(projectRoot) {
109
114
  const override = loadConfig(projectRoot);
@@ -111,8 +116,110 @@ export function loadEffectiveConfig(projectRoot) {
111
116
  return { config: defaultConfig(), source: 'defaults', override: null };
112
117
  }
113
118
  const merged = mergeConfig(defaultConfig(), override);
119
+ coerceKnownFields(merged);
114
120
  return { config: merged, source: 'override', override };
115
121
  }
122
+ /** True for a plain object (not null, not an array). */
123
+ function isPlainObject(v) {
124
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
125
+ }
126
+ /** A positive integer (rounds, line budgets, chunk sizes). */
127
+ function isPositiveInt(v) {
128
+ return typeof v === 'number' && Number.isInteger(v) && v > 0;
129
+ }
130
+ /** A non-negative integer (thresholds where 0 means "no limit"). */
131
+ function isNonNegativeInt(v) {
132
+ return typeof v === 'number' && Number.isInteger(v) && v >= 0;
133
+ }
134
+ /** An array of strings; `nonEmptyItems` also rejects blank entries. */
135
+ function isStringArray(v, nonEmptyItems = false) {
136
+ return (Array.isArray(v) &&
137
+ v.every((x) => typeof x === 'string' && (!nonEmptyItems || x.trim().length > 0)));
138
+ }
139
+ /**
140
+ * Coerce the KNOWN config fields back to their documented types, falling back
141
+ * to the built-in default PER FIELD when the on-disk value is garbage.
142
+ *
143
+ * YAML has no schema: `max_rounds: "ten"` used to flow straight into the
144
+ * review loop (which then silently ran zero rounds), `dimensions: "all"`
145
+ * replaced the dimension list with a string, and a garbage
146
+ * `observatory.approval` reached the approval gate unchecked. Free-form
147
+ * sections (`onboarding`, `personalization`) and unknown project keys are NOT
148
+ * touched beyond requiring them to be objects. Mutates `raw` in place — only
149
+ * safe on freshly-merged objects (see {@link loadEffectiveConfig}).
150
+ */
151
+ function coerceKnownFields(raw) {
152
+ const d = defaultConfig();
153
+ if (typeof raw.goal !== 'string' || raw.goal.trim().length === 0)
154
+ raw.goal = d.goal;
155
+ if (!isPositiveInt(raw.max_rounds))
156
+ raw.max_rounds = d.max_rounds;
157
+ if (raw.language !== 'zh' && raw.language !== 'en')
158
+ raw.language = d.language;
159
+ if (!isStringArray(raw.dimensions, true))
160
+ raw.dimensions = d.dimensions;
161
+ // `reasoning_effort` is optional (absent → provider default). Present but
162
+ // invalid → drop back to absent rather than inventing a value.
163
+ if ('reasoning_effort' in raw &&
164
+ raw.reasoning_effort !== 'low' && raw.reasoning_effort !== 'medium' && raw.reasoning_effort !== 'high') {
165
+ delete raw.reasoning_effort;
166
+ }
167
+ // A scalar/null value wholesale-replaces its default section during the
168
+ // merge — restore a full default section, then coerce its members.
169
+ const section = (key) => {
170
+ if (!isPlainObject(raw[key]))
171
+ raw[key] = { ...d[key] };
172
+ return raw[key];
173
+ };
174
+ const review = section('review');
175
+ if (review.scope !== 'full' && review.scope !== 'changed-only') {
176
+ review.scope = d.review.scope;
177
+ }
178
+ const atomic = section('atomic');
179
+ if (!isPositiveInt(atomic.max_lines))
180
+ atomic.max_lines = d.atomic.max_lines;
181
+ if (!isNonNegativeInt(atomic.max_adjacent_methods)) {
182
+ atomic.max_adjacent_methods = d.atomic.max_adjacent_methods;
183
+ }
184
+ const git = section('git');
185
+ if (typeof git.target_branch !== 'string' || git.target_branch.length === 0) {
186
+ git.target_branch = d.git.target_branch;
187
+ }
188
+ for (const key of ['use_worktree', 'push_per_round', 'auto_merge']) {
189
+ if (typeof git[key] !== 'boolean')
190
+ git[key] = d.git[key];
191
+ }
192
+ const validation = section('validation');
193
+ if (!isStringArray(validation.command_whitelist)) {
194
+ validation.command_whitelist = d.validation.command_whitelist;
195
+ }
196
+ // `commands` is an allow-list (module → command[]): a malformed entry must
197
+ // fall back to the default (empty) so an invalid shape can never smuggle a
198
+ // command into the runtime allow-list.
199
+ if (!isPlainObject(validation.commands) || !Object.values(validation.commands).every((v) => isStringArray(v))) {
200
+ validation.commands = d.validation.commands;
201
+ }
202
+ const reviewer = section('reviewer');
203
+ const dReviewer = d.reviewer;
204
+ for (const key of ['output_schema_validation', 'evidence_validation', 'coverage_validation']) {
205
+ if (typeof reviewer[key] !== 'boolean')
206
+ reviewer[key] = dReviewer[key];
207
+ }
208
+ if (!isPositiveInt(reviewer.scope_chunk_size))
209
+ reviewer.scope_chunk_size = dReviewer.scope_chunk_size;
210
+ const observatory = section('observatory');
211
+ const dObs = d.observatory;
212
+ if (typeof observatory.capture !== 'boolean')
213
+ observatory.capture = dObs.capture;
214
+ if (observatory.approval !== 'ask' && observatory.approval !== 'deny' && observatory.approval !== 'allow') {
215
+ observatory.approval = dObs.approval;
216
+ }
217
+ // Free-form sections must be objects (consumers treat them as maps).
218
+ for (const key of ['onboarding', 'personalization']) {
219
+ if (key in raw && !isPlainObject(raw[key]))
220
+ delete raw[key];
221
+ }
222
+ }
116
223
  /**
117
224
  * Check whether a command is in the predefined commands list.
118
225
  * A command is allowed if it is EXACTLY (after trim) listed in any
@@ -139,8 +246,66 @@ export function flattenCommands(commands) {
139
246
  return out;
140
247
  }
141
248
  /**
142
- * Validate that the config has all required fields.
143
- * Returns an array of missing field paths.
249
+ * Every top-level key `IterateConfig` defines. Anything else in the file is
250
+ * an unknown key: a typo (`maxRounds`) or a hallucinated section that nothing
251
+ * ever reads. Single source of truth for BOTH validation paths —
252
+ * {@link validateConfig} (read/merge) and `validateConfigUpdates` (write).
253
+ *
254
+ * Keep in sync with `IterateConfig` (src/types.ts).
255
+ */
256
+ export const SUPPORTED_CONFIG_KEYS = [
257
+ 'goal',
258
+ 'language',
259
+ 'dimensions',
260
+ 'max_rounds',
261
+ 'reasoning_effort',
262
+ 'review',
263
+ 'reviewer',
264
+ 'atomic',
265
+ 'git',
266
+ 'validation',
267
+ 'observatory',
268
+ 'personalization',
269
+ 'onboarding',
270
+ ];
271
+ /** Upper bound on configurable iteration rounds (config bomb guard). */
272
+ export const MAX_MAX_ROUNDS = 100;
273
+ /** Upper bound on `atomic.max_lines` (a single fix never needs more). */
274
+ export const MAX_ATOMIC_MAX_LINES = 10_000;
275
+ /** Upper bound on `atomic.max_adjacent_methods`. */
276
+ export const MAX_MAX_ADJACENT_METHODS = 200;
277
+ /** Upper bound on `reviewer.scope_chunk_size` (files per reviewer task batch). */
278
+ export const MAX_SCOPE_CHUNK_SIZE = 1000;
279
+ /** `review.scope` enum. */
280
+ const REVIEW_SCOPES = ['full', 'changed-only'];
281
+ /** `reasoning_effort` enum (absent → provider default). */
282
+ const REASONING_EFFORTS = ['low', 'medium', 'high'];
283
+ /** `observatory.approval` enum (the human-consent policy). */
284
+ const APPROVAL_POLICIES = ['ask', 'deny', 'allow'];
285
+ /** True when `v` is one of the allowed enum members. */
286
+ function isEnum(v, allowed) {
287
+ return typeof v === 'string' && allowed.includes(v);
288
+ }
289
+ /**
290
+ * Validate the config's shape, ranges, and enums.
291
+ *
292
+ * Returns an array of error strings (empty when valid). Three tiers:
293
+ * - `root` — the whole document is not an object (return immediately).
294
+ * - the three PRESENCE-required fields (`goal`, `dimensions`, `validation`)
295
+ * — reported by their bare path so existing callers keep matching them;
296
+ * - STRICT checks on every field that IS present: bounded integers
297
+ * (`max_rounds ≤ 100`, `atomic.max_lines`, `reviewer.scope_chunk_size`),
298
+ * enums (`review.scope`, `reasoning_effort`, `observatory.approval`),
299
+ * booleans (`git.*`, `reviewer.*`), the `validation.commands` allow-list
300
+ * shape (a `Record<string, string[]>` — an ARRAY is refused, otherwise
301
+ * `flattenCommands` would silently discard it and leave a whitelist that
302
+ * looks configured but allows nothing), and unknown top-level keys.
303
+ *
304
+ * Absent optional fields are accepted: `loadEffectiveConfig` fills them from
305
+ * the built-in defaults, and hand-written partial configs are legitimate —
306
+ * but a present field must be RIGHT (YAML has no schema, so `max_rounds:
307
+ * "ten"` or `reviewer: {evidence_validation: "yes"}` used to pass the old
308
+ * three-presence-check validator untouched).
144
309
  */
145
310
  export function validateConfig(config) {
146
311
  const errors = [];
@@ -149,49 +314,297 @@ export function validateConfig(config) {
149
314
  return errors;
150
315
  }
151
316
  const c = config;
152
- if (!c.goal)
317
+ // ── Required core fields (presence, bare-path identifiers) ──────────────
318
+ if (typeof c.goal !== 'string' || c.goal.trim().length === 0)
153
319
  errors.push('goal');
154
- if (!Array.isArray(c.dimensions))
320
+ if (!Array.isArray(c.dimensions)) {
155
321
  errors.push('dimensions');
156
- if (!c.validation || typeof c.validation !== 'object') {
157
- errors.push('validation');
158
322
  }
159
- else {
160
- const v = c.validation;
161
- if (!Array.isArray(v.command_whitelist))
323
+ else if (c.dimensions.length === 0 ||
324
+ !c.dimensions.every((d) => typeof d === 'string' && d.trim().length > 0)) {
325
+ // `dimensions: []` is a valid JSON type but an empty review: it passes
326
+ // `Array.isArray` while selecting zero dimensions to review.
327
+ errors.push('dimensions must be a non-empty array of non-empty strings');
328
+ }
329
+ // ── Optional scalar fields: present ⇒ must be valid ─────────────────────
330
+ if ('language' in c && !isEnum(c.language, ['zh', 'en'])) {
331
+ errors.push('language must be "zh" or "en"');
332
+ }
333
+ if ('max_rounds' in c &&
334
+ (typeof c.max_rounds !== 'number' ||
335
+ !Number.isInteger(c.max_rounds) ||
336
+ c.max_rounds < 1 ||
337
+ c.max_rounds > MAX_MAX_ROUNDS)) {
338
+ errors.push(`max_rounds must be an integer between 1 and ${MAX_MAX_ROUNDS}`);
339
+ }
340
+ if ('reasoning_effort' in c &&
341
+ c.reasoning_effort !== undefined &&
342
+ !isEnum(c.reasoning_effort, REASONING_EFFORTS)) {
343
+ errors.push('reasoning_effort must be "low", "medium", or "high"');
344
+ }
345
+ // ── Sections: absent ⇒ defaults apply; present ⇒ must be a plain object ─
346
+ const section = (key, required = false) => {
347
+ if (!(key in c)) {
348
+ if (required)
349
+ errors.push(key);
350
+ return null;
351
+ }
352
+ // `typeof [] === 'object'` — a YAML sequence in a section's place must not
353
+ // masquerade as an object (it would sail through every `key in v` check).
354
+ if (!isPlainObject(c[key])) {
355
+ errors.push(key);
356
+ return null;
357
+ }
358
+ return c[key];
359
+ };
360
+ const review = section('review');
361
+ if (review && review.scope !== undefined && !isEnum(review.scope, REVIEW_SCOPES)) {
362
+ errors.push('review.scope must be "full" or "changed-only"');
363
+ }
364
+ const atomic = section('atomic');
365
+ if (atomic) {
366
+ if ('max_lines' in atomic &&
367
+ (typeof atomic.max_lines !== 'number' ||
368
+ !Number.isInteger(atomic.max_lines) ||
369
+ atomic.max_lines < 1 ||
370
+ atomic.max_lines > MAX_ATOMIC_MAX_LINES)) {
371
+ errors.push(`atomic.max_lines must be an integer between 1 and ${MAX_ATOMIC_MAX_LINES}`);
372
+ }
373
+ if ('max_adjacent_methods' in atomic &&
374
+ (typeof atomic.max_adjacent_methods !== 'number' ||
375
+ !Number.isInteger(atomic.max_adjacent_methods) ||
376
+ atomic.max_adjacent_methods < 0 ||
377
+ atomic.max_adjacent_methods > MAX_MAX_ADJACENT_METHODS)) {
378
+ errors.push(`atomic.max_adjacent_methods must be an integer between 0 and ${MAX_MAX_ADJACENT_METHODS}`);
379
+ }
380
+ }
381
+ const git = section('git');
382
+ if (git) {
383
+ if ('target_branch' in git && (typeof git.target_branch !== 'string' || git.target_branch.length === 0)) {
384
+ errors.push('git.target_branch must be a non-empty string');
385
+ }
386
+ for (const key of ['use_worktree', 'push_per_round', 'auto_merge']) {
387
+ if (key in git && typeof git[key] !== 'boolean') {
388
+ errors.push(`git.${key} must be a boolean`);
389
+ }
390
+ }
391
+ }
392
+ const reviewer = section('reviewer');
393
+ if (reviewer) {
394
+ for (const key of ['output_schema_validation', 'evidence_validation', 'coverage_validation']) {
395
+ if (key in reviewer && typeof reviewer[key] !== 'boolean') {
396
+ errors.push(`reviewer.${key} must be a boolean`);
397
+ }
398
+ }
399
+ if ('scope_chunk_size' in reviewer &&
400
+ (typeof reviewer.scope_chunk_size !== 'number' ||
401
+ !Number.isInteger(reviewer.scope_chunk_size) ||
402
+ reviewer.scope_chunk_size < 1 ||
403
+ reviewer.scope_chunk_size > MAX_SCOPE_CHUNK_SIZE)) {
404
+ errors.push(`reviewer.scope_chunk_size must be an integer between 1 and ${MAX_SCOPE_CHUNK_SIZE}`);
405
+ }
406
+ }
407
+ const observatory = section('observatory');
408
+ if (observatory) {
409
+ if ('capture' in observatory && typeof observatory.capture !== 'boolean') {
410
+ errors.push('observatory.capture must be a boolean');
411
+ }
412
+ if ('approval' in observatory && observatory.approval !== undefined && !isEnum(observatory.approval, APPROVAL_POLICIES)) {
413
+ errors.push('observatory.approval must be "ask", "deny", or "allow"');
414
+ }
415
+ }
416
+ // ── validation: the allow-list the runtime trusts to execute commands ───
417
+ const v = section('validation', true);
418
+ if (v) {
419
+ if (!Array.isArray(v.command_whitelist)) {
162
420
  errors.push('validation.command_whitelist');
163
- if (!v.commands || typeof v.commands !== 'object')
164
- errors.push('validation.commands');
421
+ }
422
+ else if (!v.command_whitelist.every((x) => typeof x === 'string')) {
423
+ errors.push('validation.command_whitelist must be an array of strings');
424
+ }
425
+ // `commands` is an allow-list (module → command[]): it must be a mapping
426
+ // whose values are string arrays. `typeof [] === 'object'` used to let
427
+ // `["npm t"]` through, and `flattenCommands` then dropped every
428
+ // non-object entry — a whitelist that read as configured while allowing
429
+ // nothing (fail-closed for execution, but a silent configuration lie).
430
+ if (!('commands' in v) || !isPlainObject(v.commands)) {
431
+ errors.push('validation.commands must be a mapping of module → string[]');
432
+ }
433
+ else if (!Object.values(v.commands).every((arr) => Array.isArray(arr) && arr.every((x) => typeof x === 'string'))) {
434
+ errors.push('validation.commands must be a mapping of module → string[]');
435
+ }
436
+ }
437
+ // ── Unknown top-level keys: reported, never silently ignored ────────────
438
+ for (const key of Object.keys(c)) {
439
+ if (!SUPPORTED_CONFIG_KEYS.includes(key)) {
440
+ errors.push(`${key} is not a supported config key`);
441
+ }
165
442
  }
166
443
  return errors;
167
444
  }
445
+ /**
446
+ * Sensitive system directories that may never become a project root in the
447
+ * HEADLESS tier (see {@link resolveProjectRoot} tier (b)): the directory
448
+ * itself and its DIRECT children are refused, checked against both the
449
+ * lexical path and its realpath (macOS aliases `/etc` → `/private/etc`, so a
450
+ * realpath'd `/etc/foo` lands under `/private/etc`). Deeper descendants and
451
+ * sibling trees are not swept up — that is what keeps `os.tmpdir()`
452
+ * (`/var/folders/…`, i.e. `/private/var/folders/…`, three levels below
453
+ * `/private`) and `/Users/<me>/project` working. The bare home directory is
454
+ * refused separately (paths INSIDE home are allowed).
455
+ */
456
+ const SYSTEM_ROOTS = [
457
+ '/etc', '/usr', '/bin', '/sbin', '/var', '/private', '/System', '/Library',
458
+ '/Applications', '/dev', '/proc', '/sys', '/boot', '/run',
459
+ ];
460
+ /** Memoized realpath (system roots, home) — `realpath` never changes for them. */
461
+ const realpathCache = new Map();
462
+ function cachedRealpath(path) {
463
+ const memo = realpathCache.get(path);
464
+ if (memo !== undefined)
465
+ return memo;
466
+ let real = path;
467
+ try {
468
+ const r = realpathSync(path);
469
+ if (r)
470
+ real = r;
471
+ }
472
+ catch {
473
+ // Not present on this platform/filesystem — the literal path is all we have.
474
+ }
475
+ realpathCache.set(path, real);
476
+ return real;
477
+ }
478
+ /** True for the bare home directory itself (lexical or realpath form). */
479
+ function isHomeDirectory(candidate) {
480
+ return candidate === homedir() || candidate === cachedRealpath(homedir());
481
+ }
482
+ /**
483
+ * True for a path the headless tier must refuse because it is a sensitive
484
+ * system root or sits DIRECTLY inside one — checked against both the lexical
485
+ * and the realpath form (macOS aliases `/etc` → `/private/etc`, so a
486
+ * realpath'd `/etc/foo` lands under `/private/etc`, whose parent is the real
487
+ * `/etc`). Pure path inspection — no filesystem mutation.
488
+ */
489
+ function isSensitiveSystemPath(candidate) {
490
+ if (!candidate || candidate === sep)
491
+ return true;
492
+ const parent = dirname(candidate);
493
+ for (const sys of SYSTEM_ROOTS) {
494
+ if (candidate === sys || parent === sys)
495
+ return true;
496
+ // macOS: realpath(`/etc/foo`) = `/private/etc/foo` — compare against the
497
+ // real location of the sysroot or the alias slips through.
498
+ if (parent === cachedRealpath(sys))
499
+ return true;
500
+ }
501
+ return false;
502
+ }
503
+ /**
504
+ * Resolve `sessionCwd` to a containment anchor: the realpath of an EXISTING
505
+ * directory. Returns null when the session cwd is empty, `/`, NUL-bearing, or
506
+ * not an existing directory — a workspace header we cannot bound the session
507
+ * with (stale/bogus cwd) falls back to the headless tier instead.
508
+ */
509
+ function sessionAnchor(sessionCwd) {
510
+ if (typeof sessionCwd !== 'string')
511
+ return null;
512
+ const raw = sessionCwd.trim();
513
+ if (!raw || raw === sep || raw.includes('\0'))
514
+ return null;
515
+ const abs = resolve(raw);
516
+ if (abs === sep)
517
+ return null;
518
+ try {
519
+ if (!statSync(abs).isDirectory())
520
+ return null;
521
+ }
522
+ catch {
523
+ return null; // does not exist / unreadable — cannot anchor containment
524
+ }
525
+ try {
526
+ const real = realpathSync(abs);
527
+ if (real && real !== sep)
528
+ return real;
529
+ }
530
+ catch {
531
+ // Exists but realpath is fenced — the lexical absolute path still bounds it.
532
+ }
533
+ return abs;
534
+ }
535
+ /**
536
+ * Realpath `p` for CONTAINMENT comparisons: realpath directly when it exists,
537
+ * otherwise realpath the deepest existing ancestor and re-append the tail.
538
+ * A not-yet-created target can't be realpath'd as a whole, and on macOS
539
+ * `/var/folders/…` (lexical) vs `/private/var/folders/…` (anchor) would never
540
+ * match — likewise a symlinked parent must contribute its REAL destination,
541
+ * or "create a new dir through this link" would escape the workspace. Falls
542
+ * back to the lexical path when nothing along the way can be resolved.
543
+ */
544
+ function realpathForContainment(p) {
545
+ try {
546
+ if (existsSync(p))
547
+ return realpathSync(p);
548
+ }
549
+ catch {
550
+ return p;
551
+ }
552
+ let cur = p;
553
+ const tail = [];
554
+ for (;;) {
555
+ const parent = dirname(cur);
556
+ if (parent === cur)
557
+ return p; // walked to the fs root without an ancestor
558
+ tail.unshift(basename(cur));
559
+ try {
560
+ if (existsSync(parent))
561
+ return join(realpathSync(parent), ...tail);
562
+ }
563
+ catch {
564
+ return p;
565
+ }
566
+ cur = parent;
567
+ }
568
+ }
168
569
  /**
169
570
  * Resolve a caller-supplied project root to a safe absolute path.
170
571
  *
171
- * Every tool accepts a model-controlled `path` argument. Before it is used in
172
- * any file read/write or as a command `cwd`, it must be sanitized:
173
- * - an empty/missing `path` falls back to the current working directory;
174
- * - the path is resolved to an absolute path (collapsing `..` and symlinks);
175
- * - the filesystem root (`/`) is refused — it would let a prompt point tools
176
- * at arbitrary system directories (path-traversal escape).
572
+ * Every tool accepts a model-controlled `path` argument; before it is used in
573
+ * any file read/write or as a command `cwd`, it must be sanitized. Two-tier
574
+ * contract (checked AFTER the common steps below):
575
+ *
576
+ * Resolution order for the default (no explicit `path`) case:
577
+ * 1. `sessionCwd` — the absolute working directory the calling DSH session
578
+ * was created in (`exec.agent.session.header.cwd`). Authoritative
579
+ * workspace for the conversation, immune to where the process started;
580
+ * an empty session cwd or `/` is refused, everything else (including
581
+ * the home directory itself) is honored.
582
+ * 2. the process cwd, when it is a usable directory (not `/` or the home
583
+ * dir — launchd/daemon-managed servers start with cwd=`/`);
584
+ * 3. the session workspace decoded from `DSH_SESSION_JSONL`.
585
+ *
586
+ * Common steps: an empty/missing `path` falls back to the step above; the
587
+ * path is resolved to an absolute path (collapsing `..` and symlinks —
588
+ * realpath-aware so a symlinked session cwd and its target share one root);
589
+ * the filesystem root `/` is always refused.
590
+ *
591
+ * Tier (a) — a USABLE session cwd is known (an existing directory):
592
+ * an EXPLICIT `path` must resolve to the session cwd or something INSIDE
593
+ * it; otherwise `{ ok: false, reason }` (structured — callers
594
+ * short-circuit on it). This keeps the legitimate "scope to a
595
+ * subdirectory" use while blocking escapes to `/etc` or sibling trees
596
+ * (the path alone could never smuggle the model out of its workspace).
597
+ *
598
+ * Tier (b) — no usable session cwd (headless/tests):
599
+ * additionally refuse the sensitive system roots and their direct children
600
+ * (see {@link SYSTEM_ROOTS}) plus the bare home directory itself. Paths
601
+ * INSIDE home (`/Users/me/project`) and the transient tmpdir tree remain
602
+ * allowed, as does any ordinary project directory.
177
603
  *
178
604
  * Returns `{ ok: true, root }` on success, or `{ ok: false, reason }` when the
179
605
  * path is unsafe; callers must short-circuit on the failure and return a
180
606
  * structured error instead of proceeding.
181
607
  */
182
- /**
183
- * Resolve a caller-supplied project root to a safe absolute path.
184
- *
185
- * Resolution order for the default (no explicit `path`) case:
186
- * 1. `sessionCwd` — the absolute working directory the calling DSH session
187
- * was created in (`exec.agent.session.header.cwd`). This is the
188
- * authoritative workspace for the current conversation and is immune to
189
- * where the web-service process happened to start.
190
- * 2. the process cwd, when it is a usable directory (not `/` or the home
191
- * dir — launchd/daemon-managed servers start with cwd=`/`);
192
- * 3. the session workspace decoded from `DSH_SESSION_JSONL` (present when
193
- * the DSH runtime injects per-session env into tool sub-processes).
194
- */
195
608
  export function resolveProjectRoot(input, sessionCwd) {
196
609
  // A non-string input (number, array, …) from a hostile tool invocation must
197
610
  // be treated as "no explicit path" instead of crashing on `.trim()`.
@@ -201,7 +614,8 @@ export function resolveProjectRoot(input, sessionCwd) {
201
614
  if (raw.includes('\0')) {
202
615
  return { ok: false, reason: 'Refusing project root containing NUL bytes.' };
203
616
  }
204
- const rootLexical = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd));
617
+ const explicit = raw.length > 0;
618
+ const rootLexical = explicit ? resolve(raw) : resolve(effectiveCwd(sessionCwd));
205
619
  // Collapse symlinks (documented contract: "collapsing `..` and symlinks") —
206
620
  // a path through a symlinked directory must resolve to its REAL location so
207
621
  // two aliases of the same project share one `.iterate/` state, and tools can
@@ -225,14 +639,77 @@ export function resolveProjectRoot(input, sessionCwd) {
225
639
  if (!root || root === sep) {
226
640
  return { ok: false, reason: 'Refusing filesystem root as project root.' };
227
641
  }
642
+ // `.iterate` must stay INSIDE the resolved root: it is the state directory
643
+ // every tool writes through (checkpoint, decision log, fixes, backups). When
644
+ // it exists and is a symlink — or is reached through one — whose REAL target
645
+ // escapes the REAL project root, artifacts would be created/removed outside
646
+ // the workspace while the tools still believe they are under the root. Only
647
+ // the project ROOT used to be realpath'd, so a symlinked `.iterate` quietly
648
+ // redirected every product out of the tree. Refuse the root (structured —
649
+ // callers short-circuit) instead of following the link; an `.iterate` that
650
+ // stays within the root (a real dir, or a link to one inside) is accepted.
651
+ const iterateLexical = join(root, '.iterate');
652
+ if (existsSync(iterateLexical)) {
653
+ try {
654
+ const realRoot = realpathForContainment(root);
655
+ const realIterate = realpathForContainment(iterateLexical);
656
+ if (realIterate !== realRoot && !realIterate.startsWith(realRoot + sep)) {
657
+ return {
658
+ ok: false,
659
+ reason: `Refusing project root: \`.iterate\` resolves outside the project (symlink escape): ` +
660
+ `\`${realIterate}\` is not inside \`${realRoot}\`.`,
661
+ };
662
+ }
663
+ }
664
+ catch {
665
+ // A realpath failure (permission fence, race) keeps the lexical
666
+ // behavior — the state dir itself still surfaces any real I/O problem
667
+ // as a structured tool error later.
668
+ }
669
+ }
670
+ const anchor = sessionAnchor(sessionCwd);
671
+ if (anchor) {
672
+ // Tier (a): the session workspace bounds every explicit path (realpath on
673
+ // both sides — including the deepest-existing-ancestor form for a
674
+ // not-yet-created path, so `/var/…` vs `/private/var/…` and symlinked
675
+ // parents agree with the anchor).
676
+ if (explicit) {
677
+ const contained = realpathForContainment(root);
678
+ if (contained !== anchor && !contained.startsWith(anchor + sep)) {
679
+ return {
680
+ ok: false,
681
+ reason: `Refusing project root outside the session workspace: \`${root}\` is not inside \`${anchor}\`.`,
682
+ };
683
+ }
684
+ }
685
+ return { ok: true, root };
686
+ }
687
+ // Tier (b): headless — no session workspace to contain the path. Refuse the
688
+ // bare home directory (paths INSIDE home stay allowed) and sensitive system
689
+ // roots — checked on both forms: `/etc/foo` only shows its `/etc` parent
690
+ // lexically, while its realpath shows `/private/etc`.
691
+ if (isHomeDirectory(root) || isHomeDirectory(rootLexical)) {
692
+ return {
693
+ ok: false,
694
+ reason: 'Refusing the home directory itself as project root — scope to a subdirectory inside it.',
695
+ };
696
+ }
697
+ if (isSensitiveSystemPath(rootLexical) || isSensitiveSystemPath(root)) {
698
+ return { ok: false, reason: 'Refusing a sensitive system directory as project root.' };
699
+ }
228
700
  return { ok: true, root };
229
701
  }
230
702
  /**
231
703
  * Thin adapter for tool `execute(args, exec)` bodies: pull the session cwd
232
704
  * from the DSH run context and hand it to {@link resolveProjectRoot}.
705
+ *
706
+ * The cwd source is the EFFECTIVE session directory (dsh 0.2.1-alpha.2's
707
+ * `workingDirectory` service when the host provides one, else the immutable
708
+ * `header.cwd` — see `./working-directory.ts`), so a session whose directory
709
+ * was switched mid-run still resolves the project it is actually editing.
233
710
  */
234
711
  export function resolveProjectRootForExec(exec, input) {
235
- return resolveProjectRoot(input, exec?.agent?.session?.header?.cwd);
712
+ return resolveProjectRoot(input, sessionCwdOf(exec));
236
713
  }
237
714
  /**
238
715
  * Resolve the default working directory for tools invoked without an explicit
@@ -244,7 +721,11 @@ export function resolveProjectRootForExec(exec, input) {
244
721
  * `--Volumes-Eng-Dev-iterate-skill--`).
245
722
  */
246
723
  function effectiveCwd(sessionCwd) {
247
- if (sessionCwd && sessionCwd !== sep && sessionCwd !== homedir())
724
+ // A session legitimately rooted in the HOME directory must be honored: only
725
+ // an empty session cwd or the filesystem root disqualifies it. (Refusing
726
+ // `homedir()` here substituted the server's own cwd — tools then read/write
727
+ // `.iterate` in the wrong project.)
728
+ if (typeof sessionCwd === 'string' && sessionCwd && sessionCwd !== sep)
248
729
  return sessionCwd;
249
730
  let cwd = '';
250
731
  try {