Skip to content

Commit da254e7

Browse files
saravmajesticclaude
andcommitted
fix(workspace): harden the pin against the review findings
Addresses the consensus round-up and the three automated reviews on #1320. Security: - `withinRoot` delegates to `Filesystem.containsReal` instead of comparing `realpathSync` output with a LEXICAL fallback. The fallback was a real bypass: a not-yet-created path under a symlinked ancestor failed to resolve, fell back to the raw string, and passed the prefix test — enough to attribute an outside project's skills and memory to the pinned workspace, via the caller-supplied `x-opencode-directory` on an unsecured server. - `readPin` distinguishes missing keys from empty values, so three present-but-empty variables fail closed instead of reading as "no pin". - The validation memo is keyed on a digest of the API key as well as the tenant, so a credential switch inside one tenant no longer inherits the previous principal's authorization for the TTL. - API failures are classified by ERROR TYPE, not by a `status` property. `api-client` throws `ForbiddenError`/`NotFoundError`/`NotConfiguredError` as plain named errors carrying no status, so the first attempt at this sorted a real 403 into the transient bucket and granted it the offline grace — the opposite of the intent. An unclassifiable error now counts as NOT transient: for a check gating authorization, the default has to be fail-closed. - The offline grace is bounded (30 min), so an endpoint that fails forever cannot hand out an unbounded licence. - `tool/bash.ts` strips the three pin variables from spawned children alongside `ALTIMATE_CODE_SERVE`, so a nested `serve` cannot inherit a pin its session was never given. Correctness and cost: - The offline fallback keeps the server-confirmed name instead of regressing to the environment's, which can be stale after a rename. - `resolveProjectIdentifier` is cached per directory. It runs `spawnSync("git", …)` with a 3s timeout, and `resolveBindingOutcome` is reached per turn AND per memory write, so it was a synchronous subprocess on both hot paths. - One credentials read rather than two; two paths previously logged an identical line, so the message could not say which had refused. Tests: - Symlink containment: an existing symlinked escape, a not-yet-existing descendant under one, a genuine not-yet-existing descendant (which must still be allowed), and a `..` escape. - An injectable clock, so the stale-on-error branches are actually crossed. The previous test re-resolved while the memo was still fresh and would have passed with that branch deleted. - Real `ForbiddenError` / `WorkspaceApiError` instead of synthetic errors with a hand-set `status`, which tested the mock rather than the client. - Pin environment variables are snapshotted and restored, not deleted. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
1 parent eae131e commit da254e7

5 files changed

Lines changed: 282 additions & 61 deletions

File tree

‎packages/opencode/src/altimate/workspace/pin.ts‎

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
// session look like a half-populated pin, and the fail-closed rule below would then break
2222
// `--workspace` outright. The two mechanisms are kept apart deliberately, and `readPin` additionally
2323
// stands down outside `serve`.
24-
import { realpathSync } from "node:fs"
2524
import path from "node:path"
25+
import { Filesystem } from "@/util/filesystem"
2626
import { Log } from "@/altimate/util/log"
2727

2828
const log = Log.create({ service: "workspace-pin" })
@@ -50,22 +50,20 @@ export interface ValidPin {
5050
*/
5151
export type PinState = { kind: "absent" } | { kind: "invalid"; reason: string } | ValidPin
5252

53-
/** Canonicalize for comparison: `realpath` where it resolves (macOS `/tmp` -> `/private/tmp`),
54-
* otherwise the normalized absolute path, so a not-yet-created directory still compares sanely. */
55-
export function canonical(dir: string): string {
56-
try {
57-
return realpathSync(dir)
58-
} catch {
59-
return path.resolve(dir)
60-
}
61-
}
62-
63-
/** Whether `directory` is the pinned root or lives underneath it. */
53+
/**
54+
* Whether `directory` is the pinned root or lives underneath it.
55+
*
56+
* Delegates to `Filesystem.containsReal`, which resolves symlinks and — critically — walks up to
57+
* the nearest existing ancestor when the path itself does not exist yet, rejecting `..` segments
58+
* along the way. An earlier version here compared `realpathSync` output with a LEXICAL fallback
59+
* when resolution failed, which a not-yet-created path under a symlinked ancestor defeated:
60+
* `<root>/link/new`, with `link -> /outside`, resolved to nothing, fell back to the literal string,
61+
* and passed the prefix test. Since the directory arrives from the caller-supplied
62+
* `x-opencode-directory` header on an unsecured server, that was enough to attribute an outside
63+
* project's skills and memory to the pinned workspace.
64+
*/
6465
export function withinRoot(directory: string, root: string): boolean {
65-
const d = canonical(directory)
66-
const r = canonical(root)
67-
if (d === r) return true
68-
return d.startsWith(r.endsWith(path.sep) ? r : r + path.sep)
66+
return Filesystem.containsReal(root, directory)
6967
}
7068

7169
/**
@@ -81,12 +79,19 @@ export function readPin(env: NodeJS.ProcessEnv = process.env): PinState {
8179
const rawId = env[ENV_ID]
8280
const name = env[ENV_NAME]
8381
const root = env[ENV_ROOT]
84-
if (!rawId && !name && !root) return { kind: "absent" }
8582

86-
// Partial is invalid, never "good enough". The extension sets all three or none; anything else
87-
// means something rewrote the environment and we no longer know what was intended.
83+
// `absent` means the extension set NOTHING. Tested on key presence, not truthiness: three
84+
// present-but-empty variables are a broken pin, not the absence of one, and collapsing them into
85+
// `absent` let a malformed pin fall through to ordinary cache/server resolution — the exact
86+
// fall-open this function exists to prevent.
87+
if (rawId === undefined && name === undefined && root === undefined) {
88+
return { kind: "absent" }
89+
}
90+
91+
// Partial or empty is invalid, never "good enough". The extension sets all three or none;
92+
// anything else means something rewrote the environment and we no longer know what was intended.
8893
if (!rawId || !name || !root) {
89-
return { kind: "invalid", reason: "pin is partially set" }
94+
return { kind: "invalid", reason: "pin is partially set or empty" }
9095
}
9196

9297
const datamateId = Number(rawId)

‎packages/opencode/src/altimate/workspace/state.ts‎

Lines changed: 107 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// ``Global.Path.state`` at 0o600 — chmod is applied post-write since
1111
// ``Filesystem.writeJsonAtomic`` does not chmod (see filesystem.ts:294 for
1212
// why; codex round-2 flagged this gap).
13+
import { createHash } from "node:crypto"
1314
import { chmodSync, existsSync, readFileSync, realpathSync } from "node:fs"
1415
import path from "node:path"
1516
import { AltimateApi } from "@/altimate/api/client"
@@ -49,7 +50,12 @@ export interface CachedBinding {
4950
* (see ./pin.ts), not from disk or the server. Ephemeral for the life of the
5051
* process: it is never written to the cache, and `readCache` strips it back
5152
* off anything that claims it on disk. Callers use it to tell an explicit,
52-
* user-made selection apart from a binding the server volunteered. */
53+
* user-made selection apart from a binding the server volunteered.
54+
*
55+
* No production consumer yet, by design: the write-authorization guard it exists for
56+
* (`memory-sync` currently has no `adopted` checks at all, so adopted bindings are writable
57+
* despite the contract documented above) is a pre-existing gap being fixed separately. This
58+
* carries the signal that guard will key on, so the two land independently. */
5359
pinned?: boolean
5460
}
5561

@@ -386,17 +392,80 @@ export type BindingOutcome =
386392
/** How long a validated pin is trusted before its datamate is re-checked against the account.
387393
* Matches `REVALIDATE_MS` deliberately — this is the same question that rule answers, asked of a
388394
* different source of truth. */
389-
const PIN_VALIDATION_TTL_MS = REVALIDATE_MS
395+
export const PIN_VALIDATION_TTL_MS = REVALIDATE_MS
390396

391397
/** Memoized `listDatamates` verdicts, keyed by `tenant|apiUrl|datamateId`. `resolveBindingOutcome`
392398
* runs on every turn AND every memory write, so an unmemoized probe would put an HTTP round trip
393399
* on both hot paths. Only SUCCESSFUL verdicts are stored: caching a failure would turn one network
394400
* blip into five minutes of a dead workspace. */
395401
const pinValidation = new Map<string, { name: string; at: number }>()
396402

397-
/** Exposed for tests; production never calls it. */
398-
export function __resetPinValidation(): void {
403+
/** How long a validated pin may keep being served once the account becomes UNREACHABLE. Bounded
404+
* on purpose: without it, one successful validation plus an indefinitely failing endpoint would
405+
* serve the pin forever. Beyond this the pin fails closed until the account answers again. */
406+
export const PIN_STALE_IF_ERROR_MS = 30 * 60 * 1000
407+
408+
/** Reads the clock. Seam so tests can advance past the TTLs above instead of sleeping — without it
409+
* a second resolution is always a cache hit and the stale-on-error branches cannot be exercised. */
410+
let pinNow: () => number = () => Date.now()
411+
412+
/** Exposed for tests; production never calls either. */
413+
export function __resetPinValidation(clock?: () => number): void {
399414
pinValidation.clear()
415+
pinNow = clock ?? (() => Date.now())
416+
}
417+
418+
/**
419+
* Whether a `listDatamates()` failure may be treated as "the account is unreachable" and so allow
420+
* a previously-validated pin to keep being served.
421+
*
422+
* Only transport-shaped failures qualify. An authorization failure is a real answer — the account
423+
* no longer has access — and collapsing it into the offline path lets a revoked or rotated
424+
* credential keep its previous authorization until the process restarts.
425+
*
426+
* Classified by ERROR TYPE, not by a `status` property. `api-client.ts` throws `ForbiddenError`,
427+
* `NotFoundError` and `NotConfiguredError` as plain named errors carrying NO status field, so a
428+
* status-based test silently sorted a real 403 into the transient bucket — the exact opposite of
429+
* the intent. Only `WorkspaceApiError` carries a status, and it is also what a genuine transport
430+
* failure ("Cannot reach …") is reported as, with the status left undefined.
431+
*
432+
* The default is NOT transient: for a check that decides whether to keep serving authorization,
433+
* an error we cannot classify must fail closed.
434+
*/
435+
async function isTransientApiFailure(err: unknown): Promise<boolean> {
436+
const { ForbiddenError, NotFoundError, NotConfiguredError, ConflictError, PreconditionFailedError, WorkspaceApiError } =
437+
await import("./api-client")
438+
// Definite answers about access or identity. Never transient.
439+
if (
440+
err instanceof ForbiddenError ||
441+
err instanceof NotFoundError ||
442+
err instanceof NotConfiguredError ||
443+
err instanceof ConflictError ||
444+
err instanceof PreconditionFailedError
445+
) {
446+
return false
447+
}
448+
if (err instanceof WorkspaceApiError) {
449+
// No status = could not reach the host at all (DNS, refused, TLS, abort, timeout).
450+
if (err.status === undefined) return true
451+
// 408/429 are retry-shaped; 5xx is the server failing rather than the caller being refused.
452+
return err.status === 408 || err.status === 429 || err.status >= 500
453+
}
454+
return false
455+
}
456+
457+
/** Per-directory `resolveProjectIdentifier` cache. That call runs `spawnSync("git", …)` with a
458+
* 3s timeout, and `resolveBindingOutcome` is reached per turn AND per memory write — so leaving it
459+
* on the hot path put a synchronous subprocess (and up to 3s of blocked event loop) on both. The
460+
* repo remote and project path do not change for the life of a `serve` process. */
461+
const projectIdentifierCache = new Map<string, ReturnType<typeof resolveProjectIdentifier>>()
462+
463+
function cachedProjectIdentifier(directory: string): ReturnType<typeof resolveProjectIdentifier> {
464+
const hit = projectIdentifierCache.get(directory)
465+
if (hit) return hit
466+
const ident = resolveProjectIdentifier(directory)
467+
projectIdentifierCache.set(directory, ident)
468+
return ident
400469
}
401470

402471
/**
@@ -415,27 +484,41 @@ async function resolvePinnedBinding(directory: string, pin: ValidPin): Promise<B
415484
return { status: "unknown" }
416485
}
417486

418-
const key = await tenantKey()
419-
if (!key) {
420-
// Silent until now, which made a credential problem look identical to the pin never being read.
487+
// One credentials read, not two: `tenantKey()` resolves the same credentials internally, and
488+
// calling both duplicated the work and — worse — gave two different failure paths the identical
489+
// log line, so the message could not tell you which one refused.
490+
//
491+
// Scoped to the CREDENTIAL, not just the tenant. `tenantKey()` yields only `{tenant, apiUrl}`, so
492+
// two accounts on one tenant shared a cache entry: switching credentials mid-process let the new
493+
// principal inherit the previous one's successful authorization for the whole TTL, before it had
494+
// demonstrated any visibility of its own. Only a short digest of the key is stored, never the key
495+
// — same treatment as the memory index.
496+
const creds = await AltimateApi.getCredentials().catch(() => null)
497+
if (!creds?.altimateApiKey || !creds.altimateInstanceName || !creds.altimateUrl) {
421498
log.warn("cannot honour the workspace pin: no Altimate credentials resolved")
422499
return { status: "unknown" }
423500
}
424-
const cacheKey = `${key.tenant}|${key.apiUrl}|${pin.datamateId}`
501+
const account = createHash("sha256").update(creds.altimateApiKey).digest("hex").slice(0, 16)
502+
const cacheKey = `${creds.altimateInstanceName}|${creds.altimateUrl}|${account}|${pin.datamateId}`
425503

426504
const memo = pinValidation.get(cacheKey)
427-
let datamateName = pin.datamateName
428-
if (memo && Date.now() - memo.at < PIN_VALIDATION_TTL_MS) {
429-
datamateName = memo.name
430-
} else {
505+
// Seeded from the memo rather than the environment: past the TTL an offline fallback would
506+
// otherwise report the stale name the extension started with, discarding the server-confirmed
507+
// one. Inside the TTL this is already `memo.name`, so no second assignment is needed.
508+
const cachedName = memo?.name ?? pin.datamateName
509+
let datamateName = cachedName
510+
if (!(memo && pinNow() - memo.at < PIN_VALIDATION_TTL_MS)) {
431511
let accessible: { id: number; name: string }[] | null = null
512+
let transient = false
432513
try {
433514
const { WorkspaceApi } = await import("./api-client")
434515
accessible = await WorkspaceApi.listDatamates()
435516
} catch (err) {
436-
// Unreachable, not unauthorized — these are different answers and must not collapse.
517+
// Unreachable and unauthorized are different answers and must not collapse: only the former
518+
// earns the stale grace below.
437519
accessible = null
438-
log.warn("could not verify the pinned workspace", { err: String(err) })
520+
transient = await isTransientApiFailure(err)
521+
log.warn("could not verify the pinned workspace", { err: String(err), transient })
439522
}
440523
if (accessible) {
441524
const hit = accessible.find((d) => d.id === pin.datamateId)
@@ -449,18 +532,20 @@ async function resolvePinnedBinding(directory: string, pin: ValidPin): Promise<B
449532
// The server's name wins over the environment's, which can be stale if the workspace was
450533
// renamed after the extension spawned this process.
451534
datamateName = hit.name
452-
pinValidation.set(cacheKey, { name: hit.name, at: Date.now() })
453-
} else if (!memo) {
454-
// Never validated in this process and the account is unreachable: nothing was ever
455-
// established, so there is nothing to keep serving. The environment's name is a display
456-
// fallback, never an authorization one.
535+
pinValidation.set(cacheKey, { name: hit.name, at: pinNow() })
536+
} else if (!memo || !transient || pinNow() - memo.at >= PIN_STALE_IF_ERROR_MS) {
537+
// Nothing was ever established, OR the failure was a refusal rather than a network problem,
538+
// OR the grace window has run out. A revoked credential must stop working, and an endpoint
539+
// that fails forever must not grant an unbounded licence.
540+
pinValidation.delete(cacheKey)
457541
return { status: "unknown" }
458542
}
459-
// Validated earlier and now unreachable: keep serving it, which is what this module already
460-
// does for a cached binding rather than tear a working setup down over a network blip.
543+
// Validated earlier and now genuinely unreachable, inside the grace window: keep serving it,
544+
// which is what this module already does for a cached binding rather than tear a working setup
545+
// down over a network blip.
461546
}
462547

463-
const ident = resolveProjectIdentifier(directory)
548+
const ident = cachedProjectIdentifier(directory)
464549
// Logged on success as well as on every refusal: a silently-working pin is indistinguishable
465550
// from a pin that was never read, which is exactly the ambiguity that makes this hard to support.
466551
log.info("resolved the workspace pinned by the IDE extension", {

‎packages/opencode/src/tool/bash.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,14 @@ export const BashTool = Tool.define("bash", async () => {
209209
// workspace mode is off. A terminal `altimate-code` started from here
210210
// under that host is not the host, and would otherwise settle disabled.
211211
delete mergedEnv["ALTIMATE_CODE_SERVE"]
212+
// And the IDE extension's workspace pin, for the same reason. Inert while the serve marker
213+
// above is stripped (``readPin`` checks it first), but a child that starts its own nested
214+
// ``altimate-code serve`` would set that marker itself and then inherit a pin the session it
215+
// came from was never given. Defence in depth — the pin should only ever come from the
216+
// process the extension launched.
217+
delete mergedEnv["ALTIMATE_PINNED_WORKSPACE_ID"]
218+
delete mergedEnv["ALTIMATE_PINNED_WORKSPACE_NAME"]
219+
delete mergedEnv["ALTIMATE_PINNED_WORKSPACE_ROOT"]
212220
// altimate_change end
213221
// altimate_change start — strip the run-mode markers for the same reason.
214222
stripRunModeMarkers(mergedEnv)

‎packages/opencode/test/altimate/workspace/pin.test.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
//
33
// Unit coverage for the IDE extension's workspace pin parser. Pure: no cache, no network, no
44
// instance context — `readPin` reads an env bag it is handed, so every case is a plain assertion.
5-
import { describe, expect, test } from "bun:test"
5+
import { afterAll, describe, expect, test } from "bun:test"
6+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"
7+
import os from "node:os"
68
import path from "node:path"
79
import { readPin, withinRoot } from "../../../src/altimate/workspace/pin"
810

@@ -59,6 +61,40 @@ describe("readPin", () => {
5961
})
6062
})
6163

64+
describe("withinRoot — symlink containment", () => {
65+
// The bypass this replaced a lexical fallback to fix. `realpathSync` fails on a path that does
66+
// not exist yet, and the old code then compared the raw string, so a not-yet-created path under
67+
// a symlinked ancestor passed the prefix test and let an outside tree be attributed to the
68+
// pinned workspace. The directory arrives from the caller-supplied `x-opencode-directory`.
69+
const sandbox = mkdtempSync(path.join(os.tmpdir(), "pin-symlink-"))
70+
const root = path.join(sandbox, "root")
71+
const outside = path.join(sandbox, "outside")
72+
mkdirSync(root, { recursive: true })
73+
mkdirSync(outside, { recursive: true })
74+
symlinkSync(outside, path.join(root, "link"), "dir")
75+
76+
afterAll(() => rmSync(sandbox, { recursive: true, force: true }))
77+
78+
test("an EXISTING directory reached through a symlink out of the root is rejected", () => {
79+
expect(withinRoot(path.join(root, "link"), root)).toBe(false)
80+
})
81+
82+
test("a NOT-YET-EXISTING descendant under a symlinked ancestor is rejected", () => {
83+
// The exact case the lexical fallback accepted.
84+
expect(withinRoot(path.join(root, "link", "new"), root)).toBe(false)
85+
})
86+
87+
test("a real descendant that does not exist yet is still accepted", () => {
88+
// Fail-closed must not become fail-everything: `serve` legitimately resolves directories that
89+
// have not been created yet.
90+
expect(withinRoot(path.join(root, "pkg", "src"), root)).toBe(true)
91+
})
92+
93+
test("a `..` escape is rejected", () => {
94+
expect(withinRoot(path.join(root, "..", "outside"), root)).toBe(false)
95+
})
96+
})
97+
6298
describe("withinRoot", () => {
6399
test("the root itself and its descendants are in scope", () => {
64100
expect(withinRoot(ROOT, ROOT)).toBe(true)

0 commit comments

Comments
 (0)