| id | 2606101548 | |
|---|---|---|
| title | Build execution hardening | |
| status | ✅ | |
| summary | Layer security on top of plan 2606101546's basic builder execution. Trust gate so a freshly cloned repo cannot run recipes silently. Hermetic env (allowlisted PATH and env pass-through). Atomic-write hardening (random-suffix staging, refuse if the `.mdsmith/build-staging/` root is a symlink or world-writable, symlink-safe rename). Output post-conditions: every declared output must exist; no undeclared write may slip out. Process-group kill on timeout. | |
| model | opus | |
| depends-on |
|
Make the build pass safe on an untrusted
repo. Plan 2606101546 wires recipes through
os/exec; this plan adds the defenses that
stop a hostile recipe or config from
escaping its declared outputs, leaking
child processes, or writing elsewhere.
The threat model treats .mdsmith.yml and
<?build?> directives as untrusted. Plan
2606101546 assumes trusted input; this plan
closes the gap for freshly cloned repos.
mdsmith treats .mdsmith.yml as untrusted.
A freshly cloned repo may declare recipes
that run arbitrary binaries. The build pass
refuses to run until the user marks the
config as trusted (direnv-style):
mdsmith fixruns the lint-fix pass unconditionally.- The build pass runs only when a sibling
file
.mdsmith.yml.trustexists and its content is byte-for-byte identical to the current.mdsmith.yml. Any drift makes the build pass exit with a clear "config changed since trusted; review and re-trust" message. mdsmith trust(a new subcommand) diffs the current.mdsmith.ymlagainst the stored trust contents and overwrites the marker on confirmation.mdsmith fix --no-buildis the only override: it skips the build pass without touching the trust marker.
The trust file is per-clone (in
.gitignore). CI environments opt in via
MDSMITH_TRUST_BUILD=1 instead of a file —
they are presumed sandboxed.
A run with neither <?build?> directives
nor build.hooks (plan 104) never consults
the gate — nothing would execute. Plan
104's MDS040-clean gate runs after this
one. The gate covers .mdsmith.yml because
it is the only file that can declare
build:; if config ever spreads across
files, the gate must widen with it.
Each recipe is invoked with:
Cmd.Envis exactlyPATHplus thebuild.exec.env-pass-throughnames:PATHfrombuild.exec.path(default/usr/bin:/binon Unix), pass-through default[HOME, LANG, LC_ALL]. Nothing else leaks in.Cmd.Dirset to the per-recipe staging dir (see "Atomic write hardening" below).- A new process group via
Setpgidon Unix; on WindowsCREATE_NEW_PROCESS_GROUPplus a Job Object for the kill path. - Standard streams attached per plan 2606101547; this plan is process control only.
On --build-timeout expiry, mdsmith sends
SIGTERM to the process group, waits up to
5 s, then sends SIGKILL. Windows has no
SIGTERM: there mdsmith sends
CTRL_BREAK_EVENT to the group, waits 5 s,
then kills the group via its Job Object. A
recipe that spawns daemons cannot leave
orphans behind.
Plan 2606101546's basic atomic write is replaced by:
- mdsmith
Lstats.mdsmith/build-staging/. If absent, it creates the dir andChmods to0o700(umask filtersMkdirAll's mode). A symlink or non-directory is refused. - mdsmith refuses if
.mdsmith/build-staging/is group- or world-writable on Unix (0o022mask); the user fixes the permissions. - mdsmith creates the per-recipe staging
dir via
os.MkdirTemp(random suffix) under.mdsmith/build-staging/. With steps 1-2 this stops a hostile parent from planting a symlink at the temp name. - Each declared output path maps to a
file inside the staging dir; mdsmith
pre-creates parent dirs there. Only
{outputs}is substituted with staging paths; named params are never rewritten. - After post-condition checks (below),
mdsmith renames each staged file out,
Lstating the destination first: a symlink fails the build. The replace is atomic per file — POSIXrename(2), orMoveFileEx(MOVEFILE_REPLACE_EXISTING)on Windows (whatos.Renamewraps), for same-volume paths. Multi-output rename is not transactional: if rename N+1 fails after N, mdsmith logs the partial state, removes staging, and exits FAIL; the nextfixreruns the recipe (no cache write happened). - On any pre-rename failure the staging dir is removed; outputs stay untouched.
After a recipe exits 0, two checks run before the rename phase (Bazel issue 14543 lesson):
- All declared outputs exist in the staging dir. A missing one is a build failure ("recipe exited 0 but did not produce X").
- No undeclared write landed in the
project tree. mdsmith snapshots the
output-paths' parent dirs (file list, size,
mtime, mode, sha256 of contents) before
the recipe and diffs after. Content hashing
catches edits that preserve size and mtime;
mode catches
chmod. Any added, removed, or modified file outside declaredoutputs:is a build failure.
Two known limits. It only covers the parent
dirs of declared outputs; full-tree scans
are too expensive, so writes into an
unrelated subtree are missed. Symlinks are
snapshotted via Lstat metadata plus
os.Readlink, never followed. A snapshot
scope above 2 000 directory entries is a
build error naming the oversized dir —
point outputs at a narrower directory.
PATH allowlisting and Cmd.Dir are for
build determinism, not filesystem
confinement: a recipe can still write via
absolute paths or ../, and writes outside
the snapshot scope are undetected. Real
confinement needs a sandbox.
build:
exec:
path: "/usr/bin:/bin:/opt/pandoc/bin"
env-pass-through: [HOME, LANG, LC_ALL, SOURCE_DATE_EPOCH]Both keys are optional. env-pass-through
replaces the default list (no append) —
re-list the defaults you still want; the
example adds SOURCE_DATE_EPOCH for
reproducible builds. MDS040 rejects empty
pass-through names or names containing =.
- Implement the trust gate in
internal/build/trust.go: read.mdsmith.yml.trust, compare its bytes to the current.mdsmith.yml, honorMDSMITH_TRUST_BUILD=1, and refuse the build pass on mismatch. - Add
mdsmith trustsubcommand: print the diff (usingdiff-style output) between.mdsmith.ymland the stored.mdsmith.yml.trustcontents, prompt for confirmation, overwrite.mdsmith.yml.trustwith the current config on accept. - Extend
BuildConfigininternal/config/build.gowithExec ExecCfg(path, env-pass-through). MDS040 validates entries. - Implement hermetic invocation in
internal/build/exec.go: minimalCmd.Envfrom the allowlist,Cmd.Dirset to staging,Setpgid(Unix) or process group + Job Object (Windows), group kill on timeout. - Replace plan 2606101546's basic atomic
write with the hardened version:
staging-root checks,
os.MkdirTempper-recipe dir, per-destinationLstatsymlink refusal,os.Renamereplace. Document the multi-output partial-failure semantics (cleanup; nextfixreruns the recipe). - Implement output post-conditions in
internal/build/postcheck.go: snapshot staging dir + output parents pre-recipe, diff post-recipe, fail on missing declared outputs or undeclared writes. - Integration tests:
- Missing
.mdsmith.yml.trustblocks the build pass; lint-fix still runs. MDSMITH_TRUST_BUILD=1is an alternate trust source.- Editing
.mdsmith.ymlafter trust invalidates the marker;mdsmith trustshows the diff and re-trusts. mdsmith fix --no-buildskips the gate.- A write outside declared
outputs:is a build failure; the file is left in place and named in the warning. - Recipe exiting 0 without producing a declared output is a build failure.
- A group- or world-writable staging root is refused at start.
- A recipe that spawns a child process
and exceeds
--build-timeoutis killed (process group); the child is not orphaned. - A recipe is invoked with
Cmd.Envcontaining only the allowlisted names.
- Document the trust gate, hermetic env,
atomic-write hardening, and output
post-conditions in
docs/guides/directives/build.md. Include CI guidance forMDSMITH_TRUST_BUILD=1. Export it indemo.tape's hidden setup so the build demo (plan 2606101546) keeps running. Note that the defaultbuild.exec.pathomits mdsmith's own install dir; a self-invoking recipe adds it.
- Build pass refuses to run when
.mdsmith.yml.trustis missing or stale (andMDSMITH_TRUST_BUILD=1is not set); lint-fix still runs -
mdsmith trustshows the config diff and updates the trust marker on confirmation -
mdsmith fix --no-buildskips the trust check and the build pass together - Recipe writing outside
outputs:is a build failure; the undeclared file is named in the diagnostic - Recipe exiting 0 without producing every declared output is a build failure
- Atomic write uses
os.MkdirTempwith a random suffix under.mdsmith/build-staging/; that staging root is refused if it is a symlink, not a directory, or group- or world-writable - Rename phase
Lstats each output destination, refuses to replace a symlink, then usesos.Rename; multi-output partial failure cleans up the staging dir and exits with FAIL (nextfixreruns the recipe) - Recipe is invoked with
Cmd.Envrestricted to the allowlist andCmd.Dirset to the per-recipe staging dir - A snapshot scope above 2 000 directory entries is a build error naming the oversized dir
- Recipe runs in its own process group; timeout fires SIGTERM, then SIGKILL after 5 s
-
build.exec.pathandbuild.exec.env-pass-throughparse, validate, and take effect - All tests pass:
go test ./... -
go tool golangci-lint runreports no issues