Skip to content

Commit f8074d0

Browse files
committed
Phase 2: safe .pi/ materialisation from a pinned SHA (symlink/submodule/traversal proof)
sec-review flagged the obvious implementation -- fs.readFile off a clone -- as a real hole: a .pi/APPEND_SYSTEM.md symlinked to the worker's .env or /etc/passwd would pull a host file into the agent's SYSTEM PROMPT, and the worker is a trusted zone. This materialises .pi/ without that hole. Proven first-hand with git before writing a line: `git show <sha>:<symlink>` outputs the symlink's TARGET PATH as a blob (the string "/etc/passwd"), never the contents of the target file -- git reads objects, not the filesystem. And `git ls-tree` exposes the mode, so a symlink (120000) or submodule (160000) is rejected BEFORE anything is read. materialize.mjs: - enumerates via `git ls-tree -r -z <sha> .pi/`, keeps ONLY regular blobs (100644) at allowlisted paths (.pi/APPEND_SYSTEM.md, .pi/skills/*/SKILL.md); symlinks, submodules, executables, and anything else are dropped at one choke point (selectEntries); - reads content via `git cat-file blob <oid>` -- raw bytes by object id, no working-tree checkout, no smudge/clean filters, no hooks, no diff drivers: nothing in the repo executes; - runs git with `-c core.hooksPath=/dev/null` hardening; - re-derives every output path and asserts containment via path.relative (separator-agnostic -- the worker is cross-platform and destDir may be a Windows path); - takes the SHA as input, resolved by the caller from a fresh default-branch API call, never a webhook field or the triggering branch. Tests build a REAL git repo through the index (update-index --cacheinfo) with a genuine 120000 symlink-to-/etc/passwd object and a 160000 submodule -- no OS symlink privilege needed, so it runs on the Windows dev box too. Asserts the symlink and submodule materialise NOTHING and that neither the /etc/passwd contents nor the target path string reach any output file. A repo with no .pi/ materialises nothing (a guardrails-only job), no error. Found and fixed a cross-platform bug while testing: the containment check used string-prefix with a forward slash, which rejected valid paths on Windows (backslash separators). Now path.relative-based. The worker runs on the host, which may be Windows -- this would have broken every job there.
1 parent d4e57af commit f8074d0

2 files changed

Lines changed: 214 additions & 0 deletions

File tree

worker/src/materialize.mjs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { execFile } from "node:child_process";
2+
import { mkdirSync, writeFileSync } from "node:fs";
3+
import { dirname, isAbsolute, join, relative } from "node:path";
4+
import { promisify } from "node:util";
5+
6+
const exec = promisify(execFile);
7+
8+
/**
9+
* Materialise a serviced repo's `.pi/` (its persona and skills) from a pinned commit into a
10+
* read-only `/job/pi/` directory the container mounts.
11+
*
12+
* This is security-critical: the content becomes the agent's SYSTEM PROMPT, and the repo is only
13+
* trusted at maintainer level. Three properties, each PROVEN with a hostile fixture in the tests:
14+
*
15+
* 1. NO SYMLINK FOLLOWING. A `.pi/APPEND_SYSTEM.md` symlinked to the worker's `.env` or
16+
* `/etc/passwd` must never pull a host file into the prompt. We enumerate with `git ls-tree`
17+
* and REJECT any entry that is not a regular blob (mode 100644): symlinks are 120000,
18+
* submodules 160000. We never touch the working tree, so there is no link to follow.
19+
* 2. NO PATH TRAVERSAL. Every output path is re-derived from the git tree path and asserted to
20+
* stay under the destination root; a crafted entry cannot escape `/job/pi/`.
21+
* 3. NO EXECUTION. `git cat-file blob <oid>` dumps raw bytes by object id -- no working-tree
22+
* checkout, no smudge/clean filters, no hooks, no diff drivers. Nothing in the repo runs.
23+
*
24+
* The SHA is an input, resolved by the caller from a fresh default-branch API call -- NEVER a
25+
* webhook field, and NEVER the triggering (possibly fork) branch.
26+
*/
27+
28+
const PI_DIR = ".pi";
29+
// Exactly the two shapes we accept. Anything else in .pi/ is ignored, not materialised.
30+
const APPEND_SYSTEM = `${PI_DIR}/APPEND_SYSTEM.md`;
31+
const SKILL_RE = /^\.pi\/skills\/[A-Za-z0-9._-]+\/SKILL\.md$/;
32+
33+
/** A git tree path we are willing to materialise. Rejects traversal and unexpected shapes. */
34+
export function isAllowedPiPath(path) {
35+
if (path === APPEND_SYSTEM) return true;
36+
return SKILL_RE.test(path);
37+
}
38+
39+
/**
40+
* Parse `git ls-tree -r -z` output into entries, keeping ONLY regular blobs (100644) at allowed
41+
* paths. Symlinks (120000), submodules (160000), executables (100755), and anything outside the
42+
* allowlist are dropped here -- the single choke point for the reject-by-mode rule.
43+
*/
44+
export function selectEntries(lsTreeZ) {
45+
const entries = [];
46+
for (const record of lsTreeZ.split("\0")) {
47+
if (!record) continue;
48+
// "<mode> <type> <oid>\t<path>"
49+
const tab = record.indexOf("\t");
50+
if (tab === -1) continue;
51+
const [mode, type, oid] = record.slice(0, tab).split(/\s+/);
52+
const path = record.slice(tab + 1);
53+
if (mode !== "100644" || type !== "blob") continue; // rejects symlink/submodule/exec
54+
if (!isAllowedPiPath(path)) continue;
55+
entries.push({ oid, path });
56+
}
57+
return entries;
58+
}
59+
60+
/**
61+
* Assert a resolved output path stays under root. Defence in depth behind the path allowlist.
62+
* Uses path.relative rather than string-prefix so it is correct on Windows too -- the worker is
63+
* cross-platform and destDir may be a Windows path with backslash separators.
64+
*/
65+
function safeJoin(root, relPath) {
66+
const resolved = join(root, relPath);
67+
const rel = relative(root, resolved);
68+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
69+
throw new Error(`path escapes destination: ${relPath}`);
70+
}
71+
return resolved;
72+
}
73+
74+
/**
75+
* Materialise `.pi/` at `sha` from the clone at `gitDir` into `destDir` (which becomes /job/pi).
76+
* Returns the list of relative paths written (under `pi/`), for logging.
77+
*
78+
* `git` is injected for tests; defaults to a thin wrapper over the real binary.
79+
*/
80+
export async function materializePiDir({ gitDir, sha, destDir, git = defaultGit }) {
81+
const lsTreeZ = await git(gitDir, ["ls-tree", "-r", "-z", sha, `${PI_DIR}/`]);
82+
const entries = selectEntries(lsTreeZ);
83+
84+
const written = [];
85+
for (const { oid, path } of entries) {
86+
const content = await git(gitDir, ["cat-file", "blob", oid], { raw: true });
87+
// path is ".pi/skills/x/SKILL.md" (git always uses forward slashes); strip the leading
88+
// ".pi/" so it lands under destDir/pi.
89+
const relPosix = `pi/${path.slice(PI_DIR.length + 1)}`;
90+
const out = safeJoin(destDir, relPosix);
91+
mkdirSync(dirname(out), { recursive: true });
92+
writeFileSync(out, content);
93+
// Report posix-style: this names a CONTAINER path (/job/pi/...), stable across host OSes.
94+
written.push(relPosix);
95+
}
96+
return written;
97+
}
98+
99+
async function defaultGit(gitDir, args, { raw = false } = {}) {
100+
// -c protecting against a hostile repo config: no hooks, no external filters, no pager.
101+
const hardened = [
102+
"-c",
103+
"core.hooksPath=/dev/null",
104+
"-c",
105+
"core.fsmonitor=false",
106+
"--no-pager",
107+
"-C",
108+
gitDir,
109+
...args,
110+
];
111+
const { stdout } = await exec("git", hardened, {
112+
encoding: raw ? "buffer" : "utf8",
113+
maxBuffer: 16 * 1024 * 1024,
114+
});
115+
return stdout;
116+
}

worker/test/materialize.test.mjs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import assert from "node:assert/strict";
2+
import { execFileSync } from "node:child_process";
3+
import { mkdtempSync, readFileSync, readdirSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { test } from "node:test";
7+
import { isAllowedPiPath, materializePiDir, selectEntries } from "../src/materialize.mjs";
8+
9+
// --- pure selection logic: runs everywhere ---
10+
11+
test("isAllowedPiPath accepts exactly the persona and skill shapes", () => {
12+
assert.ok(isAllowedPiPath(".pi/APPEND_SYSTEM.md"));
13+
assert.ok(isAllowedPiPath(".pi/skills/bug-fix/SKILL.md"));
14+
assert.ok(!isAllowedPiPath(".pi/skills/bug-fix/notes.md"));
15+
assert.ok(!isAllowedPiPath(".pi/settings.json"));
16+
assert.ok(!isAllowedPiPath(".pi/skills/../../etc/passwd/SKILL.md")); // traversal in the name
17+
assert.ok(!isAllowedPiPath(".pi/APPEND_SYSTEM.md.evil"));
18+
});
19+
20+
test("selectEntries rejects symlinks (120000), submodules (160000), and executables (100755)", () => {
21+
const z = [
22+
"100644 blob aaa\t.pi/APPEND_SYSTEM.md",
23+
"120000 blob bbb\t.pi/EVIL_SYMLINK.md", // symlink -> host file
24+
"160000 commit ccc\t.pi/skills/sub", // submodule
25+
"100755 blob ddd\t.pi/skills/x/SKILL.md", // executable bit set
26+
"100644 blob eee\t.pi/skills/good/SKILL.md",
27+
].join("\0");
28+
const picked = selectEntries(z).map((e) => e.path);
29+
assert.deepEqual(picked, [".pi/APPEND_SYSTEM.md", ".pi/skills/good/SKILL.md"]);
30+
});
31+
32+
// --- integration against a REAL git repo with REAL hostile objects ---
33+
34+
function git(dir, args) {
35+
return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8" });
36+
}
37+
38+
/** A repo whose .pi/ contains a genuine symlink object and a submodule gitlink, plus real files. */
39+
function hostileRepo() {
40+
const dir = mkdtempSync(join(tmpdir(), "pi-mat-"));
41+
git(dir, ["init", "-q"]);
42+
git(dir, ["config", "user.email", "t@t"]);
43+
git(dir, ["config", "user.name", "t"]);
44+
git(dir, ["config", "core.autocrlf", "false"]);
45+
46+
const blob = (content) =>
47+
execFileSync("git", ["-C", dir, "hash-object", "-w", "--stdin"], { input: content, encoding: "utf8" }).trim();
48+
49+
const persona = blob("REAL-PERSONA-SENTINEL");
50+
const skill = blob("---\nname: good\ndescription: real\n---\nsteps\n");
51+
const evilTarget = blob("/etc/passwd"); // the symlink's blob content = its target path
52+
53+
// Build the tree entirely through the index -- creates a genuine 120000 symlink and 160000
54+
// gitlink without needing OS symlink privilege (which Windows dev boxes lack).
55+
git(dir, ["update-index", "--add", "--cacheinfo", `100644,${persona},.pi/APPEND_SYSTEM.md`]);
56+
git(dir, ["update-index", "--add", "--cacheinfo", `100644,${skill},.pi/skills/good/SKILL.md`]);
57+
git(dir, ["update-index", "--add", "--cacheinfo", `120000,${evilTarget},.pi/EVIL_SYMLINK.md`]);
58+
// A submodule gitlink. git rejects a null sha, so use any valid nonzero oid (the blob's) --
59+
// update-index does not verify a gitlink points at a real commit, which is all we need here.
60+
git(dir, ["update-index", "--add", "--cacheinfo", `160000,${persona},.pi/skills/sub`]);
61+
git(dir, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "x"]);
62+
const sha = git(dir, ["rev-parse", "HEAD"]).trim();
63+
return { dir, sha };
64+
}
65+
66+
test("materialize writes real files and NEVER the symlink or submodule", async () => {
67+
const { dir, sha } = hostileRepo();
68+
const dest = mkdtempSync(join(tmpdir(), "pi-dest-"));
69+
70+
const written = await materializePiDir({ gitDir: dir, sha, destDir: dest });
71+
72+
assert.deepEqual(written.sort(), ["pi/APPEND_SYSTEM.md", "pi/skills/good/SKILL.md"].sort());
73+
assert.equal(readFileSync(join(dest, "pi/APPEND_SYSTEM.md"), "utf8"), "REAL-PERSONA-SENTINEL");
74+
75+
// The symlink must have produced NOTHING -- not a file containing "/etc/passwd", not anything.
76+
const flat = JSON.stringify(readdirSync(dest, { recursive: true }));
77+
assert.ok(!flat.includes("EVIL_SYMLINK"), "the symlink entry was materialised");
78+
assert.ok(!flat.includes("sub"), "the submodule entry was materialised");
79+
80+
// And no host-file content leaked in either.
81+
const allContent = written.map((r) => readFileSync(join(dest, r), "utf8")).join("\n");
82+
assert.ok(!allContent.includes("root:x:0:0"), "host /etc/passwd content leaked into the prompt");
83+
assert.ok(!allContent.includes("/etc/passwd"), "symlink target path leaked into the prompt");
84+
});
85+
86+
test("a repo with no .pi/ materialises nothing (guardrails-only job), no error", async () => {
87+
const dir = mkdtempSync(join(tmpdir(), "pi-empty-"));
88+
git(dir, ["init", "-q"]);
89+
git(dir, ["config", "user.email", "t@t"]);
90+
git(dir, ["config", "user.name", "t"]);
91+
execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", "x"], {
92+
env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
93+
});
94+
const sha = git(dir, ["rev-parse", "HEAD"]).trim();
95+
const dest = mkdtempSync(join(tmpdir(), "pi-dest-"));
96+
const written = await materializePiDir({ gitDir: dir, sha, destDir: dest });
97+
assert.deepEqual(written, []);
98+
});

0 commit comments

Comments
 (0)