@retasc/cli 1.39.1 → 1.39.4

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 CHANGED
@@ -6,6 +6,74 @@ 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.4 (2026-09-03)
10
+
11
+ - **RTSC-810** — `retasc unbind` revokes the key it says it revokes. It had two faults in
12
+ one lookup, and each on its own was enough to leave the credential live: it destructured
13
+ `{ keys }` off `listKeys`, which returns a bare array, and it derived the folder's key
14
+ prefix with a 12-character slice while the server stores 14. An exact compare between a
15
+ 12-character string and a 14-character one is never true, so every run since `unbind`
16
+ shipped (RTSC-721) printed "not found server-side (already revoked, or the org is
17
+ gone)" and moved on. The length now mirrors the server's own `displayPrefixOf`, and the
18
+ test reads that file, so the two cannot drift apart again. A session child key can no
19
+ longer be the one revoked either: those are minted in memory by the proxy and never
20
+ reach the keystore, so a match on one would mean revoking a key this folder does not own.
21
+ - **RTSC-810** — `retasc bind --org-id X --project-id Y` learns the project's prefix. The
22
+ provisioning form skipped every branch that looks a project up, so nothing knew the
23
+ prefix: the key went out nameless, the keystore entry was written without `prefix` or
24
+ `orgName`, and the receipt card printed an empty one. It now resolves the project the
25
+ same way the interactive pickers do.
26
+ - **RTSC-810** — a key is named after the folder it was bound in, not after the project.
27
+ The Keys list is the folder map (`client-a` → `ENG`), which is what tells you which
28
+ machine a credential belongs to; `ENG key` on every row told nobody anything. Naming is
29
+ the server's job now, so no door can store a nameless key: the Dash mint form with the
30
+ name left blank and `retasc key mint` without `--name` both fall back to the project's
31
+ own name rather than leaving the Dash to print "Unnamed key".
32
+
33
+ ## 1.39.3 (2026-09-02)
34
+
35
+ - **RTSC-801** — (security) `save_attachment_file` no longer reads a workspace's own secrets,
36
+ and no longer follows a file swapped in after it was checked. The proxy reads attachments
37
+ on the agent's behalf, which is what keeps the API key away from the model, and the cost of
38
+ that is a read the harness never prompts on: whatever this path allows, an agent can upload
39
+ to an issue every org member can read. Containment to the attach root was doing all the work
40
+ and it was never the boundary it looked like, because the files worth stealing are not
41
+ outside a workspace, they are in it. `.env.local`, a legacy inline-key `.mcp.json` and
42
+ `.retasc/` all live inside a normal root, so a prompt-injected "attach the config" was
43
+ confined to exactly the secrets. Anything hidden below the root is now refused, dotfiles and
44
+ dot-directories alike, along with `secrets.md` and, wherever `RETASC_DIR` puts it, the
45
+ keystore itself. A root that contains your home directory (`~` or `/`, both previously
46
+ accepted) is refused outright rather than carved down, since it makes every credential on
47
+ the machine attachable, including every other workspace's key. Those rules all judge a name,
48
+ so a file with more than one hard link is refused too: path resolution follows symlinks but
49
+ not links, and `ln .env.local notes.txt` would otherwise hand the check a name it has no
50
+ reason to refuse while the bytes stay the same. The one carve-out is `.retasc/attachments/`,
51
+ which is where the proxy writes files fetched by `get_attachment_file`: refusing that would
52
+ be the proxy declining to read a file the proxy itself wrote, and downloading from one issue
53
+ to attach to another is a real thing agents do. Anything hidden deeper inside it is still
54
+ refused.
55
+ The second half is a race: the path was resolved and stat'd, a full
56
+ `prepare_attachment_upload` round trip went to the server, and only then was the file opened
57
+ by path, so a `ln -sf` in that window was followed on the read. The read now opens the file
58
+ `O_NOFOLLOW` and non-blocking, and proceeds only if the descriptor's device, inode and size
59
+ are the ones that passed the check. Opening non-blocking is not a detail: a `mkfifo` in that
60
+ same window would otherwise have blocked the proxy's event loop outright, stopping every
61
+ heartbeat it owes and stranding every lease it holds.
62
+ Legitimate attachments, screenshots, logs and documents, are unaffected: none of them are
63
+ hidden files.
64
+
65
+ ## 1.39.2 (2026-09-02)
66
+
67
+ - **RTSC-808** — `retasc setup` no longer writes OpenCode's config into your real home when
68
+ `RETASC_HOME` says otherwise. That variable exists so anything exercising the harness module
69
+ cannot reconfigure the machine it runs on, and every other harness honours it; OpenCode read
70
+ `XDG_CONFIG_HOME` first and so ignored the override wherever that variable is set. The
71
+ precedence is now override, then environment, then default, and `XDG_CONFIG_HOME` is still
72
+ honoured when no override is in play, because OpenCode really does read it.
73
+ This is also why CI had been red on `main` for five consecutive merges. macOS does not set
74
+ `XDG_CONFIG_HOME` and GitHub's Linux runners do, so the test guarding this passed on every
75
+ developer machine and failed on every CI run, and no PR in the repo could show a green build.
76
+
9
77
  ## 1.39.1 (2026-09-02)
10
78
 
11
79
  - **RTSC-800** — (security) a keystore-resolved key now travels only to the keystore's own
@@ -455,6 +455,20 @@ export async function completeWorkspaceSetup(args) {
455
455
  throw new Error("no project selected — pass --project-id <id>, or --project <name> --prefix <PFX>.");
456
456
  }
457
457
  }
458
+ // RTSC-810 — a `--project-id` skipped every branch above, so nothing here knew the
459
+ // project's prefix: the key went out nameless (`keyName` was built from `prefix`), the
460
+ // keystore entry was written without `prefix`/`orgName`, and the receipt card printed
461
+ // an empty one. The provisioning form is documented and supported, so it fills the
462
+ // gap the way the pickers do: one list call. Best-effort on the match — a scoped
463
+ // member may hold an id the list does not show, and that folder still has to bind.
464
+ if (projectId && !prefix) {
465
+ const { projects } = (await api.listProjects({ orgId }));
466
+ const hit = (projects ?? []).find((p) => p.id === projectId);
467
+ if (hit) {
468
+ prefix = hit.prefix;
469
+ emptyProject = projectIsEmpty(hit);
470
+ }
471
+ }
458
472
  // --- make `retasc` durable BEFORE anything is committed (RTSC-493) ---------
459
473
  // The marker names a command something else spawns on every agent start, so it has to
460
474
  // name one proved to run on this machine. Resolved (and announced) here so the install
@@ -501,7 +515,12 @@ export async function completeWorkspaceSetup(args) {
501
515
  projectId: projectId,
502
516
  agentName: opts.agent,
503
517
  runtime: opts.runtime ?? "claude-code",
504
- keyName: prefix ? `${prefix} key` : undefined,
518
+ // RTSC-810 named after the FOLDER, the leaf only, exactly as the setup-token
519
+ // door has done since RTSC-532: the Keys list is the folder map (`client-a →
520
+ // ENG`), and the old prefix-plus-"key" name told nobody which folder held it.
521
+ // The server cleans the name and falls back to the project's own when the
522
+ // leaf is empty.
523
+ keyName: basename(cwd),
505
524
  }));
506
525
  // The key is named in the receipt card below, not here — one mention, in the place
507
526
  // that says where it went (RTSC-673).
@@ -27,6 +27,35 @@ import { removeProjectMarker, tryClaudeCliRemove } from "./mcp.js";
27
27
  * • **The session is left alone.** Signing out is `logout`'s job; conflating the two
28
28
  * turns "detach this folder" into "log me out everywhere", which nobody asked.
29
29
  */
30
+ /**
31
+ * The prefix the SERVER stored for a raw key — `displayPrefixOf` in `convex/lib/keys.ts`,
32
+ * mirrored here because the CLI cannot import backend code.
33
+ *
34
+ * RTSC-810: this length is the join `unbind` revokes on, and it was wrong. The local side
35
+ * sliced 12 while the server stores 14, so the exact compare was never true once, and
36
+ * `unbind` reported "not found server-side" on every run while leaving the key LIVE —
37
+ * the same outcome as the `{ keys }` destructure below, from the other half of the same
38
+ * lookup. A change to the server's slice has to change this one; `unbindRevoke810.test.mjs`
39
+ * reads `convex/lib/keys.ts` and fails if the two ever disagree again.
40
+ */
41
+ export const DISPLAY_PREFIX_LEN = 14;
42
+ export function displayPrefixOf(key) {
43
+ return key.slice(0, DISPLAY_PREFIX_LEN);
44
+ }
45
+ /**
46
+ * This folder's live key among the org's, by display prefix — the join `unbind` revokes
47
+ * on. Takes the ARRAY `listKeys` returns; anything else is "nothing to revoke", never a
48
+ * throw, because the caller's fallback is to name the Dash rather than to fail.
49
+ *
50
+ * WORKSPACE keys only: a session child (`parentKeyId` set) is minted in memory by the
51
+ * proxy and never reaches the keystore, so one matching here would mean revoking a key
52
+ * this folder does not own.
53
+ */
54
+ export function liveKeyFor(keys, prefix) {
55
+ if (!Array.isArray(keys) || !prefix)
56
+ return undefined;
57
+ return keys.find((k) => k?.displayPrefix === prefix && !k.revokedAt && !k.parentKeyId && typeof k.id === "string");
58
+ }
30
59
  export async function unbindAction(opts) {
31
60
  const cwd = process.cwd();
32
61
  const existing = readLocalBinding(cwd);
@@ -35,7 +64,7 @@ export async function unbindAction(opts) {
35
64
  return;
36
65
  }
37
66
  const entry = existing.workspaceId ? getBinding(existing.workspaceId) : undefined;
38
- const keyPrefix = (entry?.key ?? existing.key ?? "").slice(0, 12);
67
+ const keyPrefix = displayPrefixOf(entry?.key ?? existing.key ?? "");
39
68
  console.log(`This will disconnect ${cwd} from Retasc:`);
40
69
  if (entry?.orgName || entry?.prefix) {
41
70
  console.log(` bound to: ${entry.orgName ?? entry.orgId} / ${entry.prefix ?? entry.projectId}`);
@@ -58,14 +87,18 @@ export async function unbindAction(opts) {
58
87
  return;
59
88
  }
60
89
  // Revoke FIRST, while the keystore still holds what identifies the key. displayPrefix
61
- // is how the server names keys (the raw value is never stored there), so the first 12
62
- // chars of ours is the join.
90
+ // is how the server names keys (the raw value is never stored there), so the first
91
+ // DISPLAY_PREFIX_LEN chars of ours is the join — the server's own slice, not a shorter
92
+ // guess at it (RTSC-810).
63
93
  if (entry?.orgId && keyPrefix && isLoggedIn()) {
64
94
  try {
65
- const { keys } = (await api.listKeys({ orgId: entry.orgId }));
66
- const mine = (keys ?? []).find((k) => k.displayPrefix === keyPrefix && !k.revokedAt);
95
+ // RTSC-810 `listKeys` returns a bare ARRAY (as `retasc key list` has always
96
+ // read it). This destructured `{ keys }` off it, got `undefined`, and so every
97
+ // unbind since RTSC-721 printed "not found server-side" and left the key LIVE —
98
+ // the exact half of hand-editing the command exists to stop.
99
+ const mine = liveKeyFor(await api.listKeys({ orgId: entry.orgId }), keyPrefix);
67
100
  if (mine) {
68
- await api.revokeKey({ keyId: mine.id ?? mine._id });
101
+ await api.revokeKey({ keyId: mine.id });
69
102
  console.log(`✓ Revoked key ${keyPrefix}… server-side.`);
70
103
  }
71
104
  else {
@@ -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 { basename, resolve, sep } from "node:path";
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 !== root && !real.startsWith(prefix)) {
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
- if (real.split(sep).includes(".git")) {
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, as is anything under .git). For a plain URL " +
138
- "rather than a file, use save_attachment instead.",
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: {
@@ -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.XDG_CONFIG_HOME || join(home(), ".config");
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
- log(`attaching ${resolved.path} (${resolved.size} bytes) to ${issue}`);
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: await readFile(resolved.path),
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.1",
3
+ "version": "1.39.4",
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": {