Compare commits

..
Author SHA1 Message Date
anandh8x d9ed9c0084 Preserve task objective across repeated compaction
Keep the immutable run objective when plan calls have been elided, while admitting mutable task fields only when plan parity still matches the transcript.

Make plan parsing match update_plan rejection semantics, coalesce and cap task-state trace events, rename parity to its plan-only scope, use explicit nilable dependencies, and remove confirmable objective fingerprints from traces.

Tested: make fmt-check

Tested: go vet ./...

Tested: go test ./...

Tested: go test -race ./internal/agent ./internal/trace

Tested: go run ./cmd/zero-release build

Tested: go run ./cmd/zero-release smoke

Tested: govulncheck ./... (no vulnerabilities found)

Advisory: pinned golangci-lint reports only unrelated pre-existing findings

Tested: git diff HEAD --check
2026-07-19 21:25:37 +05:30
anandh8x b3052dff2f Add transcript-grounded structured task state
Project existing plan, tool-result, changed-file, verification, and completion events into a deterministic per-run snapshot.

Emit content-free aggregate snapshots to traces. Carry bounded objective context through compaction and ground completion checks only when the projection matches the transcript; mismatches retain transcript-derived behavior and drop stale carried state.

Tested: make fmt-check

Tested: go vet ./...

Tested: go test ./...

Tested: go test -race ./internal/agent ./internal/trace

Tested: go run ./cmd/zero-release build

Tested: go run ./cmd/zero-release smoke

Tested: govulncheck ./... (no vulnerabilities found)

Advisory: pinned golangci-lint reports only unrelated pre-existing findings

Tested: git diff HEAD --check
2026-07-19 20:55:19 +05:30
160 changed files with 1833 additions and 9590 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.5.0"}
{".":"0.4.0"}
-47
View File
@@ -6,53 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.5.0](https://github.com/Gitlawb/zero/compare/v0.4.0...v0.5.0) (2026-07-22)
### Features
* **plugins:** add zero plugins info command ([#773](https://github.com/Gitlawb/zero/issues/773)) ([2479884](https://github.com/Gitlawb/zero/commit/2479884dea49580c853a53536c3bbab22ce8a2bf))
* **sandbox:** disable the sandbox via config ([#687](https://github.com/Gitlawb/zero/issues/687)) ([#746](https://github.com/Gitlawb/zero/issues/746)) ([a21a052](https://github.com/Gitlawb/zero/commit/a21a052a4a32ce9cb3c93c94350cd24e138532cd))
* **sandbox:** unify command execution and enforcement ([#781](https://github.com/Gitlawb/zero/issues/781)) ([96859c9](https://github.com/Gitlawb/zero/commit/96859c9bd16f6dad4e332efc7e68178b9116118a))
* **tui:** add /undo as an alias for /rewind ([#698](https://github.com/Gitlawb/zero/issues/698)) ([#747](https://github.com/Gitlawb/zero/issues/747)) ([8c6d302](https://github.com/Gitlawb/zero/commit/8c6d3022bd7801eaad6a0440bb21a27221887297))
* **tui:** add isolated /btw conversations ([#748](https://github.com/Gitlawb/zero/issues/748)) ([2e267bd](https://github.com/Gitlawb/zero/commit/2e267bdbee4a77813e93a7ab51f0c575b66cee8c))
* **tui:** permission prompt takes free-text feedback inline ([#780](https://github.com/Gitlawb/zero/issues/780)) ([3967d49](https://github.com/Gitlawb/zero/commit/3967d49d64998341af8ef6d7523fc63aeb2a5a7a))
### Bug Fixes
* **agent:** stop write_stdin session_id probing thrash ([#702](https://github.com/Gitlawb/zero/issues/702)) ([#749](https://github.com/Gitlawb/zero/issues/749)) ([fbf8598](https://github.com/Gitlawb/zero/commit/fbf85984f679058125951fe2d5e4f200e09a3e2f))
* **cli:** warn when ZERO_PROVIDER overrides a providers-use selection ([#767](https://github.com/Gitlawb/zero/issues/767)) ([3524f79](https://github.com/Gitlawb/zero/commit/3524f795e5fdbb827166a84f03351cedfc9eba30))
* **doctor:** detect missing native binary during runtime checks ([#450](https://github.com/Gitlawb/zero/issues/450)) ([7796022](https://github.com/Gitlawb/zero/commit/77960229b839dca856616f846839aa773f2923f7))
* **lsp:** make the real-gopls check opt-in so a broken gopls can't fail the suite ([#684](https://github.com/Gitlawb/zero/issues/684)) ([#766](https://github.com/Gitlawb/zero/issues/766)) ([b1f4173](https://github.com/Gitlawb/zero/commit/b1f41735a7f6b7928a1875e986a4dca39d106cb9))
* make extension installs transactional ([#762](https://github.com/Gitlawb/zero/issues/762)) ([baa4be1](https://github.com/Gitlawb/zero/commit/baa4be13ac5321da4e9f53e864dd1cd395481200))
* **oauth:** refuse redirects on credential POSTs ([#729](https://github.com/Gitlawb/zero/issues/729)) ([#741](https://github.com/Gitlawb/zero/issues/741)) ([974fc03](https://github.com/Gitlawb/zero/commit/974fc036c2f9a194722f2e8fddbb4fdbf797effe))
* **oauth:** validate discovered endpoints before merge/use ([#511](https://github.com/Gitlawb/zero/issues/511)) ([#739](https://github.com/Gitlawb/zero/issues/739)) ([ce4a996](https://github.com/Gitlawb/zero/commit/ce4a996ffac4482e704f0fd61b3e442398fb2401))
* **perfbench:** absolutize the bench binary and make errored tasks first-class ([#730](https://github.com/Gitlawb/zero/issues/730)) ([dbd9443](https://github.com/Gitlawb/zero/commit/dbd94430143df6754d68551d1028ad8f15b82f1b))
* **perfbench:** grant write tools so mutating tasks measure real edits ([#763](https://github.com/Gitlawb/zero/issues/763)) ([e1975c1](https://github.com/Gitlawb/zero/commit/e1975c1b396b3236ed648870230b4f85863b1d03))
* **perfbench:** keep the stamped answer file out of negative oracle greps ([#737](https://github.com/Gitlawb/zero/issues/737)) ([015452c](https://github.com/Gitlawb/zero/commit/015452c1c98a39eabb021324182094e188a8bd47))
* **providers:** stop "provider not found" for env-derived profiles ([#716](https://github.com/Gitlawb/zero/issues/716)) ([4cbd144](https://github.com/Gitlawb/zero/commit/4cbd144d11e5cb67bc5fea46f5b294562dac7a1a))
* **sandbox:** AST second opinion for interactive-command bypasses ([#473](https://github.com/Gitlawb/zero/issues/473)) ([#745](https://github.com/Gitlawb/zero/issues/745)) ([f079b90](https://github.com/Gitlawb/zero/commit/f079b90f82dac7b7ae0864b279dc9094e31a6627))
* **sandbox:** bind Windows elevated ACL setup to one no-follow handle ([#765](https://github.com/Gitlawb/zero/issues/765)) ([4945684](https://github.com/Gitlawb/zero/commit/4945684fa26aa5994eda59dcabedc817423c535d))
* **sandbox:** don't auto-allow shell when re-entrancy skips wrapping ([#727](https://github.com/Gitlawb/zero/issues/727)) ([#744](https://github.com/Gitlawb/zero/issues/744)) ([6849011](https://github.com/Gitlawb/zero/commit/684901165d1b7f50a8bb4af31b1c1951e6926a79))
* **tools:** give write_stdin's invalid-session errors the same recovery guidance ([#749](https://github.com/Gitlawb/zero/issues/749) follow-up) ([#768](https://github.com/Gitlawb/zero/issues/768)) ([da9fb50](https://github.com/Gitlawb/zero/commit/da9fb50f549f6701eb136fc99f6178d0772d9334))
* **tools:** read_file recovers a backwards line range instead of erroring ([#779](https://github.com/Gitlawb/zero/issues/779)) ([89bdc67](https://github.com/Gitlawb/zero/commit/89bdc6719a1e1b3a3ef0e36b91a7839b3efdfba9))
* **tui:** cache settled alt-screen transcript ([#647](https://github.com/Gitlawb/zero/issues/647)) ([d74ceb1](https://github.com/Gitlawb/zero/commit/d74ceb11271ed68a21c19248210e098f411805fb))
* **tui:** model rows labelled by id when the description is prose; keep the sidebar under the / palette ([#775](https://github.com/Gitlawb/zero/issues/775)) ([b30c397](https://github.com/Gitlawb/zero/commit/b30c3971b40b06ba52ad649ba9dc0ad560d4be4c))
* **tui:** stop the permission card clashing on cool themes ([#778](https://github.com/Gitlawb/zero/issues/778)) ([722bb31](https://github.com/Gitlawb/zero/commit/722bb3121682d9cd9cd4bc6c127e9014d719262a))
### Performance Improvements
* **agent:** concurrent read-only tool batches via capability gate ([#715](https://github.com/Gitlawb/zero/issues/715)) ([31d45d5](https://github.com/Gitlawb/zero/commit/31d45d5f14e915acb9946e8b8eb81632c48f126a))
* **agent:** execution profiles with one-shot posture escalation (PR10b+PR10c) ([#740](https://github.com/Gitlawb/zero/issues/740)) ([378d538](https://github.com/Gitlawb/zero/commit/378d538e240c289e57821e9f9628f76034419d01))
* **agent:** posture-escalation signals and controller (PR10a) ([#736](https://github.com/Gitlawb/zero/issues/736)) ([af875df](https://github.com/Gitlawb/zero/commit/af875df58775484304fc65586bd6c74552ad01a2))
* **agent:** preserve prompt cache prefixes ([#760](https://github.com/Gitlawb/zero/issues/760)) ([739a47e](https://github.com/Gitlawb/zero/commit/739a47e3eac92c3decc8734f52a4d99c7480c3ca))
* **openai:** optimized turn session — background prewarm and prefix telemetry (PR8) ([#723](https://github.com/Gitlawb/zero/issues/723)) ([60dc84e](https://github.com/Gitlawb/zero/commit/60dc84e7a38c5544ebc047f3cfaf4625dd1e83b5))
* **output:** add token-aware semantic output budgeting (PR11) ([#717](https://github.com/Gitlawb/zero/issues/717)) ([e5670c4](https://github.com/Gitlawb/zero/commit/e5670c427ff39c628fb0822fd5c9317ee2174583))
* **providers:** provider capabilities and default turn-session adapter (PR7) ([#720](https://github.com/Gitlawb/zero/issues/720)) ([30e2c3f](https://github.com/Gitlawb/zero/commit/30e2c3f7ffa1d5e487bd10b59d4e823cda191d48))
* **turn-bench:** Phase 0 — strengthen oracles so pass rate can't be misread as correctness ([#712](https://github.com/Gitlawb/zero/issues/712)) ([727ad4d](https://github.com/Gitlawb/zero/commit/727ad4d321fab45d0cf40f8535522e3d94e55c4a))
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
-22
View File
@@ -67,28 +67,6 @@ An approved issue means only that the specific issue has been accepted for work.
It does not approve unrelated future work, future pull requests, or future issues
from the same author. Each issue is reviewed case by case.
## Contribution Volume and Batching
To keep triage and review sustainable while the project stabilizes, please keep
your contribution flow to a size the core team can realistically work through:
- One issue per bug. File each defect as its own issue with its own
reproduction. Please do not bundle several unrelated findings into a single
"audit" issue; separate issues are far easier to triage, approve, and close
individually.
- Keep only a few items open at a time. If you already have several issues or
pull requests awaiting review, let those move through the queue before opening
more. A large simultaneous burst from one contributor slows review for
everyone, even when each item is valid.
- AI-assisted contributions are welcome, held to the same bar. Automated audits
and generated patches are fine, but each issue and pull request must be
individually verified, scoped, and reproducible before you open it. Please do
not post bulk machine-generated findings unfiltered.
These apply to all contributors and are about review bandwidth, not the value of
any single report. Maintainers may ask you to consolidate, split, or hold
submissions to keep the queue manageable.
## Pull Request Requirements
Before opening a community pull request, make sure all of the following are true:
-9
View File
@@ -384,15 +384,6 @@ go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
go install golang.org/x/vuln/cmd/[email protected]
```
The installed binaries land in `$GOBIN` when it is set, otherwise in
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
run them directly. If it isn't, add it:
```bash
gobin="$(go env GOBIN)"; [ -z "$gobin" ] && gobin="$(go env GOPATH)/bin"
case ":$PATH:" in *":$gobin:"*) ;; *) export PATH="$PATH:$gobin" ;; esac
```
### Cross-Compile Examples
```bash
-1
View File
@@ -289,7 +289,6 @@ Install and manage:
```bash
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
zero plugins remove github-pr-review # alias: rm
```
+24 -178
View File
@@ -11,9 +11,6 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -26,10 +23,9 @@ import (
// means the editor only hosts the thread — ZERO owns BYOK and telemetry-free
// operation.
type Deps struct {
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
DiscoverModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
// BuildWorkspace builds the SCOPED tool registry and the sandbox engine for a
// validated workspace root, so ACP shell tools (bash/exec_command) are confined
// exactly like the exec surface — never run unconfined on the host.
@@ -64,16 +60,13 @@ type acpSession struct {
// turnMu serializes prompt turns for one session: concurrent session/prompt
// calls run one at a time so they can't interleave history or clobber the
// single cancel slot.
turnMu sync.Mutex
modelMu sync.Mutex
turnMu sync.Mutex
mu sync.Mutex
mode agent.PermissionMode
model string
models []SessionConfigOptionValue
restrictModels bool
cancel context.CancelFunc
history []turnRecord
mu sync.Mutex
mode agent.PermissionMode
model string // override; "" => config default
cancel context.CancelFunc
history []turnRecord
}
// NewAgent builds the ACP server and registers its method handlers on conn.
@@ -127,7 +120,7 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
// ---- session lifecycle ----
func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (any, error) {
func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any, error) {
var p NewSessionParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/new params")
@@ -136,23 +129,18 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
if err != nil {
return nil, RPCError(codeInvalidParams, err.Error())
}
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
if err != nil {
return nil, RPCError(codeInternalError, "config: "+err.Error())
}
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root, ModelID: model})
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root})
if err != nil {
return nil, RPCError(codeInternalError, "create session: "+err.Error())
}
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
sess := a.registerSession(meta.SessionID, root, nil)
return NewSessionResult{
SessionID: sess.id,
ConfigOptions: a.configOptions(sess),
Modes: a.modeState(sess),
SessionID: sess.id,
Modes: a.modeState(sess),
}, nil
}
func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (any, error) {
func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (any, error) {
var p LoadSessionParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/load params")
@@ -173,17 +161,7 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
// a half-initialized session (registerSession sets history under the lock and
// reuses an already-live session rather than orphaning its in-flight turn).
history, historyErr := a.loadHistory(meta.SessionID)
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
if err != nil {
return nil, RPCError(codeInternalError, "config: "+err.Error())
}
if persistedModel := strings.TrimSpace(meta.ModelID); persistedModel != "" && (!restrictModels || modelChoiceExists(models, persistedModel)) {
model = persistedModel
if !modelChoiceExists(models, persistedModel) {
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
}
}
sess := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
sess := a.registerSession(meta.SessionID, root, history)
a.warnPersistence(
&notifier{conn: a.conn, sessionID: sess.id},
"load session history",
@@ -191,8 +169,7 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
historyErr,
)
return LoadSessionResult{
ConfigOptions: a.configOptions(sess),
Modes: a.modeState(sess),
Modes: a.modeState(sess),
}, nil
}
@@ -377,27 +354,11 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
if sess == nil {
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
}
switch p.ConfigID {
case configIDModel:
model := strings.TrimSpace(p.Value)
if err := a.updateModel(sess, model, sess.restrictModels); err != nil {
return nil, err
}
case configIDMode:
mode := agent.PermissionMode(p.Value)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
default:
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
}
default:
if p.ConfigID != configIDModel {
return nil, RPCError(codeInvalidParams, "unknown config option: "+p.ConfigID)
}
return SetSessionConfigOptionResult{ConfigOptions: a.configOptions(sess)}, nil
sess.setModel(p.Value)
return SetSessionConfigOptionResult{}, nil
}
func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (any, error) {
@@ -409,24 +370,8 @@ func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (a
if sess == nil {
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
}
model := strings.TrimSpace(p.Model)
if err := a.updateModel(sess, model, false); err != nil {
return nil, err
}
return ZeroSetModelResult{Model: model}, nil
}
func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool) error {
sess.modelMu.Lock()
defer sess.modelMu.Unlock()
if restrictModels && !sess.hasModel(model) {
return RPCError(codeInvalidParams, "unknown model: "+model)
}
if _, err := a.deps.Store.UpdateModel(sess.id, model); err != nil {
return RPCError(codeInternalError, "save model selection: "+err.Error())
}
sess.setModel(model)
return nil
sess.setModel(p.Model)
return ZeroSetModelResult{Model: p.Model}, nil
}
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
@@ -453,76 +398,6 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
}
}
// resolveModelChoices advertises authenticated live models when discovery is
// available. The configured model is always retained as the sole fallback.
func (a *Agent) resolveModelChoices(ctx context.Context, cwd string) (string, []SessionConfigOptionValue, bool, error) {
resolved, err := a.deps.ResolveConfig(cwd, config.Overrides{})
if err != nil {
return "", nil, false, err
}
selected := strings.TrimSpace(resolved.Provider.Model)
options := make([]SessionConfigOptionValue, 0, 8)
seen := make(map[string]bool)
add := func(id, description string) {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
return
}
seen[id] = true
options = append(options, SessionConfigOptionValue{Value: id, Name: id, Description: description})
}
add(selected, "")
descriptor, knownProvider := providercatalog.Get(resolved.Provider.CatalogID)
restrictModels := knownProvider && !descriptor.Custom
if a.deps.DiscoverModels != nil {
discovered, discoverErr := a.deps.DiscoverModels(ctx, resolved.Provider)
if ctx.Err() != nil {
return "", nil, false, ctx.Err()
}
if discoverErr == nil && len(discovered) > 0 {
for _, model := range discovered {
if providermodelcatalog.ModelIDAllowedForProvider(resolved.Provider.CatalogID, model.ID) {
add(model.ID, model.Description)
}
}
}
}
return selected, options, restrictModels, nil
}
func modelChoiceExists(models []SessionConfigOptionValue, model string) bool {
for _, option := range models {
if option.Value == model {
return true
}
}
return false
}
func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
model, models, mode := s.configState()
return []SessionConfigOption{{
ID: configIDModel,
Name: "Model",
Description: "Model used for this session.",
Category: configCategoryModel,
Type: configOptionTypeSelect,
CurrentValue: model,
Options: models,
}, {
ID: configIDMode,
Name: "Mode",
Description: "Permission mode used for this session.",
Category: configCategoryMode,
Type: configOptionTypeSelect,
CurrentValue: string(mode),
Options: []SessionConfigOptionValue{
{Value: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
{Value: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
},
}}
}
// ---- persistence + continuity ----
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
@@ -650,13 +525,13 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
// session is returned unchanged rather than orphaning its turn or resetting its
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
// half-initialized session.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *acpSession {
func (a *Agent) registerSession(id, cwd string, history []turnRecord) *acpSession {
a.mu.Lock()
defer a.mu.Unlock()
if existing := a.sessions[id]; existing != nil {
return existing
}
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, history: history}
a.sessions[id] = sess
return sess
}
@@ -695,18 +570,7 @@ func (s *acpSession) currentMode() agent.PermissionMode {
}
func (s *acpSession) setModel(model string) {
model = strings.TrimSpace(model)
s.mu.Lock()
found := false
for _, option := range s.models {
if option.Value == model {
found = true
break
}
}
if !found && model != "" {
s.models = append(s.models, SessionConfigOptionValue{Value: model, Name: model})
}
s.model = model
s.mu.Unlock()
}
@@ -717,24 +581,6 @@ func (s *acpSession) currentModel() string {
return s.model
}
func (s *acpSession) hasModel(model string) bool {
model = strings.TrimSpace(model)
s.mu.Lock()
defer s.mu.Unlock()
for _, option := range s.models {
if option.Value == model {
return true
}
}
return false
}
func (s *acpSession) configState() (string, []SessionConfigOptionValue, agent.PermissionMode) {
s.mu.Lock()
defer s.mu.Unlock()
return s.model, append([]SessionConfigOptionValue(nil), s.models...), s.mode
}
func (s *acpSession) appendHistory(rec turnRecord) {
s.mu.Lock()
s.history = append(s.history, rec)
-212
View File
@@ -3,7 +3,6 @@ package acp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -14,7 +13,6 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -139,12 +137,6 @@ func TestACPEndToEndPrompt(t *testing.T) {
if newRes.Modes == nil || newRes.Modes.CurrentModeID != string(agent.PermissionModeAuto) {
t.Fatalf("expected auto mode, got %+v", newRes.Modes)
}
if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" {
t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions)
}
if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1])
}
// session/prompt
var promptRes PromptResult
@@ -164,201 +156,6 @@ func TestACPEndToEndPrompt(t *testing.T) {
}
}
func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) {
deps := testDeps(t)
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
model := "gpt-5.5"
if o.Provider.Model != "" {
model = o.Provider.Model
}
return config.ResolvedConfig{Provider: config.ProviderProfile{
Name: "ChatGPT", CatalogID: "chatgpt", Model: model,
}}, nil
}
deps.DiscoverModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{
{ID: " gpt-5.4-mini ", Description: "Fast"},
{ID: "gpt-5.5", Description: "duplicate configured model"},
}, nil
}
h := newHarness(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
option := created.ConfigOptions[0]
if option.CurrentValue != "gpt-5.5" || len(option.Options) < 2 {
t.Fatalf("new model option = %+v", option)
}
for _, choice := range option.Options {
if choice.Name != choice.Value {
t.Fatalf("model choice name = %q, want model id %q", choice.Name, choice.Value)
}
}
var selected SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDModel, Value: " gpt-5.4-mini ",
}, &selected); err != nil {
t.Fatalf("set_config_option: %v", err)
}
if got := selected.ConfigOptions[0].CurrentValue; got != "gpt-5.4-mini" {
t.Fatalf("selected model = %q", got)
}
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDModel, Value: "not-advertised",
}, &SetSessionConfigOptionResult{}); err == nil {
t.Fatal("unknown standard model selection was accepted")
}
h.stop()
h = newHarness(t, deps)
defer h.stop()
var loaded LoadSessionResult
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
t.Fatalf("session/load: %v", err)
}
if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" {
t.Fatalf("load model option = %+v", loaded.ConfigOptions)
}
if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1])
}
}
func TestACPModelDiscoveryFailureUsesConfiguredFallbackOnly(t *testing.T) {
deps := testDeps(t)
deps.ResolveConfig = func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Provider: config.ProviderProfile{
Name: "ChatGPT", CatalogID: "chatgpt", Model: " configured-model ",
}}, nil
}
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return nil, fmt.Errorf("discovery unavailable")
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
option := created.ConfigOptions[0]
if option.CurrentValue != "configured-model" || len(option.Options) != 1 || option.Options[0].Value != "configured-model" {
t.Fatalf("fallback model option = %+v", option)
}
}
func TestACPCustomProviderAllowsUnadvertisedModel(t *testing.T) {
deps := testDeps(t)
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
model := "configured-model"
if o.Provider.Model != "" {
model = o.Provider.Model
}
return config.ResolvedConfig{Provider: config.ProviderProfile{
Name: "Custom", CatalogID: "custom-openai-compatible", Model: model,
}}, nil
}
h := newHarness(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
var selected SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDModel, Value: " vendor/model ",
}, &selected); err != nil {
t.Fatalf("set custom model: %v", err)
}
if got := selected.ConfigOptions[0].CurrentValue; got != "vendor/model" {
t.Fatalf("custom model = %q", got)
}
h.stop()
h = newHarness(t, deps)
defer h.stop()
var loaded LoadSessionResult
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
t.Fatalf("session/load: %v", err)
}
option := loaded.ConfigOptions[0]
if option.CurrentValue != "vendor/model" || !modelChoiceExists(option.Options, "vendor/model") {
t.Fatalf("loaded custom model option = %+v", option)
}
}
func TestACPModelDiscoveryFiltersProviderIncompatibleModels(t *testing.T) {
a := &Agent{deps: Deps{
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Provider: config.ProviderProfile{
CatalogID: "opencode-go-anthropic-compatible", Model: "minimax-m3",
}}, nil
},
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{{ID: "qwen3.7-plus"}, {ID: "claude-sonnet-4.5"}}, nil
},
}}
_, options, restricted, err := a.resolveModelChoices(context.Background(), t.TempDir())
if err != nil {
t.Fatal(err)
}
if !restricted || !modelChoiceExists(options, "qwen3.7-plus") || modelChoiceExists(options, "claude-sonnet-4.5") {
t.Fatalf("filtered model options = %+v, restricted=%v", options, restricted)
}
}
func TestACPModelDiscoveryHonorsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
a := &Agent{deps: Deps{
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Provider: config.ProviderProfile{Model: "configured-model"}}, nil
},
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return nil, context.Canceled
},
}}
if _, _, _, err := a.resolveModelChoices(ctx, t.TempDir()); !errors.Is(err, context.Canceled) {
t.Fatalf("resolve error = %v, want context canceled", err)
}
}
func TestACPConfigOptionWireSchema(t *testing.T) {
b, err := json.Marshal(SessionConfigOption{
ID: "model", Name: "Model", Description: "desc", Category: "model",
Type: configOptionTypeSelect, CurrentValue: "m1",
Options: []SessionConfigOptionValue{{Value: "m1", Name: "m1", Description: "choice"}},
})
if err != nil {
t.Fatal(err)
}
var wire map[string]any
if err := json.Unmarshal(b, &wire); err != nil {
t.Fatal(err)
}
for _, key := range []string{"type", "category", "currentValue", "options"} {
if _, ok := wire[key]; !ok {
t.Errorf("wire field %q absent: %s", key, b)
}
}
if _, ok := wire["value"]; ok {
t.Errorf("obsolete option value field present: %s", b)
}
if _, ok := wire["values"]; ok {
t.Errorf("obsolete values field present: %s", b)
}
choice := wire["options"].([]any)[0].(map[string]any)
if choice["value"] != "m1" {
t.Errorf("options[].value = %#v", choice["value"])
}
}
func TestACPUnknownSessionPromptErrors(t *testing.T) {
h := newHarness(t, testDeps(t))
defer h.stop()
@@ -384,15 +181,6 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAsk)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode ask: %v", err)
}
var configured SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAuto),
}, &configured); err != nil {
t.Fatalf("set_config_option mode: %v", err)
}
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAuto) {
t.Fatalf("configured mode = %q", got)
}
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected Unsafe mode to be rejected over ACP")
+11 -19
View File
@@ -337,22 +337,19 @@ type SetSessionModeParams struct {
type SetSessionModeResult struct{}
// ---- session config options ----
// ---- session config options (model selection) ----
type SessionConfigOptionValue struct {
Value string `json:"value"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
}
type SessionConfigOption struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
Type string `json:"type"`
CurrentValue string `json:"currentValue"`
Options []SessionConfigOptionValue `json:"options"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Value string `json:"value"`
Values []SessionConfigOptionValue `json:"values,omitempty"`
}
type SetSessionConfigOptionParams struct {
@@ -376,11 +373,6 @@ type ZeroSetModelResult struct {
Model string `json:"model"`
}
const (
configIDModel = "model"
configIDMode = "mode"
configOptionTypeSelect = "select"
configCategoryModel = "model"
configCategoryMode = "mode"
)
// configIDModel is the SessionConfigOption id ZERO uses to expose model choice
// through the standard session/set_config_option method.
const configIDModel = "model"
-31
View File
@@ -169,37 +169,6 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
}
}
// The whole point of #702's fix: the unknown-session error's normalized
// signature must not vary with the session id, so a model probing ids 1, 2,
// 3, … produces one repeated signature the failure guard can count. If the id
// leaked into the first 80 normalized chars, each probe would reset the streak
// and the halt would never fire.
func TestUnknownExecSessionErrorSignatureIsIDInvariant(t *testing.T) {
a := errorSignature(tools.UnknownExecSessionError(1))
b := errorSignature(tools.UnknownExecSessionError(999999))
if a != b {
t.Fatalf("unknown-session signature varies with id:\n %q\n %q", a, b)
}
}
// End to end: with an id-invariant signature, probing a different unknown id
// each turn now trips the repeated-failure halt at toolFailureStopAt, where
// before the fix it never would.
func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
var state guardState
var stoppedAt int
for i := 1; i <= toolFailureStopAt; i++ {
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i))
if out.Stop {
stoppedAt = i
break
}
}
if stoppedAt != toolFailureStopAt {
t.Fatalf("probing distinct unknown ids stopped at %d, want %d", stoppedAt, toolFailureStopAt)
}
}
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
var state guardState
toolOnly := zeroruntime.CollectedStream{
+40 -82
View File
@@ -11,7 +11,6 @@ import (
"strings"
"sync"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -1171,17 +1170,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
decisionCommandPrefix = grant.Prefix
}
}
// An explicit additional-permissions request is an elevation only when it
// expands the current effective policy. If the same capability was already
// approved for this session, reuse that grant across equivalent commands
// instead of prompting again merely because their command prefixes differ.
if toolFound && !permissionGranted && isShellCommandTool(call.Name) && options.Sandbox != nil {
if profile, requested, profileErr := inlineAdditionalPermissionsProfile(args, options.Cwd); profileErr == nil && requested && options.Sandbox.CoversRequestPermissions(profile) {
permissionGranted = true
decisionAction = PermissionDecisionAllowForSession
decisionReason = "requested sandbox capabilities already granted for this session"
}
}
var preflightDecision *sandbox.Decision
if toolFound && options.Sandbox != nil {
@@ -1428,18 +1416,17 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// the agent loop and the MCP server pass through), so result.Output is
// already redacted here and result.Redacted reflects whether it changed.
return ToolResult{
Risk: executedRisk,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: result.Display,
LoadedTools: loadedToolsFromResult(result.Meta),
Risk: executedRisk,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
Display: result.Display,
LoadedTools: loadedToolsFromResult(result.Meta),
// A tool may signal a mid-run model escalation by carrying the target id
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
// the Run turn loop performs the actual provider switch. Empty for every
@@ -1559,15 +1546,8 @@ func sandboxDeniedNetworkRetryCandidate(call ToolCall, args map[string]any, resu
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
return false
}
if result.ExecutionOutcome != nil {
denial := result.ExecutionOutcome.Denial
if denial == nil || denial.Capability.Kind != execution.CapabilityExternalNetwork {
return false
}
} else {
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
return false
}
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
return false
}
if value, _ := args["sandbox_permissions"].(string); strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated) {
return false
@@ -1620,11 +1600,6 @@ func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, re
}
func sandboxDeniedShellResult(result tools.Result) bool {
if result.ExecutionOutcome != nil {
// Typed denials must be handled by their exact capability path. Never
// turn a structured narrow denial into the legacy unrestricted retry.
return false
}
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
}
@@ -1753,18 +1728,17 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
}
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: output,
Truncated: result.Truncated,
Meta: meta,
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: display,
LoadedTools: loadedToolsFromResult(meta),
RequestedModel: meta["escalate_to_model"],
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: output,
Truncated: result.Truncated,
Meta: meta,
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
ChangedFiles: result.ChangedFiles,
Display: display,
LoadedTools: loadedToolsFromResult(meta),
RequestedModel: meta["escalate_to_model"],
}
}
@@ -2024,16 +1998,15 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
Cwd: options.Cwd,
})
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: result.Display,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
Display: result.Display,
}
}
return ToolResult{
@@ -2597,8 +2570,7 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
if profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd); ok && err == nil {
scopeText = requestPermissionsScope(profile)
}
decisionReason := strings.TrimSpace(reason)
reason = userFacingPermissionReason(call.Name, args, reason, safety.Reason)
reason = userFacingPermissionReason(call.Name, args, reason)
return PermissionEvent{
ToolCallID: call.ID,
@@ -2611,7 +2583,6 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
Autonomy: options.Autonomy,
SideEffect: string(safety.SideEffect),
Reason: reason,
DecisionReason: decisionReason,
Scope: scopeText,
Risk: risk,
Block: block,
@@ -2621,31 +2592,18 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
}, true
}
func userFacingPermissionReason(toolName string, args map[string]any, reason string, fallback string) string {
func userFacingPermissionReason(toolName string, args map[string]any, reason string) string {
reason = strings.TrimSpace(reason)
if !isShellCommandTool(toolName) {
if !isShellCommandTool(toolName) || !shellCommandRequiresEscalated(args) {
return reason
}
// Policy and sandbox explanations are authoritative. The tool's static
// safety copy is only an internal fallback; presenting it as the reason for
// every ordinary shell approval makes prompts generic and obscures the cases
// where policy has an actionable explanation.
fallback = strings.TrimSpace(fallback)
if reason != "" && reason != fallback && reason != "tool requires approval before execution" && reason != sandbox.ReasonEscalatedSandboxRequired {
return reason
if justification, ok := firstStringArg(args, "justification"); ok {
return justification
}
if shellCommandRequiresEscalated(args) {
if justification, ok := firstStringArg(args, "justification"); ok {
return justification
}
if reason == "" || reason == sandbox.ReasonEscalatedSandboxRequired {
return "This command needs to run outside the sandbox."
}
// Ordinary shell approvals need no invented explanation. The raw fallback is
// retained separately as DecisionReason for traces and policy auditing.
return ""
return reason
}
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
-54
View File
@@ -13,7 +13,6 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/specmode"
@@ -27,59 +26,6 @@ type mockProvider struct {
requests []zeroruntime.CompletionRequest
}
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
call := ToolCall{Name: tools.ExecCommandToolName}
options := Options{Sandbox: engine}
legacyMeta := map[string]string{
tools.SandboxLikelyDeniedMeta: "true",
tools.SandboxDenialKindMeta: tools.SandboxDenialKindNetwork,
}
applicationFailure := tools.Result{
Status: tools.StatusError,
Meta: legacyMeta,
ExecutionOutcome: &execution.Outcome{
State: execution.StateFailed,
Kind: execution.OutcomeApplicationFailure,
Exit: &execution.Exit{Code: 1},
},
}
if sandboxDeniedNetworkRetryCandidate(call, nil, applicationFailure, options) || sandboxRestrictedShellRetryCandidate(call, nil, applicationFailure, options) {
t.Fatal("typed application failure must not be retried from legacy sandbox-like text")
}
protectedDenial := applicationFailure
protectedDenial.ExecutionOutcome = &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
Source: execution.DenialSourceConfiguredPolicy,
Reason: "protected metadata",
NextAction: execution.DenialNextActionRequestApproval,
},
}
if sandboxRestrictedShellRetryCandidate(call, nil, protectedDenial, options) {
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
}
networkDenial := protectedDenial
networkDenial.ExecutionOutcome = &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityExternalNetwork},
Source: execution.DenialSourcePlatformSandbox,
Reason: "external network denied",
NextAction: execution.DenialNextActionRequestApproval,
},
}
if !sandboxDeniedNetworkRetryCandidate(call, nil, networkDenial, options) {
t.Fatal("typed external-network denial should use the narrow network approval path")
}
}
func (provider *mockProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.requests = append(provider.requests, request)
@@ -68,61 +68,3 @@ func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
}
}
func TestBuildPermissionEventReusesCoveredAdditionalPermissions(t *testing.T) {
call := ToolCall{ID: "call_3", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "npm install",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": true},
},
}
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
if shouldRequestPermission(promptShellTool{}, args, true, decision) {
t.Fatal("an already-covered capability must not trigger another prompt")
}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, true, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("covered capability should still produce an auditable allow event")
}
if event.Action != PermissionActionAllow {
t.Fatalf("Action = %q, want allow for an already-covered capability", event.Action)
}
}
func TestBuildPermissionEventOmitsGenericShellReasonFromPrompt(t *testing.T) {
call := ToolCall{ID: "call_4", Name: "bash", Arguments: `{}`}
args := map[string]any{"command": "make test"}
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: "runs a shell command"}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("generic shell approval must produce an event")
}
if event.Reason != "" {
t.Fatalf("user-facing reason = %q, want omitted generic tool text", event.Reason)
}
if event.DecisionReason != "runs a shell command" {
t.Fatalf("decision reason = %q, want raw audit reason retained", event.DecisionReason)
}
}
func TestBuildPermissionEventPrefersPolicyReasonOverModelJustification(t *testing.T) {
call := ToolCall{ID: "call_5", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "curl https://example.test",
"sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated),
"justification": "Run without sandboxing.",
}
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: sandbox.ReasonNetworkBlocked}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("policy approval must produce an event")
}
if event.Reason != sandbox.ReasonNetworkBlocked {
t.Fatalf("user-facing reason = %q, want policy reason %q", event.Reason, sandbox.ReasonNetworkBlocked)
}
}
+5 -5
View File
@@ -14,13 +14,13 @@ import (
// deliberately) or trimmed — the per-turn floor should not creep up silently.
//
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
// (auto permission-mode) interactive turn sends ~3230 tokens. The tool figure is
// the auto-mode advertised set, including exec_command: auto mode must expose both
// process creation and write_stdin process interaction or the latter is unusable
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
// the auto-mode advertised set, not the full core registry — higher-risk tools that
// only advertise in other modes are excluded, matching what a real turn actually
// pays.
const (
maxBaseSystemPromptTokens = 3500
maxEagerToolSchemaTokens = 3550
maxEagerToolSchemaTokens = 2700
)
func TestSystemPromptTokenBudget(t *testing.T) {
-3
View File
@@ -100,9 +100,6 @@ work.
- Use exec_command with `tty: true` for interactive terminal-style commands that
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
you need to clean up a running foreground command yourself, use write_stdin.
- write_stdin's session_id is only ever an id returned by a still-running
exec_command; never guess or probe ids. If you have no such session, start one
with exec_command, or use write_file/edit_file/apply_patch for file changes.
- write_stdin with empty input polls an existing exec_command session, and
`\u0003` interrupts it. Sending other stdin bytes may require approval because
it can drive the running process beyond the original command. Non-tty sessions
+1 -5
View File
@@ -3,7 +3,6 @@ package agent
import (
"context"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
@@ -72,10 +71,7 @@ type ToolResult struct {
Meta map[string]string
Redacted bool
ChangedFiles []string
// ChangeSummaries are non-selectable generated-tree summaries emitted by
// command execution; callers must not schedule per-file work from them.
ChangeSummaries []execution.Change
Display tools.Display
Display tools.Display
// DenialReason categorizes why a tool call was blocked (empty when it ran).
// It lets a surface distinguish the cause precisely instead of parsing Output.
DenialReason DenialCategory
+74 -4
View File
@@ -3,10 +3,11 @@
package background
import (
"errors"
"fmt"
"os/exec"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
// terminationGracePeriod is how long a process has to exit after SIGTERM before
@@ -21,7 +22,10 @@ var (
// the negative PID (the group), so any process the child forks dies with it
// instead of being orphaned. Must be called before cmd.Start.
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
execution.ConfigureProcessGroup(cmd)
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
// terminateProcess stops a background process. It first asks politely with
@@ -37,5 +41,71 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
// back to signalling only the individual PID, which also avoids reporting a false
// success when a non-leader group-signal returns ESRCH.
func terminateProcess(pid int) error {
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
// Guard pid <= 1: kill(-0) would target our OWN process group and kill(-1)
// every process we can signal. A real child PID is always > 1, so never let a
// bogus 0/1 expand into either.
if pid <= 1 {
return fmt.Errorf("refusing to terminate invalid pid %d", pid)
}
// Signal the whole group only when pid leads its own group; otherwise the
// individual process. See the doc comment for why a non-leader is signalled
// directly rather than via its (foreign) group.
target := pid
if pgid, err := syscall.Getpgid(pid); err == nil {
if pgid == pid {
target = -pid
}
} else if processGoneError(err) {
return nil // already gone
}
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
if processGoneError(err) {
return nil
}
return err
}
// Poll liveness so we return promptly once it exits, rather than always
// waiting out the full grace period.
deadline := time.Now().Add(terminationGracePeriod)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(terminationPollInterval)
}
if !alive() {
return nil
}
// Still alive after the grace period: force-kill.
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !processGoneError(err) {
return err
}
// SIGKILL is asynchronous: the kernel may not have reaped it yet. Poll again
// so this helper only reports success once the target is actually gone —
// otherwise the caller gets nil while descendants are still racing.
deadline = time.Now().Add(terminationGracePeriod)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(terminationPollInterval)
}
if alive() {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
}
return nil
}
// processGoneError reports whether an error means the process group has already
// exited (so termination is effectively done). syscall.Kill reports ESRCH when
// no process in the target group remains.
func processGoneError(err error) bool {
return errors.Is(err, syscall.ESRCH)
}
+26 -6
View File
@@ -3,16 +3,36 @@
package background
import (
"os"
"os/exec"
"github.com/Gitlawb/zero/internal/execution"
"path/filepath"
"strconv"
)
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
// delegated to execution.TerminateProcessTree, so no launch-time process-group
// ConfigureChildProcessGroup is a no-op on Windows: terminateProcess uses
// `taskkill /T` to kill the whole process tree, so no launch-time process-group
// setup is required (the POSIX build sets Setpgid here instead).
func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup(cmd) }
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
func terminateProcess(pid int) error {
return execution.TerminateProcessTree(pid, 0, 0)
taskkill := taskkillPath()
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
process, err := os.FindProcess(pid)
if err != nil {
return err
}
return process.Kill()
}
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
}
-5
View File
@@ -1,7 +1,6 @@
package cli
import (
"context"
"fmt"
"io"
"os"
@@ -10,7 +9,6 @@ import (
"github.com/Gitlawb/zero/internal/acp"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -45,9 +43,6 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
conn := acp.NewConn(deps.stdin, stdout)
acp.NewAgent(conn, acp.Deps{
ResolveConfig: deps.resolveConfig,
DiscoverModels: func(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return defaultDiscoverProviderModels(ctx, discoveryCredentialProfile(profile))
},
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
// so ACP is authenticated for apiKeyStored profiles like every other
// surface — no ACP-specific credential handling needed.
+16 -34
View File
@@ -17,7 +17,6 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/localcontrol"
"github.com/Gitlawb/zero/internal/mcp"
@@ -59,11 +58,7 @@ type appDeps struct {
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
// by fillAppDeps, so tests never mutate the process environment unless they
// inject it). nil ⇒ no export.
exportActiveProvider func(providerName string)
// getenv reads a process environment variable (production: os.Getenv, set in
// defaultAppDeps — deliberately NOT filled by fillAppDeps, so tests are hermetic
// against ambient vars like ZERO_PROVIDER unless they inject it). nil ⇒ empty.
getenv func(string) string
exportActiveProvider func(providerName string)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
@@ -123,7 +118,6 @@ func defaultAppDeps() appDeps {
stdin: os.Stdin,
userConfigPath: config.DefaultUserConfigPath,
exportActiveProvider: config.SetActiveProviderEnv,
getenv: os.Getenv,
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
@@ -374,8 +368,6 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
case "exec":
// Forward leading --add-dir occurrences so exec's own parser collects them.
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
case "completions":
return runCompletions(args[1:], stdout, stderr)
case "daemon":
return runDaemon(args[1:], stdout, stderr, deps)
case "config":
@@ -686,26 +678,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
registry := newCoreRegistryScoped(workspaceRoot, scope)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
executionRunner := execution.NewRunner(nil)
sandboxStore, err := deps.newSandboxStore()
if err != nil {
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
}
if notice, err := sandboxStore.ConsumeMigrationNotice(); err != nil {
return writeAppError(stderr, "failed to migrate sandbox grants: "+err.Error(), 1)
} else if notice != "" {
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
}
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
executionRunner.SetPreparer(sandboxEngine)
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
if err != nil {
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
@@ -740,8 +712,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
PermissionStore: mcpPermissionStore,
Autonomy: mcp.AutonomyLow,
Execution: executionRunner,
WorkspaceRoot: workspaceRoot,
})
}
if err != nil {
@@ -768,7 +738,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// The interactive TUI is not worktree-reassigned, so the trust root is the
// launch directory itself.
trustRoot := workspaceRoot
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
@@ -790,6 +760,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// unchanged. The interactive surface applies no operator tool filters, so
// enabled/disabled are nil — matching the AgentOptions below.
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
sandboxStore, err := deps.newSandboxStore()
if err != nil {
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
}
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
lastKnownMCPConfig := mcpConfig
fileTracker := tools.NewFileTracker()
var scratchBaseline scratchFileBaseline
@@ -806,7 +789,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
// report can be combined with the plugin activation's, and emit at most one
// notice when project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
@@ -1174,7 +1157,6 @@ Usage:
Commands:
exec Run a one-shot prompt through the Go agent runtime
completions Generate shell completion scripts
daemon Manage the local background worker daemon (start/stop/status/run/attach)
setup Guide first-run provider setup
config Inspect resolved Go configuration without leaking secrets
-404
View File
@@ -1,404 +0,0 @@
package cli
import (
"fmt"
"io"
"sort"
"strings"
)
type completionNode struct {
names []string
flags []string
children []completionNode
}
type completionContext struct {
path string
candidates []string
transitions map[string]string
}
var completionRoot = completionNode{
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--theme", "--skip-permissions-unsafe"},
children: []completionNode{
{names: []string{"exec"}, flags: []string{
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
"--use-spec", "--spec-model", "--spec-reasoning-effort", "--max-turns", "--exec-profile",
"--auto", "--enabled-tools", "--disabled-tools", "--list-tools", "--profile", "-r",
"--reasoning-effort", "-C", "--cwd", "-w", "--worktree", "--worktree-dir", "-i",
"--input-format", "-o", "--output-format", "--prompt", "--resume", "--fork",
"--calling-session-id", "--calling-tool-use-id", "--tag", "--depth", "--session-title",
"--init-session-id", "--skip-permissions-unsafe", "--allow-escalation", "--self-correct",
"--notify", "--no-notify", "--no-completion-gate",
}},
{
names: []string{"completions"},
flags: []string{"-h", "--help"},
children: leafNodes("bash", "zsh", "fish", "powershell", "elvish"),
},
{names: []string{"daemon"}, children: leafNodes("start", "stop", "status", "run", "attach", "serve-remote", "link")},
{names: []string{"setup"}},
{names: []string{"config"}},
{names: []string{"models"}, children: []completionNode{{names: []string{"list", "ls"}}}},
{names: []string{"providers"}, children: []completionNode{
{names: []string{"current"}}, {names: []string{"list"}}, {names: []string{"catalog"}},
{names: []string{"add"}}, {names: []string{"check"}}, {names: []string{"use"}},
{names: []string{"remove", "rm"}}, {names: []string{"rename"}}, {names: []string{"setup"}},
{names: []string{"detect"}}, {names: []string{"models"}},
}},
{names: []string{"doctor"}},
{names: []string{"context"}},
{names: []string{"repo-map", "repomap"}},
{names: []string{"search", "find"}},
{names: []string{"sessions", "session"}, children: leafNodes("list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan")},
{names: []string{"spec"}, children: leafNodes("show", "approve", "reject")},
{names: []string{"init"}},
{names: []string{"specialists", "specialist"}, children: []completionNode{
{names: []string{"list"}}, {names: []string{"show"}}, {names: []string{"create"}},
{names: []string{"delete", "rm"}}, {names: []string{"edit"}}, {names: []string{"path"}},
}},
{names: []string{"plugins", "plugin"}, children: []completionNode{
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"remove", "rm"}},
}},
{names: []string{"backends", "backend"}, children: leafNodes("doctor")},
{names: []string{"skills", "skill"}, children: []completionNode{
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"info"}}, {names: []string{"remove", "rm"}},
}},
{names: []string{"tools", "tool"}, children: leafNodes("make", "list")},
{names: []string{"hooks"}, children: []completionNode{
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"remove", "rm"}},
{names: []string{"enable"}}, {names: []string{"disable"}},
}},
{names: []string{"mcp"}, children: []completionNode{
{names: []string{"add"}}, {names: []string{"remove", "rm"}}, {names: []string{"enable"}},
{names: []string{"disable"}}, {names: []string{"check"}}, {names: []string{"list"}},
{names: []string{"permissions"}, children: leafNodes("list", "revoke", "clear")},
{names: []string{"tools"}, children: leafNodes("list")},
{names: []string{"oauth"}, children: leafNodes("login", "logout", "status")},
}},
{names: []string{"auth"}, children: leafNodes("openrouter", "chatgpt", "login", "logout", "status", "refresh")},
{names: []string{"sandbox"}, children: []completionNode{
{names: []string{"policy"}}, {names: []string{"setup"}}, {names: []string{"check"}},
{names: []string{"grants"}, children: leafNodes("list", "allow", "deny", "revoke", "clear")},
}},
{names: []string{"update"}},
{names: []string{"upgrade"}},
{names: []string{"worktrees", "worktree"}, children: leafNodes("prepare")},
{names: []string{"verify"}},
{names: []string{"trust"}, children: leafNodes("list", "remove")},
{names: []string{"eval"}, children: leafNodes("validate", "run", "bench")},
{names: []string{"changes", "change"}, children: []completionNode{
{names: []string{"inspect", "status"}}, {names: []string{"commit"}}, {names: []string{"push"}},
{names: []string{"pr", "pull-request"}},
}},
{names: []string{"usage"}, children: leafNodes("report")},
{names: []string{"cron"}, children: leafNodes("add", "list", "rm", "pause", "resume", "run")},
{names: []string{"repo-info", "repoinfo"}},
{names: []string{"serve"}},
{names: []string{"acp"}},
{names: []string{"help"}},
{names: []string{"version"}},
},
}
func leafNodes(names ...string) []completionNode {
nodes := make([]completionNode, 0, len(names))
for _, name := range names {
nodes = append(nodes, completionNode{names: []string{name}})
}
return nodes
}
func runCompletions(args []string, stdout io.Writer, stderr io.Writer) int {
if len(args) == 1 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help") {
if err := writeCompletionsHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if len(args) == 0 {
return writeExecUsageError(stderr, "shell required. Use `zero completions <bash|zsh|fish|powershell|elvish>`.")
}
if len(args) != 1 {
return writeExecUsageError(stderr, fmt.Sprintf("unexpected completions argument %q", args[1]))
}
contexts := completionContexts(completionRoot)
var err error
switch strings.ToLower(strings.TrimSpace(args[0])) {
case "bash":
err = writeBashCompletions(stdout, contexts)
case "zsh":
err = writeZshCompletions(stdout, contexts)
case "fish":
err = writeFishCompletions(stdout, contexts)
case "powershell":
err = writePowerShellCompletions(stdout, contexts)
case "elvish":
err = writeElvishCompletions(stdout, contexts)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unsupported shell %q; expected bash, zsh, fish, powershell, or elvish", args[0]))
}
if err != nil {
return exitCrash
}
return exitSuccess
}
func writeCompletionsHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Generate shell completion scripts.
Usage:
zero completions <shell>
Arguments:
<shell> Target shell: bash, zsh, fish, powershell, or elvish
Examples:
source <(zero completions bash)
source <(zero completions zsh)
zero completions fish > ~/.config/fish/completions/zero.fish
zero completions powershell >> $PROFILE
eval (zero completions elvish | slurp)
Flags:
-h, --help Show this help
`)
return err
}
func completionContexts(root completionNode) []completionContext {
contexts := []completionContext{}
var visit func(completionNode, []string)
visit = func(node completionNode, paths []string) {
candidates := append([]string{}, node.flags...)
transitions := map[string]string{}
for _, child := range node.children {
candidates = append(candidates, child.names...)
for _, path := range paths {
for _, name := range child.names {
childPath := strings.TrimSpace(path + " " + name)
transitions[path+"|"+name] = childPath
}
}
}
candidates = uniqueStrings(candidates)
for _, path := range paths {
contexts = append(contexts, completionContext{path: path, candidates: candidates, transitions: transitions})
}
for _, child := range node.children {
childPaths := make([]string, 0, len(paths)*len(child.names))
for _, path := range paths {
for _, name := range child.names {
childPaths = append(childPaths, strings.TrimSpace(path+" "+name))
}
}
visit(child, childPaths)
}
}
visit(root, []string{""})
sort.SliceStable(contexts, func(i, j int) bool { return contexts[i].path < contexts[j].path })
return contexts
}
func uniqueStrings(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func allTransitions(contexts []completionContext) map[string]string {
transitions := map[string]string{}
for _, context := range contexts {
for key, value := range context.transitions {
transitions[key] = value
}
}
return transitions
}
func sortedTransitionKeys(transitions map[string]string) []string {
keys := make([]string, 0, len(transitions))
for key := range transitions {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func shellWords(values []string) string {
return strings.Join(values, " ")
}
func writeBashCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "# bash completion for zero\n_zero() {\n local cur context token i candidates\n cur=\"${COMP_WORDS[COMP_CWORD]}\"\n context=\"\""); err != nil {
return err
}
if err := writePOSIXContextLoop(w, contexts, " for ((i = 1; i < COMP_CWORD; i++)); do", " token=\"${COMP_WORDS[i]}\"", " done"); err != nil {
return err
}
if err := writePOSIXCandidateCase(w, contexts, " "); err != nil {
return err
}
_, err := fmt.Fprintln(w, " COMPREPLY=( $(compgen -W \"$candidates\" -- \"$cur\") )\n}\ncomplete -F _zero zero")
return err
}
func writeZshCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "#compdef zero\n_zero() {\n local context token i\n local -a candidates\n context=\"\""); err != nil {
return err
}
if err := writePOSIXContextLoop(w, contexts, " for ((i = 2; i < CURRENT; i++)); do", " token=\"${words[i]}\"", " done"); err != nil {
return err
}
if _, err := fmt.Fprintln(w, " case \"$context\" in"); err != nil {
return err
}
for _, context := range contexts {
if _, err := fmt.Fprintf(w, " %q) candidates=(%s) ;;\n", context.path, quotedWords(context.candidates)); err != nil {
return err
}
}
_, err := fmt.Fprintln(w, " esac\n compadd -- \"${candidates[@]}\"\n}\ncompdef _zero zero")
return err
}
func writePOSIXContextLoop(w io.Writer, contexts []completionContext, start string, token string, end string) error {
if _, err := fmt.Fprintln(w, start); err != nil {
return err
}
if _, err := fmt.Fprintln(w, token); err != nil {
return err
}
if _, err := fmt.Fprintln(w, " case \"$context|$token\" in"); err != nil {
return err
}
transitions := allTransitions(contexts)
for _, key := range sortedTransitionKeys(transitions) {
if _, err := fmt.Fprintf(w, " %q) context=%q ;;\n", key, transitions[key]); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, " esac"); err != nil {
return err
}
_, err := fmt.Fprintln(w, end)
return err
}
func writePOSIXCandidateCase(w io.Writer, contexts []completionContext, indent string) error {
if _, err := fmt.Fprintln(w, indent+"case \"$context\" in"); err != nil {
return err
}
for _, context := range contexts {
if _, err := fmt.Fprintf(w, "%s %q) candidates=%q ;;\n", indent, context.path, shellWords(context.candidates)); err != nil {
return err
}
}
_, err := fmt.Fprintln(w, indent+"esac")
return err
}
func quotedWords(values []string) string {
quoted := make([]string, 0, len(values))
for _, value := range values {
quoted = append(quoted, fmt.Sprintf("%q", value))
}
return strings.Join(quoted, " ")
}
func writeFishCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "# fish completion for zero\nfunction __zero_completion_context\n set -l context ''\n set -l tokens (commandline -opc)"); err != nil {
return err
}
if _, err := fmt.Fprintln(w, " for token in $tokens[2..-1]\n switch \"$context|$token\""); err != nil {
return err
}
transitions := allTransitions(contexts)
for _, key := range sortedTransitionKeys(transitions) {
if _, err := fmt.Fprintf(w, " case %q\n set context %q\n", key, transitions[key]); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, " end\n end\n if test -z \"$context\"\n echo __root__\n else\n echo \"$context\"\n end\nend"); err != nil {
return err
}
for _, context := range contexts {
path := context.path
if path == "" {
path = "__root__"
}
if _, err := fmt.Fprintf(w, "complete -c zero -f -n 'test (__zero_completion_context) = %q' -a %q\n", path, shellWords(context.candidates)); err != nil {
return err
}
}
return nil
}
func writePowerShellCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "# PowerShell completion for zero\nRegister-ArgumentCompleter -Native -CommandName zero -ScriptBlock {\n param($wordToComplete, $commandAst, $cursorPosition)\n $context = ''\n $elements = @($commandAst.CommandElements)\n $limit = $elements.Count\n if ($wordToComplete -ne '' -and $limit -gt 1) { $limit-- }\n for ($i = 1; $i -lt $limit; $i++) {\n $token = $elements[$i].Extent.Text\n switch (\"$context|$token\") {"); err != nil {
return err
}
transitions := allTransitions(contexts)
for _, key := range sortedTransitionKeys(transitions) {
if _, err := fmt.Fprintf(w, " '%s' { $context = '%s'; break }\n", key, transitions[key]); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, " }\n }\n $candidates = switch ($context) {"); err != nil {
return err
}
for _, context := range contexts {
if _, err := fmt.Fprintf(w, " '%s' { @(%s) }\n", context.path, powershellWords(context.candidates)); err != nil {
return err
}
}
_, err := fmt.Fprintln(w, " }\n $candidates | Where-Object { $_ -like \"$wordToComplete*\" } | ForEach-Object {\n [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)\n }\n}")
return err
}
func powershellWords(values []string) string {
quoted := make([]string, 0, len(values))
for _, value := range values {
quoted = append(quoted, "'"+strings.ReplaceAll(value, "'", "''")+"'")
}
return strings.Join(quoted, ", ")
}
func writeElvishCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "# Elvish completion for zero\nset edit:completion:arg-completer[zero] = {|@args|\n var context = ''\n for token $args[1..-1] {"); err != nil {
return err
}
transitions := allTransitions(contexts)
for index, key := range sortedTransitionKeys(transitions) {
keyword := "if"
if index > 0 {
keyword = "} elif"
}
if _, err := fmt.Fprintf(w, " %s (eq $context'|'$token %q) {\n set context = %q\n", keyword, key, transitions[key]); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, " }\n }"); err != nil {
return err
}
for index, context := range contexts {
keyword := "if"
if index > 0 {
keyword = "} elif"
}
if _, err := fmt.Fprintf(w, " %s (eq $context %q) {\n put %s\n", keyword, context.path, shellWords(context.candidates)); err != nil {
return err
}
}
_, err := fmt.Fprintln(w, " }\n}")
return err
}
-175
View File
@@ -1,175 +0,0 @@
package cli
import (
"bytes"
"os/exec"
"strings"
"testing"
)
func TestCompletionsHelpAndRootHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := runWithDeps([]string{"completions", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("completion help exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
}
for _, want := range []string{
"zero completions <shell>",
"bash, zsh, fish, powershell, or elvish",
"source <(zero completions bash)",
"~/.config/fish/completions/zero.fish",
"eval (zero completions elvish | slurp)",
} {
if !strings.Contains(stdout.String(), want) {
t.Errorf("completion help missing %q:\n%s", want, stdout.String())
}
}
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("root help exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), "completions Generate shell completion scripts") {
t.Fatalf("root help does not list completions command:\n%s", stdout.String())
}
}
func TestCompletionsRejectsMissingUnknownAndExtraShellArguments(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "missing", args: []string{"completions"}, want: "shell required"},
{name: "unknown", args: []string{"completions", "nu"}, want: "unsupported shell"},
{name: "extra", args: []string{"completions", "bash", "extra"}, want: "unexpected completions argument"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := runWithDeps(test.args, &stdout, &stderr, appDeps{}); code != exitUsage {
t.Fatalf("exit code = %d, want %d: %s", code, exitUsage, stderr.String())
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("stderr = %q, want substring %q", stderr.String(), test.want)
}
})
}
}
func TestCompletionsGeneratesEverySupportedShell(t *testing.T) {
tests := []struct {
shell string
marker string
syntaxShell string
}{
{shell: "bash", marker: "complete -F _zero zero", syntaxShell: "bash"},
{shell: "zsh", marker: "#compdef zero", syntaxShell: "zsh"},
{shell: "fish", marker: "complete -c zero"},
{shell: "powershell", marker: "Register-ArgumentCompleter -Native -CommandName zero"},
{shell: "elvish", marker: "edit:completion:arg-completer[zero]"},
}
for _, test := range tests {
t.Run(test.shell, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := runWithDeps([]string{"completions", test.shell}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), test.marker) {
t.Fatalf("%s completion missing marker %q:\n%s", test.shell, test.marker, stdout.String())
}
for _, want := range []string{"daemon", "mcp oauth", "sandbox grants", "--output-format"} {
if !strings.Contains(stdout.String(), want) {
t.Errorf("%s completion missing %q", test.shell, want)
}
}
script := stdout.String()
switch test.shell {
case "fish":
assertBalancedFishBlocks(t, script)
case "powershell", "elvish":
assertBalancedBraces(t, script)
}
if test.syntaxShell != "" {
assertNativeShellSyntax(t, test.syntaxShell, script)
}
})
}
}
func assertBalancedBraces(t *testing.T, script string) {
t.Helper()
if opens, closes := strings.Count(script, "{"), strings.Count(script, "}"); opens != closes {
t.Errorf("unbalanced braces: %d opening, %d closing", opens, closes)
}
}
func assertBalancedFishBlocks(t *testing.T, script string) {
t.Helper()
depth := 0
for _, line := range strings.Split(script, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
switch fields[0] {
case "function", "for", "switch", "if":
depth++
case "end":
depth--
if depth < 0 {
t.Fatal("fish completion closes a block before one is opened")
}
}
}
if depth != 0 {
t.Errorf("fish completion has %d unclosed blocks", depth)
}
}
func assertNativeShellSyntax(t *testing.T, shell, script string) {
t.Helper()
path, err := exec.LookPath(shell)
if err != nil {
t.Skipf("%s is not installed", shell)
}
command := exec.Command(path, "-n")
command.Stdin = strings.NewReader(script)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("%s syntax check failed: %v\n%s", shell, err, output)
}
}
func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
contexts := completionContexts(completionRoot)
byPath := make(map[string][]string, len(contexts))
for _, context := range contexts {
byPath[context.path] = context.candidates
}
assertCandidates(t, byPath[""], "sessions", "session", "plugins", "plugin", "worktrees", "worktree", "--add-dir", "--theme", "-p", "--prompt")
assertCandidates(t, byPath["exec"], "--model", "--cwd", "--worktree", "--output-format", "--resume", "--skip-permissions-unsafe")
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status")
assertCandidates(t, byPath["sandbox grants"], "list", "allow", "deny", "revoke", "clear")
assertCandidates(t, byPath["completions"], "bash", "zsh", "fish", "powershell", "elvish")
}
func assertCandidates(t *testing.T, got []string, wants ...string) {
t.Helper()
set := make(map[string]bool, len(got))
for _, candidate := range got {
set[candidate] = true
}
for _, want := range wants {
if !set[want] {
t.Errorf("candidates %v do not contain %q", got, want)
}
}
}
-122
View File
@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"path/filepath"
"sort"
"strings"
@@ -253,115 +252,6 @@ func runPluginRemove(args []string, dir string, stdout io.Writer, stderr io.Writ
return exitSuccess
}
func runPluginInfo(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
id, options, help, err := parsePluginInfoArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writePluginInfoHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if id == "" {
return writeExecUsageError(stderr, "usage: zero plugins info <id> [--user] [--json]")
}
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
info, err := plugins.Info(plugins.InfoOptions{
LoadOptions: pluginLoadOptions(deps, cwd, options.user),
}, id)
if err != nil {
if errors.Is(err, plugins.ErrNotInstalled) {
return writeExecUsageError(stderr, err.Error())
}
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, redaction.RedactValue(info, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
plugin := info.Plugin
lines := []string{fmt.Sprintf("%s (%s) %s", plugin.ID, plugin.Name, plugin.Version)}
state := "enabled"
if !plugin.Enabled {
state = "disabled"
}
lines = append(lines, " state: "+state)
lines = append(lines, " source: "+string(plugin.Source))
if len(plugin.Tools) > 0 {
lines = append(lines, fmt.Sprintf(" tools: %d", len(plugin.Tools)))
}
if len(plugin.Hooks) > 0 {
lines = append(lines, fmt.Sprintf(" hooks: %d", len(plugin.Hooks)))
}
if len(plugin.Skills) > 0 {
lines = append(lines, fmt.Sprintf(" skills: %d", len(plugin.Skills)))
}
if len(plugin.Prompts) > 0 {
lines = append(lines, fmt.Sprintf(" prompts: %d", len(plugin.Prompts)))
}
if info.LockSource != "" {
lines = append(lines, " lock source: "+info.LockSource)
}
if info.LockHash != "" {
lines = append(lines, " lock hash: "+info.LockHash)
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
}
lines = append(lines, " manifest: "+plugin.ManifestPath)
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
type pluginInfoOptions struct {
json bool
user bool
}
func parsePluginInfoArgs(args []string) (string, pluginInfoOptions, bool, error) {
options := pluginInfoOptions{}
id := ""
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return "", options, true, nil
case "--json":
options.json = true
case "--user":
options.user = true
default:
if strings.HasPrefix(arg, "-") {
return "", options, false, execUsageError{fmt.Sprintf("unknown plugins info flag %q", arg)}
}
if id != "" {
return "", options, false, execUsageError{"plugins info takes a single id"}
}
id = arg
}
}
return id, options, false, nil
}
func pluginLoadOptions(deps appDeps, cwd string, userOnly bool) plugins.LoadOptions {
opts := plugins.LoadOptions{Cwd: cwd, ExcludeProject: userOnly}
roots := []plugins.Root{{Source: plugins.SourceUser, Path: deps.pluginsDir()}}
if !userOnly {
roots = append(roots, plugins.Root{Source: plugins.SourceProject, Path: filepath.Join(cwd, ".zero", "plugins")})
}
opts.Roots = roots
return opts
}
// --- tools make / list ---
func runTools(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
@@ -677,18 +567,6 @@ Removes an installed plugin directory and its plugins.lock entry.
return err
}
func writePluginInfoHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero plugins info <id> [flags]
Flags:
--user Target only user plugins (ignore ./.zero/plugins)
--json Print plugin info as JSON
-h, --help Show this help
`)
return err
}
func writeToolsHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero tools <command>
-9
View File
@@ -177,15 +177,6 @@ func TestRunPluginAddListRemove(t *testing.T) {
t.Fatalf("add output missing id:\n%s", stdout.String())
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"plugin", "info", "zero.demo"}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("plugin info exit = %d, stderr = %s", exit, stderr.String())
}
if !strings.Contains(stdout.String(), "zero.demo") || !strings.Contains(stdout.String(), "manifest:") {
t.Fatalf("info output missing plugin details:\n%s", stdout.String())
}
// remove
stdout.Reset()
stderr.Reset()
+29 -35
View File
@@ -14,7 +14,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/modelregistry"
@@ -207,7 +206,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
registry := newCoreRegistry(workspaceRoot)
executionRunner := execution.NewRunner(nil)
// Register the escalate_model tool only when the run opted into mid-run
// escalation. It is registered before --list-tools formatting and
// validateExecToolFilters so the tool is both listable and filter-validatable.
@@ -240,13 +238,16 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
var mcpRuntime mcpToolRuntime
var mcpSkip trustSkip
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
// Make local plugins live for this run: register their declared tools into the
// registry and collect their hooks + skill roots for the dispatcher and skill
// tool below. Done before --list-tools and filter validation so plugin tools
// are listable and filter-validatable; it fails OPEN (a bad plugin is skipped).
var pluginActivation pluginActivation
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
if options.useSpec {
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
}
@@ -289,29 +290,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
var displacedMaxTurns int
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
registry.Register(tool)
}
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
if notice, err := sandboxEngine.ConsumeGrantMigrationNotice(); err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", "migrate sandbox grants: "+err.Error())
} else if notice != "" {
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
}
executionRunner.SetPreparer(sandboxEngine)
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
if err := validateExecToolFilters(options, registry); err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
@@ -379,6 +357,28 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
images = nil
}
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
// Re-register the core tools with the run scope, OVERWRITING the nil-scope
// instances registered before config resolve (the registry must exist that
// early for --list-tools and tool-filter validation, which run without a
// provider). This is safe only while two invariants hold:
// 1. Registry.Register replaces by NAME, so every path-confining core
// tool is swapped wholesale; and
// 2. nothing between the initial registration and this point captures a
// core-tool INSTANCE (tool_search holds the *Registry* and resolves
// names lazily; --list-tools and filter validation use names only).
// A new wrapper that snapshots a core tool before this line would silently
// ship nil-scope enforcement — add it below this re-registration instead.
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
registry.Register(tool)
}
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
runReasoningEffort := options.reasoningEffort
if options.useSpec && options.specReasoningEffort != "" {
runReasoningEffort = options.specReasoningEffort
@@ -624,7 +624,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// Build the hooks dispatcher out of the struct literal so its trust skip report
// can be combined with the plugin activation's, and emit at most one notice when
// project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
@@ -939,12 +939,6 @@ func providerSensitiveEnvKeys(resolved config.ResolvedConfig) []string {
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
// the default policy.
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
// An explicit `"sandbox": {"enabled": false}` disables the sandbox: every
// request is allowed and commands run unwrapped. Only reachable from global
// config / CLI — project config never sets Enabled (see mergeProjectConfig).
if cfg.Enabled != nil && !*cfg.Enabled {
policy.Mode = sandbox.ModeDisabled
}
if network := strings.TrimSpace(cfg.Network); network != "" {
switch sandbox.NetworkMode(network) {
case sandbox.NetworkAllow, sandbox.NetworkDeny:
+1 -2
View File
@@ -9,7 +9,6 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -115,7 +114,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
// may be a --worktree path; this keeps a --use-spec --worktree run of a trusted
// repo trusted. Emit at most one notice when project hooks or the project MCP
// layer (registered earlier in runExec, carried in run.mcpSkip) were dropped.
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot)
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
MaxTurns: run.resolved.MaxTurns,
-3
View File
@@ -78,8 +78,6 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return exitSuccess
case "add":
return runPluginAdd(args[1:], deps.pluginsDir(), stdout, stderr)
case "info":
return runPluginInfo(args[1:], stdout, stderr, deps)
case "remove", "rm":
return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr)
default:
@@ -563,7 +561,6 @@ func writePluginsHelp(w io.Writer) error {
Commands:
list List local Zero plugins
info <id> Show plugin details and lockfile metadata
add <git-url|path> Install a plugin (manifest-validated, pinned in plugins.lock)
remove <id> Remove an installed plugin and its lockfile entry
`)
+6 -12
View File
@@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
@@ -57,8 +56,8 @@ func resolveTrust(trustRoot string) (excludeProject bool, trustCheckErrored bool
//
// trustRoot is the original launch directory (resolved before any --worktree
// reassignment); the project layer loads only when that root is trusted.
func newHookDispatcher(workspaceRoot string, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot, runners...)
func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatcher, trustSkip) {
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot)
}
// newHookDispatcherWithExtra builds the dispatcher like newHookDispatcher but also
@@ -73,7 +72,7 @@ func newHookDispatcher(workspaceRoot string, trustRoot string, runners ...*execu
// trust store cannot be read, or the workspace is not trusted (fail-closed). The
// returned trustSkip lets the caller emit one combined notice; the notice itself
// is NOT emitted here.
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string) (*hooks.Dispatcher, trustSkip) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectHooksFileExists(workspaceRoot),
@@ -107,15 +106,10 @@ func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition,
}
config.Hooks = merged
}
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: config,
Audit: audit,
Cwd: workspaceRoot,
Execution: runner,
Config: config,
Audit: audit,
Cwd: workspaceRoot,
}), skip
}
+1 -8
View File
@@ -8,7 +8,6 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -30,7 +29,7 @@ type mcpToolListItem struct {
// It returns a trustSkip alongside the runtime so the caller can fold the MCP gate
// into the one-line trust notice (mirroring the hooks and plugins chokepoints);
// otherwise a workspace whose only project config is MCP would be gated silently.
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string, runners ...*execution.Runner) (mcpToolRuntime, trustSkip, error) {
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string) (mcpToolRuntime, trustSkip, error) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
@@ -47,15 +46,9 @@ func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, reg
if err != nil {
return nil, skip, err
}
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
PermissionStore: store,
Autonomy: autonomy,
Execution: runner,
WorkspaceRoot: workspaceRoot,
})
return runtime, skip, err
}
+2 -7
View File
@@ -8,7 +8,6 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/tools"
@@ -43,7 +42,7 @@ type pluginActivation struct {
// trustRoot is the original launch directory (resolved before any --worktree
// reassignment). The returned activation carries the skip report so the caller can
// emit one combined notice; the notice itself is NOT emitted here.
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string, runners ...*execution.Runner) pluginActivation {
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string) pluginActivation {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectPluginsDirExists(workspaceRoot),
@@ -66,11 +65,7 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
writePluginActivationWarning(stderr, formatLoadDiagnostic(diagnostic))
}
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot, Execution: runner})
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot})
for _, warning := range result.Warnings {
writePluginActivationWarning(stderr, warning)
}
+1 -7
View File
@@ -10,7 +10,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers"
)
type providerModelsOptions struct {
@@ -138,12 +137,7 @@ func discoveryCredentialProfile(profile config.ProviderProfile) config.ProviderP
// the provider's model-listing endpoint with no curated-catalog merge or
// coding-model filtering, so a custom provider's full model list is returned.
func defaultDiscoverProviderModels(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
resolver, loginKey := oauthLoginForProfile(profile)
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{
OAuthResolver: resolver,
CodexAccountResolver: providers.CodexAccountResolverForLogin(loginKey),
UserAgent: userAgent(),
})
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{})
}
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
+2 -191
View File
@@ -53,39 +53,16 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
// SetActiveProvider only ever matches profiles persisted in config.json
// (see config.ProviderPersisted), but a provider can be visible in
// `zero providers list`/the TUI picker purely because Resolve()
// synthesized it in-memory from an ambient env var (e.g. OPENAI_API_KEY)
// without ever writing a row to disk. Without this check, switching to
// that provider by name always fails with a confusing "not found" even
// though it is genuinely usable this session (issue #707).
persisted, err := config.ProviderPersisted(configPath, options.name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if !persisted {
if exit, handled := reportUnpersistedProviderUse(stdout, stderr, deps, options, configPath); handled {
return exit
}
}
cfg, err := config.SetActiveProvider(configPath, options.name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
override := activeProviderEnvOverride(deps.getenv, cfg.ActiveProvider)
if options.json {
payload := map[string]any{
if err := writePrettyJSON(stdout, map[string]any{
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}
if override != "" {
// A JSON consumer must not read this as an effective switch either.
payload["effectiveProvider"] = override
payload["overriddenByEnv"] = config.ActiveProviderEnv
}
if err := writePrettyJSON(stdout, payload); err != nil {
}); err != nil {
return exitCrash
}
return exitSuccess
@@ -93,32 +70,9 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
if _, err := fmt.Fprintf(stdout, "Active provider set to %s\nnext: %s\n", cfg.ActiveProvider, providerCheckCommand(cfg.ActiveProvider, false)); err != nil {
return exitCrash
}
if override != "" {
if _, err := fmt.Fprintf(stderr, "Note: %s=%s is set and overrides config.json, so %s stays the active provider until you unset %s.\n", config.ActiveProviderEnv, override, override, config.ActiveProviderEnv); err != nil {
return exitCrash
}
}
return exitSuccess
}
// activeProviderEnvOverride returns the ZERO_PROVIDER value when it is set and
// names a DIFFERENT provider than the one just selected, meaning the saved
// `providers use` selection will NOT be the effective active provider until the
// env var is unset. applyEnv (resolver.go) makes ZERO_PROVIDER win over
// config.json unconditionally, so reporting the write as a plain success reads as
// a switch that silently has no effect (issue #721). Empty when nothing overrides
// (including when getenv is nil, e.g. a test that did not inject the environment).
func activeProviderEnvOverride(getenv func(string) string, selected string) string {
if getenv == nil {
return ""
}
override := strings.TrimSpace(getenv(config.ActiveProviderEnv))
if override == "" || strings.EqualFold(override, strings.TrimSpace(selected)) {
return ""
}
return override
}
func runProvidersSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderSetupArgs(args)
if err != nil {
@@ -398,22 +352,6 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps
return writeAppError(stderr, err.Error(), exitCrash)
}
name := options.names[0]
// RemoveProvider only ever matches profiles persisted in config.json (see
// config.ProviderPersisted), but a provider can be visible in
// `zero providers list`/the TUI picker purely because Resolve()
// synthesized it in-memory from an ambient env var (e.g. OPENAI_API_KEY)
// without ever writing a row to disk. Without this check, deleting that
// provider by name always fails with a confusing "not found" even though
// it is genuinely visible/usable this session (issue #707).
persisted, err := config.ProviderPersisted(configPath, name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if !persisted {
if exit, handled := reportUnpersistedProviderRemove(stdout, stderr, deps, name, options.json, configPath); handled {
return exit
}
}
cfg, err := config.RemoveProvider(configPath, name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
@@ -490,16 +428,6 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
oldName := options.names[0]
persisted, err := config.ProviderPersisted(configPath, oldName)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if !persisted {
if exit, handled := reportUnpersistedProviderRename(stdout, stderr, deps, oldName, options.json, configPath); handled {
return exit
}
}
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
@@ -519,120 +447,3 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
}
return exitSuccess
}
// providerResolvedByName reports whether name matches a provider in a
// resolved provider list — used to tell a genuinely unknown name (a typo)
// apart from a real, env-derived provider that just has no config.json row.
func providerResolvedByName(providers []config.ProviderProfile, name string) bool {
name = strings.TrimSpace(name)
for _, provider := range providers {
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
return true
}
}
return false
}
// reportUnpersistedProviderUse handles `zero providers use <name>` for a
// provider that is not persisted in config.json. If it's not resolvable at
// all (an unknown/misspelled name), it returns handled=false so the caller
// falls through to SetActiveProvider's real "not found" error. If it IS
// resolvable — an env-derived profile (e.g. ambient OPENAI_API_KEY) —
// SetActiveProvider would only ever fail "not found" against it, so this
// reports the situation plainly instead of that confusing error (issue
// #707).
func reportUnpersistedProviderUse(stdout, stderr io.Writer, deps appDeps, options providerUseOptions, configPath string) (int, bool) {
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
// resolveCommandCenterConfig already wrote its own error to stderr;
// stop here instead of letting the caller try SetActiveProvider too.
return exitCode, true
}
if !providerResolvedByName(resolved.Providers, options.name) {
return exitCode, false
}
message := fmt.Sprintf(
"Provider %q is not saved in config.json (likely set via an environment variable), so there is no saved profile to switch to.\nIt is available whenever its environment variable is set, but is only active when selected (for example via ZERO_PROVIDER); unset its environment variable to stop Zero from detecting it automatically.",
options.name,
)
if options.json {
if err := writePrettyJSON(stdout, map[string]any{
"activeProvider": resolved.ActiveProvider,
"configPath": configPath,
"persisted": false,
"message": message,
}); err != nil {
return exitCrash, true
}
return exitSuccess, true
}
if _, err := fmt.Fprintln(stdout, message); err != nil {
return exitCrash, true
}
return exitSuccess, true
}
// reportUnpersistedProviderRemove handles `zero providers remove <name>` for
// a provider that is not persisted in config.json, mirroring the TUI
// provider manager's delete handling (internal/tui/provider_manager.go). If
// name isn't resolvable at all, it returns handled=false so the caller falls
// through to RemoveProvider's real "not found" error.
func reportUnpersistedProviderRemove(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode, true
}
if !providerResolvedByName(resolved.Providers, name) {
return exitCode, false
}
message := fmt.Sprintf(
"Provider %q is not saved in config.json (likely set via an environment variable) — nothing to remove there.\nUnset its environment variable to stop Zero from detecting it automatically.",
name,
)
if jsonOutput {
if err := writePrettyJSON(stdout, map[string]any{
"removed": "",
"keyRemoved": false,
"activeProvider": resolved.ActiveProvider,
"configPath": configPath,
"persisted": false,
"message": message,
}); err != nil {
return exitCrash, true
}
return exitSuccess, true
}
if _, err := fmt.Fprintln(stdout, message); err != nil {
return exitCrash, true
}
return exitSuccess, true
}
func reportUnpersistedProviderRename(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode, true
}
if !providerResolvedByName(resolved.Providers, name) {
return exitCode, false
}
message := fmt.Sprintf(
"Provider %q is not saved in config.json (likely set via an environment variable), so there is no saved profile to rename.",
name,
)
if jsonOutput {
if err := writePrettyJSON(stdout, map[string]any{
"renamed": nil,
"configPath": configPath,
"persisted": false,
"message": message,
}); err != nil {
return exitCrash, true
}
return exitSuccess, true
}
if _, err := fmt.Fprintln(stdout, message); err != nil {
return exitCrash, true
}
return exitSuccess, true
}
-201
View File
@@ -75,207 +75,6 @@ func TestRunProvidersUseJSONIncludesActiveProviderAndConfigPath(t *testing.T) {
}
}
func providersUseOverrideConfig(t *testing.T) string {
t.Helper()
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{
ActiveProvider: "work",
Providers: []config.ProviderProfile{
{Name: "work", ProviderKind: config.ProviderKindOpenAI, BaseURL: config.OpenAIBaseURL, Model: "gpt-4.1"},
{Name: "fast", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", Model: "llama-3.3-70b-versatile"},
},
})
return configPath
}
// The write to config.json still succeeds, but when ZERO_PROVIDER names a
// different provider the saved selection is NOT effective, so the command must
// warn instead of reporting a silent success (issue #721).
func TestRunProvidersUseWarnsWhenEnvOverrides(t *testing.T) {
var stdout, stderr bytes.Buffer
configPath := providersUseOverrideConfig(t)
deps := providerSetupDeps(configPath)
deps.getenv = func(key string) string {
if key == config.ActiveProviderEnv {
return "work" // ZERO_PROVIDER=work overrides the switch to fast
}
return ""
}
if code := runWithDeps([]string{"providers", "use", "fast"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String())
}
if cfg := readFileConfig(t, configPath); cfg.ActiveProvider != "fast" {
t.Fatalf("ActiveProvider = %q, want fast (the write still lands)", cfg.ActiveProvider)
}
if !strings.Contains(stdout.String(), "Active provider set to fast") {
t.Fatalf("stdout missing the success line: %q", stdout.String())
}
note := stderr.String()
for _, want := range []string{config.ActiveProviderEnv, "overrides config.json", "work"} {
if !strings.Contains(note, want) {
t.Fatalf("override note missing %q, got %q", want, note)
}
}
}
func TestRunProvidersUseJSONFlagsEnvOverride(t *testing.T) {
var stdout, stderr bytes.Buffer
deps := providerSetupDeps(providersUseOverrideConfig(t))
deps.getenv = func(key string) string {
if key == config.ActiveProviderEnv {
return "work"
}
return ""
}
if code := runWithDeps([]string{"providers", "use", "fast", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String())
}
var payload struct {
ActiveProvider string `json:"activeProvider"`
EffectiveProvider string `json:"effectiveProvider"`
OverriddenByEnv string `json:"overriddenByEnv"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("JSON did not decode: %v\n%s", err, stdout.String())
}
if payload.ActiveProvider != "fast" || payload.EffectiveProvider != "work" || payload.OverriddenByEnv != config.ActiveProviderEnv {
t.Fatalf("JSON must flag the env override, got %#v", payload)
}
}
// No override note when ZERO_PROVIDER is unset or already names the selection.
func TestRunProvidersUseNoWarnWithoutEnvOverride(t *testing.T) {
cases := map[string]func(string) string{
"env unset": func(string) string { return "" },
"env matches fast": func(key string) string {
if key == config.ActiveProviderEnv {
return "fast"
}
return ""
},
}
for name, getenv := range cases {
t.Run(name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
deps := providerSetupDeps(providersUseOverrideConfig(t))
deps.getenv = getenv
if code := runWithDeps([]string{"providers", "use", "fast"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("exit = %d: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected no override note, got %q", stderr.String())
}
})
}
}
func TestRunProvidersUseSurfacesMalformedConfig(t *testing.T) {
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte("{"), 0o600); err != nil {
t.Fatalf("write malformed config: %v", err)
}
exitCode := runWithDeps([]string{"providers", "use", "openai"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitCrash {
t.Fatalf("exit code = %d, want %d", exitCode, exitCrash)
}
if !strings.Contains(stderr.String(), "invalid config JSON") {
t.Fatalf("stderr = %q, want malformed-config error", stderr.String())
}
}
func TestRunProvidersUseEnvDerivedJSONIncludesConfigPath(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
ConfigPath string `json:"configPath"`
Persisted bool `json:"persisted"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.ConfigPath != configPath || payload.Persisted {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestRunProvidersRemoveEnvDerivedJSONKeepsSchema(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "remove", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Removed string `json:"removed"`
KeyRemoved bool `json:"keyRemoved"`
Persisted bool `json:"persisted"`
ConfigPath string `json:"configPath"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.Removed != "" || payload.KeyRemoved || payload.Persisted || payload.ConfigPath != configPath {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestRunProvidersRenameEnvDerivedExplainsNoSavedProfile(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), "no saved profile to rename") {
t.Fatalf("stdout = %q, want unpersisted explanation", stdout.String())
}
}
func TestRunProvidersRenameEnvDerivedJSONKeepsSchema(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Renamed *struct{} `json:"renamed"`
ConfigPath string `json:"configPath"`
Persisted bool `json:"persisted"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.Renamed != nil || payload.ConfigPath != configPath || payload.Persisted {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestRunProvidersUseRejectsUsageErrors(t *testing.T) {
cases := []struct {
name string
-15
View File
@@ -1001,18 +1001,3 @@ func sandboxPolicyCapabilityStatus(capabilities []sandbox.BackendCapability, key
}
return ""
}
func TestApplyConfiguredSandboxPolicyEnabledFalseDisables(t *testing.T) {
disabled := false
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &disabled}); got.Mode != sandbox.ModeDisabled {
t.Fatalf("enabled:false must yield ModeDisabled, got %q", got.Mode)
}
// Omitted (nil) and explicit true keep the default enforcing mode.
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.Mode == sandbox.ModeDisabled {
t.Fatal("omitted enabled must not disable the sandbox")
}
enabled := true
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &enabled}); got.Mode == sandbox.ModeDisabled {
t.Fatal("enabled:true must not disable the sandbox")
}
}
-20
View File
@@ -93,16 +93,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
if err != nil {
return ResolvedConfig{}, err
}
// Sandbox.Enabled is NOT accepted from a provider command, for the same
// reason project config cannot set it (see mergeProjectConfig): only
// global config and the CLI may turn the sandbox off. A provider command
// is an arbitrary executable named in config, and LoadProviderCommand
// parses its stdout into a full FileConfig — so without this, a command
// returning a valid provider plus {"sandbox":{"enabled":false}} would
// disable the very sandbox meant to constrain what it can do. Cleared
// here rather than in mergeConfig because that helper is shared with the
// trusted global-config merge, which must keep honouring the setting.
commandConfig.Sandbox.Enabled = nil
mergeConfig(&cfg, commandConfig)
}
@@ -226,9 +216,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
mergeProvider(dst, provider)
}
mergeMCPConfig(&dst.MCP, src.MCP, true)
if src.Sandbox.Enabled != nil {
dst.Sandbox.Enabled = src.Sandbox.Enabled
}
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
dst.Sandbox.Network = network
}
@@ -286,10 +273,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if err := mergeProjectMCPConfig(&dst.MCP, src.MCP); err != nil {
return err
}
// Sandbox.Enabled is intentionally NOT merged from project config: a cloned
// repo's .zero/config.json must not be able to disable the sandbox that
// constrains it. Only global config and CLI can turn the sandbox off.
//
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
// config: a cloned repo's .zero/config.json must not be able to grant
// itself write access outside the workspace. Global config and CLI flags
@@ -705,9 +688,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
if overrides.MaxTurns > 0 {
cfg.MaxTurns = overrides.MaxTurns
}
if overrides.Sandbox.Enabled != nil {
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
}
if overrides.Sandbox.BlockUnixSockets {
cfg.Sandbox.BlockUnixSockets = true
}
-79
View File
@@ -1744,85 +1744,6 @@ func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
}
}
func TestResolveSandboxEnabledUserConfigDisables(t *testing.T) {
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
t.Fatalf("user config enabled:false must resolve to a false pointer, got %v", resolved.Sandbox.Enabled)
}
}
func TestResolveSandboxDisableIgnoredFromProjectConfig(t *testing.T) {
// Security: a cloned repo's project config must NOT be able to disable the
// sandbox that constrains it. Only global config / CLI can turn it off.
userPath := writeConfig(t, `{}`)
projectPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Sandbox.Enabled != nil {
t.Fatalf("project config enabled:false must be ignored, got %v — a repo could disable the sandbox", *resolved.Sandbox.Enabled)
}
}
func TestResolveSandboxEnabledIgnoredFromProviderCommand(t *testing.T) {
// Security: a provider command is an arbitrary executable named in config, and
// its stdout is parsed into a full FileConfig — so it must NOT be able to reach
// Sandbox.Enabled, in either direction. Same rule as project config: only global
// config / CLI may turn the sandbox off.
for _, tc := range []struct {
name string
value string
}{
{name: "cannot disable", value: "false"},
{name: "cannot enable", value: "true"},
} {
t.Run(tc.name, func(t *testing.T) {
command := writeCommand(t, commandScript{
Stdout: `{"activeProvider":"cmd","providers":[{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}],"sandbox":{"enabled":` + tc.value + `}}`,
})
resolved, err := Resolve(ResolveOptions{ProviderCommand: command, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
// The provider itself must still be applied — only the sandbox toggle is dropped.
if resolved.ActiveProvider != "cmd" {
t.Fatalf("ActiveProvider = %q, want cmd (provider command must still apply)", resolved.ActiveProvider)
}
if resolved.Sandbox.Enabled != nil {
t.Fatalf("provider command set sandbox.enabled=%s, got %v — a provider command could toggle the sandbox", tc.value, *resolved.Sandbox.Enabled)
}
})
}
}
func TestResolveSandboxEnabledCLIOverride(t *testing.T) {
// A CLI/programmatic override (applied after every config source) must be able
// to disable the sandbox, even with no config file.
disabled := false
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &disabled}}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
t.Fatalf("CLI override enabled:false must disable the sandbox, got %v", resolved.Sandbox.Enabled)
}
// An explicit true override must win over a lower-precedence global-config false.
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
enabled := true
resolved2, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &enabled}}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved2.Sandbox.Enabled == nil || !*resolved2.Sandbox.Enabled {
t.Fatalf("CLI override enabled:true must win over config false, got %v", resolved2.Sandbox.Enabled)
}
}
func TestResolveNotifyValid(t *testing.T) {
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
-6
View File
@@ -62,12 +62,6 @@ func HasProviderProfile(profile ProviderProfile) bool {
}
type SandboxConfig struct {
// Enabled turns the sandbox off when set to false (`"sandbox": {"enabled":
// false}`). A pointer distinguishes an explicit false (disable) from an
// omitted key (keep the default: enabled). Honored from the GLOBAL user
// config and CLI only — deliberately NOT project config, so a cloned repo
// cannot disable the sandbox that constrains it. nil / true keep enforcement.
Enabled *bool `json:"enabled,omitempty"`
// Network controls whether shell commands classified as network-touching
// (curl, git push, package installs, …) are allowed: "allow" or "deny".
// Empty keeps the built-in default (deny). Without this knob the engine's
-27
View File
@@ -177,33 +177,6 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
// ProviderPersisted reports whether a provider profile named name actually has
// a row in the config file at path. A provider can appear in the resolved/
// in-memory provider list without ever being written to config.json — e.g.
// applyProviderEnv synthesizes an "openai" profile purely from an ambient
// OPENAI_API_KEY environment variable on every Resolve() call, without ever
// persisting it. RemoveProvider/SetActiveProvider/SetProviderModel only ever
// look at what's on disk, so a caller offering to mutate a provider by name
// should check this first: "not on disk" needs different handling (nothing to
// persist/remove there) than a name that doesn't exist anywhere at all.
func ProviderPersisted(path string, name string) (bool, error) {
path = strings.TrimSpace(path)
name = strings.TrimSpace(name)
if path == "" || name == "" {
return false, nil
}
cfg, err := loadConfigFile(path)
if err != nil {
return false, err
}
for _, provider := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
return true, nil
}
}
return false, nil
}
// RemoveProvider deletes the named provider profile from the config at path.
// When the removed profile was active, activeProvider hands off to the first
// remaining provider (or clears when none remain) so the config never points at
-214
View File
@@ -1,214 +0,0 @@
package execution
import (
"crypto/sha256"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
const (
maxObservedFiles = 20_000
maxHashedFileBytes = 1 << 20
maxTotalHashedBytes = 32 << 20
)
type ChangeKind string
const (
ChangeCreated ChangeKind = "created"
ChangeModified ChangeKind = "modified"
ChangeDeleted ChangeKind = "deleted"
)
type Change struct {
Path string `json:"path"`
Kind ChangeKind `json:"kind"`
Aggregated bool `json:"aggregated,omitempty"`
Count int `json:"count,omitempty"`
}
type fileFingerprint struct {
Mode fs.FileMode
Size int64
ModTime int64
Digest [sha256.Size]byte
Hashed bool
// Aggregated marks a bounded fingerprint for a generated directory. The
// observer never enumerates that tree into individual changes.
Aggregated bool
Count int
}
// ChangeObserver records a bounded workspace snapshot around a command. It
// deliberately skips Zero/repository control metadata and large generated
// dependency trees; those paths must neither be read as command evidence nor
// flood the Files panel.
type ChangeObserver struct {
root string
before map[string]fileFingerprint
valid bool
}
func NewChangeObserver(root string) *ChangeObserver {
root = filepath.Clean(strings.TrimSpace(root))
before, ok := snapshotWorkspace(root)
return &ChangeObserver{root: root, before: before, valid: ok}
}
func (observer *ChangeObserver) Changes() []Change {
if observer == nil || !observer.valid {
return nil
}
after, ok := snapshotWorkspace(observer.root)
if !ok {
return nil
}
changes := make([]Change, 0)
for path, current := range after {
previous, existed := observer.before[path]
if !existed {
changes = append(changes, changeFromFingerprint(path, ChangeCreated, current))
continue
}
if previous != current {
changes = append(changes, changeFromFingerprint(path, ChangeModified, current))
}
}
for path, previous := range observer.before {
if _, exists := after[path]; !exists {
changes = append(changes, changeFromFingerprint(path, ChangeDeleted, previous))
}
}
sort.Slice(changes, func(i, j int) bool { return changes[i].Path < changes[j].Path })
return changes
}
func snapshotWorkspace(root string) (map[string]fileFingerprint, bool) {
if root == "" || root == "." {
return nil, false
}
files := make(map[string]fileFingerprint)
hashedBytes := int64(0)
ok := true
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return nil
}
if path == root {
return nil
}
relative, err := filepath.Rel(root, path)
if err != nil || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil
}
if entry.IsDir() {
if protectedObservationDirectory(entry.Name()) {
return filepath.SkipDir
}
if generatedObservationDirectory(entry.Name()) {
files[filepath.ToSlash(relative)+"/"] = generatedTreeFingerprint(path)
return filepath.SkipDir
}
return nil
}
if !entry.Type().IsRegular() {
return nil
}
if len(files) >= maxObservedFiles {
ok = false
return fs.SkipAll
}
info, err := entry.Info()
if err != nil {
return nil
}
fingerprint := fileFingerprint{Mode: info.Mode(), Size: info.Size(), ModTime: info.ModTime().UnixNano()}
if info.Size() <= maxHashedFileBytes && hashedBytes+info.Size() <= maxTotalHashedBytes {
if digest, err := hashObservedFile(path); err == nil {
fingerprint.Digest = digest
fingerprint.Hashed = true
hashedBytes += info.Size()
}
}
files[filepath.ToSlash(relative)] = fingerprint
return nil
})
if err != nil {
return nil, false
}
return files, ok
}
func protectedObservationDirectory(name string) bool {
switch name {
case ".git", ".zero", ".agents":
return true
default:
return false
}
}
func generatedObservationDirectory(name string) bool {
switch name {
case "node_modules", ".pnpm-store", ".yarn", "dist", "coverage", ".cache":
return true
default:
return false
}
}
// generatedTreeFingerprint samples only direct children and caps the work. It
// is intentionally qualitative: the UI needs to say that a generated tree
// changed, not inventory dependency contents.
func generatedTreeFingerprint(root string) fileFingerprint {
directory, err := os.Open(root)
if err != nil {
return fileFingerprint{Aggregated: true, Count: -1}
}
defer directory.Close()
const maxEntries = 256
entries, err := directory.ReadDir(maxEntries + 1)
if err != nil && err != io.EOF {
return fileFingerprint{Aggregated: true, Count: -1}
}
hash := sha256.New()
count := len(entries)
if count > maxEntries {
entries = entries[:maxEntries]
count = -1
}
for _, entry := range entries {
_, _ = io.WriteString(hash, entry.Name())
if info, infoErr := entry.Info(); infoErr == nil {
_, _ = io.WriteString(hash, info.Mode().String())
_, _ = io.WriteString(hash, strconv.FormatInt(info.ModTime().UnixNano(), 10))
}
}
var digest [sha256.Size]byte
copy(digest[:], hash.Sum(nil))
return fileFingerprint{Digest: digest, Hashed: true, Aggregated: true, Count: count}
}
func changeFromFingerprint(path string, kind ChangeKind, fingerprint fileFingerprint) Change {
return Change{Path: path, Kind: kind, Aggregated: fingerprint.Aggregated, Count: fingerprint.Count}
}
func hashObservedFile(path string) ([sha256.Size]byte, error) {
file, err := os.Open(path)
if err != nil {
return [sha256.Size]byte{}, err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return [sha256.Size]byte{}, err
}
var digest [sha256.Size]byte
copy(digest[:], hash.Sum(nil))
return digest, nil
}
@@ -1,67 +0,0 @@
package execution
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestChangeObserverReportsCreateModifyAndDelete(t *testing.T) {
root := t.TempDir()
modified := filepath.Join(root, "modified.txt")
deleted := filepath.Join(root, "deleted.txt")
if err := os.WriteFile(modified, []byte("before"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(deleted, []byte("delete"), 0o644); err != nil {
t.Fatal(err)
}
observer := NewChangeObserver(root)
if err := os.WriteFile(modified, []byte("after!"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(deleted); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "src", "created.ts"), []byte("export {}"), 0o644); err != nil {
t.Fatal(err)
}
want := []Change{
{Path: "deleted.txt", Kind: ChangeDeleted},
{Path: "modified.txt", Kind: ChangeModified},
{Path: "src/created.ts", Kind: ChangeCreated},
}
if got := observer.Changes(); !reflect.DeepEqual(got, want) {
t.Fatalf("Changes() = %#v, want %#v", got, want)
}
}
func TestChangeObserverSkipsControlMetadataAndSummarizesGeneratedTrees(t *testing.T) {
root := t.TempDir()
observer := NewChangeObserver(root)
for _, path := range []string{
filepath.Join(root, ".zero", "config.json"),
filepath.Join(root, ".agents", "skills", "x"),
filepath.Join(root, ".git", "config"),
filepath.Join(root, "node_modules", "pkg", "index.js"),
filepath.Join(root, "dist", "bundle.js"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("generated"), 0o644); err != nil {
t.Fatal(err)
}
}
want := []Change{
{Path: "dist/", Kind: ChangeCreated, Aggregated: true, Count: 1},
{Path: "node_modules/", Kind: ChangeCreated, Aggregated: true, Count: 1},
}
if got := observer.Changes(); !reflect.DeepEqual(got, want) {
t.Fatalf("Changes() = %#v, want generated summaries without control metadata %#v", got, want)
}
}
-248
View File
@@ -1,248 +0,0 @@
// Package execution defines the platform-neutral command execution contract.
// Frontends and tools use these types without depending on native sandbox or
// process-management details.
package execution
import (
"errors"
"fmt"
"strings"
)
const PolicyVersion = 1
type Origin string
const (
OriginInteractiveCommand Origin = "interactive_command"
OriginHook Origin = "hook"
OriginPlugin Origin = "plugin"
OriginMCPServer Origin = "mcp_server"
OriginBackgroundTask Origin = "background_task"
)
type Mode string
const (
ModeCaptured Mode = "captured"
ModeInteractive Mode = "interactive"
ModeDurable Mode = "durable"
)
type Command struct {
Name string `json:"name"`
Args []string `json:"args,omitempty"`
Env []string `json:"env,omitempty"`
}
type CapabilityKind string
const (
CapabilityRead CapabilityKind = "filesystem_read"
CapabilityWorkspaceWrite CapabilityKind = "workspace_write"
CapabilityProtectedMetadata CapabilityKind = "protected_metadata"
CapabilityExternalNetwork CapabilityKind = "external_network"
CapabilityIsolatedLoopback CapabilityKind = "isolated_loopback"
CapabilityHostVisibleBinding CapabilityKind = "host_visible_binding"
CapabilityProcessSpawn CapabilityKind = "process_spawn"
CapabilityProcessControl CapabilityKind = "process_control"
CapabilityEnvironment CapabilityKind = "environment"
CapabilityUnrestricted CapabilityKind = "unrestricted"
)
type Capability struct {
Kind CapabilityKind `json:"kind"`
Scope string `json:"scope,omitempty"`
}
type ApprovalContext struct {
SessionID string `json:"sessionId,omitempty"`
PolicyVersion int `json:"policyVersion"`
}
type Request struct {
Origin Origin `json:"origin"`
Mode Mode `json:"mode"`
Command Command `json:"command"`
WorkingDirectory string `json:"workingDirectory"`
WorkspaceRoots []string `json:"workspaceRoots"`
Capabilities []Capability `json:"capabilities,omitempty"`
Approval ApprovalContext `json:"approval"`
}
func (request Request) Validate() error {
if !validOrigin(request.Origin) {
return errors.New("execution request requires a valid origin")
}
if request.Mode != ModeCaptured && request.Mode != ModeInteractive && request.Mode != ModeDurable {
return errors.New("execution request requires a valid mode")
}
if strings.TrimSpace(request.Command.Name) == "" {
return errors.New("execution request requires a command")
}
if strings.TrimSpace(request.WorkingDirectory) == "" {
return errors.New("execution request requires a working directory")
}
if len(request.WorkspaceRoots) == 0 {
return errors.New("execution request requires at least one workspace root")
}
for _, root := range request.WorkspaceRoots {
if strings.TrimSpace(root) == "" {
return errors.New("execution request contains an empty workspace root")
}
}
for _, capability := range request.Capabilities {
if !validCapabilityKind(capability.Kind) {
return fmt.Errorf("execution request contains invalid capability %q", capability.Kind)
}
}
return nil
}
type State string
const (
StateRequested State = "requested"
StateEvaluated State = "evaluated"
StateAwaitingApproval State = "awaiting_approval"
StatePrepared State = "prepared"
StateRunning State = "running"
StateRetained State = "retained"
StateCompleted State = "completed"
StateDenied State = "denied"
StateFailed State = "failed"
StateCancelled State = "cancelled"
)
type OutcomeKind string
const (
OutcomeRunning OutcomeKind = "running"
OutcomeSuccess OutcomeKind = "success"
OutcomePolicyDenied OutcomeKind = "policy_denied"
OutcomeEnforcementDenied OutcomeKind = "enforcement_denied"
OutcomeApplicationFailure OutcomeKind = "application_failure"
OutcomeSandboxSetupFailure OutcomeKind = "sandbox_setup_failure"
OutcomeExecutableNotFound OutcomeKind = "executable_not_found"
OutcomeCancelled OutcomeKind = "cancelled"
OutcomeTimedOut OutcomeKind = "timed_out"
OutcomeEnforcementDegraded OutcomeKind = "enforcement_degraded"
)
type Exit struct {
Code int `json:"code"`
Signal string `json:"signal,omitempty"`
}
type DenialSource string
const (
DenialSourceConfiguredPolicy DenialSource = "configured_policy"
DenialSourcePlatformSandbox DenialSource = "platform_sandbox"
DenialSourceApproval DenialSource = "approval"
)
type DenialNextAction string
const (
DenialNextActionNone DenialNextAction = "none"
DenialNextActionRequestApproval DenialNextAction = "request_approval"
DenialNextActionChangeCommand DenialNextAction = "change_command"
DenialNextActionEnableSandbox DenialNextAction = "enable_sandbox"
)
type Denial struct {
Capability Capability `json:"capability"`
Source DenialSource `json:"source"`
Reason string `json:"reason"`
Recoverable bool `json:"recoverable"`
NextAction DenialNextAction `json:"nextAction"`
}
type Enforcement struct {
Backend string `json:"backend,omitempty"`
Level string `json:"level,omitempty"`
Degraded bool `json:"degraded,omitempty"`
DowngradeReason string `json:"downgradeReason,omitempty"`
}
type Outcome struct {
State State `json:"state"`
Kind OutcomeKind `json:"kind"`
ProcessID string `json:"processId,omitempty"`
Exit *Exit `json:"exit,omitempty"`
Denial *Denial `json:"denial,omitempty"`
Enforcement Enforcement `json:"enforcement"`
Changes []Change `json:"changes,omitempty"`
}
// AdapterReport is the structured, machine-readable result emitted by a
// platform enforcement adapter. It is separate from stdout and stderr so
// command text cannot impersonate a policy decision.
type AdapterReport struct {
Denial *Denial `json:"denial,omitempty"`
}
func (outcome Outcome) Validate() error {
if outcome.State == "" || outcome.Kind == "" {
return errors.New("execution outcome requires state and kind")
}
switch outcome.State {
case StateRetained, StateRunning:
if outcome.Kind != OutcomeRunning || strings.TrimSpace(outcome.ProcessID) == "" {
return errors.New("running execution outcome requires a process id")
}
case StateCompleted:
if outcome.Kind != OutcomeSuccess || outcome.Exit == nil || outcome.Exit.Code != 0 {
return errors.New("completed execution outcome requires a successful exit")
}
case StateDenied:
if (outcome.Kind != OutcomePolicyDenied && outcome.Kind != OutcomeEnforcementDenied) || outcome.Denial == nil {
return errors.New("denied execution outcome requires structured denial details")
}
if err := outcome.Denial.Validate(); err != nil {
return err
}
case StateFailed:
if outcome.Kind == OutcomeRunning || outcome.Kind == OutcomeSuccess || outcome.Exit == nil {
return errors.New("failed execution outcome requires a failure kind and exit")
}
case StateCancelled:
if outcome.Kind != OutcomeCancelled {
return errors.New("cancelled execution outcome requires cancelled kind")
}
default:
return fmt.Errorf("execution outcome state %q is not terminal or externally observable", outcome.State)
}
return nil
}
func (denial Denial) Validate() error {
if !validCapabilityKind(denial.Capability.Kind) {
return errors.New("execution denial requires a valid capability")
}
if denial.Source == "" || strings.TrimSpace(denial.Reason) == "" || denial.NextAction == "" {
return errors.New("execution denial requires source, reason, and next action")
}
return nil
}
func validOrigin(origin Origin) bool {
switch origin {
case OriginInteractiveCommand, OriginHook, OriginPlugin, OriginMCPServer, OriginBackgroundTask:
return true
default:
return false
}
}
func validCapabilityKind(kind CapabilityKind) bool {
switch kind {
case CapabilityRead, CapabilityWorkspaceWrite, CapabilityProtectedMetadata,
CapabilityExternalNetwork, CapabilityIsolatedLoopback, CapabilityHostVisibleBinding,
CapabilityProcessSpawn, CapabilityProcessControl, CapabilityEnvironment, CapabilityUnrestricted:
return true
default:
return false
}
}
-78
View File
@@ -1,78 +0,0 @@
package execution
import "testing"
func TestRequestValidationRequiresConcreteExecutionContext(t *testing.T) {
request := Request{
Origin: OriginInteractiveCommand,
Mode: ModeCaptured,
Command: Command{Name: "/bin/sh", Args: []string{"-c", "true"}},
WorkingDirectory: "/workspace",
WorkspaceRoots: []string{"/workspace"},
Capabilities: []Capability{{
Kind: CapabilityWorkspaceWrite,
Scope: "/workspace",
}},
}
if err := request.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
request.Origin = ""
if err := request.Validate(); err == nil {
t.Fatal("request without origin unexpectedly validated")
}
}
func TestOutcomeValidationPinsTerminalStateContracts(t *testing.T) {
tests := []struct {
name string
outcome Outcome
valid bool
}{
{
name: "retained process",
outcome: Outcome{State: StateRetained, Kind: OutcomeRunning, ProcessID: "1000"},
valid: true,
},
{
name: "successful completion",
outcome: Outcome{State: StateCompleted, Kind: OutcomeSuccess, Exit: &Exit{Code: 0}},
valid: true,
},
{
name: "structured denial",
outcome: Outcome{
State: StateDenied,
Kind: OutcomePolicyDenied,
Denial: &Denial{
Capability: Capability{Kind: CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
Source: DenialSourceConfiguredPolicy,
Reason: "protected workspace metadata cannot be created",
NextAction: DenialNextActionRequestApproval,
},
},
valid: true,
},
{
name: "retained without process id",
outcome: Outcome{State: StateRetained, Kind: OutcomeRunning},
},
{
name: "denied without denial",
outcome: Outcome{State: StateDenied, Kind: OutcomePolicyDenied},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := test.outcome.Validate()
if test.valid && err != nil {
t.Fatalf("Validate: %v", err)
}
if !test.valid && err == nil {
t.Fatal("invalid outcome unexpectedly validated")
}
})
}
}
-645
View File
@@ -1,645 +0,0 @@
package execution
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"sort"
"sync"
"time"
)
const (
defaultCompletedRetention = 30 * time.Second
defaultMaxProcesses = 64
maxPendingOutputBytes = 2 * 1024 * 1024
recentOutputBytes = 4096
processStopTimeout = 3 * time.Second
maxInteractiveYield = 30 * time.Second
maxEmptyPollYield = 5 * time.Minute
)
var (
ErrProcessNotFound = errors.New("execution process not found")
ErrProcessStdinDisabled = errors.New("execution process does not accept stdin")
)
type ProcessManagerOptions struct {
CompletedRetention time.Duration
MaxProcesses int
}
// ProcessManager owns retained interactive-process identity, transport,
// bounded output, continuation, cancellation, completion, and cleanup.
type ProcessManager struct {
mu sync.Mutex
nextID int
processes map[int]*managedProcess
completedRetention time.Duration
maxProcesses int
startTransport processTransportStarter
}
type ProcessStart struct {
Prepared PreparedCommand
Request Request
CommandText string
RelativeCwd string
TTY bool
Metadata map[string]string
// AfterWait may append adapter diagnostics collected by a platform monitor.
AfterWait func() []byte
}
type ProcessContinue struct {
ProcessID int
Input []byte
Interrupt bool
Wait time.Duration
}
type ProcessResult struct {
ProcessID int
CommandText string
RelativeCwd string
TTY bool
Output string
OutputTruncated bool
Exited bool
ExitCode int
Interrupted bool
Request Request
Enforcement Enforcement
Report AdapterReport
ReportErr error
Changes []Change
Metadata map[string]string
}
type ProcessSnapshot struct {
ID int
Command string
Cwd string
RelativeCwd string
StartedAt time.Time
LastUsedAt time.Time
TTY bool
Status string
ExitCode *int
RecentOutput string
OutputTruncated bool
}
func NewProcessManager(options ProcessManagerOptions) *ProcessManager {
retention := options.CompletedRetention
if retention == 0 {
retention = defaultCompletedRetention
}
maxProcesses := options.MaxProcesses
if maxProcesses == 0 {
maxProcesses = defaultMaxProcesses
}
return &ProcessManager{
nextID: 1000,
processes: make(map[int]*managedProcess),
completedRetention: retention,
maxProcesses: maxProcesses,
startTransport: startProcessTransport,
}
}
func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wait time.Duration) (ProcessResult, error) {
if manager == nil {
return ProcessResult{}, errors.New("execution process manager is not configured")
}
if err := input.Request.Validate(); err != nil {
return ProcessResult{}, fmt.Errorf("invalid execution request: %w", err)
}
if input.Prepared.Command == nil {
return ProcessResult{}, errors.New("prepared execution has no command")
}
command := input.Prepared.Command
buffer := newProcessOutputBuffer()
request := input.Request
observer := NewChangeObserver(request.WorkspaceRoots[0])
stdin, tty, transportCleanup, err := manager.startTransport(command, buffer, input.TTY)
if err != nil {
if input.Prepared.Cleanup != nil {
input.Prepared.Cleanup()
}
return ProcessResult{}, err
}
if tty {
request.Mode = ModeInteractive
} else {
request.Mode = ModeCaptured
}
now := time.Now()
process := &managedProcess{
id: manager.allocateID(),
commandText: input.CommandText,
cwd: request.WorkingDirectory,
relativeCwd: input.RelativeCwd,
startedAt: now,
lastUsedAt: now,
tty: tty,
command: command,
request: request,
enforcement: input.Prepared.Enforcement,
report: input.Prepared.Report,
cleanup: input.Prepared.Cleanup,
stdin: stdin,
output: buffer,
reaped: make(chan struct{}),
done: make(chan struct{}),
kill: KillProcessTree,
metadata: cloneStringMap(input.Metadata),
}
manager.store(process)
manager.removeCompletedLater(process)
go func() {
waitErr := command.Wait()
close(process.reaped)
if input.AfterWait != nil {
if diagnostic := input.AfterWait(); len(diagnostic) > 0 {
_, _ = buffer.Write(diagnostic)
}
}
if transportCleanup != nil {
transportCleanup()
}
adapterReport, reportErr := AdapterReport{}, error(nil)
if process.report != nil {
adapterReport, reportErr = process.report()
}
changes := observer.Changes()
if process.cleanup != nil {
process.cleanup()
}
process.markDone(waitErr, commandExitCode(waitErr), adapterReport, reportErr, changes)
}()
result := process.collectResult(ctx, clampInitialProcessWait(wait), false)
if ctx != nil && ctx.Err() != nil && !result.Exited {
process.terminate()
more := process.collectResult(context.Background(), time.Second, true)
var mergeTruncated bool
result.Output, mergeTruncated = appendBoundedProcessOutputString(result.Output, more.Output)
result.OutputTruncated = result.OutputTruncated || more.OutputTruncated
result.OutputTruncated = result.OutputTruncated || mergeTruncated
result.Exited = more.Exited
result.ExitCode = more.ExitCode
result.Interrupted = result.Interrupted || more.Interrupted
result.Report = more.Report
result.ReportErr = more.ReportErr
result.Changes = more.Changes
}
if result.Exited {
manager.Remove(process.id)
}
return result, nil
}
func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContinue) (ProcessResult, error) {
process, ok := manager.get(input.ProcessID)
if !ok {
return ProcessResult{}, ErrProcessNotFound
}
process.touch()
if input.Interrupt {
process.terminate()
} else if len(input.Input) > 0 {
if !process.tty || process.stdin == nil {
return ProcessResult{}, ErrProcessStdinDisabled
}
if _, err := process.stdin.Write(input.Input); err != nil && !process.doneClosed() {
return ProcessResult{}, err
}
}
result := process.collectResult(ctx, clampContinuationWait(input.Wait, len(input.Input) == 0), input.Interrupt)
if result.Exited {
manager.Remove(process.id)
}
return result, nil
}
func clampInitialProcessWait(wait time.Duration) time.Duration {
return min(wait, maxInteractiveYield)
}
func clampContinuationWait(wait time.Duration, emptyPoll bool) time.Duration {
if emptyPoll {
return min(wait, maxEmptyPollYield)
}
return min(wait, maxInteractiveYield)
}
func (manager *ProcessManager) List() []ProcessSnapshot {
manager.mu.Lock()
processes := make([]*managedProcess, 0, len(manager.processes))
for _, process := range manager.processes {
if !process.doneClosed() {
processes = append(processes, process)
}
}
manager.mu.Unlock()
snapshots := make([]ProcessSnapshot, 0, len(processes))
for _, process := range processes {
snapshots = append(snapshots, process.snapshot())
}
sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].ID < snapshots[j].ID })
return snapshots
}
func (manager *ProcessManager) Snapshot(id int) (ProcessSnapshot, bool) {
process, ok := manager.get(id)
if !ok {
return ProcessSnapshot{}, false
}
return process.snapshot(), true
}
func (manager *ProcessManager) Stop(id int) bool {
process, ok := manager.get(id)
if !ok {
return false
}
process.terminate()
return true
}
func (manager *ProcessManager) StopAll() []int {
manager.mu.Lock()
processes := make([]*managedProcess, 0, len(manager.processes))
for _, process := range manager.processes {
if !process.doneClosed() {
processes = append(processes, process)
}
}
manager.mu.Unlock()
ids := make([]int, 0, len(processes))
for _, process := range processes {
process.terminate()
ids = append(ids, process.id)
}
waitForProcesses(processes, processStopTimeout)
sort.Ints(ids)
return ids
}
func (manager *ProcessManager) Remove(id int) {
manager.mu.Lock()
delete(manager.processes, id)
manager.mu.Unlock()
}
func (manager *ProcessManager) Len() int {
manager.mu.Lock()
defer manager.mu.Unlock()
return len(manager.processes)
}
func (manager *ProcessManager) allocateID() int {
manager.mu.Lock()
defer manager.mu.Unlock()
id := manager.nextID
manager.nextID++
return id
}
func (manager *ProcessManager) get(id int) (*managedProcess, bool) {
manager.mu.Lock()
defer manager.mu.Unlock()
process, ok := manager.processes[id]
return process, ok
}
func (manager *ProcessManager) store(process *managedProcess) {
manager.mu.Lock()
var evicted *managedProcess
if manager.maxProcesses > 0 && len(manager.processes) >= manager.maxProcesses {
evicted = manager.processToPruneLocked()
if evicted != nil && evicted.doneClosed() {
delete(manager.processes, evicted.id)
evicted = nil
}
}
manager.processes[process.id] = process
manager.mu.Unlock()
if evicted != nil {
_, _ = evicted.output.Write([]byte("[zero] session evicted: too many background terminals\n"))
evicted.terminate()
}
}
func (manager *ProcessManager) processToPruneLocked() *managedProcess {
var processes []*managedProcess
for _, process := range manager.processes {
processes = append(processes, process)
}
sort.Slice(processes, func(i, j int) bool { return processes[i].lastUsed().Before(processes[j].lastUsed()) })
for _, process := range processes {
if process.doneClosed() {
return process
}
}
if len(processes) > 8 {
return processes[0]
}
return nil
}
func (manager *ProcessManager) removeCompletedLater(process *managedProcess) {
go func() {
<-process.done
if manager.completedRetention > 0 {
timer := time.NewTimer(manager.completedRetention)
<-timer.C
}
manager.Remove(process.id)
}()
}
type managedProcess struct {
id int
commandText string
cwd string
relativeCwd string
startedAt time.Time
lastUsedAt time.Time
tty bool
command *exec.Cmd
request Request
enforcement Enforcement
report func() (AdapterReport, error)
cleanup func()
stdin io.WriteCloser
output *processOutputBuffer
reaped chan struct{}
doneOnce sync.Once
done chan struct{}
kill func(int) error
mu sync.Mutex
exitCode *int
waitErr error
resultReport AdapterReport
reportErr error
changes []Change
metadata map[string]string
}
func (process *managedProcess) markDone(err error, exitCode int, report AdapterReport, reportErr error, changes []Change) {
process.mu.Lock()
process.waitErr = err
process.exitCode = &exitCode
process.resultReport = report
process.reportErr = reportErr
process.changes = append([]Change(nil), changes...)
process.mu.Unlock()
process.doneOnce.Do(func() { close(process.done) })
}
func (process *managedProcess) collectResult(ctx context.Context, wait time.Duration, interrupted bool) ProcessResult {
output, truncated := process.collect(ctx, wait)
process.mu.Lock()
exitCode := 0
exited := process.exitCode != nil
if exited {
exitCode = *process.exitCode
}
result := ProcessResult{
ProcessID: process.id, CommandText: process.commandText, RelativeCwd: process.relativeCwd,
TTY: process.tty, Output: output, OutputTruncated: truncated, Exited: exited,
ExitCode: exitCode, Interrupted: interrupted, Request: process.request,
Enforcement: process.enforcement, Report: process.resultReport, ReportErr: process.reportErr,
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
}
process.mu.Unlock()
return result
}
func (process *managedProcess) collect(ctx context.Context, wait time.Duration) (string, bool) {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(wait)
var output []byte
truncated := false
finish := func() (string, bool) {
truncated = truncated || process.output.consumeTruncated()
return string(output), truncated
}
for {
if chunk := process.output.drain(); len(chunk) > 0 {
var chunkTruncated bool
output, chunkTruncated = appendBoundedProcessOutput(output, chunk)
truncated = truncated || chunkTruncated
truncated = truncated || process.output.consumeTruncated()
if time.Now().After(deadline) {
return finish()
}
continue
}
if process.doneClosed() {
var chunkTruncated bool
output, chunkTruncated = appendBoundedProcessOutput(output, process.output.drain())
truncated = truncated || chunkTruncated
return finish()
}
remaining := time.Until(deadline)
if remaining <= 0 {
return finish()
}
timer := time.NewTimer(remaining)
select {
case <-process.output.notify:
case <-process.done:
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return finish()
case <-timer.C:
return finish()
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
func appendBoundedProcessOutput(output []byte, chunk []byte) ([]byte, bool) {
if len(chunk) == 0 {
return output, false
}
if len(chunk) >= maxPendingOutputBytes {
bounded := make([]byte, maxPendingOutputBytes)
copy(bounded, chunk[len(chunk)-maxPendingOutputBytes:])
return bounded, len(output) > 0 || len(chunk) > maxPendingOutputBytes
}
if len(output)+len(chunk) <= maxPendingOutputBytes {
return append(output, chunk...), false
}
drop := len(output) + len(chunk) - maxPendingOutputBytes
copy(output, output[drop:])
output = output[:len(output)-drop]
return append(output, chunk...), true
}
func appendBoundedProcessOutputString(output string, chunk string) (string, bool) {
bounded, truncated := appendBoundedProcessOutput([]byte(output), []byte(chunk))
return string(bounded), truncated
}
func (process *managedProcess) doneClosed() bool {
select {
case <-process.done:
return true
default:
return false
}
}
func (process *managedProcess) touch() {
process.mu.Lock()
process.lastUsedAt = time.Now()
process.mu.Unlock()
}
func (process *managedProcess) lastUsed() time.Time {
process.mu.Lock()
defer process.mu.Unlock()
return process.lastUsedAt
}
func (process *managedProcess) terminate() {
if process.reapedClosed() || process.command.Process == nil {
return
}
kill := process.kill
if kill == nil {
kill = KillProcessTree
}
_ = kill(process.command.Process.Pid)
}
func (process *managedProcess) reapedClosed() bool {
if process.reaped == nil {
return false
}
select {
case <-process.reaped:
return true
default:
return false
}
}
func (process *managedProcess) snapshot() ProcessSnapshot {
process.mu.Lock()
defer process.mu.Unlock()
var exit *int
if process.exitCode != nil {
value := *process.exitCode
exit = &value
}
status := "running"
if exit != nil {
status = "exited"
}
return ProcessSnapshot{ID: process.id, Command: process.commandText, Cwd: process.cwd, RelativeCwd: process.relativeCwd,
StartedAt: process.startedAt, LastUsedAt: process.lastUsedAt, TTY: process.tty, Status: status,
ExitCode: exit, RecentOutput: process.output.recentString(), OutputTruncated: process.output.peekTruncated()}
}
func waitForProcesses(processes []*managedProcess, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for _, process := range processes {
remaining := time.Until(deadline)
if remaining <= 0 {
return
}
timer := time.NewTimer(remaining)
select {
case <-process.done:
case <-timer.C:
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
type processOutputBuffer struct {
mu sync.Mutex
data []byte
recent []byte
truncated bool
notify chan struct{}
}
func newProcessOutputBuffer() *processOutputBuffer {
return &processOutputBuffer{notify: make(chan struct{}, 1)}
}
func (buffer *processOutputBuffer) Write(data []byte) (int, error) {
buffer.mu.Lock()
buffer.data = append(buffer.data, data...)
if len(buffer.data) > maxPendingOutputBytes {
buffer.data = buffer.data[len(buffer.data)-maxPendingOutputBytes:]
buffer.truncated = true
}
buffer.recent = append(buffer.recent, data...)
if len(buffer.recent) > recentOutputBytes {
buffer.recent = buffer.recent[len(buffer.recent)-recentOutputBytes:]
}
buffer.mu.Unlock()
select {
case buffer.notify <- struct{}{}:
default:
}
return len(data), nil
}
func (buffer *processOutputBuffer) drain() []byte {
buffer.mu.Lock()
defer buffer.mu.Unlock()
data := append([]byte(nil), buffer.data...)
buffer.data = nil
return data
}
func (buffer *processOutputBuffer) recentString() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return string(buffer.recent)
}
func (buffer *processOutputBuffer) consumeTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
value := buffer.truncated
buffer.truncated = false
return value
}
func (buffer *processOutputBuffer) peekTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
func cloneStringMap(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
output := make(map[string]string, len(input))
for key, value := range input {
output[key] = value
}
return output
}
-275
View File
@@ -1,275 +0,0 @@
package execution
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
func processManagerRequest(root string, command *exec.Cmd) Request {
return Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: command.Path, Args: command.Args[1:]},
WorkingDirectory: root, WorkspaceRoots: []string{root},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
}
func TestProcessManagerRetainsAndContinuesWithStableIdentity(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{CompletedRetention: time.Second})
command := exec.Command("/bin/sh", "-c", "printf first; sleep 0.05; printf second")
request := Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: "/bin/sh", Args: []string{"-c", "printf first; sleep 0.05; printf second"}},
WorkingDirectory: root, WorkspaceRoots: []string{root},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
started, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command}, Request: request, CommandText: "test",
}, time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
if started.Exited || started.ProcessID < 1000 {
t.Fatalf("initial result = %#v, want retained process", started)
}
continued, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Wait: time.Second})
if err != nil {
t.Fatalf("Continue: %v", err)
}
if !continued.Exited || continued.ProcessID != started.ProcessID {
t.Fatalf("continued result = %#v, want same completed process", continued)
}
combined := started.Output + continued.Output
if strings.Count(combined, "first") != 1 || strings.Count(combined, "second") != 1 {
t.Fatalf("output was lost or duplicated: %q", combined)
}
if manager.Len() != 0 {
t.Fatalf("completed process still retained: %d", manager.Len())
}
}
func TestProcessManagerInterruptsRetainedProcess(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.Command("/bin/sh", "-c", "sleep 30")
request := Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: "/bin/sh", Args: []string{"-c", "sleep 30"}},
WorkingDirectory: root, WorkspaceRoots: []string{root},
}
started, err := manager.Start(context.Background(), ProcessStart{Prepared: PreparedCommand{Command: command}, Request: request}, time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
stopped, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Interrupt: true, Wait: time.Second})
if err != nil {
t.Fatalf("Continue interrupt: %v", err)
}
if !stopped.Exited || !stopped.Interrupted {
t.Fatalf("interrupt result = %#v", stopped)
}
}
func TestManagedProcessTerminateSkipsReapedProcess(t *testing.T) {
reaped := make(chan struct{})
close(reaped)
called := false
process := &managedProcess{
command: &exec.Cmd{Process: &os.Process{Pid: 4242}},
reaped: reaped,
kill: func(int) error { called = true; return nil },
}
process.terminate()
if called {
t.Fatal("terminate signaled an already-reaped process")
}
}
func TestProcessManagerCancellationReportsInterrupted(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.Command("/bin/sh", "-c", "sleep 30")
ctx, cancel := context.WithCancel(context.Background())
cancel()
result, err := manager.Start(ctx, ProcessStart{
Prepared: PreparedCommand{Command: command},
Request: processManagerRequest(root, command),
}, time.Second)
if err != nil {
t.Fatalf("Start: %v", err)
}
if !result.Exited || !result.Interrupted {
t.Fatalf("cancelled result = %#v, want exited and interrupted", result)
}
}
func TestProcessManagerObservesChangesMadeDuringTransportStart(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
target := filepath.Join(root, "created.txt")
manager := NewProcessManager(ProcessManagerOptions{})
manager.startTransport = func(command *exec.Cmd, output io.Writer, tty bool) (io.WriteCloser, bool, func(), error) {
if err := os.WriteFile(target, []byte("created"), 0o600); err != nil {
return nil, false, nil, err
}
return startProcessTransport(command, output, tty)
}
command := exec.Command("/bin/sh", "-c", "true")
result, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command},
Request: processManagerRequest(root, command),
}, time.Second)
if err != nil {
t.Fatalf("Start: %v", err)
}
if len(result.Changes) != 1 || result.Changes[0].Path != "created.txt" || result.Changes[0].Kind != ChangeCreated {
t.Fatalf("changes = %#v, want created.txt created", result.Changes)
}
}
func TestProcessWaitsClampToEffectiveBounds(t *testing.T) {
if got := clampInitialProcessWait(time.Minute); got != maxInteractiveYield {
t.Fatalf("initial upper clamp = %v, want %v", got, maxInteractiveYield)
}
if got := clampInitialProcessWait(time.Millisecond); got != time.Millisecond {
t.Fatalf("initial short wait = %v, want caller value", got)
}
if got := clampContinuationWait(time.Hour, true); got != maxEmptyPollYield {
t.Fatalf("empty poll upper clamp = %v, want %v", got, maxEmptyPollYield)
}
if got := clampContinuationWait(time.Hour, false); got != maxInteractiveYield {
t.Fatalf("stdin upper clamp = %v, want %v", got, maxInteractiveYield)
}
}
func TestProcessOutputBufferCapsUndrainedData(t *testing.T) {
buffer := newProcessOutputBuffer()
chunk := []byte(strings.Repeat("x", 1024))
for i := 0; i < maxPendingOutputBytes/len(chunk)+10; i++ {
if _, err := buffer.Write(chunk); err != nil {
t.Fatalf("Write: %v", err)
}
}
buffer.mu.Lock()
dataLen := len(buffer.data)
buffer.mu.Unlock()
if dataLen > maxPendingOutputBytes {
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxPendingOutputBytes)
}
if got := buffer.drain(); !strings.HasSuffix(string(got), string(chunk)) {
t.Fatal("drained output should retain the newest bytes")
}
if !buffer.peekTruncated() || !buffer.consumeTruncated() || buffer.consumeTruncated() {
t.Fatal("truncation state was not preserved and consumed exactly once")
}
}
func TestManagedProcessCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
stop := make(chan struct{})
var writers sync.WaitGroup
for i := 0; i < 8; i++ {
writers.Add(1)
go func() {
defer writers.Done()
chunk := []byte(strings.Repeat("x", 256))
for {
select {
case <-stop:
return
default:
_, _ = process.output.Write(chunk)
}
}
}()
}
t.Cleanup(func() { close(stop); writers.Wait() })
wait := 200 * time.Millisecond
started := time.Now()
_, _ = process.collect(context.Background(), wait)
if elapsed := time.Since(started); elapsed > 5*wait {
t.Fatalf("collect took %v under continuous output, want close to %v", elapsed, wait)
}
}
func TestManagedProcessCollectCapsCumulativeOutput(t *testing.T) {
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
chunk := []byte(strings.Repeat("x", maxPendingOutputBytes/2))
go func() {
for i := 0; i < 5; i++ {
_, _ = process.output.Write(chunk)
for {
process.output.mu.Lock()
drained := len(process.output.data) == 0
process.output.mu.Unlock()
if drained {
break
}
runtime.Gosched()
}
}
close(process.done)
}()
output, truncated := process.collect(context.Background(), time.Second)
if len(output) > maxPendingOutputBytes {
t.Fatalf("collected %d bytes, want <= %d", len(output), maxPendingOutputBytes)
}
if !truncated {
t.Fatal("cumulative output cap must report truncation")
}
}
func TestProcessPruningDoesNotRaceTouch(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
for id := 1000; id < 1012; id++ {
manager.processes[id] = &managedProcess{id: id, lastUsedAt: time.Now(), done: make(chan struct{})}
}
target := manager.processes[1000]
stop := make(chan struct{})
var writer sync.WaitGroup
writer.Add(1)
go func() {
defer writer.Done()
for {
select {
case <-stop:
return
default:
target.touch()
}
}
}()
for i := 0; i < 2000; i++ {
manager.mu.Lock()
_ = manager.processToPruneLocked()
manager.mu.Unlock()
}
close(stop)
writer.Wait()
}
-100
View File
@@ -1,100 +0,0 @@
//go:build !windows
package execution
import (
"errors"
"fmt"
"os/exec"
"syscall"
"time"
)
// ConfigureProcessGroup makes cmd the leader of a process group so lifecycle
// operations cover descendants instead of orphaning them.
func ConfigureProcessGroup(cmd *exec.Cmd) {
if cmd == nil {
return
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
// KillProcessTree immediately kills pid and, when it is a group leader, its
// descendant process group.
func KillProcessTree(pid int) error {
target, err := processSignalTarget(pid)
if err != nil {
return err
}
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
return nil
}
// TerminateProcessTree requests graceful termination, then force-kills the
// process tree after grace. Callers retain their distinct persistence models;
// this function owns only the OS lifecycle primitive.
func TerminateProcessTree(pid int, grace, poll time.Duration) error {
target, err := processSignalTarget(pid)
if err != nil {
return err
}
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
if poll <= 0 {
poll = 50 * time.Millisecond
}
if grace < 0 {
grace = 0
}
deadline := time.Now().Add(grace)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(poll)
}
if !alive() {
return nil
}
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
deadline = time.Now().Add(grace)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(poll)
}
if alive() {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
}
return nil
}
func processSignalTarget(pid int) (int, error) {
if pid <= 1 {
return 0, fmt.Errorf("refusing to signal invalid pid %d", pid)
}
target := pid
if pgid, err := syscall.Getpgid(pid); err == nil {
if pgid == pid {
target = -pid
}
} else if errors.Is(err, syscall.ESRCH) {
// Preserve the individual target; the signal call below treats ESRCH as
// already gone, which is a successful lifecycle outcome.
return pid, nil
}
return target, nil
}
-37
View File
@@ -1,37 +0,0 @@
//go:build windows
package execution
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
)
func ConfigureProcessGroup(cmd *exec.Cmd) {}
func KillProcessTree(pid int) error {
if err := exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
process, err := os.FindProcess(pid)
if err != nil {
return err
}
return process.Kill()
}
func TerminateProcessTree(pid int, _, _ time.Duration) error { return KillProcessTree(pid) }
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
}
-13
View File
@@ -1,13 +0,0 @@
//go:build !linux
package execution
import (
"errors"
"io"
"os/exec"
)
func startPTYProcess(_ *exec.Cmd, _ io.Writer) (io.WriteCloser, func(), error) {
return nil, nil, errors.New("pty transport is unavailable on this platform")
}
-25
View File
@@ -1,25 +0,0 @@
//go:build linux
package execution
import (
"os/exec"
"syscall"
"testing"
)
func TestPTYFallbackRestoresOriginalProcessAttributes(t *testing.T) {
original := &syscall.SysProcAttr{Setpgid: true}
command := exec.Command("/bin/true")
command.SysProcAttr = original
saved := cloneSysProcAttr(command.SysProcAttr)
hardenPTYProcess(command)
resetCommandAfterPTYFallback(command, saved)
if command.SysProcAttr == nil || !command.SysProcAttr.Setpgid {
t.Fatalf("fallback attributes = %#v, want original Setpgid", command.SysProcAttr)
}
if command.SysProcAttr.Setsid || command.SysProcAttr.Setctty {
t.Fatalf("fallback retained PTY attributes: %#v", command.SysProcAttr)
}
}
-221
View File
@@ -1,221 +0,0 @@
package execution
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
"sync"
)
const maxCapturedStreamBytes = 1 << 20
// Preparer is the platform-adapter seam. It turns a platform-neutral request
// into one enforceable OS command while keeping native sandbox mechanics out of
// every caller.
type Preparer interface {
PrepareExecution(context.Context, Request) (PreparedCommand, error)
}
type PreparedCommand struct {
Command *exec.Cmd
Enforcement Enforcement
Report func() (AdapterReport, error)
Cleanup func()
}
type CapturedRequest struct {
Request Request
Stdin []byte
}
type CapturedResult struct {
Stdout string
Stderr string
Truncated bool
Outcome Outcome
Err error
}
// Runner is the deep execution module used by Zero-owned subprocess launchers.
// The preparer may be installed after tool registration, but execution fails
// closed until an adapter is present.
type Runner struct {
mu sync.RWMutex
preparer Preparer
}
func NewRunner(preparer Preparer) *Runner {
return &Runner{preparer: preparer}
}
func (runner *Runner) SetPreparer(preparer Preparer) {
if runner == nil {
return
}
runner.mu.Lock()
runner.preparer = preparer
runner.mu.Unlock()
}
func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest) CapturedResult {
if ctx == nil {
ctx = context.Background()
}
if err := input.Request.Validate(); err != nil {
return capturedSetupFailure("invalid execution request: "+err.Error(), err, Enforcement{})
}
if input.Request.Mode != ModeCaptured {
err := fmt.Errorf("captured execution requires mode %q", ModeCaptured)
return capturedSetupFailure(err.Error(), err, Enforcement{})
}
prepared, err := runner.Prepare(ctx, input.Request)
if err != nil {
return capturedSetupFailure("prepare execution: "+err.Error(), err, prepared.Enforcement)
}
if prepared.Cleanup != nil {
defer prepared.Cleanup()
}
if prepared.Command == nil {
err := errors.New("execution adapter returned no command")
return capturedSetupFailure(err.Error(), err, prepared.Enforcement)
}
if len(input.Stdin) > 0 {
prepared.Command.Stdin = bytes.NewReader(input.Stdin)
}
stdout := &capturedBuffer{limit: maxCapturedStreamBytes}
stderr := &capturedBuffer{limit: maxCapturedStreamBytes}
prepared.Command.Stdout = stdout
prepared.Command.Stderr = stderr
runErr := prepared.Command.Run()
report, reportErr := AdapterReport{}, error(nil)
if prepared.Report != nil {
report, reportErr = prepared.Report()
}
result := CapturedResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
Truncated: stdout.Truncated() || stderr.Truncated(),
Err: runErr,
Outcome: Outcome{
Enforcement: prepared.Enforcement,
},
}
exitCode := commandExitCode(runErr)
result.Outcome.Exit = &Exit{Code: exitCode}
switch {
case reportErr != nil:
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeSandboxSetupFailure
result.Err = reportErr
case report.Denial != nil:
denial := *report.Denial
result.Outcome.State = StateDenied
result.Outcome.Kind = OutcomeEnforcementDenied
result.Outcome.Denial = &denial
case errors.Is(ctx.Err(), context.DeadlineExceeded):
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeTimedOut
case errors.Is(ctx.Err(), context.Canceled):
result.Outcome.State = StateCancelled
result.Outcome.Kind = OutcomeCancelled
case runErr == nil:
result.Outcome.State = StateCompleted
result.Outcome.Kind = OutcomeSuccess
case executableNotFound(runErr):
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeExecutableNotFound
default:
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeApplicationFailure
}
return result
}
// Prepare evaluates and prepares a typed request for callers that require
// streaming pipes or protocol-specific lifecycle handling, such as stdio MCP.
// The returned cleanup remains mandatory; ordinary captured commands should use
// ExecuteCaptured so the Runner owns it automatically.
func (runner *Runner) Prepare(ctx context.Context, request Request) (PreparedCommand, error) {
if runner == nil {
return PreparedCommand{}, errors.New("execution runner is not configured")
}
if ctx == nil {
ctx = context.Background()
}
if err := request.Validate(); err != nil {
return PreparedCommand{}, err
}
runner.mu.RLock()
preparer := runner.preparer
runner.mu.RUnlock()
if preparer == nil {
return PreparedCommand{}, errors.New("execution adapter is not configured")
}
return preparer.PrepareExecution(ctx, request)
}
func capturedSetupFailure(message string, err error, enforcement Enforcement) CapturedResult {
return CapturedResult{
Stderr: message,
Err: err,
Outcome: Outcome{
State: StateFailed,
Kind: OutcomeSandboxSetupFailure,
Exit: &Exit{Code: -1},
Enforcement: enforcement,
},
}
}
func commandExitCode(err error) int {
if err == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode()
}
return -1
}
func executableNotFound(err error) bool {
var execErr *exec.Error
return errors.As(err, &execErr)
}
type capturedBuffer struct {
mu sync.Mutex
buffer bytes.Buffer
limit int
truncated bool
}
func (buffer *capturedBuffer) Write(data []byte) (int, error) {
buffer.mu.Lock()
defer buffer.mu.Unlock()
remaining := buffer.limit - buffer.buffer.Len()
if remaining > 0 {
_, _ = buffer.buffer.Write(data[:min(len(data), remaining)])
}
if len(data) > remaining {
buffer.truncated = true
}
return len(data), nil
}
func (buffer *capturedBuffer) String() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.buffer.String()
}
func (buffer *capturedBuffer) Truncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
var _ io.Writer = (*capturedBuffer)(nil)
-77
View File
@@ -1,77 +0,0 @@
package execution
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
)
type capturedTestPreparer struct {
request Request
}
func (preparer *capturedTestPreparer) PrepareExecution(_ context.Context, request Request) (PreparedCommand, error) {
preparer.request = request
command := exec.Command(os.Args[0], "-test.run=^TestCapturedRunnerHelperProcess$")
command.Env = append(os.Environ(), "ZERO_CAPTURED_RUNNER_HELPER=1")
return PreparedCommand{
Command: command,
Enforcement: Enforcement{
Backend: "test-adapter",
Level: "native",
},
}, nil
}
func TestCapturedRunnerHelperProcess(t *testing.T) {
if os.Getenv("ZERO_CAPTURED_RUNNER_HELPER") != "1" {
return
}
fmt.Fprint(os.Stdout, "stdout")
fmt.Fprint(os.Stderr, "stderr")
os.Exit(7)
}
func TestRunnerExecutesCapturedRequestThroughAdapter(t *testing.T) {
preparer := &capturedTestPreparer{}
runner := NewRunner(preparer)
request := Request{
Origin: OriginHook,
Mode: ModeCaptured,
Command: Command{Name: "ignored-by-test-adapter"},
WorkingDirectory: t.TempDir(),
WorkspaceRoots: []string{t.TempDir()},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
result := runner.ExecuteCaptured(context.Background(), CapturedRequest{Request: request})
if preparer.request.Origin != OriginHook {
t.Fatalf("adapter origin = %q, want hook", preparer.request.Origin)
}
if result.Stdout != "stdout" || result.Stderr != "stderr" {
t.Fatalf("captured output = stdout %q stderr %q", result.Stdout, result.Stderr)
}
if result.Outcome.Kind != OutcomeApplicationFailure || result.Outcome.Exit == nil || result.Outcome.Exit.Code != 7 {
t.Fatalf("outcome = %#v, want application failure exit 7", result.Outcome)
}
if result.Outcome.Enforcement.Backend != "test-adapter" {
t.Fatalf("enforcement = %#v", result.Outcome.Enforcement)
}
}
func TestRunnerWithoutAdapterFailsClosed(t *testing.T) {
runner := NewRunner(nil)
result := runner.ExecuteCaptured(context.Background(), CapturedRequest{Request: Request{
Origin: OriginPlugin,
Mode: ModeCaptured,
Command: Command{Name: "true"},
WorkingDirectory: t.TempDir(),
WorkspaceRoots: []string{t.TempDir()},
}})
if result.Outcome.Kind != OutcomeSandboxSetupFailure || !strings.Contains(result.Stderr, "adapter") {
t.Fatalf("result = %#v, want fail-closed missing-adapter result", result)
}
}
-51
View File
@@ -1,51 +0,0 @@
package execution
import (
"io"
"os/exec"
"syscall"
"time"
)
const processWaitDelay = 2 * time.Second
type processTransportStarter func(*exec.Cmd, io.Writer, bool) (io.WriteCloser, bool, func(), error)
func startProcessTransport(command *exec.Cmd, output io.Writer, ttyRequested bool) (io.WriteCloser, bool, func(), error) {
if ttyRequested {
original := cloneSysProcAttr(command.SysProcAttr)
if stdin, cleanup, err := startPTYProcess(command, output); err == nil {
return stdin, true, cleanup, nil
}
resetCommandAfterPTYFallback(command, original)
}
stdin, err := command.StdinPipe()
if err != nil {
return nil, false, nil, err
}
command.Stdout = output
command.Stderr = output
ConfigureProcessGroup(command)
command.WaitDelay = processWaitDelay
if err := command.Start(); err != nil {
return nil, false, nil, err
}
return stdin, false, func() {}, nil
}
func cloneSysProcAttr(attributes *syscall.SysProcAttr) *syscall.SysProcAttr {
if attributes == nil {
return nil
}
cloned := *attributes
return &cloned
}
func resetCommandAfterPTYFallback(command *exec.Cmd, original *syscall.SysProcAttr) {
command.Stdin = nil
command.Stdout = nil
command.Stderr = nil
command.SysProcAttr = original
command.Cancel = nil
command.WaitDelay = 0
}
+8 -51
View File
@@ -9,8 +9,6 @@ import (
"os/exec"
"strings"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
// defaultHookTimeout bounds a single hook command so a hung or slow hook cannot
@@ -53,14 +51,13 @@ type commandRunner func(ctx context.Context, command string, args []string, stdi
// DispatcherOptions configures a Dispatcher.
type DispatcherOptions struct {
Config Config
Audit *AuditStore // optional; when set, every run is recorded
Cwd string // working directory for hook commands
Env []string // extra environment entries appended to the process env
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
Execution *execution.Runner // routes hook subprocesses through sandbox policy
now func() time.Time
run commandRunner
Config Config
Audit *AuditStore // optional; when set, every run is recorded
Cwd string // working directory for hook commands
Env []string // extra environment entries appended to the process env
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
now func() time.Time
run commandRunner
}
// Dispatcher selects and runs the hooks configured for a lifecycle event,
@@ -90,11 +87,7 @@ func NewDispatcher(options DispatcherOptions) *Dispatcher {
}
run := options.run
if run == nil {
if options.Execution != nil {
run = executionCommandRunner(options.Execution)
} else {
run = execCommandRunner
}
run = execCommandRunner
}
return &Dispatcher{
config: options.Config,
@@ -107,42 +100,6 @@ func NewDispatcher(options DispatcherOptions) *Dispatcher {
}
}
func executionCommandRunner(runner *execution.Runner) commandRunner {
return func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
result := runner.ExecuteCaptured(ctx, execution.CapturedRequest{
Request: execution.Request{
Origin: execution.OriginHook,
Mode: execution.ModeCaptured,
Command: execution.Command{Name: command, Args: append([]string(nil), args...), Env: append([]string(nil), env...)},
WorkingDirectory: cwd,
WorkspaceRoots: []string{cwd},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
},
Stdin: stdin,
})
exitCode := -1
if result.Outcome.Exit != nil {
exitCode = result.Outcome.Exit.Code
}
stderr := result.Stderr
if stderr == "" && result.Outcome.Denial != nil {
stderr = result.Outcome.Denial.Reason
}
commandErr := error(nil)
switch result.Outcome.Kind {
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound:
commandErr = result.Err
}
return commandResult{
ExitCode: exitCode,
Stdout: result.Stdout,
Stderr: stderr,
Err: commandErr,
TimedOut: result.Outcome.Kind == execution.OutcomeTimedOut,
}
}
}
// blocksOn reports whether a non-zero exit for this event should veto the action.
// Only beforeTool gates the tool; other events are observational.
func blocksOn(event Event) bool {
-31
View File
@@ -2,25 +2,13 @@ package hooks
import (
"context"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
type hookExecutionPreparer struct {
request execution.Request
}
func (preparer *hookExecutionPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
return execution.PreparedCommand{Command: exec.Command(request.Command.Name, request.Command.Args...)}, nil
}
func beforeToolConfig(hooks ...Definition) Config {
return Config{Enabled: true, Hooks: hooks}
}
@@ -198,25 +186,6 @@ func TestExecCommandRunnerCapturesExitAndStdin(t *testing.T) {
}
}
func TestDispatcherRoutesHookThroughTypedExecutionOrigin(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses /bin/sh")
}
preparer := &hookExecutionPreparer{}
dispatcher := NewDispatcher(DispatcherOptions{
Config: beforeToolConfig(Definition{ID: "typed", Event: EventBeforeTool, Command: "/bin/sh", Args: []string{"-c", "cat"}, Enabled: true}),
Cwd: t.TempDir(),
Execution: execution.NewRunner(preparer),
})
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", Payload: map[string]any{"ok": true}})
if outcome.Blocked || outcome.Ran != 1 {
t.Fatalf("dispatch outcome = %#v", outcome)
}
if preparer.request.Origin != execution.OriginHook || preparer.request.Mode != execution.ModeCaptured {
t.Fatalf("execution request = %#v", preparer.request)
}
}
func TestExecCommandRunnerReportsLaunchFailureFailsClosedForBeforeTool(t *testing.T) {
result := execCommandRunner(context.Background(), "definitely-not-a-real-binary-zzz", nil, nil, t.TempDir(), nil)
if result.Err == nil {
-147
View File
@@ -1,147 +0,0 @@
// Package installtxn provides the cross-process filesystem transaction used by
// plugin and skill installation. Callers stage content before taking the lock,
// then commit the content swap and lockfile update together while holding it.
package installtxn
import (
"errors"
"fmt"
"os"
"path/filepath"
)
const lockFileName = ".zero-install.lock"
// Lock takes the per-install-root cross-process lock. It blocks until any other
// installer or remover using dir has completed.
func Lock(dir string) (func(), error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create install dir: %w", err)
}
return lockFile(filepath.Join(dir, lockFileName))
}
// StageDir creates an install workspace on the target filesystem. Content must
// be built and validated in the returned stage directory before CommitDir is
// called. cleanup is always safe to call.
func StageDir(dir string) (stage string, cleanup func(), err error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", func() {}, fmt.Errorf("create install dir: %w", err)
}
workspace, err := os.MkdirTemp(dir, ".zero-install-txn-")
if err != nil {
return "", func() {}, fmt.Errorf("create install staging dir: %w", err)
}
return filepath.Join(workspace, "staged"), func() { cleanupWorkspace(workspace) }, nil
}
// CommitDir replaces target with staged and runs publish while retaining the
// previous target. If either the swap or publish fails, the previous target is
// restored (or the new target is removed for a first install).
//
// The caller must hold the install-root lock returned by Lock.
func CommitDir(target string, staged string, publish func() error) error {
workspace := filepath.Dir(staged)
backup := filepath.Join(workspace, "previous")
hadPrevious := false
if _, err := os.Stat(target); err == nil {
if err := os.Rename(target, backup); err != nil {
return fmt.Errorf("retain previous install: %w", err)
}
hadPrevious = true
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect previous install: %w", err)
}
if err := os.Rename(staged, target); err != nil {
if hadPrevious {
if restoreErr := os.Rename(backup, target); restoreErr != nil {
return errors.Join(fmt.Errorf("publish staged install: %w", err), fmt.Errorf("restore previous install: %w", restoreErr))
}
}
return fmt.Errorf("publish staged install: %w", err)
}
if err := publish(); err != nil {
return rollback(target, backup, hadPrevious, err)
}
if hadPrevious {
_ = os.RemoveAll(backup)
}
cleanupWorkspace(workspace)
return nil
}
// RemoveDir removes target and runs publish while retaining the target until
// publish succeeds. A publish failure restores the directory.
//
// The caller must hold the install-root lock returned by Lock.
func RemoveDir(target string, publish func() error) error {
workspace, err := os.MkdirTemp(filepath.Dir(target), ".zero-install-txn-")
if err != nil {
return fmt.Errorf("create removal staging dir: %w", err)
}
defer cleanupWorkspace(workspace)
backup := filepath.Join(workspace, "previous")
if err := os.Rename(target, backup); err != nil {
return fmt.Errorf("retain removed install: %w", err)
}
if err := publish(); err != nil {
if restoreErr := os.Rename(backup, target); restoreErr != nil {
return errors.Join(err, fmt.Errorf("restore removed install: %w", restoreErr))
}
return err
}
_ = os.RemoveAll(backup)
return nil
}
func rollback(target string, backup string, hadPrevious bool, cause error) error {
if err := os.RemoveAll(target); err != nil {
return errors.Join(cause, fmt.Errorf("remove failed install: %w", err))
}
if hadPrevious {
if err := os.Rename(backup, target); err != nil {
return errors.Join(cause, fmt.Errorf("restore previous install: %w", err))
}
}
return cause
}
// cleanupWorkspace never removes a retained previous install. If rollback was
// unable to restore it (for example because Windows still has a target file
// open), preserving the workspace is safer than turning a recoverable error
// into data loss.
func cleanupWorkspace(workspace string) {
if _, err := os.Stat(filepath.Join(workspace, "previous")); err == nil {
return
}
_ = os.RemoveAll(workspace)
}
// WriteFileAtomically publishes data by renaming a complete sibling temporary
// file over path. The caller is responsible for any surrounding transaction
// lock.
func WriteFileAtomically(path string, data []byte, perm os.FileMode) error {
temp, err := os.CreateTemp(filepath.Dir(path), ".zero-lockfile-")
if err != nil {
return err
}
tempPath := temp.Name()
defer func() { _ = os.Remove(tempPath) }()
if err := temp.Chmod(perm); err != nil {
_ = temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return err
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
return replaceFile(tempPath, path)
}
-78
View File
@@ -1,78 +0,0 @@
package installtxn
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestCommitDirRestoresPreviousInstallWhenPublishFails(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "demo")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(target, "version"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
staged, cleanup, err := StageDir(root)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if err := os.MkdirAll(staged, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staged, "version"), []byte("new"), 0o644); err != nil {
t.Fatal(err)
}
publishErr := errors.New("publish failed")
err = CommitDir(target, staged, func() error { return publishErr })
if !errors.Is(err, publishErr) {
t.Fatalf("CommitDir error = %v, want publish failure", err)
}
data, err := os.ReadFile(filepath.Join(target, "version"))
if err != nil {
t.Fatalf("read restored install: %v", err)
}
if string(data) != "old" {
t.Fatalf("restored content = %q, want old", data)
}
}
func TestCommitDirRemovesFirstInstallWhenPublishFails(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "demo")
staged, cleanup, err := StageDir(root)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if err := os.MkdirAll(staged, 0o755); err != nil {
t.Fatal(err)
}
err = CommitDir(target, staged, func() error { return errors.New("publish failed") })
if err == nil {
t.Fatal("CommitDir unexpectedly succeeded")
}
if _, statErr := os.Stat(target); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("failed first install remains at target: %v", statErr)
}
}
func TestCleanupWorkspacePreservesRetainedPreviousInstall(t *testing.T) {
workspace := t.TempDir()
previous := filepath.Join(workspace, "previous")
if err := os.MkdirAll(previous, 0o755); err != nil {
t.Fatal(err)
}
cleanupWorkspace(workspace)
if _, err := os.Stat(previous); err != nil {
t.Fatalf("cleanup removed retained previous install: %v", err)
}
}
-29
View File
@@ -1,29 +0,0 @@
//go:build !windows
package installtxn
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
func lockFile(path string) (func(), error) {
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("open install lock: %w", err)
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil {
_ = file.Close()
return nil, fmt.Errorf("lock install root: %w", err)
}
return func() {
_ = unix.Flock(int(file.Fd()), unix.LOCK_UN)
_ = file.Close()
}, nil
}
func replaceFile(source string, target string) error {
return os.Rename(source, target)
}
-31
View File
@@ -1,31 +0,0 @@
//go:build windows
package installtxn
import (
"fmt"
"os"
"golang.org/x/sys/windows"
)
func lockFile(path string) (func(), error) {
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("open install lock: %w", err)
}
handle := windows.Handle(file.Fd())
overlapped := new(windows.Overlapped)
if err := windows.LockFileEx(handle, windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, overlapped); err != nil {
_ = file.Close()
return nil, fmt.Errorf("lock install root: %w", err)
}
return func() {
_ = windows.UnlockFileEx(handle, 0, 1, 0, overlapped)
_ = file.Close()
}, nil
}
func replaceFile(source string, target string) error {
return windows.MoveFileEx(windows.StringToUTF16Ptr(source), windows.StringToUTF16Ptr(target), windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH)
}
+2 -11
View File
@@ -11,18 +11,9 @@ import (
// TestManagerCheckRealGopls drives the exact path the self-correct LSP wiring
// uses (NewManager -> Check) against a REAL gopls and asserts a real diagnostic
// comes back. It is an OPT-IN integration check: an installed-but-unhealthy
// gopls (e.g. an unusable persistent-index cache) exits during initialization
// and would otherwise fail `go test ./...` for every developer who merely has
// gopls on PATH (issue #684). So it runs only when ZERO_GOPLS_INTEGRATION=1,
// matching the repo's other real-server opt-ins (internal/dictation smoke
// tests). Requiring exactly "1" (not merely non-empty) means an explicit
// ZERO_GOPLS_INTEGRATION=0 keeps the integration check OFF, as its value implies.
// The default suite keeps its coverage on the fake server.
// comes back. Skips when gopls is not installed. Temporary manual-verification
// test (the rest of the suite uses a fake server).
func TestManagerCheckRealGopls(t *testing.T) {
if os.Getenv("ZERO_GOPLS_INTEGRATION") != "1" {
t.Skip("set ZERO_GOPLS_INTEGRATION=1 to run the real gopls integration check")
}
if _, err := exec.LookPath("gopls"); err != nil {
t.Skip("gopls not on PATH; skipping real-server check")
}
+10 -56
View File
@@ -13,8 +13,6 @@ import (
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
type RemoteTool struct {
@@ -48,7 +46,6 @@ type Client struct {
mu sync.Mutex
closeMu sync.Mutex
nextID int
cleanup func()
// dispatchMu guards the response-dispatch state shared with the single
// reader goroutine. It is never held across a blocking read.
@@ -75,18 +72,9 @@ const (
)
func Connect(ctx context.Context, server Server) (ToolClient, error) {
return ConnectWithOptions(ctx, server, ConnectOptions{})
}
type ConnectOptions struct {
Execution *execution.Runner
WorkspaceRoot string
}
func ConnectWithOptions(ctx context.Context, server Server, options ConnectOptions) (ToolClient, error) {
switch server.Type {
case ServerTypeStdio:
return connectStdio(ctx, server, options)
return connectStdio(ctx, server)
case ServerTypeHTTP:
return connectNetwork(ctx, server)
case ServerTypeSSE:
@@ -132,37 +120,9 @@ func (b *boundedBuffer) String() string {
return b.buf.String()
}
func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*Client, error) {
var cmd *exec.Cmd
var cleanup func()
cleanupTransferred := false
defer func() {
if cleanup != nil && !cleanupTransferred {
cleanup()
}
}()
if options.Execution != nil {
workspaceRoot := strings.TrimSpace(options.WorkspaceRoot)
if workspaceRoot == "" {
return nil, fmt.Errorf("start MCP server %s: execution workspace root is required", server.Name)
}
prepared, err := options.Execution.Prepare(ctx, execution.Request{
Origin: execution.OriginMCPServer,
Mode: execution.ModeDurable,
Command: execution.Command{Name: server.Command, Args: append([]string(nil), server.Args...), Env: mergeProcessEnv(server.Env)},
WorkingDirectory: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
})
if err != nil {
return nil, fmt.Errorf("start MCP server %s: %w", server.Name, err)
}
cmd = prepared.Command
cleanup = prepared.Cleanup
} else {
cmd = exec.CommandContext(ctx, server.Command, server.Args...)
cmd.Env = mergeProcessEnv(server.Env)
}
func connectStdio(ctx context.Context, server Server) (*Client, error) {
cmd := exec.CommandContext(ctx, server.Command, server.Args...)
cmd.Env = mergeProcessEnv(server.Env)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("open MCP stdin for %s: %w", server.Name, err)
@@ -178,15 +138,13 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*
}
client := &Client{
server: server,
cmd: cmd,
stdin: stdin,
reader: newMessageReader(stdout),
writer: newMessageWriter(stdin),
nextID: 1,
cleanup: cleanup,
server: server,
cmd: cmd,
stdin: stdin,
reader: newMessageReader(stdout),
writer: newMessageWriter(stdin),
nextID: 1,
}
cleanupTransferred = true
if err := client.initialize(ctx); err != nil {
_ = client.Close()
message := strings.TrimSpace(stderr.String())
@@ -284,10 +242,6 @@ func (client *Client) Close() error {
}
}
}
if client.cleanup != nil {
client.cleanup()
client.cleanup = nil
}
return err
}
-42
View File
@@ -9,7 +9,6 @@ import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
@@ -17,21 +16,8 @@ import (
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
)
type mcpExecutionPreparer struct {
request execution.Request
}
func (preparer *mcpExecutionPreparer) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
command := exec.CommandContext(ctx, request.Command.Name, request.Command.Args...)
command.Dir = request.WorkingDirectory
command.Env = request.Command.Env
return execution.PreparedCommand{Command: command}, nil
}
func TestStdioClientListsAndCallsTools(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -79,34 +65,6 @@ func TestStdioClientListsAndCallsTools(t *testing.T) {
}
}
func TestStdioClientUsesTypedMCPExecutionOrigin(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
preparer := &mcpExecutionPreparer{}
workspace := t.TempDir()
client, err := ConnectWithOptions(ctx, Server{
Name: "docs",
Type: ServerTypeStdio,
Command: executable,
Args: []string{"-test.run=TestMCPStdioHelperProcess", "--"},
Env: map[string]string{"ZERO_MCP_STDIO_HELPER": "1"},
}, ConnectOptions{Execution: execution.NewRunner(preparer), WorkspaceRoot: workspace})
if err != nil {
t.Fatalf("ConnectWithOptions() error = %v", err)
}
defer client.Close()
if preparer.request.Origin != execution.OriginMCPServer || preparer.request.Mode != execution.ModeDurable {
t.Fatalf("execution request = %#v", preparer.request)
}
if preparer.request.WorkingDirectory != workspace {
t.Fatalf("working directory = %q, want %q", preparer.request.WorkingDirectory, workspace)
}
}
func TestStdioClientCloseAllowsConcurrentCallers(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+1 -4
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -27,8 +26,6 @@ type RegisterOptions struct {
// ConnectTimeout bounds the per-server connect+list at startup. Zero uses
// defaultConnectTimeout.
ConnectTimeout time.Duration
Execution *execution.Runner
WorkspaceRoot string
}
// SkippedServer records an MCP server that was not registered because it could
@@ -83,7 +80,7 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
factory := options.ClientFactory
if factory == nil {
factory = func(ctx context.Context, server Server) (ToolClient, error) {
return ConnectWithOptions(ctx, server, ConnectOptions{Execution: options.Execution, WorkspaceRoot: options.WorkspaceRoot})
return Connect(ctx, server)
}
}
+6 -6
View File
@@ -49,9 +49,9 @@ func RequestDeviceCode(ctx context.Context, client *http.Client, cfg Config, now
if err := validateTokenEndpoint(endpoint); err != nil {
return DeviceAuth{}, err
}
// Refuse redirects so a device-authorization redirect can't replay the
// client_id/client_secret POST body to an unvalidated origin.
client = withoutRedirects(client)
if client == nil {
client = http.DefaultClient
}
if now == nil {
now = time.Now
}
@@ -157,9 +157,9 @@ func pollDeviceOnce(ctx context.Context, client *http.Client, cfg Config, device
if err := validateTokenEndpoint(cfg.TokenEndpoint); err != nil {
return Token{}, err
}
// Refuse redirects so a poll redirect can't replay the device_code/secret
// POST body to an unvalidated origin.
client = withoutRedirects(client)
if client == nil {
client = http.DefaultClient
}
form := url.Values{}
form.Set("grant_type", deviceGrantType)
form.Set("device_code", deviceCode)
-25
View File
@@ -2,7 +2,6 @@ package oauth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -29,30 +28,6 @@ func TestRequestDeviceCodeSendsClientSecret(t *testing.T) {
}
}
func TestRequestDeviceCodeRefusesRedirect(t *testing.T) {
endpoint, hits := redirectTrap(t, http.StatusTemporaryRedirect) // 307
_, err := RequestDeviceCode(context.Background(), http.DefaultClient,
Config{ClientID: "c", ClientSecret: "shh", DeviceAuthorizationEndpoint: endpoint}, nil)
if !errors.Is(err, ErrUnsafeRedirect) {
t.Fatalf("RequestDeviceCode err = %v, want ErrUnsafeRedirect", err)
}
if n := hits(); n != 0 {
t.Fatalf("attacker received %d request(s) — client_secret replayed", n)
}
}
func TestPollDeviceOnceRefusesRedirect(t *testing.T) {
endpoint, hits := redirectTrap(t, http.StatusPermanentRedirect) // 308
_, err := pollDeviceOnce(context.Background(), http.DefaultClient,
Config{ClientID: "c", ClientSecret: "shh", TokenEndpoint: endpoint}, "device-code", nil)
if !errors.Is(err, ErrUnsafeRedirect) {
t.Fatalf("pollDeviceOnce err = %v, want ErrUnsafeRedirect", err)
}
if n := hits(); n != 0 {
t.Fatalf("attacker received %d request(s) — device_code/secret replayed", n)
}
}
func TestRequestDeviceCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WXYZ-1234","verification_uri":"https://x/dev","verification_uri_complete":"https://x/dev?c=WXYZ","expires_in":600,"interval":3}`))
+3 -20
View File
@@ -116,23 +116,6 @@ func validateTokenEndpoint(endpoint string) error {
return ValidateEndpointURL(endpoint)
}
// withoutRedirects returns a shallow copy of client whose redirect policy
// refuses to follow ANY redirect. A credential-bearing OAuth POST (token
// exchange/refresh, device authorization, device-token poll) must never let a
// 307/308 replay its form body — codes, PKCE verifiers, refresh tokens, client
// secrets — to a target that was never endpoint-validated. The caller's client
// is left unmodified.
func withoutRedirects(client *http.Client) *http.Client {
if client == nil {
client = http.DefaultClient
}
copied := *client
copied.CheckRedirect = func(*http.Request, []*http.Request) error {
return ErrUnsafeRedirect
}
return &copied
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
@@ -193,9 +176,9 @@ func PostToken(ctx context.Context, client *http.Client, tokenEndpoint string, f
if err := validateTokenEndpoint(tokenEndpoint); err != nil {
return Token{}, err
}
// Refuse redirects so a 307/308 from the token endpoint can't replay this
// credential-bearing POST body to another (unvalidated) origin.
client = withoutRedirects(client)
if client == nil {
client = http.DefaultClient
}
if now == nil {
now = time.Now
}
-60
View File
@@ -5,12 +5,10 @@ import (
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
@@ -128,64 +126,6 @@ func TestBuildAuthorizationURLRejectsInsecureEndpoint(t *testing.T) {
}
}
// redirectTrap starts an "attacker" server that counts any hit, and a credential
// endpoint that redirects to it with the given status. It returns the endpoint
// URL and a func reporting how many times the attacker was contacted — a
// non-zero count means the credential-bearing POST body was replayed (#729).
func redirectTrap(t *testing.T, status int) (endpoint string, attackerHits func() int32) {
t.Helper()
var hits int32
attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
_, _ = io.WriteString(w, `{"access_token":"leaked","refresh_token":"leaked"}`)
}))
t.Cleanup(attacker.Close)
endp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, attacker.URL, status)
}))
t.Cleanup(endp.Close)
return endp.URL, func() int32 { return atomic.LoadInt32(&hits) }
}
func TestPostTokenRefusesRedirect(t *testing.T) {
endpoint, hits := redirectTrap(t, http.StatusTemporaryRedirect) // 307
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", "the-code")
form.Set("client_secret", "shh")
_, err := PostToken(context.Background(), http.DefaultClient, endpoint, form, Token{}, nil)
if !errors.Is(err, ErrUnsafeRedirect) {
t.Fatalf("PostToken err = %v, want ErrUnsafeRedirect", err)
}
if n := hits(); n != 0 {
t.Fatalf("attacker received %d request(s) — credential body was replayed", n)
}
}
func TestExchangeCodeRefusesRedirect(t *testing.T) {
endpoint, hits := redirectTrap(t, http.StatusPermanentRedirect) // 308
_, err := ExchangeCode(context.Background(), http.DefaultClient,
Config{ClientID: "c", TokenEndpoint: endpoint}, "the-code", "verifier", "http://127.0.0.1/cb", nil)
if !errors.Is(err, ErrUnsafeRedirect) {
t.Fatalf("ExchangeCode err = %v, want ErrUnsafeRedirect", err)
}
if n := hits(); n != 0 {
t.Fatalf("attacker received %d request(s) — code/verifier replayed", n)
}
}
func TestRefreshRefusesRedirect(t *testing.T) {
endpoint, hits := redirectTrap(t, http.StatusTemporaryRedirect) // 307
_, err := Refresh(context.Background(), http.DefaultClient,
Config{ClientID: "c", TokenEndpoint: endpoint}, Token{RefreshToken: "rt"}, nil)
if !errors.Is(err, ErrUnsafeRedirect) {
t.Fatalf("Refresh err = %v, want ErrUnsafeRedirect", err)
}
if n := hits(); n != 0 {
t.Fatalf("attacker received %d request(s) — refresh_token replayed", n)
}
}
func TestValidateTokenEndpoint(t *testing.T) {
cases := map[string]bool{
"https://auth.example.com/token": true,
-5
View File
@@ -90,11 +90,6 @@ var (
// ErrInsecureTokenEndpoint is returned when a credential would be sent over a
// non-https, non-loopback endpoint.
ErrInsecureTokenEndpoint = errors.New("oauth: refusing to send credential to a non-https token endpoint")
// ErrUnsafeRedirect is returned when a credential-bearing OAuth POST (token
// exchange/refresh, device authorization, or device-token poll) is redirected.
// Following it would replay the form body — codes, PKCE verifiers, refresh
// tokens, client secrets — to a target that never passed endpoint validation.
ErrUnsafeRedirect = errors.New("oauth: refusing to follow a redirect from a credential endpoint")
// ErrNoRefreshToken is returned when a refresh is attempted without one.
ErrNoRefreshToken = errors.New("oauth: no refresh token available")
// ErrAuthorizationPending is the RFC 8628 "keep polling" signal.
+17 -72
View File
@@ -578,54 +578,28 @@ func WriteTurnBenchJSON(w io.Writer, result TurnBenchResult) error {
return encoder.Encode(result)
}
// execExitIncomplete mirrors internal/cli.exitIncomplete (the `zero exec` exit
// code, 4) for a headless run the completion gate marked INCOMPLETE: the run
// stopped without a completion signal (e.g. it couldn't self-verify because its
// sandboxed shell was unavailable under --auto member), which is distinct from a
// crash (1), usage error (2), provider failure (3), or interruption (130). It is
// the ONLY nonzero exit an oracle-bearing task may defer to the oracle for; every
// other nonzero exit stays authoritative. Kept as a local literal so perfbench
// doesn't import the cli package; must stay in sync with that constant.
const execExitIncomplete = 4
// NewTurnExecRunner builds the production turn-benchmark runner: it invokes
// headless `zero exec` with stream-json output AND `--trace <tmpfile>`, then
// parses the emitted NDJSON trace into a *trace.TurnTrace. binary is the path to
// the `zero` binary; extraArgs are appended to every invocation. Pass/fail is
// decided by the task's oracle when it has one. The oracle that gates the verdict
// is the VerificationCommand (which compiles and runs the stamped OracleTest via
// `go test`, plus any greps against the fixture); it is ground truth, so an
// oracle-bearing task that applied the right edit but exited INCOMPLETE (exit 4,
// its sandboxed self-verification couldn't run) still passes. Any OTHER nonzero
// exit (crash/usage/provider/interrupt) stays authoritative and fails even an
// oracle-bearing task. A latency-only task has no VerificationCommand, so any
// nonzero exit is its only signal and fails it.
// decided from the stream-json run_end exit code (and the task's
// VerificationCommand when present), exactly like NewExecRunner.
func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
return func(ctx context.Context, task BenchTask, rc RunContext) TurnTaskOutcome {
// buildTurnExecArgs grants the write + sandboxed-shell tool set to EVERY
// invocation, so a task MUST run inside an isolated fixture copy — never the
// caller's cwd. The grant is unconditional, so the isolation can't be
// optional: a fixtureless task would turn an agent with write/shell tools
// loose in the caller's working directory (their real repo). Enforce the
// invariant instead of trusting it — reject a fixtureless task up front,
// before anything is launched, so the grant and the guard can't drift apart.
// Every shipped task declares a fixture, so this only fires on a malformed or
// newly added task, and it fails loudly rather than silently.
if strings.TrimSpace(task.WorkspaceFixture) == "" {
return TurnTaskOutcome{Err: errors.New("benchmark task has no workspaceFixture: the run grants write/shell tools and must execute in an isolated fixture copy, not the caller's cwd")}
}
// Isolate the workspace: copy the fixture into a fresh temp dir so a
// mutating task (edit/fix/refactor) can't dirty the shared, checked-in
// fixture or bleed into a later iteration of the same task. The guard above
// guarantees WorkspaceFixture is set, so this always runs.
copyDir, parent, cerr := copyFixture(strings.TrimSpace(task.WorkspaceFixture))
if cerr != nil {
return TurnTaskOutcome{Err: fmt.Errorf("isolate fixture: %w", cerr)}
// fixture or bleed into a later iteration of the same task. When no
// fixture is configured the agent runs in the caller's cwd as before.
if fixture := strings.TrimSpace(task.WorkspaceFixture); fixture != "" {
copyDir, parent, cerr := copyFixture(fixture)
if cerr != nil {
return TurnTaskOutcome{Err: fmt.Errorf("isolate fixture: %w", cerr)}
}
// Clean the whole unique parent (which owns copyDir) so the
// per-invocation scratch dir never leaks.
defer os.RemoveAll(parent)
task.WorkspaceFixture = copyDir
}
// Clean the whole unique parent (which owns copyDir) so the per-invocation
// scratch dir never leaks.
defer os.RemoveAll(parent)
task.WorkspaceFixture = copyDir
traceFile, err := os.CreateTemp("", "zero-turn-trace-*.ndjson")
if err != nil {
@@ -682,23 +656,10 @@ func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
}
}
// Decide what a nonzero exit means. We defer to the oracle for exactly ONE
// case: an oracle-bearing task that exited INCOMPLETE (exit 4). That is the
// completion gate downgrading a run whose edits may be correct but that
// couldn't emit a completion signal (e.g. its sandboxed self-verify couldn't
// run under --auto member) — the oracle below (the compiled test / grep run
// against the actual fixture files) is ground truth, so if the edit really
// landed it passes, and if the run abandoned the task the oracle fails it.
// Every OTHER nonzero exit stays authoritative: a crash (1), usage error
// (2), provider failure (3), or interruption (130) is a genuine failure, so
// a partial edit that happens to satisfy the oracle can't launder it into a
// pass. A latency-only task has no oracle to defer to, so any nonzero exit
// fails it.
// A nonzero agent exit already decided failure; don't run verification or
// mark the task passed.
if outcome.VerifyErr != "" {
if len(task.VerificationCommand) == 0 || exitCode != execExitIncomplete {
return outcome
}
outcome.VerifyErr = ""
return outcome
}
// Stamp the compiler-backed oracle and capture the agent's final answer
// BEFORE running the verification command. Both write into the fixture
@@ -829,23 +790,7 @@ func stampOracleAndAnswer(task BenchTask, outBuf []byte) error {
}
func buildTurnExecArgs(task BenchTask, rc RunContext, tracePath string, extraArgs []string) []string {
// --auto member is REQUIRED, not optional: without a permission grant `zero
// exec` runs in its default read-only posture, which exposes no write or shell
// tools (no edit_file/apply_patch/write_file/exec_command/bash). The mutating
// task classes (edit/fix/refactor) then cannot apply any change, so every run
// grinds to the turn ceiling or reports a no-tool blocker, and the only tasks
// that "pass" are ones whose oracle grep matches the stamped answer text rather
// than a real edit. member-auto grants the write + sandboxed-shell tools the
// benchmark needs while keeping the workspace/network/destructive safeguards, so
// it is preferred over the broader --skip-permissions-unsafe (which also drops
// those guards and auto-retries blocked commands unsandboxed). Note the shell it
// grants is sandboxed: on a host without sandbox setup the agent's own
// self-verification (go test/build) can't run and the turn exits INCOMPLETE even
// after a correct edit — the runner treats the stamped oracle, not that exit
// code, as ground truth for oracle-bearing tasks (see NewTurnExecRunner), so a
// correct edit still passes. Each task also runs in an isolated, throwaway
// fixture copy, so the granted tools have nothing outside the task to harm.
args := []string{"exec", "--auto", "member", "--output-format", "stream-json", "--trace", tracePath}
args := []string{"exec", "--output-format", "stream-json", "--trace", tracePath}
if model := strings.TrimSpace(rc.Model); model != "" {
args = append(args, "--model", model)
}
-138
View File
@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -751,92 +750,6 @@ echo '{"type":"run_end","exitCode":0}'
}
}
// TestOracleAuthoritativeOnIncompleteExit is the companion to the --auto member
// switch: under member-auto the agent's shell is sandboxed, so on a host without
// sandbox setup its self-verification (go test/build) can't run and the turn
// exits INCOMPLETE (exit 4) even after a correct edit. For an ORACLE-bearing task
// the runner must treat the stamped oracle — not that INCOMPLETE exit — as ground
// truth: here the stub applies the real edit-01 rename but reports exitCode 4, and
// the task must still pass because the fixture is correct. This defers ONLY for
// exit 4; TestNonIncompleteExitStaysAuthoritative pins the other side.
func TestOracleAuthoritativeOnIncompleteExit(t *testing.T) {
task := loadBaselineTask(t, "edit-01")
outcome := runTurnStub(t, task, `sed 's/const MaxRetries = 3/const RetryLimit = 3/' main.go > .zero-tmp && mv .zero-tmp main.go
echo '{"type":"run_end","exitCode":4}'
`)
if outcome.Err != nil {
t.Fatalf("incomplete-exit with a correct edit should pass, got harness error: %v", outcome.Err)
}
if !outcome.Passed {
t.Fatalf("a correct edit that exited INCOMPLETE (exit 4) must still pass its oracle: %+v", outcome)
}
if strings.TrimSpace(outcome.VerifyErr) != "" {
t.Fatalf("a passing oracle must clear the exit-code VerifyErr, got %q", outcome.VerifyErr)
}
}
// TestNonIncompleteExitStaysAuthoritative is the guard the oracle-authoritative
// change MUST NOT weaken: only INCOMPLETE (exit 4) defers to the oracle. A crash
// (1), provider failure (3), or interruption (130) is a genuine failure, so it
// stays authoritative even for an oracle-bearing task — otherwise a partial edit
// that happens to satisfy the oracle would launder a crashed or interrupted run
// into a pass. Each case applies the CORRECT edit-01 rename (so the oracle WOULD
// pass) but reports the failing exit code; the task must still fail, and the
// failure must be attributed to the exit code, not the oracle.
func TestNonIncompleteExitStaysAuthoritative(t *testing.T) {
for _, code := range []int{1, 3, 130} {
t.Run(fmt.Sprintf("exit%d", code), func(t *testing.T) {
task := loadBaselineTask(t, "edit-01")
outcome := runTurnStub(t, task, fmt.Sprintf(`sed 's/const MaxRetries = 3/const RetryLimit = 3/' main.go > .zero-tmp && mv .zero-tmp main.go
echo '{"type":"run_end","exitCode":%d}'
`, code))
if outcome.Err != nil {
t.Fatalf("a nonzero exit should be a task fail, not a harness error: %v", outcome.Err)
}
if outcome.Passed {
t.Fatalf("a run that exited %d must not pass even when the edit satisfies the oracle: %+v", code, outcome)
}
if want := fmt.Sprintf("exit code %d", code); !strings.Contains(outcome.VerifyErr, want) {
t.Fatalf("a non-INCOMPLETE nonzero exit must stay authoritative and surface its code, got VerifyErr=%q", outcome.VerifyErr)
}
})
}
}
// TestIncompleteExitStillFailsWhenOracleFails pins the OTHER half of the exit-4
// deferral: deferring to the oracle on INCOMPLETE must still MEAN the oracle is
// consulted, not a blanket pass. Here the stub does no real work and exits
// INCOMPLETE (4); the edit-01 rename never happened, so the oracle fails and the
// task fails. Without this, a regression that turned exit 4 into an unconditional
// pass would slip through, because its sibling TestOracleAuthoritativeOnIncomplete-
// Exit applies a CORRECT edit and would pass either way.
func TestIncompleteExitStillFailsWhenOracleFails(t *testing.T) {
task := loadBaselineTask(t, "edit-01")
outcome := runTurnStub(t, task, `echo '{"type":"run_end","exitCode":4}'
`)
assertVerifyFailed(t, "incomplete exit with no edit applied", outcome)
}
// TestNonzeroExitStillFailsLatencyOnly is the guard on the other side of that
// change: a latency-only task has no oracle to appeal to, so the exit code is
// the ONLY correctness signal and a nonzero exit must still fail it. Without
// this, dropping the exit-code gate for oracle tasks could be misread as
// dropping it everywhere.
func TestNonzeroExitStillFailsLatencyOnly(t *testing.T) {
task := loadBaselineTask(t, "longproc-01")
outcome := runTurnStub(t, task, `echo '{"type":"run_end","exitCode":4}'
`)
if outcome.Err != nil {
t.Fatalf("latency-only nonzero exit should be a verify fail, not a harness error: %v", outcome.Err)
}
if outcome.Passed {
t.Fatalf("a latency-only task that exited nonzero must not pass: %+v", outcome)
}
if strings.TrimSpace(outcome.VerifyErr) == "" {
t.Fatalf("a latency-only nonzero exit must surface a VerifyErr, got none: %+v", outcome)
}
}
// --- Satisfiable tests: the oracle PASSES the right thing (real fix applied) ---
// TestStampedOraclePassesWhenRefactorHappened proves the refactor-01 oracle is
@@ -1279,57 +1192,6 @@ func TestBuildTurnExecArgsIncludesExecProfile(t *testing.T) {
}
}
// Every benchmark invocation MUST grant the write/shell tool set via --auto
// member. Without a permission grant the agent runs read-only and cannot apply
// any edit, so the mutating classes measure nothing but oracle/answer
// contamination. member-auto is the RIGHT grant: it exposes the write +
// sandboxed-shell tools the benchmark needs while keeping the workspace/network/
// destructive safeguards, so the args must NOT reach for the broader
// --skip-permissions-unsafe (which drops those guards). This is a correctness
// contract, not a preference.
func TestBuildTurnExecArgsGrantsWriteTools(t *testing.T) {
args := buildTurnExecArgs(BenchTask{ID: "t", Prompt: "edit the file"}, RunContext{Model: "m"}, "trace.ndjson", nil)
autoMember := false
for i := 0; i < len(args)-1; i++ {
if args[i] == "--auto" && args[i+1] == "member" {
autoMember = true
break
}
}
if !autoMember {
t.Fatalf("benchmark exec args must include --auto member so the agent can apply edits, got %v", args)
}
for _, arg := range args {
if arg == "--skip-permissions-unsafe" {
t.Fatalf("benchmark exec args must NOT use --skip-permissions-unsafe (member-auto keeps the sandbox/network guards), got %v", args)
}
}
if args[len(args)-1] != "edit the file" {
t.Fatalf("prompt must stay the last argument, got %v", args)
}
}
// TestTurnRunnerRejectsFixturelessTask is the other half of the write-tools
// contract: because buildTurnExecArgs grants the write + sandboxed-shell tool
// set to every invocation, a task with no fixture would run an agent with those
// tools in the caller's cwd. The runner must reject such a task BEFORE launching
// zero exec. Passing a binary path that does not exist proves the rejection is
// pre-launch — the outcome carries the fixture error, not a spawn error — so this
// runs on every OS (it never reaches the POSIX-only exec stub).
func TestTurnRunnerRejectsFixturelessTask(t *testing.T) {
task := BenchTask{ID: "no-fixture", Prompt: "do a thing"} // no WorkspaceFixture set
outcome := NewTurnExecRunner(filepath.Join(t.TempDir(), "does-not-exist-zero"))(context.Background(), task, RunContext{Model: "m"})
if outcome.Err == nil {
t.Fatalf("a fixtureless task must be rejected before launch, got %+v", outcome)
}
if !strings.Contains(outcome.Err.Error(), "workspaceFixture") {
t.Fatalf("rejection must name the missing workspaceFixture, got: %v", outcome.Err)
}
if outcome.Passed {
t.Fatalf("a rejected task must not pass, got %+v", outcome)
}
}
// The configured profile must reach the runner's RunContext and be stamped
// into the result, so a profile A/B report is self-describing. The boundary
// canonicalizes (case/whitespace) so equivalent postures always carry the same
+2 -42
View File
@@ -14,7 +14,6 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/secrets"
@@ -114,10 +113,6 @@ type ActivateOptions struct {
// Env supplies the base environment for plugin tool commands. nil uses the
// current process environment (os.Environ()).
Env []string
// Execution routes plugin tool subprocesses through the shared sandbox and
// process execution seam. Production callers provide it before any tool can
// be invoked; tests may continue to inject runTool directly.
Execution *execution.Runner
runTool toolRunner
}
@@ -146,14 +141,8 @@ func Activate(registry *tools.Registry, loaded []LoadedPlugin, options ActivateO
if timeout <= 0 {
timeout = defaultPluginToolTimeout
}
if options.Execution != nil {
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
return execPluginCommandWithExecution(ctx, options.Execution, command, timeout)
}
} else {
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
return execPluginCommand(ctx, command, timeout)
}
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
return execPluginCommand(ctx, command, timeout)
}
}
@@ -700,32 +689,3 @@ func execPluginCommand(ctx context.Context, command pluginCommand, timeout time.
output.Err = err
return output
}
func execPluginCommandWithExecution(ctx context.Context, runner *execution.Runner, command pluginCommand, timeout time.Duration) commandOutput {
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result := runner.ExecuteCaptured(runCtx, execution.CapturedRequest{
Request: execution.Request{
Origin: execution.OriginPlugin,
Mode: execution.ModeCaptured,
Command: execution.Command{Name: command.Command, Args: append([]string(nil), command.Args...), Env: append([]string(nil), command.Env...)},
WorkingDirectory: command.Cwd,
WorkspaceRoots: []string{command.Cwd},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
},
Stdin: command.Stdin,
})
exitCode := -1
if result.Outcome.Exit != nil {
exitCode = result.Outcome.Exit.Code
}
output := commandOutput{Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: exitCode}
switch result.Outcome.Kind {
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, execution.OutcomeTimedOut, execution.OutcomeCancelled:
output.Err = result.Err
}
if output.Stderr == "" && result.Outcome.Denial != nil {
output.Stderr = result.Outcome.Denial.Reason
}
return output
}
-27
View File
@@ -3,27 +3,15 @@ package plugins
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/tools"
)
type pluginExecutionPreparer struct {
request execution.Request
}
func (preparer *pluginExecutionPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
return execution.PreparedCommand{Command: exec.Command(request.Command.Name, request.Command.Args...)}, nil
}
// fakeToolRunner records the invocation and returns a canned result so tool
// activation can be exercised without spawning a real process.
type fakeToolRunner struct {
@@ -566,21 +554,6 @@ func TestActivateSkipsDisabledPlugin(t *testing.T) {
}
}
func TestPluginCommandUsesTypedPluginExecutionOrigin(t *testing.T) {
if testing.Short() {
t.Skip("spawns a subprocess")
}
preparer := &pluginExecutionPreparer{}
command := pluginCommand{Command: os.Args[0], Args: []string{"-test.run=^$"}, Cwd: t.TempDir()}
output := execPluginCommandWithExecution(context.Background(), execution.NewRunner(preparer), command, time.Second)
if output.Err != nil || output.ExitCode != 0 {
t.Fatalf("plugin execution output = %#v", output)
}
if preparer.request.Origin != execution.OriginPlugin || preparer.request.Mode != execution.ModeCaptured {
t.Fatalf("execution request = %#v", preparer.request)
}
}
func TestActivateIsDeterministic(t *testing.T) {
dirA := t.TempDir()
dirB := t.TempDir()
-62
View File
@@ -1,62 +0,0 @@
package plugins
import (
"errors"
"fmt"
"path/filepath"
"strings"
)
// ErrNotInstalled is returned when Info cannot find a plugin with the requested id.
var ErrNotInstalled = errors.New("plugin is not installed")
// PluginInfo combines a loaded plugin with optional lockfile metadata.
type PluginInfo struct {
Plugin LoadedPlugin `json:"plugin"`
LockSource string `json:"lockSource,omitempty"`
LockHash string `json:"lockHash,omitempty"`
HashDrift bool `json:"hashDrift,omitempty"`
}
// InfoOptions controls plugin lookup and lockfile resolution for Info.
type InfoOptions struct {
LoadOptions LoadOptions
}
// Info returns details for the named plugin after normal discovery precedence.
func Info(options InfoOptions, id string) (PluginInfo, error) {
id = strings.TrimSpace(id)
if id == "" {
return PluginInfo{}, errors.New("plugin id is required")
}
result, err := Load(options.LoadOptions)
if err != nil {
return PluginInfo{}, err
}
var plugin LoadedPlugin
found := false
for _, candidate := range result.Plugins {
if candidate.ID == id {
plugin = candidate
found = true
break
}
}
if !found {
return PluginInfo{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
}
info := PluginInfo{Plugin: plugin}
if lock, readErr := ReadLock(filepath.Dir(plugin.PluginDir)); readErr == nil {
if entry, ok := lock[plugin.ID]; ok {
info.LockSource = entry.Source
info.LockHash = entry.Hash
}
}
if info.LockHash != "" {
if current, hashErr := hashTree(plugin.PluginDir); hashErr == nil {
info.HashDrift = current != info.LockHash
}
}
return info, nil
}
-69
View File
@@ -1,69 +0,0 @@
package plugins
import (
"os"
"path/filepath"
"testing"
)
func TestInfoReturnsLockMetadataAndHashDrift(t *testing.T) {
dir := t.TempDir()
userRoot := filepath.Join(dir, "user")
pluginDir := filepath.Join(userRoot, "zero.demo")
writePluginManifest(t, pluginDir, map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": true,
"tools": []any{},
})
hash, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if err := writeLock(userRoot, map[string]LockEntry{
"zero.demo": {Source: "./demo-src", Hash: hash},
}); err != nil {
t.Fatal(err)
}
info, err := Info(InfoOptions{
LoadOptions: LoadOptions{Roots: []Root{{Source: SourceUser, Path: userRoot}}},
}, "zero.demo")
if err != nil {
t.Fatalf("Info: %v", err)
}
if info.Plugin.ID != "zero.demo" || !info.Plugin.Enabled {
t.Fatalf("plugin = %#v", info.Plugin)
}
if info.LockSource != "./demo-src" || info.LockHash != hash {
t.Fatalf("lock metadata = %#v, want source ./demo-src hash %s", info, hash)
}
if info.HashDrift {
t.Fatal("expected no hash drift before manifest edit")
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(`{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0","enabled":true}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
info, err = Info(InfoOptions{
LoadOptions: LoadOptions{Roots: []Root{{Source: SourceUser, Path: userRoot}}},
}, "zero.demo")
if err != nil {
t.Fatalf("Info after edit: %v", err)
}
if !info.HashDrift {
t.Fatal("expected hash drift after manifest edit")
}
}
func TestInfoMissingPlugin(t *testing.T) {
_, err := Info(InfoOptions{
LoadOptions: LoadOptions{Roots: []Root{{Source: SourceUser, Path: t.TempDir()}}},
}, "missing")
if err == nil {
t.Fatal("expected missing plugin error")
}
}
+17 -46
View File
@@ -19,8 +19,6 @@ import (
"path/filepath"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/installtxn"
)
// manifestFileName is the plugin manifest filename, matching the loader.
@@ -130,32 +128,6 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error)
return InstallResult{}, fmt.Errorf("hash plugin: %w", err)
}
staged, cleanupStage, err := installtxn.StageDir(dir)
if err != nil {
return InstallResult{}, err
}
defer cleanupStage()
// Copy the whole plugin tree (entry scripts, prompts, skills) into a sibling
// staging area. Copy DATA only — never execute it.
if err := copyTree(pluginDir, staged); err != nil {
return InstallResult{}, fmt.Errorf("stage plugin: %w", err)
}
stagedHash, err := hashTree(staged)
if err != nil {
return InstallResult{}, fmt.Errorf("validate staged plugin: %w", err)
}
if stagedHash != hash {
return InstallResult{}, errors.New("validate staged plugin: copied content hash differs from source")
}
unlock, err := installtxn.Lock(dir)
if err != nil {
return InstallResult{}, err
}
defer unlock()
// Re-read under the cross-process lock. Another install may have updated the
// lockfile while this plugin was fetched and staged.
lock, err := ReadLock(dir)
if err != nil {
return InstallResult{}, err
@@ -166,10 +138,20 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error)
}
target := filepath.Join(dir, id)
if err := os.MkdirAll(dir, 0o755); err != nil {
return InstallResult{}, fmt.Errorf("create plugins dir: %w", err)
}
if err := os.RemoveAll(target); err != nil {
return InstallResult{}, fmt.Errorf("clear previous plugin: %w", err)
}
// Copy the whole plugin tree (entry scripts, prompts, skills) so the installed
// plugin is runnable through activation. Copy DATA only — never execute it.
if err := copyTree(pluginDir, target); err != nil {
return InstallResult{}, fmt.Errorf("copy plugin: %w", err)
}
lock[id] = LockEntry{Source: source, Hash: hash}
if err := installtxn.CommitDir(target, staged, func() error {
return writeLock(dir, lock)
}); err != nil {
if err := writeLock(dir, lock); err != nil {
return InstallResult{}, err
}
@@ -200,12 +182,6 @@ func Remove(dir string, id string) error {
return fmt.Errorf("invalid plugin id %q", id)
}
unlock, err := installtxn.Lock(dir)
if err != nil {
return err
}
defer unlock()
lock, err := ReadLock(dir)
if err != nil {
return err
@@ -218,16 +194,11 @@ func Remove(dir string, id string) error {
return fmt.Errorf("plugin %q is not installed", id)
}
if present {
if err := installtxn.RemoveDir(target, func() error {
if !locked {
return nil
}
delete(lock, id)
return writeLock(dir, lock)
}); err != nil {
if err := os.RemoveAll(target); err != nil {
return fmt.Errorf("remove plugin dir: %w", err)
}
} else if locked {
}
if locked {
delete(lock, id)
if err := writeLock(dir, lock); err != nil {
return err
@@ -268,7 +239,7 @@ func writeLock(dir string, entries map[string]LockEntry) error {
if err != nil {
return fmt.Errorf("encode %s: %w", LockFileName, err)
}
if err := installtxn.WriteFileAtomically(filepath.Join(dir, LockFileName), append(data, '\n'), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(dir, LockFileName), append(data, '\n'), 0o644); err != nil {
return fmt.Errorf("write %s: %w", LockFileName, err)
}
return nil
-33
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -188,38 +187,6 @@ func TestInstallReinstallShowsHashChange(t *testing.T) {
}
}
func TestConcurrentInstallsPreserveEveryLockEntry(t *testing.T) {
destDir := t.TempDir()
const count = 12
sources := make([]string, count)
for index := range count {
manifest := validManifest()
manifest["id"] = fmt.Sprintf("zero.concurrent.%02d", index)
sources[index] = writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), manifest)
}
errs := make(chan error, count)
for _, source := range sources {
go func() {
_, err := Install(context.Background(), InstallOptions{Source: source, Dir: destDir})
errs <- err
}()
}
for range count {
if err := <-errs; err != nil {
t.Fatalf("concurrent Install: %v", err)
}
}
entries, err := ReadLock(destDir)
if err != nil {
t.Fatalf("ReadLock: %v", err)
}
if len(entries) != count {
t.Fatalf("lockfile has %d entries, want %d: %#v", len(entries), count, entries)
}
}
// TestInstallReinstallDetectsNestedFileChange guards that the recorded hash
// covers the whole installed tree, not just plugin.json. A change to a tool
// script (with the manifest unchanged) must still be reported as an update so
-1
View File
@@ -140,7 +140,6 @@ var descriptors = []Descriptor{
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
openAICompat("atlascloud", "Atlas Cloud", "https://api.atlascloud.ai/v1", "qwen/qwen3.5-flash", []string{"ATLASCLOUD_API_KEY"}, "atlas cloud", "atlas"),
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
+1 -25
View File
@@ -24,7 +24,6 @@ var expectedCatalogIDs = []string{
"together",
"dashscope",
"moonshot",
"atlascloud",
"longcat",
"nvidia-nim",
"minimax",
@@ -155,28 +154,6 @@ func TestLongCatDescriptor(t *testing.T) {
}
}
func TestAtlasCloudDescriptor(t *testing.T) {
descriptor, err := Require("atlascloud")
if err != nil {
t.Fatalf("Require(atlascloud) error = %v", err)
}
if descriptor.Name != "Atlas Cloud" {
t.Fatalf("Name = %q, want Atlas Cloud", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.atlascloud.ai/v1" {
t.Fatalf("DefaultBaseURL = %q, want Atlas Cloud OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "qwen/qwen3.5-flash" {
t.Fatalf("DefaultModel = %q, want qwen/qwen3.5-flash", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"ATLASCLOUD_API_KEY"}) {
t.Fatalf("AuthEnvVars = %#v, want ATLASCLOUD_API_KEY", descriptor.AuthEnvVars)
}
}
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
for _, descriptor := range All() {
if descriptor.ID == "" {
@@ -295,7 +272,6 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
"lm-studio": "lmstudio",
"mini_max": "minimax",
"Moonshot": "moonshot",
"Atlas Cloud": "atlascloud",
"nvidia nim": "nvidia-nim",
"xiaomi mimo": "xiaomi-mimo",
"custom_openai_compatible": "custom-openai-compatible",
@@ -346,7 +322,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
-4
View File
@@ -133,10 +133,6 @@ var curatedModels = map[string][]Model{
{ID: "kimi-k2-turbo-preview", Description: "fast coding model"},
{ID: "moonshot-v1-128k", Description: "long-context model"},
},
"atlascloud": {
{ID: "qwen/qwen3.5-flash", Description: "catalog default"},
{ID: "deepseek-ai/deepseek-v4-pro", Description: "reasoning model"},
},
"nvidia-nim": {
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "catalog default"},
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
@@ -38,11 +38,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
want: []string{"anthropic/claude-sonnet-5", "google/gemini-3.5-flash", "openai/gpt-5.5-2026-04-23", "qwen/qwen-3.7-max", "deepseek/deepseek-v4-pro"},
notWant: []string{"gpt-4o", "openai/gpt-4o", "anthropic/claude-opus-4.8", "x-ai/grok-4-5"},
},
{
provider: "atlascloud",
want: []string{"qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "openai/gpt-4.1"},
},
{
provider: "mistral",
want: []string{"mistral-large-latest", "codestral-latest"},
+19 -26
View File
@@ -14,7 +14,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providers/openai"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/redaction"
)
@@ -36,12 +35,9 @@ type Model struct {
}
type Options struct {
HTTPClient *http.Client
ModelsDevURL string
OpenGatewayURL string
OAuthResolver providerio.TokenResolver
CodexAccountResolver openai.CodexAccountResolver
UserAgent string
HTTPClient *http.Client
ModelsDevURL string
OpenGatewayURL string
}
func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, profile config.ProviderProfile, options Options) ([]Model, error) {
@@ -192,18 +188,6 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
if err != nil {
return nil, err
}
var configure func(*http.Request)
if providercatalog.NormalizeID(profile.CatalogID) == "chatgpt" {
configure = func(request *http.Request) {
account := ""
if options.CodexAccountResolver != nil {
if resolved, ok, resolveErr := options.CodexAccountResolver(request.Context()); resolveErr == nil && ok {
account = resolved
}
}
openai.ApplyCodexHeaders(request, account, options.UserAgent)
}
}
return fetchProviderModels(ctx, endpoint, profile, options, providerio.AuthHeaders{
APIKey: profile.APIKey,
DefaultAuthHeader: "Authorization",
@@ -212,7 +196,7 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
}, configure)
}, nil)
}
func discoverAnthropicModels(ctx context.Context, profile config.ProviderProfile, options Options) ([]Model, error) {
@@ -233,16 +217,25 @@ func discoverAnthropicModels(ctx context.Context, profile config.ProviderProfile
}
func fetchProviderModels(ctx context.Context, endpoint string, profile config.ProviderProfile, options Options, auth providerio.AuthHeaders, configure func(*http.Request)) ([]Model, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
// Authenticate via either an APIKey or a raw auth-header value / custom
// headers, matching how the live providers build their requests
// (internal/providers/providerio). Honoring AuthHeaderValue keeps discovery
// consistent with the credential-present logic elsewhere.
providerio.ApplyAuthHeaders(request, auth)
request.Header.Set("Accept", "application/json")
if configure != nil {
configure(request)
}
client := options.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
response, err := providerio.SendWithAuthRetry(ctx, client, http.MethodGet, endpoint, nil, auth, options.OAuthResolver, func(request *http.Request) {
request.Header.Set("Accept", "application/json")
if configure != nil {
configure(request)
}
}, 1)
response, err := client.Do(request)
if err != nil {
return nil, redactDiscoveryError(err, profile)
}
@@ -52,63 +52,6 @@ func TestDiscoverOpenAICompatibleModelsFetchesModelsEndpoint(t *testing.T) {
}
}
func TestDiscoverChatGPTModelsUsesOAuthAndCodexHeaders(t *testing.T) {
var requests int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if got := r.URL.Path; got != "/backend-api/codex/models" {
t.Errorf("path = %q, want Codex models endpoint", got)
}
wantToken := "Bearer old-token"
wantAccount := "old-account"
if requests == 2 {
wantToken = "Bearer refreshed-token"
wantAccount = "refreshed-account"
}
if got := r.Header.Get("Authorization"); got != wantToken {
t.Errorf("request %d Authorization = %q, want %q", requests, got, wantToken)
}
if got := r.Header.Get("chatgpt-account-id"); got != wantAccount {
t.Errorf("request %d chatgpt-account-id = %q, want %q", requests, got, wantAccount)
}
if got := r.Header.Get("originator"); got != "codex_cli_rs" {
t.Errorf("originator = %q", got)
}
if requests == 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.4"}]}`))
}))
defer server.Close()
models, err := Discover(context.Background(), config.ProviderProfile{
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/backend-api/codex",
}, Options{
HTTPClient: server.Client(),
OAuthResolver: func(_ context.Context, force bool) (string, string, bool, error) {
if force {
return "Authorization", "Bearer refreshed-token", true, nil
}
return "Authorization", "Bearer old-token", true, nil
},
CodexAccountResolver: func(context.Context) (string, bool, error) {
if requests == 0 {
return "old-account", true, nil
}
return "refreshed-account", true, nil
},
})
if err != nil {
t.Fatalf("Discover returned error: %v", err)
}
if requests != 2 || len(models) != 1 || models[0].ID != "gpt-5.4" {
t.Fatalf("requests = %d, models = %#v", requests, models)
}
}
func TestDiscoverAIMLAPIModelsSendsAuthAndCustomHeadersWithoutAttribution(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for header, want := range map[string]string{
+8 -12
View File
@@ -361,6 +361,13 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
// login than the bearer — a mismatch the backend rejects.
func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile, options Options) (zeroruntime.Provider, error) {
accountKey := options.OAuthLoginKey
resolver := openai.CodexAccountResolver(func(ctx context.Context) (string, bool, error) {
account := codexAccountForKey(accountKey)
if account == "" {
return "", false, nil
}
return account, true, nil
})
return openai.NewCodexProvider(openai.CodexOptions{
Options: openai.Options{
BaseURL: resolved.baseURL,
@@ -381,7 +388,7 @@ func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile,
// override. The codex provider's constructor derives the
// `/responses` endpoint from BaseURL, so the factory stays out of
// the path.
AccountResolver: CodexAccountResolverForLogin(accountKey),
AccountResolver: resolver,
})
}
@@ -404,14 +411,3 @@ func codexAccountForKey(key string) string {
}
return strings.TrimSpace(token.Account)
}
// CodexAccountResolverForLogin returns the same per-request account resolver
// used by the runtime provider. Auxiliary Codex requests use this rather than
// independently selecting or parsing an OAuth login, which could mismatch the
// bearer selected by oauthLoginForProfile.
func CodexAccountResolverForLogin(key string) openai.CodexAccountResolver {
return func(context.Context) (string, bool, error) {
account := codexAccountForKey(key)
return account, account != "", nil
}
}
+8 -19
View File
@@ -23,19 +23,6 @@ const (
codexOriginatorHeader = "originator"
)
// ApplyCodexHeaders applies the non-bearer headers required by ChatGPT's Codex
// backend. It is shared by runtime completions and auxiliary Codex endpoints
// such as live model discovery so their authentication cannot drift apart.
func ApplyCodexHeaders(req *http.Request, accountID, userAgent string) {
req.Header.Set(codexOriginatorHeader, codexDefaultOriginator)
if accountID = strings.TrimSpace(accountID); accountID != "" {
req.Header.Set(codexAccountHeader, accountID)
}
if userAgent = strings.TrimSpace(userAgent); userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
}
// CodexAccountResolver returns the `chatgpt_account_id` claim for the bearer
// that is about to be sent on a request. It is invoked once per request
// (including the 401-refresh retry) so the value can be re-derived from the
@@ -181,13 +168,15 @@ func (p *CodexProvider) StreamCompletion(ctx context.Context, request zeroruntim
// openai provider. It sets the three Codex-required headers; the bearer is
// applied separately by the openai provider's auth path.
func (p *CodexProvider) injectCodexHeaders(req *http.Request) {
account := ""
if resolved, ok, err := p.resolveAccount(req.Context()); err == nil && ok && resolved != "" {
account = strings.TrimSpace(resolved)
req.Header.Set(codexOriginatorHeader, p.originator)
if account, ok, err := p.resolveAccount(req.Context()); err == nil && ok && account != "" {
req.Header.Set(codexAccountHeader, account)
}
ApplyCodexHeaders(req, account, p.userAgent)
if p.originator != codexDefaultOriginator {
req.Header.Set(codexOriginatorHeader, p.originator)
// Branded User-Agent overrides the openai provider's default. Set last
// so a caller that supplies a different UserAgent in custom-headers is
// still respected (the openai provider's setExtra already ran before us).
if p.userAgent != "" {
req.Header.Set("User-Agent", p.userAgent)
}
}
+10 -100
View File
@@ -65,76 +65,6 @@ func AnalyzeCommand(script string) AnalysisResult {
return result
}
// astCommandFields parses command with the shell parser and returns each simple
// command as its literal field slice (program + args as text), resolving the
// real command positions across quoting, command substitution, subshells, and
// newline separators — the constructs the hand-written splitter in
// safe_command.go mis-handles (issue #473). It returns nil when the command
// cannot be parsed (e.g. a Windows cmd.exe string), so callers fall through to
// the regex path rather than hard-blocking.
func astCommandFields(command string) [][]string {
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
if err != nil {
return nil
}
var commands [][]string
syntax.Walk(file, func(node syntax.Node) bool {
call, ok := node.(*syntax.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
// EVERY word must be a static literal, not just the program name.
// wordText keeps only the literal/quoted parts of a word and silently
// drops expansions, so a dynamic word anywhere in the call reconstructs
// to something the shell will never run: `$(printf foo)vim` (runs as
// `foovim`) collapses to "vim", and `git $(printf foo)rebase -i` (runs
// as `git foorebase -i`, non-interactive) collapses to `git rebase -i`
// and fabricates an interactive match. Since the runtime value of an
// expansion is unknowable here, skip the whole call rather than classify
// a lossy reconstruction. Skipping is the safe direction: the
// hand-written passes above already ran, and a missed detection falls
// through to the normal permission prompt instead of hard-blocking a
// command the user never wrote.
for _, word := range call.Args {
if !isLiteralWord(word) {
return true
}
}
fields := make([]string, 0, len(call.Args))
for _, word := range call.Args {
fields = append(fields, wordText(word))
}
commands = append(commands, fields)
return true
})
return commands
}
// isLiteralWord reports whether every part of word is a static literal (bare or
// quoted). A word containing a command substitution, parameter/arithmetic
// expansion, process substitution, etc. is dynamic — its runtime value is
// unknown, so its wordText (a partial literal) must not be trusted as a program
// name.
func isLiteralWord(word *syntax.Word) bool {
if word == nil {
return false
}
for _, part := range word.Parts {
switch typed := part.(type) {
case *syntax.Lit, *syntax.SglQuoted:
case *syntax.DblQuoted:
for _, inner := range typed.Parts {
if _, ok := inner.(*syntax.Lit); !ok {
return false
}
}
default:
return false
}
}
return true
}
// analyzeInto parses script and folds its interactive/destructive/network usage
// into result, sharing seen so program names are de-duplicated across recursion.
func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, depth int) {
@@ -220,7 +150,7 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
case "go":
return firstSubcommand(words, nil) == "get"
case "git":
return gitUsesNetwork(words)
return firstSubcommand(words, nil) == "clone"
case "gh":
return ghUsesNetwork(words)
default:
@@ -229,15 +159,9 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
}
func packageManagerUsesNetwork(words []string, aliases map[string]string) bool {
if packageManagerOffline(words) {
return false
}
first := firstSubcommand(words, aliases)
switch first {
case "install", "add", "ci", "create", "dlx", "publish", "unpublish",
"login", "logout", "adduser", "whoami", "ping", "audit", "outdated",
"update", "upgrade", "search", "view", "info", "show", "dist-tag",
"deprecate", "owner", "org", "team", "token", "profile", "access":
case "install", "add", "publish", "login":
return true
case "start", "serve", "dev", "preview":
return true
@@ -245,33 +169,19 @@ func packageManagerUsesNetwork(words []string, aliases map[string]string) bool {
second := secondSubcommand(words)
return second == "start" || second == "serve" || second == "dev" || second == "preview"
case "exec":
// Package-manager exec commands may resolve and download a missing
// package before launching it. An explicit offline flag keeps this path
// inside the isolated namespace; otherwise request egress up front.
return true
}
return false
}
func packageManagerOffline(words []string) bool {
for _, word := range words {
switch word {
case "--offline", "--no-network":
return true
for _, word := range words {
if word == "" || strings.HasPrefix(word, "-") || isNumericToken(word) {
continue
}
if word == "exec" || word == "x" || word == "dlx" {
continue
}
return localServerPrograms[word]
}
}
return false
}
func gitUsesNetwork(words []string) bool {
switch firstSubcommand(words, nil) {
case "clone", "fetch", "pull", "push", "ls-remote", "archive":
return true
default:
return false
}
}
func npxUsesNetwork(_ []string) bool {
return true
}
-8
View File
@@ -49,12 +49,6 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
{name: "npm install", script: "npm install", network: true},
{name: "npm ci", script: "npm ci", network: true},
{name: "npm create", script: "npm create vite@latest .", network: true},
{name: "npm registry query", script: "npm view typescript version --fetch-retries=0", network: true},
{name: "npm metadata search", script: "npm search typescript", network: true},
{name: "npm offline install", script: "npm install --offline", network: false},
{name: "npm version is offline", script: "npm --version", network: false},
{name: "npm start", script: "npm start", network: true},
{name: "npm run dev", script: "npm run dev", network: true},
{name: "npx http server", script: "npx http-server public -p 8080 -a 127.0.0.1", network: true},
@@ -62,8 +56,6 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "direct vite", script: "vite --host 127.0.0.1", network: true},
{name: "next dev", script: "next dev", network: true},
{name: "git clone", script: "git clone https://example.com/repo.git", network: true},
{name: "git fetch", script: "git fetch origin", network: true},
{name: "git status is offline", script: "git status", network: false},
{name: "gh release download", script: "gh release download v1.0.0", network: true},
{name: "no network", script: "ls -la && echo done", network: false},
{name: "process pattern is not network", script: `pkill -f "python3 -m http.server 8000"`, network: false},
-34
View File
@@ -93,40 +93,6 @@ func TestSandboxedBashAutoAllowDoesNotAffectNonShell(t *testing.T) {
}
}
// Forged re-entrancy markers (ambient ZERO_SANDBOXED=1 + ZERO_SANDBOX_BACKEND at
// an unsandboxed process boundary) must NOT let a shell command combine an
// auto-allow decision with an unwrapped pass-through plan (issue #727). The
// runner skips wrapping when the markers are present, so the policy must stop
// auto-allowing on the basis of an "active sandbox" — the command takes the
// normal prompt instead. Without the markers the identical setup auto-allows
// (TestSandboxedBashAutoAllowedWhenSandboxActive), so this is the before/after.
func TestForgedReentrancyMarkersDoNotAutoAllowUnwrappedShell(t *testing.T) {
t.Setenv(EnvSandboxed, "1")
t.Setenv(EnvSandboxBackend, string(BackendLinuxBwrap))
root := t.TempDir()
engine := NewEngine(EngineOptions{
WorkspaceRoot: root,
Policy: DefaultPolicy(),
Backend: nativeWrappingBackend,
})
// Policy side: the shell command must NOT be auto-allowed.
decision := engine.Evaluate(context.Background(), bashRequest())
if decision.Action != ActionPrompt || decision.AutoAllowed {
t.Fatalf("forged markers must not auto-allow the shell command, got %#v", decision)
}
// Planning side: the same command is an unwrapped pass-through. Together with
// the prompt above, this proves auto-allow and unwrapped can no longer combine.
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "echo hi"}, Dir: root})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
if plan.Wrapped {
t.Fatalf("re-entrancy plan must be an unwrapped pass-through, got wrapped=%v", plan.Wrapped)
}
}
// TestShellSandboxActive reports correctly across backends and policy modes.
func TestShellSandboxActive(t *testing.T) {
root := t.TempDir()
-16
View File
@@ -80,13 +80,6 @@ func (engine *Engine) CanPersistGrants() bool {
return engine != nil && engine.store != nil
}
func (engine *Engine) ConsumeGrantMigrationNotice() (string, error) {
if engine == nil || engine.store == nil {
return "", nil
}
return engine.store.ConsumeMigrationNotice()
}
func (engine *Engine) GrantCommandPrefixForSession(toolName string, prefix []string) {
if engine == nil || len(prefix) == 0 {
return
@@ -239,15 +232,6 @@ func (engine *Engine) shellSandboxActive(policy Policy) bool {
if policy.Mode == ModeDisabled {
return false
}
// Re-entrancy skips wrapping: when the process carries the sandbox markers,
// the runner returns a pass-through (unwrapped) plan, so the native sandbox
// is NOT the boundary for THIS command. Because the markers are ambient and
// forgeable at an unsandboxed process boundary (issue #727), auto-allowing a
// shell command on that basis would run it unwrapped with no prompt. Mirror
// the runner: report inactive so the command takes the normal approval path.
if IsAlreadySandboxed() {
return false
}
backend := engine.backend
if !(backend.Available && backend.Executable != "" && backend.CommandWrapping && backend.NativeIsolation) {
return false
@@ -1,36 +0,0 @@
package sandbox
import (
"context"
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/execution"
)
func TestEnginePreparesTypedCapturedExecution(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell")
}
workspace := t.TempDir()
engine := NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Backend: Backend{Name: BackendUnavailable, Message: "test backend unavailable"},
})
runner := execution.NewRunner(engine)
result := runner.ExecuteCaptured(context.Background(), execution.CapturedRequest{Request: execution.Request{
Origin: execution.OriginHook,
Mode: execution.ModeCaptured,
Command: execution.Command{Name: "/bin/sh", Args: []string{"-c", "printf adapted"}},
WorkingDirectory: workspace,
WorkspaceRoots: []string{workspace},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
}})
if result.Outcome.Kind != execution.OutcomeSuccess || result.Stdout != "adapted" {
t.Fatalf("captured result = %#v", result)
}
if result.Outcome.Enforcement.Level != string(EnforcementDegraded) {
t.Fatalf("enforcement = %#v, want degraded", result.Outcome.Enforcement)
}
}
+12 -210
View File
@@ -12,11 +12,10 @@ import (
"sync"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/redaction"
)
const grantSchemaVersion = 3
const grantSchemaVersion = 2
type Grant struct {
ToolName string `json:"toolName"`
@@ -52,18 +51,8 @@ type GrantLookup struct {
type grantFile struct {
SchemaVersion int `json:"schemaVersion"`
PolicyVersion int `json:"policyVersion"`
Grants map[string][]Grant `json:"grants"`
CommandPrefixes map[string][]CommandPrefixGrant `json:"commandPrefixes,omitempty"`
Migration *grantMigration `json:"migration,omitempty"`
}
type grantMigration struct {
FromSchemaVersion int `json:"fromSchemaVersion"`
BackupPath string `json:"backupPath"`
Migrated int `json:"migrated"`
Invalidated int `json:"invalidated"`
NoticePending bool `json:"noticePending"`
}
type GrantStore struct {
@@ -129,31 +118,6 @@ func (store *GrantStore) FilePath() string {
return store.filePath
}
// ConsumeMigrationNotice returns the legacy-grant migration summary once and
// atomically records it as seen. Frontends call this during startup so users are
// told when approvals were invalidated instead of encountering unexplained
// re-prompts. The original file remains available at the reported backup path.
func (store *GrantStore) ConsumeMigrationNotice() (string, error) {
if store == nil {
return "", nil
}
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
if err != nil {
return "", err
}
if state.Migration == nil || !state.Migration.NoticePending {
return "", nil
}
migration := *state.Migration
state.Migration.NoticePending = false
if err := store.writeState(state); err != nil {
return "", err
}
return fmt.Sprintf("sandbox grants updated for policy v%d: migrated %d, invalidated %d; backup: %s", execution.PolicyVersion, migration.Migrated, migration.Invalidated, migration.BackupPath), nil
}
func (store *GrantStore) Grant(input GrantInput) (Grant, error) {
grant, err := createGrant(input, store.now)
if err != nil {
@@ -554,26 +518,25 @@ func (store *GrantStore) readState() (grantFile, error) {
// current (v2) map[tool][]Grant shape.
var head struct {
SchemaVersion int `json:"schemaVersion"`
PolicyVersion int `json:"policyVersion"`
Grants json.RawMessage `json:"grants"`
CommandPrefixes json.RawMessage `json:"commandPrefixes"`
Migration *grantMigration `json:"migration"`
}
if err := json.Unmarshal(data, &head); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
if head.SchemaVersion == 1 || head.SchemaVersion == 2 {
return store.migrateLegacyState(data, head.SchemaVersion, head.Grants, head.CommandPrefixes)
}
if head.SchemaVersion != grantSchemaVersion {
return grantFile{}, fmt.Errorf("invalid sandbox grants file at %s: unsupported schemaVersion", store.filePath)
}
if head.PolicyVersion != execution.PolicyVersion {
return store.migrateChangedPolicy(data, head.PolicyVersion, head.Grants, head.CommandPrefixes)
}
buckets := map[string][]Grant{}
commandPrefixBuckets := map[string][]CommandPrefixGrant{}
switch head.SchemaVersion {
case 1:
legacy := map[string]Grant{}
if len(head.Grants) > 0 {
if err := json.Unmarshal(head.Grants, &legacy); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
for name, grant := range legacy {
buckets[name] = []Grant{grant}
}
case grantSchemaVersion:
if len(head.Grants) > 0 {
if err := json.Unmarshal(head.Grants, &buckets); err != nil {
@@ -618,167 +581,7 @@ func (store *GrantStore) readState() (grantFile, error) {
normalizedPrefixes[key] = append(normalizedPrefixes[key], ng)
}
}
return grantFile{SchemaVersion: grantSchemaVersion, PolicyVersion: execution.PolicyVersion, Grants: normalized, CommandPrefixes: normalizedPrefixes, Migration: head.Migration}, nil
}
func (store *GrantStore) migrateLegacyState(data []byte, schemaVersion int, grantsJSON json.RawMessage, prefixesJSON json.RawMessage) (grantFile, error) {
buckets := map[string][]Grant{}
if schemaVersion == 1 {
legacy := map[string]Grant{}
if len(grantsJSON) > 0 {
if err := json.Unmarshal(grantsJSON, &legacy); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
for name, grant := range legacy {
buckets[name] = []Grant{grant}
}
} else if len(grantsJSON) > 0 {
if err := json.Unmarshal(grantsJSON, &buckets); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
prefixes := map[string][]CommandPrefixGrant{}
if len(prefixesJSON) > 0 {
if err := json.Unmarshal(prefixesJSON, &prefixes); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
backupPath, err := store.writeLegacyBackup(data, schemaVersion)
if err != nil {
return grantFile{}, err
}
state := emptyGrantState()
migrated, invalidated := 0, 0
for name, bucket := range buckets {
key := strings.TrimSpace(name)
if err := ValidateToolName(key); err != nil {
invalidated += len(bucket)
continue
}
for _, grant := range bucket {
normalized, err := normalizeStoredGrant(key, grant)
if err != nil || legacyShellGrant(normalized) {
invalidated++
continue
}
state.Grants[key] = append(state.Grants[key], normalized)
migrated++
}
}
for name, bucket := range prefixes {
key := strings.TrimSpace(name)
if err := ValidateToolName(key); err != nil {
invalidated += len(bucket)
continue
}
for _, grant := range bucket {
normalized, err := normalizeStoredCommandPrefixGrant(key, grant)
if err != nil {
invalidated++
continue
}
state.CommandPrefixes[key] = append(state.CommandPrefixes[key], normalized)
migrated++
}
}
state.Migration = &grantMigration{FromSchemaVersion: schemaVersion, BackupPath: backupPath, Migrated: migrated, Invalidated: invalidated, NoticePending: true}
if err := store.writeState(state); err != nil {
return grantFile{}, err
}
return state, nil
}
func (store *GrantStore) migrateChangedPolicy(data []byte, policyVersion int, grantsJSON json.RawMessage, prefixesJSON json.RawMessage) (grantFile, error) {
buckets := map[string][]Grant{}
prefixes := map[string][]CommandPrefixGrant{}
if len(grantsJSON) > 0 {
if err := json.Unmarshal(grantsJSON, &buckets); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
if len(prefixesJSON) > 0 {
if err := json.Unmarshal(prefixesJSON, &prefixes); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
backupPath, err := store.writeBackup(data, fmt.Sprintf("policy-v%d", policyVersion))
if err != nil {
return grantFile{}, err
}
state := emptyGrantState()
migrated, invalidated := 0, 0
for name, bucket := range buckets {
key := strings.TrimSpace(name)
if err := ValidateToolName(key); err != nil {
invalidated += len(bucket)
continue
}
for _, grant := range bucket {
normalized, err := normalizeStoredGrant(key, grant)
if err != nil || normalized.Decision != GrantDeny {
invalidated++
continue
}
state.Grants[key] = append(state.Grants[key], normalized)
migrated++
}
}
for _, bucket := range prefixes {
invalidated += len(bucket)
}
state.Migration = &grantMigration{FromSchemaVersion: grantSchemaVersion, BackupPath: backupPath, Migrated: migrated, Invalidated: invalidated, NoticePending: true}
if err := store.writeState(state); err != nil {
return grantFile{}, err
}
return state, nil
}
func legacyShellGrant(grant Grant) bool {
if grant.Decision == GrantDeny {
return false
}
switch grant.ToolName {
case "bash", "exec_command", "write_stdin":
return true
default:
return false
}
}
func (store *GrantStore) writeLegacyBackup(data []byte, schemaVersion int) (string, error) {
return store.writeBackup(data, fmt.Sprintf("v%d", schemaVersion))
}
func (store *GrantStore) writeBackup(data []byte, label string) (string, error) {
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
return "", err
}
base := fmt.Sprintf("%s.%s.backup", store.filePath, label)
for attempt := 0; ; attempt++ {
path := base
if attempt > 0 {
path = fmt.Sprintf("%s.%d", base, attempt)
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return "", err
}
if _, err := file.Write(data); err != nil {
_ = file.Close()
_ = os.Remove(path)
return "", err
}
if err := file.Close(); err != nil {
_ = os.Remove(path)
return "", err
}
return path, nil
}
return grantFile{SchemaVersion: grantSchemaVersion, Grants: normalized, CommandPrefixes: normalizedPrefixes}, nil
}
func (store *GrantStore) invalidGrantFile(err error) error {
@@ -854,7 +657,6 @@ func normalizeStoredCommandPrefixGrant(name string, grant CommandPrefixGrant) (C
func emptyGrantState() grantFile {
return grantFile{
SchemaVersion: grantSchemaVersion,
PolicyVersion: execution.PolicyVersion,
Grants: map[string][]Grant{},
CommandPrefixes: map[string][]CommandPrefixGrant{},
}
+11 -102
View File
@@ -70,11 +70,10 @@ func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
}
}
func TestGrantStoreMigratesExactV1GrantAndReportsOnce(t *testing.T) {
func TestGrantStoreReadsV1GrantFile(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
original := `{"schemaVersion":1,"grants":{"write_file":{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z","reason":"legacy"}}}`
if err := writeText(path, original); err != nil {
if err := writeText(path, `{"schemaVersion":1,"grants":{"bash":{"toolName":"bash","decision":"allow","approvedAt":"2026-06-05T14:30:00Z","reason":"legacy"}}}`); err != nil {
t.Fatalf("write v1 grants: %v", err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path, Now: fixedSandboxTime("2026-06-05T15:00:00Z")})
@@ -86,80 +85,19 @@ func TestGrantStoreMigratesExactV1GrantAndReportsOnce(t *testing.T) {
if err != nil {
t.Fatalf("List v1 grants returned error: %v", err)
}
if len(grants) != 1 || grants[0].ToolName != "write_file" || grants[0].Decision != GrantAllow || grants[0].Reason != "legacy" {
if len(grants) != 1 || grants[0].ToolName != "bash" || grants[0].Decision != GrantAllow || grants[0].Reason != "legacy" {
t.Fatalf("unexpected v1 grants: %#v", grants)
}
notice, err := store.ConsumeMigrationNotice()
if err != nil || !strings.Contains(notice, "migrated 1, invalidated 0") {
t.Fatalf("migration notice = %q err=%v", notice, err)
}
if again, err := store.ConsumeMigrationNotice(); err != nil || again != "" {
t.Fatalf("second migration notice = %q err=%v, want empty", again, err)
if _, err := store.Grant(GrantInput{ToolName: "write_file", Decision: GrantAllow}); err != nil {
t.Fatalf("Grant after v1 read returned error: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read rewritten grant file: %v", err)
}
if !strings.Contains(string(raw), `"schemaVersion": 3`) || !strings.Contains(string(raw), `"policyVersion": 1`) || !strings.Contains(string(raw), `"write_file": [`) {
t.Fatalf("grant file was not rewritten as a versioned grant store:\n%s", raw)
}
backup, err := os.ReadFile(path + ".v1.backup")
if err != nil || string(backup) != original {
t.Fatalf("migration backup = %q err=%v, want original", backup, err)
}
}
func TestGrantStoreInvalidatesLegacyShellAllowButKeepsSafePrefix(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":2,"grants":{"exec_command":[{"toolName":"exec_command","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"exec_command":[{"toolName":"exec_command","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
grants, err := store.List()
if err != nil || len(grants) != 0 {
t.Fatalf("legacy shell allows = %#v err=%v, want invalidated", grants, err)
}
prefixes, err := store.ListCommandPrefixes()
if err != nil || len(prefixes) != 1 || !sameStringSlice(prefixes[0].Prefix, []string{"git", "status"}) {
t.Fatalf("migrated prefixes = %#v err=%v", prefixes, err)
}
notice, err := store.ConsumeMigrationNotice()
if err != nil || !strings.Contains(notice, "migrated 1, invalidated 1") {
t.Fatalf("notice = %q err=%v", notice, err)
}
backup, err := os.ReadFile(path + ".v2.backup")
if err != nil || string(backup) != original {
t.Fatalf("backup = %q err=%v", backup, err)
}
}
func TestGrantStorePolicyChangePreservesDeniesAndInvalidatesApprovals(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":3,"policyVersion":0,"grants":{"write_file":[{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}],"bash":[{"toolName":"bash","decision":"deny","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"exec_command":[{"toolName":"exec_command","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
grants, err := store.List()
if err != nil || len(grants) != 1 || grants[0].Decision != GrantDeny {
t.Fatalf("migrated policy grants = %#v err=%v, want deny only", grants, err)
}
if prefixes, err := store.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
t.Fatalf("changed-policy prefixes = %#v err=%v, want invalidated", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "migrated 1, invalidated 2") {
t.Fatalf("notice = %q err=%v", notice, err)
}
backup, err := os.ReadFile(path + ".policy-v0.backup")
if err != nil || string(backup) != original {
t.Fatalf("policy backup = %q err=%v", backup, err)
if !strings.Contains(string(raw), `"schemaVersion": 2`) || !strings.Contains(string(raw), `"bash": [`) {
t.Fatalf("grant file was not rewritten as v2 buckets:\n%s", raw)
}
}
@@ -269,7 +207,7 @@ func TestGrantStoreRejectsUnsafeInputsAndMalformedFiles(t *testing.T) {
}
}
func TestGrantStoreInvalidatesUnsafeLegacyCommandPrefix(t *testing.T) {
func TestGrantStoreRejectsUnsafeStoredCommandPrefix(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
if err := writeText(path, `{"schemaVersion":2,"grants":{},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["find"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`); err != nil {
@@ -279,37 +217,8 @@ func TestGrantStoreInvalidatesUnsafeLegacyCommandPrefix(t *testing.T) {
if err != nil {
t.Fatalf("NewGrantStore returned error: %v", err)
}
if prefixes, err := store.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
t.Fatalf("unsafe legacy prefixes = %#v err=%v, want invalidated", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "invalidated 1") {
t.Fatalf("migration notice = %q err=%v", notice, err)
}
}
func TestGrantStoreInvalidatesMalformedLegacyToolKeys(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
original := `{"schemaVersion":2,"grants":{"../escape":[{"toolName":"../escape","decision":"deny","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"bad name":[{"toolName":"bad name","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
if grants, err := store.List(); err != nil || len(grants) != 0 {
t.Fatalf("malformed legacy grants = %#v err=%v, want invalidated", grants, err)
}
if prefixes, err := store.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
t.Fatalf("malformed legacy prefixes = %#v err=%v, want invalidated", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "invalidated 2") {
t.Fatalf("migration notice = %q err=%v", notice, err)
}
backup, err := os.ReadFile(path + ".v2.backup")
if err != nil || string(backup) != original {
t.Fatalf("backup = %q err=%v, want original", backup, err)
if _, err := store.ListCommandPrefixes(); err == nil || !strings.Contains(err.Error(), "invalid command prefix") {
t.Fatalf("expected invalid command prefix error, got %v", err)
}
}
+9 -51
View File
@@ -26,7 +26,6 @@ type LinuxSandboxCommandArgsOptions struct {
ApplySeccompThenExec bool
BlockUnixSockets bool
NoProc bool
PolicyReportPath string
Command []string
}
@@ -38,7 +37,6 @@ type LinuxSandboxHelperConfig struct {
ApplySeccompThenExec bool
BlockUnixSockets bool
NoProc bool
PolicyReportPath string
Command []string
}
@@ -53,16 +51,6 @@ type LinuxSandboxBwrapOptions struct {
HelperPath string
}
type linuxSandboxBwrapPlan struct {
Args []string
ProtectedCreateTargets []string
}
type linuxBwrapFilesystemPlan struct {
Args []string
ProtectedCreateTargets []string
}
var linuxSandboxHelperCommand = findLinuxSandboxHelperCommand
func BuildLinuxSandboxCommandArgs(options LinuxSandboxCommandArgsOptions) ([]string, error) {
@@ -98,9 +86,6 @@ func BuildLinuxSandboxCommandArgs(options LinuxSandboxCommandArgsOptions) ([]str
if options.NoProc {
args = append(args, "--no-proc")
}
if strings.TrimSpace(options.PolicyReportPath) != "" {
args = append(args, "--policy-report-path", options.PolicyReportPath)
}
args = append(args, "--")
args = append(args, options.Command...)
return args, nil
@@ -118,7 +103,6 @@ func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error
flags.BoolVar(&config.ApplySeccompThenExec, "apply-seccomp-then-exec", false, "apply seccomp before exec")
flags.BoolVar(&config.BlockUnixSockets, "block-unix-sockets", false, "block AF_UNIX sockets before exec")
flags.BoolVar(&config.NoProc, "no-proc", false, "skip proc mount")
flags.StringVar(&config.PolicyReportPath, "policy-report-path", "", "structured policy report path")
if err := flags.Parse(args); err != nil {
return LinuxSandboxHelperConfig{}, err
}
@@ -138,7 +122,6 @@ func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error
return LinuxSandboxHelperConfig{}, fmt.Errorf("invalid --permission-profile: %w", err)
}
config.Command = flags.Args()
config.PolicyReportPath = strings.TrimSpace(config.PolicyReportPath)
if len(config.Command) == 0 {
return LinuxSandboxHelperConfig{}, errors.New("missing command after --")
}
@@ -146,24 +129,16 @@ func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error
}
func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, error) {
plan, err := buildLinuxSandboxBwrapPlan(options)
if err != nil {
return nil, err
}
return plan.Args, nil
}
func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxBwrapPlan, error) {
config := options.Config
if config.ApplySeccompThenExec {
return linuxSandboxBwrapPlan{}, errors.New("inner seccomp stage cannot be wrapped by bubblewrap again")
return nil, errors.New("inner seccomp stage cannot be wrapped by bubblewrap again")
}
if config.UseLandlock {
return linuxSandboxBwrapPlan{}, errors.New("linux landlock helper mode is not implemented yet")
return nil, errors.New("linux landlock helper mode is not implemented yet")
}
helperPath := strings.TrimSpace(options.HelperPath)
if helperPath == "" {
return linuxSandboxBwrapPlan{}, errors.New("linux sandbox helper path is required")
return nil, errors.New("linux sandbox helper path is required")
}
commandCWD := strings.TrimSpace(config.CommandCWD)
if commandCWD == "" {
@@ -179,14 +154,13 @@ func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxB
Command: config.Command,
})
if err != nil {
return linuxSandboxBwrapPlan{}, err
return nil, err
}
args := []string{
"--new-session",
"--die-with-parent",
}
filesystemPlan := buildLinuxBwrapFilesystemPlan(config.PermissionProfile)
args = append(args, filesystemPlan.Args...)
args = append(args, linuxBwrapFilesystemArgs(config.PermissionProfile)...)
if pathExists(helperPath) {
args = append(args, "--ro-bind", helperPath, helperPath)
}
@@ -211,17 +185,10 @@ func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxB
}
args = append(args, "--", helperPath)
args = append(args, innerArgs...)
return linuxSandboxBwrapPlan{
Args: args,
ProtectedCreateTargets: filesystemPlan.ProtectedCreateTargets,
}, nil
return args, nil
}
func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
return buildLinuxBwrapFilesystemPlan(profile).Args
}
func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesystemPlan {
fs := profile.FileSystem
if fs.Kind == FileSystemUnrestricted {
// Disabled filesystem policy means no write jail: expose the host root
@@ -233,11 +200,10 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesyst
args = append(args, "--bind", root.Root, root.Root)
}
}
return linuxBwrapFilesystemPlan{Args: args}
return args
}
args := []string{}
protectedCreateTargets := []string{}
if linuxProfileHasFullReadRoot(fs) {
args = append(args, "--ro-bind", "/", "/", "--dev", "/dev")
} else {
@@ -265,12 +231,7 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesyst
args = appendReadOnlyLinuxPathArgs(args, subpath)
}
for _, name := range root.ProtectedMetadataNames {
path := filepath.Join(root.Root, name)
if pathExists(path) {
args = appendReadOnlyLinuxPathArgs(args, path)
} else {
protectedCreateTargets = append(protectedCreateTargets, path)
}
args = appendReadOnlyLinuxPathArgs(args, filepath.Join(root.Root, name))
}
}
for _, path := range fs.DenyWrite {
@@ -279,10 +240,7 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesyst
for _, path := range fs.DenyRead {
args = appendUnreadableLinuxPathArgs(args, path)
}
return linuxBwrapFilesystemPlan{
Args: args,
ProtectedCreateTargets: dedupeStrings(protectedCreateTargets),
}
return args
}
func linuxWriteRootsWithTemp(fs FileSystemPolicy) []WritableRoot {
+6 -14
View File
@@ -11,8 +11,8 @@ import (
)
var (
applyUnixSocketBlockFilter = ApplyUnixSocketBlock
applyLinuxIsolatedNetworkGuardFilter = ApplyLinuxIsolatedNetworkGuard
applyUnixSocketBlockFilter = ApplyUnixSocketBlock
applyLinuxNetworkDenyFilter = ApplyLinuxNetworkDeny
)
func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
@@ -32,7 +32,7 @@ func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": resolve helper path: "+err.Error())
return 125
}
bwrapPlan, err := buildLinuxSandboxBwrapPlan(LinuxSandboxBwrapOptions{
bwrapArgs, err := BuildLinuxSandboxBwrapArgs(LinuxSandboxBwrapOptions{
Config: config,
HelperPath: helperPath,
})
@@ -45,10 +45,7 @@ func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": bubblewrap is not available: "+err.Error())
return 125
}
if len(bwrapPlan.ProtectedCreateTargets) > 0 {
return runLinuxSandboxWithProtectedCreateMonitor(bwrapPath, bwrapPlan, config.PolicyReportPath, stderr)
}
if err := syscall.Exec(bwrapPath, append([]string{"bwrap"}, bwrapPlan.Args...), os.Environ()); err != nil {
if err := syscall.Exec(bwrapPath, append([]string{"bwrap"}, bwrapArgs...), os.Environ()); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": exec bubblewrap: "+err.Error())
return 126
}
@@ -72,14 +69,9 @@ func runLinuxSandboxInnerStage(config LinuxSandboxHelperConfig, stderr io.Writer
fmt.Fprintln(stderr, LinuxSandboxHelperName+": inner seccomp stage is incompatible with Landlock mode")
return 2
}
// The outer bubblewrap stage already enforces NetworkDeny with a private
// network namespace. Do not install the broad seccomp network filter here:
// it would also block AF_INET/AF_INET6 sockets inside that namespace and
// break isolated localhost test servers. The namespace has loopback only,
// so local bind/connect works while external egress remains unreachable.
if shouldUnshareLinuxNetwork(config.PermissionProfile.Network) {
if err := applyLinuxIsolatedNetworkGuardFilter(); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": apply isolated network guard: "+err.Error())
if err := applyLinuxNetworkDenyFilter(); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": apply network deny: "+err.Error())
return 125
}
}
+15 -15
View File
@@ -8,18 +8,18 @@ import (
"testing"
)
func TestLinuxSandboxInnerStageReliesOnOuterNamespaceForNetworkDeny(t *testing.T) {
originalNetworkGuard := applyLinuxIsolatedNetworkGuardFilter
func TestLinuxSandboxInnerStageAppliesNetworkDenySeccomp(t *testing.T) {
originalNetworkDeny := applyLinuxNetworkDenyFilter
originalUnixBlock := applyUnixSocketBlockFilter
t.Cleanup(func() {
applyLinuxIsolatedNetworkGuardFilter = originalNetworkGuard
applyLinuxNetworkDenyFilter = originalNetworkDeny
applyUnixSocketBlockFilter = originalUnixBlock
})
networkGuardCalls := 0
networkDenyCalls := 0
unixBlockCalls := 0
applyLinuxIsolatedNetworkGuardFilter = func() error {
networkGuardCalls++
applyLinuxNetworkDenyFilter = func() error {
networkDenyCalls++
return nil
}
applyUnixSocketBlockFilter = func() error {
@@ -35,24 +35,24 @@ func TestLinuxSandboxInnerStageReliesOnOuterNamespaceForNetworkDeny(t *testing.T
}, &stderr)
if code != 127 {
t.Fatalf("exit code = %d, want lookup failure 127 after Unix-socket filter; stderr=%s", code, stderr.String())
t.Fatalf("exit code = %d, want lookup failure 127 after filters; stderr=%s", code, stderr.String())
}
if networkGuardCalls != 1 {
t.Fatalf("isolated network guard calls = %d, want 1", networkGuardCalls)
if networkDenyCalls != 1 {
t.Fatalf("network deny filter calls = %d, want 1", networkDenyCalls)
}
if unixBlockCalls != 1 {
t.Fatalf("unix socket filter calls = %d, want 1", unixBlockCalls)
}
}
func TestLinuxSandboxInnerStageSkipsIsolatedNetworkGuardWhenNetworkAllowed(t *testing.T) {
originalNetworkGuard := applyLinuxIsolatedNetworkGuardFilter
func TestLinuxSandboxInnerStageSkipsNetworkDenySeccompWhenAllowed(t *testing.T) {
originalNetworkDeny := applyLinuxNetworkDenyFilter
t.Cleanup(func() {
applyLinuxIsolatedNetworkGuardFilter = originalNetworkGuard
applyLinuxNetworkDenyFilter = originalNetworkDeny
})
applyLinuxIsolatedNetworkGuardFilter = func() error {
return errors.New("isolated network guard should not run")
applyLinuxNetworkDenyFilter = func() error {
return errors.New("network deny filter should not run")
}
var stderr bytes.Buffer
@@ -62,6 +62,6 @@ func TestLinuxSandboxInnerStageSkipsIsolatedNetworkGuardWhenNetworkAllowed(t *te
}, &stderr)
if code != 127 {
t.Fatalf("exit code = %d, want lookup failure 127 without isolated network guard; stderr=%s", code, stderr.String())
t.Fatalf("exit code = %d, want lookup failure 127 without network filter; stderr=%s", code, stderr.String())
}
}
-29
View File
@@ -236,35 +236,6 @@ func TestLinuxBwrapTempUsesHostWriteRoots(t *testing.T) {
}
}
func TestLinuxBwrapFilesystemPlanPreservesMissingProtectedMetadata(t *testing.T) {
workspace := t.TempDir()
existing := filepath.Join(workspace, ".git")
if err := os.Mkdir(existing, 0o755); err != nil {
t.Fatalf("Mkdir existing metadata: %v", err)
}
missing := filepath.Join(workspace, ".zero")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{
Root: workspace,
ProtectedMetadataNames: []string{".git", ".zero"},
}},
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
plan := buildLinuxBwrapFilesystemPlan(profile)
assertArgsContainSequence(t, plan.Args, "--ro-bind", existing, existing)
if argsContainSequence(plan.Args, "--tmpfs", missing) || argsContainSequence(plan.Args, "--ro-bind", missing, missing) {
t.Fatalf("missing protected metadata must remain absent inside the sandbox: %#v", plan.Args)
}
if !reflect.DeepEqual(plan.ProtectedCreateTargets, []string{missing}) {
t.Fatalf("protected create targets = %#v, want %#v", plan.ProtectedCreateTargets, []string{missing})
}
}
func TestLinuxBwrapUnrestrictedFilesystemUsesWritableHostRoot(t *testing.T) {
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
-7
View File
@@ -228,9 +228,6 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
}
if request.ValidateExecution && preference != SandboxPreferenceForbid && backend.SupportLevel() != BackendSupportNative && policyHasExplicitDeny(policy) {
return SandboxExecutionRequest{}, errors.New("native sandbox unavailable: configured deny_read or deny_write rules cannot be enforced")
}
// Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup`
// (it applies WFP network filters + workspace ACLs and writes a marker).
// Without it, a restricted-filesystem profile can still run in the UNELEVATED
@@ -286,10 +283,6 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
}, nil
}
func policyHasExplicitDeny(policy Policy) bool {
return len(normalizeProfilePaths(policy.DenyRead)) > 0 || len(normalizeProfilePaths(policy.DenyWrite)) > 0
}
func (manager SandboxManager) BuildCommandPlan(request SandboxManagerRequest) (CommandPlan, error) {
execRequest, err := manager.BuildExecutionRequest(request)
if err != nil {
@@ -1,87 +0,0 @@
package sandbox
import (
"path/filepath"
"strings"
"testing"
)
// The implementation mechanism differs by OS, but these observable policy
// semantics must remain stable across adapters.
func TestPlatformAdaptersShareExecutionContract(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
profile := PermissionProfileFromPolicy(root, policy, nil)
if !profile.RequiresPlatformSandbox() {
t.Fatal("baseline profile must require platform enforcement")
}
protected := strings.Join(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, "\x00")
for _, name := range []string{".zero", ".agents"} {
if !strings.Contains(protected, name) {
t.Fatalf("baseline profile missing protected metadata %q: %#v", name, profile.FileSystem.WriteRoots[0].ProtectedMetadataNames)
}
}
resolvedRoot := profile.FileSystem.WriteRoots[0].Root
for _, subpath := range []string{filepath.Join(resolvedRoot, ".git", "hooks"), filepath.Join(resolvedRoot, ".git", "config")} {
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ReadOnlySubpaths, subpath) {
t.Fatalf("baseline profile missing git metadata carveout %q: %#v", subpath, profile.FileSystem.WriteRoots[0].ReadOnlySubpaths)
}
}
t.Run("macOS native plan", func(t *testing.T) {
backend := Backend{Name: BackendMacOSSeatbelt, Available: true, Platform: "darwin", CommandWrapping: true, NativeIsolation: true, Executable: "/usr/bin/sandbox-exec"}
request, err := NewSandboxManager(SandboxManagerOptions{GOOS: "darwin", Backend: backend}).BuildExecutionRequest(SandboxManagerRequest{
WorkspaceRoot: root, Policy: policy, Profile: profile, Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: root},
})
if err != nil {
t.Fatal(err)
}
if request.TargetBackend != BackendMacOSSeatbelt || !request.CommandWrapped || request.EnforcementLevel != EnforcementNative {
t.Fatalf("macOS request = %#v", request)
}
network := networkRuleForProfile(request.PermissionProfile.Network)
if network != "(deny network*)" {
t.Fatalf("macOS restricted network profile = %q, want strict deny", network)
}
})
t.Run("Windows native plan", func(t *testing.T) {
original := windowsSandboxInitialized
windowsSandboxInitialized = func() bool { return true }
t.Cleanup(func() { windowsSandboxInitialized = original })
backend := Backend{Name: BackendWindowsRestrictedToken, Available: true, Platform: "windows", CommandWrapping: true, NativeIsolation: true, Executable: `C:\zero\zero.exe`}
request, err := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend}).BuildExecutionRequest(SandboxManagerRequest{
WorkspaceRoot: root, Policy: policy, Profile: profile, Command: CommandSpec{Name: "cmd.exe", Args: WindowsShellArgs("echo ok"), Dir: root},
})
if err != nil {
t.Fatal(err)
}
if request.TargetBackend != BackendWindowsRestrictedToken || !request.CommandWrapped || request.EnforcementLevel != EnforcementNative {
t.Fatalf("Windows request = %#v", request)
}
if request.PermissionProfile.Network.Mode != NetworkDeny {
t.Fatalf("Windows native plan lost network restriction: %#v", request.PermissionProfile.Network)
}
})
}
func TestUnavailablePlatformAdaptersFailClosedOnlyForExplicitDenies(t *testing.T) {
root := t.TempDir()
for _, goos := range []string{"darwin", "windows"} {
t.Run(goos, func(t *testing.T) {
backend := unavailableBackend(goos, "native sandbox unavailable")
manager := NewSandboxManager(SandboxManagerOptions{GOOS: goos, Backend: backend})
baseline := DefaultPolicy()
request, err := manager.BuildExecutionRequest(SandboxManagerRequest{WorkspaceRoot: root, Policy: baseline, Command: CommandSpec{Name: "echo", Dir: root}})
if err != nil || request.EnforcementLevel != EnforcementDegraded || request.CommandWrapped {
t.Fatalf("baseline degraded request=%#v err=%v", request, err)
}
hardened := baseline
hardened.DenyRead = []string{"secrets"}
_, err = manager.BuildExecutionRequest(SandboxManagerRequest{WorkspaceRoot: root, Policy: hardened, Command: CommandSpec{Name: "echo", Dir: root}, ValidateExecution: true})
if err == nil {
t.Fatal("explicit deny on unavailable adapter must fail closed")
}
})
}
}
-1
View File
@@ -18,7 +18,6 @@ const (
type PermissionProfile struct {
FileSystem FileSystemPolicy `json:"fileSystem"`
Network NetworkPolicy `json:"network"`
Runtime *SandboxRuntime `json:"runtime,omitempty"`
}
type FileSystemPolicy struct {
-283
View File
@@ -1,283 +0,0 @@
//go:build linux
package sandbox
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/execution"
"golang.org/x/sys/unix"
)
const protectedCreatePollInterval = 10 * time.Millisecond
// An absent protected path cannot be mounted read-only without making it
// appear inside the child. The monitor preserves workspace path fidelity and
// treats the kernel's create/move event as the denial signal. The path can
// briefly appear on the host before the command is killed and it is removed;
// parsing the event ensures a fast create+unlink is still reported.
type protectedCreateMonitor struct {
targets []string
stderr io.Writer
inotifyFD int
watches map[int]string
targetSet map[string]struct{}
stop chan struct{}
done chan struct{}
violation atomic.Bool
once sync.Once
path string
}
type synchronizedWriter struct {
mu sync.Mutex
writer io.Writer
}
func (writer *synchronizedWriter) Write(data []byte) (int, error) {
writer.mu.Lock()
defer writer.mu.Unlock()
return writer.writer.Write(data)
}
func runLinuxSandboxWithProtectedCreateMonitor(bwrapPath string, plan linuxSandboxBwrapPlan, reportPath string, stderr io.Writer) int {
if stderr == nil {
stderr = io.Discard
}
safeStderr := &synchronizedWriter{writer: stderr}
monitor, err := newProtectedCreateMonitor(plan.ProtectedCreateTargets, safeStderr)
if err != nil {
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": prepare protected metadata monitor: "+err.Error())
return 125
}
command := exec.Command(bwrapPath, plan.Args...)
command.Stdin = os.Stdin
command.Stdout = os.Stdout
command.Stderr = safeStderr
command.Env = os.Environ()
if err := command.Start(); err != nil {
monitor.close()
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": start bubblewrap: "+err.Error())
return 126
}
monitor.start(func() {
_ = command.Process.Kill()
})
waitErr := command.Wait()
violated, target := monitor.stopAndCleanup()
if violated {
report := execution.AdapterReport{Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: target},
Source: execution.DenialSourceConfiguredPolicy,
Reason: "protected workspace metadata cannot be created by a sandboxed command",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
}}
if err := writeLinuxExecutionReport(reportPath, report); err != nil {
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": write structured policy report: "+err.Error())
}
return 1
}
if waitErr == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) {
return exitErr.ExitCode()
}
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": wait for bubblewrap: "+waitErr.Error())
return 126
}
func newProtectedCreateMonitor(targets []string, stderr io.Writer) (*protectedCreateMonitor, error) {
monitor := &protectedCreateMonitor{
targets: dedupeStrings(targets),
stderr: stderr,
inotifyFD: -1,
stop: make(chan struct{}),
done: make(chan struct{}),
watches: make(map[int]string),
targetSet: make(map[string]struct{}),
}
for _, target := range monitor.targets {
target = filepath.Clean(target)
monitor.targetSet[target] = struct{}{}
if _, err := os.Lstat(target); err == nil {
return nil, fmt.Errorf("protected metadata path appeared before sandbox start: %s", target)
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("inspect protected metadata path %s: %w", target, err)
}
}
monitor.inotifyFD, monitor.watches = openProtectedCreateInotify(monitor.targets)
return monitor, nil
}
func openProtectedCreateInotify(targets []string) (int, map[int]string) {
fd, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
if err != nil {
return -1, nil
}
parents := make(map[string]struct{}, len(targets))
watches := make(map[int]string, len(targets))
for _, target := range targets {
parent := filepath.Dir(target)
if _, exists := parents[parent]; exists {
continue
}
parents[parent] = struct{}{}
watch, err := unix.InotifyAddWatch(fd, parent, unix.IN_CREATE|unix.IN_MOVED_TO|unix.IN_DELETE_SELF|unix.IN_MOVE_SELF)
if err != nil {
continue
}
watches[watch] = parent
}
if len(watches) == 0 {
_ = unix.Close(fd)
return -1, nil
}
return fd, watches
}
func (monitor *protectedCreateMonitor) start(onViolation func()) {
go func() {
defer close(monitor.done)
for {
monitor.scanAndRemove(onViolation)
monitor.wait(onViolation)
select {
case <-monitor.stop:
return
default:
}
}
}()
}
func (monitor *protectedCreateMonitor) wait(onViolation func()) {
if monitor.inotifyFD < 0 {
select {
case <-monitor.stop:
case <-time.After(protectedCreatePollInterval):
}
return
}
pollFD := []unix.PollFd{{Fd: int32(monitor.inotifyFD), Events: unix.POLLIN}}
_, _ = unix.Poll(pollFD, int(protectedCreatePollInterval/time.Millisecond))
if pollFD[0].Revents&unix.POLLIN != 0 {
monitor.drainInotifyEvents(onViolation)
}
}
func (monitor *protectedCreateMonitor) drainInotifyEvents(onViolation func()) {
var buffer [4096]byte
const eventHeaderSize = 16
for {
read, err := unix.Read(monitor.inotifyFD, buffer[:])
if err != nil {
return
}
for offset := 0; offset+eventHeaderSize <= read; {
watch := int(int32(binary.NativeEndian.Uint32(buffer[offset : offset+4])))
mask := binary.NativeEndian.Uint32(buffer[offset+4 : offset+8])
nameLength := int(binary.NativeEndian.Uint32(buffer[offset+12 : offset+16]))
next := offset + eventHeaderSize + nameLength
if nameLength < 0 || next > read {
break
}
name := strings.TrimRight(string(buffer[offset+eventHeaderSize:next]), "\x00")
if parent, ok := monitor.watches[watch]; ok && mask&(unix.IN_CREATE|unix.IN_MOVED_TO) != 0 && name != "" {
target := filepath.Clean(filepath.Join(parent, name))
if _, protected := monitor.targetSet[target]; protected {
monitor.recordViolation(target, onViolation)
removeProtectedCreateTarget(target)
}
}
offset = next
}
}
}
func (monitor *protectedCreateMonitor) scanAndRemove(onViolation func()) {
for _, target := range monitor.targets {
if _, err := os.Lstat(target); os.IsNotExist(err) {
continue
} else if err != nil {
continue
}
monitor.recordViolation(target, onViolation)
removeProtectedCreateTarget(target)
}
}
func (monitor *protectedCreateMonitor) recordViolation(target string, onViolation func()) {
monitor.once.Do(func() {
monitor.violation.Store(true)
monitor.path = target
fmt.Fprintln(monitor.stderr, "sandbox blocked creation of protected workspace metadata path "+target)
onViolation()
})
}
func removeProtectedCreateTarget(target string) bool {
for attempt := 0; attempt < 100; attempt++ {
_, err := os.Lstat(target)
if os.IsNotExist(err) {
return attempt > 0
}
if err != nil {
return false
}
err = os.RemoveAll(target)
if err == nil || os.IsNotExist(err) {
return true
}
time.Sleep(time.Millisecond)
}
return false
}
func (monitor *protectedCreateMonitor) stopAndCleanup() (bool, string) {
close(monitor.stop)
<-monitor.done
monitor.scanAndRemove(func() {})
monitor.close()
return monitor.violation.Load(), monitor.path
}
func (monitor *protectedCreateMonitor) close() {
if monitor.inotifyFD >= 0 {
_ = unix.Close(monitor.inotifyFD)
monitor.inotifyFD = -1
}
}
func writeLinuxExecutionReport(path string, report execution.AdapterReport) error {
if path == "" {
return errors.New("policy report path is unavailable")
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
encoderErr := json.NewEncoder(file).Encode(report)
closeErr := file.Close()
if encoderErr != nil {
_ = os.Remove(path)
return encoderErr
}
return closeErr
}
@@ -1,67 +0,0 @@
//go:build linux
package sandbox
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestProtectedCreateMonitorStopsCommandAndRemovesTarget(t *testing.T) {
target := filepath.Join(t.TempDir(), ".zero")
var stderr bytes.Buffer
started := time.Now()
code := runLinuxSandboxWithProtectedCreateMonitor("/bin/sh", linuxSandboxBwrapPlan{
Args: []string{"-c", "mkdir " + shellQuote(target) + "; while :; do :; done"},
ProtectedCreateTargets: []string{target},
}, "", &stderr)
if code != 1 {
t.Fatalf("exit code = %d, want policy failure 1; stderr=%s", code, stderr.String())
}
if elapsed := time.Since(started); elapsed > 2*time.Second {
t.Fatalf("protected creation did not stop command promptly; elapsed=%s", elapsed)
}
if !strings.Contains(stderr.String(), "blocked creation of protected workspace metadata path") {
t.Fatalf("missing denial reason: %s", stderr.String())
}
if _, err := os.Lstat(target); !os.IsNotExist(err) {
t.Fatalf("protected target remained after denial: %v", err)
}
}
func TestProtectedCreateMonitorRecordsTransientCreateEvent(t *testing.T) {
target := filepath.Join(t.TempDir(), ".zero")
var stderr bytes.Buffer
monitor, err := newProtectedCreateMonitor([]string{target}, &stderr)
if err != nil {
t.Fatalf("newProtectedCreateMonitor: %v", err)
}
if monitor.inotifyFD < 0 {
monitor.close()
t.Skip("inotify is unavailable")
}
if err := os.Mkdir(target, 0o700); err != nil {
monitor.close()
t.Fatal(err)
}
if err := os.Remove(target); err != nil {
monitor.close()
t.Fatal(err)
}
triggered := make(chan struct{}, 1)
monitor.start(func() { triggered <- struct{}{} })
violated, gotTarget := monitor.stopAndCleanup()
if !violated || gotTarget != target {
t.Fatalf("transient creation = violated %t target %q, want true %q; stderr=%s", violated, gotTarget, target, stderr.String())
}
select {
case <-triggered:
default:
t.Fatal("transient create event did not invoke violation callback")
}
}
-32
View File
@@ -311,38 +311,6 @@ func (engine *Engine) GrantRequestPermissions(profile RequestPermissionProfile,
}
}
// CoversRequestPermissions reports whether the engine's current effective
// policy already includes every capability in profile. Callers use this before
// prompting for an explicit per-command permission request: asking for a
// capability that was granted for the session is not a new elevation.
func (engine *Engine) CoversRequestPermissions(profile RequestPermissionProfile) bool {
if engine == nil {
return false
}
policy := engine.effectivePolicy(engine.policy)
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled &&
NormalizeNetworkMode(policy.Network) != NetworkAllow {
return false
}
if profile.FileSystem == nil {
return true
}
workspaceRoot := engine.workspaceRoot
scope := engine.scopeFor(workspaceRoot)
enforceWorkspace := policy.EnforceWorkspace && workspaceRoot != ""
for _, path := range profile.FileSystem.Read {
if validatePathWithPolicy(scope, policy, SideEffectRead, enforceWorkspace, workspaceRoot, path) != nil {
return false
}
}
for _, path := range profile.FileSystem.Write {
if validatePathWithPolicy(scope, policy, SideEffectWrite, enforceWorkspace, workspaceRoot, path) != nil {
return false
}
}
return true
}
func permissionRoot(path string) string {
clean := filepath.Clean(path)
if info, err := os.Stat(clean); err == nil && info.IsDir() {
@@ -140,40 +140,6 @@ func TestGrantRequestPermissionsSessionPersists(t *testing.T) {
}
}
func TestCoversRequestPermissionsReusesSessionNetworkCapability(t *testing.T) {
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy()})
enabled := true
profile := RequestPermissionProfile{Network: &NetworkPermissions{Enabled: &enabled}}
if engine.CoversRequestPermissions(profile) {
t.Fatal("default network-deny policy must not cover external network access")
}
cleanup, err := engine.GrantRequestPermissions(profile, PermissionGrantScopeSession)
if err != nil {
t.Fatalf("GrantRequestPermissions: %v", err)
}
cleanup()
if !engine.CoversRequestPermissions(profile) {
t.Fatal("session network grant should cover an equivalent later request")
}
}
func TestCoversRequestPermissionsDoesNotOutliveTurnGrant(t *testing.T) {
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy()})
enabled := true
profile := RequestPermissionProfile{Network: &NetworkPermissions{Enabled: &enabled}}
cleanup, err := engine.GrantRequestPermissions(profile, PermissionGrantScopeTurn)
if err != nil {
t.Fatalf("GrantRequestPermissions: %v", err)
}
if !engine.CoversRequestPermissions(profile) {
t.Fatal("active turn grant should cover the requested network capability")
}
cleanup()
if engine.CoversRequestPermissions(profile) {
t.Fatal("cleaned-up turn grant must not cover later requests")
}
}
func TestPermissionProfileIncludesReadOnlyGrantAsReadRootOnly(t *testing.T) {
workspace := t.TempDir()
outside := tempDirOutsideDefaultTemp(t)
+36 -109
View File
@@ -2,9 +2,6 @@ package sandbox
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
@@ -14,7 +11,6 @@ import (
"strings"
"sync/atomic"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/providercatalog"
)
@@ -66,10 +62,6 @@ type CommandPlan struct {
// cleanup releases resources tied to the plan's lifetime. It is never
// serialized; callers invoke it via Cleanup() once the command has finished.
cleanup func()
// executionReportPath is an adapter-owned side channel outside the
// workspace. It carries structured policy facts; command output is never
// parsed as the control protocol.
executionReportPath string
}
// Cleanup releases any resources the plan holds. It is safe to call on a zero
@@ -80,26 +72,6 @@ func (plan CommandPlan) Cleanup() {
}
}
// ExecutionReport reads the structured platform-adapter report for a finished
// command. A command with no adapter report returns an empty report.
func (plan CommandPlan) ExecutionReport() (execution.AdapterReport, error) {
if strings.TrimSpace(plan.executionReportPath) == "" {
return execution.AdapterReport{}, nil
}
data, err := os.ReadFile(plan.executionReportPath)
if os.IsNotExist(err) {
return execution.AdapterReport{}, nil
}
if err != nil {
return execution.AdapterReport{}, fmt.Errorf("read sandbox execution report: %w", err)
}
var report execution.AdapterReport
if err := json.Unmarshal(data, &report); err != nil {
return execution.AdapterReport{}, fmt.Errorf("decode sandbox execution report: %w", err)
}
return report, nil
}
func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*exec.Cmd, CommandPlan, error) {
if ctx == nil {
ctx = context.Background()
@@ -114,35 +86,6 @@ func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*ex
return command, plan, nil
}
// PrepareExecution adapts the platform-neutral execution interface to the
// sandbox engine. Callers never need to construct native sandbox commands or
// interpret adapter reports themselves.
func (engine *Engine) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
if err := request.Validate(); err != nil {
return execution.PreparedCommand{}, err
}
command, plan, err := engine.CommandContext(ctx, CommandSpec{
Name: request.Command.Name,
Args: append([]string(nil), request.Command.Args...),
Dir: request.WorkingDirectory,
Env: append([]string(nil), request.Command.Env...),
})
if err != nil {
return execution.PreparedCommand{}, err
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{
Backend: string(plan.TargetBackend),
Level: string(plan.EnforcementLevel),
Degraded: plan.EnforcementLevel == EnforcementDegraded,
DowngradeReason: plan.DowngradeReason,
},
Report: plan.ExecutionReport,
Cleanup: plan.Cleanup,
}, nil
}
// writeRoots returns the full ordered write-root list for command plans:
// the workspace root plus any granted extra roots. The single-root fallback
// only applies to engines built without a workspace root (NewEngine always
@@ -201,20 +144,11 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
preference = SandboxPreferenceForbid
}
profile := PermissionProfileFromPolicy(workspaceRoot, policy, engine.scope)
var runtimeCleanup func()
if preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled {
runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot)
if runtimeErr != nil {
return CommandPlan{}, runtimeErr
}
runtimeCleanup = cleanup
profile = permissionProfileWithRuntime(profile, runtimeState)
}
manager := NewSandboxManager(SandboxManagerOptions{
GOOS: backend.Platform,
Backend: backend,
})
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
return manager.BuildCommandPlan(SandboxManagerRequest{
WorkspaceRoot: workspaceRoot,
Command: spec,
Policy: policy,
@@ -223,14 +157,6 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
Preference: preference,
ValidateExecution: true,
})
if err != nil {
if runtimeCleanup != nil {
runtimeCleanup()
}
return CommandPlan{}, err
}
plan.cleanup = combineSandboxCleanups(plan.cleanup, runtimeCleanup)
return plan, nil
}
func buildPlatformCommandPlan(execRequest SandboxExecutionRequest, policy Policy) (CommandPlan, error) {
@@ -275,56 +201,38 @@ func linuxSandboxHelperCommandPlan(execRequest SandboxExecutionRequest, policy P
helper = resolved
}
command := append([]string{spec.Name}, spec.Args...)
reportPath, err := newLinuxExecutionReportPath()
if err != nil {
return CommandPlan{}, err
}
args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
SandboxPolicyCWD: execRequest.WorkspaceRoot,
CommandCWD: spec.Dir,
PermissionProfile: execRequest.PermissionProfile,
BlockUnixSockets: policy.BlockUnixSockets,
PolicyReportPath: reportPath,
Command: command,
})
if err != nil {
return CommandPlan{}, err
}
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendLinuxBwrap, "", spec.sensitiveEnvKeys)
env = sandboxRuntimeEnvironment(env, execRequest.PermissionProfile.Runtime)
planDir := spec.Dir
if helper.Dir != "" {
planDir = helper.Dir
}
plan := CommandPlan{
Backend: execRequest.Backend,
TargetBackend: execRequest.TargetBackend,
WorkspaceRoot: execRequest.WorkspaceRoot,
Policy: policy,
Wrapped: true,
SandboxEnvMarkers: execRequest.SandboxEnvMarkers,
EnforcementLevel: execRequest.EnforcementLevel,
Name: helper.Name,
Args: append(append([]string{}, helper.ArgsPrefix...), args...),
Dir: planDir,
Env: env,
SandboxDir: spec.Dir,
executionReportPath: reportPath,
cleanup: func() {
_ = os.Remove(reportPath)
},
Backend: execRequest.Backend,
TargetBackend: execRequest.TargetBackend,
WorkspaceRoot: execRequest.WorkspaceRoot,
Policy: policy,
Wrapped: true,
SandboxEnvMarkers: execRequest.SandboxEnvMarkers,
EnforcementLevel: execRequest.EnforcementLevel,
Name: helper.Name,
Args: append(append([]string{}, helper.ArgsPrefix...), args...),
Dir: planDir,
Env: env,
SandboxDir: spec.Dir,
}
return withSandboxExecutionMetadata(plan, execRequest), nil
}
func newLinuxExecutionReportPath() (string, error) {
var token [16]byte
if _, err := rand.Read(token[:]); err != nil {
return "", fmt.Errorf("generate sandbox execution report path: %w", err)
}
return filepath.Join("/tmp", "zero-sandbox-report-"+hex.EncodeToString(token[:])+".json"), nil
}
func withSandboxExecutionMetadata(plan CommandPlan, request SandboxExecutionRequest) CommandPlan {
plan.Backend = request.Backend
plan.TargetBackend = request.TargetBackend
@@ -432,7 +340,6 @@ func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, prof
envBackend = BackendMacOSSeatbelt
}
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, envBackend, "", spec.sensitiveEnvKeys)
env = sandboxRuntimeEnvironment(env, profile.Runtime)
plan := CommandPlan{
Backend: backend,
TargetBackend: backend.TargetBackend(),
@@ -475,6 +382,17 @@ func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy)
}
}
func existingBubblewrapMounts() []string {
candidates := []string{"/bin", "/usr", "/lib", "/lib64", "/sbin", "/etc"}
mounts := []string{}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
mounts = append(mounts, candidate)
}
}
return mounts
}
func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string) []string {
return sandboxEnvironmentForCommand(nil, policy, backend, workspaceRoot)
}
@@ -871,6 +789,13 @@ func writeRootCarveoutDenyRules(fs FileSystemPolicy) []string {
return out
}
// denyWriteRules returns seatbelt deny clauses for the policy's resolved
// DenyWrite paths: a (subpath ...) clause for a directory, a (literal ...) clause
// for a single file. Empty when DenyWrite is unset.
func denyWriteRules(policy Policy) []string {
return denyWriteRulesFromPaths(resolvePolicyPaths(policy.DenyWrite))
}
func denyWriteRulesFromPaths(paths []string) []string {
return denySeatbeltPathRules("file-write*", paths)
}
@@ -898,14 +823,16 @@ func denySeatbeltPathRules(action string, paths []string) []string {
return out
}
// networkRuleFor returns the seatbelt network clause for a policy.
func networkRuleFor(policy Policy) string {
return networkRuleForProfile(NetworkPolicy{Mode: policy.Network})
}
func networkRuleForProfile(network NetworkPolicy) string {
switch network.Mode {
case NetworkAllow:
return "(allow network*)"
default:
// Seatbelt has no private network namespace. Its localhost filters can
// reach services on the host and host interfaces, so the restricted
// profile must deny the entire network surface.
return "(deny network*)"
}
}
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff. Show more