Conversation
Anonymous access was granted only to GET requests, so the POST git-upload-pack negotiation that every smart-HTTP clone and fetch needs returned 401 and public repositories were effectively web-only. Allow both upload-pack requests (GET info/refs?service=git-upload-pack and POST git-upload-pack) for public, non-archived repositories, also for signed-in users without a grant. Receive-pack still always requires an authenticated writer. The regression test runs real `git clone`/`git fetch` with credential helpers and prompts disabled over protocol v0 and v2, and checks that private and archived repositories and anonymous pushes stay refused. Finding: F076
collectActionArtifacts ran in the unsandboxed Trace process and used os.Stat/os.Open, which follow symlinks. Any repository writer could run `ln -s <data>/admin-token coverage.out` in a job and download the server admin token (or secrets.json, oidc.json, users.json, the SSH host key) as an artifact, even when the job itself ran in Docker or sandbox-exec. A FIFO named as an artifact also blocked the runner goroutine forever. Resolve every artifact through an os.Root bound to the workspace so no final or intermediate symlink can leave it, refuse symlinked artifacts, copy only regular files (re-checked with fstat after a non-blocking, no-follow open), and record the bytes actually copied. Finding: F077
loadActions returned an actionDB without its LastScheduledAt map when actions.json did not exist yet. On a node whose first run is scheduled, scheduleActionRuns then assigned into a nil map and panicked inside the ticker goroutine, which terminates the whole trace serve process. Found while adding the regression tests for F078.
Whether a job ran sandboxed was chosen by `.trace/workflow.json`, which is repository content any writer can change, and every push auto-queued the workflow. Any writer of any repository could therefore run arbitrary commands as the Trace service account by omitting "sandbox":true, and the operator had no switch to prevent it or to turn the runner off. Add `trace serve -actions off|sandboxed|trusted`: - sandboxed (default): only workflows with "sandbox":true are accepted; others are refused on push, manual trigger, and schedule; - off: no workflow runs; - trusted: unsandboxed workflows also run (previous behaviour). An unset store policy resolves to sandboxed, and the scheduler skips workflows the policy refuses before recording schedule state. BREAKING CHANGE: unsandboxed workflows no longer run unless the operator starts the server with `-actions trusted`. Finding: F078
The sandbox-exec profile denied reading the root directory entry, the /private/var/select/sh link that /bin/sh resolves on current macOS, and /dev/null, and it listed the workspace by its unresolved path while sandbox-exec matches resolved paths (/var -> /private/var). On macOS 26 every "sandbox":true job therefore died before running a command, so the documented macOS isolation never worked. Allow exactly those entries, resolve the writable directory before building the profile, and add a darwin-only test that runs real commands: the job writes its workspace, while reading or writing outside it fails. Found while adding the regression tests for F088.
Every job, including sandbox-exec jobs, received append(os.Environ(), ...), so any variable in the Trace service environment (cloud tokens, proxy or registry credentials) was readable by any workflow via `env`. Jobs now get exactly PATH, HOME (the checkout), TMPDIR, LANG/LC_ALL when set, TRACE_REPOSITORY, TRACE_COMMIT, CI=true and the repository's TRACE_SECRET_* values. Each run gets a private directory with the checkout (src) and a scratch TMPDIR (tmp), both passed as canonical paths so sandboxed jobs can use them; the scratch directory is also writable under sandbox-exec and mounted at /tmp for Docker. The docker client keeps the operator environment it needs to reach the daemon; containers never inherit it. Finding: F088
Docker jobs never received TRACE_SECRET_*, TRACE_REPOSITORY, TRACE_COMMIT or CI: they were set on the docker client's environment, which Docker does not forward. Cancellation and the 15-minute limit killed only the docker client, leaving the container running with the workspace mounted, and for local and sandbox-exec jobs only the direct child was killed, so background processes survived both and could hold a step open. - Docker steps run as `--name trace-run-RUN-JOB-STEP`, receive the job variables with `-e NAME` (values come from the client environment, never argv), and cancellation runs `docker kill` before killing the client. - Local and sandbox-exec steps run in their own process group; the group is killed on cancellation, on timeout, and after the step exits. WaitDelay bounds how long leftover children may hold the output pipes; a step that exited 0 still succeeds and the log notes the cleanup. - Step output is captured through the existing capped buffer, so a step that prints without bound no longer grows server memory before the 1 MiB log cap is applied. Finding: F087
actions.json embedded up to 1 MiB of log per job and was never pruned.
Every run list, every merge-policy check (requiredChecksPass) and every
run update deserialized and rewrote the whole file under one global
mutex, so cost grew with every run ever executed.
- Job logs are written to data/action-logs/RUN/JOB.log; logs that older
versions stored inline are moved out on the next run update.
- Each repository keeps its 200 most recent finished runs; queued and
running runs are never pruned, and pruned runs' logs and artifacts are
deleted.
- The run list API returns summaries; GET .../actions/runs/ID and the
actions page (20 most recent runs) load logs from disk.
The actions page also referenced {{$.ID}} for artifact links, a field
the page data does not have, so rendering a run with artifacts failed
mid-page; the links now use the run's ID.
Finding: F096
Protected-branch patterns were concatenated unescaped into the generated
sh pre-receive hook, and validation only rejected a few characters
(space, ~^:?[]\). A payload such as
main)exit${IFS}0;;esac;touch${IFS}pwned;case${IFS}x${IFS}in(x
passed validation and ran on every push as the Trace service account,
giving any forge administrator (or leaked admin token) host code
execution and a silent way to disable branch protection.
- normalizePolicy accepts only [A-Za-z0-9][A-Za-z0-9._/-]* with an
optional trailing *, or *, and rejects "..", "//" and refs/ prefixes.
- receiveHook single-quotes every pattern's literal part, leaving only
the trailing wildcard unquoted, so even an unvalidated pattern cannot
execute.
Tests run the generated hook with sh: injection payloads are rejected by
validation and do not execute even when fed to receiveHook directly, and
main, release/*, tags and refs/trace stay protected for non-admins.
Finding: F080
ensureHooks, which `trace serve` runs at every start, reinstalled each repository's pre-receive hook with the default policy (main only). After any restart, protected-branch patterns configured through the API or the policy page (for example release/*) silently stopped being enforced, while policies.json and the UI still showed them. Install each hook from the repository's stored policy. If a stored policy no longer validates (for example a pattern saved before the stricter pattern rules), log it and fail closed by protecting every branch until an administrator saves a valid policy. Found while fixing F080.
The SSH transport gated git-receive-pack only on mirror status and the managed hook, while the HTTP transport also refuses archived repositories. Any writer with an SSH key could keep pushing to an archived repository, contradicting the documented "archived repositories reject Git writes". Apply the same receive-pack checks as HTTP (mirror, archived, managed hook) with a specific refusal message. The test pushes over a real ssh client before and after archiving. Finding: F082
SSH Git processes ran with append(os.Environ(), "REMOTE_USER=...") and only appended TRACE_ADMIN=1 for administrators. If the service environment ever contained TRACE_ADMIN=1 (a debugging shell, a unit file), every non-admin SSH push bypassed the pre-receive protection, and all service variables were exposed to hooks. Build the environment explicitly, as the HTTP CGI path does: PATH, the platform library path if set, REMOTE_USER, and TRACE_ADMIN pinned to 0 or 1. The test sets TRACE_ADMIN=1 in the server process and checks that a writer's SSH push to main is still refused while a feature branch push succeeds. Finding: F083
The OIDC callback picked the local account by preferred_username or the email local part and issued a session for any existing account with that name, never checking a stored subject, the ID token's subject, or TOTP. With a public or multi-tenant provider, any identity named "admin" (or admin@anything) signed in as Trace's local admin without its token or second factor; the same applied to every local username. - userRecord stores oidc_issuer and oidc_subject; OIDC sign-in only opens the account bound to the verified (issuer, sub) pair. - Auto-provisioning creates a new bound account and refuses when the derived name already exists; `trace sso oidc link USER SUBJECT` and `unlink USER` let an administrator bind an existing account. - When an ID token is returned, its sub must equal the userinfo sub (OIDC Core 5.3.2). - `-allowed-email-domains` optionally requires a verified email in the listed domains. - Accounts with TOTP enabled are refused through OIDC instead of silently skipping the second factor. BREAKING CHANGE: accounts auto-provisioned by earlier versions carry no binding and must be linked once with `trace sso oidc link`. Finding: F079
- Send a random nonce with each authorization request, keep it in the signed state cookie, and require the ID token to echo it. - Require a non-empty ID token issuer equal to the configured issuer (an empty iss was accepted). - Reject ID token signing keys shorter than 2048 bits. - Use one provider client for discovery, JWKS, token, and userinfo with a 10-second timeout and no redirect following; the token and userinfo requests previously used http.DefaultClient without any deadline, so a stalled provider pinned callback handlers indefinitely. Finding: F098 (OIDC part)
… sessions The X-Trace-CSRF header was compared with !=, and the whole check was skipped whenever an Authorization: Basic header was present, even when those credentials were wrong. A cookie-authenticated request that also carried any Basic header therefore bypassed CSRF protection (the test created a repository that way). Compare with subtle.ConstantTimeCompare and exempt only requests whose Basic credentials authenticate the same user as the resolved identity. Finding: F098 (CSRF part)
Raw file delivery ran `git cat-file blob` with cmd.Output() and Pages ran `git show`, buffering the entire blob before comparing it with the 8 MiB cap. Both routes are anonymous for public repositories, so repeated requests for a large blob could exhaust server memory (the test measured about 134 MB allocated to refuse a 48 MiB blob). Add readBlobLimited, shared by both routes: one `cat-file --batch-check` resolves the object, rejects non-blobs, and compares the size with the cap before any content is read; the content is then streamed through a limit. Pages no longer renders Git's tree listing for a directory path, and both routes resolve the configured branch as refs/heads/NAME so a tag with the same name cannot shadow it. Finding: F085
…heable Every raw response (including private repositories and the Basic-auth API raw endpoint) and every Pages response was sent with `Cache-Control: public, max-age=60`. RFC 9111 section 3.5 lets shared caches store such responses even when the request carried credentials, so a caching proxy or CDN in front of Trace could serve a private file to an unauthenticated client for up to a minute. Public repositories keep `public, max-age=60`; private repositories now get `private, no-store` with `Vary: Cookie, Authorization`. Finding: F086
/raw and /pages served repository-controlled HTML and SVG with their
detected content types on Trace's own origin, under the global CSP
`script-src 'unsafe-inline'`. Any writer could commit an index.html whose
inline script, opened by an administrator, ran same-origin: it could
window.open('/app'), read the per-user CSRF token from the DOM, and
submit privileged forms (delete, transfer, grants, user creation).
- Raw responses (web and API) downgrade HTML, SVG, XML and JavaScript to
text/plain and carry `Content-Security-Policy: sandbox` with no script
plus nosniff; passive media types are unchanged.
- Pages responses carry a CSP sandbox with allow-scripts but without
allow-same-origin, so sites keep working with an opaque origin that
cannot act as the signed-in user.
- Trace's own pages now allow their static inline scripts by SHA-256
hash computed from the embedded templates, so script-src no longer
contains 'unsafe-inline'. The three inline onsubmit confirm handlers
became data-confirm attributes handled by the shared page script.
Documented limitation: browsers do not send the session cookie with a
sandboxed private Pages site's sub-resource requests.
Finding: F081
…xies The limiter keyed buckets by client IP plus URL path, so the documented "300 requests per minute per client" was really per path and a client could evade it by varying paths. It used only RemoteAddr, so behind the reverse proxy the README requires, every user shared the proxy's bucket. And every HTTP request, static assets included, took a file lock, read and unmarshalled rate-state.json, then marshalled, fsynced and renamed it (up to 10,000 entries). - Key HTTP buckets by client only (300/min), with a separate sign-in POST bucket (20/min); SSH stays 60 connections/min per host. - Keep counters in memory, shared by all limiters for the same data directory in the process, and prune expired windows once a minute. - `trace serve -trusted-proxy IP|CIDR,...`: for those peers, use the rightmost X-Forwarded-For entry that is not a trusted proxy, then X-Real-IP; headers from other peers are ignored. - Group IPv6 clients by /64. Counters are no longer shared between separate processes; the README now says so and points to the reverse proxy's limiter for that. Finding: F084
…emoval - A SCIM `password` became the user's token hash via unsalted SHA-256 (safe only for Trace's 256-bit random tokens), so a leaked users.json exposed low-entropy provisioned passwords to offline cracking, and they worked as Basic-auth credentials. SCIM passwords are now refused with an RFC 7644 error; new SCIM users stay disabled until an administrator issues a token. - Users and Groups lists support `filter=ATTRIBUTE eq "value"` (userName/displayName/id) and startIndex/count paging over results sorted by id, which identity providers rely on to look resources up. - Group PATCH handles `remove` (by members[value eq "x"] path, by value list, or all members) and `add`/`replace` with path "members"; remove operations were silently dropped. User PATCH accepts path "active" with a boolean or "True"/"False". Finding: F089
Webhook URLs accepted any https host, and delivery used an http.Client with the default redirect policy and environment proxies. A forge administrator (or a leaked admin token) could make Trace POST signed JSON to internal services or, through a redirect, to http://169.254.169.254/, and read the status code from the persisted delivery record. Deliveries now use a client whose dialer checks the address actually connected to, after DNS resolution: loopback only for hooks configured with a loopback host, RFC 1918/CGNAT/ULA/NAT64 only with the new `trace serve -webhook-allow-private-networks`, and link-local (including metadata), multicast, unspecified and reserved ranges never. Redirects are not followed and proxy environment variables are ignored. Finding: F090
LFS objects were stored globally as data/lfs/<oid>, and the only access check was read access to the repository named in the URL. Anyone who could read any one repository could download every LFS object on the node by OID (OIDs appear in pointer files and are easily shared), leaking private repositories' large files. - Objects live in data/lfs/OWNER/NAME/<oid>; batch, upload, and download only see the repository's own objects. - Forks get their own copy (hard links when possible), transfers move the directory, and deletes remove it. - At server start, legacy global objects are linked into each repository whose object database contains an LFS pointer to them; unreferenced ones move to data/lfs/.legacy-unreferenced and are not served. The existing LFS test asserted the old global path; it now checks the per-repository location. Finding: F091
The action-run, secrets, and Pages APIs checked `roleFor(u, repo) != "write"`, comparing the raw grant string, so users granted `maintain` (documented as a superset of write) got 403 while `write` users were allowed. Use u.canWrite(repo), which already covers admin, write, maintain, and team-inherited grants. Finding: F092
…ount TOTP verification accepted the previous, current, and next 30-second codes without remembering which step was last used, so an observed or intercepted code could be replayed for up to 90 seconds, and wrong codes were limited only by the per-address sign-in limiter. - Record the last accepted time step (totp_last_counter) under the users lock and refuse codes for that step or earlier (RFC 6238 section 5.2); enabling or disabling 2FA resets it. - After five consecutive wrong or replayed codes for an account, refuse attempts for 30 seconds, doubling up to 15 minutes. Only callers that presented the account's valid token reach this check, so it cannot be used to lock a user out without their token. Finding: F093
createBackup only rejected an output path equal to the data directory. `-out ./data/x.tar.gz` (or a path reaching data/ through a symlink) was accepted, and filepath.Walk then reached the archive while it was still growing, failing with tar.ErrWriteTooLong or embedding a partial copy of itself. Refuse any output path that resolves to the data directory or below it, comparing symlink-resolved paths. The README also states that backups of a running node are not point-in-time consistent. Finding: F094
…nder locks Repository transfer ran bytes.ReplaceAll(`"old/name"`, `"new/name"`) over every *.json and *.jsonl file in the data directory and wrote them back with os.WriteFile, outside each store's lock. Any string value equal to the old name (issue titles, comment bodies, audit resources) was rewritten, the append-only audit ledger was modified, a crash part-way left stores partially renamed, and concurrent writers could lose updates. Package files and release assets stayed under the old name. - Rewrite only structural references: keys of repository-keyed maps (including "REPO\x00..." schedule keys) and "repo" fields. Free text, audit.jsonl, and oidc.json are never touched. - Rewrite each store under its own lock (and actionRunMu for actions.json) and replace files atomically. - Record the transfer in .transfer-journal.json before any change; all steps are idempotent and openStore completes an interrupted transfer. - Move data/lfs, data/packages and data/release-assets directories. Finding: F095
govulncheck v1.1.4 reports two vulnerabilities reachable from Trace's SSH server in golang.org/x/crypto v0.55.0, both fixed in v0.56.0: - GO-2026-6355: DoS on a deadlocked established channel in x/crypto/ssh - GO-2026-6354: DoS on a deadlocked undecided channel in x/crypto/ssh `go get` also normalizes the go directive from 1.26 to 1.26.0, which x/crypto v0.56.0 requires; the language version is unchanged.
Byte-identical to the workflow added in the release-readiness PR (F100), included so this PR is checked by CI too; identical additions merge cleanly in either order. It pins Go 1.26.6, sets job timeouts, runs the tests on Linux and macOS, runs the race detector, and runs govulncheck.
migrateLegacyLFS runs at server start and returned an error, stopping `trace serve`, if any one repository could not be scanned for LFS pointers. Log and skip such a repository instead, and leave the legacy objects in place when any scan was incomplete so a later start can finish the migration after the repository is repaired. Follow-up to the F091 change.
CI failed TestLegacyLFSObjectsMigrateToReferencingRepositories on
ubuntu and macOS runners, which have Git LFS installed: its pre-push
hook tried to upload the fixture's pointer object to the local bare
repository ("batch request: missing protocol"). The test passed locally
only because this machine has no git-lfs.
The commitFiles helper now pushes with client hooks disabled
(core.hooksPath=/dev/null, GIT_LFS_SKIP_PUSH=1), and the migration
fixture no longer adds an LFS .gitattributes, which the content-based
pointer scan does not need. Reproduced and verified locally with a
global Git config whose pre-push hook always fails.
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.
Summary
Fixes the 22 security, correctness and reliability findings from the September 2026 audit of Trace (F076–F096, F098), plus six more problems found while writing the regression tests. Every fix has a test; where it was practical, the test was first run against the unfixed code to show it fails (details below). Each commit covers one concern.
Two changes are breaking for existing nodes:
trace servenow runs only sandboxed workflows by default. Use-actions trustedto keep running unsandboxed workflows, or-actions offto disable CI.(issuer, sub)pair to find the account. Accounts that earlier versions auto-provisioned are not linked yet and must be linked once withtrace sso oidc link USER SUBJECT.New operator flags:
trace serve -actions off|sandboxed|trusted,-trusted-proxy CIDR,...,-webhook-allow-private-networks, andtrace sso oidc link|unlink,trace sso oidc set -allowed-email-domains.Findings addressed
POST git-upload-packstep is allowed too. The test runs a realgit clone/fetchwith no credential helper, over protocol v0 and v2.os.Rootbound to the workspace and refuses symlinks, special files and FIFOs, so a job can no longer copy outdata/admin-token.-actionspolicy (defaultsandboxed) decides what runs; workflow files can no longer opt out of the sandbox.(issuer, sub)only and never adopts an existing account by name. Userinfosubmust equal the ID tokensub. Optional verified-email domain allowlist. Accounts with TOTP are refused through OIDC. Admins link accounts withlink/unlink.${IFS}payload that the old validator accepted is covered by a test./rawserves HTML, SVG, XML and JS astext/plainwith CSPsandbox./pagesruns in a CSP sandbox withoutallow-same-origin. Trace's own pages allow their inline scripts by SHA-256 hash, soscript-srchas no'unsafe-inline', and the inlineonsubmithandlers becamedata-confirm.receive-packrefuses archived repositories, with the same checks as HTTP.TRACE_ADMINpinned to0or1.-trusted-proxy(rightmost untrustedX-Forwarded-For), groups IPv6 clients by /64, and no longer rewritesrate-state.jsonon every request.cat-file --batch-checkbefore reading, then stream through a limit. The test measured about 134 MB allocated by the old code to refuse a 48 MiB blob.Cache-Control: private, no-storeandVary: Cookie, Authorization.-e NAMEfor the job variables and run as named containers that aredocker killed on cancel or timeout. Local and sandbox-exec steps run in their own process group, which is killed on cancel, timeout and step exit. Output is captured through the existing capped buffer.PATH,HOME,TMPDIR, locale,TRACE_*,CI, and the repository's secrets.passwordis refused (it became an unsalted SHA-256 credential). Lists supportfilter=... eq,startIndexandcount. Groupremoveworks, including themembers[value eq "x"]path form. User PATCH acceptspath: "active".data/lfs/OWNER/NAME). Forks copy them, transfers move them, deletes remove them. At startup, legacy global objects migrate into the repositories whose pointers reference them.canWriteinstead of a raw"write"string check).repofields only) under each store's lock, with atomic writes and a crash-safe journal. It moves the LFS, package and release-asset directories and never touchesaudit.jsonlor free text.data/action-logs/RUN/JOB.log. Old runs are pruned to 200 finished runs per repository. The run list is a summary, and single-run GET and the actions page load logs.iss, RSA keys of at least 2048 bits, and a provider client with a 10 s timeout and no redirects (619af3a). CSRF: constant-time comparison, and a Basic header that does not authenticate no longer waives the check (ff2924e).Found and fixed while testing (not in the audit)
trace servefrom starting.loadActionsreturned a nilLastScheduledAtmap and the scheduler panicked, killingtrace serve.sandbox-execprofile denied/,/private/var/select/shand/dev/null, and it did not resolve/var -> /private/var. On macOS 26, every"sandbox":truejob died before running.ensureHooks(run at everyservestart) reinstalled hooks with the default policy, so configured protected branches such asrelease/*stopped being enforced after a restart. It now uses the stored policy and fails closed (protects every branch) if the stored policy is invalid.Authorization: Basicheader (described under F098).{{$.ID}}for artifact links, a field its data does not have, so rendering a run with artifacts failed mid-page.golang.org/x/cryptov0.55.0 → v0.56.0 for GO-2026-6354 and GO-2026-6355 (SSH channel DoS), which govulncheck reports as reachable from Trace's SSH server.Not reproduced / deferred
Every assigned finding reproduced from source and is fixed. These are known limits, recorded rather than hidden:
setsidis not stopped. Full containment needs cgroups or containers.externalIdtosubis not implemented.Verification
Gates run from this worktree (Go 1.26.5 darwin/arm64,
GOWORK=off):CI history, for the record: the first CI run (at c71fe90) failed
TestLegacyLFSObjectsMigrateToReferencingRepositorieson all three test jobs. The runners have Git LFS installed, and its pre-push hook tried to upload the fixture's pointer object (batch request: missing protocol); this machine has no git-lfs. 45d0efa makes the test fixtures push with client hooks disabled. It was reproduced and verified locally with a global Git config whose pre-push hook always fails, and CI at 45d0efa passes: quality, govulncheck, race, and tests on ubuntu and macOS.Baseline on unchanged
main(16ea592), same commands: gofmt empty, vet ok, build ok,go test -raceok. So nothing here depends on a pre-existing failure.Regression tests run against the unfixed code, with the observed failures:
fatal: could not read Username ... terminal prompts disabledmain)exit${IFS}0;;esac;touch${IFS}pwned;case${IFS}x${IFS}in(xwas acceptedmainsucceeded withTRACE_ADMIN=1in the server environmentpublic, max-age=60TRACE_TEST_SERVER_CREDENTIALteam/oldwas rewritten toteam/newpanic: assignment to entry in nil mapsignal: abort trapOther checks:
go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...after the x/crypto bump: no module findings. The six standard-library findings it reports are all fixed in Go 1.26.6, which CI pins; the local toolchain is 1.26.5.git merge-tree --write-tree fix/security-hardening chore/release-readiness: clean, no conflicts.merge-treeis also clean at 45d0efa + 1f9ac1b. A local merge of c71fe90 + 1f9ac1b (never pushed; 45d0efa only changes test fixtures) also passes gofmt, vet, build, andgo test -race(ok, 85.882s). The release-readiness PR is chore: prepare Trace for public release #1; its CI (quality, govulncheck, race, and tests on ubuntu and macOS) passed.Follow-ups
actor_is_agent-style audit attribution and a per-repository AI-contribution policy (see FEATURES.md in the release-readiness PR).CHANGELOG.md(added by the release-readiness PR, chore: prepare Trace for public release #1) before tagging 0.0.1..github/workflows/ci.ymlhere is byte-identical to the copy in the release-readiness PR, so the two merge cleanly in either order.🤖 Generated with Claude Code