Skip to content

Enterprise security audit: close three own-rows authorization holes, unblock the CodeQL gate, correct the docs - #1247

Merged
bradflaugher merged 10 commits into
devfrom
claude/enterprise-security-audit-qomnnd
Aug 22, 2026
Merged

bradflaugher merged 10 commits into
devfrom
claude/enterprise-security-audit-qomnnd

Conversation

@bradflaugher

@bradflaugher bradflaugher commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Why

dev was red and every push to dev or main was blocked. Ahead of an enterprise security audit I fanned out an audit across the tree, the merged scanning stack (#1246), the docs, dead code, the stated security invariants, and the CI supply chain.

Three things came out of it that matter more than the rest: three real authorization holes, a gate that couldn't be satisfied, and a gate that couldn't fail.


1. Three own-rows authorization holes

The read path for task rows was narrowed to own rows in #1082, and run logs in #980. Three surfaces never got the same treatment and authorized on a permission alone, so any client-role principal reached every principal's rows:

surface hole
GET /tasks/paused Selects on status alone with no principal predicate in SQL, and the projection carries each task's prompt. Its siblings /tasks/export and /tasks/upcoming both call visibleTasks; this one did not. Leaked other principals' paused prompts — and their task UUIDs with them.
PUT /tasks/{id}, POST /tasks/{id}/tags Loaded via the unscoped GetTask and never checked ownership. A client-role principal could rewrite a teammate's pending run: prompt, model, mcp_selection, credential_allowlist. Only run_if was gated.
POST /tasks/{id}/feedback, GET /tasks/{id}/learned-instructions taskFromPath is lookup-only by its own doc contract and neither caller made the decision. A down-vote with an attacker-authored critique fed maybeDistill, minting a proposal from the victim's prompt at unmetered model spend; the GET disclosed their learned instructions.

The write gate is a new taskWritableByPrincipal, deliberately not principal.ownsTaskownsTask resolves through ownerID(), which is nil for every API-key principal, so it would deny a scoped intake-app key the right to edit the task it just created. taskCreatedByPrincipal matches a creating user or a creating key (CreatedByKeyID), which is the model #980/#1082 established. A write surface must be no looser than the read surface guarding the same row.

TestScopedAPIKeyAuthorization previously asserted "client key can edit an editable task" against an unattributed row — that was the vulnerable behavior. Split into the owned case (must keep working) and the unowned case (must 403).

Every fix mutation-tested: stripped, the tests fail with the exploit visible — a hijacked prompt persisted, another principal's prompt in the paused queue, feedback accepted on an unowned task.

2. Why the CodeQL gate deadlocked

#1246 armed a blocking any-finding gate on a measured zero across all four languages. That measurement came from Dev CI run 525, a pull_request event — and on pull_request events the CodeQL action runs diff-informed: full database, every query, then only results inside the PR's diff. Run 525's own log says both halves:

Persisted 204 diff range(s) across 43 file(s).
To speed up pull request analysis, file coverage information is only enabled
when analyzing the default branch and protected branches.

So the first full-tree scan was the push that merged it — run 527 — reporting 38 Go + 17 JS findings, turning Dev gate red with no PR-shaped way out: a PR into dev is diff-informed and stays green while dev stays red.

The generalizable lesson, now in the workflow and the ADR: a PR-event CodeQL run certifies a diff, not a tree.

All 55 were triaged. Four were reachable and are fixed in code, not waived. Severity alone does not separate the rest: go/request-forgery is 9.1 and fires on web_fetch.go, a deliberate user-facing fetch tool behind netguard's resolve-then-dial SSRF guard; go/weak-sensitive-data-hashing is 7.5 and fires on SHA-256 used as a lookup index over a 32-byte crypto/rand token — the recommended construction.

New threshold: security-severity ≥ 7.0 blocks (level error/warning as the fallback for a rule publishing no security-severity), minus .github/codeql-accepted-findings.json — accepted (rule, file) pairs, each with a mandatory written reason. Per-file, not per-rule: a query-filters exclude would switch a 9.1 query off repo-wide, while the register waives the two adjudicated files and leaves it live everywhere else. Verified — a synthetic SARIF with a fresh go/request-forgery in an unregistered file fails the gate.

3. …and then my own first attempt at that gate was vacuous

Worth calling out because it is the same failure mode, one level up. My first version reported "0 blocking" over a tree holding 30 findings, scoring every one at security-severity 0 — including the 9.1.

CodeQL writes query metadata into runs[].tool.extensions[].rules[], not tool.driver.rules[] (the driver is the CLI). Reading only the driver resolved nothing, so nothing could reach the High band. Same reason a result's level is usually absent: SARIF falls back to the rule's defaultConfiguration.level.

Fixed, and then guarded: a vacuity check fails the job when a scan produces findings but resolves zero rule metadata. Findings-but-no-metadata means the gate is evaluating nothing.

Then a second correction: once metadata resolved, banding on level as well put all 23 go/log-injection findings (6.1) in the blocking tier, because nearly every CodeQL security query is @problem.severity error. Banding is now security-severity only, with level as the fallback.

Empirically validated on a full-tree workflow_dispatch run (not diff-informed):

totals: 30 finding(s) — 0 blocking, 13 accepted, 17 advisory
rule metadata resolved: 35

with real severities resolved (9.1 request-forgery, 7.5 path-injection, 6.1 log-injection).

The filter lives in .github/codeql-gate.jq, shared by the summary and the gate via jq -f, so what reports and what blocks cannot disagree — and it can be exercised against fixture SARIF with the exact bytes CI runs. ADR-0048 records the decision, what gets worse (a note-level regression no longer fails the build; gosec's G706 still covers log-injection through golangci-lint, which does block), and the sharpest edge (the register keys on rule+file, not line).

4. The four reachable findings, fixed in code

  • internal/sched/handlers/handlers.go logged task.Prompt unsanitized on the task-create path while the update path's twin was already wrapped in logSafe. POST /tasks is reachable by a scoped create_task key — genuine log forgery.
  • internal/httpapi/attachments.go logged the raw client path with %s on the two branches where containment had just failed.
  • internal/agent/session.go logged the client-echoed attachment Name, which unlike Path is never re-sanitized on /chat.
  • web/e2e/test-auth-key.ts wrote an Ed25519 private key to a predictable path in the world-writable temp dir at 0644. Now O_EXCL at 0600 with random bytes in the sibling name.

5. CI supply chain

  • Action pins. github/codeql-action (5 refs) and golangci/golangci-lint-action (2) were pinned to the annotated tag object of a mutable major tag, not a commit: refs/tags/v44c0873ef, but refs/tags/v4^{}db488dde. A tag object is immutable but only reachable while that tag points at it — the day upstream moves v4 the object is unreferenced and Actions cannot resolve the ref. A self-inflicted CI outage with no attacker, armed in seven places. 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 enforces the shape across all 53 refs.
  • fleet_ref deny-list → allow-list. Two holes: GITHUB_OUTPUT newline injection (fleet_ref="main\nresolved=refs/pull/1/head" matched no deny pattern, exited 0, emitted two resolved= lines — last-wins handed over the ref, and the same primitive forges any step output), and a raw commit SHA (fork-PR commits live 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.
  • docs-only classifier. *.md matched at any depth and docs/* matched everything under docs/, so a PR touching only a go:embed'd builtin_skills/*/SKILL.md, a shipped config/default/system_prompts/*.md, or docs/openapi.yaml (asserted by openapi_drift_test.go) was classified docs-only — every job skipped while CI gate went green. Narrowed to a prose allow-list.
  • ci-gate accepted skipped unconditionally. Now a skip passes only when the classifier actually said docs-only. Same rot pattern as red-but-not-required, colours inverted.
  • scripts/check_gate_needs_test.go asserts every job is in its gate's needs (11/11 and 7/7 today; nothing asserted it).
  • Cron alarm only fired on conclusion == 'failure', ignoring startup_failure — the exact incident its own header describes — and timed_out. Broadened, and the daily real-model canary added to the watched list (it had no alarm at all).
  • Permission trims. ci.yml's workflow-level pull-requests: read had no consumer (only-new-issues: false) but reached every job including the npm-installing ones. screenshots.yml held contents: write for a single job running npm ci + Playwright, in exchange for a push the main ruleset can never accept.

6. Other hardening

  • internal/config/config.goValidateScheduled interpolated the first 6 bytes of OPENROUTER_API_KEY into a validation error: the only place in the tree where secret material reached an error string. Removed; the doc comment claimed "Called at startup" and it has no production caller.
  • The tool-output redactor held no connector secrets at all. Its own comment claimed otherwise. agentcore's redactor is built lazily and seeds from os.Environ(), but the broker's boot path os.Unsetenv's every connector key long before the first tool output — so a connector echoing its own credential back was scrubbed only if it matched a shape pattern (sk-*, ghp_*, …). scrubParentConnectorState now hands each value over before unsetting it, and the parent's remotemcp service gets the SetSecretObserver wiring the child already had. Race-tested; mutation-tested with a token matching no shape pattern.
  • internal/mcpoauth/discovery.go — refuse a non-http(s) scheme on the remote-derived discovery URLs before the request.
  • internal/sched/models/models.go — validate WorktreeConfig.BaseBranch. It is the trailing positional of git worktree add -b <b> <path> <base> with no -- separator, so a leading-dash value was parsed by git as an option. worktree_config is settable by any task creator, unlike run_if.
  • internal/agent/scheduled.go — run-error strings now go through RedactSecrets before the persisted transcript and the log.

7. Dead code and cruft

Six unreferenced exported identifiers in internal/ (the class unused deliberately does not report), each verified with git grep -w across all tracked files. Two mattered beyond tidiness: MaxLogSubmissionSize declared a 24 MB body cap that nothing enforced — the real cap is MaxJSONBodySize at 1 MB, so an auditor reading models.go would have concluded the opposite — and DefaultFromEmail documented a mail fallback no code path consumes.

The tree now has zero TODO/FIXME/XXX/HACK markers. The one that existed was a TODO(security) — the string every auditor greps — sitting in an applied migration (so parked where nobody can close it) and pointing at a source file in an unrelated external codebase.

8. Docs

39 false or stale claims corrected. The load-bearing ones: AGENTS.md's "Everything is at zero findings today"; docs/CODEQL.md's entire Triggers section describing a workflow that no longer exists, plus its assertion that a push to dev "would re-analyze identical content" (the exact trap that broke dev); SECURITY.md having no SAST section at all while claiming Grype gates on Python packages at CRITICAL (it is RPMs only, at CRITICAL and HIGH); docs/TESTING.md saying the fast lane skips CodeQL when it runs both scanners; and CHANGELOG.md's five mutually contradictory [Unreleased] entries from #1246.

ADR housekeeping: ADR-0036 presents its host-side exception list as exhaustive, so it has to be — it still named fastio_upload (no such native tool exists; Fast.io is an MCP server), and omitted host git worktree management and the admin-gated host podman build. ADR-0012's "removed after one release" had no anchor (git tag returns nothing, VERSION is 0.0.0) and the shim turns out to be load-bearing across four scripts and two test assertions; it now has a concrete trigger.

Verification

  • make build, make lint (golangci-lint v2.13.1 + ruff + migration DDL lint), make test — all clean. The Go DB suites skip without Postgres, so a local Postgres 16 was stood up: the new authorization tests ran against a real database rather than skipping.
  • web: npm ci, oxlint, tsc --noEmit, vitest (1104), next build — clean.
  • go test -race on the packages touched by the redactor change.
  • The gate's jq exercised in nine directions: clean, notes-only, mixed-severity, the real 55-finding set, a real-SARIF-shape fixture, a fresh unregistered High finding, an in-source-suppressed finding, missing SARIF, malformed SARIF, missing register, missing filter, and the vacuity case.
  • Every markdown link in every file touched verified to resolve on disk.
  • Playwright mocked could not run in my container (it pins browser build 1234; the image ships 1194) — CI covers it. The changed test-auth-key.ts path was exercised directly: mode 0600, atomic replace, no leftover temp siblings.

⚠️ Needs a repo-settings decision — not fixable in a diff

The dev ruleset requires no status checks. Its only rules are deletion and non_fast_forward — no pull_request rule, no required_status_checks. So every job in dev-ci.yml, CodeQL and Semgrep included, is red-but-not-required on dev. main correctly requires CI gate.

Compounding it: .github/dependabot.yml targets dev for the github-actions ecosystem daily with no cooldown (cooldown is only supported for gomod and npm), and auto-merge-dependabot.yml merges patch bumps. A GitHub Action patch bump is a rewrite of .github/workflows/* — so it could land on dev with no CI, no review, and no cooldown. The workflow's own comment assumed CI holds the merge; on dev there was nothing to hold it.

Workflow-side mitigations are in this PR (github_actions excluded from auto-merge, a branches: [main, dev] filter, job-scoped write permissions). Adding Dev gate to the dev ruleset is yours — and it is what makes several statements in docs/SCANNING.md and AGENTS.md unconditionally true rather than branch-dependent.

Two smaller items also want an owner's call, both flagged in the code rather than silently decided:

  • Are MCP account names in the threat model? credential_allowlist stores (server, account) name pairs in cleartext (never values — those are brokered host-side). If names are sensitive, the column wants encryption at rest. Recorded in migration 022 and pointed at SECURITY.md.
  • RemoteMCPStatusError and RemoteMCPTransportSSE are defined but never assigned anywhere. Is SSE transport aspirational, and does a remote-MCP connection ever get marked error? Left in place — deleting either would answer a product question by fiat.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YNnyPJfUvUEfi6TWa6zL45

Brad Flaugher added 10 commits August 22, 2026 15:43
The read path for task rows was narrowed to own rows in #1082, and run
logs in #980. Three surfaces never got the same treatment and authorized
on a permission alone, so any client-role principal reached every
principal's rows:

- GET /tasks/paused — ListPausedTasks selects on status alone with no
  principal predicate in SQL, and the projection carries each task's
  prompt. Its siblings (/tasks/export, /tasks/upcoming) both call
  visibleTasks; this one did not. Leaked other principals' paused
  prompts, and their task UUIDs with them.
- PUT /tasks/{id} and POST /tasks/{id}/tags — loaded the task with the
  unscoped GetTask and never checked ownership, so a client-role
  principal could rewrite a teammate's pending run: prompt, model,
  mcp_selection and credential_allowlist included. Only run_if was
  gated (admin-only).
- POST /tasks/{id}/feedback and GET /tasks/{id}/learned-instructions —
  taskFromPath is lookup-only by contract ("a handler that needs an
  authorization decision makes it on the returned task") and neither
  caller made one. A down-vote with an attacker-authored critique fed
  maybeDistill, which mints a proposal from the victim's prompt at
  unmetered model spend; the GET disclosed their learned instructions.

The write gate is a new taskWritableByPrincipal, deliberately NOT
principal.ownsTask: ownsTask resolves through ownerID(), which is nil
for every API-key principal, so it would deny a scoped intake-app key
the right to edit the task it just created. taskCreatedByPrincipal
matches a creating user OR a creating key (CreatedByKeyID), which is the
model #980/#1082 established. A write surface must be no looser than the
read surface guarding the same row.

TestScopedAPIKeyAuthorization previously asserted "client key can edit
an editable task" against an unattributed row — that was the vulnerable
behavior. Split into the owned case (must keep working: the intake-app
path) and the unowned case (must 403).

Every fix mutation-tested: stripped, the tests fail with the exploit
visible — a hijacked prompt persisted, another principal's prompt in the
paused queue, feedback accepted on an unowned task.

Also in this commit, from the same audit sweep:

- internal/config/config.go: ValidateScheduled interpolated the first 6
  bytes of OPENROUTER_API_KEY into a validation error — the only place
  in the tree where secret material reached an error string. Removed,
  and the doc comment corrected: it claimed "Called at startup" but has
  no production caller.
- internal/agent/scheduled.go: run-error strings now go through
  agentcore.RedactSecrets before the persisted transcript and the log.
  Tool output, the stream sink, hooks and the session log were already
  scrubbed; run errors were the one path that skipped it, and the
  transcript write is the larger surface.
- internal/mcpoauth/discovery.go: refuse a non-http(s) scheme on the
  remote-derived discovery URLs (a WWW-Authenticate resource_metadata
  pointer, a PRM-declared issuer) before the request. Contained already
  by SafeHTTPClient and the transport; this makes the argument explicit
  rather than dependent on transport behavior. Tested both directions.
- internal/sched/models/models.go: validate WorktreeConfig.BaseBranch.
  It is the trailing positional of `git worktree add -b <b> <path>
  <base>` with no "--" separator, so a leading-dash value was parsed by
  git as an option. worktree_config is settable by any task creator,
  unlike run_if.
- Log-injection sinks that carry genuinely untrusted text: the task
  create log (task.Prompt — its update-path twin was already sanitized),
  the pre-validation client attachment path on the reject branches, the
  client-echoed attachment Name, the upload filename, and the API-key
  name. logSafe/%q, matching each line's existing sanitized sibling.
- web/e2e/test-auth-key.ts: the Ed25519 private key was written to a
  fully predictable path in the world-writable temp dir at default 0644.
  Now O_EXCL at 0600 with random bytes in the sibling name.
- internal/agent/session.go: document loadImageAttachments' caller
  contract. It performs no path containment of its own and is safe only
  because httpapi's validateAttachments is its sole producer. Stated as
  a contract, not an enforced boundary, because the uploads root is not
  threaded to that call site — a local check could only re-assert part
  of the guard while looking like all of it.

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

The gate #1246 shipped blocked every push to dev and main. Its threshold
was "any finding at any severity", justified by a measured zero across
all four languages — but that measurement came from Dev CI run 525, a
`pull_request` event, and on pull_request events the CodeQL action runs
DIFF-INFORMED: it builds the full database, evaluates every query, then
reports only results located inside the PR's diff. Run 525's own log
says both halves out loud ("Persisted 204 diff range(s) across 43
file(s)", and "file coverage information is only enabled when analyzing
the default branch and protected branches"). The Go database held all
428 files and TaintedPath/RequestForgery/LogInjection/
WeakSensitiveDataHashing all ran; the SARIF was empty because results
outside the 43 changed files were dropped.

So the first full-tree evaluation was the push that merged #1246 — run
527 — which reported 38 Go and 17 javascript-typescript findings and
turned Dev gate red, with no PR-shaped way out: a PR into dev is scanned
diff-informed and stays green while dev itself stays red.

The generalizable lesson, now written into the file and the ADR: a
PR-event CodeQL run certifies a diff, not a tree. Any "the scanners are
green, therefore the tree is clean" claim resting on one is unsound, and
that is permanent behavior, not a bug.

New threshold: a finding blocks when its SARIF level is error/warning or
its rule carries security-severity >= 7.0, unless it is waived. Below
that band findings are printed and go to the Security tab as advisory.

Severity alone is not sufficient to separate the false positives, which
is why the register exists: go/request-forgery is 9.1 and fires on
web_fetch.go, a deliberate user-facing fetch tool behind netguard's
resolve-then-dial SSRF guard; go/weak-sensitive-data-hashing is 7.5 and
fires on SHA-256 used as a lookup index over a 32-byte crypto/rand
token, which is the recommended construction.

.github/codeql-accepted-findings.json registers accepted (rule, file)
pairs with a mandatory written reason. Per-FILE, not per-rule, and that
is the point of preferring it to a query-filters exclude: an exclude
switches a 9.1 query off repo-wide, while the register waives it in the
two files that were adjudicated and leaves it live everywhere else. A
synthetic SARIF carrying a fresh go/request-forgery in an unregistered
file fails the gate — verified while developing the jq, along with the
clean, notes-only, mixed-severity, in-source-suppressed, missing-SARIF,
malformed-SARIF and missing-register cases.

The gate fails closed twice over: a missing register and an
unevaluatable jq both refuse to report the scan clean, rather than
reading as zero.

Anti-rot controls, because a register nobody re-reads is worse than
none:
- scripts/check_codeql_register_test.go (in make test) requires every
  entry to name a file that exists, carry a substantive reason, use a
  plausible rule id, and be unique — and asserts codeql.yml still
  references the register, so the two cannot be silently decoupled.
  Mutation-tested: a bogus rule id, a missing file and a one-word
  reason all fail it.
- The log and step summary print three tiers — BLOCKING, ACCEPTED and
  ADVISORY — so every waiver appears in ordinary CI output instead of
  only in a file someone has to think to open.

Also corrects the claims this file made about itself, which an auditor
reads as documentation:
- the "reports ZERO ... a finding here is new" premise (false);
- "They cannot be part of CI gate" two paragraphs after correctly
  explaining that the calling job is in the gate's needs;
- "whether a red check BLOCKS a merge is branch protection's call",
  which contradicted the header — and now records the real caveat, that
  the dev ruleset requires no status checks so Dev gate is
  red-but-not-required there;
- the summarize step's "gating on findings is merge protection's job,
  not this step's", directly above the step that gates;
- dev-ci.yml's "makes green mean clean, not just ran", now qualified for
  pull_request events.

ADR-0048 records the decision, what gets worse (a note-level regression
no longer fails the build — gosec's G706 still covers the log-injection
class through golangci-lint, which does block), and the sharpest edge (
the register keys on rule+file, not rule+file+line, so a second bad
instance in an already-waived file would not block; line keys churn on
every edit and a register that fails on unrelated refactors is one
people delete).

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
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>
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>
Three tests broke on taskWritableByPrincipal, and all three for the same
reason: their fixture tasks were inserted with neither CreatedBy nor
CreatedByKeyID, so an unattributed row was being edited by a client-role
key. That is precisely the shape the new gate refuses.

None of them is about ownership:

- TestUpdateTaskRunIfPersistsAndNormalizes and
  TestUpdateTaskKeepsGatedTaskOnSchedulerPath are about the run_if
  privilege boundary and the dispatch-state recompute.
- TestTypedKeyRouteScope is about the #190 middleware type-scope gate.

So the fix is the fixture, not the gate: each task is now attributed to
the key that acts on it, which is also the realistic shape — a task a
client key created really does carry that key's CreatedByKeyID. Left
unattributed, these tests would have been asserting run_if and
type-scope behavior through a path that the ownership check short-circuits
first, which is a worse test than either intent.

mustCreateTypedKeyWithID joins mustCreateRoleKeyWithID as the helper that
returns the KeyID alongside the secret, with a note saying why a fixture
wants it.

Full Go suite green against a real Postgres 16 (`make test`, -p 1).

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
docs/implementation-plans-enhancements.md had zero inbound links from
any markdown, Go file, workflow or script, and three of its four plans
had shipped. The shipped copies are the problem, not the file: a plan
sitting next to the authoritative record is the worse of the two, and
this one was phrased in the present tense.

The #167 entry is why this matters for an audit rather than being tidy-up.
It carried a section headed "OAuth control-plane tokens parent-readable"
whose resolution — accepted for v1, threat model written down (parent
compromise implies stored remote-MCP tokens; agent runs stay child-side
per ADR-0040) — was already recorded in SECURITY.md and
docs/MCP-BROKER-SCOPES.md. A second copy, phrased as an open decision, in
an unlinked file, reads like an unresolved security gap to anyone who
greps their way into it. It is not one.

What remains is #984 (the Fleet <-> Buzz bridge), which genuinely has not
shipped, under a header that says so — and that says explicitly that its
unchecked acceptance boxes (including "Bot token not logged; secrets in
env only") are criteria the feature must meet BEFORE it ships, on code
that does not exist yet, not a list of open findings. That distinction is
invisible in a bare checklist and is exactly what an auditor would
otherwise have to ask about.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
The tool-output scrubber's literal set was empty of connector
credentials in every production build, so its own doc comment
("OPENROUTER_API_KEY, connector credentials, …") described coverage it
did not have.

The mechanism is an ordering accident, not a missing feature.
agentcore's redactor is built lazily behind a sync.Once that fires on the
first tool output, and it seeds itself from os.Environ(). But the MCP
broker's boot path calls scrubParentConnectorState, which os.Unsetenv's
every connector environment key, and that runs during broker startup —
before the interactive engine is even constructed, let alone before any
turn produces output. So by the time the snapshot is taken those values
are gone, and RegisterEnvLiterals registered only what survives the
scrub.

That divestment is the entire point of the broker boundary and is not
touched here. What it left uncovered is the return path: a connector that
echoes its OWN credential back in a tool result or error string was
scrubbed only if the value happened to match one of internal/redact's
shape patterns (sk-*, ghp_*, AKIA*, Authorization:, marker=value). A
novel bare token would have reached the model context, the SSE stream and
the session log. internal/tools/browserbase_live_view.go is written as
though the literal set were richer than it was — it survives only because
it independently checks strings.Contains(liveView, apiKey) and refuses.

Two wirings close it:

- scrubParentConnectorState now hands each value to
  agentcore.RegisterSecretLiteral immediately BEFORE unsetting it, so the
  scrubber learns it at the last moment it is knowable.
- cmd/fleet/main.go wires the PARENT's remotemcp service to the same
  hook, mirroring what the credential-owning child already does via
  mcpbroker.RegisterSecretLiteral (#1124). The parent unseals per-user
  api_key secrets and mints/refreshes OAuth bearers for its control
  plane — browserbase_live_view among them — and those are acquired at
  runtime, so no boot-time env snapshot could ever know them. That
  SetSecretObserver call was simply absent.

RegisterSecretLiteral buffers values offered before the redactor exists
and drains them at construction, both under the same mutex that guards
the sharedRedactor nil-check — so a value offered concurrently with
construction is either buffered and drained or added directly, never
dropped between the two. Publication happens under that lock for the same
reason; the unlocked read in toolRedactor is safe on sync.Once's
happens-before. Verified with `go test -race` on internal/agentcore and
internal/redact.

Neither change widens what the parent can READ. A registered value is
held solely as a redaction target and is never emitted, logged or
persisted — and this is a backstop for what comes back across the broker
boundary, not a substitute for the boundary.

The test uses a token that matches none of the shape patterns, so it
passes only if literal registration genuinely reached the redactor, and
registers it before anything forces construction — the real boot order.
Mutation-tested: neutering RegisterSecretLiteral makes it fail with the
secret visible in the output. It also covers post-construction
registration (the runtime-acquired case) and asserts that an empty
registration does not turn the scrubber into a match-everything.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
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>
Next.js rewrites web/next-env.d.ts on every `next build`, and my
verification run swept the added root-params reference into the docs
commit. The file's own header says it should not be edited, and this
branch changes nothing that would legitimately alter it — so it goes back
to dev's version rather than carrying a build artifact through review.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
streamForceFinalSummary does not apply in.GuardStep while its sibling
streamLeakedToolCallRetry does, and the asymmetry reads as an oversight —
it was flagged as one during the audit. It is deliberate, so the reason
now sits next to the code instead of in someone's head:

the retry runs WITH tools and can buy an unbounded number of paid
completions, so it must be held to the run's ceilings. The forced summary
is a single tool-less completion bounded by tc.MaxTokens, it is metered
via in.RecordUsage in OnStepFinish like any other call, and it only runs
on the canFinish path — a run stopped by ErrCostCeilingExceeded never
reaches Finalize, so it cannot be entered after a ceiling has already
tripped mid-run.

Worst case is one bounded, accounted completion of overshoot when a
ceiling is reached on the final step, which is the price of returning a
usable answer rather than a truncated one. The comment says explicitly
that this stops being true if the function ever grows tools or a loop.

No behavior change.

Signed-off-by: Brad Flaugher <brad@elcanotek.com>
@bradflaugher
bradflaugher merged commit 373dcec into dev Aug 22, 2026
19 checks passed
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 added a commit that referenced this pull request Aug 23, 2026
…d-scanner overhaul, the enterprise security audit (#1253)

32 commits from dev, plus the one CI fix the full gate demanded.

Kubernetes as a first-class deployment (#989, ADR-0049): a pluggable sandbox
backend selected by FLEET_SANDBOX_BACKEND=podman|kubernetes, one ephemeral pod
per sandbox, a fail-closed cluster preflight with no degrade to podman or host
execution, and one Helm chart. The enterprise security audit (#1247): three
own-rows authorization holes closed. The scanner overhaul: CodeQL on the High
band with a reviewed accepted-findings register (ADR-0048), CodeQL and Semgrep
wired into CI gate / Dev gate. A workflow + shell lint gate. Automatic merging
removed. Two green-but-vacuous CI holes closed. AGENTS.md reconciled with the
tree (#1252).

The CI fix: ci.yml's new pg_dump major assertion could not pass. /usr/bin/pg_dump
is postgresql-common's pg_wrapper, which dispatches on the cluster named in
~/.postgresqlrc or /etc/postgresql-common/user_clusters rather than on the newest
client installed, so a runner carrying a PostgreSQL 16 cluster kept resolving to
16 over a successful client-18 install. The versioned bin dir now goes first on
$GITHUB_PATH — which is what the round-trip test needs anyway, since it execs
pg_dump off PATH — asserted by absolute path where the install happens and by
PATH resolution in the following step.

Merged as a merge commit, not a squash: promotion #1248 was squashed, which
discarded the ancestry link between the branches and is what produced the twenty
add/add conflicts resolved in this one.
@bradflaugher
bradflaugher deleted the claude/enterprise-security-audit-qomnnd 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.

2 participants