fix(slice-05): create-path realpath-validates symlink escape (guard B1 repair on 79a89d40) (OPR.0.3.2.5)

Per velocity-guard BLOCKING-CONCERN on 79a89d40 (qitem-…bf67e58d): the
vendor helper's containment check was LEXICAL only
(nodePath.resolve does not follow symlinks), but readFileSync +
fs.cpSync({dereference:true}) follow them. So a path like
plugins/escape that's lexically under sourceRoot could be a symlink
to /etc/passwd or any outside dir, and /create would copy that
outside content into the archive as regular files.

Real trust-boundary defect on an HTTP surface that consumes author-
controlled source trees. Banked pre-existing-trust-boundary lesson
addendum extension: the lexical containment check protects against
"../" string traversal, but symlinks are a SEPARATE escape vector
that requires realpath validation.

Fix shape (per guard recipe + cont.18 §2 "no fork" constraint
explicitly expects symlinks for the gstack HYBRID author workflow —
reject-symlinks-outright was rejected as too restrictive; we
validate-then-allow):

- fs.realpathSync(sourceRoot) at top of consumeAuthorBundleYaml →
  sourceRootReal
- assertSourceRealContained helper: realpath each declared sourceAbs,
  assert it starts-with sourceRootReal (catches symlink-to-outside-
  file AND symlink-to-outside-dir at the declared path level)
- assertNoSymlinkEscapeInTree helper: for dir vendoring, pre-walk the
  realpath-resolved tree with lstat; for every symlink entry,
  realpath-check containment; for symlink-to-dir under sourceRoot,
  recurse to validate nested symlinks too. Runs BEFORE cpSync so any
  escape attempt fails before dereference.
- vendorFile + vendorDir gated on these checks before any read/copy.

Secondary honesty fix (banked guard advisory): doc comment said
"/create catches + returns a 400" but actual route catch returns
500; updated comment to match.

2 new symlink-escape discriminator tests in bundle-routes.test.ts:

1. "POST /api/bundles/create rejects author bundle.yaml skill that is
   a symlink targeting outside sourceRoot" — FILE shape. Creates
   sourceRoot/skills/escape/SKILL.md as symlink to outsideDir/secret.md.
   Bundle declares skills[skills/escape/SKILL.md]. /create expected
   500 + error message naming symlink escape, NO archive at outputPath.

2. "POST /api/bundles/create rejects author bundle.yaml plugin that
   is a symlink targeting outside sourceRoot" — DIR shape. Creates
   sourceRoot/plugins/escape as symlink to outsideDir (dir tree with
   private.json). Bundle declares plugins[escape]. /create expected
   500 + error message, NO archive.

Discriminator-flip evidence BEFORE this commit (PROACTIVELY):
removed both assertSourceRealContained calls + the
assertNoSymlinkEscapeInTree call. Result: 2/56 fail — exactly the
2 new symlink-escape tests (without the realpath check, cpSync +
readFileSync silently dereference and produce successful archives
containing outside content; tests expect 500 + assert NO archive
which would not hold). Restored, 56/56 + 231/231 across full slice-05
daemon suite.

Banked discipline extension (banked pre-existing-trust-boundary
addendum / 11th tail-cycle catch): lexical containment + dereference-
on-read is a HALF-protection class. Symlinks are a SEPARATE escape
axis from string traversal — both need to be validated independently.
Pattern to recognize forward: any code path that does
nodePath.resolve(...) for containment followed by readFile/cpSync
with dereference must ALSO realpath-validate the resolved-then-
dereferenced source. The realpath-validate-then-allow pattern is the
banked-going-forward primitive for filesystem trust boundaries when
symlinks are part of the legitimate author workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-31 20:57:24 -07:00
1 parent 79a89d4061
commit 233dd16d72
2 files changed
+206 -11

No files matched your search

+88 -11
View File
@@ -645,8 +645,13 @@ function writeInstallAudit(opts: {
* file copy); plugins + context_packs (manifest.yaml path → parent
* dir) + agent_images are dir paths (recursive copy with symlink
* dereference).
* - Throws on missing source path or path-containment violation; the
* /create route catches + returns a 400 with the message.
* - Throws on missing source path / path-containment violation /
* realpath escape (banked 79a89d40 B1: lexical containment alone is
* insufficient — symlinks under sourceRoot can target outside content;
* each declared path is realpath-validated, and dir vendoring pre-
* walks the tree with lstat to catch nested symlink escapes). The
* /create route's outer catch returns 500 with the message; if this
* maps poorly to operator UX, a follow-up can wrap to 400.
*
* Returns the cross-primitive blocks to populate on the manifest the
* assembler returned. The /create route writes the manifest AFTER
@@ -664,42 +669,114 @@ interface AuthorBundleCrossPrimitives {
function consumeAuthorBundleYaml(sourceRoot: string, staging: string): AuthorBundleCrossPrimitives {
const authorBundlePath = nodePath.join(sourceRoot, "bundle.yaml");
if (!fs.existsSync(authorBundlePath)) return {};
const sourceRootResolved = nodePath.resolve(sourceRoot);
// Canonicalize sourceRoot before vendoring (banked 79a89d40 B1 guard
// catch: lexical containment + dereference allows symlinks under
// sourceRoot to escape). realpath the root once; every declared path's
// realpath must stay within this boundary.
const sourceRootReal = fs.realpathSync(sourceRoot);
const stagingResolved = nodePath.resolve(staging);
/** Realpath-contain check: the actual file/dir behind `absPath` (after
* symlink resolution) must live under sourceRootReal. Catches symlinks
* whose targets escape the source tree even when the lexical path is
* inside sourceRoot. Throws on escape. */
const assertSourceRealContained = (absPath: string, kindLabel: string, declared: string): string => {
let realPath: string;
try {
realPath = fs.realpathSync(absPath);
} catch (err) {
throw new Error(`author bundle ${kindLabel} '${declared}' does not exist in source: ${(err as Error).message}`);
}
if (realPath !== sourceRootReal && !realPath.startsWith(sourceRootReal + nodePath.sep)) {
throw new Error(`author bundle ${kindLabel} '${declared}' resolves outside bundle source root (symlink escape); rejected`);
}
return realPath;
};
/** Pre-walk a directory tree with lstat; for every symlink encountered,
* realpath-check containment under sourceRootReal. Regular files and
* dirs need no special check (they're inherently contained — the
* problem class is symlinks escaping). Throws on any escape. */
const assertNoSymlinkEscapeInTree = (dirAbs: string, kindLabel: string, declared: string): void => {
const stack: string[] = [dirAbs];
while (stack.length > 0) {
const cur = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(cur, { withFileTypes: true });
} catch {
// Unreadable dir — skip; cpSync will surface the failure if material
continue;
}
for (const entry of entries) {
const entryAbs = nodePath.join(cur, entry.name);
if (entry.isSymbolicLink()) {
// Realpath the symlink target; reject if escapes sourceRootReal
let entryReal: string;
try {
entryReal = fs.realpathSync(entryAbs);
} catch (err) {
throw new Error(`author bundle ${kindLabel} '${declared}' has unreadable symlink at '${nodePath.relative(sourceRootReal, entryAbs)}': ${(err as Error).message}`);
}
if (entryReal !== sourceRootReal && !entryReal.startsWith(sourceRootReal + nodePath.sep)) {
throw new Error(`author bundle ${kindLabel} '${declared}' contains nested symlink at '${nodePath.relative(sourceRootReal, entryAbs)}' escaping bundle source root; rejected`);
}
// If the symlink target is a directory under sourceRoot, walk it
// (cpSync with dereference:true will follow it; we need to
// validate any nested symlinks too)
try {
const st = fs.statSync(entryReal);
if (st.isDirectory()) stack.push(entryReal);
} catch { /* unreadable — skip */ }
} else if (entry.isDirectory()) {
stack.push(entryAbs);
}
}
}
};
const authorYaml = fs.readFileSync(authorBundlePath, "utf-8");
const authorParsed = parsePodBundleManifest(authorYaml) as Record<string, unknown>;
const result: AuthorBundleCrossPrimitives = {};
const vendorFile = (declared: string, kindLabel: string): void => {
if (!isRelativeSafePath(declared)) throw new Error(`author bundle ${kindLabel} path '${declared}' is not safe`);
const sourceAbs = nodePath.resolve(sourceRoot, declared);
if (sourceAbs !== sourceRootResolved && !sourceAbs.startsWith(sourceRootResolved + nodePath.sep)) {
const sourceAbs = nodePath.resolve(sourceRootReal, declared);
// Lexical containment + realpath containment (banked 79a89d40 B1)
if (sourceAbs !== sourceRootReal && !sourceAbs.startsWith(sourceRootReal + nodePath.sep)) {
throw new Error(`author bundle ${kindLabel} path '${declared}' escapes bundle source root`);
}
if (!fs.existsSync(sourceAbs)) throw new Error(`author bundle ${kindLabel} '${declared}' does not exist in source`);
assertSourceRealContained(sourceAbs, kindLabel, declared);
const targetAbs = nodePath.resolve(staging, declared);
if (!targetAbs.startsWith(stagingResolved + nodePath.sep)) {
throw new Error(`author bundle ${kindLabel} target '${declared}' escapes staging`);
}
if (!fs.existsSync(sourceAbs)) throw new Error(`author bundle ${kindLabel} '${declared}' does not exist in source`);
fs.mkdirSync(nodePath.dirname(targetAbs), { recursive: true });
// Dereference symlinks (tar safety per banked pre-existing-trust-boundary)
// readFileSync follows the symlink; we already validated its realpath
// stays in sourceRootReal. Write as regular file (tar safety).
const content = fs.readFileSync(sourceAbs);
fs.writeFileSync(targetAbs, content);
};
const vendorDir = (declared: string, kindLabel: string): void => {
if (!isRelativeSafePath(declared)) throw new Error(`author bundle ${kindLabel} path '${declared}' is not safe`);
const sourceAbs = nodePath.resolve(sourceRoot, declared);
if (sourceAbs !== sourceRootResolved && !sourceAbs.startsWith(sourceRootResolved + nodePath.sep)) {
const sourceAbs = nodePath.resolve(sourceRootReal, declared);
if (sourceAbs !== sourceRootReal && !sourceAbs.startsWith(sourceRootReal + nodePath.sep)) {
throw new Error(`author bundle ${kindLabel} path '${declared}' escapes bundle source root`);
}
if (!fs.existsSync(sourceAbs)) throw new Error(`author bundle ${kindLabel} '${declared}' does not exist in source`);
// Realpath-validate the declared dir itself (catches symlink-to-outside-dir)
const sourceReal = assertSourceRealContained(sourceAbs, kindLabel, declared);
// Pre-walk the realpath-resolved dir to catch nested symlink escapes
// before cpSync dereferences anything
assertNoSymlinkEscapeInTree(sourceReal, kindLabel, declared);
const targetAbs = nodePath.resolve(staging, declared);
if (!targetAbs.startsWith(stagingResolved + nodePath.sep)) {
throw new Error(`author bundle ${kindLabel} target '${declared}' escapes staging`);
}
if (!fs.existsSync(sourceAbs)) throw new Error(`author bundle ${kindLabel} '${declared}' does not exist in source`);
fs.mkdirSync(nodePath.dirname(targetAbs), { recursive: true });
// dereference: true → symlinks are followed and written as regular files
// dereference: true → symlinks followed (now validated safe) and
// written as regular files for tar safety
fs.cpSync(sourceAbs, targetAbs, { recursive: true, dereference: true });
};
+118
View File
@@ -1648,6 +1648,124 @@ describe("Bundle API routes", () => {
}
});
// Item 6 / Checkpoint 7.5 B1 repair (banked 79a89d40 guard catch):
// symlink escape via author bundle.yaml — FILE shape. A skill path
// that looks lexically contained but symlinks to an outside file must
// be rejected. Without realpath validation, dereference at vendor
// time would copy outside content into the archive as a regular file.
it("POST /api/bundles/create rejects author bundle.yaml skill that is a symlink targeting outside sourceRoot", async () => {
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "create-symlink-escape-file-src-"));
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-symlink-escape-file-outside-"));
const outputPath = path.join(tmpDir, "symlink-escape-file.rigbundle");
try {
// Outside content (private/secret file the bundle should NEVER contain)
const outsideFile = path.join(outsideDir, "secret.md");
fs.writeFileSync(outsideFile, "SECRET CONTENT");
// Pod-aware sourceRoot fixture
fs.mkdirSync(path.join(sourceRoot, "agents/impl"), { recursive: true });
fs.writeFileSync(path.join(sourceRoot, "agents/impl/agent.yaml"), [
'name: impl-agent', 'version: "1.0.0"', 'resources:', ' skills: []',
'profiles:', ' default:', ' uses:', ' skills: []',
].join("\n"));
const specPath = path.join(sourceRoot, "rig.yaml");
fs.writeFileSync(specPath, [
'version: "0.2"', 'name: symlink-escape-file-rig', 'pods:',
' - id: dev', ' label: Dev', ' members:',
' - id: impl', ' agent_ref: "local:agents/impl"',
' profile: default', ' runtime: claude-code', ' cwd: .',
' edges: []', 'edges: []',
].join("\n"));
// Symlink inside sourceRoot → outside file
fs.mkdirSync(path.join(sourceRoot, "skills/escape"), { recursive: true });
fs.symlinkSync(outsideFile, path.join(sourceRoot, "skills/escape/SKILL.md"));
fs.writeFileSync(path.join(sourceRoot, "bundle.yaml"), [
'skills:',
' - skills/escape/SKILL.md',
].join("\n"));
const createRes = await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
specPath, rigRoot: sourceRoot,
bundleName: "symlink-escape-file-test", bundleVersion: "0.1.0", outputPath,
}),
});
// /create catch returns 500 with the message (per banked 79a89d40
// comment-honesty update). Body.error names the symlink escape.
expect(createRes.status).toBe(500);
const body = await createRes.json();
expect(body.error).toMatch(/symlink escape|outside bundle source root/i);
// CRUCIAL: no archive at outputPath (create failed before pack)
expect(fs.existsSync(outputPath)).toBe(false);
} finally {
fs.rmSync(sourceRoot, { recursive: true, force: true });
fs.rmSync(outsideDir, { recursive: true, force: true });
if (fs.existsSync(outputPath)) fs.rmSync(outputPath);
}
});
// Item 6 / Checkpoint 7.5 B1 repair (banked 79a89d40 guard catch):
// symlink escape via author bundle.yaml — DIR shape. A plugin path
// that looks lexically contained but symlinks to an outside dir must
// be rejected. Without realpath validation, cpSync dereference would
// copy the outside dir tree into the archive.
it("POST /api/bundles/create rejects author bundle.yaml plugin that is a symlink targeting outside sourceRoot", async () => {
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "create-symlink-escape-dir-src-"));
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-symlink-escape-dir-outside-"));
const outputPath = path.join(tmpDir, "symlink-escape-dir.rigbundle");
try {
// Outside dir tree (private content the bundle should NEVER contain)
fs.writeFileSync(path.join(outsideDir, "private.json"), '{"secret":"data"}');
fs.mkdirSync(path.join(sourceRoot, "agents/impl"), { recursive: true });
fs.writeFileSync(path.join(sourceRoot, "agents/impl/agent.yaml"), [
'name: impl-agent', 'version: "1.0.0"', 'resources:', ' skills: []',
'profiles:', ' default:', ' uses:', ' skills: []',
].join("\n"));
const specPath = path.join(sourceRoot, "rig.yaml");
fs.writeFileSync(specPath, [
'version: "0.2"', 'name: symlink-escape-dir-rig', 'pods:',
' - id: dev', ' label: Dev', ' members:',
' - id: impl', ' agent_ref: "local:agents/impl"',
' profile: default', ' runtime: claude-code', ' cwd: .',
' edges: []', 'edges: []',
].join("\n"));
// Symlink inside sourceRoot/plugins → outside dir
fs.mkdirSync(path.join(sourceRoot, "plugins"), { recursive: true });
fs.symlinkSync(outsideDir, path.join(sourceRoot, "plugins/escape"));
fs.writeFileSync(path.join(sourceRoot, "bundle.yaml"), [
'plugins:',
' - id: escape',
' source:',
' kind: local',
' path: plugins/escape',
].join("\n"));
const createRes = await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
specPath, rigRoot: sourceRoot,
bundleName: "symlink-escape-dir-test", bundleVersion: "0.1.0", outputPath,
}),
});
expect(createRes.status).toBe(500);
const body = await createRes.json();
expect(body.error).toMatch(/symlink escape|outside bundle source root/i);
expect(fs.existsSync(outputPath)).toBe(false);
} finally {
fs.rmSync(sourceRoot, { recursive: true, force: true });
fs.rmSync(outsideDir, { recursive: true, force: true });
if (fs.existsSync(outputPath)) fs.rmSync(outputPath);
}
});
// Item 6 / Checkpoint 7.3e step 3: /install routes declared workflow_specs
// after successful bootstrap. Target = SettingsStore-resolved
// <workspaceSpecsRoot>/workflows. Bundle has workflow_specs[] with a path