Post-Audit Hardening Bundle¶
- Authors
- Matt Cockayne, Claude (AI drafting assistant)
- Date
- 17 April 2026
- Status
- IMPLEMENTED
Overview¶
The 2026-04-17 secondary audit surfaced 21 findings. Four findings (H-1, H-2/H-3, M-3, M-5/M-6) have their own dedicated specs because the design surface is non-trivial. The remaining 13 findings โ workflow configuration, size limits, documentation, small helpers, and tidy-ups โ are bundled here because:
- Each individual change is a small, mechanical PR (config edit, one-line API addition, or short middleware).
- They share a theme ("tighten the boundaries") and benefit from landing together.
- Bundling avoids the overhead of 13 mini-specs for uncontroversial work.
This spec enumerates each change with its location, rationale, and acceptance criteria. The implementation PR should reference each finding ID in its commit messages.
Each sub-change in this bundle can be implemented and merged independently. They are listed in suggested order (CI/supply-chain HIGH severity first, helper-utility LOW last), but a contributor picking up any one of them does not block on the others.
Scope¶
| ID | Severity | Area | One-line summary |
|---|---|---|---|
| H-4 | HIGH | Renovate config | Add minimumReleaseAge to patch auto-merge rule |
| H-5 | HIGH | CI workflow | Pin goreleaser CLI to an exact version |
| H-6 | HIGH | CI workflow | Add concurrency: block to release workflow |
| M-1 | MEDIUM | HTTP server | Wrap management handlers with MaxBytesReader |
| M-2 | MEDIUM | gRPC server | Set MaxRecvMsgSize / MaxSendMsgSize explicitly |
| M-4 | MEDIUM | Telemetry backends | Drain response bodies via io.LimitReader |
| M-7 | MEDIUM | CI workflow | Split the goreleaser GitHub App token into per-repo tokens |
| M-8 | MEDIUM | Install scripts | Document GITHUB_TOKEN scope; make optional for public repos |
| L-1 | LOW | Agent tools | Truncate + redact subprocess output before wrapping into errors |
| L-2 | LOW | Snapshot encryption | Provide chat.GenerateEncryptionKey() helper |
| L-3 | LOW | Telemetry spill | Log spill-cleanup errors at WARN |
| L-6 | LOW | CI workflow | Set persist-credentials: false on all read-only workflows |
| L-7 | LOW | Python tooling | Pin transitive Python deps via hashes |
Findings already accepted as risk (L-4, L-5) are not in this bundle โ they are documentation-only updates tracked separately.
Per-finding details¶
H-4: Renovate minimumReleaseAge on patch auto-merge¶
File: .github/renovate.json5
Change: The Go dependency patch rule currently has automerge: true, automergeType: "pr" with no soak time. Add minimumReleaseAge: "3d" so a maliciously-published patch has a 3-day detection window before auto-merging into main.
{
matchDatasources: ["gomod-datasource"],
matchUpdateTypes: ["patch"],
groupName: "go dependencies",
groupSlug: "go-deps",
automerge: true,
automergeType: "pr",
minimumReleaseAge: "3d", // NEW
},
Apply the same minimumReleaseAge to the gomod-datasource minor rule (which is manual-review already) for consistency. Major updates stay manual with a longer implicit soak.
Acceptance: Renovate dashboard confirms the new setting is active. CI continues to run on every Renovate PR.
H-5: Pin goreleaser CLI to an exact version¶
File: .github/workflows/goreleaser.yaml
Change: Replace version: "~> v2" with an exact pin. Bump via Renovate PRs going forward.
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
with:
distribution: goreleaser
version: "2.10.2" # EXACT โ bump via Renovate PR
Add to .github/renovate.json5 a rule that treats goreleaser-action's version: input as a dependency to track. Alternatively (simpler): add a comment noting the manual bump convention.
Acceptance: CI runs use a pinned binary; a grep check in CI (or in a pre-commit hook) fails if the version becomes a tilde/caret range.
H-6: Add concurrency: block to release workflow¶
File: .github/workflows/goreleaser.yaml
Change: Add a top-level concurrency group so simultaneous tag pushes (or a quickly re-pushed tag) queue rather than race.
name: goreleaser
on:
push:
tags: ["v*"]
workflow_dispatch:
concurrency: # NEW
group: release
cancel-in-progress: false
permissions:
contents: write
cancel-in-progress: false because cancelling an in-progress release would leave half-uploaded artefacts.
Acceptance: Pushing two tags in quick succession observably serialises the workflow runs.
M-1: HTTP server request body size limit¶
File: pkg/http/server.go
Change: Wrap every handler mounted on the management HTTP server with http.MaxBytesReader. The simplest implementation: a middleware registered once when the server is constructed.
const DefaultMaxRequestBodyBytes int64 = 1 << 20 // 1 MiB
// WithMaxRequestBodyBytes overrides DefaultMaxRequestBodyBytes.
func WithMaxRequestBodyBytes(n int64) ServerOption { /* ... */ }
func (s *Server) maxBytesMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, s.maxRequestBodyBytes)
next.ServeHTTP(w, r)
})
}
1 MiB is generous for management traffic (health, readiness, liveness, admin). Downstream tools with different needs can override.
Acceptance: A test POSTs a 2 MiB body to /admin and asserts the response is 413 Request Entity Too Large.
M-2: gRPC message size limits¶
File: pkg/grpc/server.go
Change: Add explicit grpc.MaxRecvMsgSize / MaxSendMsgSize options with sensible defaults (1 MiB) and expose override options.
const DefaultMaxGRPCMessageBytes = 1 << 20 // 1 MiB
func NewServer(opts ...ServerOption) *grpc.Server {
cfg := defaultServerConfig()
for _, o := range opts {
o(&cfg)
}
return grpc.NewServer(
grpc.MaxRecvMsgSize(int(cfg.maxRecvBytes)),
grpc.MaxSendMsgSize(int(cfg.maxSendBytes)),
// ... existing options ...
)
}
Acceptance: A unit test sends a 2 MiB message to a test server and asserts the error includes ResourceExhausted.
M-4: Telemetry backend response drain¶
File: pkg/telemetry/datadog/datadog.go, pkg/telemetry/posthog/posthog.go, pkg/telemetry/backend.go (generic HTTP backend)
Change: After client.Do(req), drain the response body via io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)). Enshrines the "never read unbounded" invariant in the code so a future refactor cannot accidentally break it.
resp, err := h.client.Do(req)
if err != nil {
return nil
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= httpErrorThreshold {
h.log.Debug("backend returned non-success status", "status", resp.StatusCode)
}
return nil
Acceptance: A test with an httptest.Server that streams an unbounded body (until 2 MiB) confirms the backend reads no more than 1 MiB and returns normally.
M-7: Split the goreleaser GitHub App token¶
File: .github/workflows/goreleaser.yaml, .goreleaser.yaml
Change: Two separate actions/create-github-app-token invocations, producing:
GORELEASER_RELEASE_TOKENโ scope:go-tool-baseonly,contents: writeon that repo.GORELEASER_HOMEBREW_TOKENโ scope:homebrewonly,contents: writeon that repo.
.goreleaser.yaml references the two env vars independently (GITHUB_TOKEN for release, HOMEBREW_TAP_GITHUB_TOKEN for the Homebrew cask push).
Acceptance: Workflow diff shows two create-github-app-token steps, each with minimal repo scope. A dry-run release confirms both paths still work.
M-8: Install scripts GITHUB_TOKEN scoping¶
Files: install.sh, install.ps1, docs/installation.md
Change: Three coordinated edits:
- Make
GITHUB_TOKENoptional for public repositories. The token is only needed to dodge the 60-req/hr anonymous API rate limit, which is unlikely to trip on a single install. Detect absence and continue without Authorization headers, at the cost of potential rate limiting. - Warn on over-privileged tokens. Before proceeding, call
GET /userand inspectX-OAuth-Scopes. If any ofrepo,admin:*,delete_repois present, print a loud warning recommending a fine-grained token withcontents:readon the repo only. - Document required scopes in the installation guide.
docs/installation.mdgains a "Token permissions" subsection.
# install.sh โ revised token handling
if [ -n "${GITHUB_TOKEN:-}" ]; then
scopes=$(curl -sI -H "Authorization: token ${GITHUB_TOKEN}" "${api_base}/user" \
| awk -F': ' '/^[Xx]-[Oo][Aa]uth-[Ss]copes:/{print $2}' | tr -d '\r\n')
case "${scopes}" in
*repo*|*admin*|*delete_repo*)
echo "WARNING: GITHUB_TOKEN appears to have broad scopes (${scopes})." >&2
echo "For installing releases, a fine-grained token with only" >&2
echo "'contents: read' on this repo is sufficient." >&2
;;
esac
auth_header="-H Authorization: token ${GITHUB_TOKEN}"
else
echo "INFO: No GITHUB_TOKEN set. Proceeding anonymously (subject to rate limits)."
auth_header=""
fi
install.ps1 gets an equivalent block.
Acceptance: Running the install script with no GITHUB_TOKEN set completes successfully for the public repo. Running with an over-scoped token produces the warning but still installs.
L-1: Agent tool subprocess output truncation and redaction¶
File: internal/agent/tools.go
Change: Every path that folds subprocess output into an error message truncates to head+tail (e.g. first 50 + last 50 lines) and routes the result through pkg/redact.String (added in 2026-04-17-telemetry-redaction.md). Prevents both log-volume floods and credential leaks.
// headTailTruncate returns head + "\nโฆ [omitted X lines] โฆ\n" + tail.
func headTailTruncate(s string, headLines, tailLines int) string { /* ... */ }
// wrapToolError wraps err with the truncated, redacted output.
func wrapToolError(err error, output []byte) error {
sanitised := redact.String(headTailTruncate(string(output), 50, 50))
return errors.Wrapf(err, "\n%s", sanitised)
}
Every errors.Wrapf(err, "\n%s", string(output)) site is replaced by wrapToolError(err, output).
Acceptance: A test runs a tool that emits a long stderr containing sk-xxxxxx... and confirms the wrapped error contains sk-*** and is โค 110 lines.
L-2: chat.GenerateEncryptionKey() helper¶
File: pkg/chat/filestore.go
Change: Expose a helper that returns 32 random bytes from crypto/rand:
// GenerateEncryptionKey returns a fresh 32-byte AES-256 key from
// crypto/rand, suitable for use with WithEncryption. Each snapshot
// store should use a distinct key obtained either from this helper
// or from an operator-controlled source (e.g. KMS).
func GenerateEncryptionKey() ([]byte, error) {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, errors.Wrap(err, "reading random bytes")
}
return key, nil
}
Update WithEncryption's doc comment to recommend this helper explicitly; reject keys of length != 32 with a clearer error than "AES-GCM: invalid key length".
Acceptance: Unit test calls GenerateEncryptionKey() twice and asserts the results differ and are 32 bytes each.
L-3: Log spill-cleanup errors at WARN¶
File: pkg/telemetry/spill.go
Change: Replace _ = os.Remove(f) with explicit error checks and a WARN log:
if err := os.Remove(f); err != nil {
c.log.Warn("failed to remove spill file after successful send",
"file", filepath.Base(f),
"error", err)
}
filepath.Base(f) instead of the full path to avoid leaking the spill directory location in logs.
Acceptance: A test runs the flush path with a read-only spill directory and confirms the WARN log is emitted exactly once per stuck file.
L-6: persist-credentials: false on read-only workflows¶
Files: .github/workflows/codeql.yaml, .github/workflows/security.yaml, .github/workflows/tests.yaml โ and any other workflow that does not need git-push access.
Change: Every actions/checkout@... in a read-only workflow gains:
Scorecard already has this. Write-enabled workflows (goreleaser, release-please, etc.) keep the default.
Acceptance: Manual review of each workflow confirms the right ones got the flag and the write-enabled ones did not.
L-7: Python dependency pinning¶
Files: .github/workflows/goreleaser.yaml, .github/workflows/docs.yml, new requirements-lock.txt
Change: Generate a pinned lock file with hashes:
Update workflows to pip install --require-hashes -r requirements-lock.txt.
Acceptance: CI runs use the lock file; a Renovate rule is added to keep the lock file bumped.
Project Structure¶
The bundle touches many small places. A consolidated list:
| File | Change summary | Finding |
|---|---|---|
.github/renovate.json5 |
minimumReleaseAge: "3d" on patch rule |
H-4 |
.github/workflows/goreleaser.yaml |
Exact version: pin, concurrency: block, two create-github-app-token steps, persist-credentials: false on non-release jobs |
H-5, H-6, M-7, L-6 |
.github/workflows/codeql.yaml, security.yaml, tests.yaml, docs.yml |
persist-credentials: false |
L-6 |
.github/workflows/docs.yml, goreleaser.yaml |
pip install --require-hashes -r requirements-lock.txt |
L-7 |
requirements-lock.txt |
New | L-7 |
requirements.in |
New (source for pip-compile) | L-7 |
install.sh, install.ps1 |
Optional GITHUB_TOKEN, scope warning, docs link | M-8 |
docs/installation.md |
"Token permissions" subsection | M-8 |
.goreleaser.yaml |
Reference two separate env vars for release vs homebrew tokens | M-7 |
pkg/http/server.go |
MaxBytesReader middleware + option |
M-1 |
pkg/http/server_test.go |
413 test | M-1 |
pkg/grpc/server.go |
MaxRecvMsgSize, MaxSendMsgSize options |
M-2 |
pkg/grpc/server_test.go |
ResourceExhausted test | M-2 |
pkg/telemetry/datadog/datadog.go, pkg/telemetry/posthog/posthog.go, pkg/telemetry/backend.go |
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) |
M-4 |
Matching *_test.go |
Unbounded-body test | M-4 |
pkg/telemetry/spill.go |
WARN on cleanup error | L-3 |
pkg/telemetry/spill_test.go |
Assert WARN emitted | L-3 |
internal/agent/tools.go |
headTailTruncate + wrapToolError helpers |
L-1 |
internal/agent/tools_test.go |
Truncation + redaction test | L-1 |
pkg/chat/filestore.go |
GenerateEncryptionKey helper + better-error on bad key length |
L-2 |
pkg/chat/filestore_test.go |
Round-trip test | L-2 |
Error Handling¶
No new sentinel errors. Each sub-change follows existing package conventions (cockroachdb/errors, WithHint where user-facing).
Non-Functional Requirements¶
Testing & Quality Gates¶
| Requirement | Target |
|---|---|
| Line coverage | Maintain existing coverage targets for each package (โฅ 90 % for pkg/). Small helpers get dedicated tests; workflow/config changes verified by CI smoke runs |
| Race detector | All packages retain go test -race green |
| Workflow dry-run | A push to a scratch branch triggers goreleaser with the new config; verify concurrency, split tokens, and exact version pin work end-to-end |
| Install-script smoke | CI job invokes install.sh without GITHUB_TOKEN set (anonymous); install succeeds |
| Install-script warning | CI job invokes install.sh with a deliberately-overscoped PAT; warning is emitted |
| Lock-file hash | pip install --require-hashes -r requirements-lock.txt succeeds; hash mismatch test confirms the --require-hashes flag is actually active |
Documentation Deliverables¶
| Artefact | Scope |
|---|---|
docs/installation.md |
New "Token permissions" subsection for M-8 |
docs/how-to/releasing.md (if present) |
Reference the concurrency invariant and the two-token model for M-7/H-6 |
docs/components/http.md |
Document WithMaxRequestBodyBytes option (M-1) |
docs/components/grpc.md |
Document size-limit options (M-2) |
docs/components/telemetry.md |
Short note on spill-cleanup logging (L-3) and response-drain invariant (M-4) |
docs/components/chat.md |
Document GenerateEncryptionKey usage (L-2) |
CLAUDE.md |
One-line pointer under Architecture referencing the telemetry response-drain invariant and the chat encryption-key helper |
| Commit messages | Each commit prefixed with the finding ID (fix(security): [H-4] โฆ) so the audit is traceable in git log |
Observability¶
| Event | Level | Fields |
|---|---|---|
Request body exceeds MaxBytesReader limit (M-1) |
WARN | path, content_length (header value if present) |
gRPC message exceeds MaxRecvMsgSize (M-2) |
WARN | method; underlying gRPC ResourceExhausted returned to client |
| Spill cleanup failure (L-3) | WARN | file (basename only), error |
| Over-scoped install token (M-8) | STDERR warning, not logged | โ (install script runs before any logger is configured) |
Performance Bounds¶
| Metric | Bound | Notes |
|---|---|---|
MaxBytesReader wrapping |
O(1) per request | Negligible overhead |
| gRPC size-limit enforcement | O(1) at handler entry | gRPC already bounds via its own options |
| Telemetry response drain | โค 1 MiB read per telemetry send | Bounded; does not affect upload latency |
GenerateEncryptionKey |
โค 1 ms per call | crypto/rand is fast |
Security Invariants¶
- The management HTTP server never reads an unbounded request body.
- The gRPC server never accepts an unbounded message.
- Telemetry backends never read unbounded response bodies.
- Subprocess output routed into agent-tool errors is truncated (head+tail) and credential-redacted before leaving
internal/agent/. - Spill-file cleanup failures are visible to operators.
- Release workflows do not race against themselves; release artefacts match their
checksums.txtand (Phase 2 of the GPG spec) their signature. - Release workflow's GitHub tokens have minimum required scope and are split per repo.
- Renovate cannot auto-merge a patch dependency until it has existed for at least 3 days.
- Read-only CI workflows never persist git credentials after checkout.
- Python tooling runs from a hash-pinned lock file.
Migration & Compatibility¶
No public API signature changes. All options (WithMaxRequestBodyBytes, WithMaxRecvMsgSize, etc.) are additive. Defaults are chosen to be generous โ existing callers should not observe any behaviour difference unless they were sending traffic beyond the thresholds (in which case rejecting was the right thing).
Workflow migrations happen as PR changes and take effect on merge; no special coordination needed.
install.sh users who rely on GITHUB_TOKEN being required see no difference โ the token is still accepted and used. Users who were unaware they could omit it now have a smoother path.
Python pinning migration: the requirements-lock.txt is committed; contributors who run pip-compile locally to bump deps produce PRs against the lock file.
Implementation Phases¶
The bundle is organised so contributors can pick up any subset. Suggested order (by severity, then by blast radius):
| Phase | Changes | Effort |
|---|---|---|
| 1 โ CI/Supply-chain HIGH | H-4, H-5, H-6 | 2 hours |
| 2 โ Server size limits | M-1, M-2 | 3 hours |
| 3 โ Telemetry hardening | M-4, L-3 | 2 hours |
| 4 โ Release credentials | M-7, M-8 | 3 hours |
| 5 โ Agent + helpers | L-1, L-2 | 3 hours |
| 6 โ CI hygiene | L-6, L-7 | 2 hours |
Each phase is one PR or several related PRs; contributors can land them independently. Recommended spacing: 1 PR per day across a week.
Resolved Decisions¶
- Bundle rather than 13 mini-specs. Each change is small and uncontroversial; spec-per-change overhead would dwarf the implementation.
- Each sub-change is independently mergeable. A reviewer can sign off on any single phase without waiting for the others.
- Workflow changes before code changes. Supply-chain HIGH findings (H-4, H-5, H-6) land first because they close the biggest blast-radius gaps with the least code.
- Commit-message convention:
fix(security): [FINDING-ID] โฆ. Makes the audit-to-commit mapping searchable. - No test rewrites required โ existing tests continue to pass unchanged for every sub-change.
M-3is NOT in this bundle despite looking "small" โ it has a dedicated spec (2026-04-17-chat-baseurl-validation.md) because the validation rules need explicit design review.