sessionmap

package
v0.20.0 Latest Latest
Warning

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

Go to latest
Published: Oct 4, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package sessionmap is the hook-written state that binds a Claude Code process to its Brigade session (plan 3.2, 6.5): the by-pid map at ${stateDir}/sessions/by-pid/<claude_pid>.json, from which the session-bound commands and the watcher resolve EVERYTHING (profile, config dir, adapter argv, Brigade session id, team, socket path), and the by-native map at ${stateDir}/sessions/by-native/<claude_session_id>.json, which lets a later `claude --resume` re-open the same Brigade session (3.7). Neither map ever holds the messaging token (3.2).

Trust: the by-pid map is an unauthenticated trust boundary guarded by filesystem permissions alone (E0-7) — whoever can write it decides which profile and team a session acts as. The reader therefore applies every check available and refuses anything else with `config`, details.reason "map_not_private": a regular file opened without following a symlink, mode granting nothing to group or other, owned by the current uid, within the size cap, valid JSON of the expected shape whose claude_pid matches its file name. A hook that rewrites the map on every SessionStart (E0-8: it re-fires on /clear) is what keeps a planted map from surviving to the first Bash call; this package supplies the overwrite semantics that idempotence needs.

Every side effect here is a file under the caller's Store.StateDir; the clock is the caller's (RegisteredAt/UpdatedAt are set by the hook) and the owner check is injectable through Store.Stat, so tests stay hermetic.

Index

Constants

View Source
const (
	// ReasonMapNotPrivate: the file failed the privacy checks (mode, owner,
	// symlink, regular file). details.check names which.
	ReasonMapNotPrivate = "map_not_private"
	// ReasonMapMalformed: the file is not a JSON object of the expected
	// shape, or exceeds MaxMapBytes.
	ReasonMapMalformed = "map_malformed"
	// ReasonMapInvalid: the JSON parsed but a member fails Validate;
	// details.field names it.
	ReasonMapInvalid = "map_invalid"
	// ReasonMapMismatch: the by-pid file's claude_pid is not the pid in its
	// file name (a copied or planted file).
	ReasonMapMismatch = "map_mismatch"
	// ReasonMapUnreadable: an I/O failure other than "missing" — the
	// underlying error is never echoed.
	ReasonMapUnreadable = "map_unreadable"
	// ReasonStateDirRelative: the Store's StateDir is empty or relative;
	// the harness always computes an absolute one (3.2).
	ReasonStateDirRelative = "state_dir_relative"
)

The details.reason values of the `config` errors this package returns.

View Source
const MaxMapBytes = 64 << 10

MaxMapBytes caps what the strict reader will read of a map file. A real map is well under 2 KiB; anything larger is not one.

View Source
const MaxNativeIDLen = 80

MaxNativeIDLen is the length cap of a native session id used as a file name.

Variables

This section is empty.

Functions

func CheckAdapterCommand

func CheckAdapterCommand(argv []string) error

CheckAdapterCommand validates a resolved adapter argv prefix: every element non-empty and, when there is one, the first an absolute path. An empty argv is valid and means the bundled adapter. A relative executable is refused because a planted map must not be able to make the harness exec a path relative to whatever the cwd happens to be.

func CheckNativeID

func CheckNativeID(id string) error

CheckNativeID validates a native session id before it becomes a path component: 1–80 characters from [A-Za-z0-9_-]. Claude Code's ids are UUIDs, which pass; anything with a separator, a dot or a non-ASCII rune is refused, so a hostile id in hook stdin cannot escape the by-native directory. The offending value is never echoed.

func ValidSyncScope added in v0.16.0

func ValidSyncScope(s string) bool

ValidSyncScope reports whether s can be a folder id's scope: a repository name as teamfile.RepoName writes one — a label that sanitising leaves unchanged — with no "/", because the bundled adapter splits a folder's label "<scope>/<folder>" at its first one. The hook asks before it freezes a scope, so a name that fails is left out rather than costing the session its map.

Types

type ByNative

type ByNative struct {
	BrigadeSessionID string    `json:"brigade_session_id"`
	TeamRef          string    `json:"team_ref"`
	SessionName      string    `json:"session_name"`
	UpdatedAt        time.Time `json:"updated_at,omitzero"`
}

ByNative is the by-native map (plan 3.2, 3.7): keyed by the native Claude Code session id, it survives session end so that `claude --resume <id>` finds the Brigade session to re-open. It is OVERWRITTEN on every write: a native id RECURS within one process (E0-5 item 6: `/clear` then `/resume` returns the id to its pre-clear value), so the map tolerates a returning id by design rather than assuming ids are monotonic.

func (*ByNative) Validate

func (m *ByNative) Validate() error

Validate requires the one member a resume hint cannot do without.

type ByPID

type ByPID struct {
	// ClaudePID is the Claude Code process id; it is also the file name.
	ClaudePID int `json:"claude_pid"`
	// ClaudeSessionID is the native session id from the hook's stdin; it
	// is never sent to any backend (T10).
	ClaudeSessionID string `json:"claude_session_id"`
	// BrigadeSessionID is the adapter-assigned session id (4.4.3).
	BrigadeSessionID string `json:"brigade_session_id"`
	// TeamRef and TeamName are the profile's team binding at registration.
	TeamRef  string `json:"team_ref"`
	TeamName string `json:"team_name"`
	// SessionName is the display name registered (6.5).
	SessionName string `json:"session_name"`
	// WorkspaceLabel is the label registered with the session (P11-5):
	// the repository name the hook derived, or the user's own; "" when
	// sharing is off. The watcher carries it on a re-open, because a
	// resume that omits it clears it at the backend.
	WorkspaceLabel string `json:"workspace_label,omitempty"`
	// LabelOption is the `label` option as the hook resolved it (card 24,
	// part C): config.LabelAccount, config.LabelNone, or a literal the
	// member gave. The OPTION rides here, never a label — the account
	// email is read at the point of use by the watcher, exactly as
	// StartFacts does it, so this file never carries a member's email.
	// The watcher sends the resolved label on every registration, so a
	// membership whose label is empty is filled at the next session start
	// and at every watcher replacement.
	LabelOption string `json:"label_option,omitzero"`
	// DoingMode is the RESOLVED doing mode of card 25 (plan 5.2): one of
	// doing.ModeUnsupported, ModeOff, ModeUnasked, ModeAllowed and
	// ModeQuiet, frozen here by the SessionStart hook from the adapter's
	// capabilities, the `share_doing` option and the permission rules in
	// the settings it can read. `brigade doing` reads it to decide whether
	// to publish (the Bash-tool verb never sees CLAUDE_PLUGIN_OPTION_*);
	// the prompt hook reads it to decide whether a line may be printed. It
	// is safe on disk because it is one of five fixed words — never a
	// sentence, never a rule, never a byte of a settings file. Absent
	// (omitzero) in a map written before the mode existed; a reader treats
	// absent as "publish, print nothing" until the prompt hook resolves it
	// once.
	DoingMode string `json:"doing_mode,omitzero"`
	// PermissionMode is recorded for diagnostics only; the inbound policy
	// never depends on it (D18).
	PermissionMode string `json:"permission_mode"`
	// NonInteractive is true for a `claude -p` session (CLAUDE_CODE_ENTRYPOINT
	// sdk-cli, 6.5); diagnostics only.
	NonInteractive bool `json:"non_interactive"`
	// Inbound is the EFFECTIVE policy the hook chose: accept, hold or
	// refuse (6.8).
	Inbound string `json:"inbound"`
	// FrameLevel is the instruction paragraph the frame carries: open,
	// guarded, strict, or custom for a user-supplied clause (P5-12). The
	// hook resolves it ONCE at SessionStart and freezes it here, so the
	// watcher never re-reads a user file minutes or hours later.
	FrameLevel string `json:"frame_level"`
	// FrameText is the resolved custom clause, EMPTY unless FrameLevel is
	// custom: a named level's text is a Go constant and is never carried
	// here, so a rewritten map cannot smuggle a different paragraph in
	// under a level's name.
	FrameText string `json:"frame_text"`
	// SocketPath is CLAUDE_CODE_MESSAGING_SOCKET as the hook saw it, or ""
	// on a host without an inbox socket. The token is NOT here.
	SocketPath string `json:"socket_path"`
	// TranscriptPath is the native transcript path from the hook's stdin,
	// read by the watcher for the model and context facts; it is never
	// sent to any backend (T10). "" when the hook document carried none
	// (or a relative one); omitted from the file then, so a map from
	// before it existed still reads.
	TranscriptPath string `json:"transcript_path,omitzero"`
	// Profile is the resolved profile name.
	TeamKey string `json:"team_key"`
	// ConfigDir is the resolved absolute Brigade config directory.
	ConfigDir string `json:"config_dir"`
	// AdapterCommand is the resolved argv prefix prepended verbatim to
	// every adapter invocation; [] means the bundled adapter, which the
	// adapterclient resolves through os.Executable() at spawn time and
	// which is deliberately never pinned to a path here.
	AdapterCommand []string `json:"adapter_command"`
	// PluginBin is the bootstrap's resolved realpath when the hook knows
	// it (the shadowing check of 6.2), else "".
	PluginBin string `json:"plugin_bin"`
	// SyncAdapter, SyncFolders and SyncRoot are file sync as the hook
	// resolved it at SessionStart (folder-sync plan §4.3) and froze it for
	// the watcher, which drives the adapter, and for `brigade sync
	// status`: the NAME of the sync adapter the team file's `sync` member
	// gives (never a path — foldersync resolves it), the folders it lists
	// exactly as written (relative to the checkout), and the canonical
	// repository toplevel they are relative to. All three are set together
	// — a usable member with at least one folder and the `sync` option on —
	// or none is, and then this session syncs nothing. Absent (omitzero)
	// in a map from before file sync existed, which reads as "off".
	SyncAdapter string   `json:"sync_adapter,omitzero"`
	SyncFolders []string `json:"sync_folders,omitzero"`
	SyncRoot    string   `json:"sync_root,omitzero"`
	// SyncScope is the repository's name as the hook derived it
	// (teamfile.RepoName), frozen beside the three above: the part of a
	// folder's id that keeps two repositories of one team apart (card
	// 42). It is never the `workspace_label` option, which a member may
	// set to anything: an id must be the same on every checkout. Absent
	// (omitzero) with sync off, in a map from before it existed, and for
	// a checkout with no name to derive — then the folders keep the ids
	// of 0.11.0 to 0.15.0 (foldersync.LegacyFolderID).
	SyncScope string `json:"sync_scope,omitzero"`
	// MessageSound is the `message_sound` option as the hook resolved it
	// (card 35): true when this session's watcher plays one quiet sound
	// as a message arrives. The watcher re-reads it on every liveness
	// tick, so a SessionStart that flips the option takes effect without
	// a respawn. Absent (omitzero) when off, so a map from before the
	// option existed reads as off.
	MessageSound bool `json:"message_sound,omitzero"`
	// MessageNotification is the `message_notification` option the same
	// way (card 36): a desktop notification as a message arrives.
	MessageNotification bool `json:"message_notification,omitzero"`
	// MessageIntervalSeconds is the `message_interval` option as the hook
	// resolved it (card 38): the seconds between two sounds, or two
	// notifications, within notify's bounds. Absent (omitzero) in a map
	// from before the option existed, which Interval reads as the default.
	MessageIntervalSeconds int `json:"message_interval_seconds,omitzero"`
	// HarnessVersion is the Claude Code version from the registry's
	// `version` member when present, else "unknown".
	HarnessVersion string `json:"harness_version"`
	// RegisteredAt is when the Brigade session was registered; UpdatedAt
	// when this file was last rewritten. Both are the hook's clock.
	RegisteredAt time.Time `json:"registered_at,omitzero"`
	UpdatedAt    time.Time `json:"updated_at,omitzero"`
}

ByPID is the by-pid map (plan 3.2): everything a session-bound command or the watcher needs, resolved by the hook once at SessionStart. It carries RESOLVED values only — the profile after its default applied, the config dir as an absolute path, the adapter as the argv prefix ResolveAdapter produced — never a raw option value and never the token.

func (*ByPID) Instruction

func (m *ByPID) Instruction() frame.Instruction

Instruction is the frame instruction the map carries, for the two injectors (the watcher and the prompt-hook poll) to hand to inbound.Config. It is built, never validated, here: ReadByPID has already validated the members, and inbound.New validates it again as its own layer.

func (*ByPID) Interval added in v0.14.0

func (m *ByPID) Interval() time.Duration

Interval is the interval between two message sounds, or two notifications, this map asks for: the member's seconds, or notify.DefaultInterval when the member is absent.

func (*ByPID) Validate

func (m *ByPID) Validate() error

Validate checks the members every by-pid map must carry before it is written or trusted: a positive pid, a Brigade session id, a valid profile name, an absolute config dir, an inbound value this harness implements (accept, hold or refuse), a well-formed adapter argv, an absolute or empty socket path, an absolute or empty transcript path (the watcher opens it; a relative one would name a file relative to whatever its cwd is), an absent or valid doing mode (one of the five words of package doing; absent is a map from before the mode existed), and the frame members of P5-12 (a level among the four; a text only under custom, and then a non-empty one within frame.MaxCustomBytes that passes frame.CheckClause). The failure is `config` with details.field naming the member; the value is never echoed.

type StartFacts added in v0.3.0

type StartFacts struct {
	// ClaudePID is the Claude Code process id; it is also the file name.
	ClaudePID int `json:"claude_pid"`
	// ClaudeSessionID is the native session id from the hook's stdin.
	ClaudeSessionID string `json:"claude_session_id"`
	// ConfigDir is the resolved absolute Brigade config directory.
	ConfigDir string `json:"config_dir"`
	// PluginBin is the bootstrap's resolved realpath when the hook knows
	// it, else "".
	PluginBin string `json:"plugin_bin"`
	// LabelOption is the `label` option as the hook resolved it (card 24,
	// part B): "account", "none", or the member's own sanitised text.
	// Plugin options never reach the Bash tool, so this is the only way an
	// in-session `team create` or `team join` can learn it — exactly the
	// reason ConfigDir is here. It is the OPTION, not a label: the account
	// email is read from Claude Code's own config at the point of use, so
	// no member's email is ever written to this file. Omitted when the
	// option is absent, so a map written before it existed still reads.
	LabelOption string `json:"label_option,omitzero"`
	// WrittenAt is the hook's clock at the write.
	WrittenAt time.Time `json:"written_at"`
}

StartFacts is what the SessionStart hook knows about a Claude Code process BEFORE it knows whether the session attaches to a team (P7-11): the resolved Brigade config directory (the config_dir option applied — plugin options never reach the Bash tool, so a command run inside the session has no other way to learn it), the bootstrap's realpath and the native session id. It is written on every SessionStart, joined or not, and removed at SessionEnd, so an in-session `team create`/`team join` in a not-yet-attached session writes its credential and pin into the SAME store the hooks read. It carries no identity: the by-pid map stays the only thing that says which Brigade session a process is, and its Validate is untouched.

func (*StartFacts) Validate added in v0.3.0

func (f *StartFacts) Validate() error

Validate checks the members a reader relies on.

type Store

type Store struct {
	// StateDir is ${BRIGADE_STATE_DIR} as resolved by the caller.
	StateDir string
	// Stat, when non-nil, replaces the fstat of the opened file in the
	// privacy check. It exists for tests: a foreign-uid file cannot be
	// created without privileges, so the foreign-owner refusal is
	// exercised through a FileInfo whose Sys reports another uid. Leave
	// it nil everywhere else — the fstat on the open descriptor is what
	// makes the check free of a check-then-open race.
	Stat func(path string) (fs.FileInfo, error)
}

A Store reads and writes the two maps under one state directory. The zero Store is not usable: StateDir must be absolute (the harness always computes it, 3.2; config.BrigadeStateDir applies the in-session rule).

func (Store) ByNativeDir

func (s Store) ByNativeDir() string

ByNativeDir is ${stateDir}/sessions/by-native.

func (Store) ByNativePath

func (s Store) ByNativePath(id string) (string, error)

ByNativePath is the by-native map file for id, after validating id as a path component (CheckNativeID).

func (Store) ByPIDDir

func (s Store) ByPIDDir() string

ByPIDDir is ${stateDir}/sessions/by-pid.

func (Store) ByPIDPath

func (s Store) ByPIDPath(pid int) (string, error)

ByPIDPath is the by-pid map file for pid, after checking the Store and the pid.

func (Store) DeleteByPID

func (s Store) DeleteByPID(pid int) error

DeleteByPID removes the by-pid map at SessionEnd (3.8). A missing file is not an error; the by-native map is deliberately left in place.

func (Store) DeleteStart added in v0.3.0

func (s Store) DeleteStart(pid int) error

DeleteStart removes the start facts at SessionEnd. A missing file is not an error.

func (Store) ReadByNative

func (s Store) ReadByNative(id string) (*ByNative, error)

ReadByNative reads the by-native map for id through the strict reader. A missing file satisfies errors.Is(err, fs.ErrNotExist): no resume hint. Other failures are `config` as for ReadByPID.

func (Store) ReadByPID

func (s Store) ReadByPID(pid int) (*ByPID, error)

ReadByPID reads the by-pid map for pid through the strict reader and validates it. A missing file is returned as an error for which errors.Is(err, fs.ErrNotExist) holds (the caller maps it to `not_registered`); every other failure is a *protocol.Error with the `config` code and one of this package's Reason* details.

func (Store) ReadStart added in v0.3.0

func (s Store) ReadStart(pid int) (*StartFacts, error)

ReadStart reads the start facts for pid through the strict reader (the map's own privacy rule: regular, 0600, owned by the caller, no symlink) and validates them. A missing file satisfies errors.Is(err, fs.ErrNotExist).

func (Store) StartPath added in v0.3.0

func (s Store) StartPath(pid int) (string, error)

StartPath is the start-facts file for pid: ${stateDir}/sessions/by-pid/ <pid>.start.json, beside the map it precedes.

func (Store) WriteByNative

func (s Store) WriteByNative(id string, m *ByNative) error

WriteByNative validates m and writes the by-native map for id, overwriting any previous entry (a native id recurs, E0-5).

func (Store) WriteByPID

func (s Store) WriteByPID(m *ByPID) error

WriteByPID validates m and writes it atomically (0600 in a 0700 directory chain), overwriting any previous file for the same pid: the hook rewrites the map on every SessionStart, including the one `/clear` re-fires (E0-8). A nil AdapterCommand is written as [].

func (Store) WriteStart added in v0.3.0

func (s Store) WriteStart(f *StartFacts) error

WriteStart validates f and writes it atomically (0600 in a 0700 chain), overwriting any previous file for the same pid.

Jump to

Keyboard shortcuts

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