handoff

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 15, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package handoff implements .amod snapshot creation (FABLE_PLAN §18/§21, IMPLEMENTATION_PLAN §12). A .amod file is a zip whose members are manifest.json, inventory.json, REDACTION.md, HANDOFF.md, RESTORE.md, checksums.txt, and the payload tree under payload/ with forward-slash project-root-relative names (payload/.agentmod/...), so restore maps members back onto the project root directly. Inspect/verify/restore consume the same structures.

Index

Constants

View Source
const (
	HandoffDocName = "HANDOFF.md"
	RestoreDocName = "RESTORE.md"
)

Zip member names of the two human-readable documents.

View Source
const (
	ClaudeReloginRemedy = "claude may ask you to log in here; complete it once by running 'claude' inside this project"
	CodexReloginRemedy  = "re-login needed: run 'codex login' inside this project"
)

Canonical re-login instructions (§12). doctor's auth findings and init's copy-on-consent flow print the same strings (internal/cli aliases these), and RESTORE.md embeds them, so the wording cannot drift between the live tool and the document that travels with a snapshot.

View Source
const (
	ManifestName  = "manifest.json"
	InventoryName = "inventory.json"
	ChecksumsName = "checksums.txt"
	PayloadPrefix = "payload/"
)

Member names at the zip root. RedactionName lives in redaction.go.

View Source
const BackupPrefix = project.DirName + ".backup-"

BackupPrefix is the project-root-relative name prefix of restore backups: every backup is <BackupPrefix><utc-stamp> next to where .agentmod/ was (IMPLEMENTATION_PLAN §12). The prefix is exported so the restore command can name the pattern in its output and gitignore handling.

View Source
const GitDirName = ".agentmod-handoff"

GitDirName is the git-storable package directory at the project root (FABLE_PLAN §10). It is deliberately NOT gitignored — committing it is the point.

View Source
const RedactionName = "REDACTION.md"

RedactionName is the redaction report's zip member name.

View Source
const SchemaVersion = 1

SchemaVersion is the .amod manifest schema this build writes and the newest restore will accept.

Variables

This section is empty.

Functions

func BackupAgentmod

func BackupAgentmod(projectRoot string, now time.Time) (string, error)

BackupAgentmod moves projectRoot/.agentmod aside to .agentmod.backup-<utc-stamp> so a restore can extract a fresh tree without destroying the current environment (FABLE_PLAN §18/§25). The move is a single rename: atomic, preserves every mode/symlink/session byte without reading any of them, and keeps the original recoverable — if extraction later fails, renaming the backup back is the complete rollback. Whatever occupies the .agentmod name is backed up as-is, even a stray regular file; judging it is doctor's job, losing it is never acceptable here.

The returned path names the backup; "" with a nil error means nothing existed to back up. An existing entry at the backup name refuses the backup (D034's collision discipline) — nothing is ever overwritten.

func RedactionFindingCounts

func RedactionFindingCounts(report []byte) (total, hard int)

RedactionFindingCounts parses a rendered REDACTION.md back into its secret-candidate counts: every finding listed under the scan heading, and how many of them were HARD findings packed under --allow-findings. doctor uses it to re-surface a snapshot's create-time scan without re-reading payload content; it counts only list items in the scan section, so the exclusion list above it never inflates the numbers.

Types

type CreateOptions

type CreateOptions struct {
	ProjectRoot string    // directory containing .agentmod/
	OutputPath  string    // where the .amod file is written; must not exist
	CreatedAt   time.Time // manifest timestamp + zip member mtimes
	Version     string    // agentmod version for the manifest
	Platform    string    // "<GOOS>/<GOARCH>" for the manifest
	// Rules is the exclusion policy: nil means DefaultRules(). A non-nil
	// slice is used as-is — an explicitly empty one disables every policy
	// exclusion (the structural snapshots/ skip still applies), so the
	// caller owns the secret-safety consequences.
	Rules []Rule
	// AllowFindings packs the snapshot even when the secret scan hits a
	// HARD finding (private-key material in a kept file). Warn-level
	// findings never block; both kinds are listed in REDACTION.md.
	AllowFindings bool
	// Git is the project's git state, collected by the CALLER — the cli
	// executes git (D030 precedent) so this package stays exec-free and
	// deterministic under test. nil means no repository or no git binary;
	// manifest.json omits the key then. The dirty-worktree consent gate
	// (--allow-dirty) is also the caller's: by the time Create runs, packing
	// has been approved.
	Git *GitState
	// ForGit marks a git-storable tree package (FABLE_PLAN §19): the
	// manifest records it and the human-readable documents describe the
	// tree format instead of the .amod file. The FORMAT owns the flag —
	// CreateForGit forces it true, Create forces it false — so a caller
	// can never mislabel a package.
	ForGit bool
}

CreateOptions parameterizes Create. The clock and identity fields are injected so output is deterministic under test.

type ExcludedEntry

type ExcludedEntry struct {
	Path   string `json:"path"` // project-root-relative, forward-slash
	RuleID string `json:"rule_id"`
	Reason string `json:"reason"`
}

ExcludedEntry records one entry the exclusion engine dropped. Directory paths carry a trailing "/" (the whole subtree was pruned).

type GitState

type GitState struct {
	Branch        string `json:"branch,omitempty"` // empty when HEAD is detached
	Head          string `json:"head,omitempty"`   // commit hash; empty on an unborn branch
	Dirty         bool   `json:"dirty"`
	StatusSummary string `json:"status_summary"`       // "clean" or counts, e.g. "1 staged, 2 untracked"
	RemoteURL     string `json:"remote_url,omitempty"` // origin URL with credentials redacted
	// SourceIncluded records whether project source code traveled in the
	// snapshot (FABLE_PLAN §20). Always false in this version — patch
	// inclusion is a future explicit option; the field exists so a reader
	// of an old manifest never has to guess.
	SourceIncluded bool `json:"source_included"`
}

GitState is the manifest's record of the project's git repository at create time. The CALLER collects it (the cli executes git, D030 precedent); this package stays exec-free so snapshot writing is deterministic under test. Restore compares these fields against the target machine's repository (FABLE_PLAN §18).

type Inventory

type Inventory struct {
	Files []InventoryEntry `json:"files"` // sorted by Path
}

Inventory is inventory.json.

type InventoryEntry

type InventoryEntry struct {
	Path          string `json:"path"`   // zip member name (payload/...)
	Size          int64  `json:"size"`   // bytes of zip member content
	SHA256        string `json:"sha256"` // hex of zip member content
	Mode          string `json:"mode"`   // octal permission bits, e.g. "0755"
	SymlinkTarget string `json:"symlink_target,omitempty"`
}

InventoryEntry describes one non-directory payload member.

type Manifest

type Manifest struct {
	SchemaVersion   int    `json:"schema_version"`
	CreatedAt       string `json:"created_at"` // RFC3339, UTC
	AgentmodVersion string `json:"agentmod_version"`
	Platform        string `json:"platform"` // "<GOOS>/<GOARCH>"
	// Git is nil — and the key absent from manifest.json — when the project
	// is not inside a git repository or no git binary was available at
	// create time. Restore must tolerate its absence.
	Git *GitState `json:"git,omitempty"`
	// ForGit marks a git-storable tree package created with --for-git
	// (FABLE_PLAN §19). Absent from regular .amod snapshots.
	ForGit bool `json:"for_git,omitempty"`
}

Manifest is manifest.json. Later slices extend it (policy flags); restore must tolerate the absence of optional fields in schema-version-1 snapshots.

type PlanEntry

type PlanEntry struct {
	ZipName string      // archive member name (payload/...)
	RelPath string      // project-root-relative, slash-separated (.agentmod/...)
	Mode    fs.FileMode // permission bits only (setuid/setgid/sticky stripped)
	Target  string      // symlink target, Links entries only
}

PlanEntry is one planned extraction target.

type RestorePlan

type RestorePlan struct {
	Dirs  []PlanEntry
	Files []PlanEntry
	Links []PlanEntry
}

RestorePlan is the validated set of extraction actions, each slice sorted by RelPath (so parent directories precede children). The extraction slice should create Dirs, then Files, then Links — links last so no file write can ever pass through a just-restored symlink.

type RestoreResult

type RestoreResult struct {
	BackupPath string // where the previous .agentmod went; "" when none existed
	Dirs       int    // directories created from the plan
	Files      int    // regular files written
	Links      int    // symlinks created
}

RestoreResult reports what Restore did.

type Result

type Result struct {
	OutputPath   string
	PayloadFiles int   // non-directory payload members
	PayloadBytes int64 // total content bytes of those members
	// Excluded lists every entry the exclusion engine dropped (plus the
	// structural snapshots/ skip), in walk (lexical) order. A pruned
	// directory is recorded once, not per descendant. The redaction report
	// renders this list.
	Excluded []ExcludedEntry
	// Findings lists every secret-candidate match the content scan made in
	// KEPT files, in walk order (pattern order within a file). The
	// redaction report renders this list too; hard findings only appear
	// here when AllowFindings let creation proceed.
	Findings []ScanFinding
}

Result reports what Create wrote.

func Create

func Create(opts CreateOptions) (*Result, error)

Create packs the project's .agentmod/ directory into a .amod snapshot.

The payload is everything under .agentmod/ except .agentmod/snapshots (structural: it is the default OUTPUT directory, so packing it would nest prior snapshots — and, mid-write, the partially-written one — inside the new one) and whatever opts.Rules exclude (DefaultRules when nil: auth files, .env, ssh/cloud credentials, .git, node_modules, caches, tmp). Everything dropped is recorded in Result.Excluded.

The output file never exists in a partial state: Create writes a dot- prefixed temp file in the output directory and renames it over OutputPath only after the zip is complete; any error removes the temp.

func CreateForGit

func CreateForGit(opts CreateOptions) (*Result, error)

CreateForGit packs the project's .agentmod/ directory into the git-storable tree package at <ProjectRoot>/.agentmod-handoff/ (opts.OutputPath is ignored — the destination is fixed so the package always lands where the repository expects it).

.agentmod-handoff/ is a publishing area, so a previous package — recognized by its manifest.json — is REPLACED; anything else at that path belongs to the user and is refused untouched. The new tree is built in a dot-prefixed temp directory next to the target and swapped in only after it is complete (the D031 install --force pattern), so the package never exists in a partial state and a failed run leaves the previous one intact.

type Rule

type Rule struct {
	ID      string // stable identifier, e.g. "auth-file"
	Reason  string // human-readable; rendered into the redaction report
	Matches func(relPath, base string, isDir bool) bool
}

Rule is one name/path-based exclusion policy. Matches receives the project-root-relative forward-slash path (e.g. ".agentmod/claude/.credentials.json"), its base name, and whether the entry is a directory; matching a directory prunes the whole subtree.

func DefaultRules

func DefaultRules() []Rule

DefaultRules returns the §18 default exclusion list. Auth/secret rules come first so an entry matched by several rules is reported under the most security-relevant one.

func ForGitRules

func ForGitRules() []Rule

ForGitRules returns the exclusion policy for git-storable handoff packages (FABLE_PLAN §19): everything DefaultRules drops, plus agent sessions/history and log files — a committed package is published with the repository, so per-machine conversation history must never travel in it. The default rules come first so an entry matched by both (an auth file inside a session dir's parent, say) is still reported under the most security-relevant ID (D035). CreateForGit applies these when CreateOptions.Rules is nil.

func GitPublishRules

func GitPublishRules() []Rule

GitPublishRules returns only the session/log rules that ForGitRules adds on top of DefaultRules. doctor applies them to an existing .agentmod-handoff/ payload to detect session or log material a commit would publish (FABLE_PLAN §23) — agentmod's own CreateForGit can never pack such entries, so a hit means the tree was edited by hand or written by another tool.

type ScanFinding

type ScanFinding struct {
	Path    string `json:"path"`    // project-root-relative, forward-slash (same shape as ExcludedEntry.Path)
	Pattern string `json:"pattern"` // pattern ID, e.g. "private-key"
	Line    int    `json:"line"`    // 1-based line of the pattern's first match
	Hard    bool   `json:"hard"`    // hard findings refuse creation unless AllowFindings
}

ScanFinding records one secret-candidate pattern match in a kept payload file.

type Snapshot

type Snapshot struct {
	Path        string
	Manifest    Manifest
	Inventory   Inventory
	Redaction   []byte // REDACTION.md content, verbatim
	Members     int    // total zip members, including directory entries
	PayloadDirs int    // payload directory members (empty dirs restore too)
	// contains filtered or unexported fields
}

Snapshot is an opened .amod file. Close releases the underlying file handle. Open succeeds on any structurally complete snapshot regardless of its schema version — the CALLER decides what to do with a newer one (inspect prints a warning, Verify records a problem, restore will refuse).

func Open

func Open(path string) (*Snapshot, error)

Open reads the snapshot at path. It fails when the file is not a zip or any §21-required root member is missing or unparseable; it does NOT hash anything — integrity is Verify's job.

func (*Snapshot) Close

func (s *Snapshot) Close() error

Close releases the snapshot's file handle.

func (*Snapshot) PlanRestore

func (s *Snapshot) PlanRestore() (*RestorePlan, []string)

PlanRestore validates every archive member for safe extraction and returns the plan. Problems (human sentences, detection order) are collected rather than stopping at the first, so a hostile archive is reported in one pass; any problem means no plan. Checks per §21/§22/§25:

  • manifest schema_version must be 1..SchemaVersion (restore hard-refuses newer snapshots; inspect/verify merely warn)
  • every member is either a §21 root member or under payload/
  • payload paths are canonical, relative, forward-slash, no ".." escape, no Windows drive prefix, first element .agentmod, no protected elements, no duplicates
  • symlink targets are non-empty, relative, and resolve (lexically) inside .agentmod/
  • members are regular files, directories, or symlinks — nothing else

func (*Snapshot) Restore

func (s *Snapshot) Restore(projectRoot string, plan *RestorePlan, now time.Time) (*RestoreResult, error)

Restore executes plan under projectRoot. The existing .agentmod entry (if any) is moved aside by BackupAgentmod before the first write, so extraction always targets a fresh tree and O_EXCL writes cannot collide with current state. On any extraction failure the partial tree is removed and the backup renamed back — the rollback IS the rename (D042); when the rollback itself fails the error names both the partial tree and the backup so nothing is silently lost.

After extraction the standard layout directories missing from the payload are recreated (snapshots/ never travels — it is structurally excluded at create time) so doctor finds a complete tree.

The snapshot's zip handle stays valid even when the .amod file itself lives inside the .agentmod/snapshots/ tree being renamed away: members are read through the handle Open established, never re-opened by path.

func (*Snapshot) Verify

func (s *Snapshot) Verify() *VerifyResult

Verify re-hashes every content-bearing member (everything except directory entries and checksums.txt itself) against checksums.txt, then cross-checks inventory.json against the payload members: presence both ways, size, sha256, permission mode, and that a recorded symlink target hashes to the recorded sha256 (the member content IS the target string, D034). Read failures become problems, not errors, so one bad member never hides the rest.

type VerifyResult

type VerifyResult struct {
	Checked  int      // content-bearing members hashed against checksums.txt
	Problems []string // human-readable integrity failures, in detection order
}

VerifyResult is Verify's report. An empty Problems means every content-bearing member hashed to its checksums.txt entry and the inventory matches the payload exactly.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL