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