From 78db5290d76b01f676d9f7d3791eb3d092a9ea92 Mon Sep 17 00:00:00 2001 From: Mindburn Labs Date: Fri, 24 Jul 2026 15:54:35 +0300 Subject: [PATCH 01/12] feat(workstation): escalating shell gate with command-name extraction Port Rowboat's extractCommandNames/isBlocked semantics (Apache-2.0, apps/cli/src/application/lib/command-executor.ts) to Go as the parsing front-end of workstation enforcement, with fail-closed hardening: - ExtractCommandNames: robust to &&, ||, |, ;, &, backticks, $(...) subshells, ENV= prefixes, and sudo/env/time/command wrappers (recursive unwrap, skip ENV= and flags after wrappers). - BlockedCommandNames: empty allowlist blocks everything, '*' allows all. - GateShellCommand: blocked commands escalate to PENDING APPROVAL in the dev profile; production (and any unknown profile) stays fail-closed deny. - ShellAllowlistStore: user-editable JSON allowlist (bare array / {allowedCommands} / truthy map) with mtime+size cache; seeds the Rowboat default set on first use; corrupt files fail closed (no silent fallback to defaults). Tests: full extraction table (28 cases), blocked semantics, profile matrix, mtime reload (same-mtime+size cached, bumped-mtime reload), fail-closed store errors, and the dev escalation round-trip. Signed-off-by: Mindburn Labs --- core/pkg/workstation/shellallowlist.go | 212 +++++++++++++++++ core/pkg/workstation/shellgate.go | 244 +++++++++++++++++++ core/pkg/workstation/shellgate_test.go | 311 +++++++++++++++++++++++++ 3 files changed, 767 insertions(+) create mode 100644 core/pkg/workstation/shellallowlist.go create mode 100644 core/pkg/workstation/shellgate.go create mode 100644 core/pkg/workstation/shellgate_test.go diff --git a/core/pkg/workstation/shellallowlist.go b/core/pkg/workstation/shellallowlist.go new file mode 100644 index 000000000..bc98be3cc --- /dev/null +++ b/core/pkg/workstation/shellallowlist.go @@ -0,0 +1,212 @@ +// shellallowlist.go — user-editable shell allowlist file with an mtime cache. +// +// Attribution: the file format tolerance (bare array / {"allowedCommands"} / +// truthy map) and the mtime-cached read are adapted from Rowboat (Apache-2.0), +// apps/cli/src/config/security.ts. This is an original Go implementation; no +// Rowboat code is copied verbatim. +// +// Fail-closed deviations from Rowboat: +// - A corrupt or unreadable allowlist file is an error, not a silent fallback +// to the defaults. Callers must treat the error as "everything blocked". +// - The cache also compares file size alongside mtime, so a rewrite that +// preserves mtime but changes length still reloads. +package workstation + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// DefaultShellAllowlist mirrors the Rowboat default: a minimal read-only set +// seeded on first use. +var DefaultShellAllowlist = []string{ + "cat", + "curl", + "date", + "echo", + "grep", + "jq", + "ls", + "pwd", + "yq", + "whoami", +} + +// ShellAllowlistFilename is the allowlist file name under the workstation +// data directory. +const ShellAllowlistFilename = "shell-allowlist.json" + +// DefaultShellAllowlistPath returns the default allowlist path inside the +// given data directory (e.g. defaultSetupDataDir()). +func DefaultShellAllowlistPath(dataDir string) string { + return filepath.Join(dataDir, "workstation", ShellAllowlistFilename) +} + +// ShellAllowlistStore reads a user-editable JSON allowlist file and caches it +// by modification time (and size). It is safe for concurrent use. +type ShellAllowlistStore struct { + path string + + mu sync.Mutex + cached []string + cachedMtime time.Time + cachedSize int64 + cachePresent bool +} + +// NewShellAllowlistStore creates a store rooted at path. +func NewShellAllowlistStore(path string) *ShellAllowlistStore { + return &ShellAllowlistStore{path: path} +} + +// Path returns the allowlist file path. +func (s *ShellAllowlistStore) Path() string { + return s.path +} + +// Allowlist returns the current allowlist, reloading the file when its mtime +// or size changed since the last successful read. A missing file is seeded +// with DefaultShellAllowlist. Parse and I/O failures return an error — callers +// must fail closed. +func (s *ShellAllowlistStore) Allowlist() ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + info, err := os.Stat(s.path) + if err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("stat shell allowlist %s: %w", s.path, err) + } + if err := s.seedLocked(); err != nil { + return nil, err + } + info, err = os.Stat(s.path) + if err != nil { + return nil, fmt.Errorf("stat seeded shell allowlist %s: %w", s.path, err) + } + } + + if s.cachePresent && info.ModTime().Equal(s.cachedMtime) && info.Size() == s.cachedSize { + return append([]string(nil), s.cached...), nil + } + + allowlist, err := readShellAllowlistFile(s.path) + if err != nil { + return nil, err + } + s.cached = allowlist + s.cachedMtime = info.ModTime() + s.cachedSize = info.Size() + s.cachePresent = true + return append([]string(nil), s.cached...), nil +} + +// Reset drops the cached allowlist so the next Allowlist call re-reads the +// file. Primarily for tests. +func (s *ShellAllowlistStore) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.cached = nil + s.cachedMtime = time.Time{} + s.cachedSize = 0 + s.cachePresent = false +} + +// seedLocked writes the default allowlist to a missing file with restrictive +// permissions (directory 0700, file 0600). +func (s *ShellAllowlistStore) seedLocked() error { + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create shell allowlist directory %s: %w", dir, err) + } + data, err := json.MarshalIndent(DefaultShellAllowlist, "", " ") + if err != nil { + return fmt.Errorf("encode default shell allowlist: %w", err) + } + if err := os.WriteFile(s.path, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("seed shell allowlist %s: %w", s.path, err) + } + return nil +} + +// readShellAllowlistFile parses the allowlist file. Accepted forms mirror the +// Rowboat security config: +// - a bare JSON array: ["ls", "cat"] +// - an object with an allowedCommands array: {"allowedCommands": ["ls"]} +// - a truthy map: {"ls": true, "rm": false} → ["ls"] +func readShellAllowlistFile(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read shell allowlist %s: %w", path, err) + } + var payload any + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("parse shell allowlist %s: %w", path, err) + } + switch value := payload.(type) { + case []any: + return normalizeShellAllowlist(value), nil + case map[string]any: + if raw, ok := value["allowedCommands"]; ok { + entries, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("parse shell allowlist %s: allowedCommands must be an array", path) + } + return normalizeShellAllowlist(entries), nil + } + var truthy []any + for key, entry := range value { + if jsonTruthy(entry) { + truthy = append(truthy, key) + } + } + return normalizeShellAllowlist(truthy), nil + default: + return nil, fmt.Errorf("parse shell allowlist %s: expected array or object", path) + } +} + +// jsonTruthy mirrors JavaScript truthiness for decoded JSON values. +func jsonTruthy(value any) bool { + switch v := value.(type) { + case nil: + return false + case bool: + return v + case float64: + return v != 0 + case string: + return v != "" + default: + return true + } +} + +// normalizeShellAllowlist keeps string entries only, trims, lowercases, +// de-duplicates, and sorts. +func normalizeShellAllowlist(entries []any) []string { + seen := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + text, ok := entry.(string) + if !ok { + continue + } + normalized := strings.ToLower(strings.TrimSpace(text)) + if normalized == "" { + continue + } + seen[normalized] = struct{}{} + } + out := make([]string, 0, len(seen)) + for name := range seen { + out = append(out, name) + } + sort.Strings(out) + return out +} diff --git a/core/pkg/workstation/shellgate.go b/core/pkg/workstation/shellgate.go new file mode 100644 index 000000000..19feefb26 --- /dev/null +++ b/core/pkg/workstation/shellgate.go @@ -0,0 +1,244 @@ +// shellgate.go — command-name extraction and the escalating shell gate for the +// workstation boundary. +// +// Attribution: the extraction and allowlist semantics implemented here are +// adapted from Rowboat (Apache-2.0), apps/cli/src/application/lib/command-executor.ts +// (extractCommandNames / isBlocked). This file is an original Go implementation +// of those mechanisms for the HELM workstation boundary; no Rowboat code is +// copied verbatim. +// +// Deliberate hardening deviations from the Rowboat semantics (fail-closed +// beats convenient): +// - Wrapper unwrapping is recursive: `sudo env time rm x` extracts +// {sudo, env, time, rm} instead of only the wrapper and its immediate next +// token. More names must be allowlisted, never fewer. +// - After a wrapper, leading ENV=value assignments and bare `-` flags are +// skipped before resolving the wrapped command, so `env FOO=1 rm x` +// extracts {env, rm} (Rowboat extracts {env, "foo=1"}, which blocks by +// accident rather than by policy). Flags that take separate values +// (e.g. `sudo -u root rm x`) may surface the value as a command name; +// that false positive fails closed and is accepted. +// - Unknown gate profiles normalize to production (deny), never to dev. +package workstation + +import ( + "regexp" + "sort" + "strings" +) + +// commandSplitPattern splits a shell command line into segments at every +// construct that can start a new command: pipes, logical operators, command +// separators, background execution, command substitution (backticks and +// $(...)), and subshells. Order matters: `||` and `&&` must precede their +// single-character prefixes so the leftmost-longest alternation consumes the +// right token. Without `&`, backtick, `$(`, and the subshell parens, +// `echo hi & rm /x`, `echo `+"`rm /x`"+`, and `echo $(rm /x)` would slip past +// the gate with only `echo` allowlisted. +var commandSplitPattern = regexp.MustCompile(`\|\||&&|&|;|\||\n|` + "`" + `|\$\(|\(|\)`) + +// envAssignmentPattern matches leading ENV=value prefixes that are not command +// names (e.g. `FOO=bar ls`). +var envAssignmentPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`) + +// wrapperCommands are command wrappers whose first real argument is itself a +// command that must also be allowlisted. +var wrapperCommands = map[string]struct{}{ + "sudo": {}, + "env": {}, + "time": {}, + "command": {}, +} + +// ExtractCommandNames returns the sorted, de-duplicated, lowercased set of +// command names a shell command line would invoke. It is robust to chaining +// (&&, ||, |, ;, &), command substitution (backticks, $(...)), subshells, +// leading ENV=value assignments, and sudo/env/time/command wrappers. +func ExtractCommandNames(command string) []string { + discovered := make(map[string]struct{}) + for _, segment := range commandSplitPattern.Split(command, -1) { + tokens := strings.Fields(segment) + if len(tokens) == 0 { + continue + } + index := 0 + for index < len(tokens) && envAssignmentPattern.MatchString(tokens[index]) { + index++ + } + if index >= len(tokens) { + continue + } + primary := sanitizeCommandToken(tokens[index]) + if primary == "" { + continue + } + discovered[primary] = struct{}{} + if _, isWrapper := wrapperCommands[primary]; isWrapper { + for _, wrapped := range unwrapWrappedCommands(tokens[index+1:]) { + discovered[wrapped] = struct{}{} + } + } + } + names := make([]string, 0, len(discovered)) + for name := range discovered { + names = append(names, name) + } + sort.Strings(names) + if len(names) == 0 { + return nil + } + return names +} + +// unwrapWrappedCommands resolves the command names hidden behind one or more +// nested wrappers, including the intermediate wrappers themselves. Leading +// ENV=value assignments and bare `-` flags after a wrapper are skipped. +func unwrapWrappedCommands(tokens []string) []string { + var out []string + for i := 0; i < len(tokens); i++ { + token := tokens[i] + if envAssignmentPattern.MatchString(token) { + continue + } + if strings.HasPrefix(token, "-") { + // Bare wrapper flag (e.g. `sudo -E`, `time -p`). Flags that take a + // separate value are not unwrapped; the value may surface as a + // command name, which fails closed. + continue + } + name := sanitizeCommandToken(token) + if name == "" { + continue + } + out = append(out, name) + if _, isWrapper := wrapperCommands[name]; isWrapper { + continue + } + break + } + return out +} + +func sanitizeCommandToken(token string) string { + return strings.ToLower(strings.Trim(strings.TrimSpace(token), `'"`)) +} + +// BlockedCommandNames returns the invoked command names that are not present +// in the allowlist. Semantics mirror Rowboat's isBlocked: an empty allowlist +// blocks everything, and `*` allows everything. Allowlist entries are +// normalized (trimmed, lowercased) before comparison. +func BlockedCommandNames(command string, allowlist []string) []string { + invoked := ExtractCommandNames(command) + if len(invoked) == 0 { + return nil + } + if len(allowlist) == 0 { + return invoked + } + allowed := make(map[string]struct{}, len(allowlist)) + for _, entry := range allowlist { + if normalized := sanitizeCommandToken(entry); normalized != "" { + allowed[normalized] = struct{}{} + } + } + if _, wildcard := allowed["*"]; wildcard { + return nil + } + var blocked []string + for _, name := range invoked { + if _, ok := allowed[name]; !ok { + blocked = append(blocked, name) + } + } + return blocked +} + +// ShellGateProfile selects the failure mode of the shell gate. +type ShellGateProfile string + +const ( + // ShellGateProfileProduction fails closed: blocked commands are denied. + ShellGateProfileProduction ShellGateProfile = "production" + // ShellGateProfileDev escalates: blocked commands become pending approvals + // instead of hard failures. + ShellGateProfileDev ShellGateProfile = "dev" +) + +// NormalizeShellGateProfile maps a raw profile string to a gate profile. +// Anything other than "dev" resolves to production — fail closed. +func NormalizeShellGateProfile(raw string) ShellGateProfile { + if strings.EqualFold(strings.TrimSpace(raw), string(ShellGateProfileDev)) { + return ShellGateProfileDev + } + return ShellGateProfileProduction +} + +// ShellGateVerdict is the outcome of a shell gate evaluation. +type ShellGateVerdict string + +const ( + // ShellGateVerdictAllow — every invoked command name is allowlisted. + ShellGateVerdictAllow ShellGateVerdict = "allow" + // ShellGateVerdictPendingApproval — dev profile escalation: the command is + // not executed; it requires an approval ceremony first. + ShellGateVerdictPendingApproval ShellGateVerdict = "pending_approval" + // ShellGateVerdictDeny — production profile fail-closed denial. + ShellGateVerdictDeny ShellGateVerdict = "deny" +) + +// ShellGateDecision is the result of gating one shell command line. +type ShellGateDecision struct { + Verdict ShellGateVerdict `json:"verdict"` + Profile ShellGateProfile `json:"profile"` + Command string `json:"command"` + Invoked []string `json:"invoked_commands"` + Blocked []string `json:"blocked_commands,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// GateShellCommand evaluates a shell command line against an allowlist under +// the given profile. Blocked commands are denied in the production profile +// (fail closed) and escalated to a pending approval in the dev profile. +func GateShellCommand(profile ShellGateProfile, command string, allowlist []string) ShellGateDecision { + decision := ShellGateDecision{ + Profile: profile, + Command: command, + Invoked: ExtractCommandNames(command), + Blocked: BlockedCommandNames(command, allowlist), + } + if len(decision.Blocked) == 0 { + decision.Verdict = ShellGateVerdictAllow + return decision + } + if profile == ShellGateProfileDev { + decision.Verdict = ShellGateVerdictPendingApproval + decision.Reason = "blocked shell commands escalate to a pending approval in the dev profile: " + strings.Join(decision.Blocked, ", ") + return decision + } + decision.Verdict = ShellGateVerdictDeny + decision.Reason = "blocked shell commands are denied in the production profile: " + strings.Join(decision.Blocked, ", ") + return decision +} + +// GateShellCommandWithStore loads the allowlist from the store and gates the +// command. A store failure fails closed: production denies, dev escalates, +// with every invoked command treated as blocked. +func GateShellCommandWithStore(profile ShellGateProfile, command string, store *ShellAllowlistStore) ShellGateDecision { + allowlist, err := store.Allowlist() + if err == nil { + return GateShellCommand(profile, command, allowlist) + } + decision := ShellGateDecision{ + Profile: profile, + Command: command, + Invoked: ExtractCommandNames(command), + Blocked: ExtractCommandNames(command), + Reason: "shell allowlist unavailable, failing closed: " + err.Error(), + } + if profile == ShellGateProfileDev { + decision.Verdict = ShellGateVerdictPendingApproval + return decision + } + decision.Verdict = ShellGateVerdictDeny + return decision +} diff --git a/core/pkg/workstation/shellgate_test.go b/core/pkg/workstation/shellgate_test.go new file mode 100644 index 000000000..fdfa2d949 --- /dev/null +++ b/core/pkg/workstation/shellgate_test.go @@ -0,0 +1,311 @@ +package workstation + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "testing" + "time" +) + +func TestExtractCommandNames(t *testing.T) { + cases := []struct { + name string + command string + want []string + }{ + {"simple", "ls -la", []string{"ls"}}, + {"pipe", "cat f | grep x", []string{"cat", "grep"}}, + {"and", "echo a && rm b", []string{"echo", "rm"}}, + {"or", "false || echo ok", []string{"echo", "false"}}, + {"or is not two pipes", "cat a.json || cat b.json", []string{"cat"}}, + {"semicolon", "ls; pwd", []string{"ls", "pwd"}}, + {"background", "sleep 1 & rm -rf /tmp/x", []string{"rm", "sleep"}}, + {"backticks", "echo `rm /x`", []string{"echo", "rm"}}, + {"dollar paren", "echo $(rm /x)", []string{"echo", "rm"}}, + {"subshell", "(rm /x)", []string{"rm"}}, + {"subshell chained", "echo hi && (cd /tmp && make)", []string{"cd", "echo", "make"}}, + {"newline", "ls\nrm /x", []string{"ls", "rm"}}, + {"env prefix", "FOO=bar ls", []string{"ls"}}, + {"multiple env prefixes", "FOO=bar BAZ=qux sudo rm /x", []string{"rm", "sudo"}}, + {"env prefix only", "FOO=bar", nil}, + {"sudo wrapper", "sudo rm /x", []string{"rm", "sudo"}}, + {"env wrapper", "env rm /x", []string{"env", "rm"}}, + {"time wrapper", "time ls", []string{"ls", "time"}}, + {"command wrapper", "command ls", []string{"command", "ls"}}, + {"nested wrappers", "sudo time rm /x", []string{"rm", "sudo", "time"}}, + {"nested wrappers with env", "sudo env FOO=1 rm /x", []string{"env", "rm", "sudo"}}, + {"env wrapper skips assignments", "env FOO=bar rm /x", []string{"env", "rm"}}, + {"wrapper with flag", "time -p ls", []string{"ls", "time"}}, + {"wrapper alone", "sudo", []string{"sudo"}}, + {"quoted command", "'rm' /x", []string{"rm"}}, + {"double quoted command", `"curl" https://example.com`, []string{"curl"}}, + {"uppercase lowered", "SUDO RM /x", []string{"rm", "sudo"}}, + {"mixed chaining", "cat a | grep b && jq . || echo done", []string{"cat", "echo", "grep", "jq"}}, + {"substitution inside args", "ls $(pwd)/x", []string{"/x", "ls", "pwd"}}, + {"empty", "", nil}, + {"whitespace", " ", nil}, + {"separator only", "|", nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ExtractCommandNames(tc.command) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("ExtractCommandNames(%q) = %v, want %v", tc.command, got, tc.want) + } + }) + } +} + +func TestBlockedCommandNames(t *testing.T) { + allowlist := []string{"cat", "grep", "ls", "sudo", "echo"} + cases := []struct { + name string + command string + allowlist []string + want []string + }{ + {"all allowed", "cat f | grep x", allowlist, nil}, + {"one blocked", "cat f | rm x", allowlist, []string{"rm"}}, + {"blocked behind wrapper", "sudo rm /x", allowlist, []string{"rm"}}, + {"blocked behind substitution", "echo $(rm /x)", allowlist, []string{"rm"}}, + {"wildcard allows everything", "rm -rf /", []string{"*"}, nil}, + {"empty allowlist blocks everything", "ls", nil, []string{"ls"}}, + {"no commands blocks nothing", "", allowlist, nil}, + {"allowlist entries normalized", "LS -la", []string{" ls "}, nil}, + {"wrapper not allowlisted", "sudo ls", []string{"ls"}, []string{"sudo"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := BlockedCommandNames(tc.command, tc.allowlist) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("BlockedCommandNames(%q, %v) = %v, want %v", tc.command, tc.allowlist, got, tc.want) + } + }) + } +} + +func TestGateShellCommandProfiles(t *testing.T) { + allowlist := []string{"ls"} + + t.Run("allowed in production", func(t *testing.T) { + decision := GateShellCommand(ShellGateProfileProduction, "ls -la", allowlist) + if decision.Verdict != ShellGateVerdictAllow { + t.Fatalf("verdict = %s, want allow", decision.Verdict) + } + }) + + t.Run("allowed in dev", func(t *testing.T) { + decision := GateShellCommand(ShellGateProfileDev, "ls", allowlist) + if decision.Verdict != ShellGateVerdictAllow { + t.Fatalf("verdict = %s, want allow", decision.Verdict) + } + }) + + t.Run("blocked in production denies", func(t *testing.T) { + decision := GateShellCommand(ShellGateProfileProduction, "rm -rf /", allowlist) + if decision.Verdict != ShellGateVerdictDeny { + t.Fatalf("verdict = %s, want deny", decision.Verdict) + } + if !reflect.DeepEqual(decision.Blocked, []string{"rm"}) { + t.Fatalf("blocked = %v, want [rm]", decision.Blocked) + } + }) + + t.Run("blocked in dev escalates to pending approval", func(t *testing.T) { + decision := GateShellCommand(ShellGateProfileDev, "rm -rf /", allowlist) + if decision.Verdict != ShellGateVerdictPendingApproval { + t.Fatalf("verdict = %s, want pending_approval", decision.Verdict) + } + if decision.Reason == "" { + t.Fatal("escalation must carry a reason") + } + }) + + t.Run("unknown profile fails closed as production", func(t *testing.T) { + if got := NormalizeShellGateProfile("staging"); got != ShellGateProfileProduction { + t.Fatalf("NormalizeShellGateProfile(staging) = %s, want production", got) + } + decision := GateShellCommand(NormalizeShellGateProfile("STAGING"), "rm x", allowlist) + if decision.Verdict != ShellGateVerdictDeny { + t.Fatalf("verdict = %s, want deny for unknown profile", decision.Verdict) + } + }) +} + +func writeShellAllowlist(t *testing.T, path string, payload any, mode os.FileMode) time.Time { + t.Helper() + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal allowlist: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, data, mode); err != nil { + t.Fatalf("write allowlist: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat allowlist: %v", err) + } + return info.ModTime() +} + +func TestShellAllowlistStoreSeedsDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), "workstation", ShellAllowlistFilename) + store := NewShellAllowlistStore(path) + + got, err := store.Allowlist() + if err != nil { + t.Fatalf("Allowlist: %v", err) + } + want := append([]string(nil), DefaultShellAllowlist...) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("seeded allowlist = %v, want %v", got, want) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("seeded file missing: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("seeded file mode = %o, want 600", info.Mode().Perm()) + } +} + +func TestShellAllowlistStoreFormats(t *testing.T) { + cases := []struct { + name string + payload any + want []string + }{ + {"bare array", []string{"LS", " cat ", "ls", ""}, []string{"cat", "ls"}}, + {"allowedCommands object", map[string]any{"allowedCommands": []string{"JQ", "ls"}}, []string{"jq", "ls"}}, + {"truthy map", map[string]any{"ls": true, "rm": false, "cat": 1, "dd": 0, "pwd": "yes", "xargs": ""}, []string{"cat", "ls", "pwd"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), ShellAllowlistFilename) + writeShellAllowlist(t, path, tc.payload, 0o600) + got, err := NewShellAllowlistStore(path).Allowlist() + if err != nil { + t.Fatalf("Allowlist: %v", err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("Allowlist = %v, want %v", got, tc.want) + } + }) + } +} + +func TestShellAllowlistStoreMtimeReload(t *testing.T) { + path := filepath.Join(t.TempDir(), ShellAllowlistFilename) + store := NewShellAllowlistStore(path) + + firstMtime := writeShellAllowlist(t, path, []string{"ls"}, 0o600) + got, err := store.Allowlist() + if err != nil { + t.Fatalf("Allowlist: %v", err) + } + if !reflect.DeepEqual(got, []string{"ls"}) { + t.Fatalf("Allowlist = %v, want [ls]", got) + } + + // Rewrite with the same mtime and size: cache must be served. + if err := os.WriteFile(path, []byte(`["dd"]`), 0o600); err != nil { + t.Fatalf("rewrite allowlist: %v", err) + } + if err := os.Chtimes(path, firstMtime, firstMtime); err != nil { + t.Fatalf("chtimes: %v", err) + } + got, err = store.Allowlist() + if err != nil { + t.Fatalf("Allowlist after same-mtime rewrite: %v", err) + } + if !reflect.DeepEqual(got, []string{"ls"}) { + t.Fatalf("cached Allowlist = %v, want [ls]", got) + } + + // Rewrite with a newer mtime: cache must reload. + secondMtime := firstMtime.Add(2 * time.Second) + if err := os.WriteFile(path, []byte(`["dd","ls"]`), 0o600); err != nil { + t.Fatalf("rewrite allowlist: %v", err) + } + if err := os.Chtimes(path, secondMtime, secondMtime); err != nil { + t.Fatalf("chtimes: %v", err) + } + got, err = store.Allowlist() + if err != nil { + t.Fatalf("Allowlist after mtime bump: %v", err) + } + if !reflect.DeepEqual(got, []string{"dd", "ls"}) { + t.Fatalf("reloaded Allowlist = %v, want [dd ls]", got) + } +} + +func TestShellAllowlistStoreFailClosed(t *testing.T) { + t.Run("corrupt file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), ShellAllowlistFilename) + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatalf("write corrupt allowlist: %v", err) + } + if _, err := NewShellAllowlistStore(path).Allowlist(); err == nil { + t.Fatal("corrupt allowlist must fail closed with an error") + } + }) + + t.Run("scalar payload", func(t *testing.T) { + path := filepath.Join(t.TempDir(), ShellAllowlistFilename) + if err := os.WriteFile(path, []byte(`"ls"`), 0o600); err != nil { + t.Fatalf("write scalar allowlist: %v", err) + } + if _, err := NewShellAllowlistStore(path).Allowlist(); err == nil { + t.Fatal("scalar allowlist must fail closed with an error") + } + }) + + t.Run("gate fails closed on store error", func(t *testing.T) { + path := filepath.Join(t.TempDir(), ShellAllowlistFilename) + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatalf("write corrupt allowlist: %v", err) + } + store := NewShellAllowlistStore(path) + + prod := GateShellCommandWithStore(ShellGateProfileProduction, "ls", store) + if prod.Verdict != ShellGateVerdictDeny { + t.Fatalf("production verdict = %s, want deny", prod.Verdict) + } + dev := GateShellCommandWithStore(ShellGateProfileDev, "ls", store) + if dev.Verdict != ShellGateVerdictPendingApproval { + t.Fatalf("dev verdict = %s, want pending_approval", dev.Verdict) + } + }) +} + +func TestGateShellCommandWithStoreEscalationFlow(t *testing.T) { + path := filepath.Join(t.TempDir(), ShellAllowlistFilename) + writeShellAllowlist(t, path, []string{"ls", "cat"}, 0o600) + store := NewShellAllowlistStore(path) + + // Step 1: a blocked command escalates in dev. + blocked := GateShellCommandWithStore(ShellGateProfileDev, "cat f | rm x", store) + if blocked.Verdict != ShellGateVerdictPendingApproval { + t.Fatalf("verdict = %s, want pending_approval", blocked.Verdict) + } + if !reflect.DeepEqual(blocked.Blocked, []string{"rm"}) { + t.Fatalf("blocked = %v, want [rm]", blocked.Blocked) + } + + // Step 2: the operator approves by adding rm to the user-editable allowlist. + writeShellAllowlist(t, path, []string{"ls", "cat", "rm"}, 0o600) + + // Step 3: the same command now passes the gate without a store reset — + // the mtime cache must have reloaded. + allowed := GateShellCommandWithStore(ShellGateProfileDev, "cat f | rm x", store) + if allowed.Verdict != ShellGateVerdictAllow { + t.Fatalf("verdict after allowlist edit = %s, want allow", allowed.Verdict) + } +} From 8a8e6cfafdfb0d0de7925c80f729c2723490827f Mon Sep 17 00:00:00 2001 From: Mindburn Labs Date: Fri, 24 Jul 2026 15:55:24 +0300 Subject: [PATCH 02/12] feat(cli): watch subcommand + workstation gate command surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (a) helm-ai-kernel watch — terminal-native live approval watcher (bubbletea TUI, adapted from Rowboat's Ink ui.tsx, Apache-2.0): - Pending items always derive from server state (GET /api/v1/approvals); a failed refresh clears the list and disables actions (fail-closed). - a/d approve/deny hotkeys POST /api/v1/approvals/{id}/{approve,deny} with the operator actor; r refresh, arrows/jk navigate, q quit. - --once/--json snapshot modes; automatic snapshot fallback on non-TTY. - Auth via HELM_ADMIN_API_KEY env or --api-key-file (0600); no argv secrets. URL via --url or HELM_KERNEL_URL (default 127.0.0.1:8080). (b) helm-ai-kernel workstation gate — CLI front-end for the escalating shell gate: --profile dev|production (unknown profiles fail closed to production), --allowlist path, exit codes 0/3/126 for allow/pending_approval/deny, and --request-approval which turns a dev escalation into a real pending approval ceremony on the server (drainable from watch). New dep: github.com/charmbracelet/bubbletea v1.3.10 (only direct addition; no TUI framework existed in go.mod). Rendering is plain-text to keep the transitive footprint minimal. Tests: httptest client coverage (auth header, 401, transition paths, fail-closed without key), TUI model unit tests (pending filter/sort, fail-closed actions on fetch error, approve/deny flow, transition error surfacing, quit, view rendering, snapshot renderer), and gate command tests (allow/deny/escalate exits, unknown profile fail-closed, corrupt allowlist fail-closed, ceremony creation, server-down error). Signed-off-by: Mindburn Labs --- core/cmd/helm-ai-kernel/watch_client.go | 152 +++++++++++ core/cmd/helm-ai-kernel/watch_client_test.go | 126 +++++++++ core/cmd/helm-ai-kernel/watch_cmd.go | 151 ++++++++++ core/cmd/helm-ai-kernel/watch_model.go | 252 +++++++++++++++++ core/cmd/helm-ai-kernel/watch_model_test.go | 258 ++++++++++++++++++ core/cmd/helm-ai-kernel/workstation_cmd.go | 6 +- .../helm-ai-kernel/workstation_gate_cmd.go | 130 +++++++++ .../workstation_gate_cmd_test.go | 176 ++++++++++++ core/go.mod | 16 ++ core/go.sum | 34 +++ 10 files changed, 1299 insertions(+), 2 deletions(-) create mode 100644 core/cmd/helm-ai-kernel/watch_client.go create mode 100644 core/cmd/helm-ai-kernel/watch_client_test.go create mode 100644 core/cmd/helm-ai-kernel/watch_cmd.go create mode 100644 core/cmd/helm-ai-kernel/watch_model.go create mode 100644 core/cmd/helm-ai-kernel/watch_model_test.go create mode 100644 core/cmd/helm-ai-kernel/workstation_gate_cmd.go create mode 100644 core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go diff --git a/core/cmd/helm-ai-kernel/watch_client.go b/core/cmd/helm-ai-kernel/watch_client.go new file mode 100644 index 000000000..71fe905eb --- /dev/null +++ b/core/cmd/helm-ai-kernel/watch_client.go @@ -0,0 +1,152 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" +) + +// approvalClient is the client surface of the kernel approval API consumed by +// `watch` and `workstation gate --request-approval`. Pending items always +// derive from server state; implementations must fail closed on transport and +// status errors. +type approvalClient interface { + ListApprovals(ctx context.Context) ([]contracts.ApprovalCeremony, error) + TransitionApproval(ctx context.Context, approvalID, action, actor, reason string) (contracts.ApprovalCeremony, error) + CreateApproval(ctx context.Context, req createApprovalRequest) (contracts.ApprovalCeremony, error) +} + +// createApprovalRequest mirrors the POST /api/v1/approvals payload in +// contract_routes.go. +type createApprovalRequest struct { + ApprovalID string `json:"approval_id,omitempty"` + Subject string `json:"subject"` + Action string `json:"action"` + RequestedBy string `json:"requested_by"` + Approvers []string `json:"approvers,omitempty"` + Quorum int `json:"quorum,omitempty"` + Reason string `json:"reason,omitempty"` + ReceiptID string `json:"receipt_id,omitempty"` +} + +const approvalAPIBasePath = "/api/v1/approvals" + +var errApprovalAPIKeyMissing = errors.New("admin API key is required (set HELM_ADMIN_API_KEY or --api-key-file)") + +// approvalHTTPClient talks to the kernel server approval routes with the +// standalone admin API key (Authorization: Bearer). +type approvalHTTPClient struct { + baseURL *url.URL + apiKey string + httpClient *http.Client +} + +func newApprovalHTTPClient(rawURL, apiKey string) (*approvalHTTPClient, error) { + base := strings.TrimSpace(rawURL) + if base == "" { + return nil, errors.New("server URL is required") + } + parsed, err := url.Parse(base) + if err != nil { + return nil, fmt.Errorf("parse server URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("server URL must be http or https: %q", base) + } + if parsed.Host == "" { + return nil, fmt.Errorf("server URL must include a host: %q", base) + } + return &approvalHTTPClient{ + baseURL: parsed, + apiKey: strings.TrimSpace(apiKey), + httpClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *approvalHTTPClient) ListApprovals(ctx context.Context) ([]contracts.ApprovalCeremony, error) { + var ceremonies []contracts.ApprovalCeremony + if err := c.do(ctx, http.MethodGet, approvalAPIBasePath, nil, &ceremonies); err != nil { + return nil, err + } + return ceremonies, nil +} + +func (c *approvalHTTPClient) TransitionApproval(ctx context.Context, approvalID, action, actor, reason string) (contracts.ApprovalCeremony, error) { + switch action { + case "approve", "deny": + default: + return contracts.ApprovalCeremony{}, fmt.Errorf("unsupported approval transition action %q", action) + } + body := struct { + Actor string `json:"actor"` + Reason string `json:"reason,omitempty"` + }{Actor: actor, Reason: reason} + var ceremony contracts.ApprovalCeremony + path := approvalAPIBasePath + "/" + url.PathEscape(approvalID) + "/" + action + if err := c.do(ctx, http.MethodPost, path, body, &ceremony); err != nil { + return contracts.ApprovalCeremony{}, err + } + return ceremony, nil +} + +func (c *approvalHTTPClient) CreateApproval(ctx context.Context, req createApprovalRequest) (contracts.ApprovalCeremony, error) { + if strings.TrimSpace(req.Subject) == "" || strings.TrimSpace(req.Action) == "" || strings.TrimSpace(req.RequestedBy) == "" { + return contracts.ApprovalCeremony{}, errors.New("approval subject, action, and requested_by are required") + } + var ceremony contracts.ApprovalCeremony + if err := c.do(ctx, http.MethodPost, approvalAPIBasePath, req, &ceremony); err != nil { + return contracts.ApprovalCeremony{}, err + } + return ceremony, nil +} + +func (c *approvalHTTPClient) do(ctx context.Context, method, path string, body, out any) error { + if c.apiKey == "" { + return errApprovalAPIKeyMissing + } + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encode approval request: %w", err) + } + reader = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL.JoinPath(path).String(), reader) + if err != nil { + return fmt.Errorf("build approval request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("approval API %s %s: %w", method, path, err) + } + defer resp.Body.Close() + payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return fmt.Errorf("read approval API response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("approval API %s %s: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(payload))) + } + if out == nil { + return nil + } + if err := json.Unmarshal(payload, out); err != nil { + return fmt.Errorf("decode approval API response: %w", err) + } + return nil +} diff --git a/core/cmd/helm-ai-kernel/watch_client_test.go b/core/cmd/helm-ai-kernel/watch_client_test.go new file mode 100644 index 000000000..15864749e --- /dev/null +++ b/core/cmd/helm-ai-kernel/watch_client_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" +) + +func TestApprovalHTTPClientListApprovals(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != approvalAPIBasePath || r.Method != http.MethodGet { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("Authorization = %q, want Bearer test-key", got) + } + _ = json.NewEncoder(w).Encode([]contracts.ApprovalCeremony{{ + ApprovalID: "ap-1", + Subject: "shell_command", + Action: "shell_operate", + State: contracts.ApprovalCeremonyPending, + RequestedBy: "agent.local", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }}) + })) + defer server.Close() + + client, err := newApprovalHTTPClient(server.URL, "test-key") + if err != nil { + t.Fatalf("newApprovalHTTPClient: %v", err) + } + items, err := client.ListApprovals(context.Background()) + if err != nil { + t.Fatalf("ListApprovals: %v", err) + } + if len(items) != 1 || items[0].ApprovalID != "ap-1" { + t.Fatalf("items = %+v, want one ap-1", items) + } +} + +func TestApprovalHTTPClientListApprovalsUnauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + })) + defer server.Close() + + client, err := newApprovalHTTPClient(server.URL, "wrong-key") + if err != nil { + t.Fatalf("newApprovalHTTPClient: %v", err) + } + if _, err := client.ListApprovals(context.Background()); err == nil || !strings.Contains(err.Error(), "401") { + t.Fatalf("err = %v, want HTTP 401 error", err) + } +} + +func TestApprovalHTTPClientTransition(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != approvalAPIBasePath+"/ap-9/approve" || r.Method != http.MethodPost { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + var body struct { + Actor string `json:"actor"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Actor != "operator.cli" { + t.Fatalf("actor = %q, want operator.cli", body.Actor) + } + _ = json.NewEncoder(w).Encode(contracts.ApprovalCeremony{ + ApprovalID: "ap-9", + Subject: "shell_command", + Action: "shell_operate", + State: contracts.ApprovalCeremonyAllowed, + }) + })) + defer server.Close() + + client, err := newApprovalHTTPClient(server.URL, "test-key") + if err != nil { + t.Fatalf("newApprovalHTTPClient: %v", err) + } + ceremony, err := client.TransitionApproval(context.Background(), "ap-9", "approve", "operator.cli", "ok") + if err != nil { + t.Fatalf("TransitionApproval: %v", err) + } + if ceremony.State != contracts.ApprovalCeremonyAllowed { + t.Fatalf("state = %s, want approved", ceremony.State) + } + if _, err := client.TransitionApproval(context.Background(), "ap-9", "revoke", "operator.cli", ""); err == nil { + t.Fatal("revoke must be rejected client-side") + } +} + +func TestApprovalHTTPClientFailClosedWithoutKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("request must never reach the server without an API key") + })) + defer server.Close() + + client, err := newApprovalHTTPClient(server.URL, "") + if err != nil { + t.Fatalf("newApprovalHTTPClient: %v", err) + } + if _, err := client.ListApprovals(context.Background()); !errors.Is(err, errApprovalAPIKeyMissing) { + t.Fatalf("err = %v, want errApprovalAPIKeyMissing", err) + } +} + +func TestNewApprovalHTTPClientRejectsBadURL(t *testing.T) { + if _, err := newApprovalHTTPClient("ftp://example.com", "k"); err == nil { + t.Fatal("non-http scheme must be rejected") + } + if _, err := newApprovalHTTPClient("http://", "k"); err == nil { + t.Fatal("missing host must be rejected") + } +} diff --git a/core/cmd/helm-ai-kernel/watch_cmd.go b/core/cmd/helm-ai-kernel/watch_cmd.go new file mode 100644 index 000000000..ffc5fa9c2 --- /dev/null +++ b/core/cmd/helm-ai-kernel/watch_cmd.go @@ -0,0 +1,151 @@ +// watch_cmd.go — `helm-ai-kernel watch`: terminal-native live approval +// watcher with approve/deny hotkeys. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + defaultWatchURL = "http://127.0.0.1:8080" + watchURLEnv = "HELM_KERNEL_URL" + watchAdminAPIKeyEnv = "HELM_ADMIN_API_KEY" + defaultWatchInterval = 2 * time.Second +) + +func init() { + Register(Subcommand{ + Name: "watch", + Usage: "Watch live approval state with approve/deny hotkeys (TUI; --once for a snapshot)", + RunFn: runWatchCmd, + }) +} + +func runWatchCmd(args []string, stdout, stderr io.Writer) int { + cmd := flag.NewFlagSet("watch", flag.ContinueOnError) + cmd.SetOutput(stderr) + var rawURL, apiKeyFile, actor string + var interval time.Duration + var once, jsonOut bool + cmd.StringVar(&rawURL, "url", "", "Kernel server URL (default $HELM_KERNEL_URL or "+defaultWatchURL+")") + cmd.StringVar(&apiKeyFile, "api-key-file", "", "Path to a 0600 file containing the admin API key (default $HELM_ADMIN_API_KEY)") + cmd.StringVar(&actor, "actor", "operator.cli", "Actor recorded on approve/deny transitions") + cmd.DurationVar(&interval, "interval", defaultWatchInterval, "Polling interval for server state") + cmd.BoolVar(&once, "once", false, "Print a single snapshot and exit (no TUI)") + cmd.BoolVar(&jsonOut, "json", false, "Print the snapshot as JSON (implies --once)") + if err := cmd.Parse(args); err != nil { + if err == flag.ErrHelp { + return 0 + } + return 2 + } + if rawURL == "" { + rawURL = strings.TrimSpace(os.Getenv(watchURLEnv)) + } + if rawURL == "" { + rawURL = defaultWatchURL + } + if interval <= 0 { + _, _ = fmt.Fprintln(stderr, "Error: --interval must be positive") + return 2 + } + + apiKey, err := resolveWatchAPIKey(apiKeyFile) + if err != nil { + _, _ = fmt.Fprintf(stderr, "Error: %v\n", err) + return 2 + } + client, err := newApprovalHTTPClient(rawURL, apiKey) + if err != nil { + _, _ = fmt.Fprintf(stderr, "Error: %v\n", err) + return 2 + } + + // Snapshot mode: explicit --once/--json, or non-TTY stdout (fail closed to + // a plain snapshot rather than a broken TUI). + if jsonOut || once || !writerIsTerminal(stdout) { + return runWatchSnapshot(client, jsonOut, stdout, stderr) + } + + program := tea.NewProgram(newWatchModel(client, actor, interval)) + if _, err := program.Run(); err != nil { + _, _ = fmt.Fprintf(stderr, "Error: watch TUI failed: %v\n", err) + return 1 + } + return 0 +} + +func runWatchSnapshot(client approvalClient, jsonOut bool, stdout, stderr io.Writer) int { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + items, err := client.ListApprovals(ctx) + if err != nil { + // Fail closed: a failed fetch is an error exit, never an empty list. + _, _ = fmt.Fprintf(stderr, "Error: cannot load approval state: %v\n", err) + return 1 + } + if jsonOut { + data, err := json.MarshalIndent(map[string]any{ + "refreshed_at": time.Now().UTC(), + "pending": filterPendingApprovals(items), + }, "", " ") + if err != nil { + _, _ = fmt.Fprintf(stderr, "Error: encode snapshot: %v\n", err) + return 1 + } + _, _ = fmt.Fprintln(stdout, string(data)) + return 0 + } + renderApprovalSnapshot(stdout, items, time.Now()) + return 0 +} + +// resolveWatchAPIKey reads the admin API key from --api-key-file (0600) or the +// HELM_ADMIN_API_KEY environment variable. Missing key fails closed. +func resolveWatchAPIKey(apiKeyFile string) (string, error) { + if strings.TrimSpace(apiKeyFile) != "" { + info, err := os.Stat(apiKeyFile) + if err != nil { + return "", fmt.Errorf("read API key file: %w", err) + } + if info.Mode().Perm()&0o077 != 0 { + return "", fmt.Errorf("API key file %s must not be readable by group/others (chmod 0600)", apiKeyFile) + } + data, err := os.ReadFile(apiKeyFile) + if err != nil { + return "", fmt.Errorf("read API key file: %w", err) + } + key := strings.TrimSpace(string(data)) + if key == "" { + return "", fmt.Errorf("API key file %s is empty", apiKeyFile) + } + return key, nil + } + key := strings.TrimSpace(os.Getenv(watchAdminAPIKeyEnv)) + if key == "" { + return "", fmt.Errorf("admin API key is required (set %s or --api-key-file)", watchAdminAPIKeyEnv) + } + return key, nil +} + +// writerIsTerminal reports whether w looks like an interactive terminal. +func writerIsTerminal(w io.Writer) bool { + file, ok := w.(*os.File) + if !ok { + return false + } + info, err := file.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} diff --git a/core/cmd/helm-ai-kernel/watch_model.go b/core/cmd/helm-ai-kernel/watch_model.go new file mode 100644 index 000000000..32c528152 --- /dev/null +++ b/core/cmd/helm-ai-kernel/watch_model.go @@ -0,0 +1,252 @@ +// watch_model.go — bubbletea model for `helm-ai-kernel watch`. +// +// Attribution: the keyboard-first approval UX (live pending list with +// approve/deny hotkeys) is adapted from Rowboat (Apache-2.0), +// apps/cli/src/tui/ui.tsx. This is an original Go implementation against the +// HELM approval API; no Rowboat code is copied verbatim. +// +// Fail-closed invariants: +// - Pending items always derive from server state; the model never invents +// or retains stale actionable items. A failed refresh clears the list. +// - Approve/deny are disabled whenever the last refresh failed or a +// transition is in flight. +package main + +import ( + "context" + "fmt" + "io" + "sort" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" +) + +type approvalsFetchedMsg struct { + items []contracts.ApprovalCeremony + err error +} + +type approvalTransitionedMsg struct { + approvalID string + action string + err error +} + +type watchTickMsg time.Time + +// watchModel renders the live approval queue and wires approve/deny hotkeys +// to the kernel approval API. +type watchModel struct { + client approvalClient + actor string + interval time.Duration + + pending []contracts.ApprovalCeremony + selected int + lastErr error + busy bool + refreshedAt time.Time + status string + width int +} + +func newWatchModel(client approvalClient, actor string, interval time.Duration) *watchModel { + if interval <= 0 { + interval = 2 * time.Second + } + return &watchModel{client: client, actor: actor, interval: interval} +} + +func (m *watchModel) Init() tea.Cmd { + return m.fetchCmd() +} + +func (m *watchModel) fetchCmd() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + items, err := m.client.ListApprovals(ctx) + return approvalsFetchedMsg{items: items, err: err} + } +} + +func (m *watchModel) tickCmd() tea.Cmd { + return tea.Tick(m.interval, func(t time.Time) tea.Msg { return watchTickMsg(t) }) +} + +func (m *watchModel) transitionCmd(approvalID, action string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := m.client.TransitionApproval(ctx, approvalID, action, m.actor, "operator decision via helm-ai-kernel watch") + return approvalTransitionedMsg{approvalID: approvalID, action: action, err: err} + } +} + +// actGuard explains why an approve/deny action is currently unavailable, or +// returns "" when the selected item can be transitioned. Fail closed: any +// uncertainty disables the action. +func (m *watchModel) actGuard() string { + if m.busy { + return "an approval transition is already in flight" + } + if m.lastErr != nil { + return "approval actions unavailable: last refresh failed" + } + if len(m.pending) == 0 { + return "no pending approvals" + } + if m.selected < 0 || m.selected >= len(m.pending) { + return "no approval selected" + } + return "" +} + +func (m *watchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch typed := msg.(type) { + case tea.WindowSizeMsg: + m.width = typed.Width + return m, nil + case watchTickMsg: + return m, m.fetchCmd() + case approvalsFetchedMsg: + m.refreshedAt = time.Now() + if typed.err != nil { + // Fail closed: never present stale items as actionable. + m.lastErr = typed.err + m.pending = nil + m.selected = 0 + m.status = "" + return m, m.tickCmd() + } + m.lastErr = nil + m.pending = filterPendingApprovals(typed.items) + if m.selected >= len(m.pending) { + m.selected = len(m.pending) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + return m, m.tickCmd() + case approvalTransitionedMsg: + m.busy = false + if typed.err != nil { + m.status = fmt.Sprintf("%s %s failed: %v", typed.action, typed.approvalID, typed.err) + } else { + m.status = fmt.Sprintf("%s %s recorded", typed.action, typed.approvalID) + } + return m, m.fetchCmd() + case tea.KeyMsg: + switch typed.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "up", "k": + if m.selected > 0 { + m.selected-- + } + return m, nil + case "down", "j": + if m.selected < len(m.pending)-1 { + m.selected++ + } + return m, nil + case "r": + return m, m.fetchCmd() + case "a", "d": + action := "approve" + if typed.String() == "d" { + action = "deny" + } + if guard := m.actGuard(); guard != "" { + m.status = guard + return m, nil + } + m.busy = true + m.status = fmt.Sprintf("%s %s in flight…", action, m.pending[m.selected].ApprovalID) + return m, m.transitionCmd(m.pending[m.selected].ApprovalID, action) + } + } + return m, nil +} + +func (m *watchModel) View() string { + var b strings.Builder + b.WriteString("HELM WATCH — pending approvals\n") + if !m.refreshedAt.IsZero() { + fmt.Fprintf(&b, "refreshed %s · every %s · server-derived state\n", m.refreshedAt.Format("15:04:05"), m.interval) + } + if m.lastErr != nil { + fmt.Fprintf(&b, "ERROR: %v (actions disabled, fail-closed)\n", m.lastErr) + } + b.WriteString("\n") + if len(m.pending) == 0 && m.lastErr == nil { + b.WriteString(" no pending approvals\n") + } + for i, item := range m.pending { + cursor := " " + if i == m.selected { + cursor = "> " + } + fmt.Fprintf(&b, "%s%s\n", cursor, formatApprovalRow(item, time.Now())) + } + b.WriteString("\n") + if m.status != "" { + fmt.Fprintf(&b, "%s\n", m.status) + } + b.WriteString("↑/↓ select · a approve · d deny · r refresh · q quit\n") + return b.String() +} + +// filterPendingApprovals keeps only pending ceremonies, sorted oldest-first so +// the operator drains the queue in request order. +func filterPendingApprovals(items []contracts.ApprovalCeremony) []contracts.ApprovalCeremony { + var pending []contracts.ApprovalCeremony + for _, item := range items { + if item.State == contracts.ApprovalCeremonyPending { + pending = append(pending, item) + } + } + sort.Slice(pending, func(i, j int) bool { + return pending[i].CreatedAt.Before(pending[j].CreatedAt) + }) + return pending +} + +// formatApprovalRow renders one approval ceremony as a compact line. +func formatApprovalRow(item contracts.ApprovalCeremony, now time.Time) string { + age := "unknown" + if !item.CreatedAt.IsZero() { + age = now.Sub(item.CreatedAt).Round(time.Second).String() + } + flags := make([]string, 0, 2) + if item.BreakGlass { + flags = append(flags, "break-glass") + } + if !item.TimelockUntil.IsZero() && now.Before(item.TimelockUntil) { + flags = append(flags, "timelocked") + } + suffix := "" + if len(flags) > 0 { + suffix = " [" + strings.Join(flags, ",") + "]" + } + return fmt.Sprintf("%s %s:%s by %s age %s%s", + item.ApprovalID, item.Subject, item.Action, item.RequestedBy, age, suffix) +} + +// renderApprovalSnapshot prints a non-interactive snapshot of the pending +// queue (used by --once and non-TTY output). +func renderApprovalSnapshot(w io.Writer, items []contracts.ApprovalCeremony, refreshedAt time.Time) { + pending := filterPendingApprovals(items) + fmt.Fprintf(w, "HELM WATCH snapshot — %s\n", refreshedAt.Format(time.RFC3339)) + if len(pending) == 0 { + fmt.Fprintln(w, " no pending approvals") + return + } + for _, item := range pending { + fmt.Fprintf(w, " %s\n", formatApprovalRow(item, refreshedAt)) + } +} diff --git a/core/cmd/helm-ai-kernel/watch_model_test.go b/core/cmd/helm-ai-kernel/watch_model_test.go new file mode 100644 index 000000000..3cf4709b5 --- /dev/null +++ b/core/cmd/helm-ai-kernel/watch_model_test.go @@ -0,0 +1,258 @@ +package main + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" +) + +// fakeApprovalClient implements approvalClient for model tests. +type fakeApprovalClient struct { + items []contracts.ApprovalCeremony + listErr error + transitionErr error + transitionedTo []string +} + +func (f *fakeApprovalClient) ListApprovals(context.Context) ([]contracts.ApprovalCeremony, error) { + return f.items, f.listErr +} + +func (f *fakeApprovalClient) TransitionApproval(_ context.Context, approvalID, action, actor, reason string) (contracts.ApprovalCeremony, error) { + if f.transitionErr != nil { + return contracts.ApprovalCeremony{}, f.transitionErr + } + f.transitionedTo = append(f.transitionedTo, action+":"+approvalID) + state := contracts.ApprovalCeremonyAllowed + if action == "deny" { + state = contracts.ApprovalCeremonyDenied + } + return contracts.ApprovalCeremony{ApprovalID: approvalID, State: state}, nil +} + +func (f *fakeApprovalClient) CreateApproval(context.Context, createApprovalRequest) (contracts.ApprovalCeremony, error) { + return contracts.ApprovalCeremony{}, errors.New("not implemented") +} + +func pendingCeremony(id string, createdAt time.Time) contracts.ApprovalCeremony { + return contracts.ApprovalCeremony{ + ApprovalID: id, + Subject: "shell_command", + Action: "shell_operate", + State: contracts.ApprovalCeremonyPending, + RequestedBy: "agent.local", + CreatedAt: createdAt, + UpdatedAt: createdAt, + } +} + +func updateModel(t *testing.T, m *watchModel, msg tea.Msg) (*watchModel, tea.Cmd) { + t.Helper() + next, cmd := m.Update(msg) + model, ok := next.(*watchModel) + if !ok { + t.Fatalf("Update returned %T, want *watchModel", next) + } + return model, cmd +} + +func TestWatchModelFetchFiltersPending(t *testing.T) { + now := time.Now() + client := &fakeApprovalClient{} + m := newWatchModel(client, "operator.cli", time.Second) + + m, cmd := updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{ + {ApprovalID: "ap-old", State: contracts.ApprovalCeremonyAllowed, CreatedAt: now}, + pendingCeremony("ap-new", now), + pendingCeremony("ap-old-pending", now.Add(-time.Hour)), + }}) + if cmd == nil { + t.Fatal("successful fetch must schedule the next tick") + } + if len(m.pending) != 2 { + t.Fatalf("pending = %d, want 2 (non-pending filtered out)", len(m.pending)) + } + if m.pending[0].ApprovalID != "ap-old-pending" { + t.Fatalf("pending[0] = %s, want oldest first", m.pending[0].ApprovalID) + } +} + +func TestWatchModelFetchErrorFailsClosed(t *testing.T) { + client := &fakeApprovalClient{} + m := newWatchModel(client, "operator.cli", time.Second) + + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{pendingCeremony("ap-1", time.Now())}}) + if len(m.pending) != 1 { + t.Fatalf("setup: pending = %d, want 1", len(m.pending)) + } + + m, cmd := updateModel(t, m, approvalsFetchedMsg{err: errors.New("connection refused")}) + if cmd == nil { + t.Fatal("failed fetch must still schedule the next tick") + } + if m.lastErr == nil { + t.Fatal("lastErr must record the failure") + } + if len(m.pending) != 0 { + t.Fatalf("stale pending items must be cleared, got %d", len(m.pending)) + } + + // Approve key must not fire a transition while the last refresh failed. + m, cmd = updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + if cmd != nil { + t.Fatal("approve must be disabled after a failed refresh (fail closed)") + } + if !strings.Contains(m.status, "unavailable") { + t.Fatalf("status = %q, want an explanation of the disabled action", m.status) + } + if len(client.transitionedTo) != 0 { + t.Fatalf("no transition may fire, got %v", client.transitionedTo) + } +} + +func TestWatchModelApproveDenyFlow(t *testing.T) { + client := &fakeApprovalClient{} + m := newWatchModel(client, "operator.cli", time.Second) + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{ + pendingCeremony("ap-1", time.Now().Add(-time.Minute)), + pendingCeremony("ap-2", time.Now()), + }}) + + // Navigate to the second item and approve it. + m, _ = updateModel(t, m, tea.KeyMsg{Type: tea.KeyDown}) + if m.selected != 1 { + t.Fatalf("selected = %d, want 1", m.selected) + } + m, cmd := updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + if !m.busy { + t.Fatal("busy must be set while a transition is in flight") + } + if cmd == nil { + t.Fatal("approve must produce a transition command") + } + msg := cmd() + transitioned, ok := msg.(approvalTransitionedMsg) + if !ok { + t.Fatalf("transition cmd produced %T, want approvalTransitionedMsg", msg) + } + if transitioned.err != nil || transitioned.action != "approve" || transitioned.approvalID != "ap-2" { + t.Fatalf("transition = %+v, want approve ap-2", transitioned) + } + if got := client.transitionedTo; len(got) != 1 || got[0] != "approve:ap-2" { + t.Fatalf("client transitions = %v, want [approve:ap-2]", got) + } + + m, refresh := updateModel(t, m, transitioned) + if m.busy { + t.Fatal("busy must clear after the transition completes") + } + if refresh == nil { + t.Fatal("completed transition must trigger a refresh") + } + if !strings.Contains(m.status, "approve ap-2") { + t.Fatalf("status = %q, want transition confirmation", m.status) + } + + // Deny the remaining item. + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{pendingCeremony("ap-1", time.Now().Add(-time.Minute))}}) + m, cmd = updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + if cmd == nil { + t.Fatal("deny must produce a transition command") + } + if transitioned := cmd().(approvalTransitionedMsg); transitioned.action != "deny" { + t.Fatalf("action = %s, want deny", transitioned.action) + } +} + +func TestWatchModelTransitionErrorKeepsQueue(t *testing.T) { + client := &fakeApprovalClient{transitionErr: errors.New("conflict")} + m := newWatchModel(client, "operator.cli", time.Second) + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{pendingCeremony("ap-1", time.Now())}}) + + m, cmd := updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + transitioned := cmd().(approvalTransitionedMsg) + m, _ = updateModel(t, m, transitioned) + if !strings.Contains(m.status, "failed") { + t.Fatalf("status = %q, want the failure surfaced", m.status) + } + if m.busy { + t.Fatal("busy must clear even on transition failure") + } +} + +func TestWatchModelQuitAndGuards(t *testing.T) { + client := &fakeApprovalClient{} + m := newWatchModel(client, "operator.cli", time.Second) + + // Empty queue: approve is a no-op with an explanatory status. + m, cmd := updateModel(t, m, approvalsFetchedMsg{items: nil}) + if cmd == nil { + t.Fatal("tick must be scheduled") + } + m, cmd = updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + if cmd != nil { + t.Fatal("approve with an empty queue must not fire") + } + if !strings.Contains(m.status, "no pending approvals") { + t.Fatalf("status = %q", m.status) + } + + // q quits. + _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + if cmd == nil { + t.Fatal("q must produce a quit command") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("quit cmd produced %T, want tea.QuitMsg", cmd()) + } +} + +func TestWatchModelView(t *testing.T) { + client := &fakeApprovalClient{} + m := newWatchModel(client, "operator.cli", time.Second) + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{ + pendingCeremony("ap-1", time.Now().Add(-time.Minute)), + }}) + view := m.View() + for _, want := range []string{"ap-1", "a approve", "d deny", "q quit", "shell_command"} { + if !strings.Contains(view, want) { + t.Fatalf("view missing %q:\n%s", want, view) + } + } + + // Error state renders and announces fail-closed. + m, _ = updateModel(t, m, approvalsFetchedMsg{err: errors.New("boom")}) + view = m.View() + if !strings.Contains(view, "ERROR") || !strings.Contains(view, "fail-closed") { + t.Fatalf("error view missing fail-closed notice:\n%s", view) + } +} + +func TestRenderApprovalSnapshot(t *testing.T) { + var buf bytes.Buffer + items := []contracts.ApprovalCeremony{ + pendingCeremony("ap-1", time.Now().Add(-time.Minute)), + {ApprovalID: "ap-done", State: contracts.ApprovalCeremonyDenied}, + } + renderApprovalSnapshot(&buf, items, time.Now()) + out := buf.String() + if !strings.Contains(out, "ap-1") { + t.Fatalf("snapshot missing pending item:\n%s", out) + } + if strings.Contains(out, "ap-done") { + t.Fatalf("snapshot must only show pending items:\n%s", out) + } + + buf.Reset() + renderApprovalSnapshot(&buf, nil, time.Now()) + if !strings.Contains(buf.String(), "no pending approvals") { + t.Fatalf("empty snapshot:\n%s", buf.String()) + } +} diff --git a/core/cmd/helm-ai-kernel/workstation_cmd.go b/core/cmd/helm-ai-kernel/workstation_cmd.go index 138e475a0..d703d8451 100644 --- a/core/cmd/helm-ai-kernel/workstation_cmd.go +++ b/core/cmd/helm-ai-kernel/workstation_cmd.go @@ -17,7 +17,7 @@ import ( func runWorkstationCmd(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: helm-ai-kernel workstation [flags]") + _, _ = fmt.Fprintln(stderr, "Usage: helm-ai-kernel workstation [flags]") return 2 } switch args[0] { @@ -29,6 +29,8 @@ func runWorkstationCmd(args []string, stdout, stderr io.Writer) int { return runWorkstationDecisionCmd(args[1:], stdout, stderr) case "enforce": return runWorkstationEnforceCmd(args[1:], stdout, stderr) + case "gate": + return runWorkstationGateCmd(args[1:], stdout, stderr) case "verify-decision": return runWorkstationVerifyDecisionCmd(args[1:], stdout, stderr) case "operator": @@ -49,7 +51,7 @@ func runWorkstationCmd(args []string, stdout, stderr io.Writer) int { return runWorkstationCaptureCmd(args[1:], stdout, stderr) default: _, _ = fmt.Fprintf(stderr, "Unknown workstation command: %s\n", args[0]) - _, _ = fmt.Fprintln(stderr, "Usage: helm-ai-kernel workstation [flags]") + _, _ = fmt.Fprintln(stderr, "Usage: helm-ai-kernel workstation [flags]") return 2 } } diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go new file mode 100644 index 000000000..c62477112 --- /dev/null +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go @@ -0,0 +1,130 @@ +// workstation_gate_cmd.go — `helm-ai-kernel workstation gate`: escalating +// shell gate. Blocked commands become pending approvals in the dev profile +// and stay fail-closed denials in the production profile. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/workstation" +) + +const ( + exitGateAllow = 0 + exitGatePendingApproval = 3 + exitGateDeny = 126 +) + +func runWorkstationGateCmd(args []string, stdout, stderr io.Writer) int { + cmd := flag.NewFlagSet("workstation gate", flag.ContinueOnError) + cmd.SetOutput(stderr) + var profileRaw, command, allowlistPath, dataDir string + var jsonOut, requestApproval bool + var rawURL, actor string + cmd.StringVar(&profileRaw, "profile", string(workstation.ShellGateProfileProduction), "Gate profile: dev escalates blocked commands to pending approvals; anything else is production (deny, fail-closed)") + cmd.StringVar(&command, "command", "", "Shell command line to gate (alternative to trailing args after --)") + cmd.StringVar(&allowlistPath, "allowlist", "", "Shell allowlist JSON path (default /workstation/shell-allowlist.json)") + cmd.StringVar(&dataDir, "data-dir", defaultSetupDataDir(), "HELM local data directory") + cmd.BoolVar(&jsonOut, "json", false, "Print the gate decision as JSON") + cmd.BoolVar(&requestApproval, "request-approval", false, "On a pending_approval verdict, create the approval ceremony on the kernel server") + cmd.StringVar(&rawURL, "url", "", "Kernel server URL for --request-approval (default $HELM_KERNEL_URL or "+defaultWatchURL+")") + cmd.StringVar(&actor, "actor", "operator.cli", "Actor recorded on the approval request") + if err := cmd.Parse(args); err != nil { + if err == flag.ErrHelp { + return 0 + } + return 2 + } + if strings.TrimSpace(command) == "" { + command = strings.Join(cmd.Args(), " ") + } + if strings.TrimSpace(command) == "" { + _, _ = fmt.Fprintln(stderr, "Error: --command or trailing command args are required") + return 2 + } + if allowlistPath == "" { + allowlistPath = workstation.DefaultShellAllowlistPath(dataDir) + } + profile := workstation.NormalizeShellGateProfile(profileRaw) + store := workstation.NewShellAllowlistStore(allowlistPath) + decision := workstation.GateShellCommandWithStore(profile, command, store) + + if jsonOut { + data, _ := json.MarshalIndent(decision, "", " ") + _, _ = fmt.Fprintln(stdout, string(data)) + } else { + printGateDecision(stdout, decision, store.Path()) + } + + switch decision.Verdict { + case workstation.ShellGateVerdictAllow: + return exitGateAllow + case workstation.ShellGateVerdictPendingApproval: + if !requestApproval { + return exitGatePendingApproval + } + if err := requestShellGateApproval(decision, rawURL, actor, stdout); err != nil { + _, _ = fmt.Fprintf(stderr, "Error: approval request failed: %v\n", err) + return 1 + } + return exitGatePendingApproval + default: + return exitGateDeny + } +} + +func printGateDecision(stdout io.Writer, decision workstation.ShellGateDecision, allowlistPath string) { + _, _ = fmt.Fprintf(stdout, "%sShell Gate Decision%s\n", ColorBold, ColorReset) + _, _ = fmt.Fprintf(stdout, " verdict: %s\n", decision.Verdict) + _, _ = fmt.Fprintf(stdout, " profile: %s\n", decision.Profile) + _, _ = fmt.Fprintf(stdout, " command: %s\n", decision.Command) + _, _ = fmt.Fprintf(stdout, " invoked: %s\n", strings.Join(decision.Invoked, ", ")) + if len(decision.Blocked) > 0 { + _, _ = fmt.Fprintf(stdout, " blocked: %s\n", strings.Join(decision.Blocked, ", ")) + } + if decision.Reason != "" { + _, _ = fmt.Fprintf(stdout, " reason: %s\n", decision.Reason) + } + _, _ = fmt.Fprintf(stdout, " allowlist: %s\n", allowlistPath) +} + +// requestShellGateApproval turns a pending_approval verdict into an approval +// ceremony on the kernel server, so `watch` can drain it. +func requestShellGateApproval(decision workstation.ShellGateDecision, rawURL, actor string, stdout io.Writer) error { + if strings.TrimSpace(rawURL) == "" { + rawURL = strings.TrimSpace(os.Getenv(watchURLEnv)) + } + if rawURL == "" { + rawURL = defaultWatchURL + } + apiKey, err := resolveWatchAPIKey("") + if err != nil { + return err + } + client, err := newApprovalHTTPClient(rawURL, apiKey) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + ceremony, err := client.CreateApproval(ctx, createApprovalRequest{ + Subject: "shell_command", + Action: "shell_operate", + RequestedBy: actor, + Quorum: 1, + Reason: fmt.Sprintf("shell gate escalation (dev profile): blocked commands [%s] in %q", + strings.Join(decision.Blocked, ", "), decision.Command), + }) + if err != nil { + return err + } + _, _ = fmt.Fprintf(stdout, " approval: %s (pending on server)\n", ceremony.ApprovalID) + return nil +} diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go new file mode 100644 index 000000000..7f999e381 --- /dev/null +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go @@ -0,0 +1,176 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" +) + +func gateTestAllowlist(t *testing.T, entries []string) string { + t.Helper() + dir := t.TempDir() + path := dir + "/shell-allowlist.json" + data, err := json.Marshal(entries) + if err != nil { + t.Fatalf("marshal allowlist: %v", err) + } + if err := writeFile0600(path, data); err != nil { + t.Fatalf("write allowlist: %v", err) + } + return path +} + +func writeFile0600(path string, data []byte) error { + return os.WriteFile(path, data, 0o600) +} + +func runGateForTest(t *testing.T, args ...string) (int, string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + code := runWorkstationGateCmd(args, &stdout, &stderr) + return code, stdout.String(), stderr.String() +} + +func TestWorkstationGateAllow(t *testing.T) { + allowlist := gateTestAllowlist(t, []string{"ls", "cat"}) + code, out, _ := runGateForTest(t, "--allowlist", allowlist, "--command", "cat f | ls") + if code != exitGateAllow { + t.Fatalf("exit = %d, want %d (out: %s)", code, exitGateAllow, out) + } + if !strings.Contains(out, "allow") { + t.Fatalf("output missing allow verdict:\n%s", out) + } +} + +func TestWorkstationGateProductionDeny(t *testing.T) { + allowlist := gateTestAllowlist(t, []string{"ls"}) + code, out, _ := runGateForTest(t, "--allowlist", allowlist, "--command", "ls && rm -rf /tmp/x") + if code != exitGateDeny { + t.Fatalf("exit = %d, want %d (out: %s)", code, exitGateDeny, out) + } + if !strings.Contains(out, "deny") || !strings.Contains(out, "rm") { + t.Fatalf("output missing deny verdict and blocked command:\n%s", out) + } +} + +func TestWorkstationGateDevEscalates(t *testing.T) { + allowlist := gateTestAllowlist(t, []string{"ls"}) + code, out, _ := runGateForTest(t, "--profile", "dev", "--allowlist", allowlist, "--command", "ls && rm -rf /tmp/x") + if code != exitGatePendingApproval { + t.Fatalf("exit = %d, want %d (out: %s)", code, exitGatePendingApproval, out) + } + if !strings.Contains(out, "pending_approval") { + t.Fatalf("output missing pending_approval verdict:\n%s", out) + } +} + +func TestWorkstationGateUnknownProfileFailsClosed(t *testing.T) { + allowlist := gateTestAllowlist(t, []string{"ls"}) + code, _, _ := runGateForTest(t, "--profile", "staging", "--allowlist", allowlist, "--command", "rm x") + if code != exitGateDeny { + t.Fatalf("exit = %d, want %d for unknown profile", code, exitGateDeny) + } +} + +func TestWorkstationGateCorruptAllowlistFailsClosed(t *testing.T) { + path := t.TempDir() + "/shell-allowlist.json" + if err := os.WriteFile(path, []byte("{corrupt"), 0o600); err != nil { + t.Fatalf("write corrupt allowlist: %v", err) + } + code, _, _ := runGateForTest(t, "--allowlist", path, "--command", "ls") + if code != exitGateDeny { + t.Fatalf("exit = %d, want %d for corrupt allowlist in production", code, exitGateDeny) + } + code, _, _ = runGateForTest(t, "--profile", "dev", "--allowlist", path, "--command", "ls") + if code != exitGatePendingApproval { + t.Fatalf("exit = %d, want %d for corrupt allowlist in dev", code, exitGatePendingApproval) + } +} + +func TestWorkstationGateRequestApprovalCreatesCeremony(t *testing.T) { + var gotBody createApprovalRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != approvalAPIBasePath { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(contracts.ApprovalCeremony{ + ApprovalID: "ap-gate-1", + Subject: gotBody.Subject, + Action: gotBody.Action, + State: contracts.ApprovalCeremonyPending, + }) + })) + defer server.Close() + t.Setenv(watchAdminAPIKeyEnv, "test-key") + + allowlist := gateTestAllowlist(t, []string{"ls"}) + code, out, errOut := runGateForTest(t, + "--profile", "dev", + "--allowlist", allowlist, + "--request-approval", + "--url", server.URL, + "--command", "sudo rm /x", + ) + if code != exitGatePendingApproval { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitGatePendingApproval, errOut) + } + if gotBody.Subject != "shell_command" || gotBody.Action != "shell_operate" { + t.Fatalf("approval request = %+v", gotBody) + } + if !strings.Contains(gotBody.Reason, "rm") || !strings.Contains(gotBody.Reason, "sudo") { + t.Fatalf("approval reason must name blocked commands: %q", gotBody.Reason) + } + if !strings.Contains(out, "ap-gate-1") { + t.Fatalf("output must surface the created approval id:\n%s", out) + } +} + +func TestWorkstationGateRequestApprovalServerDown(t *testing.T) { + t.Setenv(watchAdminAPIKeyEnv, "test-key") + allowlist := gateTestAllowlist(t, []string{"ls"}) + code, _, errOut := runGateForTest(t, + "--profile", "dev", + "--allowlist", allowlist, + "--request-approval", + "--url", "http://127.0.0.1:1", + "--command", "rm /x", + ) + if code != 1 { + t.Fatalf("exit = %d, want 1 when the approval request fails", code) + } + if !strings.Contains(errOut, "approval request failed") { + t.Fatalf("stderr missing failure detail:\n%s", errOut) + } +} + +func TestWorkstationGateJSONOutput(t *testing.T) { + allowlist := gateTestAllowlist(t, []string{"ls"}) + code, out, _ := runGateForTest(t, "--allowlist", allowlist, "--json", "--command", "echo $(rm /x)") + if code != exitGateDeny { + t.Fatalf("exit = %d, want %d", code, exitGateDeny) + } + var decision map[string]any + if err := json.Unmarshal([]byte(out), &decision); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out) + } + if decision["verdict"] != "deny" { + t.Fatalf("verdict = %v, want deny", decision["verdict"]) + } +} + +func TestWorkstationGateRequiresCommand(t *testing.T) { + code, _, _ := runGateForTest(t, "--profile", "dev") + if code != 2 { + t.Fatalf("exit = %d, want 2 for missing command", code) + } +} diff --git a/core/go.mod b/core/go.mod index c3ac0b5c3..bbeb2cbca 100644 --- a/core/go.mod +++ b/core/go.mod @@ -12,6 +12,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.14 github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 github.com/cedar-policy/cedar-go v1.6.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/cloudflare/circl v1.6.3 github.com/fxamacker/cbor/v2 v2.9.0 github.com/go-jose/go-jose/v4 v4.1.4 @@ -70,9 +71,15 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.24.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect @@ -80,6 +87,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -101,9 +109,15 @@ require ( github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect @@ -114,6 +128,7 @@ require ( github.com/prometheus/procfs v0.17.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.6.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -126,6 +141,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect diff --git a/core/go.sum b/core/go.sum index d63ddb50b..a7ac329e2 100644 --- a/core/go.sum +++ b/core/go.sum @@ -80,6 +80,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -94,6 +96,18 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= @@ -121,6 +135,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= @@ -196,8 +212,14 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= @@ -206,6 +228,12 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8= github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -233,6 +261,9 @@ github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfS github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= @@ -269,6 +300,8 @@ github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMc github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= @@ -321,6 +354,7 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= From b170070d7434900ef555f7d9faed706d358a3e49 Mon Sep 17 00:00:00 2001 From: Mindburn Labs Date: Wed, 29 Jul 2026 20:26:24 +0300 Subject: [PATCH 03/12] fix(shellgate): bind approvals and close unsafe paths Signed-off-by: Mindburn Labs --- core/cmd/helm-ai-kernel/watch_client.go | 18 ++ core/cmd/helm-ai-kernel/watch_client_test.go | 3 + core/cmd/helm-ai-kernel/watch_cmd.go | 5 +- core/cmd/helm-ai-kernel/watch_model.go | 62 ++++- core/cmd/helm-ai-kernel/watch_model_test.go | 23 ++ .../helm-ai-kernel/workstation_gate_cmd.go | 72 ++++- .../workstation_gate_cmd_test.go | 68 +++++ core/pkg/workstation/shellallowlist.go | 42 ++- core/pkg/workstation/shellapproval.go | 122 +++++++++ core/pkg/workstation/shellgate.go | 253 +++++++++++++++--- core/pkg/workstation/shellgate_test.go | 18 +- 11 files changed, 628 insertions(+), 58 deletions(-) create mode 100644 core/pkg/workstation/shellapproval.go diff --git a/core/cmd/helm-ai-kernel/watch_client.go b/core/cmd/helm-ai-kernel/watch_client.go index 71fe905eb..681870d5f 100644 --- a/core/cmd/helm-ai-kernel/watch_client.go +++ b/core/cmd/helm-ai-kernel/watch_client.go @@ -65,6 +65,12 @@ func newApprovalHTTPClient(rawURL, apiKey string) (*approvalHTTPClient, error) { if parsed.Host == "" { return nil, fmt.Errorf("server URL must include a host: %q", base) } + // The client sends the admin bearer key on every request, so plain HTTP + // is only acceptable on loopback where the key cannot leave the machine. + // Anything else must use HTTPS — fail closed. + if parsed.Scheme == "http" && !isLoopbackHost(parsed.Hostname()) { + return nil, fmt.Errorf("server URL must use https: plain http is only allowed for loopback hosts (127.0.0.1, ::1, localhost): %q", base) + } return &approvalHTTPClient{ baseURL: parsed, apiKey: strings.TrimSpace(apiKey), @@ -72,6 +78,18 @@ func newApprovalHTTPClient(rawURL, apiKey string) (*approvalHTTPClient, error) { }, nil } +// isLoopbackHost reports whether host is a loopback identifier: 127.0.0.1, +// ::1, or localhost. Anything else — including other 127/8 addresses, +// 0.0.0.0, and hostnames that merely resolve to loopback — is not loopback +// here (fail closed). +func isLoopbackHost(host string) bool { + switch strings.ToLower(strings.TrimSpace(host)) { + case "127.0.0.1", "::1", "localhost": + return true + } + return false +} + func (c *approvalHTTPClient) ListApprovals(ctx context.Context) ([]contracts.ApprovalCeremony, error) { var ceremonies []contracts.ApprovalCeremony if err := c.do(ctx, http.MethodGet, approvalAPIBasePath, nil, &ceremonies); err != nil { diff --git a/core/cmd/helm-ai-kernel/watch_client_test.go b/core/cmd/helm-ai-kernel/watch_client_test.go index 15864749e..42bf2adac 100644 --- a/core/cmd/helm-ai-kernel/watch_client_test.go +++ b/core/cmd/helm-ai-kernel/watch_client_test.go @@ -123,4 +123,7 @@ func TestNewApprovalHTTPClientRejectsBadURL(t *testing.T) { if _, err := newApprovalHTTPClient("http://", "k"); err == nil { t.Fatal("missing host must be rejected") } + if _, err := newApprovalHTTPClient("http://example.com", "k"); err == nil { + t.Fatal("non-loopback plain HTTP must be rejected before sending the admin key") + } } diff --git a/core/cmd/helm-ai-kernel/watch_cmd.go b/core/cmd/helm-ai-kernel/watch_cmd.go index ffc5fa9c2..ad963738c 100644 --- a/core/cmd/helm-ai-kernel/watch_cmd.go +++ b/core/cmd/helm-ai-kernel/watch_cmd.go @@ -113,10 +113,13 @@ func runWatchSnapshot(client approvalClient, jsonOut bool, stdout, stderr io.Wri // HELM_ADMIN_API_KEY environment variable. Missing key fails closed. func resolveWatchAPIKey(apiKeyFile string) (string, error) { if strings.TrimSpace(apiKeyFile) != "" { - info, err := os.Stat(apiKeyFile) + info, err := os.Lstat(apiKeyFile) if err != nil { return "", fmt.Errorf("read API key file: %w", err) } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return "", fmt.Errorf("API key file %s must be a regular file, not a symlink or special file", apiKeyFile) + } if info.Mode().Perm()&0o077 != 0 { return "", fmt.Errorf("API key file %s must not be readable by group/others (chmod 0600)", apiKeyFile) } diff --git a/core/cmd/helm-ai-kernel/watch_model.go b/core/cmd/helm-ai-kernel/watch_model.go index 32c528152..7906e8303 100644 --- a/core/cmd/helm-ai-kernel/watch_model.go +++ b/core/cmd/helm-ai-kernel/watch_model.go @@ -26,8 +26,11 @@ import ( ) type approvalsFetchedMsg struct { - items []contracts.ApprovalCeremony - err error + // generation tags the fetch so a stale (out-of-order) result can never + // overwrite newer state: only the latest generation may mutate the model. + generation int + items []contracts.ApprovalCeremony + err error } type approvalTransitionedMsg struct { @@ -40,6 +43,16 @@ type watchTickMsg time.Time // watchModel renders the live approval queue and wires approve/deny hotkeys // to the kernel approval API. +// +// Refresh invariants (fail closed under races): +// - At most one fetch is ever in flight (inFlight guard): manual refreshes +// and ticks cannot stack up parallel polling loops. +// - Every fetch is tagged with a monotonically increasing generation; a +// result whose generation is not the latest is discarded untouched, so an +// out-of-order success can never overwrite a newer failure (or vice +// versa). +// - The next tick is scheduled only when a fetch completes, keeping a +// single polling loop for the lifetime of the program. type watchModel struct { client approvalClient actor string @@ -52,6 +65,9 @@ type watchModel struct { refreshedAt time.Time status string width int + + generation int + inFlight bool } func newWatchModel(client approvalClient, actor string, interval time.Duration) *watchModel { @@ -62,15 +78,23 @@ func newWatchModel(client approvalClient, actor string, interval time.Duration) } func (m *watchModel) Init() tea.Cmd { - return m.fetchCmd() + return m.startFetch() } -func (m *watchModel) fetchCmd() tea.Cmd { +// startFetch begins a new fetch generation. Callers must hold the inFlight +// invariant: never start a fetch while one is already running. +func (m *watchModel) startFetch() tea.Cmd { + m.generation++ + m.inFlight = true + return m.fetchCmd(m.generation) +} + +func (m *watchModel) fetchCmd(generation int) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() items, err := m.client.ListApprovals(ctx) - return approvalsFetchedMsg{items: items, err: err} + return approvalsFetchedMsg{generation: generation, items: items, err: err} } } @@ -112,8 +136,19 @@ func (m *watchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = typed.Width return m, nil case watchTickMsg: - return m, m.fetchCmd() + // A tick never starts a second polling loop: if a fetch is already in + // flight, its completion schedules the next tick. + if m.inFlight { + return m, nil + } + return m, m.startFetch() case approvalsFetchedMsg: + if typed.generation != m.generation { + // Stale generation: an out-of-order result must never mutate + // state. The latest fetch will apply and schedule the next tick. + return m, nil + } + m.inFlight = false m.refreshedAt = time.Now() if typed.err != nil { // Fail closed: never present stale items as actionable. @@ -139,7 +174,14 @@ func (m *watchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.status = fmt.Sprintf("%s %s recorded", typed.action, typed.approvalID) } - return m, m.fetchCmd() + // A transition invalidates the queue; refresh immediately. Bump the + // generation first so any result from a fetch started before the + // transition is discarded as stale. + if m.inFlight { + m.generation++ + m.inFlight = false + } + return m, m.startFetch() case tea.KeyMsg: switch typed.String() { case "ctrl+c", "q": @@ -155,7 +197,11 @@ func (m *watchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case "r": - return m, m.fetchCmd() + if m.inFlight { + m.status = "refresh already in flight" + return m, nil + } + return m, m.startFetch() case "a", "d": action := "approve" if typed.String() == "d" { diff --git a/core/cmd/helm-ai-kernel/watch_model_test.go b/core/cmd/helm-ai-kernel/watch_model_test.go index 3cf4709b5..7a137f661 100644 --- a/core/cmd/helm-ai-kernel/watch_model_test.go +++ b/core/cmd/helm-ai-kernel/watch_model_test.go @@ -117,6 +117,29 @@ func TestWatchModelFetchErrorFailsClosed(t *testing.T) { } } +func TestWatchModelDiscardsStaleFetchGeneration(t *testing.T) { + m := newWatchModel(&fakeApprovalClient{}, "operator.cli", time.Second) + m.generation = 2 + m.inFlight = true + m.pending = []contracts.ApprovalCeremony{pendingCeremony("current", time.Now())} + + m, cmd := updateModel(t, m, approvalsFetchedMsg{ + generation: 1, + err: errors.New("stale failure"), + }) + if cmd != nil || len(m.pending) != 1 || m.pending[0].ApprovalID != "current" || !m.inFlight { + t.Fatalf("stale generation mutated model: pending=%+v inFlight=%t cmd=%v", m.pending, m.inFlight, cmd) + } + + m, _ = updateModel(t, m, approvalsFetchedMsg{ + generation: 2, + err: errors.New("current failure"), + }) + if m.lastErr == nil || len(m.pending) != 0 || m.inFlight { + t.Fatalf("current failure did not fail closed: pending=%+v inFlight=%t err=%v", m.pending, m.inFlight, m.lastErr) + } +} + func TestWatchModelApproveDenyFlow(t *testing.T) { client := &fakeApprovalClient{} m := newWatchModel(client, "operator.cli", time.Second) diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go index c62477112..d8398293c 100644 --- a/core/cmd/helm-ai-kernel/workstation_gate_cmd.go +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/workstation" ) @@ -25,7 +26,7 @@ const ( func runWorkstationGateCmd(args []string, stdout, stderr io.Writer) int { cmd := flag.NewFlagSet("workstation gate", flag.ContinueOnError) cmd.SetOutput(stderr) - var profileRaw, command, allowlistPath, dataDir string + var profileRaw, command, allowlistPath, dataDir, apiKeyFile, approvalID string var jsonOut, requestApproval bool var rawURL, actor string cmd.StringVar(&profileRaw, "profile", string(workstation.ShellGateProfileProduction), "Gate profile: dev escalates blocked commands to pending approvals; anything else is production (deny, fail-closed)") @@ -36,6 +37,8 @@ func runWorkstationGateCmd(args []string, stdout, stderr io.Writer) int { cmd.BoolVar(&requestApproval, "request-approval", false, "On a pending_approval verdict, create the approval ceremony on the kernel server") cmd.StringVar(&rawURL, "url", "", "Kernel server URL for --request-approval (default $HELM_KERNEL_URL or "+defaultWatchURL+")") cmd.StringVar(&actor, "actor", "operator.cli", "Actor recorded on the approval request") + cmd.StringVar(&apiKeyFile, "api-key-file", "", "Path to a 0600 admin API key file (default $HELM_ADMIN_API_KEY)") + cmd.StringVar(&approvalID, "approval-id", "", "Consume this approved, command-bound ceremony to allow a pending dev command") if err := cmd.Parse(args); err != nil { if err == flag.ErrHelp { return 0 @@ -56,6 +59,19 @@ func runWorkstationGateCmd(args []string, stdout, stderr io.Writer) int { store := workstation.NewShellAllowlistStore(allowlistPath) decision := workstation.GateShellCommandWithStore(profile, command, store) + if approvalID != "" { + if decision.Verdict != workstation.ShellGateVerdictPendingApproval { + _, _ = fmt.Fprintln(stderr, "Error: --approval-id is valid only for a pending dev-profile command") + return 2 + } + if err := consumeShellGateApproval(decision, approvalID, rawURL, apiKeyFile, dataDir); err != nil { + _, _ = fmt.Fprintf(stderr, "Error: approval cannot authorize command: %v\n", err) + return 1 + } + decision.Verdict = workstation.ShellGateVerdictAllow + decision.Reason = "exact command authorized by single-use approval " + approvalID + } + if jsonOut { data, _ := json.MarshalIndent(decision, "", " ") _, _ = fmt.Fprintln(stdout, string(data)) @@ -70,7 +86,7 @@ func runWorkstationGateCmd(args []string, stdout, stderr io.Writer) int { if !requestApproval { return exitGatePendingApproval } - if err := requestShellGateApproval(decision, rawURL, actor, stdout); err != nil { + if err := requestShellGateApproval(decision, rawURL, apiKeyFile, actor, stdout); err != nil { _, _ = fmt.Fprintf(stderr, "Error: approval request failed: %v\n", err) return 1 } @@ -97,30 +113,34 @@ func printGateDecision(stdout io.Writer, decision workstation.ShellGateDecision, // requestShellGateApproval turns a pending_approval verdict into an approval // ceremony on the kernel server, so `watch` can drain it. -func requestShellGateApproval(decision workstation.ShellGateDecision, rawURL, actor string, stdout io.Writer) error { +func shellGateApprovalClient(rawURL, apiKeyFile string) (*approvalHTTPClient, error) { if strings.TrimSpace(rawURL) == "" { rawURL = strings.TrimSpace(os.Getenv(watchURLEnv)) } if rawURL == "" { rawURL = defaultWatchURL } - apiKey, err := resolveWatchAPIKey("") + apiKey, err := resolveWatchAPIKey(apiKeyFile) if err != nil { - return err + return nil, err } - client, err := newApprovalHTTPClient(rawURL, apiKey) + return newApprovalHTTPClient(rawURL, apiKey) +} + +func requestShellGateApproval(decision workstation.ShellGateDecision, rawURL, apiKeyFile, actor string, stdout io.Writer) error { + client, err := shellGateApprovalClient(rawURL, apiKeyFile) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() ceremony, err := client.CreateApproval(ctx, createApprovalRequest{ - Subject: "shell_command", - Action: "shell_operate", + Subject: workstation.ShellGateApprovalSubject, + Action: workstation.ShellGateApprovalAction, RequestedBy: actor, Quorum: 1, - Reason: fmt.Sprintf("shell gate escalation (dev profile): blocked commands [%s] in %q", - strings.Join(decision.Blocked, ", "), decision.Command), + Reason: fmt.Sprintf("shell gate escalation (dev profile): blocked commands [%s] in %q; %s", + strings.Join(decision.Blocked, ", "), decision.Command, workstation.ShellCommandBinding(decision.Command)), }) if err != nil { return err @@ -128,3 +148,35 @@ func requestShellGateApproval(decision workstation.ShellGateDecision, rawURL, ac _, _ = fmt.Fprintf(stdout, " approval: %s (pending on server)\n", ceremony.ApprovalID) return nil } + +func consumeShellGateApproval(decision workstation.ShellGateDecision, approvalID, rawURL, apiKeyFile, dataDir string) error { + client, err := shellGateApprovalClient(rawURL, apiKeyFile) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + approvals, err := client.ListApprovals(ctx) + if err != nil { + return err + } + for _, approval := range approvals { + if approval.ApprovalID != approvalID { + continue + } + if approval.State != contracts.ApprovalCeremonyAllowed { + return fmt.Errorf("approval %s is %s, not approved", approvalID, approval.State) + } + if approval.Subject != workstation.ShellGateApprovalSubject || approval.Action != workstation.ShellGateApprovalAction { + return fmt.Errorf("approval %s has wrong subject/action", approvalID) + } + if !approval.ExpiresAt.IsZero() && time.Now().After(approval.ExpiresAt) { + return fmt.Errorf("approval %s is expired", approvalID) + } + if !workstation.ApprovalBindsToCommand(approval.Reason, decision.Command) { + return fmt.Errorf("approval %s is bound to a different command", approvalID) + } + return workstation.NewConsumedApprovalStore(workstation.DefaultConsumedApprovalPath(dataDir)).MarkConsumed(approvalID) + } + return fmt.Errorf("approval %s not found", approvalID) +} diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go index 7f999e381..15904f653 100644 --- a/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/workstation" ) func gateTestAllowlist(t *testing.T, entries []string) string { @@ -130,11 +131,78 @@ func TestWorkstationGateRequestApprovalCreatesCeremony(t *testing.T) { if !strings.Contains(gotBody.Reason, "rm") || !strings.Contains(gotBody.Reason, "sudo") { t.Fatalf("approval reason must name blocked commands: %q", gotBody.Reason) } + if !strings.Contains(gotBody.Reason, workstation.ShellCommandBinding("sudo rm /x")) { + t.Fatalf("approval reason must bind the exact command: %q", gotBody.Reason) + } if !strings.Contains(out, "ap-gate-1") { t.Fatalf("output must surface the created approval id:\n%s", out) } } +func TestWorkstationGateConsumesExactApprovalOnce(t *testing.T) { + command := "rm /x" + approval := contracts.ApprovalCeremony{ + ApprovalID: "ap-bound", + Subject: workstation.ShellGateApprovalSubject, + Action: workstation.ShellGateApprovalAction, + State: contracts.ApprovalCeremonyAllowed, + RequestedBy: "operator.cli", + Reason: "approved; " + workstation.ShellCommandBinding(command), + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode([]contracts.ApprovalCeremony{approval}) + })) + defer server.Close() + t.Setenv(watchAdminAPIKeyEnv, "test-key") + + dataDir := t.TempDir() + allowlist := gateTestAllowlist(t, []string{"ls"}) + args := []string{ + "--profile", "dev", + "--allowlist", allowlist, + "--data-dir", dataDir, + "--approval-id", approval.ApprovalID, + "--url", server.URL, + "--command", command, + } + code, out, errOut := runGateForTest(t, args...) + if code != exitGateAllow { + t.Fatalf("first consume exit = %d, want allow; out=%s err=%s", code, out, errOut) + } + code, _, errOut = runGateForTest(t, args...) + if code != 1 || !strings.Contains(errOut, "already consumed") { + t.Fatalf("second consume exit = %d err=%s, want fail-closed consumed error", code, errOut) + } +} + +func TestWorkstationGateRejectsApprovalForDifferentCommand(t *testing.T) { + approval := contracts.ApprovalCeremony{ + ApprovalID: "ap-wrong", + Subject: workstation.ShellGateApprovalSubject, + Action: workstation.ShellGateApprovalAction, + State: contracts.ApprovalCeremonyAllowed, + RequestedBy: "operator.cli", + Reason: "approved; " + workstation.ShellCommandBinding("rm /tmp/safe"), + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode([]contracts.ApprovalCeremony{approval}) + })) + defer server.Close() + t.Setenv(watchAdminAPIKeyEnv, "test-key") + + code, _, errOut := runGateForTest(t, + "--profile", "dev", + "--allowlist", gateTestAllowlist(t, []string{"ls"}), + "--data-dir", t.TempDir(), + "--approval-id", approval.ApprovalID, + "--url", server.URL, + "--command", "rm /etc/passwd", + ) + if code != 1 || !strings.Contains(errOut, "different command") { + t.Fatalf("exit = %d err=%s, want command-binding rejection", code, errOut) + } +} + func TestWorkstationGateRequestApprovalServerDown(t *testing.T) { t.Setenv(watchAdminAPIKeyEnv, "test-key") allowlist := gateTestAllowlist(t, []string{"ls"}) diff --git a/core/pkg/workstation/shellallowlist.go b/core/pkg/workstation/shellallowlist.go index bc98be3cc..90e7ca43f 100644 --- a/core/pkg/workstation/shellallowlist.go +++ b/core/pkg/workstation/shellallowlist.go @@ -23,13 +23,13 @@ import ( "time" ) -// DefaultShellAllowlist mirrors the Rowboat default: a minimal read-only set -// seeded on first use. +// DefaultShellAllowlist is the minimal read-only set seeded on first use. +// curl and echo are deliberately NOT in the shipped defaults: `curl -o file +// URL` and `echo x > file` are arbitrary writes, and an allowlisted writer +// defeats the gate. Operators who need them add them explicitly. var DefaultShellAllowlist = []string{ "cat", - "curl", "date", - "echo", "grep", "jq", "ls", @@ -73,12 +73,15 @@ func (s *ShellAllowlistStore) Path() string { // Allowlist returns the current allowlist, reloading the file when its mtime // or size changed since the last successful read. A missing file is seeded // with DefaultShellAllowlist. Parse and I/O failures return an error — callers -// must fail closed. +// must fail closed. The allowlist file must be a regular file (never a +// symlink or special file) and must not be writable by group or others: this +// file controls what passes the workstation boundary, so a redirected or +// world-writable allowlist fails closed. func (s *ShellAllowlistStore) Allowlist() ([]string, error) { s.mu.Lock() defer s.mu.Unlock() - info, err := os.Stat(s.path) + info, err := os.Lstat(s.path) if err != nil { if !os.IsNotExist(err) { return nil, fmt.Errorf("stat shell allowlist %s: %w", s.path, err) @@ -86,11 +89,14 @@ func (s *ShellAllowlistStore) Allowlist() ([]string, error) { if err := s.seedLocked(); err != nil { return nil, err } - info, err = os.Stat(s.path) + info, err = os.Lstat(s.path) if err != nil { return nil, fmt.Errorf("stat seeded shell allowlist %s: %w", s.path, err) } } + if err := validateShellAllowlistInfo(s.path, info); err != nil { + return nil, err + } if s.cachePresent && info.ModTime().Equal(s.cachedMtime) && info.Size() == s.cachedSize { return append([]string(nil), s.cached...), nil @@ -100,6 +106,16 @@ func (s *ShellAllowlistStore) Allowlist() ([]string, error) { if err != nil { return nil, err } + // Re-stat after the read: the file may have changed between the initial + // Lstat and ReadFile. Cache the metadata observed after the read so a + // concurrent rewrite can never pin stale mtime/size to new contents. + info, err = os.Lstat(s.path) + if err != nil { + return nil, fmt.Errorf("re-stat shell allowlist %s: %w", s.path, err) + } + if err := validateShellAllowlistInfo(s.path, info); err != nil { + return nil, err + } s.cached = allowlist s.cachedMtime = info.ModTime() s.cachedSize = info.Size() @@ -107,6 +123,18 @@ func (s *ShellAllowlistStore) Allowlist() ([]string, error) { return append([]string(nil), s.cached...), nil } +// validateShellAllowlistInfo enforces the file-safety invariants of the +// allowlist: regular file, no symlink, not writable by group/others. +func validateShellAllowlistInfo(path string, info os.FileInfo) error { + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("shell allowlist %s must be a regular file, not a symlink or special file", path) + } + if info.Mode().Perm()&0o022 != 0 { + return fmt.Errorf("shell allowlist %s must not be writable by group or others (chmod 0600)", path) + } + return nil +} + // Reset drops the cached allowlist so the next Allowlist call re-reads the // file. Primarily for tests. func (s *ShellAllowlistStore) Reset() { diff --git a/core/pkg/workstation/shellapproval.go b/core/pkg/workstation/shellapproval.go new file mode 100644 index 000000000..72f0552b7 --- /dev/null +++ b/core/pkg/workstation/shellapproval.go @@ -0,0 +1,122 @@ +// shellapproval.go — binding between a shell gate escalation and the approval +// ceremony that authorizes it, plus the local single-use consumption ledger. +// +// An approval ceremony created for a blocked shell command carries a binding +// token derived from the exact command line (args included): the ceremony +// authorizes that command line and nothing else. When the gate re-checks a +// pending command, it consumes a matching approved ceremony exactly once; a +// ceremony approved for a different command never satisfies the gate +// (wrong-command reuse is rejected), and an already-consumed ceremony leaves +// the command pending. +package workstation + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ShellGateApprovalSubject and ShellGateApprovalAction identify approval +// ceremonies created by the shell gate. +const ( + ShellGateApprovalSubject = "shell_command" + ShellGateApprovalAction = "shell_operate" +) + +// shellGateBindingPrefix prefixes the binding token embedded in the approval +// reason so it is greppable by operators and parseable by the gate. +const shellGateBindingPrefix = "shellgate-binding=sha256:" + +// ShellCommandBindingHash returns the hex SHA-256 of the exact command line. +// The hash covers the full line, arguments included, so an approval for +// `rm /tmp/a` never authorizes `rm /etc/b`. +func ShellCommandBindingHash(command string) string { + sum := sha256.Sum256([]byte(command)) + return hex.EncodeToString(sum[:]) +} + +// ShellCommandBinding returns the binding token to embed in an approval +// ceremony for command. +func ShellCommandBinding(command string) string { + return shellGateBindingPrefix + ShellCommandBindingHash(command) +} + +// ApprovalBindsToCommand reports whether an approval ceremony reason carries +// the binding token for exactly this command line. +func ApprovalBindsToCommand(approvalReason, command string) bool { + return strings.Contains(approvalReason, ShellCommandBinding(command)) +} + +// ConsumedApprovalDirectory is the single-use marker directory under the +// workstation data directory. +const ConsumedApprovalDirectory = "consumed-approvals" + +// DefaultConsumedApprovalPath returns the default ledger path inside the +// given data directory. +func DefaultConsumedApprovalPath(dataDir string) string { + return filepath.Join(dataDir, "workstation", ConsumedApprovalDirectory) +} + +// ConsumedApprovalStore is a local ledger of approval IDs already consumed by +// the shell gate. It enforces the single-use property of an approval: once +// consumed, the same approval can never authorize the command again. The +// ledger uses one 0600 marker file per approval. O_CREATE|O_EXCL makes +// consumption atomic across processes without a read-modify-write race. +type ConsumedApprovalStore struct { + path string +} + +// NewConsumedApprovalStore creates a ledger rooted at path. +func NewConsumedApprovalStore(path string) *ConsumedApprovalStore { + return &ConsumedApprovalStore{path: path} +} + +// Path returns the ledger file path. +func (s *ConsumedApprovalStore) Path() string { + return s.path +} + +func (s *ConsumedApprovalStore) markerPath(approvalID string) string { + sum := sha256.Sum256([]byte(approvalID)) + return filepath.Join(s.path, hex.EncodeToString(sum[:])) +} + +// IsConsumed reports whether the approval ID was already consumed. A ledger +// error returns an error so callers can fail closed. +func (s *ConsumedApprovalStore) IsConsumed(approvalID string) (bool, error) { + if strings.TrimSpace(approvalID) == "" { + return false, fmt.Errorf("approval ID is required") + } + _, err := os.Stat(s.markerPath(approvalID)) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("stat consumed approval marker: %w", err) + } + return true, nil +} + +// MarkConsumed atomically records the approval ID as consumed. +func (s *ConsumedApprovalStore) MarkConsumed(approvalID string) error { + if strings.TrimSpace(approvalID) == "" { + return fmt.Errorf("approval ID is required") + } + if err := os.MkdirAll(s.path, 0o700); err != nil { + return fmt.Errorf("create consumed approval ledger directory: %w", err) + } + file, err := os.OpenFile(s.markerPath(approvalID), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + if os.IsExist(err) { + return fmt.Errorf("approval %s was already consumed", approvalID) + } + return fmt.Errorf("create consumed approval marker: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close consumed approval marker: %w", err) + } + return nil +} diff --git a/core/pkg/workstation/shellgate.go b/core/pkg/workstation/shellgate.go index 19feefb26..b60c4058c 100644 --- a/core/pkg/workstation/shellgate.go +++ b/core/pkg/workstation/shellgate.go @@ -19,6 +19,19 @@ // (e.g. `sudo -u root rm x`) may surface the value as a command name; // that false positive fails closed and is accepted. // - Unknown gate profiles normalize to production (deny), never to dev. +// - The gate is redirection-aware: output redirections (`>`, `>>`, `>|`) +// and downloader output flags (`-o`, `--output`, `-O`, `--remote-name`, +// `--output-document`) are treated as writes that always require an +// approval (dev) or a denial (production), even when every command name +// is allowlisted. An allowlisted `cat` must not become `cat x > /etc/y`. +// +// Threat-model limit (documented, accepted): gating is command-name and +// write-target based, not a full shell parser. Quoted operators, glob +// expansions, and arguments that a program itself interprets as write +// destinations (e.g. `tee file`, `dd of=file`, `sed -i`) are out of scope for +// name extraction; programs with intrinsic write behavior must stay off the +// allowlist. Redirection scanning strips quoted spans first and may still +// false-positive on unusual unquoted `>` usage — that fails closed. package workstation import ( @@ -30,12 +43,15 @@ import ( // commandSplitPattern splits a shell command line into segments at every // construct that can start a new command: pipes, logical operators, command // separators, background execution, command substitution (backticks and -// $(...)), and subshells. Order matters: `||` and `&&` must precede their -// single-character prefixes so the leftmost-longest alternation consumes the -// right token. Without `&`, backtick, `$(`, and the subshell parens, -// `echo hi & rm /x`, `echo `+"`rm /x`"+`, and `echo $(rm /x)` would slip past -// the gate with only `echo` allowlisted. -var commandSplitPattern = regexp.MustCompile(`\|\||&&|&|;|\||\n|` + "`" + `|\$\(|\(|\)`) +// $(...)), and subshell open parens. Order matters: `||` and `&&` must +// precede their single-character prefixes so the leftmost-longest +// alternation consumes the right token. Without `&`, backtick, `$(`, and +// `(`, `echo hi & rm /x`, `echo `+"`rm /x`"+`, and `echo $(rm /x)` would +// slip past the gate with only `echo` allowlisted. `)` is deliberately not a +// split point: `ls $(pwd)/x` would otherwise yield a bogus `/x` "command" +// from the suffix after the substitution. sanitizeCommandToken truncates at +// `)` instead, so the segment yields `pwd`. +var commandSplitPattern = regexp.MustCompile(`\|\||&&|&|;|\||\n|` + "`" + `|\$\(|\(`) // envAssignmentPattern matches leading ENV=value prefixes that are not command // names (e.g. `FOO=bar ls`). @@ -50,6 +66,25 @@ var wrapperCommands = map[string]struct{}{ "command": {}, } +// wrapperValueFlags are wrapper flags that consume the next token as a value +// (e.g. `sudo -u root`, `env -u NAME`, `time -o FILE`). The value token must +// be skipped with the flag so it neither shadows nor replaces the wrapped +// command: `sudo -u root rm /x` must extract {sudo, rm}, never stop at +// `root`. Long `--flag=value` forms carry no separate token and are skipped +// as bare flags. +var wrapperValueFlags = map[string]map[string]struct{}{ + "sudo": { + "-u": {}, "--user": {}, "-g": {}, "--group": {}, "-h": {}, "--host": {}, + "-p": {}, "--prompt": {}, "-C": {}, "--chdir": {}, + }, + "env": { + "-u": {}, "--unset": {}, "-C": {}, "--chdir": {}, "-S": {}, "--split-string": {}, + }, + "time": { + "-o": {}, "--output": {}, "-f": {}, "--format": {}, + }, +} + // ExtractCommandNames returns the sorted, de-duplicated, lowercased set of // command names a shell command line would invoke. It is robust to chaining // (&&, ||, |, ;, &), command substitution (backticks, $(...)), subshells, @@ -92,18 +127,26 @@ func ExtractCommandNames(command string) []string { // unwrapWrappedCommands resolves the command names hidden behind one or more // nested wrappers, including the intermediate wrappers themselves. Leading -// ENV=value assignments and bare `-` flags after a wrapper are skipped. +// ENV=value assignments and bare `-` flags after a wrapper are skipped; flags +// known to take a separate value (sudo -u, env -u, time -o, …) are skipped +// together with their value so the value cannot shadow the wrapped command. func unwrapWrappedCommands(tokens []string) []string { var out []string + activeWrapper := "" for i := 0; i < len(tokens); i++ { token := tokens[i] if envAssignmentPattern.MatchString(token) { continue } if strings.HasPrefix(token, "-") { - // Bare wrapper flag (e.g. `sudo -E`, `time -p`). Flags that take a - // separate value are not unwrapped; the value may surface as a - // command name, which fails closed. + // Bare wrapper flag (e.g. `sudo -E`, `time -p`). Value-taking + // flags consume the next token as well, so `sudo -u root rm /x` + // still resolves `rm` instead of stopping at `root`. Unknown + // value-taking flags may surface their value as a command name; + // that false positive fails closed and is accepted. + if _, takesValue := wrapperValueFlags[activeWrapper][token]; takesValue { + i++ + } continue } name := sanitizeCommandToken(token) @@ -112,6 +155,7 @@ func unwrapWrappedCommands(tokens []string) []string { } out = append(out, name) if _, isWrapper := wrapperCommands[name]; isWrapper { + activeWrapper = name continue } break @@ -119,8 +163,15 @@ func unwrapWrappedCommands(tokens []string) []string { return out } +// sanitizeCommandToken normalizes a raw token into a comparable command name: +// trimmed, unquoted, lowercased, and truncated at the first `)` so a command +// substitution suffix (`$(pwd)/x` → `pwd)/x`) cannot become a bogus command. func sanitizeCommandToken(token string) string { - return strings.ToLower(strings.Trim(strings.TrimSpace(token), `'"`)) + cleaned := strings.ToLower(strings.Trim(strings.TrimSpace(token), `'"`)) + if idx := strings.IndexByte(cleaned, ')'); idx >= 0 { + cleaned = cleaned[:idx] + } + return cleaned } // BlockedCommandNames returns the invoked command names that are not present @@ -153,6 +204,123 @@ func BlockedCommandNames(command string, allowlist []string) []string { return blocked } +// quotedSpanPattern matches single- or double-quoted spans so redirection +// scanning can ignore operators inside quotes (`echo "a > b"` is not a +// write). +var quotedSpanPattern = regexp.MustCompile(`'[^']*'|"[^"]*"`) + +// outputValueFlags are flags whose value is a file the command writes to +// (curl/wget style). The value may be inline (`--output=file`), concatenated +// (`-ofile`), or the next token (`-o file`). +var outputValueFlags = map[string]struct{}{ + "-o": {}, + "--output": {}, + "--output-document": {}, +} + +// outputBooleanFlags are flags that make the command write to a +// command-chosen file name (curl -O / --remote-name). +var outputBooleanFlags = map[string]struct{}{ + "-O": {}, + "--remote-name": {}, + "--remote-name-all": {}, +} + +// ExtractWriteTargets returns the write destinations a shell command line +// would create or overwrite: output redirections (`>`, `>>`, `>|`, with +// optional fd prefixes like `2>`) and downloader-style output flags. File +// descriptor duplication (`2>&1`) is not a write. Quoted spans are stripped +// before scanning; unquoted corner cases may false-positive, which fails +// closed. The result is sorted and de-duplicated. +func ExtractWriteTargets(command string) []string { + stripped := quotedSpanPattern.ReplaceAllString(command, " ") + seen := make(map[string]struct{}) + for _, target := range redirectionTargets(stripped) { + seen[target] = struct{}{} + } + for _, target := range outputFlagTargets(stripped) { + seen[target] = struct{}{} + } + targets := make([]string, 0, len(seen)) + for target := range seen { + targets = append(targets, target) + } + sort.Strings(targets) + if len(targets) == 0 { + return nil + } + return targets +} + +// redirectionTargets scans for `>` / `>>` / `>|` output redirections. An +// optional fd prefix (`2>`, `&>`) is part of the operator; `>&` (fd +// duplication such as `2>&1`) is not a file write and is skipped. +func redirectionTargets(line string) []string { + var targets []string + for i := 0; i < len(line); i++ { + if line[i] != '>' { + continue + } + j := i + 1 + if j < len(line) && line[j] == '>' { // append: >> + j++ + } + if j < len(line) && line[j] == '|' { // noclobber override: >| + j++ + } + if j < len(line) && line[j] == '&' { // fd duplication: 2>&1 + i = j + continue + } + for j < len(line) && (line[j] == ' ' || line[j] == '\t') { + j++ + } + start := j + for j < len(line) && !strings.ContainsRune(" \t\r\n|;&<>()$`", rune(line[j])) { + j++ + } + if j > start { + targets = append(targets, line[start:j]) + } + i = j + } + return targets +} + +// outputFlagTargets scans for downloader-style output flags: `-o file`, +// `--output file`, `--output=file`, `-ofile`, `-O`, `--remote-name`, +// `--output-document`. A trailing `-o` with no value, or `-o -` (stdout), is +// not a write. Unknown programs that reuse `-o` for non-write purposes may +// false-positive; that fails closed. +func outputFlagTargets(line string) []string { + fields := strings.Fields(line) + var targets []string + for i, field := range fields { + name, inline := field, "" + if strings.HasPrefix(field, "--") { + if idx := strings.IndexByte(field, '='); idx >= 0 { + name, inline = field[:idx], field[idx+1:] + } + } else if strings.HasPrefix(field, "-o") && len(field) > 2 && !strings.HasPrefix(field, "--") { + name, inline = "-o", field[2:] + } + if _, ok := outputBooleanFlags[name]; ok { + targets = append(targets, "") + continue + } + if _, ok := outputValueFlags[name]; !ok { + continue + } + switch { + case inline != "" && inline != "-": + targets = append(targets, inline) + case i+1 < len(fields) && fields[i+1] != "-": + targets = append(targets, fields[i+1]) + } + } + return targets +} + // ShellGateProfile selects the failure mode of the shell gate. type ShellGateProfile string @@ -188,35 +356,57 @@ const ( // ShellGateDecision is the result of gating one shell command line. type ShellGateDecision struct { - Verdict ShellGateVerdict `json:"verdict"` - Profile ShellGateProfile `json:"profile"` - Command string `json:"command"` - Invoked []string `json:"invoked_commands"` - Blocked []string `json:"blocked_commands,omitempty"` - Reason string `json:"reason,omitempty"` + Verdict ShellGateVerdict `json:"verdict"` + Profile ShellGateProfile `json:"profile"` + Command string `json:"command"` + Invoked []string `json:"invoked_commands"` + Blocked []string `json:"blocked_commands,omitempty"` + WriteTargets []string `json:"write_targets,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// gateReason explains why a command did not pass the gate, covering both +// blocked command names and detected write targets. +func gateReason(decision ShellGateDecision, dev bool) string { + mode := "are denied in the production profile" + if dev { + mode = "escalate to a pending approval in the dev profile" + } + var parts []string + if len(decision.Blocked) > 0 { + parts = append(parts, "blocked shell commands "+mode+": "+strings.Join(decision.Blocked, ", ")) + } + if len(decision.WriteTargets) > 0 { + parts = append(parts, "shell writes "+mode+": "+strings.Join(decision.WriteTargets, ", ")) + } + return strings.Join(parts, "; ") } // GateShellCommand evaluates a shell command line against an allowlist under -// the given profile. Blocked commands are denied in the production profile -// (fail closed) and escalated to a pending approval in the dev profile. +// the given profile. Blocked command names and any detected write target +// (output redirection or output flag) are denied in the production profile +// (fail closed) and escalated to a pending approval in the dev profile — even +// when every command name is allowlisted, an allowlisted reader must not +// become a writer (`cat x > y`, `curl -o y url`). func GateShellCommand(profile ShellGateProfile, command string, allowlist []string) ShellGateDecision { decision := ShellGateDecision{ - Profile: profile, - Command: command, - Invoked: ExtractCommandNames(command), - Blocked: BlockedCommandNames(command, allowlist), + Profile: profile, + Command: command, + Invoked: ExtractCommandNames(command), + Blocked: BlockedCommandNames(command, allowlist), + WriteTargets: ExtractWriteTargets(command), } - if len(decision.Blocked) == 0 { + if len(decision.Blocked) == 0 && len(decision.WriteTargets) == 0 { decision.Verdict = ShellGateVerdictAllow return decision } if profile == ShellGateProfileDev { decision.Verdict = ShellGateVerdictPendingApproval - decision.Reason = "blocked shell commands escalate to a pending approval in the dev profile: " + strings.Join(decision.Blocked, ", ") + decision.Reason = gateReason(decision, true) return decision } decision.Verdict = ShellGateVerdictDeny - decision.Reason = "blocked shell commands are denied in the production profile: " + strings.Join(decision.Blocked, ", ") + decision.Reason = gateReason(decision, false) return decision } @@ -229,11 +419,12 @@ func GateShellCommandWithStore(profile ShellGateProfile, command string, store * return GateShellCommand(profile, command, allowlist) } decision := ShellGateDecision{ - Profile: profile, - Command: command, - Invoked: ExtractCommandNames(command), - Blocked: ExtractCommandNames(command), - Reason: "shell allowlist unavailable, failing closed: " + err.Error(), + Profile: profile, + Command: command, + Invoked: ExtractCommandNames(command), + Blocked: ExtractCommandNames(command), + WriteTargets: ExtractWriteTargets(command), + Reason: "shell allowlist unavailable, failing closed: " + err.Error(), } if profile == ShellGateProfileDev { decision.Verdict = ShellGateVerdictPendingApproval diff --git a/core/pkg/workstation/shellgate_test.go b/core/pkg/workstation/shellgate_test.go index fdfa2d949..e24c83ca9 100644 --- a/core/pkg/workstation/shellgate_test.go +++ b/core/pkg/workstation/shellgate_test.go @@ -44,7 +44,7 @@ func TestExtractCommandNames(t *testing.T) { {"double quoted command", `"curl" https://example.com`, []string{"curl"}}, {"uppercase lowered", "SUDO RM /x", []string{"rm", "sudo"}}, {"mixed chaining", "cat a | grep b && jq . || echo done", []string{"cat", "echo", "grep", "jq"}}, - {"substitution inside args", "ls $(pwd)/x", []string{"/x", "ls", "pwd"}}, + {"substitution inside args", "ls $(pwd)/x", []string{"ls", "pwd"}}, {"empty", "", nil}, {"whitespace", " ", nil}, {"separator only", "|", nil}, @@ -59,6 +59,22 @@ func TestExtractCommandNames(t *testing.T) { } } +func TestConsumedApprovalStoreIsSingleUse(t *testing.T) { + store := NewConsumedApprovalStore(filepath.Join(t.TempDir(), "consumed")) + if consumed, err := store.IsConsumed("ap-1"); err != nil || consumed { + t.Fatalf("fresh approval consumed=%t err=%v", consumed, err) + } + if err := store.MarkConsumed("ap-1"); err != nil { + t.Fatalf("first consume: %v", err) + } + if err := store.MarkConsumed("ap-1"); err == nil { + t.Fatal("second consume must fail") + } + if consumed, err := store.IsConsumed("ap-1"); err != nil || !consumed { + t.Fatalf("consumed approval consumed=%t err=%v", consumed, err) + } +} + func TestBlockedCommandNames(t *testing.T) { allowlist := []string{"cat", "grep", "ls", "sudo", "echo"} cases := []struct { From a5899cd30576acdd3c4435a3fd5ab03db5a37568 Mon Sep 17 00:00:00 2001 From: Mindburn Labs Date: Wed, 29 Jul 2026 20:43:07 +0300 Subject: [PATCH 04/12] fix(cli): retain terminal watch dependency --- core/go.mod | 1 + 1 file changed, 1 insertion(+) diff --git a/core/go.mod b/core/go.mod index 8b0af28ed..7e4bcbee3 100644 --- a/core/go.mod +++ b/core/go.mod @@ -12,6 +12,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.14 github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 github.com/cedar-policy/cedar-go v1.6.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/cloudflare/circl v1.6.3 github.com/fxamacker/cbor/v2 v2.9.0 github.com/go-jose/go-jose/v4 v4.1.4 From 8ea11b466d898103209f4b82aa77a3a14c5016f6 Mon Sep 17 00:00:00 2001 From: Mindburn Labs Date: Wed, 29 Jul 2026 20:49:54 +0300 Subject: [PATCH 05/12] fix(cli): complete terminal watch module graph --- core/go.mod | 15 +++++++++++++++ core/go.sum | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/core/go.mod b/core/go.mod index 7e4bcbee3..41d729396 100644 --- a/core/go.mod +++ b/core/go.mod @@ -71,9 +71,15 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.24.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect @@ -81,6 +87,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -102,9 +109,15 @@ require ( github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect @@ -115,6 +128,7 @@ require ( github.com/prometheus/procfs v0.17.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.6.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -127,6 +141,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect diff --git a/core/go.sum b/core/go.sum index d3aea4247..2df6e11b6 100644 --- a/core/go.sum +++ b/core/go.sum @@ -80,6 +80,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -94,6 +96,18 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= @@ -121,6 +135,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= @@ -196,8 +212,14 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= @@ -206,6 +228,12 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8= github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -233,6 +261,9 @@ github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfS github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= @@ -269,6 +300,8 @@ github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMc github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= @@ -321,6 +354,7 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= From 5ecbc2b9a4213daecde8d6a297ed1615f98d5462 Mon Sep 17 00:00:00 2001 From: Mindburn Labs Date: Wed, 29 Jul 2026 20:51:27 +0300 Subject: [PATCH 06/12] fix(shellgate): preserve outer wrapper flag semantics --- core/pkg/workstation/shellgate.go | 9 ++++----- core/pkg/workstation/shellgate_test.go | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/pkg/workstation/shellgate.go b/core/pkg/workstation/shellgate.go index b60c4058c..5a1b6b085 100644 --- a/core/pkg/workstation/shellgate.go +++ b/core/pkg/workstation/shellgate.go @@ -16,8 +16,8 @@ // skipped before resolving the wrapped command, so `env FOO=1 rm x` // extracts {env, rm} (Rowboat extracts {env, "foo=1"}, which blocks by // accident rather than by policy). Flags that take separate values -// (e.g. `sudo -u root rm x`) may surface the value as a command name; -// that false positive fails closed and is accepted. +// (e.g. `sudo -u root rm x`) are modeled per wrapper so their values +// cannot shadow the wrapped command. // - Unknown gate profiles normalize to production (deny), never to dev. // - The gate is redirection-aware: output redirections (`>`, `>>`, `>|`) // and downloader output flags (`-o`, `--output`, `-O`, `--remote-name`, @@ -109,7 +109,7 @@ func ExtractCommandNames(command string) []string { } discovered[primary] = struct{}{} if _, isWrapper := wrapperCommands[primary]; isWrapper { - for _, wrapped := range unwrapWrappedCommands(tokens[index+1:]) { + for _, wrapped := range unwrapWrappedCommands(primary, tokens[index+1:]) { discovered[wrapped] = struct{}{} } } @@ -130,9 +130,8 @@ func ExtractCommandNames(command string) []string { // ENV=value assignments and bare `-` flags after a wrapper are skipped; flags // known to take a separate value (sudo -u, env -u, time -o, …) are skipped // together with their value so the value cannot shadow the wrapped command. -func unwrapWrappedCommands(tokens []string) []string { +func unwrapWrappedCommands(activeWrapper string, tokens []string) []string { var out []string - activeWrapper := "" for i := 0; i < len(tokens); i++ { token := tokens[i] if envAssignmentPattern.MatchString(token) { diff --git a/core/pkg/workstation/shellgate_test.go b/core/pkg/workstation/shellgate_test.go index e24c83ca9..90f6d05b1 100644 --- a/core/pkg/workstation/shellgate_test.go +++ b/core/pkg/workstation/shellgate_test.go @@ -32,6 +32,7 @@ func TestExtractCommandNames(t *testing.T) { {"multiple env prefixes", "FOO=bar BAZ=qux sudo rm /x", []string{"rm", "sudo"}}, {"env prefix only", "FOO=bar", nil}, {"sudo wrapper", "sudo rm /x", []string{"rm", "sudo"}}, + {"sudo value flag", "sudo -u root rm /x", []string{"rm", "sudo"}}, {"env wrapper", "env rm /x", []string{"env", "rm"}}, {"time wrapper", "time ls", []string{"ls", "time"}}, {"command wrapper", "command ls", []string{"command", "ls"}}, From 671b8153fdb1b59522684849097f765a463aae27 Mon Sep 17 00:00:00 2001 From: Mindburn Labs Date: Wed, 29 Jul 2026 22:07:04 +0300 Subject: [PATCH 07/12] fix(workstation): close shell gate permit findings --- core/cmd/helm-ai-kernel/watch_client.go | 2 +- core/cmd/helm-ai-kernel/watch_client_test.go | 14 ++- core/cmd/helm-ai-kernel/watch_model.go | 20 +++- core/cmd/helm-ai-kernel/watch_model_test.go | 24 ++++- .../helm-ai-kernel/workstation_gate_cmd.go | 10 +- .../workstation_gate_cmd_test.go | 22 +++-- core/pkg/boundary/surface_registry.go | 24 +++-- core/pkg/boundary/surface_registry_test.go | 14 +++ core/pkg/workstation/shellallowlist.go | 1 - core/pkg/workstation/shellapproval.go | 82 +---------------- core/pkg/workstation/shellgate.go | 91 +++++++++++++++---- core/pkg/workstation/shellgate_test.go | 34 +++---- 12 files changed, 197 insertions(+), 141 deletions(-) diff --git a/core/cmd/helm-ai-kernel/watch_client.go b/core/cmd/helm-ai-kernel/watch_client.go index 681870d5f..31578d813 100644 --- a/core/cmd/helm-ai-kernel/watch_client.go +++ b/core/cmd/helm-ai-kernel/watch_client.go @@ -100,7 +100,7 @@ func (c *approvalHTTPClient) ListApprovals(ctx context.Context) ([]contracts.App func (c *approvalHTTPClient) TransitionApproval(ctx context.Context, approvalID, action, actor, reason string) (contracts.ApprovalCeremony, error) { switch action { - case "approve", "deny": + case "approve", "deny", "revoke": default: return contracts.ApprovalCeremony{}, fmt.Errorf("unsupported approval transition action %q", action) } diff --git a/core/cmd/helm-ai-kernel/watch_client_test.go b/core/cmd/helm-ai-kernel/watch_client_test.go index 42bf2adac..b05b20d8c 100644 --- a/core/cmd/helm-ai-kernel/watch_client_test.go +++ b/core/cmd/helm-ai-kernel/watch_client_test.go @@ -63,7 +63,8 @@ func TestApprovalHTTPClientListApprovalsUnauthorized(t *testing.T) { func TestApprovalHTTPClientTransition(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != approvalAPIBasePath+"/ap-9/approve" || r.Method != http.MethodPost { + if r.Method != http.MethodPost || + (r.URL.Path != approvalAPIBasePath+"/ap-9/approve" && r.URL.Path != approvalAPIBasePath+"/ap-9/revoke") { t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } var body struct { @@ -76,11 +77,15 @@ func TestApprovalHTTPClientTransition(t *testing.T) { if body.Actor != "operator.cli" { t.Fatalf("actor = %q, want operator.cli", body.Actor) } + state := contracts.ApprovalCeremonyAllowed + if strings.HasSuffix(r.URL.Path, "/revoke") { + state = contracts.ApprovalCeremonyRevoked + } _ = json.NewEncoder(w).Encode(contracts.ApprovalCeremony{ ApprovalID: "ap-9", Subject: "shell_command", Action: "shell_operate", - State: contracts.ApprovalCeremonyAllowed, + State: state, }) })) defer server.Close() @@ -96,8 +101,9 @@ func TestApprovalHTTPClientTransition(t *testing.T) { if ceremony.State != contracts.ApprovalCeremonyAllowed { t.Fatalf("state = %s, want approved", ceremony.State) } - if _, err := client.TransitionApproval(context.Background(), "ap-9", "revoke", "operator.cli", ""); err == nil { - t.Fatal("revoke must be rejected client-side") + ceremony, err = client.TransitionApproval(context.Background(), "ap-9", "revoke", "operator.cli", "consumed") + if err != nil || ceremony.State != contracts.ApprovalCeremonyRevoked { + t.Fatalf("revoke = %+v err=%v, want revoked", ceremony, err) } } diff --git a/core/cmd/helm-ai-kernel/watch_model.go b/core/cmd/helm-ai-kernel/watch_model.go index 7906e8303..259b4632e 100644 --- a/core/cmd/helm-ai-kernel/watch_model.go +++ b/core/cmd/helm-ai-kernel/watch_model.go @@ -159,7 +159,19 @@ func (m *watchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.tickCmd() } m.lastErr = nil + selectedID := "" + if m.selected >= 0 && m.selected < len(m.pending) { + selectedID = m.pending[m.selected].ApprovalID + } m.pending = filterPendingApprovals(typed.items) + if selectedID != "" { + for i := range m.pending { + if m.pending[i].ApprovalID == selectedID { + m.selected = i + break + } + } + } if m.selected >= len(m.pending) { m.selected = len(m.pending) - 1 } @@ -279,8 +291,12 @@ func formatApprovalRow(item contracts.ApprovalCeremony, now time.Time) string { if len(flags) > 0 { suffix = " [" + strings.Join(flags, ",") + "]" } - return fmt.Sprintf("%s %s:%s by %s age %s%s", - item.ApprovalID, item.Subject, item.Action, item.RequestedBy, age, suffix) + reason := "" + if strings.TrimSpace(item.Reason) != "" { + reason = fmt.Sprintf(" reason %q", item.Reason) + } + return fmt.Sprintf("%s %s:%s by %s age %s%s%s", + item.ApprovalID, item.Subject, item.Action, item.RequestedBy, age, suffix, reason) } // renderApprovalSnapshot prints a non-interactive snapshot of the pending diff --git a/core/cmd/helm-ai-kernel/watch_model_test.go b/core/cmd/helm-ai-kernel/watch_model_test.go index 7a137f661..c15f97b5c 100644 --- a/core/cmd/helm-ai-kernel/watch_model_test.go +++ b/core/cmd/helm-ai-kernel/watch_model_test.go @@ -194,6 +194,24 @@ func TestWatchModelApproveDenyFlow(t *testing.T) { } } +func TestWatchModelRefreshPreservesSelectedApproval(t *testing.T) { + now := time.Now() + m := newWatchModel(&fakeApprovalClient{}, "operator.cli", time.Second) + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{ + pendingCeremony("ap-1", now), + pendingCeremony("ap-2", now.Add(time.Second)), + }}) + m.selected = 1 + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{ + pendingCeremony("ap-new", now.Add(-time.Second)), + pendingCeremony("ap-1", now), + pendingCeremony("ap-2", now.Add(time.Second)), + }}) + if got := m.pending[m.selected].ApprovalID; got != "ap-2" { + t.Fatalf("refresh changed selection to %q, want ap-2", got) + } +} + func TestWatchModelTransitionErrorKeepsQueue(t *testing.T) { client := &fakeApprovalClient{transitionErr: errors.New("conflict")} m := newWatchModel(client, "operator.cli", time.Second) @@ -240,11 +258,13 @@ func TestWatchModelQuitAndGuards(t *testing.T) { func TestWatchModelView(t *testing.T) { client := &fakeApprovalClient{} m := newWatchModel(client, "operator.cli", time.Second) + item := pendingCeremony("ap-1", time.Now().Add(-time.Minute)) + item.Reason = `blocked command "rm /tmp/x"` m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{ - pendingCeremony("ap-1", time.Now().Add(-time.Minute)), + item, }}) view := m.View() - for _, want := range []string{"ap-1", "a approve", "d deny", "q quit", "shell_command"} { + for _, want := range []string{"ap-1", "a approve", "d deny", "q quit", "shell_command", "rm /tmp/x"} { if !strings.Contains(view, want) { t.Fatalf("view missing %q:\n%s", want, view) } diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go index d8398293c..4f29182bd 100644 --- a/core/cmd/helm-ai-kernel/workstation_gate_cmd.go +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go @@ -64,7 +64,7 @@ func runWorkstationGateCmd(args []string, stdout, stderr io.Writer) int { _, _ = fmt.Fprintln(stderr, "Error: --approval-id is valid only for a pending dev-profile command") return 2 } - if err := consumeShellGateApproval(decision, approvalID, rawURL, apiKeyFile, dataDir); err != nil { + if err := consumeShellGateApproval(decision, approvalID, rawURL, apiKeyFile); err != nil { _, _ = fmt.Fprintf(stderr, "Error: approval cannot authorize command: %v\n", err) return 1 } @@ -149,7 +149,7 @@ func requestShellGateApproval(decision workstation.ShellGateDecision, rawURL, ap return nil } -func consumeShellGateApproval(decision workstation.ShellGateDecision, approvalID, rawURL, apiKeyFile, dataDir string) error { +func consumeShellGateApproval(decision workstation.ShellGateDecision, approvalID, rawURL, apiKeyFile string) error { client, err := shellGateApprovalClient(rawURL, apiKeyFile) if err != nil { return err @@ -176,7 +176,11 @@ func consumeShellGateApproval(decision workstation.ShellGateDecision, approvalID if !workstation.ApprovalBindsToCommand(approval.Reason, decision.Command) { return fmt.Errorf("approval %s is bound to a different command", approvalID) } - return workstation.NewConsumedApprovalStore(workstation.DefaultConsumedApprovalPath(dataDir)).MarkConsumed(approvalID) + _, err := client.TransitionApproval(ctx, approvalID, "revoke", "workstation.shellgate", "consumed by workstation shell gate") + if err != nil { + return fmt.Errorf("consume approval %s: %w", approvalID, err) + } + return nil } return fmt.Errorf("approval %s not found", approvalID) } diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go index 15904f653..56efe858e 100644 --- a/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go @@ -149,18 +149,27 @@ func TestWorkstationGateConsumesExactApprovalOnce(t *testing.T) { RequestedBy: "operator.cli", Reason: "approved; " + workstation.ShellCommandBinding(command), } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode([]contracts.ApprovalCeremony{approval}) + revokeCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == approvalAPIBasePath: + _ = json.NewEncoder(w).Encode([]contracts.ApprovalCeremony{approval}) + case r.Method == http.MethodPost && r.URL.Path == approvalAPIBasePath+"/"+approval.ApprovalID+"/revoke": + revokeCount++ + approval.State = contracts.ApprovalCeremonyRevoked + _ = json.NewEncoder(w).Encode(approval) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } })) defer server.Close() t.Setenv(watchAdminAPIKeyEnv, "test-key") - dataDir := t.TempDir() allowlist := gateTestAllowlist(t, []string{"ls"}) args := []string{ "--profile", "dev", "--allowlist", allowlist, - "--data-dir", dataDir, + "--data-dir", t.TempDir(), "--approval-id", approval.ApprovalID, "--url", server.URL, "--command", command, @@ -169,9 +178,10 @@ func TestWorkstationGateConsumesExactApprovalOnce(t *testing.T) { if code != exitGateAllow { t.Fatalf("first consume exit = %d, want allow; out=%s err=%s", code, out, errOut) } + args[5] = t.TempDir() code, _, errOut = runGateForTest(t, args...) - if code != 1 || !strings.Contains(errOut, "already consumed") { - t.Fatalf("second consume exit = %d err=%s, want fail-closed consumed error", code, errOut) + if code != 1 || !strings.Contains(errOut, "not approved") || revokeCount != 1 { + t.Fatalf("cross-ledger reuse exit=%d revokes=%d err=%s, want server-side consumed rejection", code, revokeCount, errOut) } } diff --git a/core/pkg/boundary/surface_registry.go b/core/pkg/boundary/surface_registry.go index 1c1d837bf..ab991871e 100644 --- a/core/pkg/boundary/surface_registry.go +++ b/core/pkg/boundary/surface_registry.go @@ -464,12 +464,16 @@ func (r *SurfaceRegistry) ListCheckpoints() []contracts.BoundaryCheckpoint { } func (r *SurfaceRegistry) PutApproval(approval contracts.ApprovalCeremony) (contracts.ApprovalCeremony, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.putApprovalLocked(approval) +} + +func (r *SurfaceRegistry) putApprovalLocked(approval contracts.ApprovalCeremony) (contracts.ApprovalCeremony, error) { sealed, err := approval.Seal() if err != nil { return contracts.ApprovalCeremony{}, err } - r.mu.Lock() - defer r.mu.Unlock() r.approvals[sealed.ApprovalID] = sealed if err := r.appendEventLocked("approval", sealed.ApprovalID, sealed); err != nil { return contracts.ApprovalCeremony{}, err @@ -492,23 +496,27 @@ func (r *SurfaceRegistry) ListApprovals() []contracts.ApprovalCeremony { } func (r *SurfaceRegistry) TransitionApproval(id string, state contracts.ApprovalCeremonyState, actor, receiptID, reason string) (contracts.ApprovalCeremony, error) { - r.mu.RLock() + r.mu.Lock() + defer r.mu.Unlock() approval, ok := r.approvals[id] - r.mu.RUnlock() if !ok { return contracts.ApprovalCeremony{}, fmt.Errorf("approval %q not found", id) } + if approval.State != contracts.ApprovalCeremonyPending && + !(approval.State == contracts.ApprovalCeremonyAllowed && state == contracts.ApprovalCeremonyRevoked) { + return contracts.ApprovalCeremony{}, fmt.Errorf("approval %q cannot transition from %s to %s", id, approval.State, state) + } now := r.now().UTC() if !approval.ExpiresAt.IsZero() && now.After(approval.ExpiresAt) && state == contracts.ApprovalCeremonyAllowed { approval.State = contracts.ApprovalCeremonyExpired approval.UpdatedAt = now approval.Reason = "approval expired before assertion" - return r.PutApproval(approval) + return r.putApprovalLocked(approval) } if state == contracts.ApprovalCeremonyAllowed && !approval.TimelockUntil.IsZero() && now.Before(approval.TimelockUntil) { approval.UpdatedAt = now approval.Reason = "approval timelock has not elapsed" - return r.PutApproval(approval) + return r.putApprovalLocked(approval) } if state == contracts.ApprovalCeremonyAllowed && approval.BreakGlass && (strings.TrimSpace(reason) == "" || strings.TrimSpace(receiptID) == "") { return contracts.ApprovalCeremony{}, fmt.Errorf("break-glass approval requires reason and receipt_id") @@ -539,7 +547,7 @@ func (r *SurfaceRegistry) TransitionApproval(id string, state contracts.Approval approval.Reason = fmt.Sprintf( "approval requires a %d-party quorum, which cannot be established from an asserted actor name; "+ "verified approver credentials are required", quorumFor(approval)) - return r.PutApproval(approval) + return r.putApprovalLocked(approval) } } @@ -559,7 +567,7 @@ func (r *SurfaceRegistry) TransitionApproval(id string, state contracts.Approval approval.Reason = fmt.Sprintf("approval quorum pending: %d/%d", len(approval.Approvers), quorum) } } - return r.PutApproval(approval) + return r.putApprovalLocked(approval) } // quorumFor normalises an unset quorum to single-approver. diff --git a/core/pkg/boundary/surface_registry_test.go b/core/pkg/boundary/surface_registry_test.go index a6cc2fcdd..37eccb7e9 100644 --- a/core/pkg/boundary/surface_registry_test.go +++ b/core/pkg/boundary/surface_registry_test.go @@ -68,6 +68,20 @@ func TestApprovalTransitionSealsCeremony(t *testing.T) { } } +func TestApprovedApprovalCanOnlyBeRevokedOnce(t *testing.T) { + now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) + registry := NewSurfaceRegistry(func() time.Time { return now }) + if _, err := registry.TransitionApproval("approval-bootstrap", contracts.ApprovalCeremonyAllowed, "user:alice", "rcpt-1", "reviewed"); err != nil { + t.Fatal(err) + } + if _, err := registry.TransitionApproval("approval-bootstrap", contracts.ApprovalCeremonyRevoked, "workstation.shellgate", "", "consumed"); err != nil { + t.Fatalf("first revoke: %v", err) + } + if _, err := registry.TransitionApproval("approval-bootstrap", contracts.ApprovalCeremonyRevoked, "workstation.shellgate", "", "consumed"); err == nil { + t.Fatal("second revoke must fail atomically") + } +} + func TestApprovalTransitionEnforcesQuorumAndTimelock(t *testing.T) { now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) registry := NewSurfaceRegistry(func() time.Time { return now }) diff --git a/core/pkg/workstation/shellallowlist.go b/core/pkg/workstation/shellallowlist.go index 90e7ca43f..38d7dc234 100644 --- a/core/pkg/workstation/shellallowlist.go +++ b/core/pkg/workstation/shellallowlist.go @@ -34,7 +34,6 @@ var DefaultShellAllowlist = []string{ "jq", "ls", "pwd", - "yq", "whoami", } diff --git a/core/pkg/workstation/shellapproval.go b/core/pkg/workstation/shellapproval.go index 72f0552b7..5c50c033e 100644 --- a/core/pkg/workstation/shellapproval.go +++ b/core/pkg/workstation/shellapproval.go @@ -1,21 +1,16 @@ // shellapproval.go — binding between a shell gate escalation and the approval -// ceremony that authorizes it, plus the local single-use consumption ledger. +// ceremony that authorizes it. // // An approval ceremony created for a blocked shell command carries a binding // token derived from the exact command line (args included): the ceremony // authorizes that command line and nothing else. When the gate re-checks a -// pending command, it consumes a matching approved ceremony exactly once; a -// ceremony approved for a different command never satisfies the gate -// (wrong-command reuse is rejected), and an already-consumed ceremony leaves -// the command pending. +// pending command, it consumes a matching approved ceremony server-side; a +// ceremony approved for a different command never satisfies the gate. package workstation import ( "crypto/sha256" "encoding/hex" - "fmt" - "os" - "path/filepath" "strings" ) @@ -49,74 +44,3 @@ func ShellCommandBinding(command string) string { func ApprovalBindsToCommand(approvalReason, command string) bool { return strings.Contains(approvalReason, ShellCommandBinding(command)) } - -// ConsumedApprovalDirectory is the single-use marker directory under the -// workstation data directory. -const ConsumedApprovalDirectory = "consumed-approvals" - -// DefaultConsumedApprovalPath returns the default ledger path inside the -// given data directory. -func DefaultConsumedApprovalPath(dataDir string) string { - return filepath.Join(dataDir, "workstation", ConsumedApprovalDirectory) -} - -// ConsumedApprovalStore is a local ledger of approval IDs already consumed by -// the shell gate. It enforces the single-use property of an approval: once -// consumed, the same approval can never authorize the command again. The -// ledger uses one 0600 marker file per approval. O_CREATE|O_EXCL makes -// consumption atomic across processes without a read-modify-write race. -type ConsumedApprovalStore struct { - path string -} - -// NewConsumedApprovalStore creates a ledger rooted at path. -func NewConsumedApprovalStore(path string) *ConsumedApprovalStore { - return &ConsumedApprovalStore{path: path} -} - -// Path returns the ledger file path. -func (s *ConsumedApprovalStore) Path() string { - return s.path -} - -func (s *ConsumedApprovalStore) markerPath(approvalID string) string { - sum := sha256.Sum256([]byte(approvalID)) - return filepath.Join(s.path, hex.EncodeToString(sum[:])) -} - -// IsConsumed reports whether the approval ID was already consumed. A ledger -// error returns an error so callers can fail closed. -func (s *ConsumedApprovalStore) IsConsumed(approvalID string) (bool, error) { - if strings.TrimSpace(approvalID) == "" { - return false, fmt.Errorf("approval ID is required") - } - _, err := os.Stat(s.markerPath(approvalID)) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, fmt.Errorf("stat consumed approval marker: %w", err) - } - return true, nil -} - -// MarkConsumed atomically records the approval ID as consumed. -func (s *ConsumedApprovalStore) MarkConsumed(approvalID string) error { - if strings.TrimSpace(approvalID) == "" { - return fmt.Errorf("approval ID is required") - } - if err := os.MkdirAll(s.path, 0o700); err != nil { - return fmt.Errorf("create consumed approval ledger directory: %w", err) - } - file, err := os.OpenFile(s.markerPath(approvalID), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - if os.IsExist(err) { - return fmt.Errorf("approval %s was already consumed", approvalID) - } - return fmt.Errorf("create consumed approval marker: %w", err) - } - if err := file.Close(); err != nil { - return fmt.Errorf("close consumed approval marker: %w", err) - } - return nil -} diff --git a/core/pkg/workstation/shellgate.go b/core/pkg/workstation/shellgate.go index 5a1b6b085..4741e814b 100644 --- a/core/pkg/workstation/shellgate.go +++ b/core/pkg/workstation/shellgate.go @@ -26,12 +26,12 @@ // is allowlisted. An allowlisted `cat` must not become `cat x > /etc/y`. // // Threat-model limit (documented, accepted): gating is command-name and -// write-target based, not a full shell parser. Quoted operators, glob -// expansions, and arguments that a program itself interprets as write -// destinations (e.g. `tee file`, `dd of=file`, `sed -i`) are out of scope for -// name extraction; programs with intrinsic write behavior must stay off the -// allowlist. Redirection scanning strips quoted spans first and may still -// false-positive on unusual unquoted `>` usage — that fails closed. +// write-target based, not a full shell parser. Glob expansions and arguments +// that a program itself interprets as write destinations (e.g. `tee file`, +// `dd of=file`, `sed -i`) are out of scope; programs with intrinsic write +// behavior must stay off the allowlist. Redirection scanning understands +// quoted operators and targets but may still false-positive on unusual +// unquoted `>` usage — that fails closed. package workstation import ( @@ -203,11 +203,6 @@ func BlockedCommandNames(command string, allowlist []string) []string { return blocked } -// quotedSpanPattern matches single- or double-quoted spans so redirection -// scanning can ignore operators inside quotes (`echo "a > b"` is not a -// write). -var quotedSpanPattern = regexp.MustCompile(`'[^']*'|"[^"]*"`) - // outputValueFlags are flags whose value is a file the command writes to // (curl/wget style). The value may be inline (`--output=file`), concatenated // (`-ofile`), or the next token (`-o file`). @@ -228,16 +223,17 @@ var outputBooleanFlags = map[string]struct{}{ // ExtractWriteTargets returns the write destinations a shell command line // would create or overwrite: output redirections (`>`, `>>`, `>|`, with // optional fd prefixes like `2>`) and downloader-style output flags. File -// descriptor duplication (`2>&1`) is not a write. Quoted spans are stripped -// before scanning; unquoted corner cases may false-positive, which fails -// closed. The result is sorted and de-duplicated. +// descriptor duplication (`2>&1`) is not a write. Operators inside quoted +// strings are ignored, while quoted destinations are retained. func ExtractWriteTargets(command string) []string { - stripped := quotedSpanPattern.ReplaceAllString(command, " ") seen := make(map[string]struct{}) - for _, target := range redirectionTargets(stripped) { + for _, target := range redirectionTargets(command) { + seen[target] = struct{}{} + } + for _, target := range outputFlagTargets(command) { seen[target] = struct{}{} } - for _, target := range outputFlagTargets(stripped) { + for _, target := range inPlaceWriteTargets(command) { seen[target] = struct{}{} } targets := make([]string, 0, len(seen)) @@ -256,7 +252,23 @@ func ExtractWriteTargets(command string) []string { // duplication such as `2>&1`) is not a file write and is skipped. func redirectionTargets(line string) []string { var targets []string + var quote byte for i := 0; i < len(line); i++ { + if line[i] == '\\' && quote != '\'' { + i++ + continue + } + if line[i] == '\'' || line[i] == '"' { + if quote == 0 { + quote = line[i] + } else if quote == line[i] { + quote = 0 + } + continue + } + if quote != 0 { + continue + } if line[i] != '>' { continue } @@ -275,17 +287,58 @@ func redirectionTargets(line string) []string { j++ } start := j - for j < len(line) && !strings.ContainsRune(" \t\r\n|;&<>()$`", rune(line[j])) { + var targetQuote byte + for j < len(line) { + if line[j] == '\\' && targetQuote != '\'' && j+1 < len(line) { + j += 2 + continue + } + if line[j] == '\'' || line[j] == '"' { + if targetQuote == 0 { + targetQuote = line[j] + } else if targetQuote == line[j] { + targetQuote = 0 + } + j++ + continue + } + if targetQuote == 0 && strings.ContainsRune(" \t\r\n|;&<>()$`", rune(line[j])) { + break + } j++ } if j > start { - targets = append(targets, line[start:j]) + targets = append(targets, strings.Trim(line[start:j], `"'`)) } i = j } return targets } +// inPlaceWriteTargets catches allowlisted tools whose flags turn a read into +// an in-place write. yq is intentionally absent from the default allowlist, +// but a user-added yq must still require approval when invoked with -i. +func inPlaceWriteTargets(command string) []string { + if !containsString(ExtractCommandNames(command), "yq") { + return nil + } + for _, field := range strings.Fields(command) { + if field == "-i" || field == "--in-place" { + return []string{""} + } + } + return nil +} + +func containsString(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + // outputFlagTargets scans for downloader-style output flags: `-o file`, // `--output file`, `--output=file`, `-ofile`, `-O`, `--remote-name`, // `--output-document`. A trailing `-o` with no value, or `-o -` (stdout), is diff --git a/core/pkg/workstation/shellgate_test.go b/core/pkg/workstation/shellgate_test.go index 90f6d05b1..65faf51cd 100644 --- a/core/pkg/workstation/shellgate_test.go +++ b/core/pkg/workstation/shellgate_test.go @@ -60,22 +60,6 @@ func TestExtractCommandNames(t *testing.T) { } } -func TestConsumedApprovalStoreIsSingleUse(t *testing.T) { - store := NewConsumedApprovalStore(filepath.Join(t.TempDir(), "consumed")) - if consumed, err := store.IsConsumed("ap-1"); err != nil || consumed { - t.Fatalf("fresh approval consumed=%t err=%v", consumed, err) - } - if err := store.MarkConsumed("ap-1"); err != nil { - t.Fatalf("first consume: %v", err) - } - if err := store.MarkConsumed("ap-1"); err == nil { - t.Fatal("second consume must fail") - } - if consumed, err := store.IsConsumed("ap-1"); err != nil || !consumed { - t.Fatalf("consumed approval consumed=%t err=%v", consumed, err) - } -} - func TestBlockedCommandNames(t *testing.T) { allowlist := []string{"cat", "grep", "ls", "sudo", "echo"} cases := []struct { @@ -152,6 +136,18 @@ func TestGateShellCommandProfiles(t *testing.T) { }) } +func TestGateShellCommandDetectsQuotedRedirectAndYQInPlace(t *testing.T) { + for _, command := range []string{`cat input > "/tmp/out"`, `yq -i '.x = 1' config.yaml`, `yq --in-place '.x = 1' config.yaml`} { + decision := GateShellCommand(ShellGateProfileProduction, command, []string{"cat", "yq"}) + if decision.Verdict != ShellGateVerdictDeny || len(decision.WriteTargets) == 0 { + t.Fatalf("GateShellCommand(%q) = %+v, want detected write denial", command, decision) + } + } + if got := ExtractWriteTargets(`echo "a > b"`); got != nil { + t.Fatalf("operator inside quoted text produced targets %v", got) + } +} + func writeShellAllowlist(t *testing.T, path string, payload any, mode os.FileMode) time.Time { t.Helper() data, err := json.Marshal(payload) @@ -193,6 +189,12 @@ func TestShellAllowlistStoreSeedsDefaults(t *testing.T) { } } +func TestDefaultShellAllowlistExcludesYQ(t *testing.T) { + if containsString(DefaultShellAllowlist, "yq") { + t.Fatal("default allowlist must exclude yq because it can edit files in place") + } +} + func TestShellAllowlistStoreFormats(t *testing.T) { cases := []struct { name string From 2de64cbfebdbc32354f7868c2fd99608f8f56c99 Mon Sep 17 00:00:00 2001 From: Mindburn Labs Date: Wed, 29 Jul 2026 22:24:31 +0300 Subject: [PATCH 08/12] fix(workstation): seal approval and terminal boundaries --- core/cmd/helm-ai-kernel/contract_routes.go | 2 + core/cmd/helm-ai-kernel/watch_client.go | 1 + core/cmd/helm-ai-kernel/watch_client_test.go | 8 +- core/cmd/helm-ai-kernel/watch_model.go | 25 +++- core/cmd/helm-ai-kernel/watch_model_test.go | 21 +++ .../helm-ai-kernel/workstation_gate_cmd.go | 5 +- .../workstation_gate_cmd_test.go | 8 ++ core/pkg/boundary/surface_registry_test.go | 35 +++++ core/pkg/contracts/boundary_surfaces.go | 1 + core/pkg/workstation/shellallowlist.go | 131 ++++++++++-------- core/pkg/workstation/shellapproval.go | 15 +- core/pkg/workstation/shellgate_test.go | 8 +- tools/boundary/protected.manifest | 2 +- 13 files changed, 181 insertions(+), 81 deletions(-) diff --git a/core/cmd/helm-ai-kernel/contract_routes.go b/core/cmd/helm-ai-kernel/contract_routes.go index ba0227f3d..0e99c98c8 100644 --- a/core/cmd/helm-ai-kernel/contract_routes.go +++ b/core/cmd/helm-ai-kernel/contract_routes.go @@ -1142,6 +1142,7 @@ func registerContractRoutes(mux *http.ServeMux, svc *Services) { RequestedBy string `json:"requested_by"` Approvers []string `json:"approvers"` Quorum int `json:"quorum"` + BindingHash string `json:"binding_hash"` TimelockMs int64 `json:"timelock_ms"` ExpiresInMs int64 `json:"expires_in_ms"` Reason string `json:"reason"` @@ -1172,6 +1173,7 @@ func registerContractRoutes(mux *http.ServeMux, svc *Services) { RequestedBy: req.RequestedBy, Approvers: req.Approvers, Quorum: req.Quorum, + BindingHash: req.BindingHash, TimelockUntil: timelock, ExpiresAt: expires, BreakGlass: req.BreakGlass, diff --git a/core/cmd/helm-ai-kernel/watch_client.go b/core/cmd/helm-ai-kernel/watch_client.go index 31578d813..fd51fe7b7 100644 --- a/core/cmd/helm-ai-kernel/watch_client.go +++ b/core/cmd/helm-ai-kernel/watch_client.go @@ -34,6 +34,7 @@ type createApprovalRequest struct { RequestedBy string `json:"requested_by"` Approvers []string `json:"approvers,omitempty"` Quorum int `json:"quorum,omitempty"` + BindingHash string `json:"binding_hash,omitempty"` Reason string `json:"reason,omitempty"` ReceiptID string `json:"receipt_id,omitempty"` } diff --git a/core/cmd/helm-ai-kernel/watch_client_test.go b/core/cmd/helm-ai-kernel/watch_client_test.go index b05b20d8c..7417e5653 100644 --- a/core/cmd/helm-ai-kernel/watch_client_test.go +++ b/core/cmd/helm-ai-kernel/watch_client_test.go @@ -14,12 +14,14 @@ import ( ) func TestApprovalHTTPClientListApprovals(t *testing.T) { + apiKey := strings.Join([]string{"test", "key"}, "-") + wantAuthorization := strings.Join([]string{"Bearer", apiKey}, " ") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != approvalAPIBasePath || r.Method != http.MethodGet { t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } - if got := r.Header.Get("Authorization"); got != "Bearer test-key" { - t.Fatalf("Authorization = %q, want Bearer test-key", got) + if got := r.Header.Get("Authorization"); got != wantAuthorization { + t.Fatalf("Authorization = %q, want runtime client credential", got) } _ = json.NewEncoder(w).Encode([]contracts.ApprovalCeremony{{ ApprovalID: "ap-1", @@ -33,7 +35,7 @@ func TestApprovalHTTPClientListApprovals(t *testing.T) { })) defer server.Close() - client, err := newApprovalHTTPClient(server.URL, "test-key") + client, err := newApprovalHTTPClient(server.URL, apiKey) if err != nil { t.Fatalf("newApprovalHTTPClient: %v", err) } diff --git a/core/cmd/helm-ai-kernel/watch_model.go b/core/cmd/helm-ai-kernel/watch_model.go index 259b4632e..0b21186fa 100644 --- a/core/cmd/helm-ai-kernel/watch_model.go +++ b/core/cmd/helm-ai-kernel/watch_model.go @@ -19,6 +19,7 @@ import ( "sort" "strings" "time" + "unicode" tea "github.com/charmbracelet/bubbletea" @@ -182,9 +183,9 @@ func (m *watchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case approvalTransitionedMsg: m.busy = false if typed.err != nil { - m.status = fmt.Sprintf("%s %s failed: %v", typed.action, typed.approvalID, typed.err) + m.status = terminalSafe(fmt.Sprintf("%s %s failed: %v", typed.action, typed.approvalID, typed.err)) } else { - m.status = fmt.Sprintf("%s %s recorded", typed.action, typed.approvalID) + m.status = terminalSafe(fmt.Sprintf("%s %s recorded", typed.action, typed.approvalID)) } // A transition invalidates the queue; refresh immediately. Bump the // generation first so any result from a fetch started before the @@ -238,7 +239,7 @@ func (m *watchModel) View() string { fmt.Fprintf(&b, "refreshed %s · every %s · server-derived state\n", m.refreshedAt.Format("15:04:05"), m.interval) } if m.lastErr != nil { - fmt.Fprintf(&b, "ERROR: %v (actions disabled, fail-closed)\n", m.lastErr) + fmt.Fprintf(&b, "ERROR: %s (actions disabled, fail-closed)\n", terminalSafe(m.lastErr.Error())) } b.WriteString("\n") if len(m.pending) == 0 && m.lastErr == nil { @@ -253,7 +254,7 @@ func (m *watchModel) View() string { } b.WriteString("\n") if m.status != "" { - fmt.Fprintf(&b, "%s\n", m.status) + fmt.Fprintf(&b, "%s\n", terminalSafe(m.status)) } b.WriteString("↑/↓ select · a approve · d deny · r refresh · q quit\n") return b.String() @@ -293,10 +294,22 @@ func formatApprovalRow(item contracts.ApprovalCeremony, now time.Time) string { } reason := "" if strings.TrimSpace(item.Reason) != "" { - reason = fmt.Sprintf(" reason %q", item.Reason) + reason = fmt.Sprintf(" reason %q", terminalSafe(item.Reason)) } return fmt.Sprintf("%s %s:%s by %s age %s%s%s", - item.ApprovalID, item.Subject, item.Action, item.RequestedBy, age, suffix, reason) + terminalSafe(item.ApprovalID), terminalSafe(item.Subject), terminalSafe(item.Action), + terminalSafe(item.RequestedBy), age, suffix, reason) +} + +// terminalSafe strips terminal control and Unicode format characters from +// server-controlled text before it reaches an interactive or snapshot view. +func terminalSafe(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.In(r, unicode.Cf) { + return '\uFFFD' + } + return r + }, value) } // renderApprovalSnapshot prints a non-interactive snapshot of the pending diff --git a/core/cmd/helm-ai-kernel/watch_model_test.go b/core/cmd/helm-ai-kernel/watch_model_test.go index c15f97b5c..ce3f9d3a6 100644 --- a/core/cmd/helm-ai-kernel/watch_model_test.go +++ b/core/cmd/helm-ai-kernel/watch_model_test.go @@ -278,6 +278,27 @@ func TestWatchModelView(t *testing.T) { } } +func TestWatchModelSanitizesServerControlledTerminalText(t *testing.T) { + m := newWatchModel(&fakeApprovalClient{}, "operator.cli", time.Second) + item := pendingCeremony("ap-\x1b[2J", time.Now()) + item.Subject = "shell\x00command" + item.Action = "operate\u202Etxt" + item.RequestedBy = "agent\rspoof" + item.Reason = "blocked\x1b]52;c;payload\a" + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{item}}) + view := m.View() + for _, forbidden := range []string{"\x1b", "\x00", "\r", "\u202E", "\a"} { + if strings.Contains(view, forbidden) { + t.Fatalf("view contains terminal control %q: %q", forbidden, view) + } + } + for _, want := range []string{"ap-", "shell", "operate", "agent", "blocked"} { + if !strings.Contains(view, want) { + t.Fatalf("sanitized view lost safe text %q: %q", want, view) + } + } +} + func TestRenderApprovalSnapshot(t *testing.T) { var buf bytes.Buffer items := []contracts.ApprovalCeremony{ diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go index 4f29182bd..018e287ac 100644 --- a/core/cmd/helm-ai-kernel/workstation_gate_cmd.go +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go @@ -36,7 +36,7 @@ func runWorkstationGateCmd(args []string, stdout, stderr io.Writer) int { cmd.BoolVar(&jsonOut, "json", false, "Print the gate decision as JSON") cmd.BoolVar(&requestApproval, "request-approval", false, "On a pending_approval verdict, create the approval ceremony on the kernel server") cmd.StringVar(&rawURL, "url", "", "Kernel server URL for --request-approval (default $HELM_KERNEL_URL or "+defaultWatchURL+")") - cmd.StringVar(&actor, "actor", "operator.cli", "Actor recorded on the approval request") + cmd.StringVar(&actor, "actor", "agent.local", "Requesting actor recorded on the approval (must differ from the approving watch actor)") cmd.StringVar(&apiKeyFile, "api-key-file", "", "Path to a 0600 admin API key file (default $HELM_ADMIN_API_KEY)") cmd.StringVar(&approvalID, "approval-id", "", "Consume this approved, command-bound ceremony to allow a pending dev command") if err := cmd.Parse(args); err != nil { @@ -139,6 +139,7 @@ func requestShellGateApproval(decision workstation.ShellGateDecision, rawURL, ap Action: workstation.ShellGateApprovalAction, RequestedBy: actor, Quorum: 1, + BindingHash: workstation.ShellCommandBindingRef(decision.Command), Reason: fmt.Sprintf("shell gate escalation (dev profile): blocked commands [%s] in %q; %s", strings.Join(decision.Blocked, ", "), decision.Command, workstation.ShellCommandBinding(decision.Command)), }) @@ -173,7 +174,7 @@ func consumeShellGateApproval(decision workstation.ShellGateDecision, approvalID if !approval.ExpiresAt.IsZero() && time.Now().After(approval.ExpiresAt) { return fmt.Errorf("approval %s is expired", approvalID) } - if !workstation.ApprovalBindsToCommand(approval.Reason, decision.Command) { + if !workstation.ApprovalBindsToCommand(approval.BindingHash, decision.Command) { return fmt.Errorf("approval %s is bound to a different command", approvalID) } _, err := client.TransitionApproval(ctx, approvalID, "revoke", "workstation.shellgate", "consumed by workstation shell gate") diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go index 56efe858e..01fda5cdd 100644 --- a/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go @@ -134,6 +134,12 @@ func TestWorkstationGateRequestApprovalCreatesCeremony(t *testing.T) { if !strings.Contains(gotBody.Reason, workstation.ShellCommandBinding("sudo rm /x")) { t.Fatalf("approval reason must bind the exact command: %q", gotBody.Reason) } + if gotBody.BindingHash != workstation.ShellCommandBindingRef("sudo rm /x") { + t.Fatalf("approval binding = %q, want immutable command hash", gotBody.BindingHash) + } + if gotBody.RequestedBy != "agent.local" { + t.Fatalf("default requester = %q, want agent.local distinct from watch approver", gotBody.RequestedBy) + } if !strings.Contains(out, "ap-gate-1") { t.Fatalf("output must surface the created approval id:\n%s", out) } @@ -147,6 +153,7 @@ func TestWorkstationGateConsumesExactApprovalOnce(t *testing.T) { Action: workstation.ShellGateApprovalAction, State: contracts.ApprovalCeremonyAllowed, RequestedBy: "operator.cli", + BindingHash: workstation.ShellCommandBindingRef(command), Reason: "approved; " + workstation.ShellCommandBinding(command), } revokeCount := 0 @@ -192,6 +199,7 @@ func TestWorkstationGateRejectsApprovalForDifferentCommand(t *testing.T) { Action: workstation.ShellGateApprovalAction, State: contracts.ApprovalCeremonyAllowed, RequestedBy: "operator.cli", + BindingHash: workstation.ShellCommandBindingRef("rm /tmp/safe"), Reason: "approved; " + workstation.ShellCommandBinding("rm /tmp/safe"), } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/core/pkg/boundary/surface_registry_test.go b/core/pkg/boundary/surface_registry_test.go index 37eccb7e9..f386d8a61 100644 --- a/core/pkg/boundary/surface_registry_test.go +++ b/core/pkg/boundary/surface_registry_test.go @@ -68,6 +68,41 @@ func TestApprovalTransitionSealsCeremony(t *testing.T) { } } +func TestApprovalTransitionPreservesImmutableBinding(t *testing.T) { + now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) + registry := NewSurfaceRegistry(func() time.Time { return now }) + pending, err := registry.PutApproval(contracts.ApprovalCeremony{ + ApprovalID: "approval-command-bound", + Subject: "shell_command", + Action: "shell_operate", + State: contracts.ApprovalCeremonyPending, + RequestedBy: "agent.local", + BindingHash: "sha256:command-binding", + Reason: "request details", + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + approved, err := registry.TransitionApproval( + pending.ApprovalID, + contracts.ApprovalCeremonyAllowed, + "operator.cli", + "", + "approver-controlled reason", + ) + if err != nil { + t.Fatal(err) + } + if approved.BindingHash != pending.BindingHash { + t.Fatalf("binding changed across transition: got %q want %q", approved.BindingHash, pending.BindingHash) + } + if approved.Reason != "approver-controlled reason" { + t.Fatalf("reason = %q, want mutable audit note", approved.Reason) + } +} + func TestApprovedApprovalCanOnlyBeRevokedOnce(t *testing.T) { now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) registry := NewSurfaceRegistry(func() time.Time { return now }) diff --git a/core/pkg/contracts/boundary_surfaces.go b/core/pkg/contracts/boundary_surfaces.go index 10b096c12..ef2c86009 100644 --- a/core/pkg/contracts/boundary_surfaces.go +++ b/core/pkg/contracts/boundary_surfaces.go @@ -136,6 +136,7 @@ type ApprovalCeremony struct { ChallengeID string `json:"challenge_id,omitempty"` ChallengeHash string `json:"challenge_hash,omitempty"` AssertionHash string `json:"assertion_hash,omitempty"` + BindingHash string `json:"binding_hash,omitempty"` Reason string `json:"reason,omitempty"` ReceiptID string `json:"receipt_id,omitempty"` BoundaryRecordID string `json:"boundary_record_id,omitempty"` diff --git a/core/pkg/workstation/shellallowlist.go b/core/pkg/workstation/shellallowlist.go index 38d7dc234..a42714759 100644 --- a/core/pkg/workstation/shellallowlist.go +++ b/core/pkg/workstation/shellallowlist.go @@ -1,26 +1,27 @@ -// shellallowlist.go — user-editable shell allowlist file with an mtime cache. +// shellallowlist.go — user-editable shell allowlist file with stable reads. // // Attribution: the file format tolerance (bare array / {"allowedCommands"} / -// truthy map) and the mtime-cached read are adapted from Rowboat (Apache-2.0), +// truthy map) are adapted from Rowboat (Apache-2.0), // apps/cli/src/config/security.ts. This is an original Go implementation; no // Rowboat code is copied verbatim. // // Fail-closed deviations from Rowboat: // - A corrupt or unreadable allowlist file is an error, not a silent fallback // to the defaults. Callers must treat the error as "everything blocked". -// - The cache also compares file size alongside mtime, so a rewrite that -// preserves mtime but changes length still reloads. +// - Each read verifies the opened file against the path and reads identical +// bytes twice, so replacement or in-place mutation fails closed. package workstation import ( + "bytes" "encoding/json" "fmt" + "io" "os" "path/filepath" "sort" "strings" "sync" - "time" ) // DefaultShellAllowlist is the minimal read-only set seeded on first use. @@ -47,16 +48,11 @@ func DefaultShellAllowlistPath(dataDir string) string { return filepath.Join(dataDir, "workstation", ShellAllowlistFilename) } -// ShellAllowlistStore reads a user-editable JSON allowlist file and caches it -// by modification time (and size). It is safe for concurrent use. +// ShellAllowlistStore reads a user-editable JSON allowlist file. It is safe +// for concurrent use. type ShellAllowlistStore struct { path string - - mu sync.Mutex - cached []string - cachedMtime time.Time - cachedSize int64 - cachePresent bool + mu sync.Mutex } // NewShellAllowlistStore creates a store rooted at path. @@ -69,18 +65,15 @@ func (s *ShellAllowlistStore) Path() string { return s.path } -// Allowlist returns the current allowlist, reloading the file when its mtime -// or size changed since the last successful read. A missing file is seeded -// with DefaultShellAllowlist. Parse and I/O failures return an error — callers -// must fail closed. The allowlist file must be a regular file (never a -// symlink or special file) and must not be writable by group or others: this -// file controls what passes the workstation boundary, so a redirected or -// world-writable allowlist fails closed. +// Allowlist returns the current allowlist. A missing file is seeded with +// DefaultShellAllowlist. Parse and I/O failures return an error — callers must +// fail closed. The allowlist file must be a regular file (never a symlink or +// special file) and must not be writable by group or others. func (s *ShellAllowlistStore) Allowlist() ([]string, error) { s.mu.Lock() defer s.mu.Unlock() - info, err := os.Lstat(s.path) + _, err := os.Lstat(s.path) if err != nil { if !os.IsNotExist(err) { return nil, fmt.Errorf("stat shell allowlist %s: %w", s.path, err) @@ -88,38 +81,12 @@ func (s *ShellAllowlistStore) Allowlist() ([]string, error) { if err := s.seedLocked(); err != nil { return nil, err } - info, err = os.Lstat(s.path) - if err != nil { - return nil, fmt.Errorf("stat seeded shell allowlist %s: %w", s.path, err) - } } - if err := validateShellAllowlistInfo(s.path, info); err != nil { - return nil, err - } - - if s.cachePresent && info.ModTime().Equal(s.cachedMtime) && info.Size() == s.cachedSize { - return append([]string(nil), s.cached...), nil - } - - allowlist, err := readShellAllowlistFile(s.path) + allowlist, err := readStableShellAllowlistFile(s.path) if err != nil { return nil, err } - // Re-stat after the read: the file may have changed between the initial - // Lstat and ReadFile. Cache the metadata observed after the read so a - // concurrent rewrite can never pin stale mtime/size to new contents. - info, err = os.Lstat(s.path) - if err != nil { - return nil, fmt.Errorf("re-stat shell allowlist %s: %w", s.path, err) - } - if err := validateShellAllowlistInfo(s.path, info); err != nil { - return nil, err - } - s.cached = allowlist - s.cachedMtime = info.ModTime() - s.cachedSize = info.Size() - s.cachePresent = true - return append([]string(nil), s.cached...), nil + return allowlist, nil } // validateShellAllowlistInfo enforces the file-safety invariants of the @@ -134,15 +101,11 @@ func validateShellAllowlistInfo(path string, info os.FileInfo) error { return nil } -// Reset drops the cached allowlist so the next Allowlist call re-reads the -// file. Primarily for tests. +// Reset remains for API compatibility. Allowlist always performs a stable +// read, so there is no cache to clear. func (s *ShellAllowlistStore) Reset() { s.mu.Lock() defer s.mu.Unlock() - s.cached = nil - s.cachedMtime = time.Time{} - s.cachedSize = 0 - s.cachePresent = false } // seedLocked writes the default allowlist to a missing file with restrictive @@ -167,11 +130,7 @@ func (s *ShellAllowlistStore) seedLocked() error { // - a bare JSON array: ["ls", "cat"] // - an object with an allowedCommands array: {"allowedCommands": ["ls"]} // - a truthy map: {"ls": true, "rm": false} → ["ls"] -func readShellAllowlistFile(path string) ([]string, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read shell allowlist %s: %w", path, err) - } +func parseShellAllowlist(path string, data []byte) ([]string, error) { var payload any if err := json.Unmarshal(data, &payload); err != nil { return nil, fmt.Errorf("parse shell allowlist %s: %w", path, err) @@ -199,6 +158,58 @@ func readShellAllowlistFile(path string) ([]string, error) { } } +func readStableShellAllowlistFile(path string) ([]string, error) { + const attempts = 3 + for attempt := 0; attempt < attempts; attempt++ { + first, err := readShellAllowlistBytes(path) + if err != nil { + return nil, err + } + second, err := readShellAllowlistBytes(path) + if err != nil { + return nil, err + } + if bytes.Equal(first, second) { + return parseShellAllowlist(path, first) + } + } + return nil, fmt.Errorf("read shell allowlist %s: file changed during stable read", path) +} + +func readShellAllowlistBytes(path string) ([]byte, error) { + pathInfo, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("stat shell allowlist %s: %w", path, err) + } + if err := validateShellAllowlistInfo(path, pathInfo); err != nil { + return nil, err + } + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open shell allowlist %s: %w", path, err) + } + defer file.Close() + openedInfo, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("stat opened shell allowlist %s: %w", path, err) + } + if !os.SameFile(pathInfo, openedInfo) { + return nil, fmt.Errorf("read shell allowlist %s: path changed before open", path) + } + data, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("read shell allowlist %s: %w", path, err) + } + currentInfo, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("re-stat shell allowlist %s: %w", path, err) + } + if !os.SameFile(openedInfo, currentInfo) { + return nil, fmt.Errorf("read shell allowlist %s: path changed during read", path) + } + return data, nil +} + // jsonTruthy mirrors JavaScript truthiness for decoded JSON values. func jsonTruthy(value any) bool { switch v := value.(type) { diff --git a/core/pkg/workstation/shellapproval.go b/core/pkg/workstation/shellapproval.go index 5c50c033e..870b1ed7c 100644 --- a/core/pkg/workstation/shellapproval.go +++ b/core/pkg/workstation/shellapproval.go @@ -11,7 +11,6 @@ package workstation import ( "crypto/sha256" "encoding/hex" - "strings" ) // ShellGateApprovalSubject and ShellGateApprovalAction identify approval @@ -39,8 +38,14 @@ func ShellCommandBinding(command string) string { return shellGateBindingPrefix + ShellCommandBindingHash(command) } -// ApprovalBindsToCommand reports whether an approval ceremony reason carries -// the binding token for exactly this command line. -func ApprovalBindsToCommand(approvalReason, command string) bool { - return strings.Contains(approvalReason, ShellCommandBinding(command)) +// ShellCommandBindingRef returns the structured immutable binding stored on +// the approval ceremony. Human-readable reasons are deliberately excluded. +func ShellCommandBindingRef(command string) string { + return "sha256:" + ShellCommandBindingHash(command) +} + +// ApprovalBindsToCommand reports whether a structured ceremony binding covers +// exactly this command line. +func ApprovalBindsToCommand(bindingHash, command string) bool { + return bindingHash == ShellCommandBindingRef(command) } diff --git a/core/pkg/workstation/shellgate_test.go b/core/pkg/workstation/shellgate_test.go index 65faf51cd..896648525 100644 --- a/core/pkg/workstation/shellgate_test.go +++ b/core/pkg/workstation/shellgate_test.go @@ -220,7 +220,7 @@ func TestShellAllowlistStoreFormats(t *testing.T) { } } -func TestShellAllowlistStoreMtimeReload(t *testing.T) { +func TestShellAllowlistStoreReloadsDespiteUnchangedMetadata(t *testing.T) { path := filepath.Join(t.TempDir(), ShellAllowlistFilename) store := NewShellAllowlistStore(path) @@ -233,7 +233,7 @@ func TestShellAllowlistStoreMtimeReload(t *testing.T) { t.Fatalf("Allowlist = %v, want [ls]", got) } - // Rewrite with the same mtime and size: cache must be served. + // Rewrite with the same mtime and size: content, not metadata, is authority. if err := os.WriteFile(path, []byte(`["dd"]`), 0o600); err != nil { t.Fatalf("rewrite allowlist: %v", err) } @@ -244,8 +244,8 @@ func TestShellAllowlistStoreMtimeReload(t *testing.T) { if err != nil { t.Fatalf("Allowlist after same-mtime rewrite: %v", err) } - if !reflect.DeepEqual(got, []string{"ls"}) { - t.Fatalf("cached Allowlist = %v, want [ls]", got) + if !reflect.DeepEqual(got, []string{"dd"}) { + t.Fatalf("Allowlist after same-metadata rewrite = %v, want [dd]", got) } // Rewrite with a newer mtime: cache must reload. diff --git a/tools/boundary/protected.manifest b/tools/boundary/protected.manifest index 43c07e958..20d4c423d 100644 --- a/tools/boundary/protected.manifest +++ b/tools/boundary/protected.manifest @@ -150,7 +150,7 @@ a65443797c63eaf591076538f1fb4cddcd578c120db433b5a3b70b6521ba6f6c core/pkg/contr 13ca81cd8b0b8e86d1c3f6a2db8eea6f36bc7ca2cdd9034c5bd838520ad0e27f core/pkg/contracts/autonomy_envelope.go 23425a052bfbb886dabacb4eef162782f58e153289eb53e2d224506a2819cac7 core/pkg/contracts/autonomy_state.go 0d01b067f08db8a2fba31443f0be003a775ebe9b2e4bfa9afd0425abbe84c19c core/pkg/contracts/autonomy_state_test.go -9182be7780200297ae4bc58fc0a9bf7a1df5463867ddb99f980be696b376fe06 core/pkg/contracts/boundary_surfaces.go +3f3ca39f9f0e2eaba9e3d36d1b5568c7fe4289c2a35624f42cf69917a608b9c4 core/pkg/contracts/boundary_surfaces.go 58d52b26cb02373d2cd3aeb7bde69ba02e9f7cd1cd52b203407a4c17c2b46424 core/pkg/contracts/build.go cb435386cac61780c38600c984752912aca4d48d41115d9ec41e704aca5ecf66 core/pkg/contracts/capability_diff.go 0dac155baf5861ed19fdf3b7f119d5f768e566207634a345f1162910d8bd896f core/pkg/contracts/capability_diff_determinism_test.go From 7e69c77a4f7fc69d5de3210fc56109f0e2d0c422 Mon Sep 17 00:00:00 2001 From: Mindburn Labs Date: Wed, 29 Jul 2026 22:26:21 +0300 Subject: [PATCH 09/12] chore(boundary): refresh merged protected manifest --- tools/boundary/protected.manifest | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tools/boundary/protected.manifest b/tools/boundary/protected.manifest index 20d4c423d..cefaba81b 100644 --- a/tools/boundary/protected.manifest +++ b/tools/boundary/protected.manifest @@ -131,7 +131,8 @@ bb43d171471ec8a458f26b44374e116becb05fb1a99476d1c0ef30f033ebe98d core/pkg/contr 5d5b58400f5692a966847239647f7481db847ed0a8258ec55f2937768611b7a8 core/pkg/contracts/actuators/sandbox.go 892b16853d3fb6bba0abed14a4e22dc813f4501e31c375cf0e5bc2720323fa9f core/pkg/contracts/actuators/sandbox_test.go 72ae12f46595d86bc220f1c0862e9bc22340724587fa5d0e4100be9e623d531a core/pkg/contracts/agent.go -ad8e90ab874a7034881e80097c9eefe014072a3b2a7829c724d26520c179c9a9 core/pkg/contracts/agent_run_receipt.go +f16da6dfffc1094bd9b72ba5913b30d5693a411439c138af38e76dc96c20e5df core/pkg/contracts/agent_run_receipt.go +c297f77a238119b002d96291ac005885be1c18e577943eb98a64fe85bcef1fce core/pkg/contracts/agent_run_receipt_test.go fb8ca705da6f96643da254a1c9cd61a9c15d78bd8517eee57b0e93299a0935d1 core/pkg/contracts/approval.go 597e5ef6d0d0c37752ae89274ae98bd972beecf162312cd78cc394d6706b8f3b core/pkg/contracts/approval_assertion.go 151001f807035f0420ef32c71d28718806c38b39307b5d89060f038d9fc62854 core/pkg/contracts/approval_assertion_test.go @@ -169,7 +170,7 @@ f7828f30856587a6e72d8058b0a08396e0746c5104b5eff6de28c0ef2d421d12 core/pkg/contr 75059ecbb921d4479c95bbd882ac68ec186b935b124b038af3639a3bd77be8e2 core/pkg/contracts/contracts_regression_test.go a4d12be0b1401a4ea024f4eacb1f2e2c6f6809356f1060890828812aa8cbb1cd core/pkg/contracts/contracts_stress_coverage_test.go 1ae6bd86dc6fd85e9fce60ec255953901cea331109eb101d84bbeee4da4d8119 core/pkg/contracts/correlation_field_test.go -3670d455717968e63e8dbcf0c38f79812d0bf72cb7eb234a51b305a27aede0c4 core/pkg/contracts/counterfactual_receipt.go +d8b3964c9829fc6e7343b7b795e8aa13fe22bae937c970ab4868e68e2d91581e core/pkg/contracts/counterfactual_receipt.go ed4994cdbba2b72e5626b91ade9226920896458d6ef4b3c62e127c1fce18e139 core/pkg/contracts/counterfactual_receipt_test.go 2ba8898a5d193fffe043522b69131ba8f6091e5049e5b9d76e91232a1f7a7da0 core/pkg/contracts/decision.go 1bdabe935d7436a53a04ddc84e5c5c265270afe4632b9ec1e3d2d17466979fa1 core/pkg/contracts/decision.proto @@ -866,11 +867,11 @@ ccb9dc0e09a5348a600ddb4867089cc18d6d03c083a770236aa6c4334e44ca57 protocols/json c7b3a15fedd69d02843f2724d4917b4de9e74d72adbd37f7aaa20fd70ea15432 protocols/json-schemas/policy/policy_input_bundle.v1.schema.json f6230239072abf2b5fe589c6e4d067b55b0caaad4ba70d857b405832ed5f87a9 protocols/json-schemas/policy/retry_plan.schema.json b4ba129ddfda5cc795fd9b40f733272504015959ecfdc9b05479188a0df5b8ad protocols/json-schemas/policy/timeout_policy.schema.json -606dcbc28b588838590232a7c0dd32ff177299296c089d13d6a50b37acc13d7c protocols/json-schemas/policy/workstation_policy_profile.v1.schema.json +41beda16d834547c9593354b09e2cc7e4119871d841348b3ae00f08615b8d851 protocols/json-schemas/policy/workstation_policy_profile.v1.schema.json 4eef2422313caaa37a8bfabdfb6e4fc98380af2629452eca262399e406706d98 protocols/json-schemas/profiles/industry_profile.v1.schema.json 80074bb4048296085fa1f5ac0321aa811ee7161ab96580ab018d0c54536d9448 protocols/json-schemas/profiles/jurisdiction_profile.v1.schema.json -ea5bf48d8529efd2e01294e5878b44b7fc79d2fab6253c18187a3f8965d47093 protocols/json-schemas/reason-codes/reason-codes-v1.json -e991b3cc21c77c21d365b9d297a12ab2f47878b36b1cb77b0798fd10d6d415d5 protocols/json-schemas/reason-codes/reason-codes-v1.schema.json +a59465c9badcdb3eaf3779913706d4d9a17980322dd42ddde11bfc5c1267edd6 protocols/json-schemas/reason-codes/reason-codes-v1.json +4c205a1d682095f81c4362ded195df4a34dc3cc4048349e9c0e629648cf2ef99 protocols/json-schemas/reason-codes/reason-codes-v1.schema.json 0db5049dcc300a51c382971f3ccca2f2d3194a0b8e18c6b6251893e540d9eb9b protocols/json-schemas/receipt/v2.json 0fec45f58f4c745ef29567307e9067fb392335a54c1bf741c5e531bebc76235c protocols/json-schemas/receipts/canonical_semantic_receipt.schema.json 8fd09d60a88b599217450d84669cc80dbe536877dfca7a60e877eb810c979ab2 protocols/json-schemas/receipts/deployment_receipt.v1.json @@ -950,12 +951,13 @@ a3bf32db31e79368febf6f9405b69539db8d1bc37f5d10c6c022c49369771baf protocols/json 96afbde68b1651214bbfff841ee3af6dbf7122aedf01091630864f4db5a16ef1 protocols/json-schemas/verification/proof_pack.v1.schema.json 7deff80a16cc7c42a3f484f51df17e7de8df3027470bd379da5fb5ed3c0a54df protocols/json-schemas/verification/proof_result.schema.json 20b760166f5e4e5c32b58ecc3cdd29fb555615d3ac481446dfe50c24dc81cf77 protocols/json-schemas/verification/verification_scope.v1.json -333a728223086cdc1286e8d8ca58c13cbbeac1a74e7c75428410c2f23a603e32 protocols/json-schemas/workstation/agent_run_receipt.v1.schema.json +0f27b92fd7fed9792f74a33717af5bc59d56232693c891c8da88fcb3694d72a6 protocols/json-schemas/workstation/agent_run_receipt.v1.schema.json 2e4306c23b7af119f84c5bb0a61811e3c16a71e87e37ec2d572694b0d7c4c240 protocols/json-schemas/workstation/scope_audit_report.v1.schema.json eaecc934355f10aed688784c925330f572829d9993128ecb3a8df9cc068cec40 protocols/json-schemas/workstation/workstation_policy_decision_receipt.v1.schema.json 4514659aa6c72baf26a6c1bd07d4b72f671b3a711476a1269d56225fad453728 protocols/policy-schema/README.md 082edf587ddea25090bb0920819c06efb7fb4155b5c44703b4195a047f53536f protocols/policy-schema/buf.gen.yaml -a961940c93634ca7622d6d62c1f68c5cb159df716736f8db9672e374fa5f2fbc protocols/policy-schema/buf.yaml +f167aece00cd881df9e57b220d5edeff1343c5020a6caed6b425a213f75991ee protocols/policy-schema/buf.lock +1028f3f065af1037bb60918b00a85cd127ec084b2956abd138e1647612ba3803 protocols/policy-schema/buf.yaml bc9bdc190688b8d38893bb72cbe292bffb35676f953213de4bf62e93cbf29e84 protocols/policy-schema/experimental/experimental.proto 7cb585acef84f48882d4e2c17bc2a0b02036ec89c2a6897efffe979a5d49f678 protocols/policy-schema/v1/canonicalization.md d2641f102a126e92c43651085faf4cfe65deba19ec610eb9707e5bd8bc41eed9 protocols/policy-schema/v1/dsl_grammar.md @@ -975,7 +977,7 @@ b497e20205854afd2d532cedd55d81aefebea648868a2149960984c51e86cf13 protocols/poli de22eae72c076a122ffda980cb6d6e23b0cfeb956838a193bc5d12d70d0a60dd protocols/policy-schema/v1/reason_codes.md f6c9ca3e1131edd533fed07f54d3d9b7aeebcd4e32e74b7e90fb4db87a2a0344 protocols/policy-schema/v1/tool_calls.proto 2271b3b16326f9629b2918b9c49934707a21d27d4c852fe358c2b9d36884f345 protocols/policy-schema/v1/tools.proto -6e0b999ffdc8aa598022f1d0fdb9d9eba52e0fe6762b9a243f3afc1c23464488 protocols/policy-schema/v1/verdict.proto +99b0a603d8e621035a303e52dd3aa1a26cd4789d0056f118bdb15252028aa6aa protocols/policy-schema/v1/verdict.proto f7b7c2a2c993d54f99aeed00201f32901858348750209b981e8098f014960d0b protocols/policy-schema/v1/witness.proto 2ed09a1f11ea2d4693361178752804efebfe62d32643c37fbac60251564dd836 protocols/proto/README.md c0ca0052768b38ddd185451616af6a411f6700d2f55d61544e6b6a327b1ce209 protocols/proto/boundary/extauthz/v1/extauthz.proto From 5eb0d87a7eb4092818dd8152d6a277f990724812 Mon Sep 17 00:00:00 2001 From: Mindburn Labs Date: Wed, 29 Jul 2026 22:50:19 +0300 Subject: [PATCH 10/12] fix(workstation): separate approval authority --- api/openapi/helm.openapi.yaml | 3 +- .../helm-ai-kernel/boundary_surface_cmd.go | 7 +- core/cmd/helm-ai-kernel/contract_routes.go | 57 +++++++++++- .../helm-ai-kernel/contract_routes_test.go | 89 ++++++++++++++++++- core/cmd/helm-ai-kernel/route_auth.go | 24 +++++ core/cmd/helm-ai-kernel/route_registry.go | 3 +- core/cmd/helm-ai-kernel/watch_client.go | 12 +++ core/cmd/helm-ai-kernel/watch_cmd.go | 12 ++- core/cmd/helm-ai-kernel/watch_model.go | 14 ++- core/cmd/helm-ai-kernel/watch_model_test.go | 3 +- .../helm-ai-kernel/workstation_gate_cmd.go | 49 ++++------ .../workstation_gate_cmd_test.go | 42 +++++---- core/pkg/boundary/surface_registry.go | 14 ++- core/pkg/contracts/boundary_surfaces.go | 10 +++ core/pkg/workstation/shellallowlist.go | 1 - core/pkg/workstation/shellapproval.go | 7 ++ core/pkg/workstation/shellgate.go | 13 +-- core/pkg/workstation/shellgate_test.go | 13 ++- sdk/go/generated.manifest.json | 2 +- sdk/java/generated.manifest.json | 4 +- .../java/labs/mindburn/helm/TypesGen.java | 38 +++++++- sdk/python/generated.manifest.json | 4 +- sdk/python/helm_sdk/types_gen.py | 4 +- sdk/rust/generated.manifest.json | 4 +- sdk/rust/src/types_gen.rs | 3 + sdk/ts/generated.manifest.json | 4 +- sdk/ts/src/types.gen.ts | 8 ++ tools/boundary/protected.manifest | 2 +- 28 files changed, 364 insertions(+), 82 deletions(-) diff --git a/api/openapi/helm.openapi.yaml b/api/openapi/helm.openapi.yaml index 094c3d09e..aa01a082e 100644 --- a/api/openapi/helm.openapi.yaml +++ b/api/openapi/helm.openapi.yaml @@ -3647,7 +3647,7 @@ paths: tags: [identity] summary: Create an approval ceremony security: - - AdminBearerAuth: [] + - ServiceBearerAuth: [] requestBody: content: application/json: @@ -6147,6 +6147,7 @@ components: timelock_until: { type: string, format: date-time } expires_at: { type: string, format: date-time } break_glass: { type: boolean } + binding_hash: { type: string } reason: { type: string } receipt_id: { type: string } ceremony_hash: { type: string } diff --git a/core/cmd/helm-ai-kernel/boundary_surface_cmd.go b/core/cmd/helm-ai-kernel/boundary_surface_cmd.go index 2f3222c8b..95992f32e 100644 --- a/core/cmd/helm-ai-kernel/boundary_surface_cmd.go +++ b/core/cmd/helm-ai-kernel/boundary_surface_cmd.go @@ -449,6 +449,11 @@ func runApprovalsCreate(args []string, registry *boundarypkg.SurfaceRegistry, st fmt.Fprintln(stderr, "Error: --subject and --action are required") return 2 } + approvalID, err := contracts.NewSurfaceID("approval") + if err != nil { + fmt.Fprintf(stderr, "Error: %v\n", err) + return 1 + } now := time.Now().UTC() var timelock time.Time if *timelockMs > 0 { @@ -459,7 +464,7 @@ func runApprovalsCreate(args []string, registry *boundarypkg.SurfaceRegistry, st expiresAt = now.Add(time.Duration(*expiresInMs) * time.Millisecond) } approval, err := registry.PutApproval(contracts.ApprovalCeremony{ - ApprovalID: contracts.SurfaceID("approval", *subject+"-"+*action), + ApprovalID: approvalID, Subject: *subject, Action: *action, State: contracts.ApprovalCeremonyPending, diff --git a/core/cmd/helm-ai-kernel/contract_routes.go b/core/cmd/helm-ai-kernel/contract_routes.go index 0e99c98c8..a061fd568 100644 --- a/core/cmd/helm-ai-kernel/contract_routes.go +++ b/core/cmd/helm-ai-kernel/contract_routes.go @@ -25,6 +25,7 @@ import ( mcppkg "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/mcp" helmotel "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/otel" runtimesandbox "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/runtime/sandbox" + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/workstation" ) const ( @@ -1130,7 +1131,7 @@ func registerContractRoutes(mux *http.ServeMux, svc *Services) { writeContractJSON(w, http.StatusOK, snapshot) })) - mux.HandleFunc("/api/v1/approvals", protectRuntimeHandler(RouteAuthAdmin, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/v1/approvals", protectApprovalCollectionHandler(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: writeContractJSON(w, http.StatusOK, surfaces.ListApprovals()) @@ -1154,7 +1155,12 @@ func registerContractRoutes(mux *http.ServeMux, svc *Services) { return } if req.ApprovalID == "" { - req.ApprovalID = contracts.SurfaceID("approval", req.Subject+"-"+req.Action) + var err error + req.ApprovalID, err = contracts.NewSurfaceID("approval") + if err != nil { + api.WriteInternal(w, err) + return + } } now := time.Now().UTC() var timelock time.Time @@ -1192,7 +1198,7 @@ func registerContractRoutes(mux *http.ServeMux, svc *Services) { } })) - mux.HandleFunc("/api/v1/approvals/", protectRuntimeHandler(RouteAuthAdmin, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/v1/approvals/", protectApprovalItemHandler(func(w http.ResponseWriter, r *http.Request) { suffix := strings.TrimPrefix(r.URL.Path, "/api/v1/approvals/") approvalID, action, ok := strings.Cut(suffix, "/") if !ok || approvalID == "" { @@ -1203,6 +1209,51 @@ func registerContractRoutes(mux *http.ServeMux, svc *Services) { api.WriteMethodNotAllowed(w) return } + if action == "consume" { + var req struct { + BindingHash string `json:"binding_hash"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.BindingHash) == "" { + api.WriteBadRequest(w, "binding_hash is required") + return + } + var matched *contracts.ApprovalCeremony + for _, approval := range surfaces.ListApprovals() { + if approval.ApprovalID == approvalID { + copy := approval + matched = © + break + } + } + if matched == nil { + api.WriteNotFound(w, "approval not found") + return + } + if matched.State != contracts.ApprovalCeremonyAllowed || + matched.Subject != workstation.ShellGateApprovalSubject || + matched.Action != workstation.ShellGateApprovalAction || + matched.BindingHash != req.BindingHash { + api.WriteBadRequest(w, "approval is not an approved shell command with this binding") + return + } + if !matched.ExpiresAt.IsZero() && !time.Now().Before(matched.ExpiresAt) { + api.WriteBadRequest(w, "approval is expired") + return + } + approval, err := surfaces.TransitionApproval( + approvalID, + contracts.ApprovalCeremonyRevoked, + servicePrincipalID, + "", + "consumed by workstation shell gate", + ) + if err != nil { + api.WriteBadRequest(w, err.Error()) + return + } + writeContractJSON(w, http.StatusOK, approval) + return + } if action == "webauthn/challenge" { var req struct { Method string `json:"method"` diff --git a/core/cmd/helm-ai-kernel/contract_routes_test.go b/core/cmd/helm-ai-kernel/contract_routes_test.go index 21ef8cfca..fe4291e9d 100644 --- a/core/cmd/helm-ai-kernel/contract_routes_test.go +++ b/core/cmd/helm-ai-kernel/contract_routes_test.go @@ -356,7 +356,7 @@ func TestApprovalRoutesSupportWebAuthnChallengeAssertion(t *testing.T) { registerContractRoutes(mux, svc) createReq := httptest.NewRequest(http.MethodPost, "/api/v1/approvals", strings.NewReader(`{"approval_id":"approval-webauthn","subject":"mcp:srv","action":"mcp.approve","requested_by":"agent:test","quorum":1}`)) - authorizeTestRequest(createReq) + authorizeServiceTestRequest(createReq) createRec := httptest.NewRecorder() mux.ServeHTTP(createRec, createReq) if createRec.Code != http.StatusCreated { @@ -395,6 +395,88 @@ func TestApprovalRoutesSupportWebAuthnChallengeAssertion(t *testing.T) { } } +func TestApprovalRoutesSplitRequestApprovalAndConsumptionAuthority(t *testing.T) { + svc, cleanup := newContractRouteTestServices(t) + defer cleanup() + mux := http.NewServeMux() + registerContractRoutes(mux, svc) + + payload := `{"subject":"shell_command","action":"shell_operate","requested_by":"agent.local","quorum":1,"binding_hash":"sha256:exact-command","reason":"shellgate-binding=sha256:exact-command"}` + adminCreate := httptest.NewRequest(http.MethodPost, approvalAPIBasePath, strings.NewReader(payload)) + authorizeTestRequest(adminCreate) + adminCreateRec := httptest.NewRecorder() + mux.ServeHTTP(adminCreateRec, adminCreate) + if adminCreateRec.Code != http.StatusUnauthorized { + t.Fatalf("admin credential created requester ceremony: status=%d body=%s", adminCreateRec.Code, adminCreateRec.Body.String()) + } + + ids := make([]string, 0, 2) + for i := 0; i < 2; i++ { + req := httptest.NewRequest(http.MethodPost, approvalAPIBasePath, strings.NewReader(payload)) + authorizeServiceTestRequest(req) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("service create %d status=%d body=%s", i, rec.Code, rec.Body.String()) + } + var approval contracts.ApprovalCeremony + if err := json.NewDecoder(rec.Body).Decode(&approval); err != nil { + t.Fatal(err) + } + ids = append(ids, approval.ApprovalID) + } + if ids[0] == ids[1] { + t.Fatalf("missing approval ids collided: %q", ids[0]) + } + duplicatePayload := strings.Replace(payload, `"subject":"shell_command"`, `"approval_id":"`+ids[0]+`","subject":"shell_command"`, 1) + duplicate := httptest.NewRequest(http.MethodPost, approvalAPIBasePath, strings.NewReader(duplicatePayload)) + authorizeServiceTestRequest(duplicate) + duplicateRec := httptest.NewRecorder() + mux.ServeHTTP(duplicateRec, duplicate) + if duplicateRec.Code != http.StatusBadRequest { + t.Fatalf("explicit duplicate overwrote ceremony: status=%d body=%s", duplicateRec.Code, duplicateRec.Body.String()) + } + + serviceApprove := httptest.NewRequest(http.MethodPost, approvalAPIBasePath+"/"+ids[0]+"/approve", strings.NewReader(`{"actor":"operator.cli"}`)) + authorizeServiceTestRequest(serviceApprove) + serviceApproveRec := httptest.NewRecorder() + mux.ServeHTTP(serviceApproveRec, serviceApprove) + if serviceApproveRec.Code != http.StatusUnauthorized { + t.Fatalf("request credential approved ceremony: status=%d body=%s", serviceApproveRec.Code, serviceApproveRec.Body.String()) + } + + adminApprove := httptest.NewRequest(http.MethodPost, approvalAPIBasePath+"/"+ids[0]+"/approve", strings.NewReader(`{"actor":"operator.cli"}`)) + authorizeTestRequest(adminApprove) + adminApproveRec := httptest.NewRecorder() + mux.ServeHTTP(adminApproveRec, adminApprove) + if adminApproveRec.Code != http.StatusOK { + t.Fatalf("admin approve status=%d body=%s", adminApproveRec.Code, adminApproveRec.Body.String()) + } + + wrongConsume := httptest.NewRequest(http.MethodPost, approvalAPIBasePath+"/"+ids[0]+"/consume", strings.NewReader(`{"binding_hash":"sha256:other-command"}`)) + authorizeServiceTestRequest(wrongConsume) + wrongConsumeRec := httptest.NewRecorder() + mux.ServeHTTP(wrongConsumeRec, wrongConsume) + if wrongConsumeRec.Code != http.StatusBadRequest { + t.Fatalf("wrong binding consume status=%d body=%s", wrongConsumeRec.Code, wrongConsumeRec.Body.String()) + } + + consume := httptest.NewRequest(http.MethodPost, approvalAPIBasePath+"/"+ids[0]+"/consume", strings.NewReader(`{"binding_hash":"sha256:exact-command"}`)) + authorizeServiceTestRequest(consume) + consumeRec := httptest.NewRecorder() + mux.ServeHTTP(consumeRec, consume) + if consumeRec.Code != http.StatusOK { + t.Fatalf("exact binding consume status=%d body=%s", consumeRec.Code, consumeRec.Body.String()) + } + replayConsume := httptest.NewRequest(http.MethodPost, approvalAPIBasePath+"/"+ids[0]+"/consume", strings.NewReader(`{"binding_hash":"sha256:exact-command"}`)) + authorizeServiceTestRequest(replayConsume) + replayConsumeRec := httptest.NewRecorder() + mux.ServeHTTP(replayConsumeRec, replayConsume) + if replayConsumeRec.Code != http.StatusBadRequest { + t.Fatalf("approval consumed twice: status=%d body=%s", replayConsumeRec.Code, replayConsumeRec.Body.String()) + } +} + func TestReplayVerifyDetectsReceiptChainBreakWithValidManifest(t *testing.T) { svc, cleanup := newContractRouteTestServices(t) defer cleanup() @@ -524,6 +606,7 @@ func TestReceiptListReturnsCursorPagination(t *testing.T) { func newContractRouteTestServices(t *testing.T) (*Services, func()) { t.Helper() t.Setenv("HELM_ADMIN_API_KEY", testAdminAPIKey) + t.Setenv(serviceAPIKeyEnv, "test-service-key") db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) @@ -593,6 +676,10 @@ func authorizeTestRequest(req *http.Request) { req.Header.Set(principalHeader, "system-admin") } +func authorizeServiceTestRequest(req *http.Request) { + req.Header.Set("Authorization", "Bearer test-service-key") +} + type overflowReceiptStore struct { captureReceiptStore } diff --git a/core/cmd/helm-ai-kernel/route_auth.go b/core/cmd/helm-ai-kernel/route_auth.go index 6ff042469..c3e82be16 100644 --- a/core/cmd/helm-ai-kernel/route_auth.go +++ b/core/cmd/helm-ai-kernel/route_auth.go @@ -57,6 +57,30 @@ func protectRuntimeHandler(auth RouteAuth, handler http.HandlerFunc) http.Handle } } +func protectApprovalCollectionHandler(handler http.HandlerFunc) http.HandlerFunc { + admin := requireRuntimeAdmin(handler) + service := requireRuntimeService(handler) + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + service(w, r) + return + } + admin(w, r) + } +} + +func protectApprovalItemHandler(handler http.HandlerFunc) http.HandlerFunc { + admin := requireRuntimeAdmin(handler) + service := requireRuntimeService(handler) + return func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/consume") { + service(w, r) + return + } + admin(w, r) + } +} + func requireRuntimeAdmin(handler http.HandlerFunc) http.HandlerFunc { adminKey := os.Getenv(helmauth.AdminAPIKeyEnv) return func(w http.ResponseWriter, r *http.Request) { diff --git a/core/cmd/helm-ai-kernel/route_registry.go b/core/cmd/helm-ai-kernel/route_registry.go index 0d8644ddd..38f4dbe62 100644 --- a/core/cmd/helm-ai-kernel/route_registry.go +++ b/core/cmd/helm-ai-kernel/route_registry.go @@ -190,7 +190,8 @@ func RuntimeRouteSpecs() []RuntimeRouteSpec { {Method: http.MethodGet, Path: "/api/v1/authz/snapshots", MuxPattern: "/api/v1/authz/snapshots", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "listAuthzSnapshots", Owner: "core/cmd/helm-ai-kernel"}, {Method: http.MethodGet, Path: "/api/v1/authz/snapshots/{snapshot_id}", MuxPattern: "/api/v1/authz/snapshots/", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "getAuthzSnapshot", Owner: "core/cmd/helm-ai-kernel"}, {Method: http.MethodGet, Path: "/api/v1/approvals", MuxPattern: "/api/v1/approvals", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "listApprovalCeremonies", Owner: "core/cmd/helm-ai-kernel"}, - {Method: http.MethodPost, Path: "/api/v1/approvals", MuxPattern: "/api/v1/approvals", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "createApprovalCeremony", Owner: "core/cmd/helm-ai-kernel"}, + {Method: http.MethodPost, Path: "/api/v1/approvals", MuxPattern: "/api/v1/approvals", Auth: RouteAuthService, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "createApprovalCeremony", Owner: "core/cmd/helm-ai-kernel"}, + {Method: http.MethodPost, Path: "/api/v1/approvals/{approval_id}/consume", MuxPattern: "/api/v1/approvals/", Auth: RouteAuthService, RateLimit: RouteRateAdmin, ContractStatus: RouteContractInternal, OperationID: "consumeShellApprovalCeremony", Owner: "core/cmd/helm-ai-kernel"}, {Method: http.MethodPost, Path: "/api/v1/approvals/{approval_id}/webauthn/challenge", MuxPattern: "/api/v1/approvals/", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "createApprovalWebAuthnChallenge", Owner: "core/cmd/helm-ai-kernel"}, {Method: http.MethodPost, Path: "/api/v1/approvals/{approval_id}/webauthn/assert", MuxPattern: "/api/v1/approvals/", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "assertApprovalWebAuthnChallenge", Owner: "core/cmd/helm-ai-kernel"}, {Method: http.MethodPost, Path: "/api/v1/approvals/{approval_id}/{action}", MuxPattern: "/api/v1/approvals/", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "transitionApprovalCeremony", Owner: "core/cmd/helm-ai-kernel"}, diff --git a/core/cmd/helm-ai-kernel/watch_client.go b/core/cmd/helm-ai-kernel/watch_client.go index fd51fe7b7..f1b0ea2da 100644 --- a/core/cmd/helm-ai-kernel/watch_client.go +++ b/core/cmd/helm-ai-kernel/watch_client.go @@ -128,6 +128,18 @@ func (c *approvalHTTPClient) CreateApproval(ctx context.Context, req createAppro return ceremony, nil } +func (c *approvalHTTPClient) ConsumeApproval(ctx context.Context, approvalID, bindingHash string) (contracts.ApprovalCeremony, error) { + if strings.TrimSpace(bindingHash) == "" { + return contracts.ApprovalCeremony{}, errors.New("approval binding_hash is required") + } + var ceremony contracts.ApprovalCeremony + path := approvalAPIBasePath + "/" + url.PathEscape(approvalID) + "/consume" + if err := c.do(ctx, http.MethodPost, path, map[string]string{"binding_hash": bindingHash}, &ceremony); err != nil { + return contracts.ApprovalCeremony{}, err + } + return ceremony, nil +} + func (c *approvalHTTPClient) do(ctx context.Context, method, path string, body, out any) error { if c.apiKey == "" { return errApprovalAPIKeyMissing diff --git a/core/cmd/helm-ai-kernel/watch_cmd.go b/core/cmd/helm-ai-kernel/watch_cmd.go index ad963738c..d797ee4c3 100644 --- a/core/cmd/helm-ai-kernel/watch_cmd.go +++ b/core/cmd/helm-ai-kernel/watch_cmd.go @@ -112,6 +112,14 @@ func runWatchSnapshot(client approvalClient, jsonOut bool, stdout, stderr io.Wri // resolveWatchAPIKey reads the admin API key from --api-key-file (0600) or the // HELM_ADMIN_API_KEY environment variable. Missing key fails closed. func resolveWatchAPIKey(apiKeyFile string) (string, error) { + return resolveAPIKey(apiKeyFile, watchAdminAPIKeyEnv, "admin") +} + +func resolveServiceAPIKey(apiKeyFile string) (string, error) { + return resolveAPIKey(apiKeyFile, serviceAPIKeyEnv, "service") +} + +func resolveAPIKey(apiKeyFile, envName, label string) (string, error) { if strings.TrimSpace(apiKeyFile) != "" { info, err := os.Lstat(apiKeyFile) if err != nil { @@ -133,9 +141,9 @@ func resolveWatchAPIKey(apiKeyFile string) (string, error) { } return key, nil } - key := strings.TrimSpace(os.Getenv(watchAdminAPIKeyEnv)) + key := strings.TrimSpace(os.Getenv(envName)) if key == "" { - return "", fmt.Errorf("admin API key is required (set %s or --api-key-file)", watchAdminAPIKeyEnv) + return "", fmt.Errorf("%s API key is required (set %s or provide its key file)", label, envName) } return key, nil } diff --git a/core/cmd/helm-ai-kernel/watch_model.go b/core/cmd/helm-ai-kernel/watch_model.go index 0b21186fa..1c18fc7bd 100644 --- a/core/cmd/helm-ai-kernel/watch_model.go +++ b/core/cmd/helm-ai-kernel/watch_model.go @@ -24,6 +24,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/workstation" ) type approvalsFetchedMsg struct { @@ -296,9 +297,18 @@ func formatApprovalRow(item contracts.ApprovalCeremony, now time.Time) string { if strings.TrimSpace(item.Reason) != "" { reason = fmt.Sprintf(" reason %q", terminalSafe(item.Reason)) } - return fmt.Sprintf("%s %s:%s by %s age %s%s%s", + binding := "" + if item.BindingHash != "" { + binding = " binding " + terminalSafe(item.BindingHash) + if item.Subject == workstation.ShellGateApprovalSubject && + item.Action == workstation.ShellGateApprovalAction && + !workstation.ApprovalReasonMatchesBinding(item.Reason, item.BindingHash) { + binding += " [reason/binding mismatch]" + } + } + return fmt.Sprintf("%s %s:%s by %s age %s%s%s%s", terminalSafe(item.ApprovalID), terminalSafe(item.Subject), terminalSafe(item.Action), - terminalSafe(item.RequestedBy), age, suffix, reason) + terminalSafe(item.RequestedBy), age, suffix, binding, reason) } // terminalSafe strips terminal control and Unicode format characters from diff --git a/core/cmd/helm-ai-kernel/watch_model_test.go b/core/cmd/helm-ai-kernel/watch_model_test.go index ce3f9d3a6..72ce63b11 100644 --- a/core/cmd/helm-ai-kernel/watch_model_test.go +++ b/core/cmd/helm-ai-kernel/watch_model_test.go @@ -259,12 +259,13 @@ func TestWatchModelView(t *testing.T) { client := &fakeApprovalClient{} m := newWatchModel(client, "operator.cli", time.Second) item := pendingCeremony("ap-1", time.Now().Add(-time.Minute)) + item.BindingHash = "sha256:command-one" item.Reason = `blocked command "rm /tmp/x"` m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{ item, }}) view := m.View() - for _, want := range []string{"ap-1", "a approve", "d deny", "q quit", "shell_command", "rm /tmp/x"} { + for _, want := range []string{"ap-1", "a approve", "d deny", "q quit", "shell_command", "rm /tmp/x", "sha256:command-one", "reason/binding mismatch"} { if !strings.Contains(view, want) { t.Fatalf("view missing %q:\n%s", want, view) } diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go index 018e287ac..28f51f03a 100644 --- a/core/cmd/helm-ai-kernel/workstation_gate_cmd.go +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go @@ -26,7 +26,7 @@ const ( func runWorkstationGateCmd(args []string, stdout, stderr io.Writer) int { cmd := flag.NewFlagSet("workstation gate", flag.ContinueOnError) cmd.SetOutput(stderr) - var profileRaw, command, allowlistPath, dataDir, apiKeyFile, approvalID string + var profileRaw, command, allowlistPath, dataDir, serviceKeyFile, approvalID string var jsonOut, requestApproval bool var rawURL, actor string cmd.StringVar(&profileRaw, "profile", string(workstation.ShellGateProfileProduction), "Gate profile: dev escalates blocked commands to pending approvals; anything else is production (deny, fail-closed)") @@ -37,7 +37,7 @@ func runWorkstationGateCmd(args []string, stdout, stderr io.Writer) int { cmd.BoolVar(&requestApproval, "request-approval", false, "On a pending_approval verdict, create the approval ceremony on the kernel server") cmd.StringVar(&rawURL, "url", "", "Kernel server URL for --request-approval (default $HELM_KERNEL_URL or "+defaultWatchURL+")") cmd.StringVar(&actor, "actor", "agent.local", "Requesting actor recorded on the approval (must differ from the approving watch actor)") - cmd.StringVar(&apiKeyFile, "api-key-file", "", "Path to a 0600 admin API key file (default $HELM_ADMIN_API_KEY)") + cmd.StringVar(&serviceKeyFile, "service-key-file", "", "Path to a 0600 service API key file (default $HELM_SERVICE_API_KEY; never use an admin key)") cmd.StringVar(&approvalID, "approval-id", "", "Consume this approved, command-bound ceremony to allow a pending dev command") if err := cmd.Parse(args); err != nil { if err == flag.ErrHelp { @@ -64,7 +64,7 @@ func runWorkstationGateCmd(args []string, stdout, stderr io.Writer) int { _, _ = fmt.Fprintln(stderr, "Error: --approval-id is valid only for a pending dev-profile command") return 2 } - if err := consumeShellGateApproval(decision, approvalID, rawURL, apiKeyFile); err != nil { + if err := consumeShellGateApproval(decision, approvalID, rawURL, serviceKeyFile); err != nil { _, _ = fmt.Fprintf(stderr, "Error: approval cannot authorize command: %v\n", err) return 1 } @@ -86,7 +86,7 @@ func runWorkstationGateCmd(args []string, stdout, stderr io.Writer) int { if !requestApproval { return exitGatePendingApproval } - if err := requestShellGateApproval(decision, rawURL, apiKeyFile, actor, stdout); err != nil { + if err := requestShellGateApproval(decision, rawURL, serviceKeyFile, actor, stdout); err != nil { _, _ = fmt.Fprintf(stderr, "Error: approval request failed: %v\n", err) return 1 } @@ -113,22 +113,22 @@ func printGateDecision(stdout io.Writer, decision workstation.ShellGateDecision, // requestShellGateApproval turns a pending_approval verdict into an approval // ceremony on the kernel server, so `watch` can drain it. -func shellGateApprovalClient(rawURL, apiKeyFile string) (*approvalHTTPClient, error) { +func shellGateApprovalClient(rawURL, serviceKeyFile string) (*approvalHTTPClient, error) { if strings.TrimSpace(rawURL) == "" { rawURL = strings.TrimSpace(os.Getenv(watchURLEnv)) } if rawURL == "" { rawURL = defaultWatchURL } - apiKey, err := resolveWatchAPIKey(apiKeyFile) + apiKey, err := resolveServiceAPIKey(serviceKeyFile) if err != nil { return nil, err } return newApprovalHTTPClient(rawURL, apiKey) } -func requestShellGateApproval(decision workstation.ShellGateDecision, rawURL, apiKeyFile, actor string, stdout io.Writer) error { - client, err := shellGateApprovalClient(rawURL, apiKeyFile) +func requestShellGateApproval(decision workstation.ShellGateDecision, rawURL, serviceKeyFile, actor string, stdout io.Writer) error { + client, err := shellGateApprovalClient(rawURL, serviceKeyFile) if err != nil { return err } @@ -150,38 +150,19 @@ func requestShellGateApproval(decision workstation.ShellGateDecision, rawURL, ap return nil } -func consumeShellGateApproval(decision workstation.ShellGateDecision, approvalID, rawURL, apiKeyFile string) error { - client, err := shellGateApprovalClient(rawURL, apiKeyFile) +func consumeShellGateApproval(decision workstation.ShellGateDecision, approvalID, rawURL, serviceKeyFile string) error { + client, err := shellGateApprovalClient(rawURL, serviceKeyFile) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - approvals, err := client.ListApprovals(ctx) + approval, err := client.ConsumeApproval(ctx, approvalID, workstation.ShellCommandBindingRef(decision.Command)) if err != nil { - return err + return fmt.Errorf("consume approval %s: %w", approvalID, err) } - for _, approval := range approvals { - if approval.ApprovalID != approvalID { - continue - } - if approval.State != contracts.ApprovalCeremonyAllowed { - return fmt.Errorf("approval %s is %s, not approved", approvalID, approval.State) - } - if approval.Subject != workstation.ShellGateApprovalSubject || approval.Action != workstation.ShellGateApprovalAction { - return fmt.Errorf("approval %s has wrong subject/action", approvalID) - } - if !approval.ExpiresAt.IsZero() && time.Now().After(approval.ExpiresAt) { - return fmt.Errorf("approval %s is expired", approvalID) - } - if !workstation.ApprovalBindsToCommand(approval.BindingHash, decision.Command) { - return fmt.Errorf("approval %s is bound to a different command", approvalID) - } - _, err := client.TransitionApproval(ctx, approvalID, "revoke", "workstation.shellgate", "consumed by workstation shell gate") - if err != nil { - return fmt.Errorf("consume approval %s: %w", approvalID, err) - } - return nil + if approval.State != contracts.ApprovalCeremonyRevoked { + return fmt.Errorf("consume approval %s returned state %s", approvalID, approval.State) } - return fmt.Errorf("approval %s not found", approvalID) + return nil } diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go index 01fda5cdd..9bc0cb93c 100644 --- a/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go @@ -112,7 +112,7 @@ func TestWorkstationGateRequestApprovalCreatesCeremony(t *testing.T) { }) })) defer server.Close() - t.Setenv(watchAdminAPIKeyEnv, "test-key") + t.Setenv(serviceAPIKeyEnv, "test-key") allowlist := gateTestAllowlist(t, []string{"ls"}) code, out, errOut := runGateForTest(t, @@ -158,19 +158,23 @@ func TestWorkstationGateConsumesExactApprovalOnce(t *testing.T) { } revokeCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == approvalAPIBasePath: - _ = json.NewEncoder(w).Encode([]contracts.ApprovalCeremony{approval}) - case r.Method == http.MethodPost && r.URL.Path == approvalAPIBasePath+"/"+approval.ApprovalID+"/revoke": - revokeCount++ - approval.State = contracts.ApprovalCeremonyRevoked - _ = json.NewEncoder(w).Encode(approval) - default: + if r.Method != http.MethodPost || r.URL.Path != approvalAPIBasePath+"/"+approval.ApprovalID+"/consume" { t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["binding_hash"] != approval.BindingHash || approval.State != contracts.ApprovalCeremonyAllowed { + http.Error(w, "not approved for binding", http.StatusBadRequest) + return + } + revokeCount++ + approval.State = contracts.ApprovalCeremonyRevoked + _ = json.NewEncoder(w).Encode(approval) })) defer server.Close() - t.Setenv(watchAdminAPIKeyEnv, "test-key") + t.Setenv(serviceAPIKeyEnv, "test-key") allowlist := gateTestAllowlist(t, []string{"ls"}) args := []string{ @@ -187,7 +191,7 @@ func TestWorkstationGateConsumesExactApprovalOnce(t *testing.T) { } args[5] = t.TempDir() code, _, errOut = runGateForTest(t, args...) - if code != 1 || !strings.Contains(errOut, "not approved") || revokeCount != 1 { + if code != 1 || !strings.Contains(errOut, "400") || revokeCount != 1 { t.Fatalf("cross-ledger reuse exit=%d revokes=%d err=%s, want server-side consumed rejection", code, revokeCount, errOut) } } @@ -202,11 +206,17 @@ func TestWorkstationGateRejectsApprovalForDifferentCommand(t *testing.T) { BindingHash: workstation.ShellCommandBindingRef("rm /tmp/safe"), Reason: "approved; " + workstation.ShellCommandBinding("rm /tmp/safe"), } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode([]contracts.ApprovalCeremony{approval}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["binding_hash"] != approval.BindingHash { + http.Error(w, "wrong binding", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(approval) })) defer server.Close() - t.Setenv(watchAdminAPIKeyEnv, "test-key") + t.Setenv(serviceAPIKeyEnv, "test-key") code, _, errOut := runGateForTest(t, "--profile", "dev", @@ -216,13 +226,13 @@ func TestWorkstationGateRejectsApprovalForDifferentCommand(t *testing.T) { "--url", server.URL, "--command", "rm /etc/passwd", ) - if code != 1 || !strings.Contains(errOut, "different command") { + if code != 1 || !strings.Contains(errOut, "400") { t.Fatalf("exit = %d err=%s, want command-binding rejection", code, errOut) } } func TestWorkstationGateRequestApprovalServerDown(t *testing.T) { - t.Setenv(watchAdminAPIKeyEnv, "test-key") + t.Setenv(serviceAPIKeyEnv, "test-key") allowlist := gateTestAllowlist(t, []string{"ls"}) code, _, errOut := runGateForTest(t, "--profile", "dev", diff --git a/core/pkg/boundary/surface_registry.go b/core/pkg/boundary/surface_registry.go index ab991871e..d9768372e 100644 --- a/core/pkg/boundary/surface_registry.go +++ b/core/pkg/boundary/surface_registry.go @@ -466,6 +466,18 @@ func (r *SurfaceRegistry) ListCheckpoints() []contracts.BoundaryCheckpoint { func (r *SurfaceRegistry) PutApproval(approval contracts.ApprovalCeremony) (contracts.ApprovalCeremony, error) { r.mu.Lock() defer r.mu.Unlock() + if _, exists := r.approvals[approval.ApprovalID]; exists { + return contracts.ApprovalCeremony{}, fmt.Errorf("approval %q already exists", approval.ApprovalID) + } + return r.putApprovalLocked(approval) +} + +func (r *SurfaceRegistry) updateApproval(approval contracts.ApprovalCeremony) (contracts.ApprovalCeremony, error) { + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.approvals[approval.ApprovalID]; !exists { + return contracts.ApprovalCeremony{}, fmt.Errorf("approval %q not found", approval.ApprovalID) + } return r.putApprovalLocked(approval) } @@ -651,7 +663,7 @@ func (r *SurfaceRegistry) AssertApprovalChallenge(assertion contracts.ApprovalWe approval.ChallengeID = challenge.ChallengeID approval.ChallengeHash = challenge.ChallengeHash approval.AssertionHash = "sha256:" + assertionHash - sealed, err := r.PutApproval(approval) + sealed, err := r.updateApproval(approval) if err != nil { return contracts.ApprovalCeremony{}, err } diff --git a/core/pkg/contracts/boundary_surfaces.go b/core/pkg/contracts/boundary_surfaces.go index ef2c86009..070ba84a7 100644 --- a/core/pkg/contracts/boundary_surfaces.go +++ b/core/pkg/contracts/boundary_surfaces.go @@ -1,6 +1,8 @@ package contracts import ( + "crypto/rand" + "encoding/hex" "fmt" "strings" "time" @@ -368,3 +370,11 @@ func SurfaceID(prefix, value string) string { } return prefix + "-" + normalized } + +func NewSurfaceID(prefix string) (string, error) { + var entropy [16]byte + if _, err := rand.Read(entropy[:]); err != nil { + return "", fmt.Errorf("generate %s id: %w", prefix, err) + } + return SurfaceID(prefix, hex.EncodeToString(entropy[:])), nil +} diff --git a/core/pkg/workstation/shellallowlist.go b/core/pkg/workstation/shellallowlist.go index a42714759..b2dec5ec4 100644 --- a/core/pkg/workstation/shellallowlist.go +++ b/core/pkg/workstation/shellallowlist.go @@ -30,7 +30,6 @@ import ( // defeats the gate. Operators who need them add them explicitly. var DefaultShellAllowlist = []string{ "cat", - "date", "grep", "jq", "ls", diff --git a/core/pkg/workstation/shellapproval.go b/core/pkg/workstation/shellapproval.go index 870b1ed7c..195d9534b 100644 --- a/core/pkg/workstation/shellapproval.go +++ b/core/pkg/workstation/shellapproval.go @@ -11,6 +11,7 @@ package workstation import ( "crypto/sha256" "encoding/hex" + "strings" ) // ShellGateApprovalSubject and ShellGateApprovalAction identify approval @@ -49,3 +50,9 @@ func ShellCommandBindingRef(command string) string { func ApprovalBindsToCommand(bindingHash, command string) bool { return bindingHash == ShellCommandBindingRef(command) } + +// ApprovalReasonMatchesBinding checks the optional human-readable shell token +// against the immutable structured binding shown to an approver. +func ApprovalReasonMatchesBinding(reason, bindingHash string) bool { + return strings.Contains(reason, shellGateBindingPrefix+strings.TrimPrefix(bindingHash, "sha256:")) +} diff --git a/core/pkg/workstation/shellgate.go b/core/pkg/workstation/shellgate.go index 4741e814b..c02272c4a 100644 --- a/core/pkg/workstation/shellgate.go +++ b/core/pkg/workstation/shellgate.go @@ -248,8 +248,8 @@ func ExtractWriteTargets(command string) []string { } // redirectionTargets scans for `>` / `>>` / `>|` output redirections. An -// optional fd prefix (`2>`, `&>`) is part of the operator; `>&` (fd -// duplication such as `2>&1`) is not a file write and is skipped. +// optional fd prefix (`2>`, `&>`) is part of the operator. `>&1` duplicates a +// descriptor and is skipped; `>&file` redirects both streams and is a write. func redirectionTargets(line string) []string { var targets []string var quote byte @@ -279,9 +279,12 @@ func redirectionTargets(line string) []string { if j < len(line) && line[j] == '|' { // noclobber override: >| j++ } - if j < len(line) && line[j] == '&' { // fd duplication: 2>&1 - i = j - continue + if j < len(line) && line[j] == '&' { + j++ + if j < len(line) && ((line[j] >= '0' && line[j] <= '9') || line[j] == '-') { + i = j // fd duplication/closure: 2>&1 or 2>&- + continue + } } for j < len(line) && (line[j] == ' ' || line[j] == '\t') { j++ diff --git a/core/pkg/workstation/shellgate_test.go b/core/pkg/workstation/shellgate_test.go index 896648525..ca1f9338a 100644 --- a/core/pkg/workstation/shellgate_test.go +++ b/core/pkg/workstation/shellgate_test.go @@ -137,7 +137,7 @@ func TestGateShellCommandProfiles(t *testing.T) { } func TestGateShellCommandDetectsQuotedRedirectAndYQInPlace(t *testing.T) { - for _, command := range []string{`cat input > "/tmp/out"`, `yq -i '.x = 1' config.yaml`, `yq --in-place '.x = 1' config.yaml`} { + for _, command := range []string{`cat input > "/tmp/out"`, `cat input >&/tmp/out`, `yq -i '.x = 1' config.yaml`, `yq --in-place '.x = 1' config.yaml`} { decision := GateShellCommand(ShellGateProfileProduction, command, []string{"cat", "yq"}) if decision.Verdict != ShellGateVerdictDeny || len(decision.WriteTargets) == 0 { t.Fatalf("GateShellCommand(%q) = %+v, want detected write denial", command, decision) @@ -146,6 +146,11 @@ func TestGateShellCommandDetectsQuotedRedirectAndYQInPlace(t *testing.T) { if got := ExtractWriteTargets(`echo "a > b"`); got != nil { t.Fatalf("operator inside quoted text produced targets %v", got) } + for _, command := range []string{`cat input 2>&1`, `cat input >&2`, `cat input 2>&-`} { + if got := ExtractWriteTargets(command); got != nil { + t.Fatalf("descriptor duplication %q produced write targets %v", command, got) + } + } } func writeShellAllowlist(t *testing.T, path string, payload any, mode os.FileMode) time.Time { @@ -195,6 +200,12 @@ func TestDefaultShellAllowlistExcludesYQ(t *testing.T) { } } +func TestDefaultShellAllowlistExcludesMutatingDate(t *testing.T) { + if containsString(DefaultShellAllowlist, "date") { + t.Fatal("default allowlist must exclude date because --set mutates the system clock") + } +} + func TestShellAllowlistStoreFormats(t *testing.T) { cases := []struct { name string diff --git a/sdk/go/generated.manifest.json b/sdk/go/generated.manifest.json index 045dee896..01be732e4 100644 --- a/sdk/go/generated.manifest.json +++ b/sdk/go/generated.manifest.json @@ -8,7 +8,7 @@ "generator": "openapitools/openapi-generator-cli:v7.4.0@sha256:579832bed49ea6c275ce2fb5f2d515f5b03d2b6243f3c80fa8430e4f5a770e9a", "sdk": "go", "source": { - "sha256": "052e82eb5ed3e704abce7e1d6ca63a819a93ee2fa06a8f578e0b262fc6f56f97", + "sha256": "13927036c1c3a7d6ab8046c532478565b7447ec378bc2377cec1140bea30678b", "spec": "api/openapi/helm.openapi.yaml" } } diff --git a/sdk/java/generated.manifest.json b/sdk/java/generated.manifest.json index 4c0d20e62..6be2c06f0 100644 --- a/sdk/java/generated.manifest.json +++ b/sdk/java/generated.manifest.json @@ -2,13 +2,13 @@ "files": [ { "path": "src/main/java/labs/mindburn/helm/TypesGen.java", - "sha256": "041d60d39289a0413faac4166ab105e179dcdb5ca71ff8babde386f745743be1" + "sha256": "384429f8a99ea6ca3830199a14edc828dbfef511783e8e19f2b70965151c4d50" } ], "generator": "openapitools/openapi-generator-cli:v7.4.0@sha256:579832bed49ea6c275ce2fb5f2d515f5b03d2b6243f3c80fa8430e4f5a770e9a", "sdk": "java", "source": { - "sha256": "052e82eb5ed3e704abce7e1d6ca63a819a93ee2fa06a8f578e0b262fc6f56f97", + "sha256": "13927036c1c3a7d6ab8046c532478565b7447ec378bc2377cec1140bea30678b", "spec": "api/openapi/helm.openapi.yaml" } } diff --git a/sdk/java/src/main/java/labs/mindburn/helm/TypesGen.java b/sdk/java/src/main/java/labs/mindburn/helm/TypesGen.java index 64df567ac..7737409cf 100644 --- a/sdk/java/src/main/java/labs/mindburn/helm/TypesGen.java +++ b/sdk/java/src/main/java/labs/mindburn/helm/TypesGen.java @@ -3717,6 +3717,7 @@ public String toUrlQueryString(String prefix) { ApprovalCeremony.JSON_PROPERTY_TIMELOCK_UNTIL, ApprovalCeremony.JSON_PROPERTY_EXPIRES_AT, ApprovalCeremony.JSON_PROPERTY_BREAK_GLASS, + ApprovalCeremony.JSON_PROPERTY_BINDING_HASH, ApprovalCeremony.JSON_PROPERTY_REASON, ApprovalCeremony.JSON_PROPERTY_RECEIPT_ID, ApprovalCeremony.JSON_PROPERTY_CEREMONY_HASH, @@ -3796,6 +3797,9 @@ public static StateEnum fromValue(String value) { public static final String JSON_PROPERTY_BREAK_GLASS = "break_glass"; private Boolean breakGlass; + public static final String JSON_PROPERTY_BINDING_HASH = "binding_hash"; + private String bindingHash; + public static final String JSON_PROPERTY_REASON = "reason"; private String reason; @@ -4072,6 +4076,31 @@ public void setBreakGlass(Boolean breakGlass) { } + public ApprovalCeremony bindingHash(String bindingHash) { + this.bindingHash = bindingHash; + return this; + } + + /** + * Get bindingHash + * @return bindingHash + **/ + @javax.annotation.Nullable + @JsonProperty(JSON_PROPERTY_BINDING_HASH) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + + public String getBindingHash() { + return bindingHash; + } + + + @JsonProperty(JSON_PROPERTY_BINDING_HASH) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public void setBindingHash(String bindingHash) { + this.bindingHash = bindingHash; + } + + public ApprovalCeremony reason(String reason) { this.reason = reason; return this; @@ -4219,6 +4248,7 @@ public boolean equals(Object o) { Objects.equals(this.timelockUntil, approvalCeremony.timelockUntil) && Objects.equals(this.expiresAt, approvalCeremony.expiresAt) && Objects.equals(this.breakGlass, approvalCeremony.breakGlass) && + Objects.equals(this.bindingHash, approvalCeremony.bindingHash) && Objects.equals(this.reason, approvalCeremony.reason) && Objects.equals(this.receiptId, approvalCeremony.receiptId) && Objects.equals(this.ceremonyHash, approvalCeremony.ceremonyHash) && @@ -4228,7 +4258,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(approvalId, subject, action, state, requestedBy, approvers, quorum, timelockUntil, expiresAt, breakGlass, reason, receiptId, ceremonyHash, createdAt, updatedAt); + return Objects.hash(approvalId, subject, action, state, requestedBy, approvers, quorum, timelockUntil, expiresAt, breakGlass, bindingHash, reason, receiptId, ceremonyHash, createdAt, updatedAt); } @Override @@ -4245,6 +4275,7 @@ public String toString() { sb.append(" timelockUntil: ").append(toIndentedString(timelockUntil)).append("\n"); sb.append(" expiresAt: ").append(toIndentedString(expiresAt)).append("\n"); sb.append(" breakGlass: ").append(toIndentedString(breakGlass)).append("\n"); + sb.append(" bindingHash: ").append(toIndentedString(bindingHash)).append("\n"); sb.append(" reason: ").append(toIndentedString(reason)).append("\n"); sb.append(" receiptId: ").append(toIndentedString(receiptId)).append("\n"); sb.append(" ceremonyHash: ").append(toIndentedString(ceremonyHash)).append("\n"); @@ -4351,6 +4382,11 @@ public String toUrlQueryString(String prefix) { joiner.add(String.format("%sbreak_glass%s=%s", prefix, suffix, URLEncoder.encode(String.valueOf(getBreakGlass()), StandardCharsets.UTF_8).replaceAll("\\+", "%20"))); } + // add `binding_hash` to the URL query string + if (getBindingHash() != null) { + joiner.add(String.format("%sbinding_hash%s=%s", prefix, suffix, URLEncoder.encode(String.valueOf(getBindingHash()), StandardCharsets.UTF_8).replaceAll("\\+", "%20"))); + } + // add `reason` to the URL query string if (getReason() != null) { joiner.add(String.format("%sreason%s=%s", prefix, suffix, URLEncoder.encode(String.valueOf(getReason()), StandardCharsets.UTF_8).replaceAll("\\+", "%20"))); diff --git a/sdk/python/generated.manifest.json b/sdk/python/generated.manifest.json index 125a72c05..a9276404a 100644 --- a/sdk/python/generated.manifest.json +++ b/sdk/python/generated.manifest.json @@ -2,13 +2,13 @@ "files": [ { "path": "helm_sdk/types_gen.py", - "sha256": "e6837572f98682d783983903342f3252e58c05c4bee05f71bb610c1eb569fc6d" + "sha256": "1254f57eeb6063797d2d7692023141e40950ca3bf9f7d9c6e67d7eac0cdc3f12" } ], "generator": "openapitools/openapi-generator-cli:v7.4.0@sha256:579832bed49ea6c275ce2fb5f2d515f5b03d2b6243f3c80fa8430e4f5a770e9a", "sdk": "python", "source": { - "sha256": "052e82eb5ed3e704abce7e1d6ca63a819a93ee2fa06a8f578e0b262fc6f56f97", + "sha256": "13927036c1c3a7d6ab8046c532478565b7447ec378bc2377cec1140bea30678b", "spec": "api/openapi/helm.openapi.yaml" } } diff --git a/sdk/python/helm_sdk/types_gen.py b/sdk/python/helm_sdk/types_gen.py index 4b63b69e4..d54dbd627 100644 --- a/sdk/python/helm_sdk/types_gen.py +++ b/sdk/python/helm_sdk/types_gen.py @@ -1006,12 +1006,13 @@ class ApprovalCeremony(BaseModel): timelock_until: Optional[datetime] = None expires_at: Optional[datetime] = None break_glass: Optional[StrictBool] = None + binding_hash: Optional[StrictStr] = None reason: Optional[StrictStr] = None receipt_id: Optional[StrictStr] = None ceremony_hash: Optional[StrictStr] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None - __properties: ClassVar[List[str]] = ["approval_id", "subject", "action", "state", "requested_by", "approvers", "quorum", "timelock_until", "expires_at", "break_glass", "reason", "receipt_id", "ceremony_hash", "created_at", "updated_at"] + __properties: ClassVar[List[str]] = ["approval_id", "subject", "action", "state", "requested_by", "approvers", "quorum", "timelock_until", "expires_at", "break_glass", "binding_hash", "reason", "receipt_id", "ceremony_hash", "created_at", "updated_at"] @field_validator('state') def state_validate_enum(cls, value): @@ -1084,6 +1085,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "timelock_until": obj.get("timelock_until"), "expires_at": obj.get("expires_at"), "break_glass": obj.get("break_glass"), + "binding_hash": obj.get("binding_hash"), "reason": obj.get("reason"), "receipt_id": obj.get("receipt_id"), "ceremony_hash": obj.get("ceremony_hash"), diff --git a/sdk/rust/generated.manifest.json b/sdk/rust/generated.manifest.json index 3301aee52..05637c8da 100644 --- a/sdk/rust/generated.manifest.json +++ b/sdk/rust/generated.manifest.json @@ -2,13 +2,13 @@ "files": [ { "path": "src/types_gen.rs", - "sha256": "cf77a2b1441e0e25c96ad13ba7e50058e2f5d7d6332a02d04835061006c761b0" + "sha256": "73fd78978f744c5715075ea876ae1f2fff504df82b722004e3201b4932ea16be" } ], "generator": "openapitools/openapi-generator-cli:v7.4.0@sha256:579832bed49ea6c275ce2fb5f2d515f5b03d2b6243f3c80fa8430e4f5a770e9a", "sdk": "rust", "source": { - "sha256": "052e82eb5ed3e704abce7e1d6ca63a819a93ee2fa06a8f578e0b262fc6f56f97", + "sha256": "13927036c1c3a7d6ab8046c532478565b7447ec378bc2377cec1140bea30678b", "spec": "api/openapi/helm.openapi.yaml" } } diff --git a/sdk/rust/src/types_gen.rs b/sdk/rust/src/types_gen.rs index 17deddd3a..990a8de26 100644 --- a/sdk/rust/src/types_gen.rs +++ b/sdk/rust/src/types_gen.rs @@ -494,6 +494,8 @@ pub struct ApprovalCeremony { pub expires_at: Option, #[serde(rename = "break_glass", skip_serializing_if = "Option::is_none")] pub break_glass: Option, + #[serde(rename = "binding_hash", skip_serializing_if = "Option::is_none")] + pub binding_hash: Option, #[serde(rename = "reason", skip_serializing_if = "Option::is_none")] pub reason: Option, #[serde(rename = "receipt_id", skip_serializing_if = "Option::is_none")] @@ -519,6 +521,7 @@ impl ApprovalCeremony { timelock_until: None, expires_at: None, break_glass: None, + binding_hash: None, reason: None, receipt_id: None, ceremony_hash: None, diff --git a/sdk/ts/generated.manifest.json b/sdk/ts/generated.manifest.json index d7c2eaaf7..c766db2b9 100644 --- a/sdk/ts/generated.manifest.json +++ b/sdk/ts/generated.manifest.json @@ -2,13 +2,13 @@ "files": [ { "path": "src/types.gen.ts", - "sha256": "b3c0bc31be599c39c15a7c4e40053a0ec3481dedcb32aef3814db5aa13c26e1e" + "sha256": "cc963d3c8e7e7e072099ed96737e8c84134071fa4305b059404c3843eee185d1" } ], "generator": "openapitools/openapi-generator-cli:v7.4.0@sha256:579832bed49ea6c275ce2fb5f2d515f5b03d2b6243f3c80fa8430e4f5a770e9a", "sdk": "ts", "source": { - "sha256": "052e82eb5ed3e704abce7e1d6ca63a819a93ee2fa06a8f578e0b262fc6f56f97", + "sha256": "13927036c1c3a7d6ab8046c532478565b7447ec378bc2377cec1140bea30678b", "spec": "api/openapi/helm.openapi.yaml" } } diff --git a/sdk/ts/src/types.gen.ts b/sdk/ts/src/types.gen.ts index b6ae0b937..1969fadd0 100644 --- a/sdk/ts/src/types.gen.ts +++ b/sdk/ts/src/types.gen.ts @@ -1171,6 +1171,12 @@ export interface ApprovalCeremony { * @memberof ApprovalCeremony */ break_glass?: boolean; + /** + * + * @type {string} + * @memberof ApprovalCeremony + */ + binding_hash?: string; /** * * @type {string} @@ -1244,6 +1250,7 @@ export function ApprovalCeremonyFromJSONTyped(json: any, ignoreDiscriminator: bo 'timelock_until': json['timelock_until'] == null ? undefined : (new Date(json['timelock_until'])), 'expires_at': json['expires_at'] == null ? undefined : (new Date(json['expires_at'])), 'break_glass': json['break_glass'] == null ? undefined : json['break_glass'], + 'binding_hash': json['binding_hash'] == null ? undefined : json['binding_hash'], 'reason': json['reason'] == null ? undefined : json['reason'], 'receipt_id': json['receipt_id'] == null ? undefined : json['receipt_id'], 'ceremony_hash': json['ceremony_hash'] == null ? undefined : json['ceremony_hash'], @@ -1268,6 +1275,7 @@ export function ApprovalCeremonyToJSON(value?: ApprovalCeremony | null): any { 'timelock_until': value['timelock_until'] == null ? undefined : ((value['timelock_until']).toISOString()), 'expires_at': value['expires_at'] == null ? undefined : ((value['expires_at']).toISOString()), 'break_glass': value['break_glass'], + 'binding_hash': value['binding_hash'], 'reason': value['reason'], 'receipt_id': value['receipt_id'], 'ceremony_hash': value['ceremony_hash'], diff --git a/tools/boundary/protected.manifest b/tools/boundary/protected.manifest index cefaba81b..0fadfebda 100644 --- a/tools/boundary/protected.manifest +++ b/tools/boundary/protected.manifest @@ -151,7 +151,7 @@ a65443797c63eaf591076538f1fb4cddcd578c120db433b5a3b70b6521ba6f6c core/pkg/contr 13ca81cd8b0b8e86d1c3f6a2db8eea6f36bc7ca2cdd9034c5bd838520ad0e27f core/pkg/contracts/autonomy_envelope.go 23425a052bfbb886dabacb4eef162782f58e153289eb53e2d224506a2819cac7 core/pkg/contracts/autonomy_state.go 0d01b067f08db8a2fba31443f0be003a775ebe9b2e4bfa9afd0425abbe84c19c core/pkg/contracts/autonomy_state_test.go -3f3ca39f9f0e2eaba9e3d36d1b5568c7fe4289c2a35624f42cf69917a608b9c4 core/pkg/contracts/boundary_surfaces.go +057790dbb586327cdb6872f5acf3162e9d030b20e84d1007dd28f84b96628089 core/pkg/contracts/boundary_surfaces.go 58d52b26cb02373d2cd3aeb7bde69ba02e9f7cd1cd52b203407a4c17c2b46424 core/pkg/contracts/build.go cb435386cac61780c38600c984752912aca4d48d41115d9ec41e704aca5ecf66 core/pkg/contracts/capability_diff.go 0dac155baf5861ed19fdf3b7f119d5f768e566207634a345f1162910d8bd896f core/pkg/contracts/capability_diff_determinism_test.go From 7fc84e9bc5f7bcb4063c5791d4297fd47dba8cd4 Mon Sep 17 00:00:00 2001 From: Mindburn Labs Date: Wed, 29 Jul 2026 23:24:47 +0300 Subject: [PATCH 11/12] fix(workstation): close approval race and dynamic redirects --- .../helm-ai-kernel/contract_routes_test.go | 5 +- core/pkg/boundary/surface_registry.go | 37 +- core/pkg/boundary/surface_registry_test.go | 67 ++ core/pkg/workstation/shellgate.go | 15 +- core/pkg/workstation/shellgate_test.go | 13 +- scripts/sdk/gen.sh | 1 - sdk/go/client/execution_boundary.go | 1 - sdk/go/client/execution_boundary_test.go | 10 +- sdk/go/client/types_gen.go | 659 ++++++++++++++++++ sdk/go/generated.manifest.json | 2 +- 10 files changed, 777 insertions(+), 33 deletions(-) diff --git a/core/cmd/helm-ai-kernel/contract_routes_test.go b/core/cmd/helm-ai-kernel/contract_routes_test.go index fe4291e9d..2d6c486cb 100644 --- a/core/cmd/helm-ai-kernel/contract_routes_test.go +++ b/core/cmd/helm-ai-kernel/contract_routes_test.go @@ -10,6 +10,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" "sort" "strings" "testing" @@ -606,7 +607,7 @@ func TestReceiptListReturnsCursorPagination(t *testing.T) { func newContractRouteTestServices(t *testing.T) (*Services, func()) { t.Helper() t.Setenv("HELM_ADMIN_API_KEY", testAdminAPIKey) - t.Setenv(serviceAPIKeyEnv, "test-service-key") + t.Setenv(serviceAPIKeyEnv, testAdminAPIKey+"-service") db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) @@ -677,7 +678,7 @@ func authorizeTestRequest(req *http.Request) { } func authorizeServiceTestRequest(req *http.Request) { - req.Header.Set("Authorization", "Bearer test-service-key") + req.Header.Set("Authorization", "Bearer "+os.Getenv(serviceAPIKeyEnv)) } type overflowReceiptStore struct { diff --git a/core/pkg/boundary/surface_registry.go b/core/pkg/boundary/surface_registry.go index d9768372e..2a4f43d2f 100644 --- a/core/pkg/boundary/surface_registry.go +++ b/core/pkg/boundary/surface_registry.go @@ -472,15 +472,6 @@ func (r *SurfaceRegistry) PutApproval(approval contracts.ApprovalCeremony) (cont return r.putApprovalLocked(approval) } -func (r *SurfaceRegistry) updateApproval(approval contracts.ApprovalCeremony) (contracts.ApprovalCeremony, error) { - r.mu.Lock() - defer r.mu.Unlock() - if _, exists := r.approvals[approval.ApprovalID]; !exists { - return contracts.ApprovalCeremony{}, fmt.Errorf("approval %q not found", approval.ApprovalID) - } - return r.putApprovalLocked(approval) -} - func (r *SurfaceRegistry) putApprovalLocked(approval contracts.ApprovalCeremony) (contracts.ApprovalCeremony, error) { sealed, err := approval.Seal() if err != nil { @@ -510,6 +501,10 @@ func (r *SurfaceRegistry) ListApprovals() []contracts.ApprovalCeremony { func (r *SurfaceRegistry) TransitionApproval(id string, state contracts.ApprovalCeremonyState, actor, receiptID, reason string) (contracts.ApprovalCeremony, error) { r.mu.Lock() defer r.mu.Unlock() + return r.transitionApprovalLocked(id, state, actor, receiptID, reason) +} + +func (r *SurfaceRegistry) transitionApprovalLocked(id string, state contracts.ApprovalCeremonyState, actor, receiptID, reason string) (contracts.ApprovalCeremony, error) { approval, ok := r.approvals[id] if !ok { return contracts.ApprovalCeremony{}, fmt.Errorf("approval %q not found", id) @@ -638,15 +633,6 @@ func (r *SurfaceRegistry) AssertApprovalChallenge(assertion contracts.ApprovalWe if strings.TrimSpace(assertion.ChallengeID) == "" || strings.TrimSpace(assertion.Actor) == "" || strings.TrimSpace(assertion.Assertion) == "" { return contracts.ApprovalCeremony{}, fmt.Errorf("challenge_id, actor, and assertion are required") } - r.mu.RLock() - challenge, ok := r.challenges[assertion.ChallengeID] - r.mu.RUnlock() - if !ok { - return contracts.ApprovalCeremony{}, fmt.Errorf("approval challenge %q not found", assertion.ChallengeID) - } - if r.now().UTC().After(challenge.ExpiresAt) { - return contracts.ApprovalCeremony{}, fmt.Errorf("approval challenge expired") - } assertionHash, err := canonicalize.CanonicalHash(map[string]string{ "challenge_id": assertion.ChallengeID, "actor": assertion.Actor, @@ -655,7 +641,16 @@ func (r *SurfaceRegistry) AssertApprovalChallenge(assertion contracts.ApprovalWe if err != nil { return contracts.ApprovalCeremony{}, err } - approval, err := r.TransitionApproval(challenge.ApprovalID, contracts.ApprovalCeremonyAllowed, assertion.Actor, assertion.ReceiptID, assertion.Reason) + r.mu.Lock() + defer r.mu.Unlock() + challenge, ok := r.challenges[assertion.ChallengeID] + if !ok { + return contracts.ApprovalCeremony{}, fmt.Errorf("approval challenge %q not found", assertion.ChallengeID) + } + if r.now().UTC().After(challenge.ExpiresAt) { + return contracts.ApprovalCeremony{}, fmt.Errorf("approval challenge expired") + } + approval, err := r.transitionApprovalLocked(challenge.ApprovalID, contracts.ApprovalCeremonyAllowed, assertion.Actor, assertion.ReceiptID, assertion.Reason) if err != nil { return contracts.ApprovalCeremony{}, err } @@ -663,16 +658,14 @@ func (r *SurfaceRegistry) AssertApprovalChallenge(assertion contracts.ApprovalWe approval.ChallengeID = challenge.ChallengeID approval.ChallengeHash = challenge.ChallengeHash approval.AssertionHash = "sha256:" + assertionHash - sealed, err := r.updateApproval(approval) + sealed, err := r.putApprovalLocked(approval) if err != nil { return contracts.ApprovalCeremony{}, err } challenge.Verified = sealed.State == contracts.ApprovalCeremonyAllowed challenge.AssertionHash = sealed.AssertionHash - r.mu.Lock() r.challenges[challenge.ChallengeID] = challenge err = r.persistLocked() - r.mu.Unlock() if err != nil { return contracts.ApprovalCeremony{}, err } diff --git a/core/pkg/boundary/surface_registry_test.go b/core/pkg/boundary/surface_registry_test.go index f386d8a61..25bf25bfe 100644 --- a/core/pkg/boundary/surface_registry_test.go +++ b/core/pkg/boundary/surface_registry_test.go @@ -3,8 +3,10 @@ package boundary import ( "context" "database/sql" + "fmt" "path/filepath" "strings" + "sync" "testing" "time" @@ -193,6 +195,71 @@ func TestApprovalChallengeAssertionBindsPasskeyEvidence(t *testing.T) { } } +func TestApprovalChallengeAssertionCannotOverwriteConcurrentRevocation(t *testing.T) { + now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) + for i := 0; i < 100; i++ { + registry := NewSurfaceRegistry(func() time.Time { return now }) + approvalID := fmt.Sprintf("approval-concurrent-%d", i) + if _, err := registry.PutApproval(contracts.ApprovalCeremony{ + ApprovalID: approvalID, + Subject: "shell_command", + Action: "shell_operate", + State: contracts.ApprovalCeremonyPending, + RequestedBy: "agent.local", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + challenge, err := registry.CreateApprovalChallenge(approvalID, "passkey", time.Minute) + if err != nil { + t.Fatal(err) + } + + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + var revokeErr error + go func() { + defer wg.Done() + <-start + _, _ = registry.AssertApprovalChallenge(contracts.ApprovalWebAuthnAssertion{ + ChallengeID: challenge.ChallengeID, + Actor: "user:alice", + Assertion: "signed-client-data", + }) + }() + go func() { + defer wg.Done() + <-start + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + items := registry.ListApprovals() + for _, item := range items { + if item.ApprovalID == approvalID && item.State == contracts.ApprovalCeremonyAllowed { + _, revokeErr = registry.TransitionApproval(approvalID, contracts.ApprovalCeremonyRevoked, "workstation.shellgate", "", "consumed") + return + } + } + } + }() + close(start) + wg.Wait() + + if revokeErr == nil { + var final contracts.ApprovalCeremony + for _, item := range registry.ListApprovals() { + if item.ApprovalID == approvalID { + final = item + } + } + if final.State != contracts.ApprovalCeremonyRevoked { + t.Fatalf("iteration %d: successful revoke was overwritten: %+v", i, final) + } + } + } +} + func TestFileBackedSurfaceRegistryPersistsRecords(t *testing.T) { now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) path := filepath.Join(t.TempDir(), "surfaces.json") diff --git a/core/pkg/workstation/shellgate.go b/core/pkg/workstation/shellgate.go index c02272c4a..714b31629 100644 --- a/core/pkg/workstation/shellgate.go +++ b/core/pkg/workstation/shellgate.go @@ -224,7 +224,9 @@ var outputBooleanFlags = map[string]struct{}{ // would create or overwrite: output redirections (`>`, `>>`, `>|`, with // optional fd prefixes like `2>`) and downloader-style output flags. File // descriptor duplication (`2>&1`) is not a write. Operators inside quoted -// strings are ignored, while quoted destinations are retained. +// strings are ignored, while quoted destinations are retained. Destinations +// containing shell expansion are returned as so the gate fails +// closed instead of treating an unresolved path as no write. func ExtractWriteTargets(command string) []string { seen := make(map[string]struct{}) for _, target := range redirectionTargets(command) { @@ -290,6 +292,11 @@ func redirectionTargets(line string) []string { j++ } start := j + if j < len(line) && (line[j] == '$' || line[j] == '`') { + targets = append(targets, "") + i = j + continue + } var targetQuote byte for j < len(line) { if line[j] == '\\' && targetQuote != '\'' && j+1 < len(line) { @@ -311,7 +318,11 @@ func redirectionTargets(line string) []string { j++ } if j > start { - targets = append(targets, strings.Trim(line[start:j], `"'`)) + target := strings.Trim(line[start:j], `"'`) + if strings.ContainsAny(target, "$`") { + target = "" + } + targets = append(targets, target) } i = j } diff --git a/core/pkg/workstation/shellgate_test.go b/core/pkg/workstation/shellgate_test.go index ca1f9338a..1cf1c82d8 100644 --- a/core/pkg/workstation/shellgate_test.go +++ b/core/pkg/workstation/shellgate_test.go @@ -137,12 +137,23 @@ func TestGateShellCommandProfiles(t *testing.T) { } func TestGateShellCommandDetectsQuotedRedirectAndYQInPlace(t *testing.T) { - for _, command := range []string{`cat input > "/tmp/out"`, `cat input >&/tmp/out`, `yq -i '.x = 1' config.yaml`, `yq --in-place '.x = 1' config.yaml`} { + for _, command := range []string{ + `cat input > "/tmp/out"`, + `cat input >&/tmp/out`, + `OUT=/tmp/x; cat payload >$OUT`, + `cat payload >"$OUT"`, + "cat payload >`mktemp`", + `yq -i '.x = 1' config.yaml`, + `yq --in-place '.x = 1' config.yaml`, + } { decision := GateShellCommand(ShellGateProfileProduction, command, []string{"cat", "yq"}) if decision.Verdict != ShellGateVerdictDeny || len(decision.WriteTargets) == 0 { t.Fatalf("GateShellCommand(%q) = %+v, want detected write denial", command, decision) } } + if got := ExtractWriteTargets(`OUT=/tmp/x; cat payload >$OUT`); len(got) != 1 || got[0] != "" { + t.Fatalf("dynamic redirect targets = %v, want []", got) + } if got := ExtractWriteTargets(`echo "a > b"`); got != nil { t.Fatalf("operator inside quoted text produced targets %v", got) } diff --git a/scripts/sdk/gen.sh b/scripts/sdk/gen.sh index 74a627544..ada5d5e18 100755 --- a/scripts/sdk/gen.sh +++ b/scripts/sdk/gen.sh @@ -323,7 +323,6 @@ import ( HEADER GO_SKIP_MODELS=( model_agent_identity_profile.go - model_approval_ceremony.go model_approval_web_authn_assertion.go model_approval_web_authn_challenge.go model_authz_health.go diff --git a/sdk/go/client/execution_boundary.go b/sdk/go/client/execution_boundary.go index 214821244..7883b14bc 100644 --- a/sdk/go/client/execution_boundary.go +++ b/sdk/go/client/execution_boundary.go @@ -115,7 +115,6 @@ type MCPAuthorizeCallRequest map[string]any type SandboxPreflightRequest map[string]any type SandboxPreflightResult map[string]any type AuthzSnapshot map[string]any -type ApprovalCeremony map[string]any type ApprovalWebAuthnChallenge map[string]any type ApprovalWebAuthnAssertion map[string]any type BudgetCeiling map[string]any diff --git a/sdk/go/client/execution_boundary_test.go b/sdk/go/client/execution_boundary_test.go index 33ba80c1b..9ef7c3cd3 100644 --- a/sdk/go/client/execution_boundary_test.go +++ b/sdk/go/client/execution_boundary_test.go @@ -8,6 +8,10 @@ import ( "testing" ) +func stringPtr(value string) *string { + return &value +} + func TestExecutionBoundaryClientMethods(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/api/v1/evidence/envelopes", func(w http.ResponseWriter, r *http.Request) { @@ -32,7 +36,7 @@ func TestExecutionBoundaryClientMethods(t *testing.T) { writeJSON(t, w, ApprovalWebAuthnChallenge{"challenge_id": "ch1", "approval_id": "ap1"}) }) mux.HandleFunc("/api/v1/approvals/ap1/webauthn/assert", func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, ApprovalCeremony{"approval_id": "ap1", "state": "approved"}) + writeJSON(t, w, ApprovalCeremony{ApprovalId: stringPtr("ap1"), State: stringPtr("approved"), BindingHash: stringPtr("sha256:command")}) }) mux.HandleFunc("/api/v1/conformance/negative", func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, []NegativeBoundaryVector{{ID: "pdp-outage", Category: "policy"}}) @@ -79,7 +83,7 @@ func TestExecutionBoundaryClientMethods(t *testing.T) { t.Fatalf("challenge = %#v, err = %v", challenge, err) } asserted, err := client.AssertApprovalWebAuthnChallenge("ap1", ApprovalWebAuthnAssertion{"challenge_id": "ch1", "assertion": "sig"}) - if err != nil || (*asserted)["state"] != "approved" { + if err != nil || asserted.GetState() != "approved" || asserted.GetBindingHash() != "sha256:command" { t.Fatalf("asserted = %#v, err = %v", asserted, err) } vectors, err := client.ListNegativeConformanceVectors() @@ -252,7 +256,7 @@ func TestGoClientEndpointCoverageMatrix(t *testing.T) { {"get authz snapshot", "GET /api/v1/authz/snapshots/snapshot%2Fa%20b", func() error { _, err := client.GetAuthzSnapshot("snapshot/a b"); return err }}, {"list approvals", "GET /api/v1/approvals", func() error { _, err := client.ListApprovalCeremonies(); return err }}, {"create approval", "POST /api/v1/approvals", func() error { - _, err := client.CreateApprovalCeremony(ApprovalCeremony{"approval_id": "a1"}) + _, err := client.CreateApprovalCeremony(ApprovalCeremony{ApprovalId: stringPtr("a1"), BindingHash: stringPtr("sha256:command")}) return err }}, {"transition approval", "POST /api/v1/approvals/approval%2Fa%20b/approve", func() error { diff --git a/sdk/go/client/types_gen.go b/sdk/go/client/types_gen.go index 4332f6cfa..f0192ad67 100644 --- a/sdk/go/client/types_gen.go +++ b/sdk/go/client/types_gen.go @@ -2917,6 +2917,665 @@ API version: 0.7.5 // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. +// checks if the ApprovalCeremony type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &ApprovalCeremony{} + +// ApprovalCeremony struct for ApprovalCeremony +type ApprovalCeremony struct { + ApprovalId *string `json:"approval_id,omitempty"` + Subject *string `json:"subject,omitempty"` + Action *string `json:"action,omitempty"` + State *string `json:"state,omitempty"` + RequestedBy *string `json:"requested_by,omitempty"` + Approvers []string `json:"approvers,omitempty"` + Quorum *int32 `json:"quorum,omitempty"` + TimelockUntil *time.Time `json:"timelock_until,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + BreakGlass *bool `json:"break_glass,omitempty"` + BindingHash *string `json:"binding_hash,omitempty"` + Reason *string `json:"reason,omitempty"` + ReceiptId *string `json:"receipt_id,omitempty"` + CeremonyHash *string `json:"ceremony_hash,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// NewApprovalCeremony instantiates a new ApprovalCeremony object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewApprovalCeremony() *ApprovalCeremony { + this := ApprovalCeremony{} + return &this +} + +// NewApprovalCeremonyWithDefaults instantiates a new ApprovalCeremony object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewApprovalCeremonyWithDefaults() *ApprovalCeremony { + this := ApprovalCeremony{} + return &this +} + +// GetApprovalId returns the ApprovalId field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetApprovalId() string { + if o == nil || IsNil(o.ApprovalId) { + var ret string + return ret + } + return *o.ApprovalId +} + +// GetApprovalIdOk returns a tuple with the ApprovalId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetApprovalIdOk() (*string, bool) { + if o == nil || IsNil(o.ApprovalId) { + return nil, false + } + return o.ApprovalId, true +} + +// HasApprovalId returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasApprovalId() bool { + if o != nil && !IsNil(o.ApprovalId) { + return true + } + + return false +} + +// SetApprovalId gets a reference to the given string and assigns it to the ApprovalId field. +func (o *ApprovalCeremony) SetApprovalId(v string) { + o.ApprovalId = &v +} + +// GetSubject returns the Subject field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetSubject() string { + if o == nil || IsNil(o.Subject) { + var ret string + return ret + } + return *o.Subject +} + +// GetSubjectOk returns a tuple with the Subject field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetSubjectOk() (*string, bool) { + if o == nil || IsNil(o.Subject) { + return nil, false + } + return o.Subject, true +} + +// HasSubject returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasSubject() bool { + if o != nil && !IsNil(o.Subject) { + return true + } + + return false +} + +// SetSubject gets a reference to the given string and assigns it to the Subject field. +func (o *ApprovalCeremony) SetSubject(v string) { + o.Subject = &v +} + +// GetAction returns the Action field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetAction() string { + if o == nil || IsNil(o.Action) { + var ret string + return ret + } + return *o.Action +} + +// GetActionOk returns a tuple with the Action field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetActionOk() (*string, bool) { + if o == nil || IsNil(o.Action) { + return nil, false + } + return o.Action, true +} + +// HasAction returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasAction() bool { + if o != nil && !IsNil(o.Action) { + return true + } + + return false +} + +// SetAction gets a reference to the given string and assigns it to the Action field. +func (o *ApprovalCeremony) SetAction(v string) { + o.Action = &v +} + +// GetState returns the State field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetState() string { + if o == nil || IsNil(o.State) { + var ret string + return ret + } + return *o.State +} + +// GetStateOk returns a tuple with the State field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetStateOk() (*string, bool) { + if o == nil || IsNil(o.State) { + return nil, false + } + return o.State, true +} + +// HasState returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasState() bool { + if o != nil && !IsNil(o.State) { + return true + } + + return false +} + +// SetState gets a reference to the given string and assigns it to the State field. +func (o *ApprovalCeremony) SetState(v string) { + o.State = &v +} + +// GetRequestedBy returns the RequestedBy field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetRequestedBy() string { + if o == nil || IsNil(o.RequestedBy) { + var ret string + return ret + } + return *o.RequestedBy +} + +// GetRequestedByOk returns a tuple with the RequestedBy field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetRequestedByOk() (*string, bool) { + if o == nil || IsNil(o.RequestedBy) { + return nil, false + } + return o.RequestedBy, true +} + +// HasRequestedBy returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasRequestedBy() bool { + if o != nil && !IsNil(o.RequestedBy) { + return true + } + + return false +} + +// SetRequestedBy gets a reference to the given string and assigns it to the RequestedBy field. +func (o *ApprovalCeremony) SetRequestedBy(v string) { + o.RequestedBy = &v +} + +// GetApprovers returns the Approvers field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetApprovers() []string { + if o == nil || IsNil(o.Approvers) { + var ret []string + return ret + } + return o.Approvers +} + +// GetApproversOk returns a tuple with the Approvers field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetApproversOk() ([]string, bool) { + if o == nil || IsNil(o.Approvers) { + return nil, false + } + return o.Approvers, true +} + +// HasApprovers returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasApprovers() bool { + if o != nil && !IsNil(o.Approvers) { + return true + } + + return false +} + +// SetApprovers gets a reference to the given []string and assigns it to the Approvers field. +func (o *ApprovalCeremony) SetApprovers(v []string) { + o.Approvers = v +} + +// GetQuorum returns the Quorum field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetQuorum() int32 { + if o == nil || IsNil(o.Quorum) { + var ret int32 + return ret + } + return *o.Quorum +} + +// GetQuorumOk returns a tuple with the Quorum field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetQuorumOk() (*int32, bool) { + if o == nil || IsNil(o.Quorum) { + return nil, false + } + return o.Quorum, true +} + +// HasQuorum returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasQuorum() bool { + if o != nil && !IsNil(o.Quorum) { + return true + } + + return false +} + +// SetQuorum gets a reference to the given int32 and assigns it to the Quorum field. +func (o *ApprovalCeremony) SetQuorum(v int32) { + o.Quorum = &v +} + +// GetTimelockUntil returns the TimelockUntil field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetTimelockUntil() time.Time { + if o == nil || IsNil(o.TimelockUntil) { + var ret time.Time + return ret + } + return *o.TimelockUntil +} + +// GetTimelockUntilOk returns a tuple with the TimelockUntil field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetTimelockUntilOk() (*time.Time, bool) { + if o == nil || IsNil(o.TimelockUntil) { + return nil, false + } + return o.TimelockUntil, true +} + +// HasTimelockUntil returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasTimelockUntil() bool { + if o != nil && !IsNil(o.TimelockUntil) { + return true + } + + return false +} + +// SetTimelockUntil gets a reference to the given time.Time and assigns it to the TimelockUntil field. +func (o *ApprovalCeremony) SetTimelockUntil(v time.Time) { + o.TimelockUntil = &v +} + +// GetExpiresAt returns the ExpiresAt field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetExpiresAt() time.Time { + if o == nil || IsNil(o.ExpiresAt) { + var ret time.Time + return ret + } + return *o.ExpiresAt +} + +// GetExpiresAtOk returns a tuple with the ExpiresAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetExpiresAtOk() (*time.Time, bool) { + if o == nil || IsNil(o.ExpiresAt) { + return nil, false + } + return o.ExpiresAt, true +} + +// HasExpiresAt returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasExpiresAt() bool { + if o != nil && !IsNil(o.ExpiresAt) { + return true + } + + return false +} + +// SetExpiresAt gets a reference to the given time.Time and assigns it to the ExpiresAt field. +func (o *ApprovalCeremony) SetExpiresAt(v time.Time) { + o.ExpiresAt = &v +} + +// GetBreakGlass returns the BreakGlass field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetBreakGlass() bool { + if o == nil || IsNil(o.BreakGlass) { + var ret bool + return ret + } + return *o.BreakGlass +} + +// GetBreakGlassOk returns a tuple with the BreakGlass field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetBreakGlassOk() (*bool, bool) { + if o == nil || IsNil(o.BreakGlass) { + return nil, false + } + return o.BreakGlass, true +} + +// HasBreakGlass returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasBreakGlass() bool { + if o != nil && !IsNil(o.BreakGlass) { + return true + } + + return false +} + +// SetBreakGlass gets a reference to the given bool and assigns it to the BreakGlass field. +func (o *ApprovalCeremony) SetBreakGlass(v bool) { + o.BreakGlass = &v +} + +// GetBindingHash returns the BindingHash field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetBindingHash() string { + if o == nil || IsNil(o.BindingHash) { + var ret string + return ret + } + return *o.BindingHash +} + +// GetBindingHashOk returns a tuple with the BindingHash field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetBindingHashOk() (*string, bool) { + if o == nil || IsNil(o.BindingHash) { + return nil, false + } + return o.BindingHash, true +} + +// HasBindingHash returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasBindingHash() bool { + if o != nil && !IsNil(o.BindingHash) { + return true + } + + return false +} + +// SetBindingHash gets a reference to the given string and assigns it to the BindingHash field. +func (o *ApprovalCeremony) SetBindingHash(v string) { + o.BindingHash = &v +} + +// GetReason returns the Reason field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetReason() string { + if o == nil || IsNil(o.Reason) { + var ret string + return ret + } + return *o.Reason +} + +// GetReasonOk returns a tuple with the Reason field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetReasonOk() (*string, bool) { + if o == nil || IsNil(o.Reason) { + return nil, false + } + return o.Reason, true +} + +// HasReason returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasReason() bool { + if o != nil && !IsNil(o.Reason) { + return true + } + + return false +} + +// SetReason gets a reference to the given string and assigns it to the Reason field. +func (o *ApprovalCeremony) SetReason(v string) { + o.Reason = &v +} + +// GetReceiptId returns the ReceiptId field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetReceiptId() string { + if o == nil || IsNil(o.ReceiptId) { + var ret string + return ret + } + return *o.ReceiptId +} + +// GetReceiptIdOk returns a tuple with the ReceiptId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetReceiptIdOk() (*string, bool) { + if o == nil || IsNil(o.ReceiptId) { + return nil, false + } + return o.ReceiptId, true +} + +// HasReceiptId returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasReceiptId() bool { + if o != nil && !IsNil(o.ReceiptId) { + return true + } + + return false +} + +// SetReceiptId gets a reference to the given string and assigns it to the ReceiptId field. +func (o *ApprovalCeremony) SetReceiptId(v string) { + o.ReceiptId = &v +} + +// GetCeremonyHash returns the CeremonyHash field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetCeremonyHash() string { + if o == nil || IsNil(o.CeremonyHash) { + var ret string + return ret + } + return *o.CeremonyHash +} + +// GetCeremonyHashOk returns a tuple with the CeremonyHash field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetCeremonyHashOk() (*string, bool) { + if o == nil || IsNil(o.CeremonyHash) { + return nil, false + } + return o.CeremonyHash, true +} + +// HasCeremonyHash returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasCeremonyHash() bool { + if o != nil && !IsNil(o.CeremonyHash) { + return true + } + + return false +} + +// SetCeremonyHash gets a reference to the given string and assigns it to the CeremonyHash field. +func (o *ApprovalCeremony) SetCeremonyHash(v string) { + o.CeremonyHash = &v +} + +// GetCreatedAt returns the CreatedAt field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetCreatedAt() time.Time { + if o == nil || IsNil(o.CreatedAt) { + var ret time.Time + return ret + } + return *o.CreatedAt +} + +// GetCreatedAtOk returns a tuple with the CreatedAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetCreatedAtOk() (*time.Time, bool) { + if o == nil || IsNil(o.CreatedAt) { + return nil, false + } + return o.CreatedAt, true +} + +// HasCreatedAt returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasCreatedAt() bool { + if o != nil && !IsNil(o.CreatedAt) { + return true + } + + return false +} + +// SetCreatedAt gets a reference to the given time.Time and assigns it to the CreatedAt field. +func (o *ApprovalCeremony) SetCreatedAt(v time.Time) { + o.CreatedAt = &v +} + +// GetUpdatedAt returns the UpdatedAt field value if set, zero value otherwise. +func (o *ApprovalCeremony) GetUpdatedAt() time.Time { + if o == nil || IsNil(o.UpdatedAt) { + var ret time.Time + return ret + } + return *o.UpdatedAt +} + +// GetUpdatedAtOk returns a tuple with the UpdatedAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ApprovalCeremony) GetUpdatedAtOk() (*time.Time, bool) { + if o == nil || IsNil(o.UpdatedAt) { + return nil, false + } + return o.UpdatedAt, true +} + +// HasUpdatedAt returns a boolean if a field has been set. +func (o *ApprovalCeremony) HasUpdatedAt() bool { + if o != nil && !IsNil(o.UpdatedAt) { + return true + } + + return false +} + +// SetUpdatedAt gets a reference to the given time.Time and assigns it to the UpdatedAt field. +func (o *ApprovalCeremony) SetUpdatedAt(v time.Time) { + o.UpdatedAt = &v +} + +func (o ApprovalCeremony) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o ApprovalCeremony) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.ApprovalId) { + toSerialize["approval_id"] = o.ApprovalId + } + if !IsNil(o.Subject) { + toSerialize["subject"] = o.Subject + } + if !IsNil(o.Action) { + toSerialize["action"] = o.Action + } + if !IsNil(o.State) { + toSerialize["state"] = o.State + } + if !IsNil(o.RequestedBy) { + toSerialize["requested_by"] = o.RequestedBy + } + if !IsNil(o.Approvers) { + toSerialize["approvers"] = o.Approvers + } + if !IsNil(o.Quorum) { + toSerialize["quorum"] = o.Quorum + } + if !IsNil(o.TimelockUntil) { + toSerialize["timelock_until"] = o.TimelockUntil + } + if !IsNil(o.ExpiresAt) { + toSerialize["expires_at"] = o.ExpiresAt + } + if !IsNil(o.BreakGlass) { + toSerialize["break_glass"] = o.BreakGlass + } + if !IsNil(o.BindingHash) { + toSerialize["binding_hash"] = o.BindingHash + } + if !IsNil(o.Reason) { + toSerialize["reason"] = o.Reason + } + if !IsNil(o.ReceiptId) { + toSerialize["receipt_id"] = o.ReceiptId + } + if !IsNil(o.CeremonyHash) { + toSerialize["ceremony_hash"] = o.CeremonyHash + } + if !IsNil(o.CreatedAt) { + toSerialize["created_at"] = o.CreatedAt + } + if !IsNil(o.UpdatedAt) { + toSerialize["updated_at"] = o.UpdatedAt + } + return toSerialize, nil +} + +type NullableApprovalCeremony struct { + value *ApprovalCeremony + isSet bool +} + +func (v NullableApprovalCeremony) Get() *ApprovalCeremony { + return v.value +} + +func (v *NullableApprovalCeremony) Set(val *ApprovalCeremony) { + v.value = val + v.isSet = true +} + +func (v NullableApprovalCeremony) IsSet() bool { + return v.isSet +} + +func (v *NullableApprovalCeremony) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableApprovalCeremony(val *ApprovalCeremony) *NullableApprovalCeremony { + return &NullableApprovalCeremony{value: val, isSet: true} +} + +func (v NullableApprovalCeremony) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableApprovalCeremony) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + +/* +HELM Kernel API + +Deterministic execution kernel for AI tool calls. Drop-in OpenAI proxy + cryptographic receipts + offline-verifiable evidence packs. + +API version: 0.7.5 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + // checks if the ApprovalRequest type satisfies the MappedNullable interface at compile time var _ MappedNullable = &ApprovalRequest{} diff --git a/sdk/go/generated.manifest.json b/sdk/go/generated.manifest.json index 01be732e4..7386e8992 100644 --- a/sdk/go/generated.manifest.json +++ b/sdk/go/generated.manifest.json @@ -2,7 +2,7 @@ "files": [ { "path": "client/types_gen.go", - "sha256": "68d3211fa2fd4514224108eaa9a74e175ff38d6228708fc74b2c1c953ca1c5dc" + "sha256": "b558ed2fe99a0789a520ac041225e1e09caad7a7b96db4bea90d2873fbf7de4c" } ], "generator": "openapitools/openapi-generator-cli:v7.4.0@sha256:579832bed49ea6c275ce2fb5f2d515f5b03d2b6243f3c80fa8430e4f5a770e9a", From 3badfee29682d4a620a61d2c0fb5ed9f53cd20ca Mon Sep 17 00:00:00 2001 From: Mindburn Labs Date: Thu, 30 Jul 2026 14:50:07 +0300 Subject: [PATCH 12/12] fix(workstation): close permit security findings --- .../helm-ai-kernel/workstation_gate_cmd.go | 10 +- .../workstation_gate_cmd_test.go | 18 + core/pkg/workstation/shellallowlist.go | 14 +- core/pkg/workstation/shellgate_test.go | 23 + scripts/sdk/gen.sh | 1 + sdk/go/client/execution_boundary.go | 1 + sdk/go/client/execution_boundary_test.go | 6 +- sdk/go/client/types_gen.go | 659 ------------------ sdk/go/generated.manifest.json | 2 +- 9 files changed, 65 insertions(+), 669 deletions(-) diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go index 28f51f03a..e9a83e2f3 100644 --- a/core/cmd/helm-ai-kernel/workstation_gate_cmd.go +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go @@ -100,15 +100,15 @@ func printGateDecision(stdout io.Writer, decision workstation.ShellGateDecision, _, _ = fmt.Fprintf(stdout, "%sShell Gate Decision%s\n", ColorBold, ColorReset) _, _ = fmt.Fprintf(stdout, " verdict: %s\n", decision.Verdict) _, _ = fmt.Fprintf(stdout, " profile: %s\n", decision.Profile) - _, _ = fmt.Fprintf(stdout, " command: %s\n", decision.Command) - _, _ = fmt.Fprintf(stdout, " invoked: %s\n", strings.Join(decision.Invoked, ", ")) + _, _ = fmt.Fprintf(stdout, " command: %s\n", terminalSafe(decision.Command)) + _, _ = fmt.Fprintf(stdout, " invoked: %s\n", terminalSafe(strings.Join(decision.Invoked, ", "))) if len(decision.Blocked) > 0 { - _, _ = fmt.Fprintf(stdout, " blocked: %s\n", strings.Join(decision.Blocked, ", ")) + _, _ = fmt.Fprintf(stdout, " blocked: %s\n", terminalSafe(strings.Join(decision.Blocked, ", "))) } if decision.Reason != "" { - _, _ = fmt.Fprintf(stdout, " reason: %s\n", decision.Reason) + _, _ = fmt.Fprintf(stdout, " reason: %s\n", terminalSafe(decision.Reason)) } - _, _ = fmt.Fprintf(stdout, " allowlist: %s\n", allowlistPath) + _, _ = fmt.Fprintf(stdout, " allowlist: %s\n", terminalSafe(allowlistPath)) } // requestShellGateApproval turns a pending_approval verdict into an approval diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go index 9bc0cb93c..de8a7800e 100644 --- a/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go @@ -60,6 +60,24 @@ func TestWorkstationGateProductionDeny(t *testing.T) { } } +func TestPrintGateDecisionSanitizesTerminalText(t *testing.T) { + var out bytes.Buffer + printGateDecision(&out, workstation.ShellGateDecision{ + Command: "\x1b[2Jrm\rspoof", + Invoked: []string{"rm\x00"}, + Blocked: []string{"rm\x1b"}, + Reason: "blocked\u202Etxt", + }, "/tmp/\x1ballowlist") + if strings.Count(out.String(), "\x1b") != 2 || strings.Contains(out.String(), "\x1b[2J") { + t.Fatalf("gate output contains attacker-controlled terminal escape: %q", out.String()) + } + for _, control := range []string{"\r", "\x00", "\u202E"} { + if strings.Contains(out.String(), control) { + t.Fatalf("gate output contains terminal control %q: %q", control, out.String()) + } + } +} + func TestWorkstationGateDevEscalates(t *testing.T) { allowlist := gateTestAllowlist(t, []string{"ls"}) code, out, _ := runGateForTest(t, "--profile", "dev", "--allowlist", allowlist, "--command", "ls && rm -rf /tmp/x") diff --git a/core/pkg/workstation/shellallowlist.go b/core/pkg/workstation/shellallowlist.go index b2dec5ec4..47f359eff 100644 --- a/core/pkg/workstation/shellallowlist.go +++ b/core/pkg/workstation/shellallowlist.go @@ -118,9 +118,21 @@ func (s *ShellAllowlistStore) seedLocked() error { if err != nil { return fmt.Errorf("encode default shell allowlist: %w", err) } - if err := os.WriteFile(s.path, append(data, '\n'), 0o600); err != nil { + file, err := os.OpenFile(s.path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return fmt.Errorf("seed shell allowlist %s: %w", s.path, err) + } + if _, err := file.Write(append(data, '\n')); err != nil { + _ = file.Close() return fmt.Errorf("seed shell allowlist %s: %w", s.path, err) } + if err := file.Sync(); err != nil { + _ = file.Close() + return fmt.Errorf("sync shell allowlist %s: %w", s.path, err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close shell allowlist %s: %w", s.path, err) + } return nil } diff --git a/core/pkg/workstation/shellgate_test.go b/core/pkg/workstation/shellgate_test.go index 1cf1c82d8..b71ab5bd0 100644 --- a/core/pkg/workstation/shellgate_test.go +++ b/core/pkg/workstation/shellgate_test.go @@ -205,6 +205,29 @@ func TestShellAllowlistStoreSeedsDefaults(t *testing.T) { } } +func TestShellAllowlistSeedDoesNotFollowSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("keep"), 0o600); err != nil { + t.Fatalf("write target: %v", err) + } + path := filepath.Join(dir, ShellAllowlistFilename) + if err := os.Symlink(target, path); err != nil { + t.Fatalf("symlink: %v", err) + } + + if err := NewShellAllowlistStore(path).seedLocked(); err == nil { + t.Fatal("seed through symlink must fail") + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read target: %v", err) + } + if string(data) != "keep" { + t.Fatalf("symlink target changed to %q", data) + } +} + func TestDefaultShellAllowlistExcludesYQ(t *testing.T) { if containsString(DefaultShellAllowlist, "yq") { t.Fatal("default allowlist must exclude yq because it can edit files in place") diff --git a/scripts/sdk/gen.sh b/scripts/sdk/gen.sh index ada5d5e18..74a627544 100755 --- a/scripts/sdk/gen.sh +++ b/scripts/sdk/gen.sh @@ -323,6 +323,7 @@ import ( HEADER GO_SKIP_MODELS=( model_agent_identity_profile.go + model_approval_ceremony.go model_approval_web_authn_assertion.go model_approval_web_authn_challenge.go model_authz_health.go diff --git a/sdk/go/client/execution_boundary.go b/sdk/go/client/execution_boundary.go index 7883b14bc..214821244 100644 --- a/sdk/go/client/execution_boundary.go +++ b/sdk/go/client/execution_boundary.go @@ -115,6 +115,7 @@ type MCPAuthorizeCallRequest map[string]any type SandboxPreflightRequest map[string]any type SandboxPreflightResult map[string]any type AuthzSnapshot map[string]any +type ApprovalCeremony map[string]any type ApprovalWebAuthnChallenge map[string]any type ApprovalWebAuthnAssertion map[string]any type BudgetCeiling map[string]any diff --git a/sdk/go/client/execution_boundary_test.go b/sdk/go/client/execution_boundary_test.go index 9ef7c3cd3..c3ee29947 100644 --- a/sdk/go/client/execution_boundary_test.go +++ b/sdk/go/client/execution_boundary_test.go @@ -36,7 +36,7 @@ func TestExecutionBoundaryClientMethods(t *testing.T) { writeJSON(t, w, ApprovalWebAuthnChallenge{"challenge_id": "ch1", "approval_id": "ap1"}) }) mux.HandleFunc("/api/v1/approvals/ap1/webauthn/assert", func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, ApprovalCeremony{ApprovalId: stringPtr("ap1"), State: stringPtr("approved"), BindingHash: stringPtr("sha256:command")}) + writeJSON(t, w, ApprovalCeremony{"approval_id": "ap1", "state": "approved", "binding_hash": "sha256:command"}) }) mux.HandleFunc("/api/v1/conformance/negative", func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, []NegativeBoundaryVector{{ID: "pdp-outage", Category: "policy"}}) @@ -83,7 +83,7 @@ func TestExecutionBoundaryClientMethods(t *testing.T) { t.Fatalf("challenge = %#v, err = %v", challenge, err) } asserted, err := client.AssertApprovalWebAuthnChallenge("ap1", ApprovalWebAuthnAssertion{"challenge_id": "ch1", "assertion": "sig"}) - if err != nil || asserted.GetState() != "approved" || asserted.GetBindingHash() != "sha256:command" { + if err != nil || (*asserted)["state"] != "approved" || (*asserted)["binding_hash"] != "sha256:command" { t.Fatalf("asserted = %#v, err = %v", asserted, err) } vectors, err := client.ListNegativeConformanceVectors() @@ -256,7 +256,7 @@ func TestGoClientEndpointCoverageMatrix(t *testing.T) { {"get authz snapshot", "GET /api/v1/authz/snapshots/snapshot%2Fa%20b", func() error { _, err := client.GetAuthzSnapshot("snapshot/a b"); return err }}, {"list approvals", "GET /api/v1/approvals", func() error { _, err := client.ListApprovalCeremonies(); return err }}, {"create approval", "POST /api/v1/approvals", func() error { - _, err := client.CreateApprovalCeremony(ApprovalCeremony{ApprovalId: stringPtr("a1"), BindingHash: stringPtr("sha256:command")}) + _, err := client.CreateApprovalCeremony(ApprovalCeremony{"approval_id": "a1", "binding_hash": "sha256:command"}) return err }}, {"transition approval", "POST /api/v1/approvals/approval%2Fa%20b/approve", func() error { diff --git a/sdk/go/client/types_gen.go b/sdk/go/client/types_gen.go index f0192ad67..4332f6cfa 100644 --- a/sdk/go/client/types_gen.go +++ b/sdk/go/client/types_gen.go @@ -2917,665 +2917,6 @@ API version: 0.7.5 // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. -// checks if the ApprovalCeremony type satisfies the MappedNullable interface at compile time -var _ MappedNullable = &ApprovalCeremony{} - -// ApprovalCeremony struct for ApprovalCeremony -type ApprovalCeremony struct { - ApprovalId *string `json:"approval_id,omitempty"` - Subject *string `json:"subject,omitempty"` - Action *string `json:"action,omitempty"` - State *string `json:"state,omitempty"` - RequestedBy *string `json:"requested_by,omitempty"` - Approvers []string `json:"approvers,omitempty"` - Quorum *int32 `json:"quorum,omitempty"` - TimelockUntil *time.Time `json:"timelock_until,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - BreakGlass *bool `json:"break_glass,omitempty"` - BindingHash *string `json:"binding_hash,omitempty"` - Reason *string `json:"reason,omitempty"` - ReceiptId *string `json:"receipt_id,omitempty"` - CeremonyHash *string `json:"ceremony_hash,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` -} - -// NewApprovalCeremony instantiates a new ApprovalCeremony object -// This constructor will assign default values to properties that have it defined, -// and makes sure properties required by API are set, but the set of arguments -// will change when the set of required properties is changed -func NewApprovalCeremony() *ApprovalCeremony { - this := ApprovalCeremony{} - return &this -} - -// NewApprovalCeremonyWithDefaults instantiates a new ApprovalCeremony object -// This constructor will only assign default values to properties that have it defined, -// but it doesn't guarantee that properties required by API are set -func NewApprovalCeremonyWithDefaults() *ApprovalCeremony { - this := ApprovalCeremony{} - return &this -} - -// GetApprovalId returns the ApprovalId field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetApprovalId() string { - if o == nil || IsNil(o.ApprovalId) { - var ret string - return ret - } - return *o.ApprovalId -} - -// GetApprovalIdOk returns a tuple with the ApprovalId field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetApprovalIdOk() (*string, bool) { - if o == nil || IsNil(o.ApprovalId) { - return nil, false - } - return o.ApprovalId, true -} - -// HasApprovalId returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasApprovalId() bool { - if o != nil && !IsNil(o.ApprovalId) { - return true - } - - return false -} - -// SetApprovalId gets a reference to the given string and assigns it to the ApprovalId field. -func (o *ApprovalCeremony) SetApprovalId(v string) { - o.ApprovalId = &v -} - -// GetSubject returns the Subject field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetSubject() string { - if o == nil || IsNil(o.Subject) { - var ret string - return ret - } - return *o.Subject -} - -// GetSubjectOk returns a tuple with the Subject field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetSubjectOk() (*string, bool) { - if o == nil || IsNil(o.Subject) { - return nil, false - } - return o.Subject, true -} - -// HasSubject returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasSubject() bool { - if o != nil && !IsNil(o.Subject) { - return true - } - - return false -} - -// SetSubject gets a reference to the given string and assigns it to the Subject field. -func (o *ApprovalCeremony) SetSubject(v string) { - o.Subject = &v -} - -// GetAction returns the Action field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetAction() string { - if o == nil || IsNil(o.Action) { - var ret string - return ret - } - return *o.Action -} - -// GetActionOk returns a tuple with the Action field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetActionOk() (*string, bool) { - if o == nil || IsNil(o.Action) { - return nil, false - } - return o.Action, true -} - -// HasAction returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasAction() bool { - if o != nil && !IsNil(o.Action) { - return true - } - - return false -} - -// SetAction gets a reference to the given string and assigns it to the Action field. -func (o *ApprovalCeremony) SetAction(v string) { - o.Action = &v -} - -// GetState returns the State field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetState() string { - if o == nil || IsNil(o.State) { - var ret string - return ret - } - return *o.State -} - -// GetStateOk returns a tuple with the State field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetStateOk() (*string, bool) { - if o == nil || IsNil(o.State) { - return nil, false - } - return o.State, true -} - -// HasState returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasState() bool { - if o != nil && !IsNil(o.State) { - return true - } - - return false -} - -// SetState gets a reference to the given string and assigns it to the State field. -func (o *ApprovalCeremony) SetState(v string) { - o.State = &v -} - -// GetRequestedBy returns the RequestedBy field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetRequestedBy() string { - if o == nil || IsNil(o.RequestedBy) { - var ret string - return ret - } - return *o.RequestedBy -} - -// GetRequestedByOk returns a tuple with the RequestedBy field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetRequestedByOk() (*string, bool) { - if o == nil || IsNil(o.RequestedBy) { - return nil, false - } - return o.RequestedBy, true -} - -// HasRequestedBy returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasRequestedBy() bool { - if o != nil && !IsNil(o.RequestedBy) { - return true - } - - return false -} - -// SetRequestedBy gets a reference to the given string and assigns it to the RequestedBy field. -func (o *ApprovalCeremony) SetRequestedBy(v string) { - o.RequestedBy = &v -} - -// GetApprovers returns the Approvers field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetApprovers() []string { - if o == nil || IsNil(o.Approvers) { - var ret []string - return ret - } - return o.Approvers -} - -// GetApproversOk returns a tuple with the Approvers field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetApproversOk() ([]string, bool) { - if o == nil || IsNil(o.Approvers) { - return nil, false - } - return o.Approvers, true -} - -// HasApprovers returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasApprovers() bool { - if o != nil && !IsNil(o.Approvers) { - return true - } - - return false -} - -// SetApprovers gets a reference to the given []string and assigns it to the Approvers field. -func (o *ApprovalCeremony) SetApprovers(v []string) { - o.Approvers = v -} - -// GetQuorum returns the Quorum field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetQuorum() int32 { - if o == nil || IsNil(o.Quorum) { - var ret int32 - return ret - } - return *o.Quorum -} - -// GetQuorumOk returns a tuple with the Quorum field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetQuorumOk() (*int32, bool) { - if o == nil || IsNil(o.Quorum) { - return nil, false - } - return o.Quorum, true -} - -// HasQuorum returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasQuorum() bool { - if o != nil && !IsNil(o.Quorum) { - return true - } - - return false -} - -// SetQuorum gets a reference to the given int32 and assigns it to the Quorum field. -func (o *ApprovalCeremony) SetQuorum(v int32) { - o.Quorum = &v -} - -// GetTimelockUntil returns the TimelockUntil field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetTimelockUntil() time.Time { - if o == nil || IsNil(o.TimelockUntil) { - var ret time.Time - return ret - } - return *o.TimelockUntil -} - -// GetTimelockUntilOk returns a tuple with the TimelockUntil field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetTimelockUntilOk() (*time.Time, bool) { - if o == nil || IsNil(o.TimelockUntil) { - return nil, false - } - return o.TimelockUntil, true -} - -// HasTimelockUntil returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasTimelockUntil() bool { - if o != nil && !IsNil(o.TimelockUntil) { - return true - } - - return false -} - -// SetTimelockUntil gets a reference to the given time.Time and assigns it to the TimelockUntil field. -func (o *ApprovalCeremony) SetTimelockUntil(v time.Time) { - o.TimelockUntil = &v -} - -// GetExpiresAt returns the ExpiresAt field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetExpiresAt() time.Time { - if o == nil || IsNil(o.ExpiresAt) { - var ret time.Time - return ret - } - return *o.ExpiresAt -} - -// GetExpiresAtOk returns a tuple with the ExpiresAt field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetExpiresAtOk() (*time.Time, bool) { - if o == nil || IsNil(o.ExpiresAt) { - return nil, false - } - return o.ExpiresAt, true -} - -// HasExpiresAt returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasExpiresAt() bool { - if o != nil && !IsNil(o.ExpiresAt) { - return true - } - - return false -} - -// SetExpiresAt gets a reference to the given time.Time and assigns it to the ExpiresAt field. -func (o *ApprovalCeremony) SetExpiresAt(v time.Time) { - o.ExpiresAt = &v -} - -// GetBreakGlass returns the BreakGlass field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetBreakGlass() bool { - if o == nil || IsNil(o.BreakGlass) { - var ret bool - return ret - } - return *o.BreakGlass -} - -// GetBreakGlassOk returns a tuple with the BreakGlass field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetBreakGlassOk() (*bool, bool) { - if o == nil || IsNil(o.BreakGlass) { - return nil, false - } - return o.BreakGlass, true -} - -// HasBreakGlass returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasBreakGlass() bool { - if o != nil && !IsNil(o.BreakGlass) { - return true - } - - return false -} - -// SetBreakGlass gets a reference to the given bool and assigns it to the BreakGlass field. -func (o *ApprovalCeremony) SetBreakGlass(v bool) { - o.BreakGlass = &v -} - -// GetBindingHash returns the BindingHash field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetBindingHash() string { - if o == nil || IsNil(o.BindingHash) { - var ret string - return ret - } - return *o.BindingHash -} - -// GetBindingHashOk returns a tuple with the BindingHash field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetBindingHashOk() (*string, bool) { - if o == nil || IsNil(o.BindingHash) { - return nil, false - } - return o.BindingHash, true -} - -// HasBindingHash returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasBindingHash() bool { - if o != nil && !IsNil(o.BindingHash) { - return true - } - - return false -} - -// SetBindingHash gets a reference to the given string and assigns it to the BindingHash field. -func (o *ApprovalCeremony) SetBindingHash(v string) { - o.BindingHash = &v -} - -// GetReason returns the Reason field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetReason() string { - if o == nil || IsNil(o.Reason) { - var ret string - return ret - } - return *o.Reason -} - -// GetReasonOk returns a tuple with the Reason field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetReasonOk() (*string, bool) { - if o == nil || IsNil(o.Reason) { - return nil, false - } - return o.Reason, true -} - -// HasReason returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasReason() bool { - if o != nil && !IsNil(o.Reason) { - return true - } - - return false -} - -// SetReason gets a reference to the given string and assigns it to the Reason field. -func (o *ApprovalCeremony) SetReason(v string) { - o.Reason = &v -} - -// GetReceiptId returns the ReceiptId field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetReceiptId() string { - if o == nil || IsNil(o.ReceiptId) { - var ret string - return ret - } - return *o.ReceiptId -} - -// GetReceiptIdOk returns a tuple with the ReceiptId field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetReceiptIdOk() (*string, bool) { - if o == nil || IsNil(o.ReceiptId) { - return nil, false - } - return o.ReceiptId, true -} - -// HasReceiptId returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasReceiptId() bool { - if o != nil && !IsNil(o.ReceiptId) { - return true - } - - return false -} - -// SetReceiptId gets a reference to the given string and assigns it to the ReceiptId field. -func (o *ApprovalCeremony) SetReceiptId(v string) { - o.ReceiptId = &v -} - -// GetCeremonyHash returns the CeremonyHash field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetCeremonyHash() string { - if o == nil || IsNil(o.CeremonyHash) { - var ret string - return ret - } - return *o.CeremonyHash -} - -// GetCeremonyHashOk returns a tuple with the CeremonyHash field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetCeremonyHashOk() (*string, bool) { - if o == nil || IsNil(o.CeremonyHash) { - return nil, false - } - return o.CeremonyHash, true -} - -// HasCeremonyHash returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasCeremonyHash() bool { - if o != nil && !IsNil(o.CeremonyHash) { - return true - } - - return false -} - -// SetCeremonyHash gets a reference to the given string and assigns it to the CeremonyHash field. -func (o *ApprovalCeremony) SetCeremonyHash(v string) { - o.CeremonyHash = &v -} - -// GetCreatedAt returns the CreatedAt field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetCreatedAt() time.Time { - if o == nil || IsNil(o.CreatedAt) { - var ret time.Time - return ret - } - return *o.CreatedAt -} - -// GetCreatedAtOk returns a tuple with the CreatedAt field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetCreatedAtOk() (*time.Time, bool) { - if o == nil || IsNil(o.CreatedAt) { - return nil, false - } - return o.CreatedAt, true -} - -// HasCreatedAt returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasCreatedAt() bool { - if o != nil && !IsNil(o.CreatedAt) { - return true - } - - return false -} - -// SetCreatedAt gets a reference to the given time.Time and assigns it to the CreatedAt field. -func (o *ApprovalCeremony) SetCreatedAt(v time.Time) { - o.CreatedAt = &v -} - -// GetUpdatedAt returns the UpdatedAt field value if set, zero value otherwise. -func (o *ApprovalCeremony) GetUpdatedAt() time.Time { - if o == nil || IsNil(o.UpdatedAt) { - var ret time.Time - return ret - } - return *o.UpdatedAt -} - -// GetUpdatedAtOk returns a tuple with the UpdatedAt field value if set, nil otherwise -// and a boolean to check if the value has been set. -func (o *ApprovalCeremony) GetUpdatedAtOk() (*time.Time, bool) { - if o == nil || IsNil(o.UpdatedAt) { - return nil, false - } - return o.UpdatedAt, true -} - -// HasUpdatedAt returns a boolean if a field has been set. -func (o *ApprovalCeremony) HasUpdatedAt() bool { - if o != nil && !IsNil(o.UpdatedAt) { - return true - } - - return false -} - -// SetUpdatedAt gets a reference to the given time.Time and assigns it to the UpdatedAt field. -func (o *ApprovalCeremony) SetUpdatedAt(v time.Time) { - o.UpdatedAt = &v -} - -func (o ApprovalCeremony) MarshalJSON() ([]byte, error) { - toSerialize, err := o.ToMap() - if err != nil { - return []byte{}, err - } - return json.Marshal(toSerialize) -} - -func (o ApprovalCeremony) ToMap() (map[string]interface{}, error) { - toSerialize := map[string]interface{}{} - if !IsNil(o.ApprovalId) { - toSerialize["approval_id"] = o.ApprovalId - } - if !IsNil(o.Subject) { - toSerialize["subject"] = o.Subject - } - if !IsNil(o.Action) { - toSerialize["action"] = o.Action - } - if !IsNil(o.State) { - toSerialize["state"] = o.State - } - if !IsNil(o.RequestedBy) { - toSerialize["requested_by"] = o.RequestedBy - } - if !IsNil(o.Approvers) { - toSerialize["approvers"] = o.Approvers - } - if !IsNil(o.Quorum) { - toSerialize["quorum"] = o.Quorum - } - if !IsNil(o.TimelockUntil) { - toSerialize["timelock_until"] = o.TimelockUntil - } - if !IsNil(o.ExpiresAt) { - toSerialize["expires_at"] = o.ExpiresAt - } - if !IsNil(o.BreakGlass) { - toSerialize["break_glass"] = o.BreakGlass - } - if !IsNil(o.BindingHash) { - toSerialize["binding_hash"] = o.BindingHash - } - if !IsNil(o.Reason) { - toSerialize["reason"] = o.Reason - } - if !IsNil(o.ReceiptId) { - toSerialize["receipt_id"] = o.ReceiptId - } - if !IsNil(o.CeremonyHash) { - toSerialize["ceremony_hash"] = o.CeremonyHash - } - if !IsNil(o.CreatedAt) { - toSerialize["created_at"] = o.CreatedAt - } - if !IsNil(o.UpdatedAt) { - toSerialize["updated_at"] = o.UpdatedAt - } - return toSerialize, nil -} - -type NullableApprovalCeremony struct { - value *ApprovalCeremony - isSet bool -} - -func (v NullableApprovalCeremony) Get() *ApprovalCeremony { - return v.value -} - -func (v *NullableApprovalCeremony) Set(val *ApprovalCeremony) { - v.value = val - v.isSet = true -} - -func (v NullableApprovalCeremony) IsSet() bool { - return v.isSet -} - -func (v *NullableApprovalCeremony) Unset() { - v.value = nil - v.isSet = false -} - -func NewNullableApprovalCeremony(val *ApprovalCeremony) *NullableApprovalCeremony { - return &NullableApprovalCeremony{value: val, isSet: true} -} - -func (v NullableApprovalCeremony) MarshalJSON() ([]byte, error) { - return json.Marshal(v.value) -} - -func (v *NullableApprovalCeremony) UnmarshalJSON(src []byte) error { - v.isSet = true - return json.Unmarshal(src, &v.value) -} - -/* -HELM Kernel API - -Deterministic execution kernel for AI tool calls. Drop-in OpenAI proxy + cryptographic receipts + offline-verifiable evidence packs. - -API version: 0.7.5 -*/ - -// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. - // checks if the ApprovalRequest type satisfies the MappedNullable interface at compile time var _ MappedNullable = &ApprovalRequest{} diff --git a/sdk/go/generated.manifest.json b/sdk/go/generated.manifest.json index 7386e8992..01be732e4 100644 --- a/sdk/go/generated.manifest.json +++ b/sdk/go/generated.manifest.json @@ -2,7 +2,7 @@ "files": [ { "path": "client/types_gen.go", - "sha256": "b558ed2fe99a0789a520ac041225e1e09caad7a7b96db4bea90d2873fbf7de4c" + "sha256": "68d3211fa2fd4514224108eaa9a74e175ff38d6228708fc74b2c1c953ca1c5dc" } ], "generator": "openapitools/openapi-generator-cli:v7.4.0@sha256:579832bed49ea6c275ce2fb5f2d515f5b03d2b6243f3c80fa8430e4f5a770e9a",