docs(#4653): describe the consolidated path-containment seam in the security model

The security model's input-validation section still described path traversal as
a per-call check with a macOS symlink footnote. It now describes what actually
exists: one predicate, a module-internal engine, three exported shapes none of
which can hand back a usable path when the answer is unsafe, the branded return
type, and the named acceptance policy — including the point the old wording
invited a reader to get wrong, that allowing an absolute candidate does not
relax containment.

Also records the two checks deliberately NOT routed through the predicate and
why each is narrower or stricter rather than a second opinion, so a later
cleanup pass does not read them as stragglers.

Required by the Changed changeset: scripts/lint-docs-required.cjs makes
Added/Changed/Deprecated/Removed fragments demand a file under docs/, and
CONTEXT.md is at the repo root, so the glossary entry alone would not have
satisfied it. The lint currently reports invalid_pr against the mandated pr:0
placeholder and becomes meaningful after backfill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sim
2026-09-12 13:41:40 -04:00
parent 5967f1939a
commit 9953d02184

View File

@@ -138,9 +138,22 @@ GSD Core addresses prompt injection at three levels.
**Input validation (`security.cjs`).** The `gsd-core/bin/lib/security.cjs`
module is the central security utility. It provides:
- Path traversal prevention: user-supplied file paths (`--text-file`, `--prd`)
are validated to resolve within the project directory, with macOS
`/var` → `/private/var` symlink resolution handled explicitly
- Path containment: user-supplied file paths and directories are validated to
resolve within a declared root before any filesystem access. One predicate
answers this for the whole tree (epic #4636, ADR-4650). The resolution engine
is module-internal and resolves symlinks, closes a dangling-symlink existence
oracle, and canonicalizes ancestors so a not-yet-created path under a
non-canonical base (macOS `/var` → `/private/var`) still resolves. The
exported surface is `assertWithinRoot` (throws), `tryWithinRoot` (returns
`null`), and `requireSafePath` (a preserved alias of the throwing form).
All three return a branded `ContainedPath`: a plain `string` is not assignable
to it, so validating one path and then handing a different one to the
filesystem is a type error rather than a silent bug. Whether an absolute
candidate is considered at all is a named policy — `PathAcceptance.RelativeOnly`
or `PathAcceptance.AbsoluteInsideRoot` — and neither relaxes containment: an
absolute path resolving outside the root is rejected exactly as a traversal is.
A caller may decide how to degrade on rejection, never whether a path is
contained.
- Prompt injection detection: known injection patterns (role overrides,
instruction bypasses, system tag injections) are scanned in user-supplied
text before it enters any planning artifact
@@ -149,6 +162,18 @@ module is the central security utility. It provides:
- Shell argument validation: arguments passed to subshell commands are
validated before use
Two containment checks elsewhere in the tree are deliberately NOT routed through
this predicate, because each is narrower or stricter rather than a second opinion.
The backup-restore gate in `gsd-core/bin/gsd-tools.cjs` rejects symlinks outright:
the canonical predicate accepts a link whose target resolves inside the root, but
for a restore that is still wrong, because writing through the link overwrites
whatever it points at instead of materializing a regular file at the backed-up
path. And `isPathConfined` in `src/external-descriptor-trust.cts` is lexical by
design, because two install callers must validate a destination *before* the
`mkdirSync` that creates it, where `realpath` cannot resolve. A lexical check
cannot see a symlink, so callers that rely on it for a write-confinement
guarantee must pair it with their own symlink refusal.
**Runtime hook: `gsd-prompt-guard.js`.** This hook fires on every Write or
Edit call that targets `.planning/` files. It scans the content being written
for injection patterns shared with `gsd-read-injection-scanner.js` through