sprag-cli 3.52.0 → 3.54.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.ko.md +46 -3
- package/README.md +50 -4
- package/bin/cli.js +35 -4
- package/docs/CODEX.ko.md +172 -0
- package/docs/CODEX.md +485 -0
- package/package.json +11 -3
- package/presets/delegation/bounds.md +29 -0
- package/presets/ratchet-rules.json +4 -1
- package/src/agent.js +33 -0
- package/src/codex-brief.js +89 -0
- package/src/codex-budget.js +73 -0
- package/src/codex-cache-policy.js +203 -0
- package/src/codex-cache.js +46 -0
- package/src/codex-delegation.js +154 -0
- package/src/codex-doc2md-ledger.js +45 -0
- package/src/codex-doc2md.js +99 -0
- package/src/codex-doctor.js +53 -0
- package/src/codex-harness.js +200 -0
- package/src/codex-hooks.js +131 -0
- package/src/codex-installer.js +79 -0
- package/src/codex-ledger.js +143 -0
- package/src/codex-panel-launcher.js +48 -0
- package/src/codex-panel-runner.js +74 -0
- package/src/codex-panel-session.js +35 -0
- package/src/codex-panel-shell.js +51 -0
- package/src/codex-panel-state.js +40 -0
- package/src/codex-panel.js +448 -0
- package/src/codex-parser.js +397 -0
- package/src/codex-route-scan.js +171 -0
- package/src/commands/brief.js +8 -1
- package/src/commands/codex-delegate.js +59 -0
- package/src/commands/codex.js +557 -0
- package/src/commands/delegate.js +133 -0
- package/src/commands/seed.js +11 -10
- package/src/delegation-guard.js +278 -0
- package/src/doc2md.cjs +72 -28
- package/src/handoff.js +27 -7
- package/src/harness.js +26 -17
- package/src/installer.js +168 -0
- package/src/seed-rules.js +58 -34
- package/src/session-paths.js +242 -0
- package/src/update-check.js +37 -2
package/src/seed-rules.js
CHANGED
|
@@ -46,23 +46,25 @@ import {
|
|
|
46
46
|
|
|
47
47
|
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// Codex answers are kept apart: saying no in one agent must not consume the
|
|
50
|
+
// other agent's offer, and the two agents write to different ratchet files.
|
|
51
|
+
export function seedStatePath({ agent = 'claude' } = {}) {
|
|
52
|
+
return join(userDataDir(), agent === 'codex' ? 'codex-seed-state.json' : 'seed-state.json');
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
export function loadSeedState() {
|
|
55
|
+
export function loadSeedState(opts = {}) {
|
|
54
56
|
try {
|
|
55
|
-
const data = JSON.parse(readFileSync(seedStatePath(), 'utf8'));
|
|
57
|
+
const data = JSON.parse(readFileSync(seedStatePath(opts), 'utf8'));
|
|
56
58
|
return data && typeof data.decided === 'object' && data.decided ? data : { decided: {} };
|
|
57
59
|
} catch {
|
|
58
60
|
return { decided: {} };
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
export function saveSeedState(state) {
|
|
64
|
+
export function saveSeedState(state, opts = {}) {
|
|
63
65
|
const dir = userDataDir();
|
|
64
66
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
65
|
-
writeFileSync(seedStatePath(), JSON.stringify(state, null, 2) + '\n');
|
|
67
|
+
writeFileSync(seedStatePath(opts), JSON.stringify(state, null, 2) + '\n');
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
/** Bundled tier-delegation presets (empty array when the file is unreadable). */
|
|
@@ -80,15 +82,19 @@ export function modelPresets() {
|
|
|
80
82
|
* ids would renumber the moment a rule is inserted above them, silently moving
|
|
81
83
|
* a user's "no" onto a different rule.
|
|
82
84
|
*/
|
|
83
|
-
export function ratchetPresets(lang = userLanguage()) {
|
|
84
|
-
return presetRuleEntries()
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
85
|
+
export function ratchetPresets(lang = userLanguage(), { agent = 'claude' } = {}) {
|
|
86
|
+
return presetRuleEntries()
|
|
87
|
+
// A preset without `agents` applies everywhere; one naming Claude model
|
|
88
|
+
// tiers would only mislead a Codex session.
|
|
89
|
+
.filter((r) => !r.agents || r.agents.includes(agent))
|
|
90
|
+
.map((r) => ({
|
|
91
|
+
// Hashed from the Korean text in both languages: the id has to survive a
|
|
92
|
+
// reworded translation, or a user who declined a rule would be asked again
|
|
93
|
+
// the next time its English copy changes.
|
|
94
|
+
id: `fix-${createHash('sha1').update(r.ko).digest('hex').slice(0, 6)}`,
|
|
95
|
+
text: lang === 'ko' ? r.ko : r.en,
|
|
96
|
+
ko: r.ko,
|
|
97
|
+
}));
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}(\s*\([^)]*\))?:\s*/, '').trim();
|
|
@@ -102,9 +108,11 @@ const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}(\s*\([^)]*\))?:\s*/, '').
|
|
|
102
108
|
* whose own route-scan already promoted that shape does not need ours, and
|
|
103
109
|
* offering it anyway would read as the tool failing to notice its own state.
|
|
104
110
|
*/
|
|
105
|
-
export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot() } = {}) {
|
|
106
|
-
const { decided } = loadSeedState();
|
|
107
|
-
|
|
111
|
+
export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(), agent = 'claude' } = {}) {
|
|
112
|
+
const { decided } = loadSeedState({ agent });
|
|
113
|
+
// Codex model routing comes from its own measured route-scan candidates;
|
|
114
|
+
// Claude tier presets name models Codex does not run.
|
|
115
|
+
const registered = agent === 'codex' ? [] : (() => {
|
|
108
116
|
try {
|
|
109
117
|
return loadModelRules().rules;
|
|
110
118
|
} catch {
|
|
@@ -116,12 +124,12 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot() }
|
|
|
116
124
|
const haveText = new Set();
|
|
117
125
|
for (const scope of ['global', 'project']) {
|
|
118
126
|
try {
|
|
119
|
-
for (const r of harnessListRules({ root, scope }).rules) haveText.add(stripDate(r.text));
|
|
127
|
+
for (const r of harnessListRules({ root, scope, agent }).rules) haveText.add(stripDate(r.text));
|
|
120
128
|
} catch { /* a missing ratchet file just means nothing is registered yet */ }
|
|
121
129
|
}
|
|
122
130
|
|
|
123
131
|
const out = [];
|
|
124
|
-
for (const p of modelPresets()) {
|
|
132
|
+
for (const p of agent === 'codex' ? [] : modelPresets()) {
|
|
125
133
|
if (decided[p.id]) continue;
|
|
126
134
|
if (haveModel.has(`${p.tier}|${p.category}`)) continue;
|
|
127
135
|
out.push({
|
|
@@ -134,7 +142,7 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot() }
|
|
|
134
142
|
preset: p,
|
|
135
143
|
});
|
|
136
144
|
}
|
|
137
|
-
for (const p of ratchetPresets(lang)) {
|
|
145
|
+
for (const p of ratchetPresets(lang, { agent })) {
|
|
138
146
|
if (decided[p.id]) continue;
|
|
139
147
|
// Either language counts as already registered: `harness pull` may have
|
|
140
148
|
// written the other one.
|
|
@@ -149,10 +157,10 @@ export function findSeed(id, opts = {}) {
|
|
|
149
157
|
return pendingSeeds(opts).find((s) => s.id === id) || null;
|
|
150
158
|
}
|
|
151
159
|
|
|
152
|
-
function recordDecision(id, action, extra = {}) {
|
|
153
|
-
const state = loadSeedState();
|
|
160
|
+
function recordDecision(id, action, extra = {}, { agent = 'claude' } = {}) {
|
|
161
|
+
const state = loadSeedState({ agent });
|
|
154
162
|
state.decided[id] = { action, at: new Date().toISOString().slice(0, 10), ...extra };
|
|
155
|
-
saveSeedState(state);
|
|
163
|
+
saveSeedState(state, { agent });
|
|
156
164
|
}
|
|
157
165
|
|
|
158
166
|
/**
|
|
@@ -162,13 +170,15 @@ function recordDecision(id, action, extra = {}) {
|
|
|
162
170
|
*
|
|
163
171
|
* Returns { id, kind, scope, path?, rule } or null when the id is not pending.
|
|
164
172
|
*/
|
|
165
|
-
export async function acceptSeed(id, { scope
|
|
166
|
-
|
|
173
|
+
export async function acceptSeed(id, { scope, root = findProjectRoot(), lang = userLanguage(), agent = 'claude' } = {}) {
|
|
174
|
+
// No default: a rule in the wrong scope is noise everywhere or missing where it mattered.
|
|
175
|
+
if (scope !== 'global' && scope !== 'project') throw new Error('acceptSeed needs an explicit scope: global or project');
|
|
176
|
+
const seed = findSeed(id, { lang, root, agent });
|
|
167
177
|
if (!seed) return null;
|
|
168
178
|
|
|
169
179
|
if (seed.kind === 'ratchet') {
|
|
170
|
-
const res = harnessPromote(seed.ruleText, { root, scope });
|
|
171
|
-
recordDecision(id, 'accepted', { scope });
|
|
180
|
+
const res = harnessPromote(seed.ruleText, { root, scope, agent });
|
|
181
|
+
recordDecision(id, 'accepted', { scope }, { agent });
|
|
172
182
|
return { id, kind: 'ratchet', scope, path: res.path, rule: seed.ruleText };
|
|
173
183
|
}
|
|
174
184
|
|
|
@@ -216,15 +226,15 @@ export async function acceptSeed(id, { scope = 'global', root = findProjectRoot(
|
|
|
216
226
|
export function skipSeed(id, opts = {}) {
|
|
217
227
|
const seed = findSeed(id, opts);
|
|
218
228
|
if (!seed) return null;
|
|
219
|
-
recordDecision(id, 'skipped');
|
|
229
|
+
recordDecision(id, 'skipped', {}, { agent: opts.agent });
|
|
220
230
|
return { id, kind: seed.kind, rule: seed.ruleText };
|
|
221
231
|
}
|
|
222
232
|
|
|
223
233
|
/** Forget every recorded answer — the full set becomes pending again. */
|
|
224
|
-
export function resetSeeds() {
|
|
225
|
-
const state = loadSeedState();
|
|
234
|
+
export function resetSeeds({ agent = 'claude' } = {}) {
|
|
235
|
+
const state = loadSeedState({ agent });
|
|
226
236
|
const count = Object.keys(state.decided).length;
|
|
227
|
-
saveSeedState({ decided: {} });
|
|
237
|
+
saveSeedState({ decided: {} }, { agent });
|
|
228
238
|
return count;
|
|
229
239
|
}
|
|
230
240
|
|
|
@@ -236,9 +246,23 @@ export function resetSeeds() {
|
|
|
236
246
|
* asked for, and because a single "register all of these?" question invites a
|
|
237
247
|
* yes to rules the user never read.
|
|
238
248
|
*/
|
|
239
|
-
export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot() } = {}) {
|
|
240
|
-
const pending = pendingSeeds({ lang, root });
|
|
249
|
+
export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot(), agent = 'claude' } = {}) {
|
|
250
|
+
const pending = pendingSeeds({ lang, root, agent });
|
|
241
251
|
if (pending.length === 0) return null;
|
|
252
|
+
if (agent === 'codex') {
|
|
253
|
+
// Codex hook text stays English; the rule text itself follows the user's language.
|
|
254
|
+
const cmd = (s) => `${CLI_NAME} seed ${s} --agent codex`;
|
|
255
|
+
return [
|
|
256
|
+
`[Sprag seed] ${pending.length} recommended Codex ratchet rule(s) from the bundled presets are not registered yet.`,
|
|
257
|
+
'Ask in your first reply, after answering any request the user opened with. Offer these choices in this order:',
|
|
258
|
+
` 1. Register all, globally: ${cmd('accept all --global')}`,
|
|
259
|
+
` 2. Register all, this project only: ${cmd('accept all --project')}`,
|
|
260
|
+
' 3. Decide one at a time: ask about each rule below and run its command as soon as they answer',
|
|
261
|
+
` 4. Register none: ${cmd('skip all')} (never offered again)`,
|
|
262
|
+
'Always confirm the scope with the user before running an accept command.',
|
|
263
|
+
...pending.map((s) => ` [${s.id}] ${s.ruleText}`),
|
|
264
|
+
].join('\n');
|
|
265
|
+
}
|
|
242
266
|
const models = pending.filter((s) => s.kind === 'model');
|
|
243
267
|
const fixes = pending.filter((s) => s.kind === 'ratchet');
|
|
244
268
|
const lines = [];
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-paths — pull the file paths this session has already looked at out
|
|
3
|
+
* of its transcript, so a fresh subagent can be pointed at them instead of
|
|
4
|
+
* re-discovering them by exploring from scratch.
|
|
5
|
+
*
|
|
6
|
+
* Only the transcript TAIL is read: this runs from a PreToolUse hook on every
|
|
7
|
+
* Task/Agent call, so the whole file must not be parsed each time. Older tool
|
|
8
|
+
* calls fall out of the window, which is acceptable — the point is "recently
|
|
9
|
+
* touched", not "ever touched".
|
|
10
|
+
*
|
|
11
|
+
* The window is sized by measurement, not by brief.js's 256KiB convention. A
|
|
12
|
+
* transcript's bytes are dominated by tool RESULTS (file contents, command
|
|
13
|
+
* output, base64 images) while the tool_use records this reads are a few
|
|
14
|
+
* hundred bytes each, so a byte window is far shallower than it looks: on a
|
|
15
|
+
* real 4.65MB session it held 28 lines, 1.9% of the session, and recovered 0
|
|
16
|
+
* of the 8 path-bearing calls that session had made. At 1MB the same
|
|
17
|
+
* transcript gave back 4; the read cost 3.3ms against this hook's 10s
|
|
18
|
+
* timeout. See TAIL_BYTES below for why the default sits higher still.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, statSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
|
|
22
|
+
import { isAbsolute, resolve, relative, sep } from 'node:path';
|
|
23
|
+
|
|
24
|
+
// Tools whose argument names a file directly, each with the key it uses. The
|
|
25
|
+
// key is spelled out per tool rather than assumed: NotebookEdit takes
|
|
26
|
+
// `notebook_path`, so reading `file_path` from every one of these silently
|
|
27
|
+
// collected nothing from notebook edits while the docs listed the tool as
|
|
28
|
+
// supported. MultiEdit belongs here for the same reason korean-lint's matcher
|
|
29
|
+
// (`Write|Edit|MultiEdit|Bash`) counts it as a writing tool.
|
|
30
|
+
//
|
|
31
|
+
// Bash is left out on purpose: its `command` string is free text, not a path
|
|
32
|
+
// field, and scraping it for paths would be a much noisier feature than this.
|
|
33
|
+
const FILE_PATH_ARG = {
|
|
34
|
+
Read: 'file_path',
|
|
35
|
+
Edit: 'file_path',
|
|
36
|
+
MultiEdit: 'file_path',
|
|
37
|
+
Write: 'file_path',
|
|
38
|
+
NotebookEdit: 'notebook_path',
|
|
39
|
+
};
|
|
40
|
+
const PATH_ARG_TOOLS = new Set(['Grep', 'Glob']);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How far back to read. 4MB costs 11.5ms on the measured session — nothing
|
|
44
|
+
* against the hook's 10s timeout — and buys margin that 1MB does not: a single
|
|
45
|
+
* transcript line can be 240KB when a session reads an image, so four such
|
|
46
|
+
* reads would exhaust a 1MB window inside one turn and empty this section
|
|
47
|
+
* again. The ceiling also bounds the buffer this allocates per delegation.
|
|
48
|
+
*/
|
|
49
|
+
const TAIL_BYTES = 4 * 1024 * 1024;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A path that exists, is a file, and still resolves inside `root`.
|
|
53
|
+
*
|
|
54
|
+
* The containment test in the loop compares strings, and statSync follows
|
|
55
|
+
* symlinks, so a link that sits inside root while pointing outside it passed
|
|
56
|
+
* both. No new access is opened — every path here is one the parent session
|
|
57
|
+
* already read — but a function that goes as far as rejecting control
|
|
58
|
+
* characters in a filename should not be the loose one about links.
|
|
59
|
+
*/
|
|
60
|
+
function isRegularFileInside(abs, realRoot) {
|
|
61
|
+
try {
|
|
62
|
+
if (!statSync(abs).isFile()) return false;
|
|
63
|
+
// Both sides resolved: comparing a resolved path against an unresolved root
|
|
64
|
+
// rejects everything whenever the root itself sits behind a link, which is
|
|
65
|
+
// the normal case for a macOS temp directory (/tmp -> /private/tmp) and so
|
|
66
|
+
// for this project's own tests.
|
|
67
|
+
const real = relative(realRoot, realpathSync(abs));
|
|
68
|
+
return real !== '' && real !== '..' && !real.startsWith('..' + sep) && !isAbsolute(real);
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Recently-touched file paths for this session, most-recent-first, restricted
|
|
76
|
+
* to `root` and returned relative to it.
|
|
77
|
+
*
|
|
78
|
+
* Every failure mode — missing transcript, unreadable file, malformed JSON —
|
|
79
|
+
* collapses to an empty array rather than throwing, because this feeds a
|
|
80
|
+
* PreToolUse hook: one bad transcript line must not block the delegation it
|
|
81
|
+
* was only trying to make cheaper.
|
|
82
|
+
*/
|
|
83
|
+
export function recentToolPaths(transcriptPath, { root, limit = 15, tailBytes = TAIL_BYTES } = {}) {
|
|
84
|
+
try {
|
|
85
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return [];
|
|
86
|
+
// Paths are reported relative to root, so without one there is nothing
|
|
87
|
+
// meaningful to return. resolve() would throw on undefined and the outer
|
|
88
|
+
// catch would hide that as an empty result, so say it here instead.
|
|
89
|
+
if (typeof root !== 'string' || root === '') return [];
|
|
90
|
+
|
|
91
|
+
const size = statSync(transcriptPath).size;
|
|
92
|
+
const start = Math.max(0, size - tailBytes);
|
|
93
|
+
// One byte of lookbehind, so the shift() below is right in both cases. When
|
|
94
|
+
// `start` happens to land just past a newline, the first line in the window
|
|
95
|
+
// is a whole record and dropping it lost one for nothing. Reading from the
|
|
96
|
+
// byte before means that case decodes with a leading '\n', whose split
|
|
97
|
+
// produces an empty first element — so the same unconditional shift()
|
|
98
|
+
// removes an empty string there and a genuine fragment otherwise.
|
|
99
|
+
const readFrom = start > 0 ? start - 1 : 0;
|
|
100
|
+
// allocUnsafe, not alloc: the read loop below overwrites what it uses and
|
|
101
|
+
// only buf.subarray(0, bytesRead) is ever decoded, so the untouched
|
|
102
|
+
// remainder never leaves this function. This saves zeroing 4MB per
|
|
103
|
+
// delegation, which is worth taking but is not where the cost of this
|
|
104
|
+
// function is — decoding the window to a string and splitting it allocates
|
|
105
|
+
// several times more. Cutting that means walking the buffer backwards by
|
|
106
|
+
// newline and decoding only the lines needed to reach `limit`, which is
|
|
107
|
+
// the change to make if the window is ever widened again.
|
|
108
|
+
const buf = Buffer.allocUnsafe(size - readFrom);
|
|
109
|
+
let fd;
|
|
110
|
+
let bytesRead = 0;
|
|
111
|
+
try {
|
|
112
|
+
fd = openSync(transcriptPath, 'r');
|
|
113
|
+
// One readSync is not promised to fill the whole request — a short read
|
|
114
|
+
// is common on some filesystems even mid-file — and the part it leaves
|
|
115
|
+
// unread here is the tail end of the buffer, which is the most recent
|
|
116
|
+
// slice of the transcript and exactly what this feature is trying to
|
|
117
|
+
// recover. Looping until the buffer is full (or the file runs out)
|
|
118
|
+
// keeps that slice from being silently dropped.
|
|
119
|
+
while (bytesRead < buf.length) {
|
|
120
|
+
const n = readSync(fd, buf, bytesRead, buf.length - bytesRead, readFrom + bytesRead);
|
|
121
|
+
if (n === 0) break;
|
|
122
|
+
bytesRead += n;
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
if (fd !== undefined) closeSync(fd);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Only what was actually read. A transcript is still being written while
|
|
129
|
+
// this runs, so between statSync and openSync the file may have been
|
|
130
|
+
// truncated or rotated, which ends the loop above early (n === 0) with
|
|
131
|
+
// bytesRead short of the buffer. Decoding the untouched remainder would
|
|
132
|
+
// turn the last record into NUL bytes and lose the newest path to a
|
|
133
|
+
// parse error.
|
|
134
|
+
const lines = buf.subarray(0, bytesRead).toString('utf8').split('\n');
|
|
135
|
+
// A tail that began mid-file leaves the first line either a truncated
|
|
136
|
+
// record or, thanks to the lookbehind byte above, an empty string. Either
|
|
137
|
+
// way it goes. When the read started at byte 0 the file was smaller than
|
|
138
|
+
// the window and that line is whole, so keeping it costs nothing and
|
|
139
|
+
// dropping it would lose the only record a short transcript has.
|
|
140
|
+
if (start > 0) lines.shift();
|
|
141
|
+
|
|
142
|
+
// Resolved once, not per path: only the stat'ed candidates need it, and the
|
|
143
|
+
// root does not change between them. Falls back to the given root when it
|
|
144
|
+
// cannot be resolved, which keeps a missing directory from emptying the
|
|
145
|
+
// whole list.
|
|
146
|
+
let realRoot = root;
|
|
147
|
+
try {
|
|
148
|
+
realRoot = realpathSync(root);
|
|
149
|
+
} catch {
|
|
150
|
+
// keep root as given
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Chronological order first (oldest touched path to newest); reversed
|
|
154
|
+
// and deduped below so the most recent occurrence of each path wins.
|
|
155
|
+
const touched = [];
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
// Cheap prefilter before the JSON.parse cost, same trick brief.js uses
|
|
158
|
+
// for `"usage"` — most lines in a transcript are not tool_use blocks.
|
|
159
|
+
if (!line.includes('"tool_use"')) continue;
|
|
160
|
+
let entry;
|
|
161
|
+
try {
|
|
162
|
+
entry = JSON.parse(line);
|
|
163
|
+
} catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const content = entry?.message?.content;
|
|
167
|
+
if (!Array.isArray(content)) continue;
|
|
168
|
+
|
|
169
|
+
for (const block of content) {
|
|
170
|
+
if (!block || block.type !== 'tool_use') continue;
|
|
171
|
+
const input = block.input;
|
|
172
|
+
if (!input || typeof input !== 'object') continue;
|
|
173
|
+
|
|
174
|
+
let rawPath = null;
|
|
175
|
+
// Grep and Glob are the two whose argument is a directory as often as
|
|
176
|
+
// a file, so what they contribute has to be confirmed before it is
|
|
177
|
+
// offered as something to read. Read/Edit/Write name a file by
|
|
178
|
+
// contract.
|
|
179
|
+
let mayBeDirectory = false;
|
|
180
|
+
const argKey = FILE_PATH_ARG[block.name];
|
|
181
|
+
if (argKey) rawPath = input[argKey];
|
|
182
|
+
else if (PATH_ARG_TOOLS.has(block.name)) {
|
|
183
|
+
rawPath = input.path;
|
|
184
|
+
mayBeDirectory = true;
|
|
185
|
+
}
|
|
186
|
+
if (typeof rawPath !== 'string' || rawPath === '') continue;
|
|
187
|
+
// A POSIX filename may hold any byte but `/` and NUL, and this list is
|
|
188
|
+
// rendered one path per line straight into the prompt of a subagent
|
|
189
|
+
// that has tool permissions. So a filename carrying a newline plus a
|
|
190
|
+
// sentence would leave the "already read these" section and arrive as
|
|
191
|
+
// its own instruction. Filenames are outside input whenever the
|
|
192
|
+
// session is working in a repository it did not write, which makes
|
|
193
|
+
// this a boundary rather than a curiosity.
|
|
194
|
+
//
|
|
195
|
+
// The class covers more than C0 and DEL, because more than those end a
|
|
196
|
+
// line: U+2028 and U+2029 are Unicode's own line and paragraph
|
|
197
|
+
// separators and U+0085 is NEL, and a renderer may break on any of
|
|
198
|
+
// them. The backtick is here for a different reason — the section wraps
|
|
199
|
+
// each path in one, so a name containing a backtick closes that span
|
|
200
|
+
// early and returns the rest of the line to prose. Both are cheaper to
|
|
201
|
+
// reject while collecting than to escape while rendering.
|
|
202
|
+
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029`]/.test(rawPath)) continue;
|
|
203
|
+
// The free half of the directory test; the stat below covers the rest.
|
|
204
|
+
if (rawPath.endsWith('/') || rawPath.endsWith(sep)) continue;
|
|
205
|
+
|
|
206
|
+
const abs = isAbsolute(rawPath) ? rawPath : resolve(root, rawPath);
|
|
207
|
+
const rel = relative(root, abs);
|
|
208
|
+
// Outside root: relative() climbs out as ".." alone or ".." followed by
|
|
209
|
+
// a separator, or (on a different Windows drive) returns an
|
|
210
|
+
// already-absolute path. The separator has to be part of the test —
|
|
211
|
+
// without it a directory legitimately named `..fixtures` or `..cache`
|
|
212
|
+
// read as an escape and everything under it vanished from the list with
|
|
213
|
+
// no signal anywhere.
|
|
214
|
+
if (rel === '' || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) continue;
|
|
215
|
+
|
|
216
|
+
touched.push({ rel, mayBeDirectory });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const deduped = [];
|
|
221
|
+
const seen = new Set();
|
|
222
|
+
for (let i = touched.length - 1; i >= 0 && deduped.length < limit; i--) {
|
|
223
|
+
const { rel: p, mayBeDirectory } = touched[i];
|
|
224
|
+
if (seen.has(p)) continue;
|
|
225
|
+
seen.add(p);
|
|
226
|
+
// `Grep({ path: 'src' })` names a directory without a trailing
|
|
227
|
+
// separator, and the section this feeds says the session "already read
|
|
228
|
+
// these" — so a directory listed there invites a subagent to Read it and
|
|
229
|
+
// spend one of its capped tool calls on the failure. The same reasoning
|
|
230
|
+
// already guards the ratchet pointer in delegation-guard.js.
|
|
231
|
+
//
|
|
232
|
+
// This is the one place a syscall is worth it. It runs after dedupe and
|
|
233
|
+
// stops at `limit`, so at most 15 stats happen per delegation against an
|
|
234
|
+
// 11.5ms read, and only for the two tools that can name a directory.
|
|
235
|
+
if (mayBeDirectory && !isRegularFileInside(resolve(root, p), realRoot)) continue;
|
|
236
|
+
deduped.push(p);
|
|
237
|
+
}
|
|
238
|
+
return deduped;
|
|
239
|
+
} catch {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
package/src/update-check.js
CHANGED
|
@@ -266,16 +266,51 @@ export function releaseHighlights(body, lang = 'en') {
|
|
|
266
266
|
return out;
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The releases endpoint for one version, or a throw.
|
|
271
|
+
*
|
|
272
|
+
* The version that arrives here came out of a registry response rather than out
|
|
273
|
+
* of this process, so it is checked rather than trusted: a value carrying a
|
|
274
|
+
* slash or a scheme would otherwise have a say in which URL this function
|
|
275
|
+
* requests. Two checks are needed, because either one alone leaves a gap. The
|
|
276
|
+
* shape test rejects anything that is not a version, and the prefix test runs
|
|
277
|
+
* against the parsed URL rather than against the string it was built from,
|
|
278
|
+
* because `new URL` has collapsed `../` segments by that point and the same
|
|
279
|
+
* test on the raw string would pass a value that walks back out of the path.
|
|
280
|
+
* The origin is a constant either way, so no input can move the request to
|
|
281
|
+
* another host; this states that guarantee in code instead of leaving it
|
|
282
|
+
* implied.
|
|
283
|
+
*/
|
|
284
|
+
function releaseTagUrl(version) {
|
|
285
|
+
const tag = String(version);
|
|
286
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(tag)) {
|
|
287
|
+
throw new Error(`refusing to request notes for ${JSON.stringify(tag)}: not a version`);
|
|
288
|
+
}
|
|
289
|
+
const url = new URL(`${RELEASES_API}/v${tag}`);
|
|
290
|
+
if (!url.href.startsWith(`${RELEASES_API}/`)) {
|
|
291
|
+
throw new Error(`refusing to request ${url.href}: outside ${RELEASES_API}`);
|
|
292
|
+
}
|
|
293
|
+
return url;
|
|
294
|
+
}
|
|
295
|
+
|
|
269
296
|
/**
|
|
270
297
|
* Fetch the highlights for one version. Returns [] on any failure: the notice
|
|
271
298
|
* is worth showing with a bare version number, so a missing release, a rate
|
|
272
|
-
* limit, or an offline machine must not cost the user the notice itself.
|
|
299
|
+
* limit, or an offline machine must not cost the user the notice itself. A
|
|
300
|
+
* version that fails the endpoint check above lands in that same place: no
|
|
301
|
+
* highlights, and the notice still goes out.
|
|
273
302
|
*/
|
|
274
303
|
export async function fetchHighlights(version, { timeoutMs = FETCH_TIMEOUT_MS, lang = 'en' } = {}) {
|
|
275
304
|
const controller = new AbortController();
|
|
276
305
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
277
306
|
try {
|
|
278
|
-
const
|
|
307
|
+
const endpoint = releaseTagUrl(version);
|
|
308
|
+
// The URL is this module's own constant plus a checked version. The
|
|
309
|
+
// scanner's node_ssrf heuristic reads any two-argument function as an HTTP
|
|
310
|
+
// handler and its first argument as request input, which is what it matched
|
|
311
|
+
// on here; this module serves nothing and has no request to read.
|
|
312
|
+
// nosemgrep: rules.lgpl.nodejs_scan.javascript-ssrf-rule-node_ssrf
|
|
313
|
+
const res = await fetch(endpoint, {
|
|
279
314
|
signal: controller.signal,
|
|
280
315
|
headers: { accept: 'application/vnd.github+json' },
|
|
281
316
|
});
|