Skip to content

Commit a67bbb0

Browse files
Hotragndavidmckayv
andauthored
Resolve the name a write lands on, not only the directory above it (#74)
* Resolve the name a write lands on, not only the directory above it `resolvePath` resolved `dirname(target)` for a write and handed back the lexical target, so a symlink at the last component was followed by `writeFile`. A read through the identical link was already refused; the write side was the asymmetry. A link at `notes.txt` pointing outside has no `..`, is not absolute, and sits directly in the workspace, so it passed all three layers and the bytes landed outside the volume. The walk uses `lstat` and `readlink` rather than `realpath`, because `realpath` throws on a dangling link while `writeFile` creates its destination regardless, so that shape escaped through the failure path rather than the success one. Hops are bounded, so a cycle is refused instead of surfacing an `ELOOP` from the write. Confining rather than forbidding, as on the read side: a link pointing back inside the workspace keeps working. The escape is not the main cost, because a Bot holding `run_command` can write outside directly. The cost is that the gateway decides and writes the audit row in another process, from the path as it was asked for, so a rule written for `credentials/` never sees the file that is written and the trail names a file nothing touched. * Resolve where the link lands before checking it, not after Reapplying this after a rebase dropped it. The leaf walk checked the raw destination first and canonicalised the directory holding it second. `root` is itself a real path, so a destination that still runs through a symlinked ancestor fails the lexical comparison even when it points straight back into the workspace. Anywhere /tmp is a link to /private/tmp, which is every macOS machine and no Linux CI runner, a legitimate in-workspace link is refused, and that asymmetry is why this branch's own "points back inside still works" test passes in CI and fails on a developer's machine. Resolving the holder first fixes it, and the destination is rebuilt from the resolved directory so what the function returns is the path that will actually be written rather than the one that was asked for. It failed safe either way, so this was a correctness bug rather than a hole. --------- Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
1 parent 0a55adc commit a67bbb0

3 files changed

Lines changed: 178 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,13 @@ Sessions survive and nobody signs in again.
159159
access and refresh tokens use Better Auth's own encryption, keyed on `BETTER_AUTH_SECRET`.
160160
- **A failed provider registration looked like a button that did not work.** The error was rendered
161161
on the page behind the dialog, which was covering it.
162+
- **A write could follow a symlink out of the Bot's workspace.** The confinement resolved the
163+
directory a write would land in but not the name it would land on, so a link left at `notes.txt`
164+
pointing outside was followed by the write; a read through the identical link was already refused.
165+
The gateway had already decided and written the audit row against the path as it was asked for, so a
166+
rule written for `credentials/` never saw the file that was written and the trail named a file
167+
nothing had touched. A dangling link escaped the same way, because resolving the path throws where
168+
the write would still land. Links pointing back inside the workspace continue to work.
162169
- **A Bot could become root inside its container.** `sudo` was granted as `NOPASSWD: ALL`, and the
163170
comment above it named the two conditions that made that acceptable: the container being one Bot's
164171
alone, and not holding a database. The image meets neither, because the supervisor is deliberately

agent-computer/src/workspace.ts

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,32 @@
1414
* 3. The resolved path must still be inside the root after symlinks are followed. This is the layer
1515
* people miss: a symlink placed inside the workspace (by an earlier write, or by a page the Bot
1616
* downloaded something from) passes the lexical check and then points anywhere on the filesystem.
17-
* For a write, the file may not exist yet, so it is the deepest existing ancestor that gets
18-
* resolved, which is the directory the write will actually land in.
17+
* For a write, the file may not exist yet, so the deepest existing ancestor gets resolved, which
18+
* is the directory the write will land in, AND the name itself is resolved when something is
19+
* already there, because `writeFile` follows a link at the last component too.
1920
*
2021
* A factory taking its root as an argument rather than reading the environment, so the confinement
2122
* can be tested against a temporary directory instead of being taken on trust.
2223
*/
2324
import {
25+
lstat,
2426
mkdir,
2527
readdir,
2628
readFile,
29+
readlink,
2730
realpath,
2831
stat,
2932
writeFile,
3033
} from "node:fs/promises";
31-
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
34+
import {
35+
basename,
36+
dirname,
37+
isAbsolute,
38+
join,
39+
relative,
40+
resolve,
41+
sep,
42+
} from "node:path";
3243

3344
export class WorkspacePathError extends Error {
3445
constructor(message: string) {
@@ -131,10 +142,48 @@ export function createWorkspace(
131142
}
132143
assertInside(root, realAnchor, wanted);
133144

134-
// For a write, return the full lexical target. It is already proven contained lexically, and the
135-
// deepest existing directory is proven contained after symlinks, so `mkdir -p` can only create the
136-
// rest inside the workspace.
137-
return forWrite ? target : realAnchor;
145+
if (!forWrite) return realAnchor;
146+
147+
/*
148+
* Layer three again, for the last component rather than the directory holding it.
149+
*
150+
* Containing `dirname(target)` proves where a NEW file would be created. It proves nothing about
151+
* a name that already exists, and `writeFile` follows a symlink at the last component the same
152+
* way `readFile` does. A link at `notes.txt` pointing at `/root/.ssh/authorized_keys` passes
153+
* every check above, having no `..`, not being absolute, and sitting directly in the workspace,
154+
* and the bytes land outside the volume. The read side already refuses the identical link; the
155+
* write side was the asymmetry.
156+
*
157+
* The link has to get there first, which takes a shell or an archive that was unpacked with one,
158+
* so this is not a fresh escape for a Bot that already has `run_command`: that Bot can write
159+
* outside directly. What it is, is a hole in what the gateway can still see. The decision and the
160+
* audit row are both made against the path as the Bot asked for it, so a rule written for
161+
* `credentials/` or `*.env` is evaluated against `notes.txt` and never sees the file that gets
162+
* written, and the row names a file in the workspace that nothing touched. A deployment that
163+
* denies `run_command` and allows writes is relying on exactly that, and so is one reading the
164+
* trail afterwards. A permissive workspace is a decision a deployment can make. A trail that
165+
* describes a different file from the one on disk is not.
166+
*/
167+
const landing = await writeDestination(target, wanted);
168+
if (landing === target) return target;
169+
170+
// A link was followed, so the destination gets the checks the requested path already passed:
171+
// inside lexically, and inside after the directory holding it is resolved.
172+
/*
173+
* The holder is resolved BEFORE either check. `root` is a real path, so comparing it against a
174+
* destination that still runs through a symlinked ancestor refuses a link that points straight
175+
* back inside, which is what happens wherever the workspace sits behind one.
176+
*/
177+
const holder = await realpath(dirname(landing)).catch(() => null);
178+
if (holder === null) {
179+
throw new WorkspacePathError(
180+
`${wanted} points at somewhere that does not exist, so where a write would land cannot be established.`,
181+
);
182+
}
183+
assertInside(root, holder, wanted);
184+
const resolved = join(holder, basename(landing));
185+
assertInside(root, resolved, wanted);
186+
return resolved;
138187
}
139188

140189
return {
@@ -277,6 +326,45 @@ function assertInside(root: string, candidate: string, shown?: string): void {
277326
}
278327
}
279328

329+
/**
330+
* How many links a chain may pass through before it is treated as a cycle rather than a path.
331+
*
332+
* Linux gives up at 40. Anything approaching this is a loop or a deliberate attempt to make the walk
333+
* expensive, and neither is a file a Bot needs to write.
334+
*/
335+
const MAX_LINK_HOPS = 32;
336+
337+
/**
338+
* Where a write to `target` would actually put the bytes.
339+
*
340+
* Returns `target` unchanged when nothing is there or what is there is not a link, which is every
341+
* ordinary write. Only a name that is already a symlink walks.
342+
*
343+
* Walked with `lstat` and `readlink` rather than resolved with `realpath`, because `realpath` throws
344+
* on a DANGLING link and `writeFile` creates the file at its destination regardless. A link aimed at
345+
* a name that does not exist yet would otherwise escape through the failure path rather than the
346+
* success one, which is the harder version of the bug to notice.
347+
*
348+
* Confining rather than forbidding, the same as the read side. A link that points back inside the
349+
* workspace keeps working: refusing every link would be easier and would break legitimate use.
350+
*/
351+
async function writeDestination(
352+
target: string,
353+
shown: string,
354+
): Promise<string> {
355+
let current = target;
356+
for (let hop = 0; hop <= MAX_LINK_HOPS; hop += 1) {
357+
const entry = await lstat(current).catch(() => null);
358+
// Nothing there, or something that is not a link. This is where the write lands.
359+
if (entry === null || !entry.isSymbolicLink()) return current;
360+
// A relative link is relative to the directory the link sits in, not to the workspace root.
361+
current = resolve(dirname(current), await readlink(current));
362+
}
363+
throw new WorkspacePathError(
364+
`${shown} is a chain of links that does not settle, so where a write would land cannot be established.`,
365+
);
366+
}
367+
280368
/** The closest ancestor of `target` that exists, never above `root`. */
281369
async function nearestExistingAncestor(
282370
root: string,

agent-computer/tests/workspace.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2-
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
2+
import {
3+
mkdir,
4+
mkdtemp,
5+
readFile,
6+
rm,
7+
symlink,
8+
writeFile,
9+
} from "node:fs/promises";
310
import { tmpdir } from "node:os";
411
import { join } from "node:path";
512
import {
@@ -206,6 +213,74 @@ describe("escaping the workspace", () => {
206213
);
207214
});
208215

216+
test("refuses to write THROUGH a symlinked FILE that points outside", async () => {
217+
// The asymmetry between the two tests above. Resolving `dirname` catches a link standing in for a
218+
// directory; a link standing in for the FILE has the workspace as its parent and passes, and then
219+
// `writeFile` follows it. Refusing is only half of what this asserts: the file outside has to be
220+
// untouched afterwards, because an error thrown after the bytes landed would still be an escape.
221+
const secret = join(outside, "secret.txt");
222+
await symlink(secret, join(root, "notes.txt"));
223+
await expect(workspace().write("notes.txt", "owned")).rejects.toThrow(
224+
WorkspacePathError,
225+
);
226+
expect(await readFile(secret, "utf8")).toBe("a private key");
227+
});
228+
229+
test("refuses to append THROUGH a symlinked file that points outside", async () => {
230+
// `append` is a separate flag reaching a separate `writeFile` mode, so it is a separate way in.
231+
const secret = join(outside, "secret.txt");
232+
await symlink(secret, join(root, "log.txt"));
233+
await expect(
234+
workspace().write("log.txt", "owned", { append: true }),
235+
).rejects.toThrow(WorkspacePathError);
236+
expect(await readFile(secret, "utf8")).toBe("a private key");
237+
});
238+
239+
test("refuses to write through a DANGLING link that points outside", async () => {
240+
// The harder half. `realpath` throws on a link whose destination does not exist, so a check built
241+
// on it treats this as "no such file" and lets the write through the failure path, while
242+
// `writeFile` creates the destination regardless. Nothing exists here to prove the escape with,
243+
// so the assertion is that the file was never created outside.
244+
const notThere = join(outside, "planted.txt");
245+
await symlink(notThere, join(root, "fresh.txt"));
246+
await expect(workspace().write("fresh.txt", "owned")).rejects.toThrow(
247+
WorkspacePathError,
248+
);
249+
await expect(readFile(notThere, "utf8")).rejects.toThrow();
250+
});
251+
252+
test("refuses a chain of links that ends up outside", async () => {
253+
// One hop is the obvious case and the only one a single `readlink` would catch.
254+
await symlink(join(outside, "secret.txt"), join(root, "second.txt"));
255+
await symlink(join(root, "second.txt"), join(root, "first.txt"));
256+
await expect(workspace().write("first.txt", "owned")).rejects.toThrow(
257+
WorkspacePathError,
258+
);
259+
expect(await readFile(join(outside, "secret.txt"), "utf8")).toBe(
260+
"a private key",
261+
);
262+
});
263+
264+
test("refuses a cycle of links rather than following it forever", async () => {
265+
// Two links pointing at each other never reach something that is not a link. The walk has to stop
266+
// on its own and say why, instead of spinning or surfacing an ELOOP from the write.
267+
await symlink(join(root, "b.txt"), join(root, "a.txt"));
268+
await symlink(join(root, "a.txt"), join(root, "b.txt"));
269+
await expect(workspace().write("a.txt", "owned")).rejects.toThrow(
270+
WorkspacePathError,
271+
);
272+
});
273+
274+
test("writing THROUGH a link that points back inside still works", async () => {
275+
// Confining, not forbidding, on the write side too. Refusing every link would pass the tests
276+
// above and quietly break a Bot that keeps `latest.csv` pointing at the newest report.
277+
const ws = workspace();
278+
await ws.write("real/data.txt", "before");
279+
await symlink(join(root, "real/data.txt"), join(root, "alias.txt"));
280+
await ws.write("alias.txt", "after");
281+
expect((await ws.read("real/data.txt")).text).toBe("after");
282+
});
283+
209284
test("a symlink pointing back INSIDE the workspace still works", async () => {
210285
// The guard must confine, not merely forbid symlinks: refusing every link would be easier and
211286
// would break legitimate use.

0 commit comments

Comments
 (0)