Skip to content

fix: phase-zero trust hardening for restore, hooks, imports and serve - #5

Open
Patel230 wants to merge 23 commits into
mainfrom
chore/phase-zero-trust
Open

Patel230 wants to merge 23 commits into
mainfrom
chore/phase-zero-trust

Conversation

@Patel230

Copy link
Copy Markdown
Contributor

Summary

Commits the owner's uncommitted phase-zero hardening work (45 modified + new files) in coherent groups, reviewed against the AGENTS.md invariants, followed by one fix commit per audit finding and a final docs reconciliation.

Stacked on #3 (ci/go-toolchain-gates): the first two commits of this branch are that PR. Review from a3b2d03 onward; once #3 merges the diff shrinks to the hardening commits. The release workflow draft is in #4.

Owner's work (committed as written)

  • feat(store): checksummed migration ledger (refuses unknown/future versions, gaps, edited migrations), migration 4 indexes, migration 5 provenance schema; migrations 1-3 unchanged.
  • feat(config), feat(git) ×2: symlink-safe managed directories, ResolveRevision via rev-parse --verify, marker-owned staged hook installs.
  • fix(adapter): the nine across-agent-* binaries become honest protocol-v1 shells.
  • feat(cli): typed errors/exit codes, argument contracts, transactional mutations, reference validation, import provenance, tombstones, session fork, context/handoff/bundle contracts, truthful MCP inventory.
  • feat(backup), feat(serve), test(e2e).
    The full suite passes at facd67c (the owner's state on top of ci: move to Go 1.26.6, pin actions by SHA, and gate Windows as compile-only #3): go test -count=1 ./... all packages ok.

Fixes on top (one per finding)

Commit Finding
fix(config): resolve the chosen Across home instead of rejecting symlinks F054
fix(backup): never destroy an unrelated directory on restore F046, F061 (restore)
fix(git): make chained hooks actually run the preserved original F053
fix(cli): record the caller's export path as the source origin F061 (provenance)
fix(cli): tombstones only block the identity that was deleted F052
refactor(store): drop schema that nothing reads or writes before it is frozen F056
feat(cli): add hook uninstall; remove helpers nothing calls F056
test(cli): cover session fork, context manifests, checkpoint bundles and handoffs F056 (+ fixes context show of an empty manifest, which no longer matched its hash)
fix(serve): accept the console cookie only for same-origin requests F057
chore: remove the unwired logging package and the unused logs/ directory F059
docs: reconcile README, SECURITY, CHANGELOG and AGENTS with the code F049, F050, F051, F058 (+ F056 docs)
chore: keep local research notes and audit reports out of the repository F060

Findings addressed

  • F046 — backup restore --target-home RemoveAll'd any existing directory. Now: missing/empty target restored; non-empty non-Across directory refused (exit 4) even with --force; an Across home replaced only with the new --force, and kept as DIR.across-old-<UTC timestamp>.
  • F049 — confinement was contradicted three ways. Decision: keep the repository-root rule the code and E2E test enforce, and state it everywhere (AGENTS.md, README, SECURITY, CHANGELOG, agent-compatibility) with the error message and how to import a provider export.
  • F050 — the "import-session could never succeed" CHANGELOG entry described a bug that only existed inside this branch; replaced by a Security entry.
  • F051 — SECURITY.md/README now describe the implemented controls and the residual gaps precisely.
  • F052 — a single delete no longer blocks every later native-id-less import of that kind.
  • F053 — chained pre-receive originals were dead code after exit 0; the wrapper now runs them (with stdin replayed) and propagates rejection; post-commit runs both.
  • F054 — symlinks in the user-chosen home path are resolved once; managed subdirectories must still be real directories.
  • F056 — removed idempotency_keys and the always-zero event_cursor columns (and from the sealed bundle) before migration 5 is frozen; exposed hook uninstall; removed dead helpers; added tests for session fork, context pack/show, checkpoint bundle, handoff envelopes; CHANGELOG/README document the new commands, --native-id, migrations 4-5 and the checksum ledger.
  • F057 — the across_token cookie is honoured only for same-origin requests (Sec-Fetch-Site absent/same-origin/none, Origin equal to the served host:port); bearer auth unchanged.
  • F058 — the dated 0.0.1 entry is restored as originally written, with a note that no v0.0.1 tag/release exists; its overclaims are listed under Unreleased → Corrected.
  • F059 — dead internal/logging and ACROSS_LOG=file removed; logs/ no longer created; docs updated.
  • F060 — reports/ and research_notes/ (~620 KB of research prose, referenced nowhere) are ignored, not committed and not deleted.
  • F061 — manifest.json no longer left in a restored home; import-session records the caller's path as sources.origin instead of the deleted temp copy.

Findings F047, F055 and the CI half of F247 are in #3; F048 and the release half of F247 are in #4.

Security-invariant decisions (AGENTS.md requires recording them)

  1. Transcript confinement — AGENTS.md said "confined to the provider root", which was never implemented. The invariant now says registered repository root, which the code enforces and the E2E test covers. Provider exports must be copied into the working tree first.
  2. Home path symlinks — the owner's draft rejected any symlink in the home path. The user-chosen path (ACROSS_HOME, --home, restore --target-home parent) is now resolved once with EvalSymlinks; symlinks inside the home (managed subdirectories) and symlinked hook files/dirs are still refused. The owner's tests TestEnsureHomeRejectsSymlinkedParent and TestRestoreRejectsSymlinkedTargetParent encoded the reported bug and were replaced by tests of the new rule (resolved location used, previous home kept, symlinked managed dir refused).
  3. Restore — restore now refuses to replace a non-Across directory at all and never deletes a replaced home.

Not reproduced / deferred

  • None of the assigned findings were not reproduced; F247 (unverified) was confirmed from source.
  • Deferred (documented, not implemented): context pack --query does not rank/filter items yet; recorded handoffs/manifests/bundles have no list command; issue/change/principal references in collaboration commands are still not validated (the unused helpers were removed rather than wired in untested).

Verification

Run on this branch head (39b6970) on darwin/arm64, go1.26.6:

Command Result
GOWORK=off make check pass (gofmt check, vet, go test -race -count=1 ./...; e2e 51.8s, cli 12.5s, all packages ok)
GOWORK=off make test-e2e pass (ok github.com/graycodeai/across/e2e 111.3s)
GOWORK=off go test -race -count=1 ./... pass (e2e 144.0s, cli 66.9s, all packages ok)
GOOS=linux GOARCH=amd64 GOWORK=off go build ./... pass
GOOS=windows GOARCH=amd64 GOWORK=off go build ./... pass
GOWORK=off make cross-check pass
GOWORK=off go mod tidy -diff no diff
GOWORK=off make vulncheck (govulncheck v1.8.0) No vulnerabilities found.
every commit from a3b2d03 to 39b6970 go build ./..., go vet ./... and gofmt -l clean in a temporary worktree, plus the tests of the packages it touches
regression proof the new hook, tombstone and origin tests fail against the previous code (e.g. original hook did not receive stdin, unrelated import without a native identity was blocked, source origin ".../across-import-.../transcript.jsonl") and pass after the fix

Linux CI jobs will run on this PR; Windows is compile-checked only.

Follow-ups

  • Rebase on main after ci: move to Go 1.26.6, pin actions by SHA, and gate Windows as compile-only #3 merges (the first two commits drop out).
  • Dev databases created from an earlier build of this branch that already applied the old migration 5 will fail the checksum check; recreate them (the owner's ~/.local/share/across is at migration 3 and upgrades cleanly).
  • Rank or record the context pack --query; add read commands for recorded handoffs/manifests/bundles; validate issue/change/principal references; restore fault-injection tests; Windows qualification.
  • Browser qualification of the console (XSS/CSP in a real browser).

🤖 Generated with Claude Code

across added 23 commits September 27, 2026 04:37
govulncheck v1.8.0 (the version @latest resolves to) refuses to run on
Go 1.24, and Go 1.24 is outside the upstream support window; the
reachable stdlib fixes the security job needs ship in Go 1.26.6, the
toolchain the rest of the GrayCode ecosystem already uses.

- go.mod: go 1.26.6 (go mod tidy -diff is clean)
- Makefile: add fmt-check, test-e2e, coverage, cross-check and
  vulncheck (govulncheck pinned to v1.8.0); make check now runs the
  gofmt gate; fuzz no longer swallows failures with || true
- fmt-check escapes its command substitution: the unescaped
  $(gofmt -l .) was expanded by make to an empty string, so the gate
  could never fail
- AGENTS.md, README.md, CONTRIBUTING.md: document the targets, the Go
  version, and the cgo requirement
- Pin every action to a full commit SHA with a version comment (same
  pins as rho/flux), check out without persisted credentials, and keep
  the workflow token read-only.
- Use Go 1.26.6 with GOTOOLCHAIN=local so the job runs the toolchain it
  names instead of silently switching.
- Split quality (tidy -diff, gofmt, build, vet, test on Linux and
  macOS), race, E2E, coverage and govulncheck (pinned) jobs; each runs
  the Makefile target a contributor runs locally.
- Windows was added to the test matrix without qualification: the E2E
  and unit suites exec sh/python3, create symlinks and rely on cgo, and
  no test is gated by GOOS. Keep Windows as a compile-only check until
  it is qualified; README already lists it as untested.
- Add .gitattributes (eol=lf) so a Windows checkout cannot turn every
  file into a gofmt failure.
- schema_migrations gains a checksum column; existing 0.0.1 ledgers are
  backfilled, and Migrate refuses unknown or future versions, gaps and
  edited migrations. Migrations run under BEGIN IMMEDIATE on one
  connection so concurrent first opens serialize.
- migration 4 adds the indexes the list/search queries use.
- migration 5 adds import provenance (content hash, size, parser
  version, redaction/import status), session lineage and checkpoint
  bundle columns, and the context_manifests, context_items, handoffs
  and evidence_bundles tables.
- Open retries the connection pragmas on SQLITE_BUSY.

Migrations 1-3 are byte-identical, so 0.0.1 databases upgrade in place.
EnsureHome/EnsureDirectory create directories one component at a time
and refuse symbolic links outside a small set of system prefixes;
ResolveDirectory returns a real, symlink-free directory for callers that
write into it (hook directories, restore parents).
ResolveRevision runs `git rev-parse --verify --end-of-options
<rev>^{commit}` so checkpoints, workspaces and context manifests only
ever record a revision that exists in the repository.
…rades

InstallHook validates the hook name and directory, refuses symlinked
hooks and hook directories, preserves a foreign hook as
<hook>.across-orig, identifies Across-owned hooks by a marker line, and
replaces hooks through a staged temp file and rename with rollback so
repeated upgrades keep the preserved original. UninstallHook restores
the original.
The nine across-agent-* binaries advertised capture, hook, resume and
token-usage support and answered every request with success without
doing the work. They now share internal/adapter.Run: `capabilities`
reports status protocol_shell with every capability false and
qualification UNIMPLEMENTED, `ping` succeeds, and any other method
returns a typed UNSUPPORTED_METHOD error.
Owner's phase-zero work on the command layer:

- CLIError with stable codes and exit statuses (invalid_argument 2,
  not_found 3, conflict 4, operation_failed 5, internal 1) printed as
  `across: <code>: <message>`; flag errors, required flags, enums and
  empty positional arguments are rejected before the store is opened.
- withTx runs multi-step mutations (imports, source delete, sessions,
  checkpoints and restore bookkeeping, memory lifecycle, handoffs,
  context manifests, bundles) in one BEGIN IMMEDIATE transaction, and
  repository/session/source/memory references are validated.
- Transcript imports record content hash, size and parser version,
  are confined to the repository root, and tombstoned identities are
  refused; `source import --native-id` and `session fork` are added.
- Versioned, hashed contracts: handoff envelope, `context pack/show`
  manifests and `checkpoint bundle`.
- MCP advertises only the eight store-backed tools and refuses
  placeholder, unknown and mutation names; agent-help reports the live
  inventory and immutable_enforced:false.
- `agent info` checks adapter identity and protocol; `verify run`
  records a failing command and exits 5; plugin install/run confine
  plugins to the Across plugin directory.
- create: SQLite snapshot via VACUUM INTO, archive written to a temp
  file and renamed with mode 0600; serve.token, tmp/, backups/, logs/
  and live database files are excluded.
- verify/restore: every member must be a unique regular file listed in
  the manifest with matching size, SHA-256 and mode; traversal,
  absolute, drive-letter, control-character, linked and special members
  and trailing data are rejected, with size and member-count limits.
- restore stages beside the target, never writes through symlinked
  parents, requires the candidate snapshot to pass PRAGMA
  integrity_check with a schema ledger, and commits by rename with
  rollback.
- The web console (index.html, app.js, styles.css) is embedded in the
  binary and served from it, so the served UI is the checked-in UI;
  records render with textContent only.
- `/?token=` sets an HttpOnly SameSite=Strict cookie and redirects so
  the token leaves the address bar; the UI fetches same-origin.
- --addr must be loopback; Host/Origin checks reject lookalikes; the
  http.Server has read/write/idle timeouts; the CSP adds
  frame-ancestors 'none'.
…d hooks

Adds end-to-end coverage for the typed exit codes, failing `verify run`,
the adapter protocol shells, the MCP inventory and error paths,
repository-root confinement of `agent import-session` (outside rejected,
inside imported) and post-commit hook chaining.
…inks

EnsureHome refused any symbolic link in the home path outside a few
system prefixes, so `across --home link/home repo list` (or an
ACROSS_HOME under a symlinked ~/.local/share or another volume) failed
every command with operation_failed after upgrading from 0.0.1, which
simply called MkdirAll.

The path the user chooses is now resolved once with EvalSymlinks
(ResolveUserPath: deepest existing ancestor resolved, missing
components appended), and the rule is applied where it protects
something: the directories Across creates and writes inside the home
(repositories/, mirrors/, workspaces/, plugins/, backups/, tmp/) must
still be real directories.

Tests: a home beneath a symlinked ancestor and a symlinked home are
accepted and created at the resolved location; a symlinked managed
subdirectory is still rejected.

Fixes F054.
`backup restore --target-home DIR` renamed any existing DIR aside,
moved the restored home into place and then RemoveAll'd the old
directory, so a mistyped target (a project or ~/Documents) was deleted
with exit 0 and no copy left.

Restore now classifies the target before staging and again at commit:
- missing or empty: restored in place;
- a non-empty directory without across.db: refused as a conflict
  (exit 4), even with --force;
- an existing Across home: replaced only with the new --force flag, and
  the previous home is kept as DIR.across-old-<UTC timestamp> (never
  deleted) and reported.

The target's parent is resolved with config.ResolveUserPath, matching
how the home itself is resolved. manifest.json is removed from the
stage before commit, so it no longer lingers in the restored home.

Tests cover a foreign target (with and without --force), an Across
home without and with --force (previous home kept, manifest absent),
an empty target, a symlinked parent, and a failed snapshot validation
that must not displace the target; the E2E round trip exercises the
same rules through the binary.

Fixes F046; fixes the restore half of F061.
The managed wrapper appended `exec <original> "$@"` after the Across
hook body. The pre-receive body ends in `exit 0`, so the chain line was
unreachable: installing a branch rule on a hosted repository silently
disabled the operator's existing pre-receive policy (and the original
would not have received the ref updates on stdin even if reached).

A chained hook is now generated as a wrapper that runs both parts:
- hooks that read stdin (pre-receive, post-receive, pre-push,
  post-rewrite, reference-transaction, proc-receive) capture stdin to a
  temp file, run the original first with that input and stop if it
  rejects, then run the Across body in a subshell with the same input;
- other hooks run the Across body in a subshell, then the original, and
  fail if either fails.

Tests: unit tests execute a chained pre-receive (original receives the
refs; Across still rejects main; an original rejection propagates) and
a chained post-commit (both run, in order); an E2E test pushes to a
hosted repo with a pre-existing pre-receive through `branch-rule add`.
Both unit tests fail against the previous wrapper.

Fixes F053.
`agent import-session` parses a private staged copy under os.MkdirTemp
and passed that path as `file`; the confinement check already used the
caller's resolved path, but sources.origin and the tombstone lookup
still used the staged copy, so provenance pointed at a deleted temp
file (/var/folders/.../across-import-XXXX/transcript.jsonl).

The import now uses one `origin` (the caller's resolved path, or the
file itself for `source import`) for confinement, the tombstone check
and the stored origin. The rejection message names the repository root
and how to proceed.

Test: import-session stores the resolved export path as origin (fails
against the previous code, which stored the temp path).

Fixes the provenance half of F061.
rejectTombstonedSource refused a native-id-less import whenever any
tombstoned source of the same kind had a native id
(`oldNative != "" || oldOrigin == origin`), so after one
`source delete` the default `source import --file F` was permanently
broken for that kind in that repository.

Without a native id, only an import of the same kind from the same
origin is now treated as a resurrection; with a native id, only the
same native id is. The conflict message names the origin.

Test: after deleting a source imported with a native id, a different
file imports without one while re-importing the deleted origin is still
refused (the first half fails against the previous rule).

Fixes F052.
…s frozen

Migration 5 is not released yet; once it is, its checksum is part of
every database's ledger and its columns are permanent. Two parts of it
had no implementation behind them:

- idempotency_keys: no reader or writer anywhere.
- sessions.event_cursor / checkpoints.event_cursor: always inserted as 0
  and never advanced, so every checkpoint bundle sealed an
  event_cursor of 0 into its bundle_hash, overstating the evidence it
  carries.

Remove them from migration 5 and from the session insert/show,
checkpoint insert and CheckpointBundle contract. A real event cursor
can be added in a later migration together with the code that
advances it.

Part of F056.
- git.UninstallHook (restore the chained original, or remove an
  Across-only hook) had no command. Expose it as
  `across hook uninstall REPO_PATH`, sharing the hooks-directory lookup
  with `hook install` (a non-repository path is now invalid_argument).
  The E2E hook-chaining test uninstalls and checks the original hook
  is restored byte-for-byte and the .across-orig sidecar is gone.
- Remove code with no callers: the requireFlags annotation helper and
  the PreRunE wrapper that only served it (commands declare required
  flags with requiredFlags), and issueMustBelong, changeMustBelong and
  principalMustExist.

Part of F056.
…and handoffs

The new contract commands had no tests. Add unit tests that run them
through the command tree:

- session fork records parent, fork_type and lineage_version, and
  rejects a missing parent (3) and a parent from another repository (4);
- checkpoint bundle writes a bundle whose bundle_hash reseals to the
  same value, records the identical payload in evidence_bundles, and
  returns not_found for an unknown checkpoint;
- context pack includes only approved memories, marks over-budget items
  as not included, rejects a zero budget; context show reproduces the
  sealed manifest and returns not_found for an unknown id;
- handoff --format json reseals to its content_hash, is recorded in
  handoffs, and rejects an unknown format.

The round-trip test found that `context show` of a manifest with no
items printed `"items": null` while `context pack` hashed `[]`, so the
shown manifest no longer matched its content_hash; show now starts from
an empty item list.

Part of F056.
The cookie set from `/?token=` (Path=/, SameSite=Strict) was accepted
as equivalent to the bearer token on every route, including /git/.
SameSite compares sites, not ports, so any page served from another
localhost port could send credentialed simple GET/POST requests to the
API and to git http-backend after the user had opened the console.

A cookie now authorizes a request only when Sec-Fetch-Site is absent,
`same-origin` or `none`, and any Origin header equals the served
host:port. Bearer-token requests are unchanged, so non-browser clients
and git with an Authorization header keep working.

Test: table test of requestAuthorized for bearer, same-origin fetch,
top-level navigation, non-browser client, matching and mismatched
Origin, same-site/cross-site fetch metadata, opaque origin and a wrong
cookie.

Fixes F057.
cmd/across no longer constructs internal/logging (errors are printed
by cli.FormatError), so nothing referenced the package, ACROSS_LOG=file
did nothing, and EnsureHome still created a logs/ directory that was
never written. Delete the package, stop creating logs/ (existing homes
keep theirs; backups still skip it), and drop the README/AGENTS lines
that described it.

Test: EnsureHome does not create logs/.

Fixes F059.
Owner's documentation corrections (protocol shells vs parsers vs
provider integrations, eight MCP tools, unreleased 0.0.1 source
snapshot, phase-0 roadmap) plus the reconciliation the audit asked for
once the code was final:

- Transcript confinement (F049): the policy is the one the code and
  E2E test enforce: imports must be inside the registered repository
  root. AGENTS.md said "provider root", README/SECURITY/CHANGELOG said
  confinement was not enforced; all now state the repository-root rule,
  the exit code and message, and how to import a provider export (copy
  it into an untracked or ignored directory in the working tree).
- SECURITY.md/README (F051): describe the implemented controls (marker
  ownership and chained originals, staged and validated restore with
  target protection, tombstone identity rules, embedded console
  assets, same-origin cookie) and the residual gaps precisely, instead
  of listing them as not implemented.
- CHANGELOG (F050, F058): the 0.0.1 entry is restored as originally
  written, with a note that no v0.0.1 tag or release exists and links
  to commit 1085a2a; its overclaims are listed under Unreleased →
  Corrected; the "import-session could never succeed" entry described
  a bug that never shipped and is replaced by the Security entry for
  repository-root confinement; Added/Changed/Removed/Security now cover
  the new commands, --native-id, --force, hook uninstall, typed exit
  codes, migrations 4-5 and the checksum ledger (F056).
- README documents the new commands, exit codes and platform status;
  STATUS reflects the hardened core.

Fixes F049, F050, F051, F058; documentation part of F056.
reports/ and research_notes/ (about 620 KB of OSS comparison and
roadmap research with third-party repository snapshots) are working
notes, not product documentation, and nothing in the repository links
to them. Ignore them instead of deleting the owner's files; conclusions
worth publishing can be distilled into docs/ in a separate change.

Fixes F060.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant