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
- func BackupAgentmod(projectRoot string, now time.Time) (string, error)
- func RedactionFindingCounts(report []byte) (total, hard int)
- type CreateOptions
- type ExcludedEntry
- type GitState
- type Inventory
- type InventoryEntry
- type Manifest
- type PlanEntry
- type RestorePlan
- type RestoreResult
- type Result
- type Rule
- type ScanFinding
- type Snapshot
- type VerifyResult
Constants ¶
const ( HandoffDocName = "HANDOFF.md" RestoreDocName = "RESTORE.md" )
Zip member names of the two human-readable documents.
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.
const ( ManifestName = "manifest.json" InventoryName = "inventory.json" ChecksumsName = "checksums.txt" PayloadPrefix = "payload/" )
Member names at the zip root. RedactionName lives in redaction.go.
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.
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.
const RedactionName = "REDACTION.md"
RedactionName is the redaction report's zip member name.
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 ¶
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 ¶
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 ¶
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 ¶
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) 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.