fix(slice-05): install compat extraction uses safe unpack path (guard B1 repair on fbf92f9c) (OPR.0.3.2.5)

Repairs guard BLOCKING-CONCERN B1 on qitem-20260518201306-2ec080de /
fbf92f9c: the install-time compat check's extractManifestForCompatCheck
called tar.extract directly on the untrusted .rigbundle, bypassing
the existing safety boundary in domain/bundle-archive.unpack
(verifyArchiveDigest + tar.list unsafe-entry prescan rejecting
symlinks / hardlinks / absolute paths / dot-dot traversal). Even
though extraction went to a tmp dir, the trust-boundary regression
was real because the new pre-delegation gate ran on attacker-craftable
archives before any safety code.

Fix: replace the raw tar.extract call with a call to the canonical
unpack(bundlePath, tmpDir) from domain/bundle-archive. unpack
performs digest verify, then the same tar.list safety prescan, then
extracts, then runs content-integrity verification. Single source for
the safety contract — future safety changes to unpack auto-apply
here.

Discriminator test added in test/bundle-routes.test.ts:
- Hand-crafts a bundle whose tar contains a symlink entry to /etc/passwd
- Writes a VALID sibling .sha256 so the digest check passes (proving
  the prescan is what catches the symlink, not the digest)
- POSTs /api/bundles/install with plan=true
- Asserts status 400 and the response body contains the unsafe-entries
  message (SymbolicLink / symlink / "Unsafe archive entries")
- Verified the test is a real discriminator: temporarily reverting
  extractManifestForCompatCheck to a raw tar.extract call makes this
  one test FAIL (1/24); restoring the unpack call returns 24/24 PASS

Verified locally:
- 24/24 daemon bundle-routes suite green (was 23; +1 new unsafe-archive test)
- Other daemon bundle suites unchanged
- All scope-and-versioning sections 1 and 3 coherence grep — empty
- audit-every-layer unchanged: function-level reads only

Diff: 2 files, +62 / -5 lines. No CLI files modified.

Branch chain since main: 82d39715 -> 3b7432e4 -> 6b81d3b6 -> 77bed48b
(B1 repair from Item 1) -> 1fd11b76 -> f10a1990 -> fbf92f9c (Item 2
close-out, B1 finding on this) -> THIS COMMIT (B1 repair).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-18 13:16:22 -07:00
1 parent fbf92f9ce1
commit 6401fe4be5
2 files changed
+62 -5

No files matched your search

+9 -5
View File
@@ -95,15 +95,19 @@ function checkBundleCompatibility(
}
/**
* Extract bundle.yaml manifest from a .rigbundle archive without doing the
* full bootstrap unpack. Used by the install-time compatibility check. Throws
* on archive / parse / safety failures.
* Extract bundle.yaml manifest from a .rigbundle archive via the canonical
* safe-extraction path (unpack from domain/bundle-archive). unpack performs
* verifyArchiveDigest, then tar.list pre-scan rejecting symlinks / hardlinks
* / absolute paths / dot-dot traversal, then extracts, then verifies content
* integrity. Using unpack here keeps the install-time compat check inside the
* existing trust boundary — raw tar.extract on an untrusted archive would
* bypass the safety prescan (B1 regression fixed). Throws on archive / safety
* / parse failures; the caller converts these into a 3-part 400 response.
*/
async function extractManifestForCompatCheck(bundlePath: string): Promise<Record<string, unknown>> {
const tmpDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-compat-"));
try {
const tar = await import("tar");
await tar.extract({ file: bundlePath, cwd: tmpDir });
await unpack(bundlePath, tmpDir);
const manifestPath = nodePath.join(tmpDir, "bundle.yaml");
if (!fs.existsSync(manifestPath)) throw new Error("Bundle missing bundle.yaml");
const manifestYaml = fs.readFileSync(manifestPath, "utf-8");
@@ -767,6 +767,59 @@ describe("Bundle API routes", () => {
}
});
// Item 2 / slice-05 Checkpoint 3.3 / guard B1 repair: install rejects unsafe
// archives through the safe error path BEFORE bootstrap delegation.
// Discriminator: reverting extractManifestForCompatCheck to a raw tar.extract
// (without unpack's verifyArchiveDigest + tar.list unsafe-entry prescan) must
// make this test fail — the unsafe symlink would be silently extracted and
// bootstrap would see an attacker-controlled link target.
it("POST /api/bundles/install rejects an archive containing a symlink entry via the safe path (B1 repair)", async () => {
const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-unsafe-staging-"));
const bundlePath = path.join(tmpDir, "unsafe-symlink.rigbundle");
try {
// Minimal valid manifest contents (bundle.yaml present) plus the unsafe
// symlink that the safety prescan must reject.
fs.writeFileSync(path.join(stagingDir, "bundle.yaml"), [
'schema_version: 1',
'name: unsafe-test',
'version: "0.1.0"',
'created_at: "2026-05-18T00:00:00Z"',
'rig_spec: rig.yaml',
'packages: []',
].join("\n"));
fs.writeFileSync(path.join(stagingDir, "rig.yaml"), 'schema_version: 1\nname: x\nversion: "1.0"\nnodes: []\nedges: []');
fs.symlinkSync("/etc/passwd", path.join(stagingDir, "evil-symlink"));
const tar = await import("tar");
await tar.create(
{ gzip: { level: 9 }, file: bundlePath, cwd: stagingDir, portable: true },
["bundle.yaml", "rig.yaml", "evil-symlink"],
);
// Write a valid sibling .sha256 so digest verification PASSES — proving
// the prescan is what catches the symlink, not the digest check.
const { createHash } = await import("node:crypto");
const archiveHash = createHash("sha256").update(fs.readFileSync(bundlePath)).digest("hex");
fs.writeFileSync(`${bundlePath}.sha256`, archiveHash, "utf-8");
const installRes = await app.request("/api/bundles/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath, plan: true }),
});
expect(installRes.status).toBe(400);
const body = await installRes.json();
// Either explicit-extraction-failed shape (safe rejection happened inside
// unpack) OR the compat-check-failed shape with the safety message in
// detail. Either way, the symlink string must appear and bootstrap must
// NOT have been entered.
const text = JSON.stringify(body);
expect(text).toMatch(/Unsafe archive entries|SymbolicLink|symlink/i);
} finally {
fs.rmSync(stagingDir, { recursive: true, force: true });
}
});
// T11: Install concurrency lock
it("concurrent bundle install returns 409", async () => {
// Acquire lock manually