fix(files): fail closed on remote symlinks, guard PUT for remote cases, bound ssh fan-out

Follow-up to #421 (remote-case file reads over ssh), addressing the review.

Symlink escape on a host without `readlink -f` (blocker). The probe's
portable fallback canonicalized only the directory chain and returned the
final component unresolved, so on macOS < 12.3 `ws/notes.txt -> ~/.ssh/id_rsa`
came back as `.../ws/notes.txt` (with the target's size), passed every
containment and blocklist check that runs on `realPath`, and `cat` followed
the link. The fallback now walks the directory chain with `cd -P`/`pwd -P`
and follows the LAST component with plain `readlink` for a bounded number of
hops, and anything it cannot fully resolve (a loop, a readlink failure, the
hop cap) is reported with an `x` marker that parses as null, i.e. 404. It
never returns the unresolved string. Measured on a real /bin/sh with
`readlink -f` shadowed: the pre-fix script reports `/ws/notes.txt`, the fixed
one `/secret/id_rsa`; both branches (native and fallback) now agree.

`PUT /api/sessions/:id/file-content` never had the remote guard the PR
described. It sits ahead of `validateSessionFilePath`, which resolves against
the LOCAL filesystem, because with a same-named directory on the Codeman host
(an sshfs mount of the remote tree, the documented stop-gap) the write landed
on the local twin while the viewer believed it edited the remote file.

ssh fan-out is bounded. `src/remote-ssh-limiter.ts` is a
document-conversion-limiter-shaped semaphore (default 4, env
`CODEMAN_MAX_REMOTE_FILE_SSH`) around every probe and buffered read; the
attachment-history list resolves its whole history in ONE batched probe
(`probeRemoteAttachmentHistory`, threaded into
`registerExternalAttachment({remoteProbes})` so the guards run unchanged)
instead of one handshake per entry; and probes chunk at 40 paths because the
whole script is one argv string. Terminal output in a remote session is
written on the remote host, so a prompt-injected agent printing hundreds of
`codeman://attach` links forked one ssh per link, each holding a 20 s
timeout, and a 100-entry history re-listed on every attachment:detected
tripped OpenSSH's default MaxStartups. Streams are deliberately not counted
(one per browser request, held for a whole playback, and gated behind a
counted probe anyway).

Smaller items from the same review: probe records are NUL-terminated and
index-keyed after a leading NUL (a newline in a filename can no longer shift
the alignment, and the banner is fenced off without last-N-lines guessing);
size comes from `stat -c %s || stat -f %z`; the three IO functions refuse
under VITEST instead of opening a connection; an unreachable host now reads
as unknown (missing: false) for detected AND external history entries, where
external used to fold its 502 into missing; a client that aborted during the
guard probe has its body's ssh child reaped (`reply.raw.destroyed` is checked
before the close listener is attached); `describeExecError` never returns
Node's `Command failed: <ssh line>` message, which carried the identity path
and the probe script into a 502 body; and the docs note that
`isSensitivePath`'s three home-anchored entries resolve against the Codeman
host's home, not the remote one.

Tests: the probe script runs on a real /bin/sh with a `readlink` shim that
rejects `-f` (the escape, a relative chain through a symlinked directory, a
loop, a newline filename, banner chatter that itself looks like a record),
the limiter's cap and FIFO order, and route tests for the PUT guard (local
twin untouched, no connection), the single batched history probe, the
unreachable-host alignment and the aborted-client reap. All four route tests
fail against the pre-fix file-routes.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-14 23:42:06 +02:00
parent 792a251e35
commit e49c48145b
10 changed files with 815 additions and 114 deletions
+31 -12
View File
@@ -13,7 +13,7 @@ import { basename, extname, isAbsolute } from 'node:path';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
import { validateSessionFilePath } from './web/route-helpers.js';
import { remoteProbePaths, RemoteFileAccessError } from './remote-files.js';
import { remoteProbePaths, RemoteFileAccessError, type RemoteProbe } from './remote-files.js';
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
import type { SessionRemote } from './types/session.js';
@@ -228,6 +228,13 @@ export interface RegisterExternalAttachmentOptions {
* so a symlinked `remotePath` does not refuse every registration.
*/
remote?: SessionRemote;
/**
* Remote only: `[file, workspaceRoot]` probes a caller already resolved in a BATCHED
* `remoteProbePaths` call (the attachment-history list does one round trip for the
* whole history). Skips this registration's own ssh probe; every guard below still
* runs on the same resolved path it would have produced itself.
*/
remoteProbes?: readonly [RemoteProbe | null, RemoteProbe | null];
}
/**
@@ -277,17 +284,24 @@ async function resolveLocalAttachment(requestedPath: string): Promise<ResolvedAt
async function resolveRemoteAttachment(
requestedPath: string,
remote: SessionRemote,
sessionWorkingDir?: string
sessionWorkingDir?: string,
preResolved?: readonly [RemoteProbe | null, RemoteProbe | null]
): Promise<ResolvedAttachmentFile> {
const paths = sessionWorkingDir ? [requestedPath, sessionWorkingDir] : [requestedPath];
let probes;
try {
probes = await remoteProbePaths(remote, paths);
} catch (err) {
throw new AttachmentRegistrationError(
err instanceof RemoteFileAccessError ? err.message : 'remote host unreachable',
502
);
let probes: ReadonlyArray<RemoteProbe | null>;
if (preResolved) {
probes = preResolved;
} else {
try {
probes = await remoteProbePaths(remote, paths);
} catch (err) {
// 502 marks the TRANSPORT as the failure, distinct from the file's own 404/403,
// so a history listing can report the entry as unknown rather than missing.
throw new AttachmentRegistrationError(
err instanceof RemoteFileAccessError ? err.message : 'remote host unreachable',
502
);
}
}
const [probe, rootProbe] = probes;
@@ -315,7 +329,7 @@ export async function registerExternalAttachment(
}
const resolved = await (options.remote
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir)
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes)
: resolveLocalAttachment(requestedPath));
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
@@ -343,7 +357,12 @@ export async function registerExternalAttachment(
// codeman-publish and the ~/.codeman review loop keep working.
//
// The list is a pattern list over ABSOLUTE paths, so it is host-agnostic and holds
// for a remote path exactly as it does for a local one.
// for a remote path exactly as it does for a local one, with ONE exception worth
// knowing: `isSensitivePath`'s three home-anchored members (`~/.claude.json`,
// `~/.claude/settings.json`, `~/.claude/settings.local.json`) resolve against THIS
// host's `homedir()`, so on a remote host with a different home they do not match.
// Everything else in that list is depth-anchored (`/.ssh/`, `/.aws/credentials`,
// `/.claude/.credentials.json`, ...) and applies unchanged.
if (isBlockedAttachmentPath(resolved.resolvedPath, guard.blockedTrees)) {
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
}