mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* feat(sandbox): unify command execution and enforcement * test(sandbox): make platform contracts portable * fix(sandbox): close execution lifecycle races Bound retained interactive output, preserve interruption state, and start workspace observation before process launch. Protect active runtime roots with cross-platform leases, capture transient protected-path creation events, and restore process attributes after PTY fallback. * fix(sandbox): restore strict macOS isolation Remove Seatbelt host-local network allowances after native validation showed localhost filters can reach host interfaces. Track process reap completion so retained sessions cannot signal a stale numeric PID.
158 lines
6.6 KiB
Go
158 lines
6.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/Gitlawb/zero/internal/execution"
|
|
"github.com/Gitlawb/zero/internal/hooks"
|
|
"github.com/Gitlawb/zero/internal/workspacetrust"
|
|
)
|
|
|
|
// trustSkip reports whether a runtime chokepoint dropped the project layer because
|
|
// the workspace was not trusted, so the caller can emit a single combined notice.
|
|
// Both chokepoints (hooks and plugins) return one, and the caller ORs them.
|
|
type trustSkip struct {
|
|
// excludedProjectConfig is true when the project layer was excluded AND a
|
|
// project config for this surface actually exists on disk (so a notice is
|
|
// worth showing). It stays false in a trusted workspace, or when the
|
|
// workspace has no project config to skip.
|
|
excludedProjectConfig bool
|
|
// trustCheckErrored is true when the exclusion was caused by a trust-store
|
|
// read error rather than a clean untrusted verdict, so the caller can name
|
|
// the error in its notice.
|
|
trustCheckErrored bool
|
|
}
|
|
|
|
// resolveTrust computes the fail-closed trust verdict for a chokepoint. It treats
|
|
// any error OR an empty trustRoot as untrusted, so a forgotten or future call site
|
|
// cannot fail open. It returns whether the project layer should be excluded and
|
|
// whether the decision was driven by a store-read error.
|
|
//
|
|
// Each chokepoint (hooks and plugins) calls this itself rather than the caller
|
|
// resolving trust once and passing a bool down. That is deliberate: keeping the
|
|
// check inside the chokepoint is what makes the gate fail-closed by construction.
|
|
// Do NOT hoist it to the callers behind an excludeProject bool whose zero value
|
|
// includes the project layer, that reintroduces a fail-open default. The extra
|
|
// store read per session is negligible.
|
|
func resolveTrust(trustRoot string) (excludeProject bool, trustCheckErrored bool) {
|
|
if trustRoot == "" {
|
|
return true, false
|
|
}
|
|
trusted, err := workspacetrust.IsTrusted(trustRoot)
|
|
if err != nil {
|
|
return true, true
|
|
}
|
|
return !trusted, false
|
|
}
|
|
|
|
// newHookDispatcher builds the per-session hooks dispatcher for a workspace,
|
|
// merging user + project hooks.json and wiring the audit store. It fails OPEN:
|
|
// any load or setup error yields a nil dispatcher, which Dispatch treats as a
|
|
// no-op, so a malformed hooks config can never wedge tool execution. With no
|
|
// hooks configured the dispatcher selects nothing and runs no commands, so the
|
|
// hot path stays free of overhead until a user opts in via hooks.json.
|
|
//
|
|
// trustRoot is the original launch directory (resolved before any --worktree
|
|
// reassignment); the project layer loads only when that root is trusted.
|
|
func newHookDispatcher(workspaceRoot string, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
|
|
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot, runners...)
|
|
}
|
|
|
|
// newHookDispatcherWithExtra builds the dispatcher like newHookDispatcher but also
|
|
// folds plugin-activated hook definitions into the active hook set, so a plugin's
|
|
// declared hooks run alongside the user/project hooks.json hooks. Plugin hooks are
|
|
// appended after the configured hooks; their ids are plugin-namespaced (plugin
|
|
// id + hook name) so they never collide with hooks.json ids. A nil/empty extra
|
|
// slice is byte-equivalent to newHookDispatcher.
|
|
//
|
|
// The trust check lives here, inside the chokepoint, so no caller can bypass it:
|
|
// the project layer is dropped (ExcludeProject) whenever trustRoot is empty, the
|
|
// trust store cannot be read, or the workspace is not trusted (fail-closed). The
|
|
// returned trustSkip lets the caller emit one combined notice; the notice itself
|
|
// is NOT emitted here.
|
|
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
|
|
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
|
skip := trustSkip{
|
|
excludedProjectConfig: excludeProject && projectHooksFileExists(workspaceRoot),
|
|
trustCheckErrored: trustCheckErrored,
|
|
}
|
|
|
|
loaded, err := hooks.LoadConfig(hooks.LoadOptions{Cwd: workspaceRoot, ExcludeProject: excludeProject})
|
|
if err != nil {
|
|
return nil, skip
|
|
}
|
|
var audit *hooks.AuditStore
|
|
if store, err := hooks.NewAuditStore(hooks.AuditStoreOptions{}); err == nil {
|
|
audit = store
|
|
}
|
|
config := loaded.Config
|
|
if len(extra) > 0 {
|
|
// Plugin hooks only run when hooks are enabled overall; an explicit
|
|
// `enabled:false` in hooks.json still disables the whole hook surface.
|
|
merged := append([]hooks.Definition{}, config.Hooks...)
|
|
existing := make(map[string]bool, len(merged))
|
|
for _, hook := range merged {
|
|
existing[hook.ID] = true
|
|
}
|
|
for _, hook := range extra {
|
|
// A hooks.json hook with the same (namespaced) id wins, so an operator can
|
|
// still disable a plugin hook by id without the plugin re-enabling it.
|
|
if existing[hook.ID] {
|
|
continue
|
|
}
|
|
merged = append(merged, hook)
|
|
}
|
|
config.Hooks = merged
|
|
}
|
|
var runner *execution.Runner
|
|
if len(runners) > 0 {
|
|
runner = runners[0]
|
|
}
|
|
return hooks.NewDispatcher(hooks.DispatcherOptions{
|
|
Config: config,
|
|
Audit: audit,
|
|
Cwd: workspaceRoot,
|
|
Execution: runner,
|
|
}), skip
|
|
}
|
|
|
|
// projectHooksFileExists reports whether a ./.zero/hooks.json is present under
|
|
// workspaceRoot, so the caller only notices about config it actually skipped.
|
|
func projectHooksFileExists(workspaceRoot string) bool {
|
|
if workspaceRoot == "" {
|
|
return false
|
|
}
|
|
info, err := os.Stat(filepath.Join(workspaceRoot, ".zero", "hooks.json"))
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
// emitTrustNotice writes at most one stderr line summarizing that project-scoped
|
|
// hooks, plugins, and/or MCP servers were skipped in an untrusted workspace. It
|
|
// takes the skip report from each trust-gated surface (hooks, plugins, MCP) and ORs
|
|
// them, so a workspace that only drops one surface still gets the notice. It is
|
|
// computed once per session by the caller (each session-setup site runs once), so it
|
|
// is naturally once-per-process. When any surface's skip was a trust-store read
|
|
// error, the notice names that so a transient config-dir problem is diagnosable.
|
|
func emitTrustNotice(stderr io.Writer, skips ...trustSkip) {
|
|
if stderr == nil {
|
|
return
|
|
}
|
|
excluded := false
|
|
storeErrored := false
|
|
for _, skip := range skips {
|
|
excluded = excluded || skip.excludedProjectConfig
|
|
storeErrored = storeErrored || skip.trustCheckErrored
|
|
}
|
|
if !excluded {
|
|
return
|
|
}
|
|
if storeErrored {
|
|
_, _ = fmt.Fprintln(stderr, "zero: the workspace-trust store could not be read; ignoring project hooks/plugins/MCP servers (fail-closed). Run 'zero trust' to enable.")
|
|
return
|
|
}
|
|
_, _ = fmt.Fprintln(stderr, "zero: ignoring project hooks/plugins/MCP servers in an untrusted workspace. Run 'zero trust' to enable.")
|
|
}
|