Enterprise security audit: close three own-rows authorization holes, unblock the CodeQL gate, correct the docs - #1247
Merged
Conversation
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
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.
4 tasks
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
devwas red and every push todevormainwas 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:GET /tasks/paused/tasks/exportand/tasks/upcomingboth callvisibleTasks; this one did not. Leaked other principals' paused prompts — and their task UUIDs with them.PUT /tasks/{id},POST /tasks/{id}/tagsGetTaskand never checked ownership. A client-role principal could rewrite a teammate's pending run: prompt, model,mcp_selection,credential_allowlist. Onlyrun_ifwas gated.POST /tasks/{id}/feedback,GET /tasks/{id}/learned-instructionstaskFromPathis lookup-only by its own doc contract and neither caller made the decision. A down-vote with an attacker-authored critique fedmaybeDistill, 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 notprincipal.ownsTask—ownsTaskresolves throughownerID(), 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.taskCreatedByPrincipalmatches 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.TestScopedAPIKeyAuthorizationpreviously 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_requestevent — 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:So the first full-tree scan was the push that merged it — run 527 — reporting 38 Go + 17 JS findings, turning
Dev gatered with no PR-shaped way out: a PR intodevis diff-informed and stays green whiledevstays 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-forgeryis 9.1 and fires onweb_fetch.go, a deliberate user-facing fetch tool behindnetguard's resolve-then-dial SSRF guard;go/weak-sensitive-data-hashingis 7.5 and fires on SHA-256 used as a lookup index over a 32-bytecrypto/randtoken — the recommended construction.New threshold: security-severity ≥ 7.0 blocks (level
error/warningas 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: aquery-filtersexclude 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 freshgo/request-forgeryin 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[], nottool.driver.rules[](the driver is the CLI). Reading only the driver resolved nothing, so nothing could reach the High band. Same reason a result'slevelis usually absent: SARIF falls back to the rule'sdefaultConfiguration.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
levelas well put all 23go/log-injectionfindings (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_dispatchrun (not diff-informed):with real severities resolved (
9.1request-forgery,7.5path-injection,6.1log-injection).The filter lives in
.github/codeql-gate.jq, shared by the summary and the gate viajq -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 throughgolangci-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.gologgedtask.Promptunsanitized on the task-create path while the update path's twin was already wrapped inlogSafe.POST /tasksis reachable by a scopedcreate_taskkey — genuine log forgery.internal/httpapi/attachments.gologged the raw client path with%son the two branches where containment had just failed.internal/agent/session.gologged the client-echoed attachmentName, which unlikePathis never re-sanitized on/chat.web/e2e/test-auth-key.tswrote an Ed25519 private key to a predictable path in the world-writable temp dir at0644. NowO_EXCLat0600with random bytes in the sibling name.5. CI supply chain
github/codeql-action(5 refs) andgolangci/golangci-lint-action(2) were pinned to the annotated tag object of a mutable major tag, not a commit:refs/tags/v4→4c0873ef, butrefs/tags/v4^{}→db488dde. A tag object is immutable but only reachable while that tag points at it — the day upstream movesv4the 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.Zcomments (they read# v4 (4.37.8)and# v9, which dependabot-core parses as "4" and "9").scripts/check_action_pins_test.goenforces the shape across all 53 refs.fleet_refdeny-list → allow-list. Two holes:GITHUB_OUTPUTnewline injection (fleet_ref="main\nresolved=refs/pull/1/head"matched no deny pattern, exited 0, emitted tworesolved=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 andactions/checkoutwill fetch a bare SHA). Both matter because these workflows execute the checked-out build script, and the publish twin holdspackages: writewith a live GHCR login. Tested: 5 legitimate refs pass, 9 attack shapes fail.*.mdmatched at any depth anddocs/*matched everything underdocs/, so a PR touching only ago:embed'dbuiltin_skills/*/SKILL.md, a shippedconfig/default/system_prompts/*.md, ordocs/openapi.yaml(asserted byopenapi_drift_test.go) was classified docs-only — every job skipped whileCI gatewent green. Narrowed to a prose allow-list.ci-gateacceptedskippedunconditionally. 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.goasserts every job is in its gate'sneeds(11/11 and 7/7 today; nothing asserted it).conclusion == 'failure', ignoringstartup_failure— the exact incident its own header describes — andtimed_out. Broadened, and the daily real-model canary added to the watched list (it had no alarm at all).ci.yml's workflow-levelpull-requests: readhad no consumer (only-new-issues: false) but reached every job including the npm-installing ones.screenshots.ymlheldcontents: writefor a single job runningnpm ci+ Playwright, in exchange for a push themainruleset can never accept.6. Other hardening
internal/config/config.go—ValidateScheduledinterpolated the first 6 bytes ofOPENROUTER_API_KEYinto 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.agentcore's redactor is built lazily and seeds fromos.Environ(), but the broker's boot pathos.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_*, …).scrubParentConnectorStatenow hands each value over before unsetting it, and the parent'sremotemcpservice gets theSetSecretObserverwiring 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— validateWorktreeConfig.BaseBranch. It is the trailing positional ofgit worktree add -b <b> <path> <base>with no--separator, so a leading-dash value was parsed by git as an option.worktree_configis settable by any task creator, unlikerun_if.internal/agent/scheduled.go— run-error strings now go throughRedactSecretsbefore the persisted transcript and the log.7. Dead code and cruft
Six unreferenced exported identifiers in
internal/(the classunuseddeliberately does not report), each verified withgit grep -wacross all tracked files. Two mattered beyond tidiness:MaxLogSubmissionSizedeclared a 24 MB body cap that nothing enforced — the real cap isMaxJSONBodySizeat 1 MB, so an auditor readingmodels.gowould have concluded the opposite — andDefaultFromEmaildocumented 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 todev"would re-analyze identical content" (the exact trap that brokedev);SECURITY.mdhaving no SAST section at all while claiming Grype gates on Python packages at CRITICAL (it is RPMs only, at CRITICAL and HIGH);docs/TESTING.mdsaying the fast lane skips CodeQL when it runs both scanners; andCHANGELOG.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 hostgit worktreemanagement and the admin-gated hostpodmanbuild. ADR-0012's "removed after one release" had no anchor (git tagreturns nothing,VERSIONis0.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.npm ci, oxlint,tsc --noEmit, vitest (1104),next build— clean.go test -raceon the packages touched by the redactor change.test-auth-key.tspath was exercised directly: mode0600, atomic replace, no leftover temp siblings.The
devruleset requires no status checks. Its only rules aredeletionandnon_fast_forward— nopull_requestrule, norequired_status_checks. So every job indev-ci.yml, CodeQL and Semgrep included, is red-but-not-required ondev.maincorrectly requiresCI gate.Compounding it:
.github/dependabot.ymltargetsdevfor thegithub-actionsecosystem daily with nocooldown(cooldown is only supported for gomod and npm), andauto-merge-dependabot.ymlmerges patch bumps. A GitHub Action patch bump is a rewrite of.github/workflows/*— so it could land ondevwith no CI, no review, and no cooldown. The workflow's own comment assumed CI holds the merge; ondevthere was nothing to hold it.Workflow-side mitigations are in this PR (
github_actionsexcluded from auto-merge, abranches: [main, dev]filter, job-scoped write permissions). AddingDev gateto thedevruleset is yours — and it is what makes several statements indocs/SCANNING.mdandAGENTS.mdunconditionally true rather than branch-dependent.Two smaller items also want an owner's call, both flagged in the code rather than silently decided:
credential_allowliststores(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 atSECURITY.md.RemoteMCPStatusErrorandRemoteMCPTransportSSEare defined but never assigned anywhere. Is SSE transport aspirational, and does a remote-MCP connection ever get markederror? Left in place — deleting either would answer a product question by fiat.🤖 Generated with Claude Code
https://claude.ai/code/session_01YNnyPJfUvUEfi6TWa6zL45