Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
- **`make release-reconcile` and `make release-golden RELEASE=X.Y.Z` sequence release steps R4 and R5** ([#227](https://github.com/L3DigitalNet/project-standards/issues/227) E3 item 7), whose only sequencer was the runbook prose. The golden-fixture target encodes the recurring trap that `--output` is resolved relative to `--root`, so the synthetic fixture is re-rendered rather than the real repository catalog.
- **`make go-check` skips the three-build Go reproducibility proof when nothing it depends on has changed** ([#227](https://github.com/L3DigitalNet/project-standards/issues/227) E3 item 8). `scripts/go-verify-stamp.sh` keys on `internal/**`, `cmd/**`, `go.mod`, `go.sum`, the build scripts, the shared build library, and the committed artifact bytes themselves, so replacing a committed binary without touching its source still re-triggers the proof. A missing, stale, or indeterminate stamp runs it, the stamp is written only after it passes, and `GO_VERIFY_FORCE=1` forces it for the post-publish proof.

- **New payload binaries are linked with debug information stripped.** `-s -w` joins `ARTIFACT_LDFLAGS` in `scripts/build-agent-handoff-session-start.sh` ([#228](https://github.com/L3DigitalNet/project-standards/issues/228) lever 1), taking the launcher from 3,906,258 to 2,646,142 bytes (−32.3%). `.gopclntab` is retained, so panic traces still carry function names and line numbers; DWARF inspection of the shipped file is recovered by rebuilding from the same script. Published payload bytes stay unstripped, so a size step between a retained version and its successor is expected. Consumers running a `pre-commit` `check-added-large-files` guard still need the documented exemption for the hook path — the launcher remains far above a typical `--maxkb=1024`.

### Fixed

- **`Agent Handoff 1.17` stops an untrusted checkout from executing a command during session start.** The `session-start` launcher ran its Git reads with the full inherited process environment and no configuration isolation, so a repository-local or ancestor `core.fsmonitor` setting named a hook that Git ran — unconditionally, before the operator had seen anything — as soon as that checkout was opened as a session-start target ([#235](https://github.com/L3DigitalNet/project-standards/issues/235)). Every read now runs with an explicit minimal environment (`PATH` and `HOME` only, so no `GIT_DIR`, `GIT_WORK_TREE`, or `GIT_CONFIG_*` value from the harness can redirect it) and passes `-c core.fsmonitor=`, which outranks every configuration file, plus `--no-optional-locks` so the read cannot race a concurrent write. The injected session context is byte-identical to 1.16; reconcile replaces the installed hook because its digest moved. Catalog 5 promotes `agent-handoff@1.17` and retains 1.16.
- **`Agent Handoff 1.17`'s launcher reports the payload version that ships it.** 1.15 and 1.16 carried forward the 1.14 binary byte for byte, so `session-start --version` answered `1.14` on both — the one question the stale-launcher diagnostic exists to answer, answered with a version the consumer never selected ([#229](https://github.com/L3DigitalNet/project-standards/issues/229)). 1.17 is re-linked from its own payload path with its own stamp and prints `agent-handoff session-start 1.17`; a contract test now pins that equality against the catalog's default version, so a future cut cannot reintroduce the drift. The 1.14–1.16 bytes are published and immutable and keep answering `1.14`.

## [5.28.0] — 2026-09-01

### Added
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@ User-facing CLI usage documentation — help text, the canonical usage reference

Repository-local project knowledge and bounded session continuity for coding agents. Agent Handoff creates consumer-owned status, task, and lifetime-routed knowledge under `docs/`; installs a repo-local `agent-handoff` skill; optionally registers one shared SessionStart hook for Claude Code and Codex; and validates layout, drift, provenance, document budgets, and credential references without owning workstation-global state.

- **Standard:** [`standards/agent-handoff/versions/1.16/README.md`](standards/agent-handoff/versions/1.16/README.md)
- **Skill:** [`skills/agent-handoff/`](standards/agent-handoff/versions/1.16/skills/agent-handoff/) — installed repo-local at `.agents/skills/agent-handoff/` and `.claude/skills/agent-handoff/`.
- **Adopt:** [`adopt.md`](standards/agent-handoff/versions/1.16/adopt.md) · **Migration:** [`resources/legacy-migration.md`](standards/agent-handoff/versions/1.16/resources/legacy-migration.md)
- **Standard:** [`standards/agent-handoff/versions/1.17/README.md`](standards/agent-handoff/versions/1.17/README.md)
- **Skill:** [`skills/agent-handoff/`](standards/agent-handoff/versions/1.17/skills/agent-handoff/) — installed repo-local at `.agents/skills/agent-handoff/` and `.claude/skills/agent-handoff/`.
- **Adopt:** [`adopt.md`](standards/agent-handoff/versions/1.17/adopt.md) · **Migration:** [`resources/legacy-migration.md`](standards/agent-handoff/versions/1.17/resources/legacy-migration.md)

### GitHub Workflow Standard

Expand Down Expand Up @@ -196,7 +196,7 @@ The path must be one exact repo-relative, non-glob path with exclusive whole-fil
| Markdown Tooling | `1.16` | [`standards/markdown-tooling/versions/1.16/adopt.md`](standards/markdown-tooling/versions/1.16/adopt.md) |
| Project Specification | `1.11` | [`standards/project-spec/versions/1.11/adopt.md`](standards/project-spec/versions/1.11/adopt.md) |
| CLI Documentation | `1.6` | [`standards/cli-documentation/versions/1.6/adopt.md`](standards/cli-documentation/versions/1.6/adopt.md) |
| Agent Handoff | `1.16` | [`standards/agent-handoff/versions/1.16/adopt.md`](standards/agent-handoff/versions/1.16/adopt.md) |
| Agent Handoff | `1.17` | [`standards/agent-handoff/versions/1.17/adopt.md`](standards/agent-handoff/versions/1.17/adopt.md) |
| GitHub Workflow | `1.9` | [`standards/github-workflow/versions/1.9/adopt.md`](standards/github-workflow/versions/1.9/adopt.md) |
| Project Toolbox | `1.1` | [`standards/project-toolbox/versions/1.1/adopt.md`](standards/project-toolbox/versions/1.1/adopt.md) |

Expand Down
6 changes: 6 additions & 0 deletions catalogs/5.toml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ role = "retained"
id = "agent-handoff"
version = "1.16"
digest = "sha256:c5740e1c40ae3643f3df67014ac1458a78e9d5f1682cdcaa1adcfc2f259d28ff"
role = "retained"

[[packages]]
id = "agent-handoff"
version = "1.17"
digest = "sha256:d70b2ef33c5de487f36fba445b33f7bb51196cb2770cd96cb1eff08d558007e8"
role = "default"

[[packages]]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
bug_id: '010'
date: '2026-09-01'
title: 'agent-handoff 1.15 and 1.16 shipped the byte-identical 1.14 session-start binary, so --version answered 1.14'
services: '[agent-handoff, go, packaging, release]'
status: 'fixed'
---

# 010 — agent-handoff 1.15/1.16 carry forward the 1.14 launcher

**Status:** fixed for 1.17 and later. The 1.14, 1.15, and 1.16 payload bytes are published and immutable, so those three versions permanently answer `agent-handoff session-start 1.14`; that is a recorded exception, not drift.

## Symptom

The committed `session-start` binaries for agent-handoff payloads 1.14, 1.15, and 1.16 are byte-identical — SHA-256 group `8b90…`, 3,906,258 bytes each — and all three report `agent-handoff session-start 1.14` from `--version`.

`--version` is documented, in both the build script and the family README, as the stale-launcher diagnostic: it exists so an operator can tell which package version installed the launcher sitting in a consumer repository. On 1.15 and 1.16 it answered a different question — which build produced the bytes — and answered it with a version the consumer never selected.

## Cause

`scripts/build-agent-handoff-session-start.sh` carries the artifact path and the version stamp as two literals. Both still named 1.14 while 1.15 and 1.16 were cut, because neither cut changed Go source: the payload directory was copied from its predecessor, the binary came along inside the copy, and nothing in the cut procedure required re-linking it.

Nothing detected this. Reproducibility is verified by `make go-verify-binary`, which rebuilds only the one path the script names, and the per-version contract tests asserted the payload's declared digest — which matched, because the carried-forward bytes were exactly the bytes the manifest declared.

## Consequence

No consumer behavior was wrong: the 1.14 launcher is functionally correct in 1.15 and 1.16, both of which changed nothing the launcher does. The cost is diagnostic. An investigator reading `1.14` from a launcher installed by a 1.16 selection sees what a genuinely stale installation looks like, and either chases a packaging defect that does not exist or — worse — dismisses a future real staleness report as this known artifact.

## Fix

Agent Handoff 1.17 (issues #229 and #235) re-links the launcher from the 1.17 path with `-X main.version=1.17`, and the build script now states the cut-time rule directly: every new payload version re-links the binary with its own stamp even when the Go source is unchanged, so two consecutive versions' bytes may differ by the stamp alone.

`tests/package_contract/test_agent_handoff_1_17.py` pins the rule against the catalog's **default** agent-handoff version rather than a literal, so a future cut that byte-copies its predecessor's launcher fails the contract instead of shipping a misleading stamp. Retained versions are deliberately unasserted — their bytes cannot change.

## Lesson

- **A carried-forward binary carries its stamp with it.** Copying a payload directory copies every build-time literal baked into the artifacts inside it; only re-linking updates them.
- **Assert a diagnostic against what it claims to answer.** The digest assertions were all green because they compared the artifact with its own manifest. Only executing the binary and comparing `--version` to the payload version tests the claim the diagnostic makes.
- **Pin such an invariant to the role, not the version.** Asserting "the default version's launcher reports the default version" survives every future cut; a test naming 1.17 would have to be remembered at the next one — which is precisely the step that failed here.
1 change: 1 addition & 0 deletions docs/handoff/bugs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ Generated from frontmatter. Regenerate with `python3 docs/handoff/bugs/_regen_in
| 007 | 2026-08-09 | remote gate blocked by a redirected uv environment, and .git absence misreports as ledger corruption | rexec, tests, ledger, tooling | fixed |
| 008 | 2026-08-09 | superseded pre-format-3 checklists remained at the work-item root and were read as live executor state | execute-plan, handoff, docs | fixed |
| 009 | 2026-08-10 | active ADRs state timestamped observations about code in decision voice, so the observations go stale invisibly | adr, docs, triage | open |
| 010 | 2026-09-01 | agent-handoff 1.15 and 1.16 shipped the byte-identical 1.14 session-start binary, so --version answered 1.14 | agent-handoff, go, packaging, release | fixed |
14 changes: 14 additions & 0 deletions docs/handoff/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,17 @@ add new payload-tree test coverage there rather than re-deriving file enumeratio
**Sources:** ADR 0031 D4; github-workflow 1.9 (#203, #218), adopted 2026-09-01.

**Related:** 24.

## 26. New payload binaries are stripped; published bytes are not

**Applies when:** cutting a payload version that ships a Go binary, or reading a size difference between two retained versions.

**Rule:** a family's `scripts/build-*.sh` pins `-s -w` in `ARTIFACT_LDFLAGS` from its first cut after 2026-09-01 — agent-handoff at 1.17 — so binaries cut from then on ship without their symbol table or DWARF. Published payload bytes are immutable and stay unstripped, so a retained version is roughly 30% larger than its stripped successor. That step is the policy working, not drift — never "fix" it by rebuilding a released payload.

**Why:** `.debug*`, `.symtab`, and `.strtab` were 32.4% of the session-start binary and 30.2% of `gh-workflow` (#228 lever 1). `.gopclntab` is untouched, so panic traces still carry function names and line numbers; only `delve`/DWARF inspection of the *shipped* file is lost, and that is recovered by rebuilding from the same script without the flags.

**Gotcha:** the strip changes the artifact digest, so the payload version, its digest chain, and `make go-verify-binary` all move together in the cutting commit. A cut also always re-links the binary with its own `-X main.version=` stamp even when the Go source is unchanged (bug 010), so consecutive versions may differ by the stamp alone.

**Sources:** #228 (levers 2–4 recorded there as "not now"); agent-handoff 1.17 cut, 2026-09-01.

**Related:** 18, 20.
66 changes: 65 additions & 1 deletion internal/agenthandoff/sessionstart/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,19 +129,83 @@ func readState(root string) string {
return truncateUTF8(data, maxStateBytes) + stateNote
}

// gitHardeningOptions precede every session-start Git subcommand.
//
// `-c core.fsmonitor=` is a security control, not tuning: `git status` runs the
// configured fsmonitor hook as a child process, and that setting is reachable from the
// target repository's own `.git/config`. Without this override, opening an untrusted
// checkout as a session-start target executes an attacker-chosen command before the
// operator has seen anything (issue #235). A command-line `-c` outranks every config
// file, so this holds regardless of what system, global, or repository config says.
//
// SCOPE: it closes the fsmonitor vector and nothing else. Repository-level config still
// reaches two known execution paths under a `.git/config`-write precondition —
// `filter.<driver>.clean`/`.process` selected by `.gitattributes` during `status`, and
// `log.showSignature` with `gpg.program` during `log`. Both are an accepted residual:
// an attacker who can write `.git/config` in the repository that owns the installed hook
// already has the same execution the hook would give them. Do not reach for
// `GIT_CONFIG_NOSYSTEM` here — it suppresses only the system file, while repository-local
// config is always read, so it would close nothing these paths use.
//
// `--no-optional-locks` keeps the read from taking the index lock, so session start
// cannot fail — or make an unrelated Git command fail — by racing a concurrent write in
// the same checkout.
//
// Both are global options and must stay ahead of the subcommand; Git rejects them after
// it.
var gitHardeningOptions = []string{"-c", "core.fsmonitor=", "--no-optional-locks"}

// gitEnvironmentNames are the only parent variables the session-start Git reads inherit.
//
// Everything else is dropped rather than filtered, so no `GIT_*` variable exported by
// the harness or by an enclosing shell can redirect these reads: `GIT_DIR`,
// `GIT_WORK_TREE`, `GIT_CONFIG_GLOBAL`, and the `GIT_CONFIG_COUNT` family would all
// otherwise let the environment choose the repository or inject configuration, which
// would defeat the executable-path repository authority established by repositoryRoot.
// PATH is kept because Git resolves its own subprograms through it, and HOME is kept so
// a developer's normal identity and `~/.gitconfig` still apply. The allowlist is
// deliberately not exhaustive: `XDG_CONFIG_HOME` is dropped, so a global config
// relocated under XDG falls back to the `$HOME` paths and its settings do not apply to
// these reads. That is the intended trade — the reads must not honor configuration this
// process cannot vouch for — and it can only make the injected context thinner, never
// wrong. The fsmonitor setting a global config could carry is already neutralized by
// gitHardeningOptions.
var gitEnvironmentNames = []string{"PATH", "HOME"}

func gitEnvironment() []string {
environment := make([]string, 0, len(gitEnvironmentNames))
for _, name := range gitEnvironmentNames {
if value, ok := os.LookupEnv(name); ok {
environment = append(environment, name+"="+value)
}
}
return environment
}

// runGit executes one fixed argv inside root under a bounded timeout.
//
// The argument vector is always a literal from this package and the command runs without
// a shell, so repository contents can never reach argv. A failure of any kind — missing
// Git, timeout, non-zero status — returns ok=false and the caller degrades.
//
// The child gets the hardening options and the minimal environment documented above; a
// new call site inherits both by construction, which is why every Git read in this
// package goes through this one function.
func runGit(root string, arguments ...string) (string, bool) {
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()

argv := make([]string, 0, len(gitHardeningOptions)+len(arguments))
argv = append(argv, gitHardeningOptions...)
argv = append(argv, arguments...)

var stdout bytes.Buffer
command := exec.CommandContext(ctx, "git", arguments...) // #nosec G204 -- arguments
command := exec.CommandContext(ctx, "git", argv...) // #nosec G204 -- arguments
// are package literals; no caller-supplied value reaches this argv.
command.Dir = root
// A nil Env would hand the child the full parent environment; this assignment is
// load-bearing even when gitEnvironment returns an empty slice.
command.Env = gitEnvironment()
command.Stdout = &stdout
command.Stderr = nil
if err := command.Run(); err != nil {
Expand Down
Loading
Loading