mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
e40df6b31c
* feat(cli): pad session arm/disarm/status + consent config resolution (PLAN-2613 S2, TASK-2617) The S2 CLI contract S3's plugin skills and S4's web composer build against. S1 gated push delivery on a server-side armed bit declared at stream connect; nothing decided WHETHER to arm or sent the declaration. S2 adds both, defaulting off everywhere. - ResolveAutoArm (internal/cli/arm_consent.go): pure consent resolver. .pad.toml [push] auto_arm is the only per-repo enabler (D4); a per-user config auto_arm=false vetoes it (deny-wins); default off. Config surfaces: PadToml.Push.AutoArm + config.Config.Push.AutoArm (*bool, unset != false), both nil-safe. - Wire contract: StreamSessionIdentity.Armed sends ?armed=true on the event stream — S1's server gate finally has a sender. The monitor announces armed = live local arm OR resolved auto_arm, so a repo opt-in works end to end with a safe default-off skew. - Verbs pad session arm/disarm/status: arm/disarm manage a per-session local arm-state file; status reports the resolved local/auto decision plus the server's own armed/connected counts (new Client.ListSessions), degrading gracefully when padd is unreachable. - Arm-state file (session_arm_state.go): keyed per session by CLAUDE_CODE_MESSAGING_SOCKET (cwd fallback for headless, secondary to auto_arm). Mandatory liveness — a dead-owner file (socket vanished / pid gone) reads as disarmed and is reaped, so a crashed session can never arm a future monitor. Local client state only; the server's armed bit stays the sole delivery authority. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(cli): address Codex R1 on push-consent (fail-closed config, owner-identity liveness) - HIGH-1: user config.toml read now fails CLOSED. config.LoadPushConfigAutoArm reads the [push] auto_arm value strictly — absent → no opinion, but present-but-unparseable → error — and ResolveAutoArmFromDisk refuses to auto-arm when it can't confirm the user's veto (was: swallowed by the lenient config.Load and treated as no-opinion). - HIGH-2: arm-state liveness now checks owner IDENTITY, not just presence. Socket-keyed files record the socket's mtime and require an exact match, so a reused socket path can't revive a stale file. Headless files record a Linux /proc start-time token (portable fallback documented) to reject a reused pid. - MED-1: arm-state writes are atomic (temp + rename) and reaping is non-destructive (re-checks staleness before removing) — a concurrent re-arm is never clobbered. - MED-2: pad session status applies the .pad.toml URL override, so it queries the same server the monitor connects to. - LOW: malformed arm-state files are now reaped (safe now that writes are atomic — a corrupt file can't be a torn in-progress write). Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(cli): address Codex R2 on push-consent (atomic config write, stronger owner identity) - HIGH-1: Config.Save() is now atomic (temp + rename), so a monitor reconnecting while `pad configure` rewrites config.toml can't read a truncated/partial file, miss a [push] auto_arm=false veto, and arm. - finding 2: socket owner identity now uses inode+device (unix) as the primary signal, with mtime as the non-unix fallback — a rebound socket or a lingering stale node at the same path gets a new inode and is rejected, closing the mtime-collision / reused-node gaps. - finding 3: headless liveness fails closed when a proc-start token was recorded but can't be re-verified (was: fell back to bare pid-liveness, which a reused pid passes); zombies (state 'Z') now report not-alive. - finding 5: `pad session status` applies an explicit --url override too, not just the .pad.toml one. - finding 4 (connect-time TOCTOU): documented as an accepted, bounded residual — a disarm racing an in-flight connect is corrected on the next reconnect; fully closing it needs S3's server-side disarm-on-open signal. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
111 lines
5.0 KiB
Go
111 lines
5.0 KiB
Go
package cli
|
|
|
|
import "github.com/PerpetualSoftware/pad/internal/config"
|
|
|
|
// Arm-consent resolution (PLAN-2613 S2, D4).
|
|
//
|
|
// "Arming" is a session's declaration that it consents to receive `pad
|
|
// push` notifications — the security gate S1 built server-side (a stream
|
|
// declares armed=true at connect and only armed streams receive
|
|
// KindPush; see internal/server/session_identity.go). Nothing decided
|
|
// WHETHER to arm yet. This file is that decision for the AUTO path: a
|
|
// repository opts its sessions in through .pad.toml, a user can veto it
|
|
// globally, and the default everywhere is off.
|
|
//
|
|
// The EXPLICIT path — an in-session `pad session arm` toggle — layers on
|
|
// top of this (its state overrides the resolved value); the running
|
|
// plugin monitor's mid-session re-arm is the S3 lockfile's job. This
|
|
// resolver is the piece both paths and the status verb share, so it is a
|
|
// pure function of its two inputs with a separate loader for the IO.
|
|
|
|
// ResolveAutoArm decides whether a session should auto-arm at connect,
|
|
// from the two config sources, with default OFF (PLAN-2613 D4).
|
|
//
|
|
// The full table (repoAutoArm = .pad.toml [push] auto_arm; userAutoArm =
|
|
// ~/.pad/config.toml [push] auto_arm):
|
|
//
|
|
// repo=false, user=nil → false (default: not opted in)
|
|
// repo=false, user=&true → false (a per-user true is NOT an enabler —
|
|
// D4 forbids a machine-global
|
|
// always-on, so it cannot arm a repo
|
|
// that didn't opt in itself)
|
|
// repo=false, user=&false → false
|
|
// repo=true, user=nil → true (repo opted in, user has no opinion)
|
|
// repo=true, user=&true → true
|
|
// repo=true, user=&false → false (VETO — deny-wins: a per-user
|
|
// explicit false forces off even over
|
|
// a repo opt-in)
|
|
//
|
|
// The last row is the one decision the design under-specified ("deny-
|
|
// wins: .pad.toml over per-user"). It is resolved deny-wins — the
|
|
// security-conservative reading for a consent gate: a user who has said
|
|
// "never auto-arm me" must not be re-armed by a committed .pad.toml they
|
|
// pulled from someone else's opt-in. The alternative (repo overrides the
|
|
// user's global off) is exactly the surprise a consent gate exists to
|
|
// prevent, so a per-repo enable cannot override a per-user disable.
|
|
func ResolveAutoArm(repoAutoArm bool, userAutoArm *bool) bool {
|
|
if userAutoArm != nil && !*userAutoArm {
|
|
return false // per-user veto — deny-wins
|
|
}
|
|
return repoAutoArm
|
|
}
|
|
|
|
// ArmDecision is the resolved arm state plus the inputs that produced it,
|
|
// so callers (notably `pad session status`) can explain WHY a session is
|
|
// or isn't arming rather than just reporting the bit.
|
|
type ArmDecision struct {
|
|
// Armed is the resolved auto-arm value — the same bool a connecting
|
|
// stream would declare for this repo, absent an explicit in-session
|
|
// override.
|
|
Armed bool
|
|
// RepoAutoArm is the .pad.toml [push] auto_arm value (false when no
|
|
// workspace is linked).
|
|
RepoAutoArm bool
|
|
// UserVeto is true when the per-user config explicitly set auto_arm
|
|
// to false — the one thing that forces Armed off over a repo opt-in.
|
|
UserVeto bool
|
|
// ConfigUnreadable is true when the user's config.toml exists but
|
|
// could not be read or parsed. The decision fails CLOSED (Armed
|
|
// false) in that case, because a veto that cannot be read must be
|
|
// assumed present; the flag lets `pad session status` explain the
|
|
// off state honestly rather than as an ordinary "not opted in".
|
|
ConfigUnreadable bool
|
|
}
|
|
|
|
// ResolveAutoArmFromDisk loads both config sources (the nearest .pad.toml
|
|
// and the user's config.toml) and returns the resolved auto-arm
|
|
// decision. Failures degrade to the safe default: an unreadable or
|
|
// missing source contributes "not opted in" / "no veto" rather than an
|
|
// error, because a consent gate must fail CLOSED (off), and a torn config
|
|
// file should never silently arm a session — nor block one path of a CLI
|
|
// verb that has other useful things to report.
|
|
func ResolveAutoArmFromDisk() ArmDecision {
|
|
// A .pad.toml that exists but can't be parsed leaves repoAutoArm
|
|
// false — a repo can't opt in through a file we can't read, which is
|
|
// already the fail-closed direction.
|
|
repoAutoArm := false
|
|
if pt, err := LoadPadToml(); err == nil {
|
|
repoAutoArm = pt.PadTomlAutoArm()
|
|
}
|
|
|
|
// The per-user config is read STRICTLY (config.LoadPushConfigAutoArm,
|
|
// not the lenient config.Load): an existing-but-unreadable config.toml
|
|
// means we cannot confirm the user hasn't vetoed, so the auto path
|
|
// must NOT arm (Codex R1 HIGH-1). Local explicit arm is a separate
|
|
// signal and is unaffected.
|
|
userAutoArm, err := config.LoadPushConfigAutoArm()
|
|
if err != nil {
|
|
return ArmDecision{
|
|
Armed: false,
|
|
RepoAutoArm: repoAutoArm,
|
|
ConfigUnreadable: true,
|
|
}
|
|
}
|
|
|
|
return ArmDecision{
|
|
Armed: ResolveAutoArm(repoAutoArm, userAutoArm),
|
|
RepoAutoArm: repoAutoArm,
|
|
UserVeto: userAutoArm != nil && !*userAutoArm,
|
|
}
|
|
}
|