@retasc/cli 1.39.1 → 1.39.3
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/CHANGELOG.md +44 -0
- package/dist/lib/attachFile.js +232 -7
- package/dist/lib/harness.js +11 -2
- package/dist/proxy.js +12 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,50 @@ release commits and the issues they reference.
|
|
|
6
6
|
|
|
7
7
|
Dates are the npm publish date. Each entry names the RTSC issue behind it.
|
|
8
8
|
|
|
9
|
+
## 1.39.3 (2026-09-02)
|
|
10
|
+
|
|
11
|
+
- **RTSC-801** — (security) `save_attachment_file` no longer reads a workspace's own secrets,
|
|
12
|
+
and no longer follows a file swapped in after it was checked. The proxy reads attachments
|
|
13
|
+
on the agent's behalf, which is what keeps the API key away from the model, and the cost of
|
|
14
|
+
that is a read the harness never prompts on: whatever this path allows, an agent can upload
|
|
15
|
+
to an issue every org member can read. Containment to the attach root was doing all the work
|
|
16
|
+
and it was never the boundary it looked like, because the files worth stealing are not
|
|
17
|
+
outside a workspace, they are in it. `.env.local`, a legacy inline-key `.mcp.json` and
|
|
18
|
+
`.retasc/` all live inside a normal root, so a prompt-injected "attach the config" was
|
|
19
|
+
confined to exactly the secrets. Anything hidden below the root is now refused, dotfiles and
|
|
20
|
+
dot-directories alike, along with `secrets.md` and, wherever `RETASC_DIR` puts it, the
|
|
21
|
+
keystore itself. A root that contains your home directory (`~` or `/`, both previously
|
|
22
|
+
accepted) is refused outright rather than carved down, since it makes every credential on
|
|
23
|
+
the machine attachable, including every other workspace's key. Those rules all judge a name,
|
|
24
|
+
so a file with more than one hard link is refused too: path resolution follows symlinks but
|
|
25
|
+
not links, and `ln .env.local notes.txt` would otherwise hand the check a name it has no
|
|
26
|
+
reason to refuse while the bytes stay the same. The one carve-out is `.retasc/attachments/`,
|
|
27
|
+
which is where the proxy writes files fetched by `get_attachment_file`: refusing that would
|
|
28
|
+
be the proxy declining to read a file the proxy itself wrote, and downloading from one issue
|
|
29
|
+
to attach to another is a real thing agents do. Anything hidden deeper inside it is still
|
|
30
|
+
refused.
|
|
31
|
+
The second half is a race: the path was resolved and stat'd, a full
|
|
32
|
+
`prepare_attachment_upload` round trip went to the server, and only then was the file opened
|
|
33
|
+
by path, so a `ln -sf` in that window was followed on the read. The read now opens the file
|
|
34
|
+
`O_NOFOLLOW` and non-blocking, and proceeds only if the descriptor's device, inode and size
|
|
35
|
+
are the ones that passed the check. Opening non-blocking is not a detail: a `mkfifo` in that
|
|
36
|
+
same window would otherwise have blocked the proxy's event loop outright, stopping every
|
|
37
|
+
heartbeat it owes and stranding every lease it holds.
|
|
38
|
+
Legitimate attachments, screenshots, logs and documents, are unaffected: none of them are
|
|
39
|
+
hidden files.
|
|
40
|
+
|
|
41
|
+
## 1.39.2 (2026-09-02)
|
|
42
|
+
|
|
43
|
+
- **RTSC-808** — `retasc setup` no longer writes OpenCode's config into your real home when
|
|
44
|
+
`RETASC_HOME` says otherwise. That variable exists so anything exercising the harness module
|
|
45
|
+
cannot reconfigure the machine it runs on, and every other harness honours it; OpenCode read
|
|
46
|
+
`XDG_CONFIG_HOME` first and so ignored the override wherever that variable is set. The
|
|
47
|
+
precedence is now override, then environment, then default, and `XDG_CONFIG_HOME` is still
|
|
48
|
+
honoured when no override is in play, because OpenCode really does read it.
|
|
49
|
+
This is also why CI had been red on `main` for five consecutive merges. macOS does not set
|
|
50
|
+
`XDG_CONFIG_HOME` and GitHub's Linux runners do, so the test guarding this passed on every
|
|
51
|
+
developer machine and failed on every CI run, and no PR in the repo could show a green build.
|
|
52
|
+
|
|
9
53
|
## 1.39.1 (2026-09-02)
|
|
10
54
|
|
|
11
55
|
- **RTSC-800** — (security) a keystore-resolved key now travels only to the keystore's own
|
package/dist/lib/attachFile.js
CHANGED
|
@@ -20,8 +20,11 @@
|
|
|
20
20
|
// would otherwise be a clean exfiltration primitive. The confinement below IS the security
|
|
21
21
|
// boundary, not hygiene — keep it strict, and keep it here where it is unit-tested, rather
|
|
22
22
|
// than inline in the proxy's I/O path.
|
|
23
|
-
import { realpathSync, statSync } from "node:fs";
|
|
24
|
-
import {
|
|
23
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
|
|
26
|
+
import { keystorePath } from "./keystore.js";
|
|
27
|
+
import { DOWNLOAD_DIR_SEGMENTS } from "./fetchFile.js";
|
|
25
28
|
/** The one tool name for "attach a file", whichever transport can carry the bytes. The
|
|
26
29
|
* server publishes a base64 variant under this SAME name for clients with no proxy; when a
|
|
27
30
|
* proxy is present it overrides the entry in `tools/list` with the path form below. One
|
|
@@ -34,6 +37,12 @@ export const MAX_ATTACH_BYTES = 50 * 1024 * 1024;
|
|
|
34
37
|
/** Env var that widens the root a path may live under. Set it deliberately (a worktree
|
|
35
38
|
* layout, a screenshots dir); unset, the root is the proxy's cwd. */
|
|
36
39
|
export const ATTACH_ROOT_ENV = "RETASC_ATTACH_ROOT";
|
|
40
|
+
/** Files inside the root that carry credentials under a NON-dot name, so the dot rule below
|
|
41
|
+
* doesn't catch them. Deliberately short: a name denylist is the weakest of the three rules
|
|
42
|
+
* here (rename the file and it stops applying), and it exists only for the two names this
|
|
43
|
+
* repo's own conventions put in a workspace. The load-bearing rules are the dot rule, the
|
|
44
|
+
* keystore rule and the home-root refusal — those hold regardless of naming. */
|
|
45
|
+
export const DENIED_ATTACH_NAMES = new Set(["secrets.md", "secrets.txt"]);
|
|
37
46
|
/**
|
|
38
47
|
* The directory a file must live under to be attachable. `RETASC_ATTACH_ROOT` if set (a
|
|
39
48
|
* human's deliberate choice — the fleet case is real: agents work in sibling worktrees, so
|
|
@@ -52,15 +61,93 @@ export function attachRoot(env, cwd) {
|
|
|
52
61
|
return resolve(raw); // non-existent root: nothing will resolve inside it anyway
|
|
53
62
|
}
|
|
54
63
|
}
|
|
64
|
+
/** Resolve a path without caring whether it exists yet: `realpathSync` if we can, the plain
|
|
65
|
+
* resolution otherwise. Used for the fixed paths we compare AGAINST (home, the keystore), so
|
|
66
|
+
* a machine where the keystore hasn't been created yet still gets the rule. */
|
|
67
|
+
function realOrResolved(p) {
|
|
68
|
+
try {
|
|
69
|
+
return realpathSync(p);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return resolve(p);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** One message for both places the link count is checked, so the resolution-time refusal and
|
|
76
|
+
* the read-time one read identically to whoever sees them. */
|
|
77
|
+
function hardLinkError(path, links) {
|
|
78
|
+
return (`refusing to read ${path}: it has ${links} hard links, so this name may be an alias for a ` +
|
|
79
|
+
`file the rules above would refuse (a hard link is invisible to path resolution). Copy it ` +
|
|
80
|
+
`to a new file and attach the copy.`);
|
|
81
|
+
}
|
|
82
|
+
/** macOS and Windows compare filenames case-insensitively, and `realpathSync` does NOT correct
|
|
83
|
+
* the case there — it hands back whatever case was asked for. So a purely case-sensitive
|
|
84
|
+
* compare lets `/users/<name>` past a rule written against `/Users/<name>` while the kernel
|
|
85
|
+
* happily opens the same directory. Fold on those platforms only; on Linux the case IS the
|
|
86
|
+
* identity and folding would wrongly conflate two real directories. */
|
|
87
|
+
const CASE_BLIND = process.platform === "darwin" || process.platform === "win32";
|
|
88
|
+
function samePath(a, b) {
|
|
89
|
+
return CASE_BLIND ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
90
|
+
}
|
|
91
|
+
/** Is `inner` `outer`, or inside it? Both must already be resolved. */
|
|
92
|
+
function isWithin(inner, outer) {
|
|
93
|
+
if (samePath(inner, outer))
|
|
94
|
+
return true;
|
|
95
|
+
const prefix = outer.endsWith(sep) ? outer : outer + sep;
|
|
96
|
+
return samePath(inner.slice(0, prefix.length), prefix);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Does this root swallow the home directory? A root of `~` or `/` does, and both used to be
|
|
100
|
+
* accepted — which put `~/.retasc/bindings.json` (every org's workspace key, for EVERY
|
|
101
|
+
* workspace on this machine, not just this one) inside the attachable set. There is no
|
|
102
|
+
* legitimate attachment that needs a root that wide, and the failure mode if we allowed one
|
|
103
|
+
* is the worst this module has, so it is refused outright rather than carved down with
|
|
104
|
+
* exceptions. A root BELOW home (`~/projects/foo`) is the normal case and stays fine.
|
|
105
|
+
*
|
|
106
|
+
* An unusable `homedir()` disables the rule rather than widening it. Containers, launchd and
|
|
107
|
+
* systemd units run with HOME cleared, where `homedir()` can be "" — and "" resolves to the
|
|
108
|
+
* process's cwd, which is the DEFAULT root, so folding it in would refuse every attachment on
|
|
109
|
+
* exactly the machines least able to debug it. With no home directory there is also no
|
|
110
|
+
* `~/.retasc` for the rule to be protecting.
|
|
111
|
+
*/
|
|
112
|
+
export function rootSwallowsHome(root) {
|
|
113
|
+
const home = homedir();
|
|
114
|
+
if (!home || !isAbsolute(home))
|
|
115
|
+
return false;
|
|
116
|
+
return isWithin(realOrResolved(home), root);
|
|
117
|
+
}
|
|
55
118
|
/**
|
|
56
119
|
* Resolve a caller-supplied path against `root` and decide whether we are willing to read it.
|
|
57
120
|
*
|
|
58
121
|
* Refusals, and what each one is actually for:
|
|
122
|
+
* - a root that contains the home directory — see `rootSwallowsHome`. Checked first because
|
|
123
|
+
* it condemns every path, and the human needs to see WHY rather than a per-file refusal.
|
|
59
124
|
* - outside `root` — the containment rule. Compared AFTER `realpathSync` on both sides, so a
|
|
60
125
|
* symlink inside the root pointing at `~/.ssh/id_rsa` is caught: resolving only the
|
|
61
126
|
* requested path (or neither) is the classic way this check is defeated.
|
|
62
127
|
* - anything under a `.git` directory — inside the root by construction, and `.git/config`
|
|
63
128
|
* routinely holds credentials in remote URLs. No legitimate attachment lives there.
|
|
129
|
+
* - any OTHER dotfile or dot-directory below the root (RTSC-801). Containment alone was never
|
|
130
|
+
* the whole boundary: the files worth stealing are not outside the workspace, they are in
|
|
131
|
+
* it. `.env.local`, a legacy inline-key `.mcp.json` and `.retasc/` all sit inside a normal
|
|
132
|
+
* root, so "confined to the project" was confining an attacker to exactly the secrets. A
|
|
133
|
+
* dot prefix is the one convention every one of them shares, and attachments are
|
|
134
|
+
* screenshots, logs and documents — none of which are dotfiles — so the rule costs nothing
|
|
135
|
+
* real and does not have to be kept in step with a list of names. The ONE carve-out is
|
|
136
|
+
* `.retasc/attachments/`, which is where this same proxy writes downloads: refusing it would
|
|
137
|
+
* mean the proxy declining to read a file the proxy just wrote, and "download from one
|
|
138
|
+
* issue, attach to another" is a real thing agents do. The carve-out steps over exactly
|
|
139
|
+
* those two segments; anything hidden deeper inside is refused normally.
|
|
140
|
+
* - `secrets.md` and friends — the same class under a name the dot rule can't see.
|
|
141
|
+
* - the keystore file, and anything sitting beside it — the dot rule already covers the
|
|
142
|
+
* default `~/.retasc/`, but `RETASC_DIR` can move it somewhere undotted, and a root INSIDE
|
|
143
|
+
* that directory would put it above the dot check entirely. Direct children only, not the
|
|
144
|
+
* subtree, so the default layout's `.retasc/attachments/` stays readable.
|
|
145
|
+
* - a file with more than one hard link — the rules above judge a NAME, and a hard link gives
|
|
146
|
+
* one inode a second name. `realpathSync` resolves symlinks but not links, so `ln .env.local
|
|
147
|
+
* notes.txt` inside the root hands the dot rule a name it has no reason to refuse and the
|
|
148
|
+
* read follows to the same bytes. Link count is the property that survives renaming, so it
|
|
149
|
+
* is what gets checked. Real attachments (a screenshot, a log, a build output) have one
|
|
150
|
+
* link; the fix for the rare tool that hard-links its output is to copy the file.
|
|
64
151
|
* - not a regular file — a directory, fifo or device isn't an attachment, and reading a fifo
|
|
65
152
|
* would hang the proxy's stdio loop rather than fail.
|
|
66
153
|
* - empty / oversized — the server rejects both; failing here costs one round trip less and
|
|
@@ -68,6 +155,10 @@ export function attachRoot(env, cwd) {
|
|
|
68
155
|
*
|
|
69
156
|
* A `~` is deliberately NOT expanded: the home directory is precisely what the root confines
|
|
70
157
|
* away from, so silently reaching it would undo the boundary.
|
|
158
|
+
*
|
|
159
|
+
* What this function decides is only half the guarantee — see `readAttachFile`, which is what
|
|
160
|
+
* actually opens the bytes, because a decision made about a path is not a decision about the
|
|
161
|
+
* file that path names a network round trip later.
|
|
71
162
|
*/
|
|
72
163
|
export function resolveAttachPath(input, root) {
|
|
73
164
|
const requested = typeof input === "string" ? input.trim() : "";
|
|
@@ -80,6 +171,14 @@ export function resolveAttachPath(input, root) {
|
|
|
80
171
|
`under ${root}; give a path inside it (relative paths resolve against it).`,
|
|
81
172
|
};
|
|
82
173
|
}
|
|
174
|
+
if (rootSwallowsHome(root)) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: `refusing to attach anything: the attach root ${root} contains your home directory, ` +
|
|
178
|
+
`which would make every credential on this machine attachable. Set ${ATTACH_ROOT_ENV} ` +
|
|
179
|
+
`to the project directory you actually work in.`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
83
182
|
const candidate = resolve(root, requested);
|
|
84
183
|
let real;
|
|
85
184
|
try {
|
|
@@ -92,22 +191,64 @@ export function resolveAttachPath(input, root) {
|
|
|
92
191
|
// "/" would refuse everything instead of allowing everything. Fail-closed is the right
|
|
93
192
|
// direction, but silently, and only for that one root; normalize instead.
|
|
94
193
|
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
95
|
-
if (real
|
|
194
|
+
if (!isWithin(real, root)) {
|
|
96
195
|
return {
|
|
97
196
|
ok: false,
|
|
98
197
|
error: `refusing to read ${real}: it resolves outside ${root}. Only files under that root can ` +
|
|
99
198
|
`be attached (set ${ATTACH_ROOT_ENV} to widen it deliberately).`,
|
|
100
199
|
};
|
|
101
200
|
}
|
|
102
|
-
|
|
201
|
+
// Only the segments BELOW the root: the root itself is the human's deliberate choice and may
|
|
202
|
+
// legitimately be a dotted path, so judging it here would refuse everything under it.
|
|
203
|
+
let below = real.slice(prefix.length).split(sep).filter(Boolean);
|
|
204
|
+
// `.retasc/attachments/` is the directory THIS proxy writes downloads into, so a blanket dot
|
|
205
|
+
// rule would make "download the design from RTSC-1, attach it to RTSC-2" impossible — the
|
|
206
|
+
// proxy refusing to read a file the proxy just wrote. Step over those two segments and judge
|
|
207
|
+
// the rest normally, so a hand-placed `.env` deeper inside is still refused.
|
|
208
|
+
if (below.length > DOWNLOAD_DIR_SEGMENTS.length && DOWNLOAD_DIR_SEGMENTS.every((seg, i) => below[i] === seg)) {
|
|
209
|
+
below = below.slice(DOWNLOAD_DIR_SEGMENTS.length);
|
|
210
|
+
}
|
|
211
|
+
if (below.includes(".git")) {
|
|
103
212
|
return { ok: false, error: `refusing to read ${real}: paths inside a .git directory are not attachable.` };
|
|
104
213
|
}
|
|
214
|
+
const dotted = below.find((seg) => seg.startsWith("."));
|
|
215
|
+
if (dotted) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: `refusing to read ${real}: "${dotted}" is hidden, and hidden files and directories are ` +
|
|
219
|
+
`not attachable. Attach a file that isn't hidden.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
// Trailing dots and spaces are stripped by Win32 before the filesystem sees a name, so
|
|
223
|
+
// `secrets.md.` and `secrets.md ` open the same file while a literal compare misses both.
|
|
224
|
+
const leaf = (below[below.length - 1] ?? "").replace(/[. ]+$/, "");
|
|
225
|
+
if (DENIED_ATTACH_NAMES.has(leaf.toLowerCase())) {
|
|
226
|
+
return { ok: false, error: `refusing to read ${real}: that file is not attachable.` };
|
|
227
|
+
}
|
|
228
|
+
// The keystore file itself, and anything sitting beside it in the directory it lives in —
|
|
229
|
+
// `RETASC_DIR` names a credential directory, not just one file. Direct children only: a
|
|
230
|
+
// subtree ban would swallow `.retasc/attachments/` on the default layout, which the carve-out
|
|
231
|
+
// above exists to keep readable.
|
|
232
|
+
const keystore = realOrResolved(keystorePath());
|
|
233
|
+
if (samePath(real, keystore) || samePath(dirname(real), realOrResolved(dirname(keystorePath())))) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
error: `refusing to read ${real}: it is in the directory retasc keeps credentials in ` +
|
|
237
|
+
`(RETASC_DIR). Nothing there is attachable.`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
105
240
|
let size;
|
|
241
|
+
let dev;
|
|
242
|
+
let ino;
|
|
106
243
|
try {
|
|
107
244
|
const st = statSync(real);
|
|
108
245
|
if (!st.isFile())
|
|
109
246
|
return { ok: false, error: `not a regular file: ${real}` };
|
|
247
|
+
if (st.nlink > 1)
|
|
248
|
+
return { ok: false, error: hardLinkError(real, st.nlink) };
|
|
110
249
|
size = st.size;
|
|
250
|
+
dev = st.dev;
|
|
251
|
+
ino = st.ino;
|
|
111
252
|
}
|
|
112
253
|
catch {
|
|
113
254
|
return { ok: false, error: `cannot stat ${real}` };
|
|
@@ -122,7 +263,90 @@ export function resolveAttachPath(input, root) {
|
|
|
122
263
|
}
|
|
123
264
|
// The display name comes from what the caller ASKED for, not from the resolved target: a
|
|
124
265
|
// symlink's own name is the one the human recognizes, and it only ever becomes a label.
|
|
125
|
-
return { ok: true, path: real, filename: basename(candidate), size };
|
|
266
|
+
return { ok: true, path: real, filename: basename(candidate), size, dev, ino };
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Read the bytes we agreed to read — and prove they are the SAME bytes (RTSC-801).
|
|
270
|
+
*
|
|
271
|
+
* `resolveAttachPath` runs, then the proxy spends a full network round trip on
|
|
272
|
+
* `prepare_attachment_upload`, and only then does the file get opened. Reading by path at that
|
|
273
|
+
* point re-does the lookup against whatever the filesystem holds NOW, which is a window an
|
|
274
|
+
* agent's own shell can drive: a background `ln -sf` loop swaps the approved file for a link
|
|
275
|
+
* to `~/.ssh/id_rsa` and the read follows it, having passed every check above.
|
|
276
|
+
*
|
|
277
|
+
* Three things close it. `O_NOFOLLOW` refuses a symlink as the final component outright.
|
|
278
|
+
* `O_NONBLOCK` keeps a fifo swapped into the window from blocking the whole event loop rather
|
|
279
|
+
* than being refused. And the dev/ino/size comparison covers the rest — an intermediate
|
|
280
|
+
* directory swapped for another one, or the file replaced by a different regular file —
|
|
281
|
+
* because it asks the question that actually matters: is the fd I am holding open on the file
|
|
282
|
+
* I checked? A path can be re-pointed; an open descriptor cannot. Everything after the open
|
|
283
|
+
* reads from the fd, never from the path.
|
|
284
|
+
*
|
|
285
|
+
* What that does NOT cover, stated plainly rather than implied away: a rewrite in place, through
|
|
286
|
+
* the same inode, that lands on exactly the same byte count. Nothing short of hashing the
|
|
287
|
+
* content twice would catch it, and hashing is itself racy and costs a second full read of up
|
|
288
|
+
* to 50MB. It is also the weakest of these attacks by some distance — it needs write access to
|
|
289
|
+
* the file the human already approved AND the secret bytes already in hand, which is an
|
|
290
|
+
* attacker who could simply have named the secret in the first place.
|
|
291
|
+
*/
|
|
292
|
+
export function readAttachFile(resolved) {
|
|
293
|
+
// O_NOFOLLOW is POSIX; on a platform without it we lean on the dev/ino check below.
|
|
294
|
+
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
295
|
+
// O_NONBLOCK matters more than it looks. `openSync` on a FIFO with no writer BLOCKS, and it
|
|
296
|
+
// blocks the whole process — not a request, the event loop, so heartbeats stop and every
|
|
297
|
+
// lease this proxy holds goes stale. `resolveAttachPath` rejects a fifo, but a `mkfifo` in
|
|
298
|
+
// the same window this function exists for is never seen by that check, which would turn a
|
|
299
|
+
// refusal into a frozen proxy. Opening non-blocking returns immediately and lets the
|
|
300
|
+
// `isFile` check below refuse it like any other non-file. Harmless on a regular file.
|
|
301
|
+
const nonBlock = typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0;
|
|
302
|
+
let fd;
|
|
303
|
+
try {
|
|
304
|
+
fd = openSync(resolved.path, constants.O_RDONLY | noFollow | nonBlock);
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
const code = e?.code;
|
|
308
|
+
if (code === "ELOOP" || code === "EMLINK") {
|
|
309
|
+
return { ok: false, error: `refusing to read ${resolved.path}: it became a symlink after it was checked.` };
|
|
310
|
+
}
|
|
311
|
+
return { ok: false, error: `cannot open ${resolved.path}: ${String(e?.message ?? e)}` };
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
const st = fstatSync(fd);
|
|
315
|
+
// Identity AND size: dev/ino prove the same inode, the size proves it was not rewritten in
|
|
316
|
+
// place through the same inode (`open(path, "r+")` changes neither dev nor ino nor link
|
|
317
|
+
// count). Together they are what "the file that was checked" can honestly mean here.
|
|
318
|
+
if (!st.isFile() || st.dev !== resolved.dev || st.ino !== resolved.ino || st.size !== resolved.size) {
|
|
319
|
+
return { ok: false, error: `refusing to read ${resolved.path}: the file changed after it was checked.` };
|
|
320
|
+
}
|
|
321
|
+
// With no O_NOFOLLOW and no usable inode number there is nothing left proving this
|
|
322
|
+
// descriptor is the file that passed the checks, so refuse rather than pretend. Real on
|
|
323
|
+
// SMB, FAT/exFAT and some FUSE mounts, where `ino` is 0 or not stable.
|
|
324
|
+
if (noFollow === 0 && st.ino === 0) {
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
error: `refusing to read ${resolved.path}: this filesystem reports no stable file id and the ` +
|
|
328
|
+
`platform cannot refuse symlinks on open, so the file cannot be proven unchanged.`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
// Re-checked on the fd, not just at resolution: a link can be added inside the same window
|
|
332
|
+
// the dev/ino check exists for, and adding one does not change the inode.
|
|
333
|
+
if (st.nlink > 1)
|
|
334
|
+
return { ok: false, error: hardLinkError(resolved.path, st.nlink) };
|
|
335
|
+
// No separate empty/oversize re-check: resolveAttachPath already refused both, and any size
|
|
336
|
+
// that could newly violate either is a size that differs from the one just compared.
|
|
337
|
+
return { ok: true, bytes: readFileSync(fd) };
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
return { ok: false, error: `cannot read ${resolved.path}: ${String(e?.message ?? e)}` };
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
try {
|
|
344
|
+
closeSync(fd);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
/* already gone */
|
|
348
|
+
}
|
|
349
|
+
}
|
|
126
350
|
}
|
|
127
351
|
/** The tool the proxy advertises in place of (or in addition to) the server's base64 variant.
|
|
128
352
|
* It names the root in the description because the model has no other way to learn where it
|
|
@@ -134,8 +358,9 @@ export function localAttachToolDef(root) {
|
|
|
134
358
|
"proxy reads the bytes and uploads them with the credential it already holds. You do NOT " +
|
|
135
359
|
"need an API key for this and must not go looking for one — the whole point is that the " +
|
|
136
360
|
`key stays in the proxy. Readable paths are confined to ${root} (relative paths resolve ` +
|
|
137
|
-
"against it, symlinks out of it are refused,
|
|
138
|
-
"
|
|
361
|
+
"against it, symlinks out of it are refused, and so is any hidden file or directory, any " +
|
|
362
|
+
"hard-linked file, and anything retasc keeps credentials in). For a plain URL rather than " +
|
|
363
|
+
"a file, use save_attachment instead.",
|
|
139
364
|
inputSchema: {
|
|
140
365
|
type: "object",
|
|
141
366
|
properties: {
|
package/dist/lib/harness.js
CHANGED
|
@@ -460,9 +460,18 @@ function addCommandHarness(opts) {
|
|
|
460
460
|
};
|
|
461
461
|
}
|
|
462
462
|
/** `~/.config/opencode/`, XDG-aware — OpenCode honours XDG_CONFIG_HOME, so resolving it
|
|
463
|
-
* to `~/.config` unconditionally would name the wrong file in the receipt.
|
|
463
|
+
* to `~/.config` unconditionally would name the wrong file in the receipt.
|
|
464
|
+
*
|
|
465
|
+
* `RETASC_HOME` outranks XDG_CONFIG_HOME, which is the whole point of the override and the
|
|
466
|
+
* one thing this function used to get wrong: reading the environment first meant the escape
|
|
467
|
+
* hatch was ignored on every machine that sets XDG_CONFIG_HOME, so a test run wrote into the
|
|
468
|
+
* developer's REAL `~/.config/opencode/` — the exact accident `home()` exists to prevent, and
|
|
469
|
+
* invisible on macOS, which does not set the variable. Take the override first, then the
|
|
470
|
+
* environment, then the default. */
|
|
464
471
|
function opencodeConfig() {
|
|
465
|
-
const xdg = process.env.
|
|
472
|
+
const xdg = process.env.RETASC_HOME
|
|
473
|
+
? join(home(), ".config")
|
|
474
|
+
: process.env.XDG_CONFIG_HOME || join(home(), ".config");
|
|
466
475
|
return join(xdg, "opencode", "opencode.jsonc");
|
|
467
476
|
}
|
|
468
477
|
/**
|
package/dist/proxy.js
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
// reclaims the lease (the correct default) — a broken watchdog is never worse than
|
|
7
7
|
// no watchdog. Self-enforcing: no proxy → no Retasc tools → can't orphan a lease.
|
|
8
8
|
import { createInterface } from "node:readline";
|
|
9
|
-
import { readFile } from "node:fs/promises";
|
|
10
9
|
import { hostname } from "node:os";
|
|
11
10
|
import { spawn, spawnSync } from "node:child_process";
|
|
12
11
|
import { dirname, resolve } from "node:path";
|
|
@@ -15,7 +14,7 @@ import { applyObservation, heartbeatRequest, isClaimLost, isUnauthorized, should
|
|
|
15
14
|
import { AUTO_WORKSPACE, resolveConn } from "./lib/keystore.js";
|
|
16
15
|
import { toolResult as parseTool } from "./lib/toolresult.js";
|
|
17
16
|
import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
|
|
18
|
-
import { attachRoot, isLocalAttachCall, mergeAttachTool, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
|
|
17
|
+
import { attachRoot, isLocalAttachCall, mergeAttachTool, readAttachFile, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
|
|
19
18
|
import { MAX_FETCH_BYTES, downloadFailureMessage, existingDownload, isLocalFetchCall, mergeFetchTool, resolveDownloadTarget, writeDownloadedFile, } from "./lib/fetchFile.js";
|
|
20
19
|
// RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
|
|
21
20
|
// the direct commands (claim/tidy/done) can never diverge. The proxy carries its
|
|
@@ -255,7 +254,16 @@ async function handleLocalAttach(msg) {
|
|
|
255
254
|
return replyToolResult(msg.id, `could not prepare an upload for ${issue}: ${detail}`, true);
|
|
256
255
|
}
|
|
257
256
|
const title = typeof args.title === "string" ? args.title : undefined;
|
|
258
|
-
|
|
257
|
+
// Open AFTER the round trip, and re-prove the inode: the path was resolved before we went to
|
|
258
|
+
// the network, and reading it by path again here would follow whatever it points at now
|
|
259
|
+
// (RTSC-801). `readAttachFile` opens O_NOFOLLOW and reads from the descriptor.
|
|
260
|
+
const read = readAttachFile(resolved);
|
|
261
|
+
if (!read.ok) {
|
|
262
|
+
log(`refused attachment for ${issue}: ${read.error}`);
|
|
263
|
+
return replyToolResult(msg.id, read.error, true);
|
|
264
|
+
}
|
|
265
|
+
const bytes = read.bytes;
|
|
266
|
+
log(`attaching ${resolved.path} (${bytes.length} bytes) to ${issue}`);
|
|
259
267
|
try {
|
|
260
268
|
const res = await fetch(uploadUrlWith(uploadUrl, resolved.filename, title), {
|
|
261
269
|
method: "POST",
|
|
@@ -263,7 +271,7 @@ async function handleLocalAttach(msg) {
|
|
|
263
271
|
// is better than anything guessed here — declaring octet-stream would REPLACE it and
|
|
264
272
|
// cost the attachment its inline preview.
|
|
265
273
|
headers: { Authorization: `Bearer ${activeKey}` },
|
|
266
|
-
body:
|
|
274
|
+
body: bytes,
|
|
267
275
|
});
|
|
268
276
|
const body = await res.text();
|
|
269
277
|
if (!res.ok) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.39.
|
|
3
|
+
"version": "1.39.3",
|
|
4
4
|
"description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|