iterate-plugin 3.5.6 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -20
- package/README.zh-CN.md +53 -17
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +509 -34
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +165 -0
- package/dist/index.js +54 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +52 -13
- 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 +55 -13
- package/dist/skill-prompt.js +417 -67
- 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 +122 -27
- 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 +156 -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/lib/client.js +945 -181
- package/lib/parse.js +760 -57
- package/package.json +15 -12
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/index.ts +600 -129
- package/src/config-loader.ts +542 -35
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +184 -0
- package/src/index.ts +53 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +48 -11
- 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 +54 -12
- package/src/skill-prompt.ts +417 -67
- 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 +127 -22
- 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 +160 -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/dist/review-scope.js
CHANGED
|
@@ -22,8 +22,12 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { readdirSync } from 'node:fs';
|
|
24
24
|
import { join, relative } from 'node:path';
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// Single source of truth for the whole-file line sentinel: evidence.ts's gate
|
|
26
|
+
// and this module's inventory filters must agree on what `0` means. Evidence
|
|
27
|
+
// imports neither this module nor review.ts, so the edge is acyclic (review.ts
|
|
28
|
+
// → review-scope.ts → evidence.ts → types.ts).
|
|
29
|
+
import { WHOLE_FILE_LINE } from "./evidence.js";
|
|
30
|
+
export { WHOLE_FILE_LINE };
|
|
27
31
|
/** Source extensions a full-scope walk includes. */
|
|
28
32
|
const SOURCE_EXTENSIONS = new Set([
|
|
29
33
|
'.py', '.pyi', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
@@ -82,13 +86,19 @@ function isIgnoredDir(name) {
|
|
|
82
86
|
}
|
|
83
87
|
/** Collect the sorted relative-path inventory for a review scope. */
|
|
84
88
|
export function collectScopeFiles(root, opts) {
|
|
89
|
+
// `opts` / `scope` come from tool args: a missing or malformed options
|
|
90
|
+
// object must degrade to a full walk, never throw inside the inventory step.
|
|
91
|
+
if (!opts || typeof opts !== 'object')
|
|
92
|
+
return collectFull(root);
|
|
85
93
|
if (opts.scope === 'changed-only')
|
|
86
94
|
return collectChanged(opts.changedFiles ?? []);
|
|
87
95
|
return collectFull(root);
|
|
88
96
|
}
|
|
89
97
|
function collectChanged(changedFiles) {
|
|
98
|
+
// A non-array `changedFiles` (JSON arg) must not be iterated char-by-char.
|
|
99
|
+
const list = Array.isArray(changedFiles) ? changedFiles : [];
|
|
90
100
|
const out = new Set();
|
|
91
|
-
for (const rel of
|
|
101
|
+
for (const rel of list) {
|
|
92
102
|
if (typeof rel !== 'string' || !rel.trim())
|
|
93
103
|
continue;
|
|
94
104
|
if (rel === String(WHOLE_FILE_LINE))
|
|
@@ -145,9 +155,17 @@ function collectFull(root) {
|
|
|
145
155
|
/** Split `files` into stable batches, keeping directory runs together. */
|
|
146
156
|
export function chunkFiles(files, perChunk) {
|
|
147
157
|
// Number.isFinite: NaN fails `perChunk < 1` and would yield one unbounded
|
|
148
|
-
// chunk (current.length >= NaN is never true).
|
|
149
|
-
|
|
150
|
-
|
|
158
|
+
// chunk (current.length >= NaN is never true). Math.floor: a fractional
|
|
159
|
+
// size (2.5) made chunks alternate between 2 and 3 members — the caller's
|
|
160
|
+
// "files per batch" is a count, so it must be an integer.
|
|
161
|
+
const size = Number.isFinite(perChunk) && perChunk >= 1
|
|
162
|
+
? Math.floor(perChunk)
|
|
163
|
+
: DEFAULT_SCOPE_CHUNK_SIZE;
|
|
164
|
+
// A non-array / sparse-free copy: callers pass tool-arg JSON, and sort()
|
|
165
|
+
// on a non-array (or `rel.includes` on a number) would throw.
|
|
166
|
+
const ordered = (Array.isArray(files) ? files : [])
|
|
167
|
+
.filter((rel) => typeof rel === 'string')
|
|
168
|
+
.sort();
|
|
151
169
|
const chunks = [];
|
|
152
170
|
let current = [];
|
|
153
171
|
let lastDir;
|
|
@@ -172,19 +190,24 @@ export function chunkFiles(files, perChunk) {
|
|
|
172
190
|
}
|
|
173
191
|
/** Score self-reported reads against the assigned inventory. */
|
|
174
192
|
export function computeCoverage(assigned, readFiles) {
|
|
193
|
+
// Both lists are JSON-arg-derived: `for...of` over a bare number throws and
|
|
194
|
+
// over a bare string iterates single characters into the read set. Anything
|
|
195
|
+
// non-array behaves like "nothing" (empty inventory / nothing read).
|
|
196
|
+
const assignedList = Array.isArray(assigned) ? assigned : [];
|
|
197
|
+
const readList = Array.isArray(readFiles) ? readFiles : [];
|
|
175
198
|
const readNorm = new Set();
|
|
176
|
-
for (const p of
|
|
199
|
+
for (const p of readList) {
|
|
177
200
|
if (typeof p === 'string' && p)
|
|
178
201
|
readNorm.add(normalizePath(p));
|
|
179
202
|
}
|
|
180
|
-
const assignedSorted = [...
|
|
203
|
+
const assignedSorted = [...assignedList].filter((rel) => typeof rel === 'string').sort();
|
|
181
204
|
const covered = assignedSorted.filter((rel) => readNorm.has(normalizePath(rel)));
|
|
182
205
|
const uncovered = assignedSorted.filter((rel) => !readNorm.has(normalizePath(rel)));
|
|
183
206
|
const rawRatio = assignedSorted.length === 0 ? 1 : covered.length / assignedSorted.length;
|
|
184
207
|
const ratio = Math.round(rawRatio * 1000) / 1000;
|
|
185
208
|
return {
|
|
186
209
|
assigned: assignedSorted,
|
|
187
|
-
read: [...new Set(
|
|
210
|
+
read: [...new Set(readList.filter((p) => typeof p === 'string'))].sort(),
|
|
188
211
|
covered,
|
|
189
212
|
uncovered,
|
|
190
213
|
ratio,
|
package/dist/review.js
CHANGED
|
@@ -44,9 +44,44 @@ export function sortFindings(findings) {
|
|
|
44
44
|
return (Number(a?.line) || 0) - (Number(b?.line) || 0);
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Hard ceiling on the round cap the aggregation math will honor.
|
|
49
|
+
*
|
|
50
|
+
* Mirrors `MAX_MAX_ROUNDS` in `config-loader.ts` (the config bomb guard) —
|
|
51
|
+
* `test/review.test.ts` asserts the two constants stay in sync. The tool
|
|
52
|
+
* boundary only clamps the LOWER bound of `maxReviewRounds`, so an absurd
|
|
53
|
+
* caller value (1e15) would otherwise drive the `findingsByRound` allocation
|
|
54
|
+
* bound (`roundCap * 2`) straight into OOM territory.
|
|
55
|
+
*/
|
|
56
|
+
export const MAX_REVIEW_ROUNDS_CAP = 100;
|
|
57
|
+
/**
|
|
58
|
+
* Clamp an untrusted round cap to `[1, MAX_REVIEW_ROUNDS_CAP]`.
|
|
59
|
+
*
|
|
60
|
+
* Non-finite / non-numeric input (`NaN`, `Infinity`, a string) collapses to
|
|
61
|
+
* 1 — the smallest sane cap. Without this, `Math.max(1, NaN)` stayed `NaN`
|
|
62
|
+
* and `new Array(NaN)` threw `RangeError` out of `aggregateRounds`.
|
|
63
|
+
*/
|
|
64
|
+
export function clampMaxReviewRounds(value) {
|
|
65
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
66
|
+
return 1;
|
|
67
|
+
return Math.min(MAX_REVIEW_ROUNDS_CAP, Math.max(1, Math.floor(value)));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* True when `n` is a round number the deterministic core accepts: a positive
|
|
71
|
+
* integer. Shared by `aggregateRounds` / `computeConvergence` /
|
|
72
|
+
* `buildReviewReport` (and re-read by the meta-review audit) so every reader
|
|
73
|
+
* of "the last round" agrees on which round numbers count — a fractional
|
|
74
|
+
* (`1.5`) or non-finite round is simply "not a round", never a convergence
|
|
75
|
+
* input that could be mis-indexed into `findingsByRound`.
|
|
76
|
+
*/
|
|
77
|
+
export function isValidRoundNumber(n) {
|
|
78
|
+
return typeof n === 'number' && Number.isInteger(n) && n >= 1;
|
|
79
|
+
}
|
|
47
80
|
/** Normalize a summary so near-identical duplicates collapse to one key. */
|
|
48
81
|
export function normalizeSummary(summary) {
|
|
49
|
-
|
|
82
|
+
// Coerce: `summary` is model-authored JSON when schema validation is off,
|
|
83
|
+
// so a numeric summary must not crash `.trim()` inside the dedupe key.
|
|
84
|
+
return String(summary ?? '')
|
|
50
85
|
.trim()
|
|
51
86
|
.toLowerCase()
|
|
52
87
|
.replace(/[\s\n\t]+/g, ' ');
|
|
@@ -58,8 +93,12 @@ export function normalizeSummary(summary) {
|
|
|
58
93
|
* neither side anchors one, i.e. whole-file findings).
|
|
59
94
|
*/
|
|
60
95
|
export function findingKey(f) {
|
|
61
|
-
|
|
62
|
-
|
|
96
|
+
// Defensive reads: with `output_schema_validation: false` a finding may
|
|
97
|
+
// carry wrong-typed fields (and `f` itself may be a null list element), and
|
|
98
|
+
// this key feeds the dedupe Set on EVERY round — it must never throw.
|
|
99
|
+
const src = (f ?? {});
|
|
100
|
+
const line = typeof src.line === 'number' && src.line > 0 ? src.line : 0;
|
|
101
|
+
return `${String(src.file ?? '')}|${String(src.dimension ?? '')}|${line}|${normalizeSummary(src.summary)}`;
|
|
63
102
|
}
|
|
64
103
|
/**
|
|
65
104
|
* Remove duplicate findings within a list.
|
|
@@ -69,6 +108,10 @@ export function dedupeFindings(findings) {
|
|
|
69
108
|
const seen = new Set();
|
|
70
109
|
const out = [];
|
|
71
110
|
for (const f of findings) {
|
|
111
|
+
// A null / array entry (hostile or schema-validation-off JSON) must never
|
|
112
|
+
// reach `summarize`, which reads `f.severity` unguarded.
|
|
113
|
+
if (!f || typeof f !== 'object' || Array.isArray(f))
|
|
114
|
+
continue;
|
|
72
115
|
const key = findingKey(f);
|
|
73
116
|
if (seen.has(key))
|
|
74
117
|
continue;
|
|
@@ -84,10 +127,19 @@ export function dedupeFindings(findings) {
|
|
|
84
127
|
* - entry `line` is 0/undefined (whole file) OR equals the finding's line.
|
|
85
128
|
*/
|
|
86
129
|
export function filterKnownIntentional(findings, known) {
|
|
87
|
-
|
|
130
|
+
// `known` is config/arg JSON: a non-array truthy value must behave like
|
|
131
|
+
// "no entries", never reach `known.some` and throw.
|
|
132
|
+
if (!Array.isArray(known) || known.length === 0)
|
|
88
133
|
return findings;
|
|
89
134
|
return findings.filter((f) => {
|
|
135
|
+
// A null / non-object finding has no file/dimension to match on; drop it
|
|
136
|
+
// here so it can never crash the comparator (aggregateRounds drops it too,
|
|
137
|
+
// so the report's counts stay consistent either way).
|
|
138
|
+
if (!f || typeof f !== 'object' || Array.isArray(f))
|
|
139
|
+
return false;
|
|
90
140
|
const matched = known.some((k) => {
|
|
141
|
+
if (!k || typeof k !== 'object')
|
|
142
|
+
return false;
|
|
91
143
|
const sameFile = k.file === f.file;
|
|
92
144
|
const sameDim = k.dimension === f.dimension;
|
|
93
145
|
if (!sameFile || !sameDim)
|
|
@@ -124,16 +176,25 @@ export function aggregateRounds(rounds, maxReviewRounds) {
|
|
|
124
176
|
// 1e9) would otherwise allocate an array of that size below (OOM). Round
|
|
125
177
|
// numbers above the configured cap are clamped to the cap.
|
|
126
178
|
let maxRound = 0;
|
|
127
|
-
|
|
128
|
-
|
|
179
|
+
// Upper-bounded too: `Math.max(1, NaN)` used to stay NaN and blow up in
|
|
180
|
+
// `new Array(effectiveMax)`, and an unbounded cap scaled the allocation
|
|
181
|
+
// bound to whatever the caller asked for.
|
|
182
|
+
const roundCap = clampMaxReviewRounds(maxReviewRounds);
|
|
183
|
+
for (const round of Array.isArray(rounds) ? rounds : []) {
|
|
129
184
|
if (!round || typeof round !== 'object')
|
|
130
185
|
continue;
|
|
131
|
-
if (
|
|
186
|
+
if (!isValidRoundNumber(round.round))
|
|
132
187
|
continue;
|
|
133
188
|
const findings = Array.isArray(round.findings) ? round.findings : [];
|
|
134
189
|
if (round.round > maxRound)
|
|
135
190
|
maxRound = round.round;
|
|
136
191
|
for (const f of findings) {
|
|
192
|
+
// Null / non-object elements (schema validation off) would crash
|
|
193
|
+
// `findingKey`; dropping them here keeps `findingsByRound` summing to
|
|
194
|
+
// exactly the returned findings list (the meta-review's CONVERGENCE_SUM
|
|
195
|
+
// check reads that equality).
|
|
196
|
+
if (!f || typeof f !== 'object' || Array.isArray(f))
|
|
197
|
+
continue;
|
|
137
198
|
const key = findingKey(f);
|
|
138
199
|
if (seen.has(key))
|
|
139
200
|
continue;
|
|
@@ -162,8 +223,9 @@ export function aggregateRounds(rounds, maxReviewRounds) {
|
|
|
162
223
|
* Compute convergence statistics for a dry-run review.
|
|
163
224
|
*/
|
|
164
225
|
export function computeConvergence(rounds, maxReviewRounds) {
|
|
165
|
-
const
|
|
166
|
-
const
|
|
226
|
+
const list = Array.isArray(rounds) ? rounds : [];
|
|
227
|
+
const { findingsByRound } = aggregateRounds(list, maxReviewRounds);
|
|
228
|
+
const totalRounds = list.length;
|
|
167
229
|
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
168
230
|
// r-1), sized to the highest present round (clamped). Convergence must read
|
|
169
231
|
// the HIGHEST PRESENT round's count — not the last array element (rounds
|
|
@@ -171,15 +233,17 @@ export function computeConvergence(rounds, maxReviewRounds) {
|
|
|
171
233
|
// 1..N). The count index is bounded by the array length aggregateRounds
|
|
172
234
|
// actually allocated.
|
|
173
235
|
let lastRound = 0;
|
|
174
|
-
for (const round of
|
|
175
|
-
if (!
|
|
236
|
+
for (const round of list) {
|
|
237
|
+
if (!isValidRoundNumber(round?.round))
|
|
176
238
|
continue;
|
|
177
239
|
if (round.round > lastRound)
|
|
178
240
|
lastRound = round.round;
|
|
179
241
|
}
|
|
180
242
|
const idx = Math.min(lastRound, findingsByRound.length) - 1;
|
|
181
243
|
const lastRoundCount = idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
|
|
182
|
-
|
|
244
|
+
// `lastRound === 0` means NO round carried an interpretable number: claiming
|
|
245
|
+
// `converged` there would bless a report the core could not actually read.
|
|
246
|
+
const converged = totalRounds > 0 && lastRound > 0 && lastRoundCount === 0;
|
|
183
247
|
return {
|
|
184
248
|
totalRounds,
|
|
185
249
|
findingsByRound,
|
|
@@ -199,7 +263,15 @@ function summarize(findings) {
|
|
|
199
263
|
high: 0,
|
|
200
264
|
medium: 0,
|
|
201
265
|
low: 0,
|
|
202
|
-
|
|
266
|
+
// NULL-PROTOTYPE map: a model-authored `dimension` of `__proto__` or
|
|
267
|
+
// `constructor` would otherwise resolve against Object.prototype — the
|
|
268
|
+
// `__proto__` setter silently discards a primitive value and `constructor`
|
|
269
|
+
// string-concatenates the Object function — so the counter never
|
|
270
|
+
// accumulated and the meta-review's DIMENSION_SUM check false-positived.
|
|
271
|
+
// A null-prototype object has no inherited accessors, so every dimension
|
|
272
|
+
// (including those three names) is a plain own data property and
|
|
273
|
+
// JSON-serializes normally.
|
|
274
|
+
byDimension: Object.create(null),
|
|
203
275
|
};
|
|
204
276
|
for (const f of findings) {
|
|
205
277
|
if (f.severity === 'critical')
|
|
@@ -223,7 +295,8 @@ function summarize(findings) {
|
|
|
223
295
|
*/
|
|
224
296
|
export function buildReviewReport(input) {
|
|
225
297
|
// 1. Filter known-intentional per round (before cross-round dedupe).
|
|
226
|
-
const
|
|
298
|
+
const inputRounds = Array.isArray(input.rounds) ? input.rounds : [];
|
|
299
|
+
const filteredRounds = inputRounds
|
|
227
300
|
.map((r) => ({
|
|
228
301
|
round: typeof r?.round === 'number' ? r.round : 0,
|
|
229
302
|
findings: filterKnownIntentional(Array.isArray(r?.findings) ? r.findings : [], input.knownIntentional),
|
|
@@ -246,7 +319,11 @@ export function buildReviewReport(input) {
|
|
|
246
319
|
// non-contiguous round sets would otherwise read the wrong count).
|
|
247
320
|
let lastRound = 0;
|
|
248
321
|
for (const r of filteredRounds) {
|
|
249
|
-
|
|
322
|
+
// Same validity rule as aggregateRounds: a fractional / NaN round is not
|
|
323
|
+
// a round, so it must never be used as an index into `findingsByRound`
|
|
324
|
+
// (Math.min(1.5, len) - 1 = 0.5 → reads `undefined` → counts as 0 new →
|
|
325
|
+
// a round that really did report findings would be read as "converged").
|
|
326
|
+
if (isValidRoundNumber(r.round) && r.round > lastRound)
|
|
250
327
|
lastRound = r.round;
|
|
251
328
|
}
|
|
252
329
|
// `lastRound` can exceed the array length when over-cap rounds were FOLDED
|
|
@@ -255,7 +332,7 @@ export function buildReviewReport(input) {
|
|
|
255
332
|
// new findings (which would falsely report "converged").
|
|
256
333
|
const idx = Math.min(lastRound, findingsByRound.length) - 1;
|
|
257
334
|
const lastRoundCount = lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
|
|
258
|
-
const converged = filteredRounds.length > 0 && lastRoundCount === 0;
|
|
335
|
+
const converged = filteredRounds.length > 0 && lastRound > 0 && lastRoundCount === 0;
|
|
259
336
|
// Attach the normal-mode fix count to the summary (dry-run leaves it absent).
|
|
260
337
|
const computed = summarize(sorted);
|
|
261
338
|
if (input.mode === 'normal' && typeof input.fixedCount === 'number' && Number.isInteger(input.fixedCount)) {
|
|
@@ -659,7 +736,9 @@ export function buildReviewPlan(input) {
|
|
|
659
736
|
goal,
|
|
660
737
|
scope,
|
|
661
738
|
dimensions: dimensionTasks,
|
|
662
|
-
|
|
739
|
+
// Clamped with the SAME rule aggregateRounds honors, so the cap this plan
|
|
740
|
+
// advertises is never a number the aggregation math would silently clamp.
|
|
741
|
+
maxReviewRounds: clampMaxReviewRounds(input.maxReviewRounds),
|
|
663
742
|
knownIntentional: input.knownIntentional ?? [],
|
|
664
743
|
changedFiles: effectiveChangedOnly ? changedFiles : [],
|
|
665
744
|
fallbackToFull,
|
package/dist/session-hooks.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Wires the {@link decideApproval} policy gate to dsh's `tools/pre-execute`
|
|
5
5
|
* waterfall. This is the AUTHORITATIVE approval seam for destructive iterate
|
|
6
|
-
* tools (`iterate_fix` / `iterate_rollback` / `iterate_prune` with dryRun:false
|
|
6
|
+
* tools (`iterate_fix` / `iterate_rollback` / `iterate_prune` with dryRun:false
|
|
7
|
+
* / `iterate_config` with operation:"write"):
|
|
7
8
|
*
|
|
8
9
|
* - `allow` policy → the call runs.
|
|
9
10
|
* - `deny` policy → the call is refused (fail-closed), surfaced as an
|
|
@@ -13,11 +14,19 @@
|
|
|
13
14
|
* plus the human-readable reason) so durable
|
|
14
15
|
* projections can route it distinctly from a normal
|
|
15
16
|
* tool failure.
|
|
16
|
-
* - `ask` policy → return `{ kind: 'ask', reason }`;
|
|
17
|
-
* scheduler routes it through the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* on the
|
|
17
|
+
* - `ask` policy → return `{ kind: 'ask', reason, displayReason }`;
|
|
18
|
+
* dsh's own scheduler routes it through the
|
|
19
|
+
* `approval` service (see
|
|
20
|
+
* `@deepseek-ai/dsh-user-approval`), which prompts
|
|
21
|
+
* the human and audits an approve/deny pair on the
|
|
22
|
+
* session. `reason` is the audited English summary;
|
|
23
|
+
* `displayReason` (dsh 0.2.x) is the localized
|
|
24
|
+
* prompt text. LANGUAGE RULE: human-facing text
|
|
25
|
+
* follows the project `language` (zh|en); every
|
|
26
|
+
* structured/durable field (deny reason,
|
|
27
|
+
* `info.reason`, decision-log entries, console
|
|
28
|
+
* logs) stays English so machine matchers never
|
|
29
|
+
* depend on a locale.
|
|
21
30
|
* - canceled → if the caller aborted the invocation before
|
|
22
31
|
* dispatch, return `{ kind: 'cancel' }` (the
|
|
23
32
|
* canonical 0.1.6-alpha.1 cancellation result) so a
|
|
@@ -31,7 +40,15 @@
|
|
|
31
40
|
*
|
|
32
41
|
* Safety properties:
|
|
33
42
|
* - Read-only tools and non-iterate tools are always allowed (the gate only
|
|
34
|
-
* inspects the
|
|
43
|
+
* inspects the four gated iterate toolnames, and of those only prune
|
|
44
|
+
* `dryRun:false` and `iterate_config` `operation:"write"` — a config read
|
|
45
|
+
* or a dry-run prune short-circuits to allow inside the policy gate).
|
|
46
|
+
* - The approval policy is read from the SESSION workspace whenever a
|
|
47
|
+
* session cwd is known — the model-controlled `path` argument can never
|
|
48
|
+
* choose where the human's policy comes from (it is only consulted when
|
|
49
|
+
* there is no session cwd at all). Otherwise `path:` could point at an
|
|
50
|
+
* `approval: allow` directory to self-grant, or dodge a `deny` in the
|
|
51
|
+
* workspace.
|
|
35
52
|
* - If the project root / observatory config cannot be resolved, the policy
|
|
36
53
|
* degrades to `ask` (fail-safe: destructive writes always require consent).
|
|
37
54
|
*/
|
|
@@ -77,8 +94,14 @@ export function gateDecision(exec) {
|
|
|
77
94
|
if (signal && typeof signal.aborted === 'boolean' && signal.aborted) {
|
|
78
95
|
return { kind: 'cancel' };
|
|
79
96
|
}
|
|
80
|
-
// Resolve the project root
|
|
81
|
-
//
|
|
97
|
+
// Resolve the project root whose observatory policy governs this call.
|
|
98
|
+
// SECURITY: the policy source must NOT be model-controlled. `path` is a
|
|
99
|
+
// model argument, so reading the policy from the path-resolved root let a
|
|
100
|
+
// call point `path:` at a directory with `approval: allow` (self-grant) or
|
|
101
|
+
// dodge a human's `deny` in the session workspace. When the session cwd is
|
|
102
|
+
// known, the SESSION workspace's config is authoritative and `path` is
|
|
103
|
+
// ignored; the path-resolved root is only the fallback when there is no
|
|
104
|
+
// session cwd at all (headless/tests). The `ask` default stays fail-closed.
|
|
82
105
|
let argPath;
|
|
83
106
|
let sessionCwd;
|
|
84
107
|
try {
|
|
@@ -93,8 +116,15 @@ export function gateDecision(exec) {
|
|
|
93
116
|
catch {
|
|
94
117
|
// hostile/proxied exec — fall through with both undefined (defaults to ask)
|
|
95
118
|
}
|
|
96
|
-
|
|
119
|
+
// Passing sessionCwd as BOTH the input and the anchor makes the root the
|
|
120
|
+
// session cwd itself (no server-cwd fallback can sneak in), anchored there.
|
|
121
|
+
const resolved = sessionCwd
|
|
122
|
+
? resolveProjectRoot(sessionCwd, sessionCwd)
|
|
123
|
+
: resolveProjectRoot(argPath, sessionCwd);
|
|
97
124
|
let policy = 'ask';
|
|
125
|
+
// Project language for the HUMAN-FACING ask prompt only (see the header's
|
|
126
|
+
// language rule: structured deny/audit text stays English).
|
|
127
|
+
let language = 'en';
|
|
98
128
|
if (resolved.ok) {
|
|
99
129
|
const { config } = loadEffectiveConfig(resolved.root);
|
|
100
130
|
const p = config.observatory?.approval;
|
|
@@ -103,8 +133,10 @@ export function gateDecision(exec) {
|
|
|
103
133
|
else if (p === 'allow')
|
|
104
134
|
policy = 'allow';
|
|
105
135
|
// anything else (including a corrupt/missing `ask`) → 'ask'
|
|
136
|
+
if (config.language === 'zh')
|
|
137
|
+
language = 'zh';
|
|
106
138
|
}
|
|
107
|
-
const decision = decideApproval(exec, policy);
|
|
139
|
+
const decision = decideApproval(exec, policy, language);
|
|
108
140
|
if (decision.kind === 'deny') {
|
|
109
141
|
return {
|
|
110
142
|
kind: 'deny',
|
|
@@ -113,12 +145,19 @@ export function gateDecision(exec) {
|
|
|
113
145
|
...DENY_INFO,
|
|
114
146
|
// ToolErrorInfo.reason (0.1.6-alpha.1): raw user-facing detail kept in
|
|
115
147
|
// durable state; the model-facing text still only sees `reason` above.
|
|
148
|
+
// Deliberately the ENGLISH structured reason, never the localized
|
|
149
|
+
// prompt — durable projections must match on a stable string.
|
|
116
150
|
reason: decision.reason,
|
|
117
151
|
},
|
|
118
152
|
};
|
|
119
153
|
}
|
|
120
|
-
|
|
121
|
-
|
|
154
|
+
// dsh 0.2.x: `displayReason` is the localized prompt text (en + the
|
|
155
|
+
// configured locale); `reason` stays the audited English summary. Both are
|
|
156
|
+
// forwarded so the approval UI can show the full localized explanation
|
|
157
|
+
// (including the WARNING line for gate-disabling config writes).
|
|
158
|
+
if (decision.kind === 'ask') {
|
|
159
|
+
return { kind: 'ask', reason: decision.reason, displayReason: decision.displayReason };
|
|
160
|
+
}
|
|
122
161
|
return { kind: 'allow' };
|
|
123
162
|
}
|
|
124
163
|
/**
|
|
@@ -139,6 +178,9 @@ export function registerSessionHooks(ctx) {
|
|
|
139
178
|
return Promise.resolve({
|
|
140
179
|
kind: 'ask',
|
|
141
180
|
reason: 'iterate approval gate unavailable — require consent',
|
|
181
|
+
// Fail-safe path: no config was read, so the localized prompt falls
|
|
182
|
+
// back to English (displayReason requires at least an `en` entry).
|
|
183
|
+
displayReason: { en: 'iterate approval gate unavailable — require consent' },
|
|
142
184
|
});
|
|
143
185
|
}
|
|
144
186
|
if (decision.kind === 'ask') {
|