iterate-plugin 3.5.1 → 3.5.2

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.
@@ -1,10 +1,40 @@
1
- import { readFileSync, existsSync, statSync } from 'node:fs';
2
- import { join, dirname, resolve } from 'node:path';
1
+ import { readFileSync, existsSync, realpathSync, statSync } from 'node:fs';
2
+ import { join, dirname, resolve, sep } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { defineTool } from '@deepseek-ai/dsh-tools';
5
5
  import { resolveProjectRootForExec } from "../config-loader.js";
6
6
  /** How many ancestor directories we walk up looking for a SKILL.md. */
7
7
  const MAX_SKILL_DIR_LOOKUP_DEPTH = 12;
8
+ /**
9
+ * Whether a model-supplied `skillDir` may be used as a SKILL.md search root.
10
+ *
11
+ * `skillDir` is model-controlled, so it is only honored when it is an existing
12
+ * directory whose REAL path (symlinks resolved) sits inside one of the allowed
13
+ * roots (the project root or the plugin directory). Anything else — a path
14
+ * outside the allowed roots, a nonexistent dir, or a symlink pointing outside
15
+ * (e.g. at `/etc`) — is rejected so arbitrary file contents can never be
16
+ * injected wholesale into the review context.
17
+ */
18
+ export function isAllowedSkillDir(skillDir, allowedRoots) {
19
+ if (typeof skillDir !== 'string' || skillDir.trim().length === 0)
20
+ return false;
21
+ try {
22
+ const dir = resolve(skillDir);
23
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
24
+ return false;
25
+ return allowedRoots.some((root) => {
26
+ if (typeof root !== 'string' || root.length === 0)
27
+ return false;
28
+ const rootReal = realpathSync(root);
29
+ const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep;
30
+ const real = realpathSync(dir);
31
+ return real === rootReal || real.startsWith(rootPrefix);
32
+ });
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
8
38
  /** Maximum number of image attachments relayed into the context in one call. */
9
39
  const MAX_ATTACHMENTS = 8;
10
40
  /** Maximum intrinsic width/height (px) accepted for an attached image. */
@@ -271,9 +301,13 @@ export function registerContextTool(ctx) {
271
301
  // the auto-detected root / project root.
272
302
  if (typeof args.skillDir === 'string' && args.skillDir.trim()) {
273
303
  try {
274
- const dir = resolve(args.skillDir);
275
- if (existsSync(dir) && statSync(dir).isDirectory())
276
- candidates.push(dir);
304
+ // Model-controlled path: only honor it when its REAL path stays
305
+ // inside the project root or the plugin directory — never let a
306
+ // skillDir point at arbitrary filesystem locations (e.g. /etc)
307
+ // whose contents would be injected wholesale into the context.
308
+ if (isAllowedSkillDir(args.skillDir, [projectRoot, PLUGIN_SRC_DIR])) {
309
+ candidates.push(resolve(args.skillDir));
310
+ }
277
311
  }
278
312
  catch {
279
313
  // unreadable/invalid skillDir — skip it
@@ -94,7 +94,14 @@ export function readDecisionLogDetailed(projectRoot) {
94
94
  if (trimmed.length === 0)
95
95
  continue;
96
96
  try {
97
- entries.push(JSON.parse(trimmed));
97
+ const parsed = JSON.parse(trimmed);
98
+ if (parsed === null || typeof parsed !== 'object' ||
99
+ typeof parsed.timestamp !== 'string' ||
100
+ typeof parsed.type !== 'string') {
101
+ invalidLines += 1;
102
+ continue;
103
+ }
104
+ entries.push(parsed);
98
105
  }
99
106
  catch {
100
107
  invalidLines += 1;
package/dist/tools/fix.js CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
20
20
  import { writeJsonAtomic, writeTextAtomic } from "../atomic-fs.js";
21
- import { join, sep } from 'node:path';
21
+ import { dirname, join, sep } from 'node:path';
22
22
  import { defineTool } from '@deepseek-ai/dsh-tools';
23
23
  import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
24
24
  import { runWithJob } from "../jobs.js";
@@ -198,15 +198,26 @@ export function resolveProjectFile(projectRoot, file) {
198
198
  return { ok: false, reason: 'file resolves outside the project root' };
199
199
  }
200
200
  // Symlink containment: the lexical prefix check above does not resolve
201
- // symlinks. If the target exists, verify its REAL path stays inside the REAL
202
- // project root so a symlinked directory/file inside the repo can never route
203
- // a fix (write/rollback/diff) outside the project.
204
- if (existsSync(resolved)) {
201
+ // symlinks. Verify the REAL path of the target (when it exists) AND of its
202
+ // nearest existing ancestor directory (when it does not — e.g. a fix that
203
+ // creates a new file) stays inside the REAL project root, so a symlinked
204
+ // directory inside the repo can never route a fix (write/rollback/diff)
205
+ // outside the project. The probe stops at the project root itself: the root
206
+ // is harness-provided (not model-controlled), and a path with no existing
207
+ // entry inside the repo cannot hide a symlink.
208
+ let probe = resolved;
209
+ while (!existsSync(probe) && probe !== projectRoot) {
210
+ const parent = dirname(probe);
211
+ if (parent === probe)
212
+ break;
213
+ probe = parent;
214
+ }
215
+ if (existsSync(probe)) {
205
216
  let rootReal;
206
217
  let real;
207
218
  try {
208
219
  rootReal = realpathSync(projectRoot);
209
- real = realpathSync(resolved);
220
+ real = realpathSync(probe);
210
221
  }
211
222
  catch {
212
223
  return { ok: false, reason: 'failed to resolve real path for containment check' };
@@ -485,9 +496,9 @@ export function registerFixTool(ctx) {
485
496
  // Registry write failed → the file was already modified but no record
486
497
  // exists, so a later rollback/diff could never see it and a retry would
487
498
  // back up the already-fixed content as "original". Restore the file
488
- // from the backup to leave the tree exactly as it was.
499
+ // from the backup atomically to leave the tree exactly as it was.
489
500
  try {
490
- copyFileSync(backupPath, target.resolved);
501
+ writeTextAtomic(target.resolved, readFileSync(backupPath, 'utf-8'));
491
502
  }
492
503
  catch (restoreErr) {
493
504
  return {
@@ -691,7 +702,9 @@ export function registerRollbackTool(ctx) {
691
702
  if (!target.ok)
692
703
  return { ok: false, error: target.reason };
693
704
  try {
694
- copyFileSync(record.backupPath, target.resolved);
705
+ // Atomic restore: never leave a truncated source file if we crash
706
+ // mid-restore (matches the writeTextAtomic guarantee used by apply).
707
+ writeTextAtomic(target.resolved, readFileSync(record.backupPath, 'utf-8'));
695
708
  }
696
709
  catch (err) {
697
710
  return { ok: false, error: `failed to restore backup: ${String(err)}` };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.5.1",
3
+ "version": "3.5.2",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,7 +36,8 @@
36
36
  "0.1.3-alpha.1": "compatible",
37
37
  "0.1.5-alpha.1": "compatible",
38
38
  "0.1.5-alpha.2": "compatible",
39
- "0.1.5-rc.1": "compatible"
39
+ "0.1.5-rc.1": "compatible",
40
+ "0.1.5-rc.2": "compatible"
40
41
  }
41
42
  },
42
43
  "client": {
@@ -74,13 +75,13 @@
74
75
  },
75
76
  "dependencies": {
76
77
  "@deepseek-ai/cordis": "4.0.2",
77
- "@deepseek-ai/dsh-tools": "0.1.2-rc.1",
78
- "@deepseek-ai/dsh-util-values": "0.1.2-rc.1",
78
+ "@deepseek-ai/dsh-tools": "0.1.5-rc.2",
79
+ "@deepseek-ai/dsh-util-values": "0.1.5-rc.2",
79
80
  "js-yaml": "4.3.1"
80
81
  },
81
82
  "devDependencies": {
82
- "@deepseek-ai/dsh-jobs": "^0.1.2-rc.1",
83
- "@deepseek-ai/dsh-session": "0.1.2-rc.1",
83
+ "@deepseek-ai/dsh-jobs": "^0.1.5-rc.2",
84
+ "@deepseek-ai/dsh-session": "0.1.5-rc.2",
84
85
  "@types/js-yaml": "4.0.9",
85
86
  "@types/node": "22.15.0",
86
87
  "@types/react": "19.2.2",
@@ -1,5 +1,5 @@
1
- import { readFileSync, existsSync, statSync } from 'node:fs'
2
- import { join, dirname, resolve } from 'node:path'
1
+ import { readFileSync, existsSync, realpathSync, statSync } from 'node:fs'
2
+ import { join, dirname, resolve, sep } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
5
5
  import { resolveProjectRootForExec } from '../config-loader.ts'
@@ -7,6 +7,33 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
7
7
  /** How many ancestor directories we walk up looking for a SKILL.md. */
8
8
  const MAX_SKILL_DIR_LOOKUP_DEPTH = 12
9
9
 
10
+ /**
11
+ * Whether a model-supplied `skillDir` may be used as a SKILL.md search root.
12
+ *
13
+ * `skillDir` is model-controlled, so it is only honored when it is an existing
14
+ * directory whose REAL path (symlinks resolved) sits inside one of the allowed
15
+ * roots (the project root or the plugin directory). Anything else — a path
16
+ * outside the allowed roots, a nonexistent dir, or a symlink pointing outside
17
+ * (e.g. at `/etc`) — is rejected so arbitrary file contents can never be
18
+ * injected wholesale into the review context.
19
+ */
20
+ export function isAllowedSkillDir(skillDir: string, allowedRoots: readonly string[]): boolean {
21
+ if (typeof skillDir !== 'string' || skillDir.trim().length === 0) return false
22
+ try {
23
+ const dir = resolve(skillDir)
24
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return false
25
+ return allowedRoots.some((root) => {
26
+ if (typeof root !== 'string' || root.length === 0) return false
27
+ const rootReal = realpathSync(root)
28
+ const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep
29
+ const real = realpathSync(dir)
30
+ return real === rootReal || real.startsWith(rootPrefix)
31
+ })
32
+ } catch {
33
+ return false
34
+ }
35
+ }
36
+
10
37
  /** Maximum number of image attachments relayed into the context in one call. */
11
38
  const MAX_ATTACHMENTS = 8
12
39
 
@@ -304,8 +331,13 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
304
331
  // the auto-detected root / project root.
305
332
  if (typeof args.skillDir === 'string' && args.skillDir.trim()) {
306
333
  try {
307
- const dir = resolve(args.skillDir)
308
- if (existsSync(dir) && statSync(dir).isDirectory()) candidates.push(dir)
334
+ // Model-controlled path: only honor it when its REAL path stays
335
+ // inside the project root or the plugin directory — never let a
336
+ // skillDir point at arbitrary filesystem locations (e.g. /etc)
337
+ // whose contents would be injected wholesale into the context.
338
+ if (isAllowedSkillDir(args.skillDir, [projectRoot, PLUGIN_SRC_DIR])) {
339
+ candidates.push(resolve(args.skillDir))
340
+ }
309
341
  } catch {
310
342
  // unreadable/invalid skillDir — skip it
311
343
  }
@@ -106,7 +106,16 @@ export function readDecisionLogDetailed(projectRoot: string): DecisionLogRead {
106
106
  const trimmed = line.trim()
107
107
  if (trimmed.length === 0) continue
108
108
  try {
109
- entries.push(JSON.parse(trimmed) as DecisionLogEntry)
109
+ const parsed: unknown = JSON.parse(trimmed)
110
+ if (
111
+ parsed === null || typeof parsed !== 'object' ||
112
+ typeof (parsed as { timestamp?: unknown }).timestamp !== 'string' ||
113
+ typeof (parsed as { type?: unknown }).type !== 'string'
114
+ ) {
115
+ invalidLines += 1
116
+ continue
117
+ }
118
+ entries.push(parsed as DecisionLogEntry)
110
119
  } catch {
111
120
  invalidLines += 1
112
121
  }
package/src/tools/fix.ts CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
21
21
  import { writeJsonAtomic, writeTextAtomic } from '../atomic-fs.ts'
22
- import { join, sep } from 'node:path'
22
+ import { dirname, join, sep } from 'node:path'
23
23
  import { defineTool } from '@deepseek-ai/dsh-tools'
24
24
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
25
25
  import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
@@ -207,15 +207,25 @@ export function resolveProjectFile(projectRoot: string, file: string): { ok: tru
207
207
  return { ok: false, reason: 'file resolves outside the project root' }
208
208
  }
209
209
  // Symlink containment: the lexical prefix check above does not resolve
210
- // symlinks. If the target exists, verify its REAL path stays inside the REAL
211
- // project root so a symlinked directory/file inside the repo can never route
212
- // a fix (write/rollback/diff) outside the project.
213
- if (existsSync(resolved)) {
210
+ // symlinks. Verify the REAL path of the target (when it exists) AND of its
211
+ // nearest existing ancestor directory (when it does not — e.g. a fix that
212
+ // creates a new file) stays inside the REAL project root, so a symlinked
213
+ // directory inside the repo can never route a fix (write/rollback/diff)
214
+ // outside the project. The probe stops at the project root itself: the root
215
+ // is harness-provided (not model-controlled), and a path with no existing
216
+ // entry inside the repo cannot hide a symlink.
217
+ let probe = resolved
218
+ while (!existsSync(probe) && probe !== projectRoot) {
219
+ const parent = dirname(probe)
220
+ if (parent === probe) break
221
+ probe = parent
222
+ }
223
+ if (existsSync(probe)) {
214
224
  let rootReal: string
215
225
  let real: string
216
226
  try {
217
227
  rootReal = realpathSync(projectRoot)
218
- real = realpathSync(resolved)
228
+ real = realpathSync(probe)
219
229
  } catch {
220
230
  return { ok: false, reason: 'failed to resolve real path for containment check' }
221
231
  }
@@ -492,9 +502,9 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
492
502
  // Registry write failed → the file was already modified but no record
493
503
  // exists, so a later rollback/diff could never see it and a retry would
494
504
  // back up the already-fixed content as "original". Restore the file
495
- // from the backup to leave the tree exactly as it was.
505
+ // from the backup atomically to leave the tree exactly as it was.
496
506
  try {
497
- copyFileSync(backupPath, target.resolved)
507
+ writeTextAtomic(target.resolved, readFileSync(backupPath, 'utf-8'))
498
508
  } catch (restoreErr) {
499
509
  return {
500
510
  ok: false,
@@ -705,7 +715,9 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
705
715
  const target = resolveProjectFile(projectRoot, record.finding.file)
706
716
  if (!target.ok) return { ok: false, error: target.reason }
707
717
  try {
708
- copyFileSync(record.backupPath, target.resolved)
718
+ // Atomic restore: never leave a truncated source file if we crash
719
+ // mid-restore (matches the writeTextAtomic guarantee used by apply).
720
+ writeTextAtomic(target.resolved, readFileSync(record.backupPath, 'utf-8'))
709
721
  } catch (err) {
710
722
  return { ok: false, error: `failed to restore backup: ${String(err)}` }
711
723
  }