Repository navigation
feat(officers): officer_ci_logs — read why your own PR's Tekton CI failed (read-only) - #390
Conversation
|
Round 0 (requirements & deletion) at ee723cf — verdict: requirement questioned (ownership refactor overlaps #389) + deletion candidates; no open premises. Requirements 11 (unattributed 5). Correction to the report: Tekton CI does run on draft PRs in the target repo (measured 2026-11-01); only the hosted review skips drafts. Round-1 pointer: line-wise redaction lets a PEM block's base64 body through while the docs say the block is withheld whole. |
Red-first tests for a read-only officer tool that reads why the officer's OWN pull request's Tekton CI failed (fake GitHub + fake cluster reader): - ownership: another author's / another bot's PR, a human branch and a fork's branch are refused by the author / head_branch gate before any cluster read; - matching: the newest PipelineRun labelled with the PR's CURRENT head sha and carrying its PR_NUMBER wins over an older head's run, the same head under another PR, another pipeline's run and an older retry; the label selector is pinned as a literal; the answer follows the head after a push; - excerpt: the failed step's container only (never a sidecar or a passed step), with the --- FAIL / FAIL / playwright lines and the SQLSTATE error; every excerpt together within max_bytes, valid UTF-8 at every cut; - no failure (succeeded / running / failed with no failed task / no run): a state and a note, failed_tasks an explicit empty list; - credential-looking lines withheld, URL userinfo masked (with a positive control); - the ci_logs config block's defaults and refusals, the offer rule and the kickoff line; the MCP offer + dispatch through internal/api. These do not compile at this commit: the tool is the next commit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ailed
A shipping officer whose CI is Tekton in a cluster it cannot reach saw only
"red" (the commit statuses carry no target_url; the instance has no cluster
access and no toolchain) and escalated, though the cause was one error line
in the failed step's log.
officer_ci_logs{pr}, offered to a SHIPPING officer with a new `ci_logs`
block (namespace, pipeline, token_file required; commit_label, pr_param,
max_bytes defaulted), answered by muster-server:
- ownership: the same head-branch and author rule as officer_merge_pr,
now one function each (refuseForeignHead / refuseForeignAuthor) used by
both;
- match: PipelineRuns with tekton.dev/pipeline=<pipeline>,<commit_label>=
<the PR's CURRENT head sha>, re-checked on the labels, filtered on
pr_param == the PR number; newest wins, older retries counted. The
defaults are what pipeline-trigger-operator sets
(pipeline.jquad.rocks/pr.branch.commit, PR_NUMBER);
- the failed TaskRuns' failed step-<name> containers only (never sidecars),
tail-read with a byte limit, reduced to FAIL / ✘ / ERROR / SQLSTATE /
panic / file:line lines (deduplicated) + the last 15 lines, credential-
looking and env-assignment lines withheld, URL userinfo masked, the whole
answer capped at max_bytes and cut on character boundaries;
- never empty: no_run / running / succeeded ("no failure found") / failed,
with a note.
Reads the in-cluster API with the token in token_file (a dedicated
namespace-scoped ServiceAccount; never muster's own). A source that cannot
be built logs and answers "unavailable" rather than refusing the boot. The
kickoff tells the officer to call it before escalating red CI; docs/
officers.md documents the block, the match rule, the excerpt and the RBAC.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Redact is line-wise: it withholds a PEM block's BEGIN line, but the base64 body lines pass and land in the excerpt's tail (docs/officers.md says the block is withheld whole). Red at this commit in all five shapes: the block at the end of the log (BEGIN outside the 15-line tail), a block with no END (the step died mid-print), and a block mid-log followed by the failure. Plus the log read starting mid-block (only the body and END in it) and an encrypted key cut short, whose Proc-Type/DEK-Info headers are not base64. A real 2048-bit RSA key, generated per run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
RedactLines carries state across a CI log: from a -----BEGIN …----- line through its -----END …----- line every line is withheld (headers such as Proc-Type/DEK-Info included), and to the end of the log when END never came. A line that is nothing but 40+ base64 characters is withheld too, for a block whose BEGIN fell before the log read's window. Excerpt now redacts the whole log with it before picking lines and the tail. Mutation-checked separately: without the BEGIN…END state the "encrypted key's headers" case goes red; without the base64-line rule the "log read starts mid-block" case goes red. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tests first for replacing `ci_logs.token_file` (a never-expiring token Secret) with a TokenRequest per call: - mintCIToken asks for the CONFIGURED ServiceAccount in muster's own namespace, 600 s, default audience, once per mint; an empty token is an error (fake clientset reactor); - ciReaderConfig keeps the API server and CA and carries ONLY the minted token: no token file (client-go prefers it over the token), client cert, basic auth, exec/auth plugin or impersonation of muster's own; - the reader is opened once per call and never on a refused call; a mint failure is a ci_runs refusal naming the read token; - config: `service_account` required and a name; `token_file` is now an unknown field. Do not compile at this commit: the change is the next commit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Replaces `ci_logs.token_file` (a never-expiring ServiceAccount token Secret mounted into muster-server) with `ci_logs.service_account`: for each call muster's own in-cluster identity creates a TokenRequest for that ServiceAccount in muster's namespace (expirationSeconds 600, default audience), and the reading client is built FRESH from the API server, the CA and that token alone (ciReaderConfig) — muster's own token, and its token file that client-go would prefer, never reach the reads. The token is minted only after the ownership gates pass, once per call; a failed mint is a ci_runs refusal. Also drops the ci_config gate inside CILogsReader.Read (internal/api builds the reader only for a configured officer and already refuses otherwise), and the docs now give the RBAC exactly as called: list on pipelineruns/taskruns and get on pods/log in the CI namespace, plus create on serviceaccounts/token for that one ServiceAccount for muster's own SA. Mutation-checked: keeping muster's token file in the reader config, 3600 s, accepting an empty token, and minting before ownership each turn a named test red. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
b73b983 to
b3a7813
Compare
|
Round 1 (full checklist) at b3a7813: 🔴 1 · 🟡 1 · 🟢 3 — fixes in progress; a delta round 2 follows.
Security questions checked and held: ownership (shared #389 helper, fail-closed), scoping, token never logged/returned, muster's own credential never used for reads, memory bounded, PEM withheld whole, RBAC/admission in civitai/agent-cluster#78bf61b04 match the calls exactly. |
The new imports (k8s.io/api/authentication/v1, k8s.io/client-go/dynamic, client-go fakes) change the vendored module tree; CI's nix outputs job reported got sha256-O96XsPL9AexSc9n9LA3vAVmyeSh/5s7AgwVWN1LJN7s=. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Round 1 on #390 measured these leaking verbatim through the excerpt: a keyword after an underscore (AWS_SECRET_ACCESS_KEY=, API_TOKEN=, OAUTH_CLIENT_SECRET:), JSON "DATABASE_PASSWORD":"…", set -x traces (+ DB_PASSWORD=, ++ export STRIPE_SECRET=), an assignment after a lone \r (progress line), lowercase npm_config__authToken=, and an empty-username URL (postgres://:pass@), plus three env assignments with no credential keyword (a set -x trace, a lowercase name, one after a lone \r) that only the env-assignment rule can catch. Red at this commit in all twelve. Positive control: ordinary failure lines that name a password/token/secret pass unredacted (green before and after). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- secretRE: the credential keyword may sit anywhere in a key (no leading
word boundary, so _PASSWORD/_SECRET/_TOKEN/_ACCESS_KEY match), any case,
with an optional quote before and after the separator ("KEY":"value");
_pat added; github_pat_ unanchored.
- envLineRE: a set -x trace prefix (+ , ++ export) may precede the
assignment, and lowercase names count.
- Excerpt splits on a lone \r too (progress lines).
- urlUserRE: an empty username (scheme://:pass@) is masked.
- docs/officers.md lists exactly what is withheld and masked.
Mutation-checked, each with its own killing case: restoring the leading
\b (4 keyword-after-underscore cases), the ^\s* env anchor (the + trace),
upper-case-only env names (the lowercase one), \n-only splitting (the \r
one), and a required URL username (the empty-user URL).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…kRuns, redacts the run message Round 1 nits on #390: - G1: the PipelineRun's message (run_message, and the no-failed-task note) goes through RedactLines too; TaskRun messages as well. - G2: PodLog no longer sends LimitBytes (which keeps the START of the tail): it asks for the last 20,000 lines and keeps the last 8 MiB of them (readTail), at a line boundary, reporting cut. Truncated now reflects every cut: the read dropping the log's start, the byte cap, and a clipped emitted line. Docs say so. - G3: each listed TaskRun's tekton.dev/pipelineRun label is re-checked against the run, like pickRun does for PipelineRuns. Tests for each; mutation-checked: dropping the TaskRun re-check, the run message redaction, reading the head instead of the tail (LimitReader at maxBytes), and the clip-cut marking each turn a named test red. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TestCILogsRespectsTheByteCap ran Excerpt 200 times over a 5000-line log: 10.7 s locally, and in CI's coverage run internal/officership took 572 s (main: 1.3 s), which pushed the concurrently running internal/api package past go test's 10 min timeout (test (postgres) on 3bc4f03). The loop only needs budgets landing inside multi-byte characters; 40 lines of the same shape do that. Now 0.19 s; the package 0.26 s. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Round 1 fixes landed (b3a7813..fca4e09); full CI green on fca4e09 incl. nix outputs and test (postgres). A delta round 2 follows. |
Tests first for round 2 on #390: - F1/F2 must-pass controls, all withheld by the round-1 filter (red here): file_path:, output_path:, tokenizer:, a Secret named by its k8s path, pwd: <path>, and three logfmt/slog lines (time=… level=ERROR msg=… err=…, level=error msg=…, exit_code=1), and compat=…/upass=… (a short word only counts as a whole key segment). The round-1 lowercase case becomes 'export deploy_conn_url=…' (bare lowercase is a logfmt key now). - Must withhold: GITHUB_PAT= (whole segment) and level=error password=hunter2… (both already withheld: invariant guards), and, red here, set -x trace lines (+ / ++), docker login -p, mysql -pX, --password X, --token X, {"auth":"<b64>"}, Cookie:, Set-Cookie:, a URL's sig= and signature=, DB_PASS:, credentials:, basic auth: <b64>. - F6: redactText splits on a lone \r (red). - F4/F5: readTail with one read between max and 2*max (the final trim decides), a window starting on a line boundary keeps its first line and is not emptied (red), a last line longer than max keeps its end, a UTF-8 cut, exactly maxLines lines without a trailing newline is cut. - F5: each clipped-line check reached alone (green here: invariant guard, mutation-checked in the fix commit). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…shapes Round 2 on #390: - F1: keyRE requires the credential word to END the key; long words may follow any prefix, short ones (pat, pass, pw, auth, credentials) only a segment break or the key's start; a value that is a path (absolute, or a lowercase namespace/name) is not a secret. file_path:, output_path:, tokenizer:, compat=, Secret: default/my-secret and pwd: <path> now pass; GITHUB_PAT=, DB_PASS:, {"auth":…}, credentials: are withheld. - F2: a line-start env assignment is UPPER-case, or any name after export; bare lowercase key=value (logfmt/slog) passes; a credential key inside it is still withheld. - F3: whole set -x trace lines, Cookie/Set-Cookie, signed-URL sig=/ signature=/token=, --password/--token/--secret/--api-key VALUE, login -p VALUE / mysql -pVALUE, basic auth: <b64>. - F6: redactText splits on a lone \r like Excerpt (one splitLines). - docs/officers.md lists exactly what is withheld and what passes, and says plainly what still leaks. Mutation-checked, each killed by its named case (a mutant that does not build is reported as such, not as survived): the key-end anchor (tokenizer:), the short-word segment anchor (compat=/upass=), the path exclusion (Secret: default/my-secret), lowercase env anywhere (the logfmt controls), the trace rule, the \r split in redactText. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…TF-8 Round 2 F4/F5 on #390. readTail keeps one byte before the window and: - drops to the first newline only when that byte is not a newline (the window may already start a line); - never drops to a newline that is the window's last byte (one line longer than maxBytes keeps its end instead of coming back empty); - starts on a UTF-8 character boundary; - counts a last line without a trailing newline, so exactly maxLines lines with none is cut. Mutation-checked against TestReadTailEdges (whose first case is one read between max and 2*max, so the final trim decides): each of the four, and keeping the head in the final trim, turns it red. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Under CI's go test -race -cover, internal/officership took 30 s on e5a4c9b (TestCILogsRespectsTheByteCap 5.4 s locally under -race alone); 800 lines (~48 kB) is still twelve times the 4 kB cap the test asserts. Now 2.9 s under -race -cover locally. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Round 2 (delta) at fca4e09: 🔴 0 · 🟡 3 · 🟢 3 — ladder ends. All fixed un-re-audited in 7dc9483..7513263 (tests first; 13 mutants killed); full CI green on 7513263. |
Problem
A shipping officer whose CI runs as Tekton PipelineRuns in a cluster sees only "red": the commit statuses carry no
target_url, the instance has no cluster access and no toolchain. So it escalates, even when one error line in the failed step's log gives the cause.What this adds
officer_ci_logs{pr}is a read-only tool. It is offered only to a shipping officer that has a newci_logsblock, and muster-server answers it:(
commit_labeldefaults topipeline.jquad.rocks/pr.branch.commit,pr_paramtoPR_NUMBER,max_bytesto 16384, with a range of 2048–65536.)owner.headAndAuthor(the same asowner.ownPRminus the base check): the head is a<prefix>…branch ofrepoand the author is the officer App's bot. An identity read failure fails closed throughfailClosed, whichGates.ownPRalso uses now. There is no second copy of the rule. A refusal nameshead_branchorauthor, and happens before any cluster read.tekton.dev/pipeline=<pipeline>,<commit_label>=<the head GitHub reports now>. It re-checks those labels on each result, then keeps only runs whosepr_paramequals the PR number. The newest of those is used, and older retries are counted.Succeeded=False. For each one it reads only the failedstep-*containers (every step on a timeout, never a sidecar), each as the tail of the log.--- FAIL,FAIL …,✘,N failed,ERROR,SQLSTATE,panic:,Error:,x_test.go:N:andfile.go:N:M:lines, deduplicated with a repeat count, then the last 15 lines. ANSI codes are stripped, each line is capped at 400 bytes, and all excerpts together stay withinmax_bytes, cut on character boundaries.stateis one ofno_run,running,succeeded("no failure found") orfailed, and there is always anote.Secret safety. A PEM block is withheld whole, every line from BEGIN through END (to the end of the log if END is missing). A bare line of 40+ base64 characters is withheld too. Any other line that looks like it carries a credential (GitHub/AWS/Slack/
sk-tokens, JWTs, PEM blocks, Authorization/Bearer headers,password=/token:assignments), and any env-dumpNAME=valueline, is withheld whole. URLuser:pass@is masked. This is a pattern filter, not a guarantee; the byte cap limits what a miss can carry. Only failed step containers are read, never sidecars.Cluster access goes through the in-cluster API. Each call, muster's own identity mints a TokenRequest for
service_account(600 s, default audience), and a client is built fresh from the API server, the CA and that token alone. muster's own token, and its token file, never reach the reads. The token is minted only after the ownership gates pass, once per call; a failed mint is aci_runsrefusal. RBAC:liston pipelineruns and taskruns andgeton pods/log in the CI namespace; for muster's SA,createonserviceaccounts/tokenlimited to that SA by resourceNames. If the source can't be built (not in a cluster), it is logged and the tool answers "unavailable". It does not refuse the boot.The kickoff tells the officer to call
officer_ci_logsbefore escalating a red check.docs/officers.mddocuments the block, the match rule, the excerpt and the RBAC.Update after round 0
1121311eofficershipred (5/5 subtests)2513774dRedactLineskeeps BEGIN…END state; also withholds bare base64 linesde181720token_fileis an unknown fieldb73b9833ci_logs.service_accountand a per-call TokenRequest replacetoken_file; the duplicateci_configgate is droppedMutation checks, each killed by a named test: removing the BEGIN…END state (killed by the "encrypted key's headers" case); removing the base64 rule (killed by "log read starts mid-block"); copying muster's token file into the reader config; a 3600 s token; accepting an empty token; minting before the ownership check. The pr_param filter stays. The ownership helper is unchanged until #389 merges, then this branch is rebased onto it.
Rebased onto #389 (
382194c5)The commits were replayed, and the shas changed:
9df7f4c184ec8b6574a588adTestCILogsWithholdsAWholePEMBlock)248a2bb8a37fa51fb3a78138At
b3a78138, the fullofficershipandofficerspackages pass, and so do theapiofficer, ship, mark-ready and merge tests. vet is clean.Conflicts, both features kept:
gates.go: took main's as-is, and deleted myrefuseForeignHead/refuseForeignAuthor.MergePRusesGates.ownPRagain. I addedfailClosed, the fail-closed wrapper thatGates.ownPRnow delegates to.officer_ship.go: the header lists the four tools and thenofficer_ci_logsas the fifth.isShipToolincludes bothToolMarkReadyandToolCILogs.kickoff.go: both constants.shipNotekeeps theToolMarkReadyarg and appendsciLogsNote(o).docs/officers.md: the tools-table row withofficer_mark_ready, plus theofficer_ci_logsrow.Ownership mutation against the shared helper:
owner.headAndAuthorturns these red:TestCILogsRefusesAPullRequestThatIsNotTheOfficersOwn/{a_human's,another_bot's}_pull_request,TestEveryMergeGateRefusesItsOwnFailure,TestMarkReadyRefusesEverythingButOwnDraftToReadyandTestThePushGateUsesTheGatesOwnPRRule.AuthorType != "Bot"still refuses the human.The message of
84ec8b65still mentions the retiredrefuseForeignHead/refuseForeignAuthor. Its code doesn't use them.Tests (red-first)
e3870cb9contains only the tests. At that commit,internal/officership,internal/officersandinternal/apifail to build because the tool doesn't exist yet.ee723cfd(the implementation), all three packages pass: 17 / 13 / 1 passing cases, run with-run 'CILogs|ParsePipelineRun'. The rest ofinternal/officershipalso passes, including the merge-gate tests that cover the ownership refactor.What's covered: refusal of another author's PR, another bot's PR, a human branch and a fork (with zero cluster reads); picking the current head's run over an old head, the same head under another PR, another pipeline and an older retry (the fake reader ignores the selector, and the selector is pinned as a literal); following the head after a push; FAIL + SQLSTATE lines read only from the failed step; the byte cap across tasks plus valid UTF-8 at every cut; clear no-failure answers; credential lines withheld (with a positive control); config defaults and refusals; MCP offer and dispatch.
Mutation check. Each guard was disabled on purpose and a named test went red:
…RefusesAPullRequestThatIsNotTheOfficersOwn/{a_human's,another_bot's}_pull_requestpr_paramfilter…PicksTheRunForTheCurrentHead(it pickedrun-other-pr)…PicksTheRunForTheCurrentHead,…FollowsTheHeadNotTheNewestRun(it pickedother-pipeline-run)…RespectsTheByteCap(695539 bytes against a cap of 4096)…WithholdsCredentialLines…ExcerptCarriesTheFailAndErrorLines(it readstep-provision)Per the repo convention, CI runs the full suite; locally I ran only the new tests plus the
officershippackage.Not in this PR
Deploying it needs a pin bump plus the
ci_logsblock in the deployment'sMUSTER_OFFICERS, along with the RBAC. A build without this change refuses the unknownci_logsfield at boot.🤖 Generated with Claude Code