ADR-038: Contain References to the Repository, Not the Project Directory¶
Status: Accepted
Context: Every file a run opens because a document named it — an
include: or extends: {file:} target, an include: entry's
project_directory:, an env_file:, the .env, a COMPOSE_FILE entry, a
Compose file that is itself a symlink — passes a two-gate containment test:
what the path says (lexical segment math, ADR-023
§1) and where it resolves on this filesystem (_safe_read.out_of_reach).
ADR-036 put the
lexical gate at the project directory, the directory of the file being
linted. Its 0.32.0 amendment and the one after it put the physical gate at the
project directory or the directory the run started in, so that a committed
symlink into a monorepo's shared directory would be followed.
Those are two different lines, and the gap between them refused the layout
Compose is most often deployed in. Measured on 0.33.0, from a repository root,
with the Compose project in svc/:
Reference in svc/compose.yml |
compose-lint | Compose 5.5.0 |
|---|---|---|
include: [sub/part.yml] |
followed | accepts |
include: [linked-shared.yml] (link to ../shared/part.yml) |
followed | accepts |
include: [../shared/part.yml] |
gap, exit 2 | accepts |
extends: {file: ../shared/base.yml, service: base} |
gap, exit 2 | accepts |
include: [{path: sub/part.yml, project_directory: ..}] |
gap, exit 2 | accepts |
env_file: ../shared/app.env |
not opened, unread_input |
accepts |
The same file is followed through a symlink and refused when spelled with
... ADR-036's corpus could not see this: it stored files singly, with no
sibling context, so it measured the shape of reference paths and not whether
multi-file layouts work. A run over 1,821 Compose projects from 388 public
multi-file repositories, each linted from the repository root as CI does, did:
Compose accepts 1,324 of them, and 0.33.0 exits 2 on 243 — 235 of those for
exactly this reason, a .. path whose target is inside the checkout. The
shapes are a sibling stack directory (include: ../common/compose.yaml), a
shared base at the repository root, and end-to-end test cases extending
../../base-compose.yml. The sample is biased toward multi-file layouts, so
18% is an upper bound for the user base; single-file projects are unaffected.
None of the reasons recorded for the project-directory line argues against a
target that stays inside the repository. ADR-027
§7's concern is lint-host leakage: a pull request making the linter read
/home/runner/.aws/credentials, a file outside the checkout.
ADR-026 §4 is about a document narrowing
what is graded; following a reference adds graded content. ADR-023 already
admitted following a reference whose target is part of the configuration
Compose requires to run. ADR-036 adopted the project directory from the
env_file: rule because it was there, and its own justification for following
links — "content the change under review can already see" — applies word for
word to a .. path inside the same checkout.
The run directory is the wrong root to widen to. It is the checkout in CI
but wherever the command was typed locally: run from $HOME, "inside the run
directory" would cover all of $HOME, and a .. path is a plain string where
a link at least has to be committed. The repository is the boundary the
argument actually rests on, so the root is the repository.
Decision:
- One containment root for every read (
_safe_read.containment_root): the nearest directory at or above the project holding a.gitentry — a directory in a checkout, a file in a worktree or a submodule, so a submodule is its own root. Found by walking parents, never by runninggit: the answer is the same whether or not git is installed, and nothing executes on the lint host's behalf. Without a repository, the directory the run started in, if it contains the project — a tarball checkout linted from its top keeps what the 0.32.0 link rule gave it — and never a filesystem root, since a run from/would otherwise reach every file on the machine. Failing both, the project directory, which is where the rule started. - Both gates measure against that root. The lexical walk
(
project_relative) is seeded with the writing document's directory under the root, so../shared/part.ymlwritten insvc/cleans toshared/part.ymland stays inside, while../../outside/part.ymlpops past the root and leaves. The physical gate (out_of_reach) refuses a link whose target resolves outside the root. This applies toinclude:, cross-fileextends:,project_directory:,env_file:, the.envof the project and of included files, a linked Compose file, and the link half ofCOMPOSE_FILEand.compose-lint.yml. Absolute and~paths are refused as before; so is a..that climbs above the root and comes back (../../repo/shared/x), because the lexical walk pops past the start. .gitis never read, by either gate: a path naming a.gitsegment leaves lexically, and a link resolving into one is out of reach. The repository root is defined by its.git, and that directory is the one thing inside a checkout that is not the change under review — a defaultactions/checkoutwrites the job's credential into.git/config. Before this decision a committed link into.gitwas followed, and only the parse error's wording — a position, not the line — stood between the file and the log. That is a property of error formatting, not of containment. Now it is containment.- The merged document stays in Compose's frame. Inherited
env_file:paths are re-expressed relative to the project directory, not the root: a base atshared/writing./app.envfor a project atsvc/becomes../shared/app.env, which is what the primary would have to write. The oracle harness hands our merge to Compose from the project directory, and a root-relative spelling made Compose opensvc/svc/app.env. Containment is measured against the root; paths are written for Compose. COMPOSE_FILEkeeps the project-directory line for what it says. A..entry is still refused lexically, while a link in the list follows the new root with every other read. Its refusal rests on ADR-026 §4 — the.envchoosing which documents the run lints — which is a different argument from leakage, and this decision does not reopen it. If the corpus shows the shape matters, it gets its own decision.- Messages name the new line. The lexical refusal reads "outside the repository"; the physical one "resolves through a symlink to a target outside the repository". The caller-scoped remedy sentence is unchanged.
- Policy class: MINOR, as ADR-036's bump table already says for retiring a coverage-gap condition. A reference that is now followed can surface findings that were invisible, which is the new-findings class; nothing that was graded before is graded differently. No runway is needed, because nothing turns red: exit 2 becomes a verdict.
What a widened root can and cannot disclose, checked against 0.33.0 on synthetic fixtures before deciding:
- A finding quotes the document's spelling, never a substituted value. With
TOKEN=nginx:MARKERin a.env, the verdict moved from CL-0004 to CL-0019 and the message still readImage '${TOKEN}'. Zero marker hits across the sibling, linked-directory andinclude:cases, in text and JSON. - A parse error quotes a position, not a line.
env_file:reports key names only; values are never retained (ADR-026 §5).- So the residual channel is a one-bit oracle per rule on a value's shape, plus the key names of an env file. Both already existed for anything inside the project directory, and for anything in the checkout through a link.
Residuals accepted, and written down because no hook catches them:
- Untracked files inside the checkout. A CI step that writes a
.envwith secrets into the workspace before linting puts it inside the root, and a hostile pull request could point anenv_file:at it to list key names. The 0.32.0 link rule already allowed this; the boundary is "the checkout", not "tracked content", and the defence is the value-free report above. Checking tracking status would need a git subprocess, which decision 1 rules out. - A dotfiles-managed
$HOME. On a developer machine whose home directory is a repository, the root is$HOME. The content is the user's own and the report is their own; CI runners do not do this. - A tarball linted from elsewhere falls back to the project directory: today's behaviour, no regression.
- A
..that leaves the root and returns stays refused, as decision 2 says. Compose accepts it; no corpus project does it.
Verified:
- The oracle harness now writes every generated tree as a repository and
starts the run above it, so the root is found from the marker and not
from the run directory. The seeds that carried a registered policy gap
(
project_directory: ..above the Compose project, still inside the tree) flip to agreement with Compose 5.5.0, and the one registered divergence,include-outside-the-project, is re-pointed at a fixture whose project is its own repository so that it keeps asserting both halves. - Over the multi-file sample, compared against the same commit without this
change: the 235 projects become graded (126 exit 0, 109 exit 1); 8 of the
1,324 Compose-accepted projects still exit 2, all for other reasons (six
remote
include:targets, one interpolated path, one chain opening more than 64 files); no project graded before exits 2 now; and among the 1,478 projects graded by both, the only findings that changed are new CL-0020 findings in 7 projects whoseenv_file:above the project directory is now read instead of reported asunread_input. - Over the single-file corpus, zero findings changed: those files have no sibling context, so nothing there could be followed.
Consequences: ADR-036 §7's "outside the project directory" residual
becomes "outside the repository"; its two link-rule amendments are
superseded by one root. ADR-027 §7 and ADR-023 §3 (as amended by #780) are
amended to say "repository". docs/compatibility.md's gap list,
docs/what-a-run-reads.md, docs/how-it-works.md and the README describe
the new line. _safe_read.escapes_project is unchanged and still the one
physical test; containment_root is the only place the root is chosen.