Skip to content

feat(terminal-demo): contain untrusted repository paths - #581

Draft
ss-o wants to merge 1 commit into
mainfrom
feature-458
Draft

feat(terminal-demo): contain untrusted repository paths#581
ss-o wants to merge 1 commit into
mainfrom
feature-458

Conversation

@ss-o

@ss-o ss-o commented Aug 30, 2026

Copy link
Copy Markdown
Member

Summary

  • add internal/repopath: kernel-enforced containment for every untrusted repository path
  • resolve each relative open with openat2 using RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS plus O_NOFOLLOW on the final component
  • provide OpenRoot, Close, OpenRead, OpenDir, and AtomicReplace
  • prove *repopath.Root satisfies the existing manifest.Reader interface, so Task 2 orchestration works unchanged against real containment

Refs #458

Scope: Task 4a of an approved split

The approved plan defines Task 4 as one commit covering repopath, fixture, snapshot, readme, and main.go wiring. The maintainer approved splitting it so the security-critical containment layer gets its own review gate rather than being reviewed inside a four-package diff.

This PR is Task 4a: internal/repopath only. Task 4b (fixture staging, snapshot extraction, README contract, __snapshot wiring) follows on merged 4a and keeps the plan's original commit message. Deliverables, contracts, and verification requirements are unchanged; only review granularity differs.

Contract boundaries

This package never resolves a path with filepath.EvalSymlinks followed by an ordinary open. That pattern is racy: a component can be replaced between the check and the open. Lexical validation is a fast sanitized rejection for obviously unsafe input and is explicitly documented as not being the security boundary; the kernel is.

Every rejection uses the existing sanitized boundary failure.UnsafePath / failure.StageSource / failure.RuleSourceMutated, public exit code 3. No public class, stage, rule, or exit code was added or renamed. The structured field names a bounded category only and never carries the offending path, which may contain untrusted repository text. A test asserts that no substantial path leaks into either Error() or the field.

OpenRead deliberately accepts a directory, because manifest.Load opens the declared fixtures directory through this same boundary and inspects its type afterwards. OpenDir is the strict variant. AtomicReplace creates its staging file with O_EXCL beneath the contained parent and renames it into place, so a pre-planted symlink at the destination is replaced rather than followed.

Verification, including mutation testing

Because this is security code, passing tests were not treated as sufficient. Each guard was removed in turn to confirm a test actually fails:

  1. Reducing the resolve flags to RESOLVE_BENEATH alone initially kept the suite green. That exposed a real gap: an intermediate symlink whose target stays beneath the root is not caught by RESOLVE_BENEATH, and O_NOFOLLOW only guards the final component. Three tests were added for that case, and they now fail against the weakened build. The implementation was restored byte-identical afterwards.
  2. Removing RESOLVE_NO_MAGICLINKS alone kills no test, because a magic link is only reachable through a symlink that RESOLVE_NO_SYMLINKS already rejects. Rather than claim coverage that does not exist, the flag is retained as defense in depth and the test carries a comment recording exactly this, so a later reader does not mistake it for proven coverage.

The decisive containment test swaps a real directory component for an escaping symlink after the path has already validated once. A lexical check passes that case; only kernel-enforced resolution rejects it.

Verification

All commands ran through the pinned scripts/in-go-image.sh wrapper (Go 1.26.5, linux/amd64); host Go was never an acceptance path.

  • go test ./internal/repopath ./internal/manifest -count=1: PASS
  • go test ./... -count=1: PASS, including internal/sandbox
  • go vet ./...: clean
  • gofmt -l .: clean
  • go mod tidy -diff: still reports only the reserved Goldmark pin, which Task 4b consumes in internal/readme

Status

No fixture staging, snapshot extraction, README validation, main.go wiring, image publication, release, pilot rollout, or pattern promotion occurred in this PR.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant