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