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.
- package/dist/tools/context.js +39 -5
- package/dist/tools/decision-log.js +8 -1
- package/dist/tools/fix.js +22 -9
- package/package.json +7 -6
- package/src/tools/context.ts +36 -4
- package/src/tools/decision-log.ts +10 -1
- package/src/tools/fix.ts +21 -9
package/dist/tools/context.js
CHANGED
|
@@ -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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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.
|
|
202
|
-
//
|
|
203
|
-
// a
|
|
204
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
78
|
-
"@deepseek-ai/dsh-util-values": "0.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.
|
|
83
|
-
"@deepseek-ai/dsh-session": "0.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",
|
package/src/tools/context.ts
CHANGED
|
@@ -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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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.
|
|
211
|
-
//
|
|
212
|
-
// a
|
|
213
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|