iterate-plugin 3.5.4 → 3.5.6

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/dist/atomic-fs.js CHANGED
@@ -48,12 +48,21 @@ function tempPathFor(filePath) {
48
48
  */
49
49
  export function writeTextAtomic(filePath, data) {
50
50
  const tmp = tempPathFor(filePath);
51
- writeFileSync(tmp, data, 'utf-8');
51
+ try {
52
+ writeFileSync(tmp, data, 'utf-8');
53
+ }
54
+ catch (err) {
55
+ // The write itself failed (disk full, permission, …): the temp file may
56
+ // already be partially written, so remove it before propagating — a
57
+ // failed write must never litter the directory either.
58
+ cleanupTemp(tmp);
59
+ throw err;
60
+ }
52
61
  try {
53
62
  renameSync(tmp, filePath);
54
63
  }
55
64
  catch (err) {
56
- // Best-effort temp cleanup so a failed write never litters the directory.
65
+ // Best-effort temp cleanup so a failed rename never litters the directory.
57
66
  cleanupTemp(tmp);
58
67
  throw err;
59
68
  }
@@ -61,12 +70,21 @@ export function writeTextAtomic(filePath, data) {
61
70
  /** Async variant of {@link writeTextAtomic} (same temp naming convention). */
62
71
  export async function writeTextAtomicAsync(filePath, data) {
63
72
  const tmp = tempPathFor(filePath);
64
- await writeFile(tmp, data, 'utf-8');
73
+ try {
74
+ await writeFile(tmp, data, 'utf-8');
75
+ }
76
+ catch (err) {
77
+ // The write itself failed (disk full, permission, …): the temp file may
78
+ // already be partially written, so remove it before propagating — a
79
+ // failed write must never litter the directory either.
80
+ await cleanupTempAsync(tmp);
81
+ throw err;
82
+ }
65
83
  try {
66
84
  await rename(tmp, filePath);
67
85
  }
68
86
  catch (err) {
69
- // Best-effort temp cleanup so a failed write never litters the directory.
87
+ // Best-effort temp cleanup so a failed rename never litters the directory.
70
88
  await cleanupTempAsync(tmp);
71
89
  throw err;
72
90
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join, resolve, sep } from 'node:path';
4
4
  import yaml from 'js-yaml';
@@ -10,7 +10,10 @@ export function loadConfig(projectRoot) {
10
10
  try {
11
11
  const content = readFileSync(join(projectRoot, 'iterate.config.yaml'), 'utf-8');
12
12
  const parsed = yaml.load(content);
13
- if (!parsed || typeof parsed !== 'object')
13
+ // A YAML sequence root (e.g. a list of findings or a `- foo` file) must
14
+ // not masquerade as a config object — treating it as one would merge its
15
+ // indices into the config (config bomb). Reject it, not coerce it.
16
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
14
17
  return null;
15
18
  return parsed;
16
19
  }
@@ -141,7 +144,7 @@ export function flattenCommands(commands) {
141
144
  */
142
145
  export function validateConfig(config) {
143
146
  const errors = [];
144
- if (!config || typeof config !== 'object') {
147
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
145
148
  errors.push('root');
146
149
  return errors;
147
150
  }
@@ -190,13 +193,35 @@ export function validateConfig(config) {
190
193
  * the DSH runtime injects per-session env into tool sub-processes).
191
194
  */
192
195
  export function resolveProjectRoot(input, sessionCwd) {
193
- const raw = (input ?? '').trim();
196
+ // A non-string input (number, array, …) from a hostile tool invocation must
197
+ // be treated as "no explicit path" instead of crashing on `.trim()`.
198
+ const raw = (typeof input === 'string' ? input : '').trim();
194
199
  // A NUL byte can never name a real path and makes `resolve()` (and every
195
200
  // downstream fs call) throw — treat it as unsafe input, not a throw path.
196
201
  if (raw.includes('\0')) {
197
202
  return { ok: false, reason: 'Refusing project root containing NUL bytes.' };
198
203
  }
199
- const root = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd));
204
+ const rootLexical = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd));
205
+ // Collapse symlinks (documented contract: "collapsing `..` and symlinks") —
206
+ // a path through a symlinked directory must resolve to its REAL location so
207
+ // two aliases of the same project share one `.iterate/` state, and tools can
208
+ // never read outside the resolved root via a link. Only when the path
209
+ // exists: a not-yet-created target (dirs created through the tools) keeps
210
+ // the lexical path so the caller can still write into it. The filesystem
211
+ // root check runs against the NORMALIZED path, so a symlink to `/` is
212
+ // refused too.
213
+ let root = rootLexical;
214
+ try {
215
+ if (existsSync(rootLexical)) {
216
+ const real = realpathSync(rootLexical);
217
+ if (real)
218
+ root = real;
219
+ }
220
+ }
221
+ catch {
222
+ // realpath can throw on a broken link or a permission fence — keep the
223
+ // lexical path (existing behavior) rather than failing the resolution.
224
+ }
200
225
  if (!root || root === sep) {
201
226
  return { ok: false, reason: 'Refusing filesystem root as project root.' };
202
227
  }
@@ -9,8 +9,8 @@
9
9
  * The security posture mirrors the triage tool: never overwrite a malformed
10
10
  * config, always back up before writing, roll back on failure.
11
11
  */
12
- import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
13
- import { join } from 'node:path';
12
+ import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
13
+ import { basename, dirname, join } from 'node:path';
14
14
  import yaml from 'js-yaml';
15
15
  import { writeTextAtomic } from "./atomic-fs.js";
16
16
  /** Config file name (must match config-loader). */
@@ -19,6 +19,41 @@ export const CONFIG_FILE = 'iterate.config.yaml';
19
19
  export function configBackupSuffix(now = new Date()) {
20
20
  return now.toISOString().replace(/[:.]/g, '-');
21
21
  }
22
+ /** Upper bound on configurable iteration rounds (config bomb guard). */
23
+ export const MAX_MAX_ROUNDS = 100;
24
+ /** Upper bound on `atomic.max_lines` (a single fix never needs more). */
25
+ export const MAX_ATOMIC_MAX_LINES = 10_000;
26
+ /** Upper bound on `atomic.max_adjacent_methods`. */
27
+ export const MAX_MAX_ADJACENT_METHODS = 200;
28
+ /** Upper bound on `reviewer.scope_chunk_size` (files per reviewer task batch). */
29
+ export const MAX_SCOPE_CHUNK_SIZE = 1000;
30
+ /** Keep at most this many timestamped config backups (older ones are removed). */
31
+ export const MAX_CONFIG_BACKUPS = 5;
32
+ /**
33
+ * Bound the timestamped config backups: after a fresh one is written, delete
34
+ * every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
35
+ * filesystem failure here must never fail the write that just succeeded.
36
+ * @returns the absolute paths of the backups that were removed.
37
+ */
38
+ export function pruneOldConfigBackups(configPath, keep = MAX_CONFIG_BACKUPS) {
39
+ const removed = [];
40
+ try {
41
+ const dir = dirname(configPath);
42
+ const prefix = `${basename(configPath)}.bak-`;
43
+ const matches = existsSync(dir)
44
+ ? readdirSync(dir).filter((f) => f.startsWith(prefix)).sort()
45
+ : [];
46
+ const doomed = matches.slice(0, Math.max(0, matches.length - keep));
47
+ for (const f of doomed) {
48
+ rmSync(join(dir, f), { force: true });
49
+ removed.push(join(dir, f));
50
+ }
51
+ }
52
+ catch {
53
+ // Best-effort cleanup — never surface a cleanup failure.
54
+ }
55
+ return removed;
56
+ }
22
57
  /**
23
58
  * Validate a partial config update.
24
59
  * Returns an array of error strings (empty when the update is valid).
@@ -40,8 +75,35 @@ export function validateConfigUpdates(updates) {
40
75
  }
41
76
  }
42
77
  if ('max_rounds' in updates) {
43
- if (typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) || updates.max_rounds < 1) {
44
- errors.push('updates.max_rounds must be a positive integer');
78
+ if (typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) ||
79
+ updates.max_rounds < 1 || updates.max_rounds > MAX_MAX_ROUNDS) {
80
+ errors.push(`updates.max_rounds must be an integer between 1 and ${MAX_MAX_ROUNDS}`);
81
+ }
82
+ }
83
+ if ('reasoning_effort' in updates) {
84
+ if (updates.reasoning_effort !== undefined &&
85
+ updates.reasoning_effort !== 'low' &&
86
+ updates.reasoning_effort !== 'medium' &&
87
+ updates.reasoning_effort !== 'high') {
88
+ errors.push('updates.reasoning_effort must be "low", "medium", or "high"');
89
+ }
90
+ }
91
+ if ('reviewer' in updates) {
92
+ const rv = updates.reviewer;
93
+ if (!rv || typeof rv !== 'object') {
94
+ errors.push('updates.reviewer must be an object');
95
+ }
96
+ else {
97
+ for (const boolKey of ['output_schema_validation', 'evidence_validation', 'coverage_validation']) {
98
+ if (rv[boolKey] !== undefined && typeof rv[boolKey] !== 'boolean') {
99
+ errors.push(`updates.reviewer.${boolKey} must be a boolean`);
100
+ }
101
+ }
102
+ if (rv.scope_chunk_size !== undefined &&
103
+ (typeof rv.scope_chunk_size !== 'number' || !Number.isInteger(rv.scope_chunk_size) ||
104
+ rv.scope_chunk_size < 1 || rv.scope_chunk_size > MAX_SCOPE_CHUNK_SIZE)) {
105
+ errors.push(`updates.reviewer.scope_chunk_size must be an integer between 1 and ${MAX_SCOPE_CHUNK_SIZE}`);
106
+ }
45
107
  }
46
108
  }
47
109
  if ('review' in updates) {
@@ -59,11 +121,11 @@ export function validateConfigUpdates(updates) {
59
121
  errors.push('updates.atomic must be an object');
60
122
  }
61
123
  else {
62
- if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1)) {
63
- errors.push('updates.atomic.max_lines must be a positive integer');
124
+ if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1 || a.max_lines > MAX_ATOMIC_MAX_LINES)) {
125
+ errors.push(`updates.atomic.max_lines must be an integer between 1 and ${MAX_ATOMIC_MAX_LINES}`);
64
126
  }
65
- if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0)) {
66
- errors.push('updates.atomic.max_adjacent_methods must be a non-negative number');
127
+ if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0 || a.max_adjacent_methods > MAX_MAX_ADJACENT_METHODS)) {
128
+ errors.push(`updates.atomic.max_adjacent_methods must be a number between 0 and ${MAX_MAX_ADJACENT_METHODS}`);
67
129
  }
68
130
  }
69
131
  }
@@ -164,7 +226,10 @@ export function readRawConfig(configPath) {
164
226
  catch {
165
227
  throw new Error('existing iterate.config.yaml is not a valid YAML mapping');
166
228
  }
167
- if (!parsed || typeof parsed !== 'object') {
229
+ // A YAML sequence root (array) must not masquerade as a config object —
230
+ // `typeof [] === 'object'`, so the presence check alone would accept a
231
+ // config file that is actually a list. Writing over it would destroy data.
232
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
168
233
  throw new Error('existing iterate.config.yaml is not a valid YAML mapping');
169
234
  }
170
235
  return parsed;
@@ -201,5 +266,9 @@ export function writeConfigFile(projectRoot, config) {
201
266
  }
202
267
  return { ok: false, error: `failed to write config: ${String(err)}${rollbackError}` };
203
268
  }
269
+ // Success: bound the accumulation of timestamped backups so a long-lived
270
+ // project never collects an unbounded pile of config snapshots.
271
+ if (backupPath)
272
+ pruneOldConfigBackups(configPath);
204
273
  return { ok: true, backupPath };
205
274
  }
package/dist/evidence.js CHANGED
@@ -83,14 +83,45 @@ export function verifyLineBounds(line, text) {
83
83
  if (line === undefined || line === null || line === WHOLE_FILE_LINE) {
84
84
  return { inBounds: true, lineTotal };
85
85
  }
86
- if (line < 1)
86
+ // Only a positive INTEGER is a valid line anchor. A fractional line (e.g.
87
+ // 3.7 from a bypassed schema) or a negative value is out of bounds — a
88
+ // fractional line "within" the file would otherwise be accepted and poison
89
+ // the evidence trail with a location nothing can jump to.
90
+ if (line < 1 || !Number.isInteger(line))
87
91
  return { inBounds: false, lineTotal };
88
92
  return { inBounds: line <= lineTotal, lineTotal };
89
93
  }
90
94
  /** Verify a single finding's location against the real filesystem. */
91
95
  export function verifyFinding(root, input, opts = {}) {
92
- const relFile = input.file ?? '';
96
+ // A null element / non-object finding (hostile or model-authored JSON) must
97
+ // be reported as poisoned evidence, never crash `path.resolve` downstream.
98
+ if (!input || typeof input !== 'object') {
99
+ return {
100
+ file: '',
101
+ line: null,
102
+ lineTotal: null,
103
+ resolvedPath: null,
104
+ verified: false,
105
+ error: 'file_not_found',
106
+ };
107
+ }
108
+ // A non-string `file` (number, object, …) would make `resolve` throw
109
+ // ERR_INVALID_ARG_TYPE — coerce to "" so it fails closed as not-found.
110
+ const relFile = typeof input.file === 'string' ? input.file : '';
93
111
  const line = typeof input.line === 'number' ? input.line : null;
112
+ // "" resolves to the project ROOT (resolve(root, '') === root), which then
113
+ // trips the not-a-regular-file branch as `line_out_of_range` — but there is
114
+ // no file reference at all. Report it as `file_not_found` upfront.
115
+ if (relFile === '') {
116
+ return {
117
+ file: '',
118
+ line,
119
+ lineTotal: null,
120
+ resolvedPath: null,
121
+ verified: false,
122
+ error: 'file_not_found',
123
+ };
124
+ }
94
125
  const resolved = resolveWithin(root, relFile);
95
126
  if (resolved === null || !existsSync(resolved)) {
96
127
  return {
package/dist/git-scope.js CHANGED
@@ -89,7 +89,10 @@ function decodeQuotedPath(content) {
89
89
  }
90
90
  export function parseChangedFiles(stdout) {
91
91
  if (stdout.includes('\0')) {
92
- return stdout.split('\0').map((s) => s.trim()).filter((s) => s.length > 0);
92
+ // NUL mode: git emits exact names with no surrounding whitespace, so any
93
+ // trim would CORRUPT a filename that legitimately starts/ends with spaces
94
+ // (git supports such names). Split and drop only the trailing empty entry.
95
+ return stdout.split('\0').filter((s) => s.length > 0);
93
96
  }
94
97
  return stdout
95
98
  .split('\n')
@@ -147,9 +150,15 @@ export function decideScope(changedFiles) {
147
150
  * Uses execFile (no shell), so a model-controlled branch name can never be
148
151
  * interpreted as shell syntax.
149
152
  */
150
- export function runGit(args, cwd) {
153
+ export function runGit(args, cwd, opts = {}) {
151
154
  return new Promise((resolve) => {
152
- execFile('git', args, { cwd, timeout: 30_000, maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PAGER: 'cat' } }, (error, stdout, stderr) => {
155
+ execFile('git', args, {
156
+ cwd,
157
+ timeout: 30_000,
158
+ maxBuffer: 10 * 1024 * 1024,
159
+ env: { ...process.env, PAGER: 'cat' },
160
+ signal: opts.signal,
161
+ }, (error, stdout, stderr) => {
153
162
  const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
154
163
  resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode });
155
164
  });
@@ -161,7 +170,7 @@ export function runGit(args, cwd) {
161
170
  * `full`-scope result with `error` set — the reviewer must never crash the
162
171
  * plan because git is unavailable.
163
172
  */
164
- export async function resolveChangedFiles(root, targetBranch) {
173
+ export async function resolveChangedFiles(root, targetBranch, opts = {}) {
165
174
  // Option-injection guard: a branch name starting with '-' would be parsed by
166
175
  // git as an option (e.g. --output=...), not a ref. Reject it outright.
167
176
  if (typeof targetBranch !== 'string' || targetBranch.trim() === '' || targetBranch.startsWith('-')) {
@@ -174,7 +183,7 @@ export async function resolveChangedFiles(root, targetBranch) {
174
183
  }
175
184
  // -z: NUL-delimited names — machine-safe for any filename (spaces, quotes,
176
185
  // non-ASCII), and never confused with option-like content.
177
- const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root);
186
+ const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root, opts);
178
187
  if (!ok) {
179
188
  const reason = stderr.trim() || `git diff --name-only -z ${targetBranch} failed`;
180
189
  return { scope: 'full', changedFiles: [], fallbackToFull: true, error: reason };
package/dist/jobs.js CHANGED
@@ -16,6 +16,20 @@
16
16
  * - The registry is memory-only and panel rows are read-only (no progress
17
17
  * updates), so these jobs are completion records, not control channels.
18
18
  */
19
+ /**
20
+ * Read `ctx.jobs` defensively: the dsh plugin context can be a Proxy whose
21
+ * `jobs` getter throws (or is missing) when no job registry is present. A
22
+ * Proxy throw on property access must degrade to "no jobs", never crash the
23
+ * tool execution we are about to wrap.
24
+ */
25
+ function safeJobs(ctx) {
26
+ try {
27
+ return ctx?.jobs;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
19
33
  /**
20
34
  * Run `fn` wrapped in a dsh background job, settling it completed/failed
21
35
  * with the execution's outcome. When the host exposes no job registry (or
@@ -29,7 +43,7 @@
29
43
  * @returns the registry-issued job id, or `null` when unavailable.
30
44
  */
31
45
  export async function runWithJob(ctx, kind, label, fn) {
32
- const jobs = ctx?.jobs;
46
+ const jobs = safeJobs(ctx);
33
47
  if (!jobs || typeof jobs.start !== 'function') {
34
48
  return { result: await fn(), jobId: null };
35
49
  }
package/dist/live.js CHANGED
@@ -36,6 +36,45 @@ export const LIVE_MAX_BYTES = 64 * 1024;
36
36
  export function liveFilePath(projectRoot) {
37
37
  return join(projectRoot, '.iterate', 'transcript-live.ndjson');
38
38
  }
39
+ /**
40
+ * Module-level in-process queue serializing live-feed writes. The trim path
41
+ * (read → rewrite → append) is a read-modify-write: two concurrent tool
42
+ * results could interleave and one's rewrite would silently drop the other's
43
+ * freshly-appended line. Chaining every append onto one tail makes the whole
44
+ * read+rewrite+append atomic within the process (the only realistic mode for
45
+ * the plugin); cross-process racing remains a theoretical hazard, but the
46
+ * cap-trim makes a lost line a cosmetic issue, not a correctness one.
47
+ */
48
+ let liveWriteQueue = Promise.resolve();
49
+ function enqueueLiveWrite(task) {
50
+ const next = liveWriteQueue.then(task, task);
51
+ liveWriteQueue = next.catch(() => { });
52
+ return next;
53
+ }
54
+ /** Append one activity record to the project's live feed (byte-capped). */
55
+ export function appendLive(projectRoot, entry) {
56
+ return enqueueLiveWrite(async () => {
57
+ const file = liveFilePath(projectRoot);
58
+ const line = JSON.stringify(entry) + '\n';
59
+ await mkdir(join(projectRoot, '.iterate'), { recursive: true });
60
+ // Amortized O(1): only read+rewrite when the file has grown past the cap.
61
+ try {
62
+ const st = await stat(file).catch(() => null);
63
+ if (st && st.size > LIVE_MAX_BYTES) {
64
+ const raw = await readFile(file, 'utf-8');
65
+ const lines = raw.split('\n').filter(Boolean);
66
+ const tail = lines.slice(-LIVE_MAX_ENTRIES);
67
+ // Atomic rewrite (unique temp + rename) so a crash mid-trim never
68
+ // truncates the live feed; the temp file is prunable by iterate_prune.
69
+ await writeTextAtomicAsync(file, tail.join('\n') + '\n');
70
+ }
71
+ await appendFile(file, line, 'utf-8');
72
+ }
73
+ catch {
74
+ // Fire-and-forget: never let live capture break a tool call.
75
+ }
76
+ });
77
+ }
39
78
  /** Resolve the project root a tool execution belongs to, if any. */
40
79
  function projectRootOf(exec) {
41
80
  const cwd = exec.agent?.session?.header?.cwd;
@@ -95,28 +134,6 @@ export function classifyTool(name, args, projectRoot) {
95
134
  target = name;
96
135
  return { ts: new Date().toISOString(), type, tool: name, target };
97
136
  }
98
- /** Append one activity record to the project's live feed (byte-capped). */
99
- export async function appendLive(projectRoot, entry) {
100
- const file = liveFilePath(projectRoot);
101
- const line = JSON.stringify(entry) + '\n';
102
- await mkdir(join(projectRoot, '.iterate'), { recursive: true });
103
- // Amortized O(1): only read+rewrite when the file has grown past the cap.
104
- try {
105
- const st = await stat(file).catch(() => null);
106
- if (st && st.size > LIVE_MAX_BYTES) {
107
- const raw = await readFile(file, 'utf-8');
108
- const lines = raw.split('\n').filter(Boolean);
109
- const tail = lines.slice(-LIVE_MAX_ENTRIES);
110
- // Atomic rewrite (unique temp + rename) so a crash mid-trim never
111
- // truncates the live feed; the temp file is prunable by iterate_prune.
112
- await writeTextAtomicAsync(file, tail.join('\n') + '\n');
113
- }
114
- await appendFile(file, line, 'utf-8');
115
- }
116
- catch {
117
- // Fire-and-forget: never let live capture break a tool call.
118
- }
119
- }
120
137
  /** Read the live feed (newest first), capped at the last LIVE_MAX_ENTRIES. */
121
138
  export async function readLive(projectRoot) {
122
139
  const file = liveFilePath(projectRoot);
@@ -99,7 +99,7 @@ export function metaReviewReport(report) {
99
99
  if (dimSum !== total) {
100
100
  add('DIMENSION_SUM', 'high', 'byDimension counts do not sum to totalFindings', `byDimension sums to ${dimSum}, but totalFindings is ${total}.`);
101
101
  }
102
- const invalidDim = findings.find((f) => !dimensions.includes(f?.dimension));
102
+ const invalidDim = findings.find((f) => !!f && !dimensions.includes(f?.dimension));
103
103
  if (invalidDim) {
104
104
  add('DIMENSION_UNKNOWN', 'medium', `Finding references unknown dimension "${invalidDim.dimension}"`, `dimension "${invalidDim.dimension}" is not in report.dimensions ` +
105
105
  `(${dimensions.join(', ') || 'none'}).`);
@@ -129,7 +129,11 @@ export function metaReviewReport(report) {
129
129
  ? reportRounds[reportRounds.length - 1].round
130
130
  : null;
131
131
  const lastRoundNew = lastRecordedRound !== null && lastRecordedRound > 0
132
- ? Number(findingsByRound[lastRecordedRound - 1] ?? 0)
132
+ // Over-cap rounds are FOLDED by aggregateRounds into the final slot, so a
133
+ // reported round number may exceed the array length — read the SAME
134
+ // clamped index buildReviewReport/computeConvergence use, or a real
135
+ // over-cap round would be mis-read as 0 new (flag mismatch).
136
+ ? Number(findingsByRound[Math.min(lastRecordedRound, findingsByRound.length) - 1] ?? 0)
133
137
  : null;
134
138
  const expectedConverged = lastRoundNew === 0;
135
139
  if (report.convergence?.converged !== expectedConverged) {
@@ -227,7 +231,7 @@ export function buildFinalReviewReport(report, opts = {}) {
227
231
  // Try to attribute the poisoned finding to the round that first
228
232
  // surfaced it (best-effort; report rounds carry it).
229
233
  for (const r of report.rounds ?? []) {
230
- const matched = (r.findings ?? []).some((fnd) => fnd.file === violation.file && fnd.line === violation.line);
234
+ const matched = (r.findings ?? []).some((fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line);
231
235
  if (matched) {
232
236
  roundHint = ` (round ${r.round})`;
233
237
  break;
@@ -57,10 +57,14 @@ function normalizePath(path) {
57
57
  if (part === '' || part === '.')
58
58
  continue;
59
59
  if (part === '..') {
60
- if (parts.length > 0)
60
+ // Mirrors Python `os.path.normpath`: a leading `..` is preserved and a
61
+ // second `..` is NEVER collapsed into a bare `/` away — otherwise a
62
+ // traversal like `../../evil` folds into the single segment `evil` and
63
+ // the escape becomes undetectable downstream.
64
+ if (parts.length > 0 && parts[parts.length - 1] !== '..')
61
65
  parts.pop();
62
66
  else
63
- parts.push(part); // no root segment to pop — keep the leading '..'
67
+ parts.push(part);
64
68
  continue;
65
69
  }
66
70
  parts.push(part);
package/dist/review.js CHANGED
@@ -28,19 +28,20 @@ export const SEVERITY_RANK = {
28
28
  export function sortFindings(findings) {
29
29
  return [...findings].sort((a, b) => {
30
30
  // Guard against an out-of-spec severity string (e.g. from a model that
31
- // bypassed the schema): treat it as the least severe so NaN never enters
32
- // the comparator and ordering stays deterministic.
33
- const rankA = SEVERITY_RANK[a.severity] ?? SEVERITY_RANK.low;
34
- const rankB = SEVERITY_RANK[b.severity] ?? SEVERITY_RANK.low;
31
+ // bypassed the schema) AND against a null list element (model-authored
32
+ // JSON): treat both as the least severe so NaN never enters the
33
+ // comparator and ordering stays deterministic.
34
+ const rankA = SEVERITY_RANK[a?.severity] ?? SEVERITY_RANK.low;
35
+ const rankB = SEVERITY_RANK[b?.severity] ?? SEVERITY_RANK.low;
35
36
  const bySeverity = rankA - rankB;
36
37
  if (bySeverity !== 0)
37
38
  return bySeverity;
38
39
  // Defensive coercion: `file`/`line` can be wrong-typed when schema
39
40
  // validation is disabled — String()/Number() keep the comparator total.
40
- const byFile = String(a.file ?? '').localeCompare(String(b.file ?? ''));
41
+ const byFile = String(a?.file ?? '').localeCompare(String(b?.file ?? ''));
41
42
  if (byFile !== 0)
42
43
  return byFile;
43
- return (Number(a.line) || 0) - (Number(b.line) || 0);
44
+ return (Number(a?.line) || 0) - (Number(b?.line) || 0);
44
45
  });
45
46
  }
46
47
  /** Normalize a summary so near-identical duplicates collapse to one key. */
@@ -143,14 +144,17 @@ export function aggregateRounds(rounds, maxReviewRounds) {
143
144
  }
144
145
  // Clamp the allocation bound so a hostile round number cannot OOM the tool.
145
146
  const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2));
146
- const findingsByRound = [];
147
- for (let r = 1; r <= effectiveMax; r++) {
148
- let count = 0;
149
- for (const key of firstRoundByKey.keys()) {
150
- if (firstRoundByKey.get(key) === r)
151
- count++;
152
- }
153
- findingsByRound.push(count);
147
+ const findingsByRound = new Array(effectiveMax).fill(0);
148
+ for (const key of firstRoundByKey.keys()) {
149
+ const firstRound = firstRoundByKey.get(key) ?? 1;
150
+ // Fold rounds BEYOND the allocation bound into the highest slot instead of
151
+ // dropping them: a finding first seen in an over-cap round was previously
152
+ // invisible to `findingsByRound`, so a run whose final rounds exceeded the
153
+ // cap looked "converged" even though a real round found new issues. Folding
154
+ // keeps the counts truthful (the sum still equals totalFindings) while the
155
+ // memory bound stays fixed.
156
+ const idx = Math.min(firstRound, effectiveMax) - 1;
157
+ findingsByRound[idx] = (findingsByRound[idx] || 0) + 1;
154
158
  }
155
159
  return { findings: dedupeFindings(merged), findingsByRound, firstRoundByKey };
156
160
  }
@@ -245,7 +249,12 @@ export function buildReviewReport(input) {
245
249
  if (typeof r.round === 'number' && r.round > lastRound)
246
250
  lastRound = r.round;
247
251
  }
248
- const lastRoundCount = lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0;
252
+ // `lastRound` can exceed the array length when over-cap rounds were FOLDED
253
+ // into the last slot (see aggregateRounds). Read the same clamped index
254
+ // computeConvergence uses, so a real over-cap round is never mis-read as 0
255
+ // new findings (which would falsely report "converged").
256
+ const idx = Math.min(lastRound, findingsByRound.length) - 1;
257
+ const lastRoundCount = lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
249
258
  const converged = filteredRounds.length > 0 && lastRoundCount === 0;
250
259
  // Attach the normal-mode fix count to the summary (dry-run leaves it absent).
251
260
  const computed = summarize(sorted);
@@ -516,6 +525,9 @@ export function attachmentClause(attachments) {
516
525
  export function reviewerTaskPrompt(input) {
517
526
  const parts = [];
518
527
  parts.push(`You are the "${input.dimension}" reviewer for the iterate review.`, `Goal: ${input.goal}`, `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`);
528
+ if (input.effort) {
529
+ parts.push(`Reasoning effort for this review pass: ${input.effort}.`);
530
+ }
519
531
  if (input.focus) {
520
532
  parts.push(`FOCUS: ${input.focus}`);
521
533
  }
@@ -573,6 +585,11 @@ export function buildReviewPlan(input) {
573
585
  const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English';
574
586
  const goal = input.config.goal ?? '';
575
587
  const configuredScope = input.config.review?.scope ?? 'full';
588
+ const reasoningEffort = input.config.reasoning_effort === 'low' ||
589
+ input.config.reasoning_effort === 'medium' ||
590
+ input.config.reasoning_effort === 'high'
591
+ ? input.config.reasoning_effort
592
+ : null;
576
593
  const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : [];
577
594
  const maxLines = input.config.atomic?.max_lines ?? 20;
578
595
  const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : [];
@@ -631,6 +648,7 @@ export function buildReviewPlan(input) {
631
648
  scopeFiles: batch,
632
649
  focus: focusMap.get(d),
633
650
  attachments,
651
+ effort: reasoningEffort ?? undefined,
634
652
  }),
635
653
  findingsSchema: findingsSchema(),
636
654
  });
@@ -646,5 +664,6 @@ export function buildReviewPlan(input) {
646
664
  changedFiles: effectiveChangedOnly ? changedFiles : [],
647
665
  fallbackToFull,
648
666
  attachments,
667
+ reasoningEffort,
649
668
  };
650
669
  }