WIP: next #34

Draft
clawbot wants to merge 6 commits from next into main

6 Commits

Author SHA1 Message Date
cc6a5a00e7 Run every lint in a container via Dockerfile.lint (closes #40)
All checks were successful
check / check (push) Successful in 9s
script/lint runs the linter directly when it is already inside a
container and otherwise builds Dockerfile.lint, so the linter never runs
on a developer host. That closes three host-only mechanisms: the result
cache golangci-lint keys on file content rather than location, which
produced a confirmed false green and findings reported against other
checkouts; the host-global $TMPDIR/golangci-lint.lock, which fails a run
in a way no caller can distinguish from findings; and host/container
version skew, which hid thirteen findings on one repo.

Detection is on LINT_IN_CONTAINER=1, set by every Dockerfile, and on
nothing else. The two directions are not symmetric: a false negative
inside a container attempts a nested docker build, finds no daemon and
fails loudly, while a false positive on a host silently lints there,
which is the defect this issue exists to kill. /.dockerenv is therefore
rejected even as a fallback -- measured absent inside BuildKit RUN steps
and present on any host that is itself a container, so it fails in both
directions and one of them is the dangerous one.

Nothing else changes shape. The Dockerfile still runs make check,
script/check still runs test, lint and fmt-check, script/cibuild is
still a single docker build with CHECK_EPOCH and VERSION, and the Go
multistage lint stage and its COPY --from=lint ordering dependency
survive with ENV LINT_IN_CONTAINER=1 added. Dockerfile.lint is the
standalone developer-host path and carries the same CHECK_EPOCH guard,
with the ARG below the dependency layer so only the lint re-runs.

The script/bootstrap golangci-lint install and the per-checkout
GOLANGCI_LINT_CACHE/TMPDIR wrapper are deleted as superseded. Neither
has a caller left. A JS repo's yarn install stays: the rule is that no
lint verdict may come from a host invocation, not that no linter binary
may exist there, and in a repo whose formatter is its linter the
formatter necessarily runs on the host.

golangci-lint config verify is kept, on measurement. Under the pinned
v2.12.2 a bogus top-level key and a bogus key under linters.settings.lll
both pass `golangci-lint run` with exit 0 and `0 issues` while config
verify exits 3 and names them; an unknown linter name fails run and
passes config verify. It needs no network: every case reproduced
byte-identically under `docker run --network none`, in a container where
`getent hosts golangci-lint.run` exits 2.

Comment blocks were cut hard across every file this unit touches.
.dockerignore drops from 67 comment lines to 28, script/cibuild from 17
to 12, script/docker from 18 to 12, and prompts/REPO_POLICIES.md from
1182 lines to 907. What remains says why a line is load-bearing; the
discovery narratives are gone.

config verify lives in script/lint's native branch rather than in a
Dockerfile, so every path that lints inherits it: the lint stage of the
main image, which is what CI runs, as well as Dockerfile.lint. Putting
it in one Dockerfile is how the other path silently loses it.
2026-08-10 14:05:54 +00:00
clawbot
0620416869 Give golangci-lint per-checkout cache and lock state (closes #30)
All checks were successful
check / check (push) Successful in 7s
A golangci-lint result on a host running many concurrent workers does not
reliably belong to the tree that asked for it. Two independent mechanisms,
which have repeatedly been mistaken for one:

The result cache is keyed on file content, not location, so two checkouts
of the same commit hold byte-identical files, share cache entries, and one
tree's findings are served for the other under the other tree's path. This
produced a confirmed false green as well as the loud false reds. Moving
workers from shared worktrees to their own clones does not address it —
two clones collide exactly as two worktrees did — and removes only the
foreign-path artefact that made the defect noticeable.

The concurrency lock is $TMPDIR/golangci-lint.lock (pkg/commands/run.go,
acquireFileLock), host-global and independent of GOLANGCI_LINT_CACHE, with
a five-second acquire timeout, so it fails when the host is busiest. A
private cache directory does not isolate it. Setting only the cache closes
the contamination half and leaves runs failing red on a condition that is
not a result at all.

REPO_POLICIES.md now carries the canonical Go script/lint: both variables
scoped into a .lint-cache/ directory inside the checkout, above any
container-versus-host branch so every path reaching the linter gets them;
--allow-serial-runners, which keeps the mutual-exclusion guard and queues
rather than aborting, for the same-checkout overlap TMPDIR scoping cannot
cover, with --allow-parallel-runners rejected because it deletes the
guard; and a bounded retry that treats the lock error as VOID rather than
as findings, exiting 75 on exhaustion so it is neither a pass nor a
failure. Detection is on the stderr stream and never on exit status:
findings go to stdout, so a finding quoting the lock message in source
cannot be retried away, and the exit status is not a stable discriminator
anyway. The interim void rule is recorded with the ../ clause that the
original filter missed, and with its limit stated — it catches
contamination that names foreign files, not contamination that suppresses
findings. Both checklists gained the corresponding items, since a half-fix
that sets only the cache reads as complete.

GOCACHE was measured rather than assumed and does not need isolating: with
the two variables scoped per checkout and GOCACHE shared at the host
default, each checkout reported its own paths.

Verified with the snippet extracted from the committed document and
executed as a consuming repo would adopt it, each control paired against
the pre-fix form: contamination reproduced on the pre-fix script and
absent on the adopted one; a stub linter colliding twice then clearing,
with the retry engaging and succeeding; exhaustion exiting 75 with a VOID
message; a genuine finding whose text quotes the lock message reported as
findings with no retry; and a real held lock failing the pre-fix script
with exit 3 while the adopted script, inheriting the same environment,
completed in one second.
2026-08-09 18:51:22 +00:00
3a218497b8 Keep in-repo agent scratch out of the build context and out of git (closes #27)
All checks were successful
check / check (push) Successful in 17s
The canonical .dockerignore and .gitignore both omitted the in-repo agent
scratch directory. On this fleet that directory holds one worktree per
in-flight agent -- an entire additional checkout of the repo each -- so under
`COPY . .` all of it reached the build context and the image. Measured on this
repo before the change: five planted scratch files, at every depth beneath the
directory, all present inside a probe image built from the real context.

Three consequences, only the first of which is about size. The context inflates
by a multiple of the repo. Another session's unreviewed and sometimes
uncommitted work is copied into a build artifact. And the directory is created
and destroyed constantly by tooling, so it invalidates `COPY . .` for reasons
that have nothing to do with this repo's content -- which is the accidental
cache protection described at length in the issue thread, and the reason this
change was sequenced behind the CHECK_EPOCH bust rather than landed alongside
the rest of the .dockerignore work.

The two entries are deliberately different shapes, because the two files have
different semantics and neither is derived from the other. In .dockerignore the
entry is anchored, `.claude`, with no `**/` prefix: the directory occurs exactly
once, at the context root, and the prefixed form additionally matches any nested
directory of that name. Measured rather than argued -- the `**/`-prefixed
control was built and enumerated too, and it removes prompts/.claude/ from the
context as well, which in a repo with a legitimately named nested directory
would silently delete it from the build. In .gitignore the entry is unanchored,
`.claude/`, because a .gitignore pattern already matches at every depth;
`git check-ignore -v` confirms it covering both .claude/ and prompts/.claude/,
so a `**/` prefix there would be redundant at best, and on an anchored pattern
it would be actively wrong.

Anchoring buys that at the cost of a residual exposure, and the vendored files
now say so rather than only asserting the reason to anchor. "Occurs exactly
once, at the context root" is a property of how agents are run, not of the
tooling: the directory is created in the agent's working directory, so a
monorepo running a per-service agent in services/api/ still ships
services/api/.claude/ into the context and the image -- the exact exposure this
change exists to close, left open in the repo shape where it is likeliest. The
.dockerignore header block, the REPO_POLICIES.md bullet and both checklists
state the gap and the remedy (anchored entries for the subdirectories that have
one, or `**/.claude` once no legitimately named nested directory would be
caught). Consuming repos receive the files and not the tracker, so a caveat that
lives only in a PR body is not a caveat.

It is not case-folded the way the neighbouring secret patterns are: tooling
creates the directory in exactly one spelling, so a folded pattern would add no
coverage. The earlier justification -- that a miss costs bloat rather than
exposure -- is gone, because it contradicted this issue's own framing, in which
the cost of a miss is unreviewed work in an image layer.

The second half of this change is the consequence that ships broken silently.
Excluding .git means `git describe` cannot run in any build stage, and it fails
quietly there rather than erroring: `-X main.Version=` comes out empty, the
binary reports no version, and the build still exits 0. The Go template in
REPO_POLICIES.md had `ARG VERSION=dev` and never said where VERSION came from,
which is precisely the gap a reader fills in with `git describe` inside the
build. It now says: computed on the host, threaded in with `--build-arg
VERSION=...`, shown as a complete command rather than as two rules each
documenting half of one. script/docker and script/cibuild do it, with the same
discipline the epoch already has -- assignment on its own line, because a
failing command substitution inside an argument does not trip `set -e`, plus a
non-empty fallback so a build from an export with no .git reports `unknown`
rather than an empty string that reads as a successful version.

That fallback is applied in exactly one place, and that place can actually
execute. `git describe ... || true` leaves the value empty when it fails, and
the `[ -n "$version" ]` line is what substitutes `unknown`. Folding the fallback
into the substitution as `|| echo unknown` would have left the guard unreachable
-- harmless in itself, but a guard that cannot fire is indistinguishable from
one that works to every repo that copies it, and canonical text should not carry
a check that is decorative. Measured firing in both sh and dash: not a git
repository -> unknown, repository with no commits yet -> unknown, this
repository -> the describe output. The checklist now carries the guard line as
well, so the vendored guidance and the vendored script no longer disagree.

The scripts pass VERSION unconditionally rather than growing a per-repo variant.
This repo's Dockerfile declares no `ARG VERSION`, and BuildKit was measured
accepting the unconsumed arg silently -- no warning, no cache effect, confirmed
by the paired runs below in which the bootstrap layer still caches. The
alternative, leaving it to each repo, reintroduces the trap: a repo that needs a
version and finds no VERSION in its scripts writes `git describe` into the
Dockerfile, which is the failure being closed.

The same correction reaches the two Go documents that carry the GOLDFLAGS
pattern, since a `$(shell git describe)` evaluated inside a build stage is
exactly this empty version. Both are now `?=`, and their comments say precisely
when that matters: where a build stage compiles by invoking make, `ARG VERSION`
puts the value in the environment and `?=` defers to it, whereas the canonical
Go template compiles with `go build` directly and uses the Makefile on the host
only -- still `?=`, so that a repo which later moves its build behind make does
not silently start shipping an empty version. Leaving them as `:=`
would have left the corpus telling a reader one thing in the policy and the
opposite in the styleguide.

Both repo checklists gain the entries too. They are what an agent reads while
writing these files, so they are where the wrong shape actually gets written:
the .gitignore item is the one an existing repo never re-fetches, and it now
names `.claude/` explicitly along with the warning not to prefix it.

Verification, by enumerating a probe image rather than by reading the patterns.
Standalone minimal Dockerfile held outside the context, `--no-cache` scoped to
that one image, no prune of any kind. Before: all five planted scratch files in
the image, 42 files total. After: zero, 37 files total, with README.md,
script/check, prompts/NEW_REPO_CHECKLIST.md and a planted probe_src/app.md all
still present as positive controls, so the exclusion is a real exclusion and not
a COPY that stopped copying. Transferred context fell from 161.86kB to 68.82kB,
recorded as corroboration only: BuildKit reports a delta, not a total, and an
earlier run in this repo transferred 2.18kB while shipping 43 files.

The CHECK_EPOCH verification was re-run under the changed context, because the
context moved underneath the earlier measurement. Two consecutive script/cibuild
runs on an unchanged tree: run 1 in 17.07s, run 2 in 5.73s, both executing the
check layer with a distinct epoch and real prettier output from both lint and
fmt-check. The `RUN script/bootstrap` layer is CACHED in run 2, which is the
validity control -- it proves no concurrent prune landed between the runs and
that no --no-cache path was taken, so the check layer executing is the bust
working rather than a cold cache.

Planted files were removed afterwards and their absence confirmed against the
filesystem with `find`, not against `git status`, which cannot see them once
.gitignore covers the directory -- the same blind spot that made the earlier
secret exposure invisible.
2026-08-09 17:13:10 +00:00
fd78aeb003 Keep secrets out of the Docker build context at every depth (closes #29)
All checks were successful
check / check (push) Successful in 21s
The canonical .dockerignore was three lines -- .git, node_modules, .DS_Store
-- while the canonical Dockerfile does `COPY . .`, so a developer's local
.env, *.pem or *.key was shipped into the build context and could land in an
image layer. Nothing surfaced it because .gitignore covers those patterns, so
the files are invisible to every git-based check.

The obvious repair, copying .gitignore's secret patterns across, is worse than
the gap it closes. .dockerignore does not use .gitignore semantics: Docker
matches with moby/patternmatcher, which is filepath.Match semantics plus a `**`
extension compiled to a regexp. `*` does not cross `/`, and a pattern without
a leading `**/` is anchored at the build-context root. A file listing .env,
*.pem and *.key therefore reads as solved, reviews as solved, and protects
only the repository root, while config/.env and certs/server.key still ship.
The three-line file at least invited scrutiny; the transplanted form
manufactures confidence and stops anyone looking.

So every depth-independent pattern here carries the `**/` prefix and only
genuinely root-anchored entries stay unprefixed. `**/node_modules` fixes a
defect the three-line file had today for any nested node_modules,
independently of the secret exposure.

Coverage is not limited to the three patterns the issue names, because the
enumeration found more shapes reaching the image. `**/*.env` covers the
prod.env / local.env convention, which the .env and .env.* spellings miss
entirely; `.envrc` is a secrets file by direnv convention; *.p12 and *.pfx are
bundles carrying private keys; and id_rsa, id_dsa, id_ecdsa and id_ed25519 are
the extensionless SSH keys that were covered before only when someone happened
to append a .key suffix.

Matching is case-sensitive, so `**/*.key` does not match certs/SERVER.KEY,
which is reachable on the case-insensitive filesystems most laptops use.
Doubling each pattern with an ALL-CAPS twin is not the fix: measured against
the planted set it still ships certs/Server.Key and certs/Ca.Pem while reading
as though case were handled, which is this issue's failure mode restated in a
new place. The matcher supports character ranges, so every secret name is
written that way -- `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
`**/.[eE][nN][vV][rR][cC]`, `**/[iI][dD]_[rR][sS][aA]` and the rest. The
extensionless SSH keys and .envrc are folded for the same reason the
extensions are: on the very filesystems that make SERVER.KEY reachable, direnv
reads .ENVRC and ssh reads ID_RSA, so exempting them would have contradicted
the rule that justifies the folding. Since `*` matches the empty string,
`**/*.[eE][nN][vV]` already covers a bare .ENV and no separate literal .env
entry is needed; the literal one is gone rather than left to imply that case
is unhandled there.

Deliberately not covered: bare `key` and `pem` filenames, which no tool
produces and which collide with legitimate paths (a `**/key` pattern would
delete an internal/key/ package directory from the context); `**/id_*`, which
would match ordinary source such as id_generator.go and ID_MAP.go; and *.crt
and *.cer, which are public certificates rather than secrets and are sometimes
a legitimate build input. Each of those is planted as a positive control and
verified present in the image after the change.

The one predictable false positive is a committed env template: `**/*.env`
excludes example.env. The remedy travels with the file rather than living in a
review thread -- the header comment and the policy both say to re-include it
with a negation, `!docs/example.env`, and never to delete the pattern, which
would reopen the exposure for everything else it covers.

The OS and editor patterns are included on their own merits rather than by
mirroring .gitignore. None of them is ever a build input, and editor state in
particular churns under a developer's hands, so each one is a source of
`COPY . .` invalidation carrying no information about the source tree. Now
that the checks are keyed on CHECK_EPOCH rather than on accidental context
churn, there is no reason left to keep churn in the context. Language build
artifacts are deliberately absent: they are per-repo, and the file's header
comment tells consuming repos to add their own host-built binaries, which is
the case that actually bites -- a host `make build` drops a multi-megabyte
artifact into the context where .gitignore hides it from every git-based
check. That comment gives the anchored form explicitly, `/myapp` rather than
`**/myapp`, because the prefixed spelling also matches cmd/myapp/ and deletes
the package directory. The header comment is the only part of this guidance a
consuming repo actually receives, since it is vendored with the file.

.gitignore is untouched. Its semantics are the inverse: an unanchored pattern
already matches at any depth, so `**/`-prefixing it produces a file that is
wrong in a way that looks careful. That asymmetry is why "derive one from the
other" was the wrong instruction, and it is now written down in
REPO_POLICIES.md in both directions, together with the case-sensitivity rule,
the negation remedy, and the requirement to verify by enumerating the image
rather than by reading the patterns. Every consuming repo inherits
.dockerignore by copy, so the trap has to live where the next person looks,
not only be fixed once here. Both repo checklists gain the same requirements.

Verified by planting 130 secret files -- twenty-six name shapes, including
every capitalisation of .env, .env.*, prod.env, .envrc, id_rsa, id_ed25519,
ca.pem, server.key, bundle.p12 and bundle.pfx, at five depths from the context
root to a/b/c -- alongside nine positive controls, then building a standalone
probe image doing `COPY . .` and listing what actually landed inside it. The
patterns as first written leaked 35 of the 130: every .ENVRC, .Envrc, ID_RSA,
Id_Rsa, ID_ED25519, .ENV.PRODUCTION and .Env.Local, at all five depths. A
lowercase-only control leaks 80, so the probe is not vacuous. After this
change: zero, with all nine controls still present, including
internal/ID_MAP.go and certs/CA.CRT.

Transferred-context size is recorded but load-bearing on nothing: an earlier
run reported 2.18kB transferred while 43 files, five of them secrets, were in
the image. BuildKit transfers only the delta from the previous build, so the
number describes the transfer and not the contents.

Planted files were removed and their absence confirmed against the filesystem
rather than against `git status`, which could not have seen them.

`make docker` re-run after the change: the check layer executed rather than
being served from cache, so the CHECK_EPOCH verification still holds under the
altered build context.
2026-08-09 16:42:00 +00:00
clawbot
d173e69f85 Make the pinned golangci-lint actually reach the host (closes #28)
All checks were successful
check / check (push) Successful in 9s
REPO_POLICIES.md now carries the canonical script/bootstrap snippet for Go
repos alongside the .golangci.yml bullet, where the pinned linter version
already lives.

The guard it replaces, `if missing golangci-lint; then go install ...; fi`,
tests PATH presence and never version, so on any already-provisioned machine
the pin is inert and a version bump is a no-op. The Dockerfile installs
unconditionally into a clean image, so CI and local then disagree about what
the linter is: a local `make check` green while `make docker` rejects the same
commit, and a container run surfacing findings the host run cannot see.

Comparing versions alone is not enough. `go install` writes to GOBIN (or
GOPATH/bin) while callers resolve through PATH, so a shadowing binary earlier
in PATH lets the install succeed and change nothing a caller ever sees, while
bootstrap prints success. The canonical form therefore compares the installed
version against the pin, re-resolves through PATH after installing and asserts
the pin, and treats any unparseable --version output as a mismatch so the
failure direction is a redundant install rather than a skipped one.

The comparison is exact over the whole version token. A parser that stops at
the first `-` reports 2.12.2 for a host running 2.12.2-rc1, which compares
equal to a 2.12.2 pin and skips the install -- the original defect, reachable
through the comparison meant to close it, and not caught by requiring the pin
to be tagged, since a pre-release is tagged too.

When the assert fails the diagnosis is derived from the resolved path rather
than asserted: a path outside the install directory is shadowing and the
operator is told to remove it or reorder PATH; a path inside it is not, and
saying so would send them after a fault that does not exist; no resolution at
all means the install directory is simply absent from PATH. A trailing slash
on GOBIN is normalised away, since it would otherwise make the inside-the-
directory test miss and misreport shadowing.

The snippet ends with a call site, and both success paths print a confirmation
naming the version. Two definitions with no invocation are a silent no-op with
exactly the shape this change exists to close, and a success path that prints
nothing is byte-identical to that no-op: same exit status, same empty output.

The version helper ends in `|| true` so a --version that exits non-zero cannot
kill the script through `set -e` under `set -o pipefail` before the diagnostic
is printed, which the styleguide's bash form would otherwise do.

The policy text states each of those as a requirement rather than leaving them
implicit in the code, and records why the commit-pinned `go install` ref
satisfies the hash-pinning rule: a commit hash is not a mutable tag, and the
checksum database verifies the fetch, with no repo go.sum consulted, since
`go install pkg@version` ignores the go.mod in the current directory or any
parent. Whether a go.mod tool dependency should replace that is an open
decision and is linked rather than settled here. The two version strings must
be kept in sync and must match exactly what --version prints; tagged pins are
preferred because the expected string is then derivable from the ref, rather
than because the comparison cannot handle a pseudo-version.

Verification runs the controls against the block as a consuming repo would
adopt it, pasted into a script/bootstrap-shaped file and executed, rather than
sourcing it and calling the function directly.

The node and yarn handling described earlier in the document is untouched.
2026-08-09 16:01:36 +00:00
clawbot
51c394552e Bust the Docker check-layer cache with a per-invocation CHECK_EPOCH (closes #26)
All checks were successful
check / check (push) Successful in 7s
script/cibuild was a plain `docker build .`, and the Dockerfile does
`COPY . .` followed by `RUN make check`. Docker invalidates a COPY layer
only when the copied content changes, so on an unchanged tree the check
layer was served from cache, the suite never ran, and the build still
exited 0. Measured here: run 1 took 18.5s and ran the suite; run 2 on a
byte-identical tree took 0.286s with `RUN make check` CACHED.

script/cibuild and script/docker now assign a per-invocation nonce on its
own line and pass it as --build-arg CHECK_EPOCH. The Dockerfile declares
ARG CHECK_EPOCH, guards it with `[ -n "$CHECK_EPOCH" ] || exit 1`, and
expands it into the check command. Post-fix, two consecutive runs both
execute make check (17.4s / 8.1s) with `RUN script/bootstrap` still
CACHED, so dependency layers are untouched and the build ceiling is not
at risk.

The guard is what makes a bare `docker build .` — the command
REPO_POLICIES named verbatim — fail closed rather than reuse the empty
and therefore stable cache key; verified failing in 0.455s. Holding the
epoch constant restores the false green (run 2 fully CACHED), which pins
the varying value as the operative mechanism rather than a coincidence.

Because the guard references $CHECK_EPOCH it is itself value-keyed:
BuildKit renders the epoch into that layer's description and re-runs the
layer when the value changes. Each stage therefore has two independent
invalidation points, the guard and the expansion, and the guard always
precedes the check RUN. Both are kept and the prose now records this;
the expansion remains defence in depth and is what puts the epoch in the
build log.

The false guarantee was org-canonical text in more than one document, so
it is corrected everywhere it appeared rather than only where the issue
first found it. REPO_POLICIES.md carried it in two places, and its Go
multistage template had check steps in two stages; ARG is stage-scoped,
so both stages get the treatment or the fleet inherits the half-fixed
shape. CODE_STYLEGUIDE_GO.md restated the guarantee for the bare command
this change makes fail closed. NEW_REPO_CHECKLIST.md specified the
pre-fix script/cibuild verbatim, so every new repo would have been born
with the false green, and EXISTING_REPO_CHECKLIST.md ended on a
`docker build` acceptance item that the guard makes unsatisfiable by
design — an agent working that checklist would have been led to delete
the guard to tick the last box. Both checklists' Dockerfile criteria were
also satisfiable by a Dockerfile whose check layers are still frozen, and
now require the ARG and guard in every check-running stage.

REPO_POLICIES.md's own Dockerfile criterion carried that same incomplete
form; it is tightened by cross-reference to the CHECK_EPOCH rule rather
than by duplicating the canonical block. The Go template's Key points
gain a caveat that the cache-bust turns the `COPY --from=lint` no-op into
a content-cache hit, so a repo using a file-dependency trick for stage
ordering must re-prove that ordering on a warm cache after adopting it.
That was re-proved in another repo in the org which uses the trick with a
marker file, where the ordering held; the caveat states explicitly that
it was not verified here, this repo being single-stage with no lint stage
to order against.
2026-08-09 15:15:03 +00:00