Add verified compilation download - #9
Conversation
Technical reviewThe riskiest surface in this CLI so far: it takes bytes from a server and writes a directory tree onto the user's disk. Most of this review is an audit of the write path, because that is where a mistake stops being a bug and starts being a vulnerability. Path validation is complete
component.length === 0 ||
component === "." ||
component === ".." ||
Buffer.byteLength(component) > MAX_COMPONENT_BYTES ||
!COMPONENT_PATTERN.test(component) ||
component.endsWith(".") ||
component.toLowerCase() === ".git"Plus Windows device names on the stem. That covers the classic archive-extraction attack, where a server-supplied path like Two of those go beyond the server-side equivalent I read in firstdraft/firstdraft#236. The trailing-dot rejection matters on Windows, where The write path is defended at four independent layersAny one of these would help. Together they mean a compromised or buggy server cannot quietly corrupt the destination. Contents are verified before acceptance. Each file's SHA-256 is checked against the manifest during parsing, so bytes that do not match their declared digest never reach the filesystem. Writes cannot clobber. Symlinks are seen, not followed. The tree is verified after writing, then published atomically: writeArtifactTree(artifact.files, temporaryDirectory);
chmodSync(temporaryDirectory, DIRECTORY_MODE);
verifyArtifactTree(artifact.files, temporaryDirectory);
assertTargetStillAvailable(target, parent);
renameSync(temporaryDirectory, target);The output path never holds a partial or unverified tree. It does not exist, and then it exists complete. Two details in that sequence are easy to get wrong and are right here. The temporary directory is created inside the target's own parent: const prefix = path.join(parent, `.firstdraft-${path.basename(target)}-`);Had it gone to And The cli#8 finding is closedOn #8 I noted that the body claimed to "exercise the packed executable against a real local endpoint," but the packed smoke had zero network references and the real server lived only in the source-level tests. The two halves were separate. They are joined now. const server = createServer(async (request, response) => { /* ... */ });
// ...
["plan", "compile", "--output", output], // via spawnPackedCliSo the artifact a user would actually install now performs a real HTTP compilation and materialization in CI. That is a stronger claim than the one I questioned, and this time the diff supports it. No findingsI went looking specifically for the ways this class of code fails and did not find one. Path traversal, overwrite, symlink following, unverified contents, partial output on failure, and cross-filesystem rename are all handled. The boundary in the body is also accurate to what I read: no cancellation, resume, remote publication, authentication, or retry-after-ambiguous-mutation. The last of those is the right omission, since retrying a mutation whose outcome is unknown is precisely what the error contract from #6 exists to prevent. |
Lesson: writing somebody else's files to your diskYour code downloads an archive and extracts it. Or accepts an upload and saves it. Or, like this PR, fetches a generated project and writes it to a directory. The filename came from somewhere else. That one fact changes everything about how you write the next twenty lines. The attack that has been working since 1991The archive contains a file named: Your extractor joins that onto the destination directory and writes it. The path resolves out of the destination, up into the home directory, and now somebody else can log in as you. This is Zip Slip, and it is remarkable mostly for how long it keeps working. It has hit archive libraries in every major language, and it hits again every time someone writes their own extraction loop and reaches for the obvious code: writeFileSync(path.join(destination, entry.name), entry.contents); // no
Rejecting, not sanitizingThe tempting fix is to clean the path. Strip Validate instead, and reject anything that is not obviously fine. This PR splits on component.length === 0 ||
component === "." ||
component === ".." ||
Buffer.byteLength(component) > MAX_COMPONENT_BYTES ||
!COMPONENT_PATTERN.test(component) ||
component.endsWith(".") ||
component.toLowerCase() === ".git"Allowlist the characters, reject the dot segments by name, and refuse anything oversized. Reject unknown input rather than repairing it, the same instinct as strong parameters. Two of those checks are experience talking. If you are in Ruby, full = File.expand_path(File.join(destination, entry_name))
raise "unsafe path" unless full.start_with?(File.expand_path(destination) + File::SEPARATOR)Three more things that biteOverwriting. The default write mode replaces whatever is there. If the destination has files, an extraction can silently destroy them. Open exclusively instead, so an existing file is an error: writeFileSync(destination, contents, { flag: "wx" }); // fails if it existsSymlinks. If an attacker can get a symlink into the destination first, writing "through" it lands somewhere else entirely. Use Partial output. Something fails halfway. Now the destination holds half a project, and the next thing to look at it has no way to know. Write somewhere private, verify, and publish atomically: temporaryDirectory = mkdtempSync(prefix);
writeArtifactTree(files, temporaryDirectory);
verifyArtifactTree(files, temporaryDirectory);
renameSync(temporaryDirectory, target);
One detail there is worth copying exactly. The temporary directory is created inside the target's parent, not in Verify before you publish, not afterThe other habit in this code: every file's SHA-256 is checked against the manifest before anything is written, and the whole tree is verified again before the rename. That ordering matters. Verifying after publishing means a user can already be looking at bad output when you discover it. Verifying inside the temporary directory means a failure leaves nothing behind but a directory you delete. The short versionAny time a path comes from outside your process, treat it as hostile:
Five rules, and every one of them exists because somebody skipped it. |
Let agents turn an accepted Foundation Plan into a local app without trusting mutable status responses or unverified artifact bytes. Pin the Compilation lifecycle, validate the deterministic artifact, and publish only a verified tree at an absent destination.
7f7f062 to
c58325b
Compare
Windows CI resolutionThe three Windows failures had one root cause: the materializer compared exact POSIX The amended commit keeps the trust boundary intact:
The platform-aware test now sets a restrictive Node documents the limitation in its filesystem mode guidance. Verification on amended head
The hosted OS matrix has restarted on the amended commit. |
Summary
firstdraft plan compile --output <absent-path>for one conditional, pinned CompilationBoundary
This is the local smoke-test slice. It does not add cancellation or resume commands, MCP, remote publication, deployment, authentication, or retries for ambiguous mutations.
Verification
asdf exec npm run check