Skip to content

Blocking multi-scanner stack: CodeQL security-extended, Semgrep, ruff, npm audit — every finding fixed, gated through the existing gates - #1246

Merged
bradflaugher merged 15 commits into
devfrom
claude/codeql-advanced-setup-go-r078t8
Aug 22, 2026
Merged

bradflaugher merged 15 commits into
devfrom
claude/codeql-advanced-setup-go-r078t8

Conversation

@bradflaugher

@bradflaugher bradflaugher commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Started as "restore the broken CodeQL setup"; finished as a blocking, agent-readable scanning stack where every finding was fixed — none deferred, none silenced without a written, mutation-tested reason — and everything gates through the checks that already gate (ci-gate on main, Dev gate on dev), with no branch-protection change.

Verified end-to-end in CI: Dev CI run 525 (32580031374) is green on this head with the full stack live — CodeQL security-extended on all four languages, Semgrep's four registry packs, ruff check + format, npm audit + override canary, all blocking.

Design notes: docs/SCANNING.md (the stack: who checks what and why) and docs/CODEQL.md (why default setup died, how Go extraction is proven, why quality queries moved to ruff).

What was broken

CodeQL default setup was OFF — nothing scanned this repo. When enabled it died on Go: go.mod requires go >= 1.27.0 (running go 1.26.6; GOTOOLCHAIN=local). The failure sat behind a red-but-not-required check for weeks, which is the rot pattern several pieces of this PR exist to prevent recurring.

The stack that ships

lane scope blocks via
CodeQL (security-extended, 4 languages) interprocedural taint / dataflow ci-gate / Dev gate (workflow_call)
Semgrep (p/github-actions p/golang p/javascript p/python, pinned 1.174.0) fast SAST + Actions supply chain ci-gate / Dev gate (workflow_call)
ruff 0.15.8 (E4,E7,E9,F,B,SIM,S) + ruff format --check Python lint/security/format ci-gate / Dev gate + make lint
npm audit --audit-level=low (web + rampart) + override staleness canary npm dependency CVEs ci-gate / Dev gate
grype (tightened to fixable CRITICAL + HIGH) sandbox image CVEs existing lanes

Both scanners fail their job on any finding and print findings (with file:line) plus coverage counts (files in the database, files scanned/skipped, parse errors) to the job log and step summary — so a green check means clean, not ran, and an agent can fix from the log without the Security tab. SARIF still uploads to the Security tab; Semgrep JSON uploads as an artifact.

Gating mechanism (corrects an earlier claim in this PR that a settings click was needed): codeql.yml and semgrep.yml are reusable workflows (on: workflow_call) called as jobs by ci.yml/dev-ci.yml, so they sit in the gates' needs like any other job.

Every finding fixed

  • Go extraction proven, not assumed: actions/setup-go with go-version-file: go.mod (never a literal — the existing directory-wide test enforces this, verified by mutation), GOFLAGS: -tags=fleet_host_executor so host.go is in the DB. Extractor output + DB file counts checked, not just a green check.
  • 53 mutable action tags → commit-SHA pins across all 12 workflows (Semgrep p/github-actions), Dependabot-compatible form.
  • 5 high-severity npm vulns in scripts/rampart-service (which had no lockfile at all): sharp libvips CVEs + adm-zip GHSA via onnxruntime-node. No upstream fix exists, so overrides force patched lines — installed and load-tested (sharp renders through the new libvips, transformers loads, adm-zip round-trips). scripts/check-npm-overrides.sh fails the build the day upstream makes an override droppable.
  • 21 ruff B/SIM/S findings fixed, then the families enabled: zip(strict=True), NamedTemporaryFile into its with, best-effort try/except-pass → explicit contextlib.suppress, one reasoned # noqa: S603. Whole tree ruff-formatted in a dedicated commit, validated by the full Go suite.
  • CodeQL security-extended found exactly one thing — and it was real: actions/untrusted-checkout/medium on build-sandbox-image.yml's fleet_ref-fed checkout. Fixed, not waived (the actions language has no AlertSuppression.ql): the workflow now refuses refs/pull/* refs before checkout (fork-controlled code would otherwise run via the checked-out build script), and the identical hardening went into publish-sandbox-image.yml — the unflagged twin holding packages: write that the name-heuristic query missed.
  • 6 Semgrep false positives suppressed at the line with rule-scoped nosemgrep: <rule-id> + reason (three already gosec-triaged; one — insecure-file-permissions advising world-readable 0o644 for a sandbox dir — is actively wrong). Every waiver mutation-tested: strip it and the finding returns.
  • 3 Semgrep parse errors fixed for real (each silently cost file coverage), incl. ${{ }} interpolated into run: blocks — also the injection-safe fix.

Deliberate decisions (with receipts in the docs)

  • Code-quality queries dropped, ruff adopted: measured — CodeQL quality was 40s/28 note-level Python issues with no autofix; ruff covers it in <1s. Go/JS quality already owned by golangci-lint/oxlint.
  • Semgrep rules cannot be vendored/pinned — rejected on license grounds (Semgrep Rules License v1.0 forbids redistribution). Binary pinned; registry-rule drift documented as the first suspect for a mystery red run.
  • Grype tightened to fixable CRITICAL+HIGH after measuring the published image clean at that tier; policy mutation-tested in three directions.
  • A red scheduled scan files an issue (all four cron lanes, deduped). For CodeQL/Semgrep the alarm is a workflow_run watcher (scan-cron-alarm.yml) because a called workflow may not request permissions its caller didn't grant — checked at plan time; learned by startup-failing an entire Dev CI run with the in-job variant.
  • analysis-kinds is GitHub-internal — it errors and exits 0 in advanced setup. Documented so nobody re-adds it.
  • The three pre-existing upload-sarif@v4 calls (grype, govulncheck) untouched per the brief, beyond SHA-pinning.

Coverage gaps + items only the repo owner can close

  • _test.go files (621) are outside CodeQL's database (autobuild builds packages, not tests) — unchanged from default setup.
  • Governance, documented not actioned: no required human review; Dependabot can auto-update .github/workflows/; dev branch-protection state unknown to me; no SBOM/signing/provenance. Named in docs/SCANNING.md rather than left for an auditor to find.
  • No repo-settings/API changes were made (per the brief); code-scanning merge protection remains available on top but nothing depends on it.

Validation

  • make build / make test (foreground, -p 1) / make lint (golangci-lint v2.13.1 + ruff + migration DDL lint) green locally at each step.
  • web: npm ci, oxlint, tsc, vitest (1104), build green.
  • CI: gating mechanism proven live in run 522; extended-suite measurement in run 524 (red by design → the untrusted-checkout fix); run 525 green with the complete stack.
  • All commits DCO-signed.

Brad Flaugher added 3 commits August 22, 2026 11:41
…orks

Default setup was switched off, so nothing was being scanned at all. It had to
go because its Go analysis could not be repaired from anywhere: it installed the
Go its extractor was built with (1.26.6) and pinned GOTOOLCHAIN=local, so
against a go.mod requiring 1.27 it could neither build nor fetch a usable
toolchain:

    go: go.mod requires go >= 1.27.0 (running go 1.26.6; GOTOOLCHAIN=local)
    Failed to run `go mod tidy -e` in .
    Extraction failed for all discovered Go projects.

Every main-targeting PR carried that failure from the Go 1.27 bump (#1240,
promoted in #1242) on, so the repo's Go code went unscanned for that whole
stretch. Default setup is zero-config and exposes no Go version input, hence
advanced setup.

.github/workflows/codeql.yml restores both analyses default setup ran —
security over go, python, javascript-typescript and actions; code quality over
go, python and javascript-typescript — and gives Go an interpreter via
actions/setup-go with go-version-file: go.mod, so the version keeps one
declaration point instead of a copy that goes stale. The three languages in
both sets pass analysis-kinds: code-scanning,code-quality, building one
database and running both suites over it rather than extracting twice.

Triggers are push on main and pull_request on main and dev. Covering dev PRs is
the one place this exceeds default setup, which never ran on them: every change
lands on dev first and main only receives promote merges, so scanning main alone
surfaces a finding for the first time on a promote commit. A weekly Monday 10:00
UTC cron is offset from the three existing scheduled lanes.

dev-ci.yml's header listed CodeQL among the checks deferred to the dev->main
gate. That is no longer true, so it now says where CodeQL runs instead.

The three existing upload-sarif calls are untouched.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
… tag

Two defects in the first cut, both found by reading the run log rather than the
check mark — the first run was green with neither working.

1. `analysis-kinds: code-scanning,code-quality` does not do what it looks like.
   The action logged two ##[error] lines and then exited 0:

       The `analysis-kinds` input is experimental and for GitHub-internal use
       only. [...] An analysis kind other than `code-scanning` was specified in
       a custom workflow. This is not supported and will become a fatal error
       in a future version of the CodeQL Action. If your intention is to use
       quality queries outside of Code Quality, use the `queries` input with
       `code-quality` instead.
       [...] Specifying multiple values as input is no longer supported.
       Continuing with only `analysis-kinds: code-scanning`.

   Confirmed in the artifacts, not just the warning: the Go job loaded only
   codeql/go-queries and uploaded a single go.sarif, so the code-quality half of
   the coverage this change claims to restore was not running at all. Switched
   to `queries: code-quality` as the message directs. Code quality as a distinct
   analysis KIND stays closed to custom workflows; the quality queries
   themselves now run, surfacing as ordinary code-scanning alerts.

2. Go extraction had exactly one hole, and it was the worst possible file. The
   extractor reported 426 files against 427 non-test .go files in the tree; the
   missing one was internal/sandbox/host.go, the unsandboxed host executor,
   fenced behind `//go:build fleet_host_executor` and therefore absent from the
   default build. ci.yml and dev-ci.yml both pass that tag to `go vet` and
   `go test` precisely so it is not unchecked. GOFLAGS on the autobuild step
   passes it here for the same reason.

Also recorded in the file's header: GOTOOLCHAIN=local is set by the codeql-action
itself, not by the generated default-setup workflow as was assumed. The fix works
because setup-go makes the LOCAL toolchain 1.27.0, satisfying go.mod, not because
the pin is gone.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
docs/CODEQL.md records what shipped, what deviated from a like-for-like
restoration of default setup, and what was deliberately left out — with the
verified/assumed split stated explicitly, since the last two bugs in this area
both shipped because a toolchain layout was assumed rather than observed.

Verified and quoted from the run's own log archive: extraction succeeded for
both discovered Go projects, 916 packages, 426 .go files including host.go, and
distinct queries evaluated per language across the two runs (72->116 go,
90->292 python, 178->374 javascript-typescript, 36->36 actions).

Also corrected there: GOTOOLCHAIN=local is set by the codeql-action itself, in
four steps of our own Go job, not by the generated default-setup workflow. The
fix works because setup-go makes the local toolchain 1.27.0 — "give `local`
something good enough", not "unset the pin".

Stated as NOT verified: no push-on-main or scheduled run has executed yet; test
files stay outside the database (unchanged from default setup, not a
regression); the lines-of-code metric value is never printed to the log, so no
line count is claimed; and build-mode: manual was not built because autobuild
works.

CHANGELOG entry and an AGENTS.md "Where to look" pointer alongside.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
@bradflaugher
bradflaugher marked this pull request as ready for review August 22, 2026 12:00
Brad Flaugher added 5 commits August 22, 2026 12:29
CodeQL still is not blocking, and this commit does not make it blocking: `ci-gate`
remains the only required status check on main, and requiring a check is a
repo-settings action that a workflow file deliberately cannot perform.

What it fixes is the shape of that future decision. CodeQL cannot be folded into
ci.yml's `CI gate` at all — `needs` cannot reach across workflow files — so
without this, making CodeQL required would mean naming `Analyze (go)`,
`Analyze (python)`, `Analyze (javascript-typescript)` and `Analyze (actions)`
individually in branch protection. That has to be re-pointed by hand every time
the matrix gains or loses a language, and both ways of getting it wrong are bad:
a required check that never reports again blocks every PR, and a removed one
silently stops gating.

One aggregate job has neither failure mode, and it is the pattern this repo
already uses twice — ci.yml's `CI gate` and dev-ci.yml's `Dev gate`, including
the same `if: always()` + join(needs.*.result) shape. `needs: [analyze]` on a
matrix job collapses to a single aggregate result, so with fail-fast: false every
language still runs and reports before the gate evaluates them.

docs/CODEQL.md now also records where findings actually surface, since an empty
Security tab is easy to misread as a broken pipeline: the alert list shown there
is the DEFAULT BRANCH's, and this workflow's only push trigger is main, so it
repopulates at a dev->main promotion rather than when a PR is scanned. PR runs
report on the PR, where CodeQL additionally suppresses file-coverage detail.

Also noted: the quality queries this change enables (+44 go, +202 python, +196
javascript-typescript) have never run against this codebase, so any pre-existing
finding becomes an alert on merge — a reason to add `CodeQL gate` to the ruleset
after a few green promotions rather than on day one.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
Two gaps this closes, both surfaced by the question "can you see scanning
results from the logs, and can you gate on this?"

1. A CodeQL run reports NOTHING about what it found to its own log. It writes
   SARIF, uploads it, and exits 0 — with findings or without. Grepping a full
   run's log archive for any alert or result count returns nothing; the only
   result-shaped lines are `Exporting results to SARIF...` and `Successfully
   uploaded results`, which say a file moved, not what was in it. That leaves a
   run's real outcome invisible to anyone reading CI output, to `gh run view`,
   and to any automation holding the log but not the code-scanning API.

   The analyze step now also writes SARIF locally via `output:` (results are
   still uploaded — `upload` defaults true) and a following step jq-summarizes
   per-rule counts into both the job log and the step summary. This is the
   pattern govulncheck-scheduled.yml already uses on its own SARIF. It is
   reporting only and never fails the job. When no SARIF was written it says so
   instead of printing "No findings.", because reporting a clean result you did
   not observe is the mistake this repo keeps recording.

   The jq was exercised against SARIF fixtures before pushing: findings spread
   over two files, a repeated ruleId, a result with no `level` key (falls back
   to note), an empty `results` array, a run with no `results` key, and a doc
   with no `runs` key. The last four all yield "No findings." rather than a jq
   error.

2. docs/CODEQL.md now states the distinction that makes "gate on CodeQL"
   ambiguous: a required status check on the job gates on the analysis having
   RUN, not on what it FOUND. A CodeQL job with a hundred open alerts still
   exits 0 and reports green — which is both why the toolchain break hid behind
   a red-but-not-required check, and why a green check is not evidence of a
   clean tree. Blocking on findings is a separate feature, code scanning merge
   protection (ruleset -> Code scanning rule -> tool CodeQL -> severity
   thresholds), which is available here at no cost because this repo is public.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
…ep owns Actions

Reshapes the scanning stack on measurements rather than on "more scanners is
better". docs/SCANNING.md is the full note; docs/CODEQL.md carries the CodeQL
half.

ruff is new, and it BLOCKS (ci.yml + dev-ci.yml `python` job, both wired into
their gate jobs). fleet ships 13 Python files — the sandbox FileOp helper, the
python bridge, the bento-slides and data-profiler skill scripts, MCP test
servers — and nothing linted any of them: Go had golangci-lint, the web tier had
oxlint, Python had neither. Rule selection is narrow and ruff.toml records the
numbers behind that: default rules find 3 findings here, a broad selection finds
333, of which 176 are %-format style, 43 magic values, 35 line length. Gating on
that would be a whole-tree reformat for no correctness gain.

Three real findings were fixed so the gate is clean on day one and a new
violation is a regression rather than backlog noise: an unused import in
dummy_server.py, a lambda assignment in bento_pdf.py, and a byte-identical
duplicate `has_guard` definition in bento_doc.py whose second copy silently
shadowed the first. `ruff format` is reported but NOT gated — the tree has never
been ruff-formatted, so failing on it would block every PR on a reformat nobody
scheduled.

CodeQL narrows to security queries only. The code-quality suite was enabled,
measured, and dropped: 32 findings, every one note-level, ZERO security
findings. For Go and the web tier it duplicates golangci-lint (gosec,
staticcheck, revive, unparam, gocritic) and oxlint, which already block; 28 of
the 32 were Python, which is ruff's job now and done in a second with autofix
instead of ~40s without; and 3 were false positives on correct code
(`value != value`, the idiomatic NaN test). What CodeQL keeps is the thing
nothing else here can do — interprocedural taint, which is the actual shape of
"a credential must not reach a log sink, the model context, or the sandbox".

Semgrep is new, scoped, and advisory. Pointing it at p/golang, p/javascript and
p/python was tried and rejected on evidence: 55 findings, and all 6 non-Actions
findings were false positives. tls.go's open-redirect is an HTTP->HTTPS upgrade
to the same host; runner.go's math/rand is jitter; elcano.go's cookie is a
deletion cookie with no secret; httptool.go's interface{} is required because the
value feeds a jq program; proxy.ts's X-Frame-Options value is the literal
"DENY"; and fileops.py's advice — 0o644 for a sandbox directory — would be a
security REGRESSION if followed. Three of the six were already formally triaged
and suppressed for gosec, which already blocks. Re-reporting adjudicated
findings is how a scanner teaches people to ignore it.

What ships instead is p/github-actions, which found 51 instances of one real
issue nothing else in this repo checks: actions pinned to a mutable tag rather
than an immutable commit SHA, which runs attacker-controlled code with this
repo's token if a tag moves. It is advisory because all 51 are real and
repinning every workflow is its own PR — failing CI for an unscheduled backlog
just trains people to ignore the lane. Flip continue-on-error off in the PR that
repins.

Both scanners now print a per-rule summary to the job log and the step summary,
and Semgrep uploads its raw JSON as an artifact for a fixing agent to consume.

Supporting changes: RUFF_VERSION is duplicated across ci.yml and dev-ci.yml, so
scripts/check_versions_test.go now asserts the two agree (mutation-tested: the
assertion fails when the pins diverge). `make lint` gains lint-python, which
skips LOUDLY with the install command when ruff is absent rather than quietly
doing nothing. dev-ci.yml's header and AGENTS.md's build/CI prose now name the
Python lane, and AGENTS.md points at docs/SCANNING.md.

Gate: make build, make lint (0 issues + ruff clean), make test (exit 0),
make lint-migrations. All Python files still byte-compile and the bento golden
tests pass.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
The scanning stack now passes clean and fails on anything new. Turning a gate on
over an unfixed backlog is how a gate becomes something people route around, so
every finding was fixed or adjudicated before the switch was flipped.

All 53 action references pinned to commit SHAs. Semgrep's
github-actions-mutable-action-tag found 51 instances of actions referenced by a
mutable tag (actions/checkout@v7); if such a tag moves, attacker-controlled code
runs with this repo's GITHUB_TOKEN. Every `uses:` across all 12 workflows is now
@<40-hex> with the version in a trailing comment — the form Dependabot reads and
updates, and .github/dependabot.yml already watches the github-actions
ecosystem. Each SHA is the commit the previously-used tag resolved to at pin
time, so the pin is behaviourally identical to the runs already verified green; a
pin should not smuggle in a version bump. The only `uses:` lines left on @main
are two inside COMMENTS, documenting how a downstream bundle repo calls fleet's
reusable workflows — @main is correct guidance there, and Semgrep does not flag
them because a YAML comment is not a `uses:` key.

Semgrep now blocks over all four packs (p/github-actions, p/golang,
p/javascript, p/python) with --error and no continue-on-error. The 6 false
positives are suppressed at the line with `nosemgrep: <rule-id>` plus a stated
reason, scoped to the rule so a different rule on the same line still reports.
Three of the six were already formally triaged and suppressed for gosec, which
runs inside golangci-lint and already blocks; one of them — advising 0o644,
world-readable, for a sandbox directory — would have been a security REGRESSION
if followed.

Every suppression was mutation-tested: strip it and the finding reappears, keep
it and the finding is gone. That check matters because "0 findings" has two
explanations — the waivers work, or the rules silently stopped matching — and
only one is safety. Verified across all three comment syntaxes.

CodeQL now fails on findings. Previously the analyze step exited 0 whether it
found nothing or a hundred alerts, so a red check could only ever mean "the
scanner broke" — which is precisely how the Go toolchain break sat unnoticed for
weeks behind a red-but-not-required check. Threshold is ANY finding, which is
safe because the security suite reports zero across go, python,
javascript-typescript and actions. The step also fails when no SARIF was written
at all, rather than reporting a clean scan that never happened.

Both scanners report as their own checks (CodeQL gate, Semgrep scan) rather than
through ci-gate, because a job's `needs` cannot reach across workflow files.
Making a red check BLOCK a merge still requires adding those checks to the branch
ruleset; a workflow file cannot make itself required.

Two knock-on defects found and fixed while doing this:

- SHA pinning broke two regexes in scripts/check_versions_test.go that matched
  `golangci-lint-action@v\d+`. Those assertions fail OPEN — a non-match logs
  "skipping" rather than failing — so the pin would have silently disabled the
  golangci-lint version agreement checks. Widened to tolerate a pinned ref plus
  its trailing version comment, then mutation-tested against the real docs text
  to confirm they still bite rather than skip.
- A standalone nosemgrep comment inside a Go import block makes goimports
  reformat the group, failing lint. That waiver is a trailing comment on the
  import line instead; re-tested to confirm the trailing form still suppresses.

Deliberately NOT included, with measurements, in docs/SCANNING.md: `ruff format`
(9 of 13 files differ, a 3725-line diff — cosmetics, and landing it here would
bury the security change) and widening ruff's rule set (`--select B,SIM,S` adds
21 findings, of which the interesting ones are 2x B905 zip-without-strict and 1x
SIM115 open-without-context-manager).

Gate: gofmt clean, make build, make lint (0 issues + ruff clean), make test
(exit 0), make lint-migrations, and semgrep --error over all four packs at 0
findings.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
"No findings." on its own is indistinguishable from "scanned nothing", which is
the green-but-vacuous outcome this whole stack exists to rule out. Verifying the
first blocking run required downloading the artifact and inspecting the JSON by
hand — the log said the scan was clean but not what it had looked at. That is an
instrumentation gap, and it is the same class of gap as CodeQL not printing its
own findings.

Both summary steps now print coverage alongside the verdict:

- Semgrep: files scanned, files skipped, and a per-extension breakdown, so it is
  visible at a glance that every pack actually applied to its language rather
  than one silently matching nothing. Parse/scan errors are listed with their
  paths instead of only being counted, since an error means a rule or file did
  not fully run.
- CodeQL: the number of files in the database, read from the source archive the
  database was built from. Note CODEQL_DB is not always the matrix language —
  the extractor names the javascript-typescript database "javascript".

Both jq blocks were dry-run against the real semgrep.json artifact from run
32575445870 before being committed, which is where these numbers come from:
898 files scanned, 0 skipped, 427 .go / 298 .ts / 134 .tsx / 13 .py / 22
yml+yaml, 3 warn-level parse errors in 2 files.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
@bradflaugher bradflaugher changed the title Restore CodeQL coverage via advanced setup, with a Go analysis that works Restore CodeQL, add ruff + Semgrep, fix every finding, and make the scanners block Aug 22, 2026
Brad Flaugher added 7 commits August 22, 2026 13:35
The new coverage line interpolated `${{ runner.temp }}` directly into a bash
`run:` script. That is the exact shape semgrep's gha-curl-pipe-shell and
curl-eval rules exist to flag — a GitHub expression expanded into a shell script
before the shell ever sees it — and it also breaks their bash sub-parser, so
those two rules stopped evaluating against codeql.yml entirely.

Caught by the coverage reporting added in the previous commit: parse/scan errors
in the Semgrep summary went from 3 to 5, with .github/workflows/codeql.yml newly
among them. Without that instrumentation the lane would have stayed green while
quietly analyzing this file with two fewer rules — the same green-but-vacuous
failure the whole change is built to prevent, introduced by the change itself.

$RUNNER_TEMP is the equivalent env var, parses cleanly, and avoids the
interpolation entirely. Verified: codeql.yml now reports 0 errors and 0 findings
under p/github-actions, and repo-wide errors are back to the 3 pre-existing ones
(a bash snippet in build-sandbox-image.yml, a TS type in fixtures.ts).

The remaining `${{ runner.temp }}` references are in `with:` and `env:` blocks,
which is the correct placement — the value never reaches a shell unparsed.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
Mechanical, behaviour-free reformat of 9 files (~3.7k diff lines), kept as its
own commit so the substantive changes around it stay reviewable. Validated
before the gate flipped: `ruff check` still clean, every file byte-compiles,
the full Go suite passes (the bento/fileops golden tests exercise these
scripts), and the fileops.py line-level nosemgrep waiver survived the reformat
(re-scanned: 0 python findings).

The `ruff format --check` gate lands in the next commit; this commit is what
makes that gate start clean instead of red.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
…ormatting

Closes the three things still standing between "the scanners run" and "the
scanners are load-bearing", plus every finding doing so surfaced. Nothing is
deferred.

GATING, CORRECTED. The earlier design note claimed making CodeQL/Semgrep
merge-blocking needed a branch-protection click, reasoning from "`needs` cannot
cross workflow files". Incomplete: codeql.yml and semgrep.yml are now REUSABLE
workflows (`on: workflow_call`) that ci.yml and dev-ci.yml call as jobs, and a
job that calls a reusable workflow sits in a gate's `needs` like any other job.
So `CI gate` — already the single required check on main — and `Dev gate` now
block on scanner findings with no settings change anywhere. Their own
push/pull_request triggers are removed so nothing runs twice; each keeps its
weekly re-scan cron (new queries/rules against unchanged code) plus a
workflow_dispatch. In ci.yml the calls are docs-only-skippable like the other
heavy jobs (a docs-only change cannot touch scanned code, and the gate treats a
skip as a pass); in dev-ci they are unconditional because Dev gate demands
strict success and the fast lane has no docs-only detection — which also means
direct pushes to dev get scanned.

NPM AUDIT, NEW BLOCKING GATE. `npm audit --audit-level=low` runs in both web
jobs, lockfile-only, before the expensive `npm ci`, failing on any severity —
the npm counterpart of the govulncheck gate, clock-dependent by design. web/
was already clean (0 vulns). scripts/rampart-service HAD NO LOCKFILE AT ALL, so
nothing could audit it and installs were unreproducible; generating one exposed
5 high-severity vulnerabilities it had been hiding: sharp <0.35.0 (libvips
CVE-2026-33327/-33328/-35590/-35591) and adm-zip <0.6.0 (GHSA-xcpc-8h2w-3j85)
via onnxruntime-node. No upstream release fixes either — the latest
@huggingface/transformers still pins sharp ^0.34.5, and npm's suggested "fix"
was a BREAKING DOWNGRADE of transformers to 3.8.1 — so package.json carries two
overrides (sharp ^0.35.3, adm-zip ^0.6.0; each the release immediately after
its vulnerable line). The overridden stack was installed and load-tested, not
just resolved: sharp renders a PNG through the new libvips, transformers loads
on it, rampart exports its API, adm-zip 0.6 round-trips a zip. Both trees now
audit at 0. Drop the overrides when upstream ships fixed ranges.

RUFF FORMAT, NOW A GATE. `ruff format --check` blocks in both CI python jobs
and in `make lint` (lint-python). Safe because the previous commit formatted
the whole tree, so the gate starts clean and a failure means one new file.

ALL THREE SEMGREP PARSE ERRORS FIXED, so no file is partially covered — a
partial parse silently drops rules from a file, which is coverage loss wearing
a green check:
- build-sandbox-image.yml interpolated ${{ steps.build.outcome }} into its
  run: script; now passed via env (also the script-injection-safe form — the
  same fix codeql.yml got for $RUNNER_TEMP).
- The same script's ${tag:-(tag unavailable)} expansion default is valid bash,
  but the bare paren chokes semgrep's bash sub-parser; hoisted to a plain
  `if [ -z "$tag" ]` assignment.
- fixtures.ts used an inline `import("@playwright/test")` type; now a named
  `import type { BrowserContext }`. Validated with the real web toolchain:
  npm ci, oxlint, tsc --noEmit, and all 1104 vitest tests pass.

Final measured state, whole tree: semgrep --error over all four packs exits 0
with 0 findings, 898 files scanned, 0 parse errors; both npm trees 0
vulnerabilities; ruff check + format --check clean; gofmt clean; make build,
make lint, make test (exit 0), make lint-migrations all green.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
…ed, grype High, canary, cron alarms

Every item from the audit self-assessment that can live in code, closed. One
item closed by a reasoned rejection rather than code, with the license cited.

ruff B/SIM/S (bandit tier): all 21 measured findings fixed, families ENABLED.
Both zip() sites get strict=True — each provably equal-length (bento_pdf
appends pages and contents in lockstep in the same loop; profile.py sits
behind an explicit len(row) != len(cols) guard) — so a future desync fails
loud instead of silently truncating a PDF or a profile. bento_doc's unclosed
NamedTemporaryFile moved inside its with (tmp=None sentinel keeps the
unlink-on-failure path exact). The fourteen deliberate best-effort
try/except-pass sites — kernel cleanup, the duck-typed pandas/numpy probes in
normalize_json_value, unlink-on-failure in the sandbox fileops commit path —
became explicit contextlib.suppress with the intent stated at each site; the
suppressions are semantically identical, and the sandbox fileops conversion is
covered by its test suite (full make test green). The one subprocess.Popen
carries a reasoned `# noqa: S603`: argv is sys.executable plus literal flags
plus a connection-file path this process just created with mkstemp — nothing
model- or user-controlled. The waiver was mutation-tested: stripping the noqa
re-fires S603. Two of my own first-cut mistakes fixed in the same pass: a
prose comment beginning with the literal token "# noqa" (parsed as a malformed
directive) and a nested-with that tripped SIM117.

CodeQL: security-extended on all four languages. Adopted the way every other
gate here was — the default suite measured zero findings, so the broader suite
starts from a clean baseline, and this PR's own run (whose Fail-on-findings
step reads the SARIF) is the measurement. Anything extended surfaces must be
fixed or reasoned away; it cannot accrue.

Grype: gate tightened from fixable-CRITICAL to fixable CRITICAL+HIGH, measured
first: the published sandbox image (pinned grype 0.117.0, checksum-verified,
scanned directly from GHCR) carries ZERO fixable Critical/High RPM findings —
its only fixable findings are two Medium openssh advisories that the next
routine image rebuild picks up. Policy mutation-tested three ways: the real
scan passes, an injected fixable High fails, an injected fixable Medium still
passes.

scripts/check-npm-overrides.sh: an override is a fork of upstream's intent,
correct only while upstream is broken — and the day upstream fixes itself,
nothing notices, leaving Dependabot silently pinned down. Both CI lanes now
ask the registry what floor @huggingface/transformers and onnxruntime-node
declare, and FAIL with removal instructions once those reach sharp>=0.35 /
adm-zip>=0.6. Registry flake is a skip with a notice, never a verdict (npm
audit beside it is the CVE gate proper). Mutation-tested in both directions.

Cron failure alarms: all four scheduled scan lanes (codeql, semgrep,
govulncheck, grype) file a deduped GitHub issue when a SCHEDULED run fails —
a red cron has no PR to surface it, which is exactly the rot pattern that let
the CodeQL toolchain break sit red for weeks. In the workflow_call path the
alarm job stays skipped (schedule-only condition), so callers never need to
grant issues: write.

Semgrep rule vendoring: investigated and REJECTED on license grounds. The
Semgrep Rules License v1.0 permits "your own internal business purposes" only
and states "This license does not allow you to distribute the rules" —
committing the packs to this public MIT repo would be redistribution. The
binary stays pinned; the rules stay registry-fetched with the failure mode
named in docs/SCANNING.md.

Docs: every stale "fixable CRITICAL" mention updated (AGENTS.md, TESTING.md,
SANDBOX-IMAGE-FRESHNESS.md, CONTRIBUTING.md, ci.yml comment); SCANNING.md and
CODEQL.md carry the new levels and the license decision; ruff.toml's header
records why B/SIM/S went from measured-deferred to fixed-enabled and that
PLR0124 stays rejected (its only hits are the idiomatic NaN test).

Gate: yaml parses, gofmt clean, make build/lint/test green (ruff check+format
clean under the widened select), semgrep --error 0 findings / 898 scanned /
0 parse errors.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
The previous commit put an `issues: write` alarm job inside codeql.yml and
semgrep.yml. Those are REUSABLE workflows, and a called workflow may not
request token permissions its caller did not grant — and that check fires at
PLAN time, before any `if: github.event_name == 'schedule'` can skip the job.
Result: the calling Dev CI run on 745fe6d died with startup_failure (run
32578976517) and NOTHING scanned on that head. The "intersection semantics"
assumption in that commit's comment was wrong, and this is the correction.

The alarm for those two lanes now lives in scan-cron-alarm.yml, a workflow_run
watcher on [CodeQL, Semgrep] completions, filtered to conclusion=failure AND
event=schedule. A watcher has no caller, so it holds issues: write without
widening any gate's token — a PR-path scanner failure already reddens the
calling gate, and a red manual dispatch has a human watching it.
govulncheck-scheduled.yml and grype-scheduled.yml keep their in-job steps:
standalone workflows, no caller, no plan-time constraint.

workflow_run only fires from the default branch's copy, so the alarm arms at
the dev->main promotion — the same moment the crons start mattering.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
… in summaries

Three fixes from reading run 32579378165, the first run where the
security-extended measurement actually executed.

THE EXTENDED SUITE'S FIRST CATCH, AND ITS UNFLAGGED TWIN. Extended found one
actions finding: actions/untrusted-checkout/medium at
build-sandbox-image.yml's "Checkout fleet (build script)" step, whose
`ref: inputs.fleet_ref` is caller-controlled — and the checked-out script is
then EXECUTED. Reproduced locally with the same CodeQL 2.26.3 bundle and suite
to get the exact location, then triaged for real: every ref of ElcanoTek/fleet
is collaborator-written EXCEPT refs/pull/* (fork PRs), so a caller passing a
pull-request ref would execute non-collaborator code — with a contents:read
token of the calling repo, which for a private bundle repo is an exfiltration
primitive. The fix is a validation step that refuses pull-request refs (and
leading-dash values) and exposes the vetted value as a step output the
checkout consumes; the query's trigger was a NAME heuristic (any ref: fed by a
field matching .*(head|branch|ref).*), so consuming the validator's neutrally
named output also clears the alert honestly — the sanitizer is genuinely in
the path, not renamed around.

The better half of the catch: publish-sandbox-image.yml has the IDENTICAL
pattern and escaped BOTH query variants — too privileged for medium (which
only reports non-privileged contexts) and no PR-event taint for high — while
holding packages:write, making it the more dangerous twin. Hardened
symmetrically. Verified: rebuilding the actions database and re-running the
full security-extended suite locally now reports 0 findings across all 13
workflow files, and semgrep stays clean on both edited files.

CANARY PATH. scripts/check-npm-overrides.sh was invoked repo-relative from the
web jobs, whose default working-directory is web/ — exit 127, exactly what the
gate is for. The script is cwd-free (registry queries only), so it is now
invoked via $GITHUB_WORKSPACE. (The rampart audit step beside it already
proved step-level working-directory resolves from the workspace root.)

SUMMARIES NOW PRINT file:line PER FINDING. Run 32579378165's summary named the
rule but not the location, which sent the fix hunt through a 600MB CLI bundle
download. The jq now renders "[level]  ruleId  file:line" per finding,
validated against a location-bearing SARIF fixture and the empty case.

security-extended status after this commit: go, python and
javascript-typescript measured clean in CI on the previous run; actions
measured clean locally on the same toolchain after the hardening. All four
verified in CI on this run.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
… in the docs

Docs-only follow-up to the last workflow commit, which shipped the changes
but not their story:

- docs/CODEQL.md: the matrix table now says security-extended (it still read
  "default suite"); the adoption section records the one finding the wider
  suite produced (actions/untrusted-checkout/medium on build-sandbox-image.yml),
  why it was fixed rather than waived (the actions language has no
  AlertSuppression.ql), the refs/pull/* refusal that fixes it, and the same
  hardening applied to publish-sandbox-image.yml — the unflagged twin with
  packages: write that the name-heuristic query missed. Verified clean in CI
  on all four languages (Dev CI run 525, id 32580031374).

- docs/SCANNING.md: same story in the stack doc; the job-log example updated
  to the shipped file:line format plus the database-file-count coverage line;
  the override canary's $GITHUB_WORKSPACE invocation explained (the step runs
  under working-directory: web, where a repo-relative path exits 127).

- CHANGELOG.md: the security-extended bullet now carries the finding, the fix
  in both workflows, the CI verification, and the summary/canary fixes.

go test ./scripts green (the docs↔pin agreement assertions still hold).

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
@bradflaugher bradflaugher changed the title Restore CodeQL, add ruff + Semgrep, fix every finding, and make the scanners block Blocking multi-scanner stack: CodeQL security-extended, Semgrep, ruff, npm audit — every finding fixed, gated through the existing gates Aug 22, 2026
@bradflaugher
bradflaugher merged commit 4856815 into dev Aug 22, 2026
14 checks passed
bradflaugher pushed a commit that referenced this pull request Aug 22, 2026
The gate I added in the previous commit was VACUOUS, and the first
full-tree run proved it: run 32583247659 reported "0 blocking" over a
tree holding 30 findings, with sec-sev=0 on every one of them —
including go/request-forgery, whose real security-severity is 9.1.

Cause: CodeQL writes query metadata into runs[].tool.extensions[].rules[]
(one extension per query pack), NOT runs[].tool.driver.rules[]. The
driver is the CLI itself. Reading only the driver resolved nothing, so
every finding scored 0 and nothing could ever reach the High band. For
the same reason a result's `level` is usually absent from the result:
SARIF falls back to the rule's defaultConfiguration.level, which was
also unreachable.

Three changes, in order of importance:

1. The classifier now reads driver.rules AND extensions[].rules, and
   resolves level from the rule when the result omits it. Verified
   against a fixture built to the real SARIF shape.

2. A VACUITY CHECK that would have caught this: if a scan produced
   findings but resolved zero rule metadata, the job fails instead of
   reporting clean. "Findings but no metadata" means the lookup is
   broken and the gate is evaluating nothing — the green-but-vacuous
   outcome this workflow exists to rule out, which I then walked
   straight into.

3. The banding is now security-severity only, with level as the fallback
   for a rule that publishes no security-severity. Once metadata
   resolved, banding on level as well put all 23 go/log-injection
   findings (security-severity 6.1) into the blocking tier, because
   nearly every CodeQL security query is @problem.severity error — level
   carries no severity information for them. That would have reproduced
   the any-finding deadlock by a different route.

The filter now lives in .github/codeql-gate.jq and is used by both the
summary and the gate via `jq -f`, so the thing that reports and the
thing that blocks cannot disagree about what "blocking" means — and it
can be exercised against fixture SARIF with the exact bytes CI runs.

Exercised end-to-end from the YAML: the real-shape fixture (0 blocking,
8 accepted, 2 advisory), a fresh unregistered go/request-forgery in an
unwaived file (blocks — the property that makes the per-file register
different from a query-filters exclude), the vacuity fixture (fails),
missing register, missing filter, and unparseable SARIF.

CI supply chain, from the same audit:

- Repin github/codeql-action (5 refs) and golangci/golangci-lint-action
  (2 refs). Both were pinned to the ANNOTATED TAG OBJECT of a MUTABLE
  major tag, not to a commit: `refs/tags/v4` -> 4c0873ef but
  `refs/tags/v4^{}` -> db488dde. A tag object is immutable but only
  reachable while that tag points at it, so the day upstream moves v4 —
  which codeql-action does on essentially every release — the object is
  unreferenced and Actions can no longer resolve the ref. A
  self-inflicted CI outage with no attacker involved, armed in seven
  places. Verified with `git ls-remote --tags` and repinned to the
  peeled commits, with exact `# vX.Y.Z` comments (they read `# v4
  (4.37.8)` and `# v9`, which dependabot-core parses as "4" and "9").
  scripts/check_action_pins_test.go now enforces the shape across all
  53 third-party refs.

- build-sandbox-image.yml / publish-sandbox-image.yml: replace the
  fleet_ref deny-list with an allow-list. The deny-list had two holes.
  GITHUB_OUTPUT newline injection: a workflow_call string input may
  contain newlines and the value was printf'd unsanitized, so
  fleet_ref="main\nresolved=refs/pull/1/head" matched no deny pattern,
  exited 0, and emitted two `resolved=` lines — last-wins handed the
  attacker the ref, and the same primitive forges any step output. And
  a raw commit SHA: "every ref here is collaborator-written except
  refs/pull/*" is true of named refs and false of reachable commits,
  since GitHub keeps fork-PR commits in the base repo's object store
  and actions/checkout will fetch a bare SHA. Both matter because these
  workflows EXECUTE the checked-out build script, and the publish twin
  holds packages: write with a live GHCR login. Tested: 5 legitimate
  refs pass, 9 attack shapes fail, including the injection payload.

- ci.yml docs-only classifier: `*.md` matched at any depth and `docs/*`
  matched everything under docs/, so a PR touching only
  internal/clientconfig/builtin_skills/*/SKILL.md (go:embed'd and
  asserted by three test files), config/default/system_prompts/*.md
  (the shipped prompts docs/PROMPT-CACHE-CONTRACT.md exists to
  protect), or docs/openapi.yaml (asserted by openapi_drift_test.go)
  was classified docs-only — and every job skipped while CI gate
  reported green. Narrowed to a prose allow-list.

- ci.yml ci-gate: a `skipped` job passed the gate unconditionally.
  Now a skip is only accepted when the classifier actually said
  docs-only; otherwise the gate refuses to pass over a suite that did
  not run. Same rot pattern as red-but-not-required, colours inverted.
  Gate logic tested in six directions.

- scripts/check_gate_needs_test.go: assert every job in ci.yml and
  dev-ci.yml is in its aggregate gate's `needs`. Both are complete
  today (11/11 and 7/7); nothing asserted it, and adding a job without
  extending needs is a silent one-line regression that produces a
  red-but-not-required check — the exact failure #1246 was written to
  stop recurring.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
bradflaugher pushed a commit that referenced this pull request Aug 22, 2026
Dead code is audit surface: an auditor asks "why is this here, is it
reachable, is it maintained?" and every unreachable identifier costs a
round of that. Each deletion below was verified with
`git grep -w <name>` across all tracked files — every hit was the
declaration and its own doc comment, zero call sites, zero test
references, and no coupling to docs/openapi.yaml or schemaModelRegistry
(so TestOpenAPISchemaDrift cannot regress).

- internal/mcpoauth/errors.go: IsInvalidClient. The odd one out of a
  four-predicate set — IsInvalidGrant, IsInvalidTarget and
  IsInvalidScope all have callers. Its job is already done inline:
  IsTerminalRefreshError and ReauthDetail both switch on the literal
  "invalid_client" rather than calling it.
- internal/sched/apikeys/apikeys.go: Manager.LogAction. A nine-parameter
  exported wrapper over the private m.logAudit that nothing calls. Worth
  naming precisely because it is API-key AUDIT logging: a reviewer
  grepping for audit surface lands here first and has to work out that
  callers use logAudit directly. Deleting removes the ambiguity.
- internal/sched/models/models.go: TaskAssignment and LogSubmission —
  the two halves of the retired v1 remote-worker protocol
  (OrchestratorURL, Files/FileChecksums, the worker log POST),
  superseded when the platform consolidated into one process. The live
  log path uses models.LogSession directly.
- internal/sched/models/models.go: MaxLogSubmissionSize. This one is
  more than clutter — it declared a 24 MB body cap that NOTHING
  enforced. The cap actually applied is MaxJSONBodySize = 1 MB
  (internal/sched/handlers/middleware.go, wired via
  BodySizeLimitMiddleware). So the real posture was 24x stricter than
  the constant claimed, and an auditor reading models.go would have
  concluded fleet accepts 24 MB bodies. An unenforced-limit claim is
  exactly the kind of thing that becomes a finding.
- internal/config/config.go: DefaultFromEmail. Its doc comment called
  it "the fallback From address for outgoing mail", but no code path
  consumes it — an operator who sets neither SENDGRID_FROM_EMAIL nor
  MAILBUX_FROM_EMAIL does not get this fallback. A documented
  capability that does not exist is a violation of this repo's own
  honesty-in-docs invariant, so the constant goes rather than the claim
  being left standing.
- internal/tools/task_tracker.go: the only commented-out code block in
  the tree, plus the inProgressCount it was the sole reader of (the
  counter was incremented and never read once the comment is gone).
  Replaced with a sentence saying why there is deliberately no
  "more than one in_progress" check.

`golangci-lint` with `default: standard` already includes `unused` and
is a full gate, so unexported dead code is structurally zero — which is
why everything above is an EXPORTED identifier in internal/, the class
`unused` deliberately does not report. Confirmed independently with
`deadcode -test -tags fleet_host_executor ./...`, which now reports
nothing.

CI permissions and the alarm:

- scan-cron-alarm.yml only fired on `conclusion == 'failure'`. That
  ignores startup_failure — which is the exact failure this file's own
  header describes as the incident that motivated it (an in-job alarm
  variant failed a whole Dev CI run that way, so NO scanning ran on
  that head) — and timed_out, which matters given codeql.yml caps at 30
  minutes and semgrep.yml at 15. Now alarms on any conclusion that is
  not success or skipped. Also added the daily real-model canary to the
  watched list: it had no alarm at all, and a silently red daily canary
  is the rot pattern this file exists to prevent. Noted that the watcher
  matches on workflow DISPLAY NAME, so renaming `name:` disarms it.

- ci.yml carried `pull-requests: read` at WORKFLOW level for
  golangci-lint-action's only-new-issues, which is explicitly `false`.
  So the scope had no consumer while still reaching every job that does
  not override it — including web, playwright and e2e-live, which
  npm-install and execute thousands of third-party packages. Removed.

- screenshots.yml held `contents: write` at workflow level for a single
  job that runs `npm ci` and `playwright install`. It bought nothing:
  the push it existed for cannot succeed, because the main ruleset
  carries a pull_request rule with current_user_can_bypass: never and no
  bypass actors. So it was a repo-writable token handed to third-party
  code on every run, in exchange for a guaranteed-failing push whose
  commit message also carried [skip ci]. Dropped to read, with the
  shape a real implementation would take written down.

- auto-merge-dependabot.yml: the header asserted "CI is the approval
  signal and it is never bypassed", because `gh pr merge --auto` holds
  the merge until every REQUIRED check passes. That is only true where
  something is required. The dev ruleset requires no status checks at
  all, and dependabot.yml points every version update at dev — so there
  was nothing holding the merge. Excluded `github_actions` from
  auto-merge (that ecosystem's "dependency" IS the CI definition, and
  cooldown is not even available for it — Dependabot supports cooldown
  for gomod and npm only), added a `branches: [main, dev]` filter so
  this can never silently apply to an unprotected branch, and moved the
  write scopes from workflow level onto the one job that needs them.
  Getting `Dev gate` into the dev ruleset is the real fix and is a
  repo-settings action; it is flagged for the owner.

- codeql.yml / dev-ci.yml: moved the two remaining `${{ }}` expressions
  out of `run:` blocks and into `env:`. The values come from
  {success, failure, cancelled, skipped} so nothing attacker-controlled
  reached the shell, but this is the shape the two sites fixed in #1246
  were fixed away FROM, and it breaks semgrep's bash sub-parser, which
  silently costs coverage on the very files it appears in.

Two stale claims settled rather than left for an auditor to find:

- docs/adr/0012: `cmd/fleet-admin` was to be "a deprecation shim for ONE
  release ... removed next release". That clock never started — `git
  tag` returns nothing, VERSION is 0.0.0, and CHANGELOG.md has only an
  [Unreleased] heading, so "next release" is not a date. The shim also
  turns out to be load-bearing rather than vestigial: the Makefile,
  bootstrap.sh, update.sh and fleet-upgrade.sh all build or install it,
  the last two hard-fail without it, and scripts_dryrun_test.go asserts
  the "would install fleet + fleet-admin" string. Amended with a
  concrete trigger — removed in the first release after 1.0.0 — and the
  note that it forks no logic (it shares internal/admincli.Run).
  docs/EVENT-TRIGGERS.md and docs/openapi.yaml were still teaching
  `fleet-admin sched trigger …` as the primary command for HMAC-secret
  rotation; those are security procedures, so they now say `fleet`.

- migration 022 carried the tree's only TODO(security) — and every
  auditor greps for that string. Two problems beyond the deferred work:
  it sat in an APPLIED migration, so it was parked where nobody can
  close it in place, and it pointed at a source file in an unrelated
  external codebase (a dangling cross-repo pointer in a security note).
  Rewritten to state the fact plainly (the column holds account NAMES,
  never credential values, which are brokered host-side per ADR-0003 /
  ADR-0042), to say that whether account names are themselves in scope
  is an open threat-model question for the owner, and to point at
  SECURITY.md as where that gets answered. golang-migrate tracks by
  version with no checksum, so editing the comment cannot re-run or
  invalidate the applied DDL. The tree now has zero
  TODO/FIXME/XXX/HACK markers.

- .golangci.yml's noctx exclusion said "the one production noctx
  (cmd/fleet-admin bootstrap) is fixed in code via exec.CommandContext".
  cmd/fleet-admin has held no exec call since the CLI was unified in
  #461; the real call sites are in cmd/fleet and internal/admincli.
  Repointed, because a lint suppression whose stated reason names dead
  code is a suppression nobody can re-verify.

- scripts/generate-icons.py declared web/src/app/favicon.ico among its
  outputs. That file has never been committed and is not gitignored
  either, so it existed only on whoever last ran the script — while
  every other declared output IS committed. The App Router serves
  icon.svg and apple-icon.png, with favicon-16/32.png under public/, so
  the .ico had no consumer. Dropped, and the docstring now records that
  the outputs are committed and when to regenerate.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
bradflaugher pushed a commit that referenced this pull request Aug 22, 2026
An auditor reads the docs and then checks the code, so a doc that
overstates is worse than one that says less. This repo has an explicit
"Honesty in docs" invariant; these are the places it had drifted.

The load-bearing corrections:

- AGENTS.md said "Everything is at zero findings today; keeping it there
  is the point." False, and it was the one line in the agent-facing
  operating guide an auditor would hold up. The thresholds now differ by
  scanner, and the difference is stated: Semgrep, ruff and npm audit gate
  on any finding; CodeQL gates on the High band minus the reviewed
  register, with everything below it advisory. Also records the two facts
  an agent must not get wrong — a pull_request CodeQL run certifies a
  DIFF and never a tree, and `Dev gate` is not a required check on `dev`.

- docs/CODEQL.md carried a whole Triggers section describing a workflow
  that no longer exists (it showed `push: [main]` and
  `pull_request: [main, dev]`; the file has only workflow_call,
  workflow_dispatch and schedule), plus the assertion that a push to dev
  "would re-analyze identical content". That reasoning is exactly the
  trap that broke dev — the push run is full-tree and the PR run is
  diff-informed, so they are not identical content — and it is now
  preserved as the error rather than the rule. Also removed "a CodeQL job
  with a hundred open alerts still exits 0 and reports green" (false
  since the Fail-on-findings step) and the sample log format that never
  matched what the workflow prints.

- docs/SCANNING.md claimed the extended suite "reports zero findings on
  this tree (verified in CI across all four languages on Dev CI run
  525)" and that a green check means "clean tree". Run 525 was a
  pull_request event. Corrected, with the run-527 numbers, and the
  known-gaps section rewritten — the dev-ruleset gap is now the FIRST
  gap, since it is the one that makes several other statements in the
  file conditional.

- SECURITY.md had no SAST section at all, though CodeQL and Semgrep are
  the controls an enterprise auditor asks about by name. It now has one.
  Its Grype paragraph claimed the gate covers "the image's RPM or Python
  packages" at a fixable CRITICAL; the policy script selects
  `.artifact.type == "rpm"` and fires on CRITICAL *and* HIGH, so Python
  dist-info records are reported and deliberately do not gate — both
  halves were wrong in the direction that overstates coverage. Its
  supply-chain section omitted the npm CVE gate entirely. And "CI runs
  gitleaks on every push" is not true of a feature branch, which runs
  nothing.

- docs/TESTING.md's lane table said the fast lane SKIPS CodeQL. It runs
  both scanners. The table also omitted four lanes that now block.

- CONTRIBUTING.md contradicted itself inside one sentence: "fails the
  build on a fixable CRITICAL or HIGH CVE ... (HIGH and below are
  reported, not blocking)".

- CHANGELOG.md's [Unreleased] section carried five overlapping entries
  from #1246 that contradicted each other — one said the scanners gate
  through ci-gate, another said "CodeQL and Semgrep stay advisory"; one
  said `ruff format` is reported but not gated, another said it gates;
  one said Semgrep ships only p/github-actions, another all four packs;
  one said the code-quality suite was restored when it was dropped. A
  reader could not tell which was current. Collapsed into one entry
  describing the shipped end state, and extended with this PR's work.

- ruff.toml's "<- what we gate on" marker pointed at the default-only
  rule line while `select` includes B, SIM and S.

- Makefile's .PHONY omitted lint-python.

- README.md's documentation table did not list SCANNING.md or CODEQL.md
  — the two most audit-relevant docs, unreachable from the README — and
  its layout tree was abridged without saying so.

ADR housekeeping, both mine:

- ADR-0036 presents its host-side exception list as exhaustive, and an
  auditor reads it that way, so it has to be. It still named
  `fastio_upload` as a host-read exception; there is no such native tool
  any more (Fast.io is an MCP server behind the broker), so the ADR was
  claiming a hole that does not exist. Two real classes were missing:
  host `git worktree` management on the scheduled-run path, and the
  admin-gated host `podman` build for the rampart install. Neither is
  model-authored and neither weakens the invariant — but "is this
  exception in the ADR?" should have a reliable answer, and now does.

- ADR-0048's two counts were off and are now measured, not recalled: 625
  `_test.go` files (not 621) and 81 `//nolint:gosec // G706` sites (not
  77 — four of the increase are this branch's own).

Verification: make build, make lint (golangci-lint v2.13.1 + ruff +
migration DDL lint) and make test all clean; web oxlint, tsc, vitest
(1104 tests) and next build all clean. Every markdown link target in
every file touched here was checked to resolve on disk.

Two lint findings from my own earlier commits fixed here rather than left
standing: four `//nolint:gosec // G706` directives I had added were
UNUSED — once the value goes through logSafe, gosec stops flagging the
line, so nolintlint was right and an unused suppression is worse than
none — and a prealloc nit in the new gate-needs test.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
bradflaugher added a commit that referenced this pull request Aug 22, 2026
…oles closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.
@bradflaugher
bradflaugher deleted the claude/codeql-advanced-setup-go-r078t8 branch August 23, 2026 10:56
bradflaugher added a commit that referenced this pull request Aug 23, 2026
* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* 📝 Point the Kubernetes docs at the example bundle for that path

fleet links to example-config in four places, but nothing referenced
example-kubernetes-config — so an operator reading DEPLOYMENT-KUBERNETES.md had
no pointer to a worked bundle for the path they were on, even though one now
exists and carries the two Containerfiles, a documented values overlay for this
chart, and an empty-cluster-to-working-fleet walkthrough.

Four entry points, chosen because each is somewhere a reader lands cold: the
Kubernetes guide's intro, the chart README (which renders a control plane but
supplies no bundle for it to load), the two README spots where the template is
introduced as one of the three ways in, and AGENTS.md's Kubernetes row.

Framed as peers rather than parent and child, since that is what they are, and
linked rather than vendored — client content stays out of this repo per the
coupling doctrine.

Docs only. Both URLs verified to resolve (HTTP 200; the repo is public).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>
bradflaugher added a commit that referenced this pull request Aug 23, 2026
…or that path (#1260)

* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.


Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* 📝 Point the Kubernetes docs at the example bundle for that path

fleet links to example-config in four places, but nothing referenced
example-kubernetes-config — so an operator reading DEPLOYMENT-KUBERNETES.md had
no pointer to a worked bundle for the path they were on, even though one now
exists and carries the two Containerfiles, a documented values overlay for this
chart, and an empty-cluster-to-working-fleet walkthrough.

Four entry points, chosen because each is somewhere a reader lands cold: the
Kubernetes guide's intro, the chart README (which renders a control plane but
supplies no bundle for it to load), the two README spots where the template is
introduced as one of the three ways in, and AGENTS.md's Kubernetes row.

Framed as peers rather than parent and child, since that is what they are, and
linked rather than vendored — client content stays out of this repo per the
coupling doctrine.

Docs only. Both URLs verified to resolve (HTTP 200; the repo is public).


Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>
bradflaugher added a commit that referenced this pull request Aug 23, 2026
* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* Promote dev → main: point the Kubernetes docs at the example bundle for that path (#1260)

* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.


Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* 📝 Point the Kubernetes docs at the example bundle for that path

fleet links to example-config in four places, but nothing referenced
example-kubernetes-config — so an operator reading DEPLOYMENT-KUBERNETES.md had
no pointer to a worked bundle for the path they were on, even though one now
exists and carries the two Containerfiles, a documented values overlay for this
chart, and an empty-cluster-to-working-fleet walkthrough.

Four entry points, chosen because each is somewhere a reader lands cold: the
Kubernetes guide's intro, the chart README (which renders a control plane but
supplies no bundle for it to load), the two README spots where the template is
introduced as one of the three ways in, and AGENTS.md's Kubernetes row.

Framed as peers rather than parent and child, since that is what they are, and
linked rather than vendored — client content stays out of this repo per the
coupling doctrine.

Docs only. Both URLs verified to resolve (HTTP 200; the repo is public).


Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>

* 🚨 Stop the Connections focus assertions sampling a single tick

One of these failed a docs-only PR yesterday, reporting document.activeElement
as <body> where the API-key input was expected. Nothing in that diff could
reach the web tier, and the same suite passed locally 1104/1104, so it was a
flake — but a flake nobody had pinned down, and it would have bitten the next
person just as arbitrarily.

The mechanism, confirmed rather than guessed. The deep-link test awaits
findByTestId("dir-form-browserbase") and then asserts focus inline. The form
opening and the focus landing are not the same event: the state update comes
from the catalog fetch resolving OUTSIDE act, so React schedules the card's
apiKeyRef focus effect on a macrotask, while findByTestId resolves the instant
the form NODE appears — a commit earlier. I proved the window is real by
watching the raw DOM through a MutationObserver outside RTL's act wrapper: at
the moment the form node appears, the input exists and activeElement is not yet
it. On an unloaded machine the effect flush wins that race every time (0/25
repeats, and 0/12 under saturating CPU load, which is why it does not reproduce
locally); on a loaded CI runner it can lose.

So the assertion was never testing "focus ends up in the key field" — it was
testing "focus has already landed at this particular instant", which is not a
property the component promises or a user could perceive. Retrying it through
waitFor tests the guarantee that actually matters.

Two sibling assertions in the same file had the identical shape — dialog focus
after findByRole, and the focus hand-back after the dialog unmounts. Neither has
flaked yet; both could, for the same reason, so both are fixed now rather than
after they do.

Deliberately NOT changed: the focus assertions in Menu, Toast and admin/users.
Those follow a synchronous fireEvent or a direct .focus(), which React flushes
inside act, so they are not racy — waitFor there would be noise. The
distinguishing factor is focus driven by an effect after an ASYNC state update.

Kept its teeth, checked rather than assumed: removing the apiKeyRef focus()
from the component still fails the deep-link assertion, and removing
panelRef.focus() still fails the dialog one. 20/20 repeats green afterwards,
plus npm run lint, typecheck and the full 1104-test suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>
bradflaugher added a commit that referenced this pull request Aug 23, 2026
…single tick (#1262)

* 📝 Point the Kubernetes docs at the example bundle for that path (#1259)

* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* 📝 Point the Kubernetes docs at the example bundle for that path

fleet links to example-config in four places, but nothing referenced
example-kubernetes-config — so an operator reading DEPLOYMENT-KUBERNETES.md had
no pointer to a worked bundle for the path they were on, even though one now
exists and carries the two Containerfiles, a documented values overlay for this
chart, and an empty-cluster-to-working-fleet walkthrough.

Four entry points, chosen because each is somewhere a reader lands cold: the
Kubernetes guide's intro, the chart README (which renders a control plane but
supplies no bundle for it to load), the two README spots where the template is
introduced as one of the three ways in, and AGENTS.md's Kubernetes row.

Framed as peers rather than parent and child, since that is what they are, and
linked rather than vendored — client content stays out of this repo per the
coupling doctrine.

Docs only. Both URLs verified to resolve (HTTP 200; the repo is public).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>

* 🚨 Stop the Connections focus assertions sampling a single tick (#1261)

* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* Promote dev → main: point the Kubernetes docs at the example bundle for that path (#1260)

* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.


Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* 📝 Point the Kubernetes docs at the example bundle for that path

fleet links to example-config in four places, but nothing referenced
example-kubernetes-config — so an operator reading DEPLOYMENT-KUBERNETES.md had
no pointer to a worked bundle for the path they were on, even though one now
exists and carries the two Containerfiles, a documented values overlay for this
chart, and an empty-cluster-to-working-fleet walkthrough.

Four entry points, chosen because each is somewhere a reader lands cold: the
Kubernetes guide's intro, the chart README (which renders a control plane but
supplies no bundle for it to load), the two README spots where the template is
introduced as one of the three ways in, and AGENTS.md's Kubernetes row.

Framed as peers rather than parent and child, since that is what they are, and
linked rather than vendored — client content stays out of this repo per the
coupling doctrine.

Docs only. Both URLs verified to resolve (HTTP 200; the repo is public).


Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>

* 🚨 Stop the Connections focus assertions sampling a single tick

One of these failed a docs-only PR yesterday, reporting document.activeElement
as <body> where the API-key input was expected. Nothing in that diff could
reach the web tier, and the same suite passed locally 1104/1104, so it was a
flake — but a flake nobody had pinned down, and it would have bitten the next
person just as arbitrarily.

The mechanism, confirmed rather than guessed. The deep-link test awaits
findByTestId("dir-form-browserbase") and then asserts focus inline. The form
opening and the focus landing are not the same event: the state update comes
from the catalog fetch resolving OUTSIDE act, so React schedules the card's
apiKeyRef focus effect on a macrotask, while findByTestId resolves the instant
the form NODE appears — a commit earlier. I proved the window is real by
watching the raw DOM through a MutationObserver outside RTL's act wrapper: at
the moment the form node appears, the input exists and activeElement is not yet
it. On an unloaded machine the effect flush wins that race every time (0/25
repeats, and 0/12 under saturating CPU load, which is why it does not reproduce
locally); on a loaded CI runner it can lose.

So the assertion was never testing "focus ends up in the key field" — it was
testing "focus has already landed at this particular instant", which is not a
property the component promises or a user could perceive. Retrying it through
waitFor tests the guarantee that actually matters.

Two sibling assertions in the same file had the identical shape — dialog focus
after findByRole, and the focus hand-back after the dialog unmounts. Neither has
flaked yet; both could, for the same reason, so both are fixed now rather than
after they do.

Deliberately NOT changed: the focus assertions in Menu, Toast and admin/users.
Those follow a synchronous fireEvent or a direct .focus(), which React flushes
inside act, so they are not racy — waitFor there would be noise. The
distinguishing factor is focus driven by an effect after an ASYNC state update.

Kept its teeth, checked rather than assumed: removing the apiKeyRef focus()
from the component still fails the deep-link assertion, and removing
panelRef.focus() still fails the dialog one. 20/20 repeats green afterwards,
plus npm run lint, typecheck and the full 1104-test suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>
bradflaugher added a commit that referenced this pull request Aug 27, 2026
), scheduled-run file tools (#1290), sealed warm pool (#1291), audit-abort fix, dep bumps (#1293)

* 📝 Point the Kubernetes docs at the example bundle for that path (#1259)

* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* 📝 Point the Kubernetes docs at the example bundle for that path

fleet links to example-config in four places, but nothing referenced
example-kubernetes-config — so an operator reading DEPLOYMENT-KUBERNETES.md had
no pointer to a worked bundle for the path they were on, even though one now
exists and carries the two Containerfiles, a documented values overlay for this
chart, and an empty-cluster-to-working-fleet walkthrough.

Four entry points, chosen because each is somewhere a reader lands cold: the
Kubernetes guide's intro, the chart README (which renders a control plane but
supplies no bundle for it to load), the two README spots where the template is
introduced as one of the three ways in, and AGENTS.md's Kubernetes row.

Framed as peers rather than parent and child, since that is what they are, and
linked rather than vendored — client content stays out of this repo per the
coupling doctrine.

Docs only. Both URLs verified to resolve (HTTP 200; the repo is public).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>

* 🚨 Stop the Connections focus assertions sampling a single tick (#1261)

* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* Promote dev → main: point the Kubernetes docs at the example bundle for that path (#1260)

* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.


Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* 📝 Point the Kubernetes docs at the example bundle for that path

fleet links to example-config in four places, but nothing referenced
example-kubernetes-config — so an operator reading DEPLOYMENT-KUBERNETES.md had
no pointer to a worked bundle for the path they were on, even though one now
exists and carries the two Containerfiles, a documented values overlay for this
chart, and an empty-cluster-to-working-fleet walkthrough.

Four entry points, chosen because each is somewhere a reader lands cold: the
Kubernetes guide's intro, the chart README (which renders a control plane but
supplies no bundle for it to load), the two README spots where the template is
introduced as one of the three ways in, and AGENTS.md's Kubernetes row.

Framed as peers rather than parent and child, since that is what they are, and
linked rather than vendored — client content stays out of this repo per the
coupling doctrine.

Docs only. Both URLs verified to resolve (HTTP 200; the repo is public).


Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>

* 🚨 Stop the Connections focus assertions sampling a single tick

One of these failed a docs-only PR yesterday, reporting document.activeElement
as <body> where the API-key input was expected. Nothing in that diff could
reach the web tier, and the same suite passed locally 1104/1104, so it was a
flake — but a flake nobody had pinned down, and it would have bitten the next
person just as arbitrarily.

The mechanism, confirmed rather than guessed. The deep-link test awaits
findByTestId("dir-form-browserbase") and then asserts focus inline. The form
opening and the focus landing are not the same event: the state update comes
from the catalog fetch resolving OUTSIDE act, so React schedules the card's
apiKeyRef focus effect on a macrotask, while findByTestId resolves the instant
the form NODE appears — a commit earlier. I proved the window is real by
watching the raw DOM through a MutationObserver outside RTL's act wrapper: at
the moment the form node appears, the input exists and activeElement is not yet
it. On an unloaded machine the effect flush wins that race every time (0/25
repeats, and 0/12 under saturating CPU load, which is why it does not reproduce
locally); on a loaded CI runner it can lose.

So the assertion was never testing "focus ends up in the key field" — it was
testing "focus has already landed at this particular instant", which is not a
property the component promises or a user could perceive. Retrying it through
waitFor tests the guarantee that actually matters.

Two sibling assertions in the same file had the identical shape — dialog focus
after findByRole, and the focus hand-back after the dialog unmounts. Neither has
flaked yet; both could, for the same reason, so both are fixed now rather than
after they do.

Deliberately NOT changed: the focus assertions in Menu, Toast and admin/users.
Those follow a synchronous fireEvent or a direct .focus(), which React flushes
inside act, so they are not racy — waitFor there would be noise. The
distinguishing factor is focus driven by an effect after an ASYNC state update.

Kept its teeth, checked rather than assumed: removing the apiKeyRef focus()
from the component still fails the deep-link assertion, and removing
panelRef.focus() still fails the dialog one. 20/20 repeats green afterwards,
plus npm run lint, typecheck and the full 1104-test suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>

* ⬆️ Bump the go-minor-patch group: bubbletea 2.0.9, fantasy 0.41.2 (#1254)

Direct bumps: charm.land/bubbletea/v2 2.0.8 → 2.0.9 and charm.land/fantasy
0.41.1 → 0.41.2. Both are patch releases: bubbletea fixes MouseButton11 and
media-record key mapping, a ProgressBarState.String() panic, a cursedRenderer
pendingErase artifact and the kitty keyboard stack on exit; fantasy corrects
Bedrock SSO auth priority and stops emitting empty reasoning_content fields on
assistant tool-call messages.

Transitive updates ride along: the AWS SDK v2 set, cloud.google.com/go/auth,
anthropic-sdk-go, enterprise-certificate-proxy, google.golang.org/api, genai,
genproto, protobuf 1.36.12 and testify 1.12.0.

go.mod and go.sum only — no source changes. Verified on top of current dev:
make build, make test (full suite against Postgres), make lint (Go + ruff) and
make govulncheck all clean; govulncheck reports 0 vulnerabilities called. The
PR branch was updated onto dev's head first, so the green Dev gate certifies
this exact merge result.

* agent,runner: extract RunTurn and executeTask into phase helpers

TLDR: RunTurn (275 lines) and executeTask (305 lines) juggled enough
state that two confirmed audit bugs (#1105, #1117) partly stemmed from
them (#1127). Both now read as narratives of named phases -- RunTurn
is 153 lines calling 7 helpers, executeTask is 180 calling 6 -- with
every extracted body line-identical to its original span modulo the
mechanical edits extraction forces (parameter threading, return
plumbing, unindent). Zero logic changes, zero defer-scope changes.

Fix: RunTurn gained admitInteractiveTurn, composeTurnSystemPrompt,
assembleTurnMessages, interactiveRunSelection, openTurnRemoteOverlay,
failedTurnResult, and completedTurnResult (mirroring the pre-existing
cancelledTurnResult); executeTask gained buildTaskRunContext,
captureRunFailure, parkForQuestion, finishStopped, finishLeaseLost,
and finishSuccess (joining the existing per-outcome family). Function-
exit defers stayed in the parents: the limiter release and overlay
close are returned/re-registered by RunTurn under the exact HEAD
conditions, and the sandbox/workspace + MCP-scope acquisition block
was deliberately NOT extracted because it owns three such defers.
finishSuccess's absorbed returns are equivalent because nothing
follows the terminal switch; the terminal frame flows to the parent's
deferred emit through the same map reference.

Tests: zero test files touched -- the ADR-0035, #1116 lease/zombie,
behavioral net. Equivalence proven mechanically: an AST inventory
shows only the two parent functions' hashes changed (+13 helpers, all
other declarations byte-identical), and three adversarial reviewers
reproduced the inventory and hand-diffed all 13 extracted bodies
against their HEAD spans (no undeclared differences) plus the full
defer audit. Test rounds: both packages -count=1/-count=2/-race,
agentcore/scheduledrun/httpapi/cmd-fleet dependents, full make test
with both schemas -- green except the podman-gated sandbox
integration tests (no fleet-sandbox image on this box; identical on
dev). gofmt/vet/golangci-lint clean, no new suppressions.

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* sched: encode the task lifecycle as a tested transition table

TLDR: transition rules lived only in scattered WHERE status=...
clauses across claim/recovery/serialization/reporting queries -- every
new status touched them all by hand, and nothing enumerated the legal
edges (#1127). The lifecycle is now a 49-edge tested constant
(internal/sched/models/task_lifecycle.go) with derived status sets,
init-time validation, and coupling tests that turn drift into red
tests. No behavior change: the only runtime derivation is claim.go's
already-named taskActiveStatuses now mapping the table-derived
models.ActiveTaskStatuses (same {leased, running} set; the IN-list
placeholder pin proves it).

Fix: the table records every edge the GUARDED runtime writers can
produce -- birth, due-sweep, run_if settlement, claim, worker report,
retry requeue, dead-letter (in-process and #1116 recovery), pause/ask
+ expiry, wake/park + expiry + 24h backstop, cancel, DLQ replay, and
the editable/replace re-derivations -- each edge naming its
authoritative writer and file. Coupling is machine-checked at the
strongest seam available per writer, honestly labeled: behavioral
writer matrices drive each db/storage transition writer against a row
in EVERY status and assert the outcome matches the table; an AST scan
proves every status literal in the packages' tasks-table SQL is a
known status; set pins cover cleanup, serialization placeholders, the
scheduler's parametric callers, and the legacy-import births
(admincli's validSchedTaskStatus === the table's birth To-set, both
directions). Completeness validation (reachability, no non-terminal
dead ends, terminal exits guarded-only) runs at package init and as a
test. Deliberately OUTSIDE the model and documented as such: the two
verbatim-upsert import paths (sched task import, legacy import
--overwrite) can produce any->imported-status past every guard --
pre-existing restore surgery recorded, not changed.

Findings encoded, not fixed (current reality preserved): cancel can
erase a DLQ row's replayability (dead_lettered->cancelled is live);
terminal refusal lists are inconsistent across writers; leased->
terminal edges are reachable because a failed running-report only
logs; IsValidReportedStatus has zero production callers (the worker-
report to-side is caller discipline, not a guard -- documented).

Tests: red-cased three ways during review (edge removal fails the
storage matrix; a guard change without a table edit fails the db
matrix; a resurrected retired-status literal fails the scan); three
adversarial reviewers independently re-inventoried every status-
writing path and confirmed the guarded edges match reality exactly.
Lifecycle tests -count=2; full sched tree + runner + admincli green;
full make test green with both schemas except the podman-gated
sandbox integration tests (no fleet-sandbox image on this box;
identical on dev). gofmt/vet/golangci-lint clean.

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: unify the two-plane admin permission

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: put unified admin permission first

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: show viewer before member

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: call active roles contributors

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: assign permissions when creating users

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: describe permissions on hover

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: show role help immediately

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* ci: check overrides against locked parents

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* Anchor scheduled-run file staging to the worktree; stop confirm_audit aborts from failing finished work (#1280)

download_url resolved a relative output_dir against the process cwd in
scheduled runs, then refused its own path as escaping the worktree; runs
with a forced working dir now also get a working-directory message tail so
MCP file tools receive an absolute output_dir. Unbound same-tool re-audits
supersede instead of stacking, an abort after all declared work executed
is refused instead of flagging the run terminal, and an abort no longer
requires the critical_actions unlock list.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Support multiple logins ("seats") for hosted MCP connections (#988) (#1281)

A user can hold several logins to one hosted MCP server — a work and a
personal GitHub, two Gamma workspaces — each its own row with its own
sealed credential and share grants, and choose which one a chat or a
scheduled task uses. Mirrors the bundled <VAR>_<ACCOUNT> seat model.

- Migration 051: remote_mcp_servers.account + is_default; uniqueness per
  (user, name, account); one default per name (partial unique index);
  every existing row becomes its name's default.
- Migration 052: conversations.mcp_accounts — per-conversation seat
  override for the chat Tools picker (bundled connectors too).
- Runtime: agent.RemoteMCPSelection (filter / pins / exact) replaces the
  enabled-set everywhere an overlay opens; exactly one seat per name is
  mounted, registered as RegisteredMCPName(name, account); a pinned seat
  that is not connected is skipped, never replaced by another account.
- Broker protocol: RemoteScopeSpec gains accounts/exact (labels only).
- Chat: overrides ride in MCPAccountDefaults; RunTurn rebinds the approval
  stager with the composite broker + mounted seats so cards record the
  seat that ran; approval execution against a hosted connection reopens
  that exact seat (remote half of #167 residual 2).
- Tasks: mcp_selection may pin a hosted seat; hosted names route to the
  overlay instead of the bundle binder; unknown names still fail loudly.
- API: POST /remote-mcp-servers accepts account; POST /{id}/default;
  PUT /{id}/account; pickers list one entry per name with accounts /
  default_account / account; POST /conversations/{id}/mcp-servers accepts
  accounts (unknown seat = 400); first POST /chat accepts mcp_accounts.
- Web: Connections groups seats per name (Set default / Rename / Add
  another account); chat and task pickers gain a seat select.
- Docs: docs/REMOTE-MCP-MULTI-LOGIN.md, ADR-0050, CHANGELOG.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* deps(go): bump github.com/go-chi/chi/v5 from 5.3.1 to 5.3.2 in the go-minor-patch group (#1277)

Bumps the go-minor-patch group with 1 update: github.com/go-chi/chi/v5 5.3.1 -> 5.3.2.

Signed-off-by: dependabot[bot] <support@github.com>

* deps(web): bump next from 16.3.1 to 16.3.2 in /web in the npm-minor-patch group (#1278)

Bumps the npm-minor-patch group in /web with 1 update: next 16.3.1 -> 16.3.2.

Signed-off-by: dependabot[bot] <support@github.com>

* Audit: an abort retires abandoned commitments; the confirm trailer names outstanding work (#1282)

Field case (Energizer daily, 2026-08-25 16:11 UTC, task 9847380d): the audit
declared the inline mcp_pages_update_page_data; the payload had gone by
reference; mcp_pages_update_page_data_upload was BLOCKED as undeclared; the
model aborted (correctly — nothing had executed), re-audited the upload tool
and published v572. Finish enforcement still demanded the inline declaration,
the model's only exit was a second abort, and a live page landed as error.

- confirm_audit(success=false) now zeroes every declared-but-unexecuted
  commitment (typed and legacy) and drops blocked calls awaiting retry, and
  its response names what it retired. A later confirm_audit(success=true)
  already clears the terminal flag, so the run is judged on what executes
  after the re-audit; an abort AFTER that execution still hits the
  completed-work refusal from #1280.
- The success trailer described the wrong ledger: a fresh declaration used to
  come back "All 0 critical actions executed. Finish now." It now names the
  outstanding call(s) to make, and counts actual executed critical calls.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* deps(web): bump @testing-library/user-event (#1284)

Bumps the npm-minor-patch group in /web with 1 update: [@testing-library/user-event](https://github.com/testing-library/user-event).


Updates `@testing-library/user-event` from 14.6.5 to 14.6.6
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](testing-library/user-event@v14.6.5...v14.6.6)

---
updated-dependencies:
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Shared files: native cross-chat file library (#1292)

Admin-published files every conversation's agent can read, on both sandbox
backends: canonical bytes host-side under DataDir, a read-only staged tree
under the workspace root (nested :ro bind on podman, read-only subPath of
the workspace claim on kubernetes), a self-healing reconciler, a per-turn
prompt block, member list/download + admin manage API and Settings page,
and the live shared_files_max_total_mb cap. Migration 053.

Also fixes chat attachments under the kubernetes backend (staged into the
conversation workspace at send time, since pods cannot see the uploads
root), with the staging path defending its own path/log boundaries
(CodeQL alerts 148/149 fixed in code).

Design note: docs/SHARED-FILES.md

* Guard user-influenced values in two RunTurn log lines (#1294)

CodeQL's diff-informed run on the dev-to-main promotion flagged
log-injection flows in RunTurn's re-auth notice (user-named remote MCP
servers + caller email) and stream-failure line (user-selected model slug,
error text, and the reason derived from it). All advisory, fixed anyway:
every value passes a CR/LF-stripping guard — spelled with
strings.ReplaceAll, the sanitizer go/log-injection actually models, so the
alerts close instead of standing open as false positives — and the slug is
%q-quoted.

* health: make the /readyz sandbox probe backend-aware (#1287)

Under FLEET_SANDBOX_BACKEND=kubernetes the sandbox readiness check ran
`podman --version` on the control plane — a binary that shape never has —
so every healthy install reported 207 degraded. The probe now reports on
what sandboxes actually run on: one bounded (5s) apiserver GET /version
through a new narrow KubernetesBackend.ApiserverVersion accessor, memoized
by the generalized cachedProbe so the #215 unauthenticated-endpoint bound
covers both backends. A missing backend handle reports an error — never ok,
never a podman fallback. Podman path byte-for-byte unchanged.

Refs #1264 (finding 2), #989.

* validate-config: probe /api/v1/key so a bad API key actually fails (#1289)

The model_api check GET'd OpenRouter's PUBLIC /api/v1/models, so any
non-empty key was blessed with "API key authenticates" — a mis-created
64-hex junk secret passed validate-config and then 401'd on the first real
completion. The check now probes GET /api/v1/key (401 on a bad key, 200
otherwise), converging with the provider probe's existing endpoint;
warning-not-blocking posture, skips, base-URL override, and the
never-print-the-key property unchanged. internal/fakellm serves
/api/v1/key with the real auth contract so E2E seams don't 404.

Refs #1264, #989.

* sandbox: make FLEET_SANDBOX_WARM_SIZE=0 mean "no warm pool" (#1288)

warmSize 0 was inexpressible: config defaulted the field to 0 and
resolveWarmSize treated <=0 as "derive 2..8", so a kind-cluster overlay
documenting "no warm pool" ran a two-pod Guaranteed-QoS pool anyway. The
default becomes a -1 unset sentinel: unset derives (unchanged), 0 disables
the warm pool, positive pins, below -1 fails loudly at load. Chart warmSize
defaults null (omit env), emits any set value including 0, schema rejects
negatives. Semantic change called out in the CHANGELOG.

Review note for a follow-up: the negative-value guard sits as a post-load
check instead of a min bound on the #1119 knob-registry row, so an explicit
-1 is accepted as "derive" and ValidateEnvKnobs reports negatives as
well-formed; min: bound(0) on the registry row would close both.

Refs #1264 (finding 3), #989, #181.

* docs: link email report onboarding runbook (#1286)

Point AGENTS.md and CONNECTOR-ONBOARDING.md at the external canonical
SES/S3 email-report onboarding runbook; client identifiers stay in the
client bundle, per the engine/bundle coupling doctrine.

* sandbox: ride client-go for kubernetes exec streaming (#1285)

The hand-rolled v4.channel.k8s.io exec client lost stdin
nondeterministically for multi-KB payloads on the first real cluster it
met (#1264 kind rehearsal): the 28KB bridge upload wedged ~4 of 5
attempts and no tool call could run. Exec streaming now rides client-go's
remotecommand WebSocket executor (v5, real stdin half-close), proven on
the same cluster/pod/payloads. Adoption is deliberately narrow: client-go
is a TRANSPORT for exec only — pod CRUD and the fail-closed preflight
stay on the hand-rolled REST client, and the rest.Config is built from
material fleet's strict kubeconfig parser already validated (clientcmd
never invoked; exec plugins and insecure-skip-tls-verify still refused).
ADR-0049 amended in place (its recorded revisit trigger fired).

Hardened per review before merge: cappedBuffer is mutex-guarded and read
via snapshot() copies (client-go abandons its copy goroutines on a
cancelled stream — a reproduced -race failure on the cancelled-bash
path); writeStdin is bounded so a stalled dial can never park the sandbox
mutex forever; rest.Config pins Proxy off so exec never asymmetrically
honors HTTPS_PROXY while pod CRUD ignores it. Regression tests pin all
three.

Refs #1264, #989.

* ci: allow dispatching the full gate manually (#1295)

pull_request webhook deliveries were dropped during the 2026-08-26 GitHub
incident: the dev->main promotion PR's opened event was lost and five
synchronize events never spawned a run, leaving the required "CI gate"
check impossible to produce without empty commits or close/reopen — both
forbidden. workflow_dispatch runs the identical suite on the branch head,
so the name-matched required check is satisfied honestly; the docs-only
classifier already treats un-diffable events as "run everything".

* sandbox: seal the warm pool under lockdown and let sealed takes claim it (#1291)

Under FLEET_DEFAULT_NETWORK_MODE=lockdown two defects shared one cause.
Warm spawns (Pool.fill and Take's no-slot cold start) used
PoolConfig.Container verbatim, so the pool parked OPEN-egress sandboxes
(NoNetwork=false) — while every take under fleet-wide lockdown (the
interactive lockdown branch, approved bash, and scheduled runs) forced a
sealed cold start via TakeContainer. The result: N warm sandboxes of
dead reserved cpu/memory/disk that nothing could ever claim, respawned
forever by the TTL keeper, plus a cold-start latency tax on every
lockdown turn. On the kubernetes backend the parked pods were labeled
fleet.elcanotek.com/egress=open directly beside a boot log line
claiming every pod is labeled none.

The fix makes the warm pool useful under lockdown instead of deleting
it:

- warmContainerConfig seals warm spawns (NoNetwork=true) when the
  pool's fleet-wide mode is lockdown on a container backend. This alone
  makes the k8s egress=none label claim true by construction and the
  podman warm containers sealed. ModeHost is excluded — no network
  namespace to seal, and host pools never reach the container takes.
- TakeContainerWithOverrides routes a sealed, zero-override take on a
  lockdown pool to Take(): the parked sandbox already has the exact
  posture and pool-default ceilings the caller wants, and Take's own
  no-slot fallback cold-starts through warmContainerConfig, so the
  sealed posture holds warm or cold. Every other combination keeps
  today's behavior exactly: any resource override cold-starts (per-task
  ceilings need a fresh container), a sealed take on a NON-lockdown
  pool still cold-starts sealed (that warm inventory is open, and a
  sealed take must never receive an open sandbox), and allowlisted
  takes are untouched (the per-turn proxy token requires a cold start).

Caller audit for the posture invariant: under fleet-wide lockdown no
open take of the warm pool exists — takeTurnSandboxFrom,
takeTaskSandbox, and takeStagedBashSandbox all route lockdown to
TakeContainer and reach plain Take() only on ErrContainerUnavailable
(an image-less host/mock pool, where there is no network namespace to
seal) or in non-lockdown modes (where warm spawns stay open).
TakePersistent is unreachable under fleet-wide lockdown (the lockdown
branch precedes the persistent borrow in every caller).

Doc truth restored alongside: the "always cold-starts" comments on
TakeContainer / TakeContainerWithOverrides, the container.go :z
SELinux comment's lockdown sentence (the shared-label rationale is
unaffected; the behavioral claim was about to become a lie), the
kubernetes boot log line that lied before, and the sandbox-probe /
scheduledrun taker comments.

Tests pin all four behaviors against the fake kubernetes apiserver
(pod egress labels + pod identity distinguish warm claim from cold
start) and the podman-shared warmContainerConfig seam: lockdown warm
spawns are sealed, lockdown TakeContainer claims a warm sandbox, an
open pool's sealed take still cold-starts sealed, and overrides still
cold-start under lockdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GhoJPvJfQGVoucCyczmEkR

* tools/taskrun/scheduledrun: let scheduled and one-shot runs use the file tools and read bundle docs (#1290)

Three stacked, backend-independent gaps made every scheduled run's and
fleet task run's file-tool calls fail — workspace writes in the one-shot
harness, and view_file protocols/... everywhere:

(a) internal/taskrun never registered the workspace root or the
    supporting-doc dirs, so tools.ValidatePath fell back to its legacy
    process-cwd allowlist (/opt/fleet/client in the split control-plane
    image) and rejected every workspace-relative path. taskrun now
    registers both globals with its minted workspace, exactly like the
    serve boot, and mints that workspace container-readable (0o755 —
    MkdirTemp's 0o700 is unreadable to the sandbox uid under rootless
    podman, per tools.EnsureWorkspaceDir's rationale).

(b) fileOpRoot's forced-working-dir branch had no supporting-doc
    exception, so even a validated doc read was refused ("path escapes
    the scheduled-run worktree"). It now mirrors the conversation
    branch's narrow shape: the model's UNRESOLVED path must originate
    beneath the forced root and the RESOLVED target must land beneath a
    registered doc root. Read-only by construction — the doc-mount check
    at the top of fileOpRoot already refuses every writable op, and the
    sandbox anchors those roots read-only independently.

(c) Nothing seeded the supporting-doc symlinks into scheduled/one-shot
    workspaces, so the system prompt's bare protocols/foo.yaml
    convention — which the audit enforcement itself relies on — silently
    broke. EnsureWorkspaceDir's seeding loop is extracted into
    tools.SeedSupportingDocSymlinks and called from
    configureRunWorkspace for every scheduled and one-shot run. The
    helper never plants a self-referential symlink (a non-worktree run
    seeds the shared workspace root, where the registered shared-file
    library dir IS <root>/shared).

Writes into doc mounts, non-doc symlink escapes, absolute doc-mount
paths, and ..-traversal out of the forced root all stay refused;
regression tests pin each, plus the seeding idempotence/repoint/
real-file guarantees and the one-shot harness registration end to end.

Closes #1290

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Signed-off-by: Brad <brad@elcanotek.com>
Signed-off-by: jzhao234 <junzhao234@gmail.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jzhao234 <junzhao234@gmail.com>
Co-authored-by: Junhao Zhao <149023600+jzhao234@users.noreply.github.com>
Co-authored-by: Roman Y <148697404+obsessixnv@users.noreply.github.com>
Co-authored-by: Kristian Yendrek <122704517+KristianYe@users.noreply.github.com>
jzhao234 added a commit that referenced this pull request Aug 28, 2026
#1336)

* 📝 Point the Kubernetes docs at the example bundle for that path (#1259)

* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* 📝 Point the Kubernetes docs at the example bundle for that path

fleet links to example-config in four places, but nothing referenced
example-kubernetes-config — so an operator reading DEPLOYMENT-KUBERNETES.md had
no pointer to a worked bundle for the path they were on, even though one now
exists and carries the two Containerfiles, a documented values overlay for this
chart, and an empty-cluster-to-working-fleet walkthrough.

Four entry points, chosen because each is somewhere a reader lands cold: the
Kubernetes guide's intro, the chart README (which renders a control plane but
supplies no bundle for it to load), the two README spots where the template is
introduced as one of the three ways in, and AGENTS.md's Kubernetes row.

Framed as peers rather than parent and child, since that is what they are, and
linked rather than vendored — client content stays out of this repo per the
coupling doctrine.

Docs only. Both URLs verified to resolve (HTTP 200; the repo is public).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>

* 🚨 Stop the Connections focus assertions sampling a single tick (#1261)

* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* Promote dev → main: point the Kubernetes docs at the example bundle for that path (#1260)

* Promote dev → main: drive every reclaimer and guard disk; approval-card rework; tracked web agent-rules files (#1217)

Promote dev → main. Three changes since #1215:

- Drive every reclaimer, and act on the disk you measure: one hourly
  in-process maintenance loop, one daily systemd timer for the podman
  image-store prune, and a disk guard that sheds background load
  asymmetrically (scheduler stops claiming; chat keeps serving). Plus a
  terminal backstop for unwakeable paused_awaiting_wake rows, session-cap
  enforcement in the idle reaper, the remote-MCP OAuth sweep folded into
  the ctx-bound loop, a single-pass ctx-aware /admin/storage walk, and one
  diskguard.Usage statfs implementation.
- Rework approval cards: honest per-tool chrome, reload persistence,
  humane timeouts.
- web: commit the agent-rules files next dev auto-generates.

Full gate green: Go build/vet/lint/test, the -race lane, govulncheck, web
lint/test/build, Playwright mocked and live, the Grype image scan, the
migration DDL lint, gitleaks, and CodeQL.

* Promote dev → main: enterprise security audit — three authorization holes closed, the CodeQL gate unblocked, docs corrected (#1248)

Carries #1246 (the multi-scanner stack) and #1247 (the audit of what shipping it exposed).

Three own-rows authorization holes closed: GET /tasks/paused leaked every
principal's paused prompts and task UUIDs; PUT /tasks/{id} and POST
/tasks/{id}/tags let any client-role principal rewrite a teammate's pending
run including mcp_selection and credential_allowlist; /feedback and
/learned-instructions acted on unowned tasks. All mutation-tested.

The CodeQL gate now bands on security-severity >= 7.0 minus a per-file
accepted-findings register with written reasons, replacing an any-finding
threshold that had been armed on a zero measured during a diff-informed
pull_request run and therefore deadlocked every push to dev and main. A
vacuity check fails the job when findings resolve no rule metadata.

Also: seven action refs repinned off annotated tag objects of mutable major
tags, the fleet_ref GITHUB_OUTPUT newline-injection bypass, a docs-only
classifier that skipped the whole suite on embedded product content, a
redactor that held no connector secrets, a secret prefix in a validation
error, dead code including an unenforced 24MB body-cap claim, and 39 false
or stale doc claims.

ADR-0048 records the gating decision; ADR-0036 and ADR-0012 amended.

* Make ci.yml's pg_dump major assertion able to pass

The assertion added in this range failed on its first real outing — got=16
against server 18 — over an install that had plainly succeeded
(`Setting up postgresql-client-18 (18.6-1.pgdg24.04+2)`). Installing the
package is not what decides the answer: /usr/bin/pg_dump is a symlink to
postgresql-common's pg_wrapper, and the wrapper's own header states the
rule — it calls the client "with the version, cluster and default database
specified in ~/.postgresqlrc or /etc/postgresql-common/user_clusters". That
is cluster configuration, not "the newest client installed", so on a runner
image carrying a PostgreSQL 16 cluster, adding a client package alongside it
changes nothing about the dispatch.

Verified rather than reasoned about: this container has the same shape as
the runner (/usr/bin/pg_dump -> ../share/postgresql-common/pg_wrapper, the
versioned tree at /usr/lib/postgresql/16/bin), and there the wrapper
likewise reports 16 while prepending the versioned bin dir resolves
pg_dump to it directly.

So the versioned bin dir goes first on $GITHUB_PATH. That is also what the
test needs: internal/admincli/backup.go execs `pg_dump` off PATH, with no
versioned path of its own, so PATH resolution — not package presence — is
the thing that decides whether backup_test.go's major-mismatch t.Skipf
turns the only coverage of `fleet backup`/`fleet restore` off.

Asserted in two places, because $GITHUB_PATH takes effect only from the
next step: the install by absolute path in the step that performs it, and
PATH resolution in a step of its own. The second one is the assertion that
mirrors what the test actually invokes, so the two cannot drift apart again
without going red.

The CHANGELOG entry claiming the step "now asserts the major" is corrected
in the same commit — the assertion was there and could not pass, which is
the kind of half-true the honesty-in-docs invariant is about.

Verified: actionlint clean over every workflow; go test ./scripts/ passes,
including TestPostgresMajorAgreesAcrossCI, which requires every postgres
major named anywhere in .github/workflows to agree.


Claude-Session: https://claude.ai/code/session_018eFXcF6BM7bxFVEQH67iuk

* 📝 Point the Kubernetes docs at the example bundle for that path

fleet links to example-config in four places, but nothing referenced
example-kubernetes-config — so an operator reading DEPLOYMENT-KUBERNETES.md had
no pointer to a worked bundle for the path they were on, even though one now
exists and carries the two Containerfiles, a documented values overlay for this
chart, and an empty-cluster-to-working-fleet walkthrough.

Four entry points, chosen because each is somewhere a reader lands cold: the
Kubernetes guide's intro, the chart README (which renders a control plane but
supplies no bundle for it to load), the two README spots where the template is
introduced as one of the three ways in, and AGENTS.md's Kubernetes row.

Framed as peers rather than parent and child, since that is what they are, and
linked rather than vendored — client content stays out of this repo per the
coupling doctrine.

Docs only. Both URLs verified to resolve (HTTP 200; the repo is public).


Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>

* 🚨 Stop the Connections focus assertions sampling a single tick

One of these failed a docs-only PR yesterday, reporting document.activeElement
as <body> where the API-key input was expected. Nothing in that diff could
reach the web tier, and the same suite passed locally 1104/1104, so it was a
flake — but a flake nobody had pinned down, and it would have bitten the next
person just as arbitrarily.

The mechanism, confirmed rather than guessed. The deep-link test awaits
findByTestId("dir-form-browserbase") and then asserts focus inline. The form
opening and the focus landing are not the same event: the state update comes
from the catalog fetch resolving OUTSIDE act, so React schedules the card's
apiKeyRef focus effect on a macrotask, while findByTestId resolves the instant
the form NODE appears — a commit earlier. I proved the window is real by
watching the raw DOM through a MutationObserver outside RTL's act wrapper: at
the moment the form node appears, the input exists and activeElement is not yet
it. On an unloaded machine the effect flush wins that race every time (0/25
repeats, and 0/12 under saturating CPU load, which is why it does not reproduce
locally); on a loaded CI runner it can lose.

So the assertion was never testing "focus ends up in the key field" — it was
testing "focus has already landed at this particular instant", which is not a
property the component promises or a user could perceive. Retrying it through
waitFor tests the guarantee that actually matters.

Two sibling assertions in the same file had the identical shape — dialog focus
after findByRole, and the focus hand-back after the dialog unmounts. Neither has
flaked yet; both could, for the same reason, so both are fixed now rather than
after they do.

Deliberately NOT changed: the focus assertions in Menu, Toast and admin/users.
Those follow a synchronous fireEvent or a direct .focus(), which React flushes
inside act, so they are not racy — waitFor there would be noise. The
distinguishing factor is focus driven by an effect after an ASYNC state update.

Kept its teeth, checked rather than assumed: removing the apiKeyRef focus()
from the component still fails the deep-link assertion, and removing
panelRef.focus() still fails the dialog one. 20/20 repeats green afterwards,
plus npm run lint, typecheck and the full 1104-test suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6WuXeSvxcwaFHTUoWKBVa

---------

Signed-off-by: Brad <brad@elcanotek.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>

* ⬆️ Bump the go-minor-patch group: bubbletea 2.0.9, fantasy 0.41.2 (#1254)

Direct bumps: charm.land/bubbletea/v2 2.0.8 → 2.0.9 and charm.land/fantasy
0.41.1 → 0.41.2. Both are patch releases: bubbletea fixes MouseButton11 and
media-record key mapping, a ProgressBarState.String() panic, a cursedRenderer
pendingErase artifact and the kitty keyboard stack on exit; fantasy corrects
Bedrock SSO auth priority and stops emitting empty reasoning_content fields on
assistant tool-call messages.

Transitive updates ride along: the AWS SDK v2 set, cloud.google.com/go/auth,
anthropic-sdk-go, enterprise-certificate-proxy, google.golang.org/api, genai,
genproto, protobuf 1.36.12 and testify 1.12.0.

go.mod and go.sum only — no source changes. Verified on top of current dev:
make build, make test (full suite against Postgres), make lint (Go + ruff) and
make govulncheck all clean; govulncheck reports 0 vulnerabilities called. The
PR branch was updated onto dev's head first, so the green Dev gate certifies
this exact merge result.

* agent,runner: extract RunTurn and executeTask into phase helpers

TLDR: RunTurn (275 lines) and executeTask (305 lines) juggled enough
state that two confirmed audit bugs (#1105, #1117) partly stemmed from
them (#1127). Both now read as narratives of named phases -- RunTurn
is 153 lines calling 7 helpers, executeTask is 180 calling 6 -- with
every extracted body line-identical to its original span modulo the
mechanical edits extraction forces (parameter threading, return
plumbing, unindent). Zero logic changes, zero defer-scope changes.

Fix: RunTurn gained admitInteractiveTurn, composeTurnSystemPrompt,
assembleTurnMessages, interactiveRunSelection, openTurnRemoteOverlay,
failedTurnResult, and completedTurnResult (mirroring the pre-existing
cancelledTurnResult); executeTask gained buildTaskRunContext,
captureRunFailure, parkForQuestion, finishStopped, finishLeaseLost,
and finishSuccess (joining the existing per-outcome family). Function-
exit defers stayed in the parents: the limiter release and overlay
close are returned/re-registered by RunTurn under the exact HEAD
conditions, and the sandbox/workspace + MCP-scope acquisition block
was deliberately NOT extracted because it owns three such defers.
finishSuccess's absorbed returns are equivalent because nothing
follows the terminal switch; the terminal frame flows to the parent's
deferred emit through the same map reference.

Tests: zero test files touched -- the ADR-0035, #1116 lease/zombie,
behavioral net. Equivalence proven mechanically: an AST inventory
shows only the two parent functions' hashes changed (+13 helpers, all
other declarations byte-identical), and three adversarial reviewers
reproduced the inventory and hand-diffed all 13 extracted bodies
against their HEAD spans (no undeclared differences) plus the full
defer audit. Test rounds: both packages -count=1/-count=2/-race,
agentcore/scheduledrun/httpapi/cmd-fleet dependents, full make test
with both schemas -- green except the podman-gated sandbox
integration tests (no fleet-sandbox image on this box; identical on
dev). gofmt/vet/golangci-lint clean, no new suppressions.

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* sched: encode the task lifecycle as a tested transition table

TLDR: transition rules lived only in scattered WHERE status=...
clauses across claim/recovery/serialization/reporting queries -- every
new status touched them all by hand, and nothing enumerated the legal
edges (#1127). The lifecycle is now a 49-edge tested constant
(internal/sched/models/task_lifecycle.go) with derived status sets,
init-time validation, and coupling tests that turn drift into red
tests. No behavior change: the only runtime derivation is claim.go's
already-named taskActiveStatuses now mapping the table-derived
models.ActiveTaskStatuses (same {leased, running} set; the IN-list
placeholder pin proves it).

Fix: the table records every edge the GUARDED runtime writers can
produce -- birth, due-sweep, run_if settlement, claim, worker report,
retry requeue, dead-letter (in-process and #1116 recovery), pause/ask
+ expiry, wake/park + expiry + 24h backstop, cancel, DLQ replay, and
the editable/replace re-derivations -- each edge naming its
authoritative writer and file. Coupling is machine-checked at the
strongest seam available per writer, honestly labeled: behavioral
writer matrices drive each db/storage transition writer against a row
in EVERY status and assert the outcome matches the table; an AST scan
proves every status literal in the packages' tasks-table SQL is a
known status; set pins cover cleanup, serialization placeholders, the
scheduler's parametric callers, and the legacy-import births
(admincli's validSchedTaskStatus === the table's birth To-set, both
directions). Completeness validation (reachability, no non-terminal
dead ends, terminal exits guarded-only) runs at package init and as a
test. Deliberately OUTSIDE the model and documented as such: the two
verbatim-upsert import paths (sched task import, legacy import
--overwrite) can produce any->imported-status past every guard --
pre-existing restore surgery recorded, not changed.

Findings encoded, not fixed (current reality preserved): cancel can
erase a DLQ row's replayability (dead_lettered->cancelled is live);
terminal refusal lists are inconsistent across writers; leased->
terminal edges are reachable because a failed running-report only
logs; IsValidReportedStatus has zero production callers (the worker-
report to-side is caller discipline, not a guard -- documented).

Tests: red-cased three ways during review (edge removal fails the
storage matrix; a guard change without a table edit fails the db
matrix; a resurrected retired-status literal fails the scan); three
adversarial reviewers independently re-inventoried every status-
writing path and confirmed the guarded edges match reality exactly.
Lifecycle tests -count=2; full sched tree + runner + admincli green;
full make test green with both schemas except the podman-gated
sandbox integration tests (no fleet-sandbox image on this box;
identical on dev). gofmt/vet/golangci-lint clean.

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: unify the two-plane admin permission

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: put unified admin permission first

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: show viewer before member

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: call active roles contributors

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: assign permissions when creating users

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: describe permissions on hover

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* admin: show role help immediately

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* ci: check overrides against locked parents

Signed-off-by: jzhao234 <junzhao234@gmail.com>

* Anchor scheduled-run file staging to the worktree; stop confirm_audit aborts from failing finished work (#1280)

download_url resolved a relative output_dir against the process cwd in
scheduled runs, then refused its own path as escaping the worktree; runs
with a forced working dir now also get a working-directory message tail so
MCP file tools receive an absolute output_dir. Unbound same-tool re-audits
supersede instead of stacking, an abort after all declared work executed
is refused instead of flagging the run terminal, and an abort no longer
requires the critical_actions unlock list.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Support multiple logins ("seats") for hosted MCP connections (#988) (#1281)

A user can hold several logins to one hosted MCP server — a work and a
personal GitHub, two Gamma workspaces — each its own row with its own
sealed credential and share grants, and choose which one a chat or a
scheduled task uses. Mirrors the bundled <VAR>_<ACCOUNT> seat model.

- Migration 051: remote_mcp_servers.account + is_default; uniqueness per
  (user, name, account); one default per name (partial unique index);
  every existing row becomes its name's default.
- Migration 052: conversations.mcp_accounts — per-conversation seat
  override for the chat Tools picker (bundled connectors too).
- Runtime: agent.RemoteMCPSelection (filter / pins / exact) replaces the
  enabled-set everywhere an overlay opens; exactly one seat per name is
  mounted, registered as RegisteredMCPName(name, account); a pinned seat
  that is not connected is skipped, never replaced by another account.
- Broker protocol: RemoteScopeSpec gains accounts/exact (labels only).
- Chat: overrides ride in MCPAccountDefaults; RunTurn rebinds the approval
  stager with the composite broker + mounted seats so cards record the
  seat that ran; approval execution against a hosted connection reopens
  that exact seat (remote half of #167 residual 2).
- Tasks: mcp_selection may pin a hosted seat; hosted names route to the
  overlay instead of the bundle binder; unknown names still fail loudly.
- API: POST /remote-mcp-servers accepts account; POST /{id}/default;
  PUT /{id}/account; pickers list one entry per name with accounts /
  default_account / account; POST /conversations/{id}/mcp-servers accepts
  accounts (unknown seat = 400); first POST /chat accepts mcp_accounts.
- Web: Connections groups seats per name (Set default / Rename / Add
  another account); chat and task pickers gain a seat select.
- Docs: docs/REMOTE-MCP-MULTI-LOGIN.md, ADR-0050, CHANGELOG.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* deps(go): bump github.com/go-chi/chi/v5 from 5.3.1 to 5.3.2 in the go-minor-patch group (#1277)

Bumps the go-minor-patch group with 1 update: github.com/go-chi/chi/v5 5.3.1 -> 5.3.2.

Signed-off-by: dependabot[bot] <support@github.com>

* deps(web): bump next from 16.3.1 to 16.3.2 in /web in the npm-minor-patch group (#1278)

Bumps the npm-minor-patch group in /web with 1 update: next 16.3.1 -> 16.3.2.

Signed-off-by: dependabot[bot] <support@github.com>

* Audit: an abort retires abandoned commitments; the confirm trailer names outstanding work (#1282)

Field case (Energizer daily, 2026-08-25 16:11 UTC, task 9847380d): the audit
declared the inline mcp_pages_update_page_data; the payload had gone by
reference; mcp_pages_update_page_data_upload was BLOCKED as undeclared; the
model aborted (correctly — nothing had executed), re-audited the upload tool
and published v572. Finish enforcement still demanded the inline declaration,
the model's only exit was a second abort, and a live page landed as error.

- confirm_audit(success=false) now zeroes every declared-but-unexecuted
  commitment (typed and legacy) and drops blocked calls awaiting retry, and
  its response names what it retired. A later confirm_audit(success=true)
  already clears the terminal flag, so the run is judged on what executes
  after the re-audit; an abort AFTER that execution still hits the
  completed-work refusal from #1280.
- The success trailer described the wrong ledger: a fresh declaration used to
  come back "All 0 critical actions executed. Finish now." It now names the
  outstanding call(s) to make, and counts actual executed critical calls.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* deps(web): bump @testing-library/user-event (#1284)

Bumps the npm-minor-patch group in /web with 1 update: [@testing-library/user-event](https://github.com/testing-library/user-event).


Updates `@testing-library/user-event` from 14.6.5 to 14.6.6
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.6)

---
updated-dependencies:
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Shared files: native cross-chat file library (#1292)

Admin-published files every conversation's agent can read, on both sandbox
backends: canonical bytes host-side under DataDir, a read-only staged tree
under the workspace root (nested :ro bind on podman, read-only subPath of
the workspace claim on kubernetes), a self-healing reconciler, a per-turn
prompt block, member list/download + admin manage API and Settings page,
and the live shared_files_max_total_mb cap. Migration 053.

Also fixes chat attachments under the kubernetes backend (staged into the
conversation workspace at send time, since pods cannot see the uploads
root), with the staging path defending its own path/log boundaries
(CodeQL alerts 148/149 fixed in code).

Design note: docs/SHARED-FILES.md

* Guard user-influenced values in two RunTurn log lines (#1294)

CodeQL's diff-informed run on the dev-to-main promotion flagged
log-injection flows in RunTurn's re-auth notice (user-named remote MCP
servers + caller email) and stream-failure line (user-selected model slug,
error text, and the reason derived from it). All advisory, fixed anyway:
every value passes a CR/LF-stripping guard — spelled with
strings.ReplaceAll, the sanitizer go/log-injection actually models, so the
alerts close instead of standing open as false positives — and the slug is
%q-quoted.

* health: make the /readyz sandbox probe backend-aware (#1287)

Under FLEET_SANDBOX_BACKEND=kubernetes the sandbox readiness check ran
`podman --version` on the control plane — a binary that shape never has —
so every healthy install reported 207 degraded. The probe now reports on
what sandboxes actually run on: one bounded (5s) apiserver GET /version
through a new narrow KubernetesBackend.ApiserverVersion accessor, memoized
by the generalized cachedProbe so the #215 unauthenticated-endpoint bound
covers both backends. A missing backend handle reports an error — never ok,
never a podman fallback. Podman path byte-for-byte unchanged.

Refs #1264 (finding 2), #989.

* validate-config: probe /api/v1/key so a bad API key actually fails (#1289)

The model_api check GET'd OpenRouter's PUBLIC /api/v1/models, so any
non-empty key was blessed with "API key authenticates" — a mis-created
64-hex junk secret passed validate-config and then 401'd on the first real
completion. The check now probes GET /api/v1/key (401 on a bad key, 200
otherwise), converging with the provider probe's existing endpoint;
warning-not-blocking posture, skips, base-URL override, and the
never-print-the-key property unchanged. internal/fakellm serves
/api/v1/key with the real auth contract so E2E seams don't 404.

Refs #1264, #989.

* sandbox: make FLEET_SANDBOX_WARM_SIZE=0 mean "no warm pool" (#1288)

warmSize 0 was inexpressible: config defaulted the field to 0 and
resolveWarmSize treated <=0 as "derive 2..8", so a kind-cluster overlay
documenting "no warm pool" ran a two-pod Guaranteed-QoS pool anyway. The
default becomes a -1 unset sentinel: unset derives (unchanged), 0 disables
the warm pool, positive pins, below -1 fails loudly at load. Chart warmSize
defaults null (omit env), emits any set value including 0, schema rejects
negatives. Semantic change called out in the CHANGELOG.

Review note for a follow-up: the negative-value guard sits as a post-load
check instead of a min bound on the #1119 knob-registry row, so an explicit
-1 is accepted as "derive" and ValidateEnvKnobs reports negatives as
well-formed; min: bound(0) on the registry row would close both.

Refs #1264 (finding 3), #989, #181.

* docs: link email report onboarding runbook (#1286)

Point AGENTS.md and CONNECTOR-ONBOARDING.md at the external canonical
SES/S3 email-report onboarding runbook; client identifiers stay in the
client bundle, per the engine/bundle coupling doctrine.

* sandbox: ride client-go for kubernetes exec streaming (#1285)

The hand-rolled v4.channel.k8s.io exec client lost stdin
nondeterministically for multi-KB payloads on the first real cluster it
met (#1264 kind rehearsal): the 28KB bridge upload wedged ~4 of 5
attempts and no tool call could run. Exec streaming now rides client-go's
remotecommand WebSocket executor (v5, real stdin half-close), proven on
the same cluster/pod/payloads. Adoption is deliberately narrow: client-go
is a TRANSPORT for exec only — pod CRUD and the fail-closed preflight
stay on the hand-rolled REST client, and the rest.Config is built from
material fleet's strict kubeconfig parser already validated (clientcmd
never invoked; exec plugins and insecure-skip-tls-verify still refused).
ADR-0049 amended in place (its recorded revisit trigger fired).

Hardened per review before merge: cappedBuffer is mutex-guarded and read
via snapshot() copies (client-go abandons its copy goroutines on a
cancelled stream — a reproduced -race failure on the cancelled-bash
path); writeStdin is bounded so a stalled dial can never park the sandbox
mutex forever; rest.Config pins Proxy off so exec never asymmetrically
honors HTTPS_PROXY while pod CRUD ignores it. Regression tests pin all
three.

Refs #1264, #989.

* ci: allow dispatching the full gate manually (#1295)

pull_request webhook deliveries were dropped during the 2026-08-26 GitHub
incident: the dev->main promotion PR's opened event was lost and five
synchronize events never spawned a run, leaving the required "CI gate"
check impossible to produce without empty commits or close/reopen — both
forbidden. workflow_dispatch runs the identical suite on the branch head,
so the name-matched required check is satisfied honestly; the docs-only
classifier already treats un-diffable events as "run everything".

* sandbox: seal the warm pool under lockdown and let sealed takes claim it (#1291)

Under FLEET_DEFAULT_NETWORK_MODE=lockdown two defects shared one cause.
Warm spawns (Pool.fill and Take's no-slot cold start) used
PoolConfig.Container verbatim, so the pool parked OPEN-egress sandboxes
(NoNetwork=false) — while every take under fleet-wide lockdown (the
interactive lockdown branch, approved bash, and scheduled runs) forced a
sealed cold start via TakeContainer. The result: N warm sandboxes of
dead reserved cpu/memory/disk that nothing could ever claim, respawned
forever by the TTL keeper, plus a cold-start latency tax on every
lockdown turn. On the kubernetes backend the parked pods were labeled
fleet.elcanotek.com/egress=open directly beside a boot log line
claiming every pod is labeled none.

The fix makes the warm pool useful under lockdown instead of deleting
it:

- warmContainerConfig seals warm spawns (NoNetwork=true) when the
  pool's fleet-wide mode is lockdown on a container backend. This alone
  makes the k8s egress=none label claim true by construction and the
  podman warm containers sealed. ModeHost is excluded — no network
  namespace to seal, and host pools never reach the container takes.
- TakeContainerWithOverrides routes a sealed, zero-override take on a
  lockdown pool to Take(): the parked sandbox already has the exact
  posture and pool-default ceilings the caller wants, and Take's own
  no-slot fallback cold-starts through warmContainerConfig, so the
  sealed posture holds warm or cold. Every other combination keeps
  today's behavior exactly: any resource override cold-starts (per-task
  ceilings need a fresh container), a sealed take on a NON-lockdown
  pool still cold-starts sealed (that warm inventory is open, and a
  sealed take must never receive an open sandbox), and allowlisted
  takes are untouched (the per-turn proxy token requires a cold start).

Caller audit for the posture invariant: under fleet-wide lockdown no
open take of the warm pool exists — takeTurnSandboxFrom,
takeTaskSandbox, and takeStagedBashSandbox all route lockdown to
TakeContainer and reach plain Take() only on ErrContainerUnavailable
(an image-less host/mock pool, where there is no network namespace to
seal) or in non-lockdown modes (where warm spawns stay open).
TakePersistent is unreachable under fleet-wide lockdown (the lockdown
branch precedes the persistent borrow in every caller).

Doc truth restored alongside: the "always cold-starts" comments on
TakeContainer / TakeContainerWithOverrides, the container.go :z
SELinux comment's lockdown sentence (the shared-label rationale is
unaffected; the behavioral claim was about to become a lie), the
kubernetes boot log line that lied before, and the sandbox-probe /
scheduledrun taker comments.

Tests pin all four behaviors against the fake kubernetes apiserver
(pod egress labels + pod identity distinguish warm claim from cold
start) and the podman-shared warmContainerConfig seam: lockdown warm
spawns are sealed, lockdown TakeContainer claims a warm sandbox, an
open pool's sealed take still cold-starts sealed, and overrides still
cold-start under lockdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GhoJPvJfQGVoucCyczmEkR

* tools/taskrun/scheduledrun: let scheduled and one-shot runs use the file tools and read bundle docs (#1290)

Three stacked, backend-independent gaps made every scheduled run's and
fleet task run's file-tool calls fail — workspace writes in the one-shot
harness, and view_file protocols/... everywhere:

(a) internal/taskrun never registered the workspace root or the
    supporting-doc dirs, so tools.ValidatePath fell back to its legacy
    process-cwd allowlist (/opt/fleet/client in the split control-plane
    image) and rejected every workspace-relative path. taskrun now
    registers both globals with its minted workspace, exactly like the
    serve boot, and mints that workspace container-readable (0o755 —
    MkdirTemp's 0o700 is unreadable to the sandbox uid under rootless
    podman, per tools.EnsureWorkspaceDir's rationale).

(b) fileOpRoot's forced-working-dir branch had no supporting-doc
    exception, so even a validated doc read was refused ("path escapes
    the scheduled-run worktree"). It now mirrors the conversation
    branch's narrow shape: the model's UNRESOLVED path must originate
    beneath the forced root and the RESOLVED target must land beneath a
    registered doc root. Read-only by construction — the doc-mount check
    at the top of fileOpRoot already refuses every writable op, and the
    sandbox anchors those roots read-only independently.

(c) Nothing seeded the supporting-doc symlinks into scheduled/one-shot
    workspaces, so the system prompt's bare protocols/foo.yaml
    convention — which the audit enforcement itself relies on — silently
    broke. EnsureWorkspaceDir's seeding loop is extracted into
    tools.SeedSupportingDocSymlinks and called from
    configureRunWorkspace for every scheduled and one-shot run. The
    helper never plants a self-referential symlink (a non-worktree run
    seeds the shared workspace root, where the registered shared-file
    library dir IS <root>/shared).

Writes into doc mounts, non-doc symlink escapes, absolute doc-mount
paths, and ..-traversal out of the forced root all stay refused;
regression tests pin each, plus the seeding idempotence/repoint/
real-file guarantees and the one-shot harness registration end to end.

Closes #1290

Co-Authored-By: Claude <noreply@anthropic.com>

* Close out the #1298–#1302 batch: promotion ancestry automation, gate-cancellation neutrality, warm-size bound, node-LTS reminder, scheduled shared-files announcement (#1303)

Five fixes, one per issue:
- #1298: Promotion ancestry workflow — on push to main, verify main^{tree}
  == dev^{tree}, record git merge -s ours on dev; manual fallback documented
  in CONTRIBUTING.md ("Promotions").
- #1299: FLEET_SANDBOX_WARM_SIZE carries min: 0 in the knob registry; every
  explicit negative (including -1) is rejected at the validation seam.
- #1300: node-lts-reminder.yml files the node-26 move issue (full checklist)
  once v26 is past its scheduled LTS date while web/.nvmrc is behind.
- #1301: shared file library announced to scheduled runs via the one
  renderer (sharedfiles.PromptBlock); fleet task run stays out of scope;
  docs/SHARED-FILES.md updated.
- #1302: Dev gate / CodeQL gate / CI gate conclude neutral over cancelled
  (superseded) needs; a real failure still turns them red.

* test(agent): assert the remote-MCP overlay closes at RunTurn exit (#1306)

Adds TestManagerRunTurn_ClosesRemoteOverlayOnEveryExitPath: injects an
opener through the existing ManagerOptions.OpenRemoteMCPOverlay seam,
returning an active broker-backed overlay whose CloseScope counts
releases, then drives full turns through the mock-mode/fake-LLM fixture
for each way a turn can end (success, fatal failure, cancellation) and
asserts Close fired exactly once by the time RunTurn returned. Two
ordering probes pin the release to RunTurn's exit rather than "sometime":
the overlay must be open when handed to the turn and still open at
CommitTerminal.

Verified by deleting RunTurn's overlay-close defer — all three subtests
fail — then restoring it. Test-only; no production file touched.

Closes #1275

* chore: ignore agent-harness git worktree checkouts (#1307)

Parallel AI-agent work creates one full `git worktree` checkout per agent
under .claude/worktrees/. Untracked they surface as pending changes in
every status/pre-push check; committed they would land the repository
inside itself. Ignore only that directory, so the rest of .claude/
(skills, settings — referenced by AGENTS.md) stays tracked.

Same reasoning as the web/node_modules entry above it.

* sched: one terminal refusal set, and refuse cancelling a dead-lettered task (#1310)

All four internal/sched/storage transition writers now guard the from-side
on TaskStatus.IsTerminal() — the one set models.TerminalTaskStatuses
mirrors and validateTaskLifecycle cross-checks at init — instead of
hand-listing three or four statuses apiece. The refusal style stays split
and is now documented as a decision: cancel errors (an operator request),
the three lease-guarded runner writers return the row unchanged (a late
idempotent report must not fail a run that already landed).

Cancelling a dead_lettered task is now refused. A DLQ'd occurrence exists
to await operator replay, and cancel moved it to a status no replay path
leaves, silently destroying that. No caller depended on the old edge: web
STOPPABLE_STATUSES already excludes dead_lettered, chat manage_tasks
pre-skips terminal rows, and there is no CLI cancel — so no UI/API/CLI
change was needed. The error keeps the "cannot cancel" substring the HTTP
handler maps to 400 and names the two real options: replay or delete.

The worker-report to-side is now enforced rather than left dead:
IsValidReportedStatus guards UpdateTaskStatusAtomicWithContext, refusing
with ErrTaskNotReportableStatus before BeginTx, since a bad target is a
caller bug and not a race.

No behavior change for any edge in the current table: the three runner
writers' widening is inert because a dead_lettered row holds no lease, so
their lease check refuses first. Only cancel's guard changed observably.

Closes #1268
Closes #1269

* deps(web): bump the npm-minor-patch group in /web with 2 updates (#1305)

Bumps the npm-minor-patch group in /web with 2 updates: [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) and [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react).


Updates `@types/react-dom` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `@vitejs/plugin-react` from 6.0.5 to 6.1.0
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.0/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bound the runtime-secret literal set and cover the control plane (#1311)

Two follow-ups from #1124.

Bounded literals via a generation/epoch swap rather than FIFO: rotation is
the moment fleet knows a secret was superseded, so no per-server cap has to
be guessed and the only tunable is the window. internal/redact now
separates PERMANENT literals (AddLiteral — boot env secrets, static
api_keys) from SCOPED ones keyed by hosted-MCP server row.
AddScopedLiterals joins a row's current generation; RotateScopedLiterals
opens a new generation with the row's complete live set and starts
retirement on the previous one. literalRetireGrace = 15m, with
maxScopeGenerations = 4 as a refresh-storm backstop. Retirement can only
ever drop a value the same scope superseded, re-listed values are revived,
and permanent literals are never demoted. Steady state is 3 literals per
connection; sweeps are lazy behind an atomic pre-check. Bytes are not
zeroed because Go strings are immutable — retirement drops the last
reference for the GC, as the code states.

Coverage extended past the broker child through one scope-aware observer
seam, func(scope string, rotated bool, secrets ...string), wired to
agentcore.RegisterSecretLiterals and mcpbroker.RegisterSecretLiterals.
Main-process control-plane acquisitions now register before the request
that could echo them: callback code exchange (including the single-use
authorization code), Authorize's unsealed client secret, the DCR secret and
registration access token, and both api_key probes.
httpapi.remoteMCPError's default branch, which relays wrapped vendor text,
now runs through agentcore.RedactSecrets so those literals reach the
redactor that sees that string.

Closes #1274

* Gate Optional variant seats through one server-name keying rule (#1308)

The two layers deciding whether an Optional MCP server's tools are
available keyed their opt-in checks differently. agentcore's Gate-1 did an
exact map lookup on the registered server name, while the system-prompt
roster prefix-matched tool names and resolved the longest matching Optional
server.

For a named-account variant seat jira_prod whose bundle declares only jira
as optional, Gate-1's exact lookup missed: the seat's tools registered and
were CALLABLE on every run while the roster hid them from the model — the
dangerous half of the mismatch, since an operator believes the connector is
opt-in gated when it is not.

Both layers now resolve through one helper, longestServerKey, implementing
a single documented rule: a key K governs a name N iff N == K or N begins
with K_, longest key wins. Gate-2's mcpAllowlist.toolsFor, which already
implemented this rule by hand, is refolded onto the same helper so the
three gates cannot drift apart again. Gate-1 now fails closed on a variant
seat rather than the roster being loosened to match the leak.

The whole-name branch stays disabled for roster names: a roster name always
carries a trailing tool segment, so mcp_jira_search must resolve to server
jira, never to a server literally named jira_search. This preserves the
roster's existing behavior byte-for-byte and leaves the prompt-cache prefix
goldens untouched.

Closes #1272

* Persist a round-capped scheduled run's partial transcript (#1309)

When a scheduled run exhausts the enforcement rounds without its finish
gates clearing, agentcore.Run returns the accumulated Result alongside the
error (#1125) — but the scheduled driver's `if err != nil { return err }`
ran before its FinalText persistence block, so up to 20 rounds of paid
assistant text never reached the session log. #1125 made that accounting
available; this surfaces it.

A new agentcore.ErrMaxEnforcementRounds sentinel wraps the round-cap error
so the driver recognizes the case structurally via errors.Is rather than by
string-matching. The rendered message is byte-identical, so operator log
greps and the existing strings.Contains assertion still hold.

internal/agent/scheduled.go's persistRoundCapPartial then writes two
records into the session log — a [truncated] notice naming the rounds
burned plus the prompt/completion tokens and dollars spent, then the
carried FinalText — both stamped message_type round_cap_truncated, before
the error is returned.

The run still FAILS identically: same message, same terminal failure class
(the sentinel is deliberately not added to runner.classifyFailure, asserted
by a new TestClassifyFailure case), same retries, same notifications. Only
transcript visibility changed. The other halves were never missing — tool
calls, results and enforcement nudges are written live by
scheduledObserver, and the token/cost counters live by agentcore's
orchestration accounting into the same LogSession.

Closes #1271

* Validate task imports at the seam: no resurrection, no lease overwrite; key provenance immutable (#1312)

db.AddTask is an unconditional full-column upsert whose ON CONFLICT DO
UPDATE includes status, lease_owner and lease_expires_at, and two operator
import paths reached it against existing rows with no status validation.
Every transition guard in the system sits above that seam, so the upsert
bypassed them all: re-importing an envelope for a task that had run to
success rewrote it back to scheduled with a stale scheduled_for and the due
sweep re-ran its external side effects, and importing over a running row
overwrote the live lease mid-run.

Validation now lives at the import seam, not in AddTask, whose verbatim
semantics are load-bearing for same-generation re-import idempotency. New
internal/admincli/import_policy.go is called by both paths:

- importableTaskStatus (renamed from validSchedTaskStatus) now gates the
  sched task import envelope too, so no import can write leased, running or
  either paused status, nor an unknown/retired/empty one.
- A status collision on an existing row is refused without an explicit
  opt-in: new `fleet sched task import --replace-status`; the legacy
  importer's opt-in is its pre-existing --overwrite.
- Neither opt-in can touch a lease: a write over a leased/running row is
  refused outright, and the lease columns are never importable onto an
  existing row. The envelope path checks the whole batch before any write.

Refusal was chosen over "definition-only otherwise": a partial overlay
would have to freeze status plus the run-outcome columns with no
machine-checkable coupling to the registry, and its failure mode is a
silently incoherent row.

created_by_key_id also leaves the upsert set — provenance is immutable
creation-time data, with no legitimate re-stamp anywhere in the tree — and
both exclusion reasons now state that intent rather than "historical
asymmetry". The #1126 round-trip test pins it.

Closes #1267
Closes #1270

* Extend env-knob strictness to the knobs parsed outside the config loader (#1313)

#1119's fail-loud registry covered every knob config.Load reads; a handful
parsed elsewhere still silently (or only warn-)defaulted, and
validate-config could not preflight them.

Every out-of-loader knob is now a row in the one envKnobs registry under a
third class, scopeExternal. config.Load does not consume their values but
validates them, folding failures into the same one-pass boot error with
each message naming the reading package, and ValidateEnvKnobs walks the
whole table so validate-config preflights every knob the binary parses
anywhere. Two reads left the ad-hoc path entirely for the new exported
config.EnvKnobInt: fleet serve's rate-limit trio (replacing envIntDefault's
warn-and-default) and fleet backup's retention, a verb that never calls
Load.

Strict bounds are exactly what each consumer already accepts, so nothing
honored today starts being refused. Five bools became a new kindStrconvBool
because their reader is strconv.ParseBool — registering them as plain
kindBool would have certified `=on`, which that reader resolves to false.
FLEET_OTEL_SAMPLE_RATIO is the only documented-lenient knob: it carries a
required rationale and renders as a non-blocking warn.

The sweep found 17 knobs the issue did not list, all folded in. Also fixed
because the promise depends on it: 11 of these were missing from the .env
allowlist, so a FLEET_ENV_FILE-only value was dropped before either reader
or gate saw it — including FLEET_DISABLE_PROMPT_CACHE, documented as an
env-file knob all along.

New knobs_sweep_test.go is a repo-wide AST sweep that finds ad-hoc
os.Getenv reads whose value flows into a strconv/time parse, plus
package-local env-parse helpers and their call sites, and fails until each
key is registered or exempted with a reason.

Closes #1273

* sched: return the lease sentinel from every lease-guarded writer (#1314)

internal/sched/storage has exactly three lease-possession guards, but only
UpdateTaskStatusAtomicWithContext returned ErrTaskLeaseNotHeld.
RequeueTaskForRetryWithContext and DeadLetterTaskWithContext built their
refusal with fmt.Errorf over the same string, so errors.Is failed on those
two paths while working on the third.

This is a trap rather than a live misbehavior: the runner already branches
on this identity in two places — renewActiveLeases cancels a zombie run's
context so its side effects stop once a renewal proves the lease was
recovered, and the success-commit path suppresses side effects on a fenced
write — and anyone extending that to the retry or dead-letter paths would
have read a false negative off an error whose text stated exactly what
happened. Neither current call site does an identity check on those two
paths, so there is no behavior change today.

Both now return the sentinel unwrapped, on purpose, so err.Error() stays
byte-identical: only the identity changed. The sentinel's doc comment names
all three writers and says a new lease guard must return it, not its text.

A sweep of internal/sched found no other instance of the anti-pattern:
every other sentinel is returned or %w-wrapped at every construction site,
and the identical-looking literals in handlers/notes.go, handlers/prompts.go
and admincli/sched_dlq.go are HTTP/CLI presentation text emitted after an
errors.Is check. Bare errors.New one-offs with no corresponding sentinel and
no caller matching on them were left alone.

Found while working #1268/#1269 (PR #1310) and fixed directly rather than
filed.

* Borrow the high-value Prime Agent ideas: structured/iterative compaction, plan re-announcement, budget wind-down, completion-audit wording (#1317)

A real diff of fleet against PrimeIntellect-ai/prime-agent (#990), with the
four ideas that cleared the high-value bar ported behind fleet's existing
governance: structured + iterative compaction summary prompts, post-compaction
task-plan re-announcement, the FLEET_BUDGET_WINDDOWN_FRACTION soft budget
wind-down notice, and the completion-audit wording in the scheduled self-audit
nudge. docs/PRIME-AGENT-COMPARISON.md records the comparison and the
deliberate non-borrowings with reasons.

* web: ship Nebula Sans + Hack, and tabular figures with them (#1324)

Elcano standardised on exactly two typefaces: Nebula Sans (SIL OFL 1.1) for
UI/body/headings and Hack (MIT + Bitstream Vera) for code, logs and tabular
output. Removes the four self-hosted IBM Plex woff2 files and the
next/font/local wrapper; adds one vendored sheet (a copy of flag's
design-system/fonts/fonts.css) that is now the only place a font family is
named, with both licence files shipping beside the binaries. Still
self-host-only — no font CDN.

Nebula Sans has PROPORTIONAL figures (digit advances 407-625/1000) where IBM
Plex Sans had every digit at 600, so tabular figures are now the @layer base
default for every table plus explicit on the non-table numeric readouts.
Measured: an 8-digit cell's all-1s vs all-8s width differed by 25.09px before
and 0.11px after. web/src/app/fonts.test.ts guards the two-face rule, the
licence files, the url() paths and the tabular-figures rules; the docs
screenshots are regenerated.

* fix(web): honor default-on connectors in Ops tasks (#1334)

TLDR: New Ops tasks now start with catalog connectors marked enabled by default, while existing tasks preserve their saved connector selection.

Problem:
- Selecting optional connectors could hide default-on mailbox connectors from Ops runs.
- New-task defaults loaded asynchronously and were not reflected in the picker.

Fix:
- Derive untouched new-task selections from enabled non-remote catalog entries.
- Preserve explicit operator choices and persisted selections on existing tasks.
- Document connector-selection behavior and compatibility semantics.

Tests:
- npm test -- --run web/src/app/orchestrator/TaskCreateModal.test.tsx
- npm test
- npm run lint
- npm run typecheck
- npm run build
- git diff --check

* fix(ops): preserve connectors on task resubmit (#1335)

TLDR: Connector edits made while resubmitting a completed Ops task now reach the new one-off run instead of silently inheriting the source selection.

Problem:
- The terminal-task editor displayed connector changes but omitted mcp_selection from rerun overrides.
- The rerun API did not accept a connector-selection override.

Fix:
- Add nil-aware mcp_selection rerun overrides so omitted inherits and explicit empty clears.
- Send the editor’s complete visible connector selection on terminal resubmits.
- Document the immutable-source/new-copy behavior.

Tests:
- go test ./internal/sched/handlers
- npm test -- --run src/app/orchestrator/TaskCreateModal.test.tsx
- git diff --check

---------

Signed-off-by: Brad <brad@elcanotek.com>
Signed-off-by: jzhao234 <junzhao234@gmail.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Brad Flaugher <16511019+bradflaugher@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brad <brad@elcanotek.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Roman Y <148697404+obsessixnv@users.noreply.github.com>
Co-authored-by: Kristian Yendrek <122704517+KristianYe@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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