mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe98067153 | ||
|
|
52b3338a40 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.5.0"}
|
||||
{".":"0.4.0"}
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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(
|
||||
¬ifier{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)
|
||||
(¬ifier{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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
+40
-82
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -74,18 +74,11 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
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 +86,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 {
|
||||
|
||||
@@ -75,102 +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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ const (
|
||||
type PermissionProfile struct {
|
||||
FileSystem FileSystemPolicy `json:"fileSystem"`
|
||||
Network NetworkPolicy `json:"network"`
|
||||
Runtime *SandboxRuntime `json:"runtime,omitempty"`
|
||||
}
|
||||
|
||||
type FileSystemPolicy struct {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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*)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
func TestLinuxHelperRealSandboxSmoke(t *testing.T) {
|
||||
@@ -46,11 +44,6 @@ func TestLinuxHelperRealSandboxSmoke(t *testing.T) {
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", strings.Join([]string{
|
||||
"set -eu",
|
||||
"test \"$HOME\" != \"$PWD\"",
|
||||
"test ! -e .zero && test ! -e .agents",
|
||||
"echo cache > \"$npm_config_cache/zero-runtime-probe\"",
|
||||
"test ! -e .npm && test ! -e .cache",
|
||||
"rm -f \"$npm_config_cache/zero-runtime-probe\"",
|
||||
"echo ok > write-ok.txt",
|
||||
"test \"$(cat write-ok.txt)\" = ok",
|
||||
"echo tmp > /tmp/zero-sandbox-smoke",
|
||||
@@ -174,95 +167,6 @@ func TestLinuxLandlockRealSandboxSmoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxHelperAllowsIsolatedLoopbackWithoutExternalEgress(t *testing.T) {
|
||||
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
|
||||
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
|
||||
}
|
||||
backend := SelectBackend(BackendOptions{})
|
||||
if !backend.Available || backend.Name != BackendLinuxBwrap {
|
||||
t.Skipf("Linux sandbox backend unavailable: %s", backend.Message)
|
||||
}
|
||||
python, err := exec.LookPath("python3")
|
||||
if err != nil || python == "" {
|
||||
t.Skip("python3 not found; skipping isolated loopback probe")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
|
||||
loopbackScript := strings.Join([]string{
|
||||
"import socket",
|
||||
"server = socket.socket()",
|
||||
"server.bind(('127.0.0.1', 0))",
|
||||
"server.listen()",
|
||||
"client = socket.socket()",
|
||||
"client.connect(('127.0.0.1', server.getsockname()[1]))",
|
||||
"accepted, _ = server.accept()",
|
||||
"client.send(b'ok')",
|
||||
"assert accepted.recv(2) == b'ok'",
|
||||
}, "; ")
|
||||
output, runErr := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
|
||||
Name: python,
|
||||
Args: []string{"-c", loopbackScript},
|
||||
Dir: root,
|
||||
})
|
||||
if runErr != nil {
|
||||
t.Fatalf("isolated loopback failed: %v\n%s", runErr, output)
|
||||
}
|
||||
|
||||
output, runErr = runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
|
||||
Name: python,
|
||||
Args: []string{"-c", "import socket; socket.create_connection(('1.1.1.1', 80), 1).close()"},
|
||||
Dir: root,
|
||||
})
|
||||
if runErr == nil {
|
||||
t.Fatalf("sandbox allowed external egress while isolated loopback was enabled: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxHelperPreservesAbsentProtectedMetadata(t *testing.T) {
|
||||
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
|
||||
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
|
||||
}
|
||||
backend := SelectBackend(BackendOptions{})
|
||||
if !backend.Available || backend.Name != BackendLinuxBwrap {
|
||||
t.Skipf("Linux sandbox backend unavailable: %s", backend.Message)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
command, plan, err := engine.CommandContext(ctx, CommandSpec{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", "set -eu; test ! -e .zero; mkdir .zero"},
|
||||
Dir: root,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CommandContext: %v", err)
|
||||
}
|
||||
defer plan.Cleanup()
|
||||
output, runErr := command.CombinedOutput()
|
||||
if runErr == nil {
|
||||
t.Fatalf("sandbox reported success after protected metadata creation; output=%s", output)
|
||||
}
|
||||
if !strings.Contains(string(output), "blocked creation of protected workspace metadata path") {
|
||||
t.Fatalf("sandbox did not explain protected metadata denial: %v\n%s", runErr, output)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, ".zero")); !os.IsNotExist(err) {
|
||||
t.Fatalf("protected metadata path remained after execution: %v", err)
|
||||
}
|
||||
report, err := plan.ExecutionReport()
|
||||
if err != nil {
|
||||
t.Fatalf("ExecutionReport: %v", err)
|
||||
}
|
||||
if report.Denial == nil || report.Denial.Capability.Kind != execution.CapabilityProtectedMetadata {
|
||||
t.Fatalf("execution report denial = %#v, want protected metadata", report.Denial)
|
||||
}
|
||||
if report.Denial.Capability.Scope != filepath.Join(root, ".zero") {
|
||||
t.Fatalf("denial scope = %q, want exact protected path", report.Denial.Capability.Scope)
|
||||
}
|
||||
}
|
||||
|
||||
func runLinuxSandboxSmokeCommand(t *testing.T, engine *Engine, spec CommandSpec) ([]byte, error) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
@@ -399,11 +399,6 @@ func TestSeatbeltProfileConsumesPermissionProfile(t *testing.T) {
|
||||
t.Fatalf("Seatbelt profile missing %q:\n%s", want, sbpl)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"network-bind", "network-inbound", `remote ip "localhost:*"`} {
|
||||
if strings.Contains(sbpl, forbidden) {
|
||||
t.Fatalf("restricted Seatbelt profile must not contain host-local rule %q:\n%s", forbidden, sbpl)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sbpl, "(allow file-read*)\n(allow file-write*)") {
|
||||
t.Fatalf("restricted permission profile must not become full read/write:\n%s", sbpl)
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const sandboxRuntimeLeaseSuffix = ".lease"
|
||||
|
||||
type sandboxRuntimeLease struct {
|
||||
handle runtimeLeaseHandle
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func sandboxRuntimeLeasePath(root string) string {
|
||||
return root + sandboxRuntimeLeaseSuffix
|
||||
}
|
||||
|
||||
func acquireSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
|
||||
handle, err := acquireSharedRuntimeLease(sandboxRuntimeLeasePath(root))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire sandbox runtime lease: %w", err)
|
||||
}
|
||||
return &sandboxRuntimeLease{handle: handle}, nil
|
||||
}
|
||||
|
||||
func tryAcquireSandboxRuntimeCleanupLease(root string) (*sandboxRuntimeLease, bool, error) {
|
||||
handle, inUse, err := tryAcquireExclusiveRuntimeLease(sandboxRuntimeLeasePath(root))
|
||||
if err != nil || inUse {
|
||||
return nil, inUse, err
|
||||
}
|
||||
return &sandboxRuntimeLease{handle: handle}, false, nil
|
||||
}
|
||||
|
||||
func (lease *sandboxRuntimeLease) release() {
|
||||
if lease == nil {
|
||||
return
|
||||
}
|
||||
lease.once.Do(lease.handle.release)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type runtimeLeaseHandle struct {
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_SH); err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
return runtimeLeaseHandle{file: file}, nil
|
||||
}
|
||||
|
||||
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
|
||||
return runtimeLeaseHandle{}, true, nil
|
||||
}
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
return runtimeLeaseHandle{file: file}, false, nil
|
||||
}
|
||||
|
||||
func (lease runtimeLeaseHandle) release() {
|
||||
if lease.file == nil {
|
||||
return
|
||||
}
|
||||
_ = unix.Flock(int(lease.file.Fd()), unix.LOCK_UN)
|
||||
_ = lease.file.Close()
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type runtimeLeaseHandle struct {
|
||||
file *os.File
|
||||
overlapped windows.Overlapped
|
||||
}
|
||||
|
||||
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
handle := runtimeLeaseHandle{file: file}
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), 0, 0, 1, 0, &handle.overlapped); err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
handle := runtimeLeaseHandle{file: file}
|
||||
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &handle.overlapped); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
|
||||
return runtimeLeaseHandle{}, true, nil
|
||||
}
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
return handle, false, nil
|
||||
}
|
||||
|
||||
func (lease runtimeLeaseHandle) release() {
|
||||
if lease.file == nil {
|
||||
return
|
||||
}
|
||||
_ = windows.UnlockFileEx(windows.Handle(lease.file.Fd()), 0, 1, 0, &lease.overlapped)
|
||||
_ = lease.file.Close()
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var sandboxUserCacheDir = os.UserCacheDir
|
||||
var sandboxRuntimeNow = time.Now
|
||||
|
||||
const (
|
||||
sandboxRuntimeMaxAge = 30 * 24 * time.Hour
|
||||
sandboxRuntimeMaxRoots = 64
|
||||
)
|
||||
|
||||
var fallbackSandboxRuntimes = struct {
|
||||
sync.Mutex
|
||||
roots map[string]string
|
||||
}{roots: make(map[string]string)}
|
||||
|
||||
type SandboxRuntime struct {
|
||||
Root string `json:"root,omitempty"`
|
||||
Home string `json:"home,omitempty"`
|
||||
Cache string `json:"cache,omitempty"`
|
||||
Config string `json:"config,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Temp string `json:"temp,omitempty"`
|
||||
}
|
||||
|
||||
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
|
||||
workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot))
|
||||
if workspaceRoot == "" || workspaceRoot == "." {
|
||||
return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root")
|
||||
}
|
||||
cacheRoot, err := sandboxUserCacheDir()
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err)
|
||||
}
|
||||
cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot))
|
||||
if cacheRoot == "" || cacheRoot == "." {
|
||||
return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(workspaceRoot))
|
||||
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
|
||||
if pathWithinRoot(workspaceRoot, root) {
|
||||
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
}
|
||||
lease, err := prepareSandboxRuntimeLease(root)
|
||||
if err != nil {
|
||||
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
lease, err = prepareSandboxRuntimeLease(root)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
}
|
||||
prepared := false
|
||||
defer func() {
|
||||
if !prepared {
|
||||
lease.release()
|
||||
}
|
||||
}()
|
||||
runtimeState := SandboxRuntime{
|
||||
Root: root,
|
||||
Home: filepath.Join(root, "home"),
|
||||
Cache: filepath.Join(root, "cache"),
|
||||
Config: filepath.Join(root, "config"),
|
||||
Data: filepath.Join(root, "data"),
|
||||
State: filepath.Join(root, "state"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
}
|
||||
directories := []string{
|
||||
runtimeState.Root,
|
||||
runtimeState.Home,
|
||||
runtimeState.Cache,
|
||||
runtimeState.Config,
|
||||
runtimeState.Data,
|
||||
runtimeState.State,
|
||||
runtimeState.Temp,
|
||||
filepath.Join(runtimeState.Cache, "npm"),
|
||||
filepath.Join(runtimeState.Cache, "yarn"),
|
||||
filepath.Join(runtimeState.Cache, "corepack"),
|
||||
filepath.Join(runtimeState.Cache, "pip"),
|
||||
filepath.Join(runtimeState.Cache, "go-build"),
|
||||
filepath.Join(runtimeState.Data, "go-mod"),
|
||||
filepath.Join(runtimeState.Data, "cargo"),
|
||||
}
|
||||
for _, directory := range directories {
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("create sandbox runtime directory %s: %w", directory, err)
|
||||
}
|
||||
if err := os.Chmod(directory, 0o700); err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("secure sandbox runtime directory %s: %w", directory, err)
|
||||
}
|
||||
}
|
||||
now := sandboxRuntimeNow()
|
||||
if err := os.Chtimes(runtimeState.Root, now, now); err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("touch sandbox runtime root: %w", err)
|
||||
}
|
||||
cleanupSandboxRuntimeRoots(filepath.Dir(runtimeState.Root), runtimeState.Root, now)
|
||||
prepared = true
|
||||
return runtimeState, lease.release, nil
|
||||
}
|
||||
|
||||
func prepareSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create sandbox runtime parent: %w", err)
|
||||
}
|
||||
return acquireSandboxRuntimeLease(root)
|
||||
}
|
||||
|
||||
// cleanupSandboxRuntimeRoots applies a conservative age/count policy. Cleanup
|
||||
// is best-effort and never removes the runtime selected for the current
|
||||
// command, so cache maintenance cannot turn a valid execution into a failure.
|
||||
func cleanupSandboxRuntimeRoots(parent, current string, now time.Time) {
|
||||
entries, err := os.ReadDir(parent)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
type candidate struct {
|
||||
path string
|
||||
modTime time.Time
|
||||
}
|
||||
var candidates []candidate
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(parent, entry.Name())
|
||||
if !entry.IsDir() || filepath.Clean(path) == filepath.Clean(current) {
|
||||
continue
|
||||
}
|
||||
info, infoErr := entry.Info()
|
||||
if infoErr != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(info.ModTime()) > sandboxRuntimeMaxAge {
|
||||
removeSandboxRuntimeRootIfUnused(path)
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, candidate{path: path, modTime: info.ModTime()})
|
||||
}
|
||||
if len(candidates) < sandboxRuntimeMaxRoots {
|
||||
return
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool { return candidates[i].modTime.Before(candidates[j].modTime) })
|
||||
for len(candidates) >= sandboxRuntimeMaxRoots {
|
||||
removeSandboxRuntimeRootIfUnused(candidates[0].path)
|
||||
candidates = candidates[1:]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSandboxRuntimeRootIfUnused(root string) {
|
||||
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if err != nil || inUse {
|
||||
return
|
||||
}
|
||||
defer lease.release()
|
||||
_ = os.RemoveAll(root)
|
||||
}
|
||||
|
||||
func combineSandboxCleanups(cleanups ...func()) func() {
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
for _, cleanup := range cleanups {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) {
|
||||
fallbackSandboxRuntimes.Lock()
|
||||
defer fallbackSandboxRuntimes.Unlock()
|
||||
if root := fallbackSandboxRuntimes.roots[workspaceRoot]; root != "" {
|
||||
return root, nil
|
||||
}
|
||||
parent, err := os.MkdirTemp("", "zero-runtime-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create fallback sandbox runtime: %w", err)
|
||||
}
|
||||
root := filepath.Join(parent, "runtime")
|
||||
if pathWithinRoot(workspaceRoot, root) {
|
||||
_ = os.RemoveAll(parent)
|
||||
return "", fmt.Errorf("fallback sandbox runtime root %q must be outside workspace %q", root, workspaceRoot)
|
||||
}
|
||||
fallbackSandboxRuntimes.roots[workspaceRoot] = root
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func sandboxRuntimeEnvironment(env []string, runtimeState *SandboxRuntime) []string {
|
||||
if runtimeState == nil || strings.TrimSpace(runtimeState.Root) == "" {
|
||||
return env
|
||||
}
|
||||
overrides := []string{
|
||||
"HOME=" + runtimeState.Home,
|
||||
"XDG_CACHE_HOME=" + runtimeState.Cache,
|
||||
"XDG_CONFIG_HOME=" + runtimeState.Config,
|
||||
"XDG_DATA_HOME=" + runtimeState.Data,
|
||||
"XDG_STATE_HOME=" + runtimeState.State,
|
||||
"TMPDIR=" + runtimeState.Temp,
|
||||
"TMP=" + runtimeState.Temp,
|
||||
"TEMP=" + runtimeState.Temp,
|
||||
"npm_config_cache=" + filepath.Join(runtimeState.Cache, "npm"),
|
||||
"NPM_CONFIG_USERCONFIG=" + filepath.Join(runtimeState.Config, "npmrc"),
|
||||
"YARN_CACHE_FOLDER=" + filepath.Join(runtimeState.Cache, "yarn"),
|
||||
"COREPACK_HOME=" + filepath.Join(runtimeState.Cache, "corepack"),
|
||||
"PIP_CACHE_DIR=" + filepath.Join(runtimeState.Cache, "pip"),
|
||||
"GOCACHE=" + filepath.Join(runtimeState.Cache, "go-build"),
|
||||
"GOMODCACHE=" + filepath.Join(runtimeState.Data, "go-mod"),
|
||||
"CARGO_HOME=" + filepath.Join(runtimeState.Data, "cargo"),
|
||||
}
|
||||
return upsertEnvList(env, overrides...)
|
||||
}
|
||||
|
||||
func permissionProfileWithRuntime(profile PermissionProfile, runtimeState SandboxRuntime) PermissionProfile {
|
||||
profile.Runtime = &runtimeState
|
||||
if profile.FileSystem.Kind != FileSystemRestricted || runtimeState.Root == "" {
|
||||
return profile
|
||||
}
|
||||
for _, root := range profile.FileSystem.WriteRoots {
|
||||
if filepath.Clean(root.Root) == filepath.Clean(runtimeState.Root) {
|
||||
return profile
|
||||
}
|
||||
}
|
||||
profile.FileSystem.WriteRoots = append(profile.FileSystem.WriteRoots, WritableRoot{Root: runtimeState.Root})
|
||||
return profile
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cacheRoot := t.TempDir()
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSandboxRuntime: %v", err)
|
||||
}
|
||||
defer release()
|
||||
if pathWithinRoot(workspace, runtimeState.Root) {
|
||||
t.Fatalf("runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
|
||||
}
|
||||
for _, path := range []string{runtimeState.Home, runtimeState.Cache, runtimeState.Config, runtimeState.Data, runtimeState.State, runtimeState.Temp} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Fatalf("managed runtime directory %q was not prepared: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cacheRoot := t.TempDir()
|
||||
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||
originalCache := sandboxUserCacheDir
|
||||
originalNow := sandboxRuntimeNow
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
sandboxRuntimeNow = func() time.Time { return now }
|
||||
t.Cleanup(func() {
|
||||
sandboxUserCacheDir = originalCache
|
||||
sandboxRuntimeNow = originalNow
|
||||
})
|
||||
parent := filepath.Join(cacheRoot, "zero", "runtime", "v1")
|
||||
expired := filepath.Join(parent, "expired")
|
||||
if err := os.MkdirAll(expired, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := now.Add(-sandboxRuntimeMaxAge - time.Hour)
|
||||
if err := os.Chtimes(expired, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSandboxRuntime: %v", err)
|
||||
}
|
||||
release()
|
||||
if _, err := os.Stat(expired); !os.IsNotExist(err) {
|
||||
t.Fatalf("expired runtime still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSandboxRuntimeFallsBackWhenUserCacheIsInsideWorkspace(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSandboxRuntime: %v", err)
|
||||
}
|
||||
defer release()
|
||||
if pathWithinRoot(workspace, runtimeState.Root) {
|
||||
t.Fatalf("fallback runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
|
||||
}
|
||||
if filepath.Clean(filepath.Dir(runtimeState.Root)) == filepath.Clean(os.TempDir()) {
|
||||
t.Fatalf("fallback runtime %q must use a private cleanup parent", runtimeState.Root)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cacheRoot := t.TempDir()
|
||||
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||
originalCache := sandboxUserCacheDir
|
||||
originalNow := sandboxRuntimeNow
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
sandboxRuntimeNow = func() time.Time { return now }
|
||||
t.Cleanup(func() {
|
||||
sandboxUserCacheDir = originalCache
|
||||
sandboxRuntimeNow = originalNow
|
||||
})
|
||||
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSandboxRuntime: %v", err)
|
||||
}
|
||||
old := now.Add(-sandboxRuntimeMaxAge - time.Hour)
|
||||
if err := os.Chtimes(runtimeState.Root, old, old); err != nil {
|
||||
release()
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent := filepath.Dir(runtimeState.Root)
|
||||
cleanupSandboxRuntimeRoots(parent, filepath.Join(parent, "other"), now)
|
||||
if _, err := os.Stat(runtimeState.Root); err != nil {
|
||||
release()
|
||||
t.Fatalf("active runtime was removed: %v", err)
|
||||
}
|
||||
|
||||
release()
|
||||
cleanupSandboxRuntimeRoots(parent, filepath.Join(parent, "other"), now)
|
||||
if _, err := os.Stat(runtimeState.Root); !os.IsNotExist(err) {
|
||||
t.Fatalf("released expired runtime still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "runtime")
|
||||
runtimeState := SandboxRuntime{
|
||||
Root: root,
|
||||
Home: filepath.Join(root, "home"),
|
||||
Cache: filepath.Join(root, "cache"),
|
||||
Config: filepath.Join(root, "config"),
|
||||
Data: filepath.Join(root, "data"),
|
||||
State: filepath.Join(root, "state"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
}
|
||||
env := sandboxRuntimeEnvironment([]string{
|
||||
"HOME=/workspace",
|
||||
"XDG_CACHE_HOME=/host/cache",
|
||||
"PATH=/usr/bin",
|
||||
}, &runtimeState)
|
||||
|
||||
for key, want := range map[string]string{
|
||||
"HOME": runtimeState.Home,
|
||||
"XDG_CACHE_HOME": runtimeState.Cache,
|
||||
"XDG_CONFIG_HOME": runtimeState.Config,
|
||||
"XDG_DATA_HOME": runtimeState.Data,
|
||||
"XDG_STATE_HOME": runtimeState.State,
|
||||
"TMPDIR": runtimeState.Temp,
|
||||
"npm_config_cache": filepath.Join(runtimeState.Cache, "npm"),
|
||||
"NPM_CONFIG_USERCONFIG": filepath.Join(runtimeState.Config, "npmrc"),
|
||||
"YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"),
|
||||
"COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"),
|
||||
} {
|
||||
if got := envListValue(env, key, ""); got != want {
|
||||
t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env)
|
||||
}
|
||||
}
|
||||
if got := envListValue(env, "PATH", ""); got != "/usr/bin" {
|
||||
t.Fatalf("PATH = %q, want preserved caller path", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cacheRoot := t.TempDir()
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{
|
||||
Name: BackendLinuxBwrap,
|
||||
Available: true,
|
||||
Platform: "linux",
|
||||
Executable: "/usr/bin/zero-linux-sandbox",
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
},
|
||||
})
|
||||
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: workspace})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
defer plan.Cleanup()
|
||||
if plan.PermissionProfile.Runtime == nil || plan.PermissionProfile.Runtime.Root == "" {
|
||||
t.Fatal("command plan is missing managed runtime state")
|
||||
}
|
||||
if cleanupLease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(plan.PermissionProfile.Runtime.Root); err != nil {
|
||||
t.Fatalf("inspect active runtime lease: %v", err)
|
||||
} else if cleanupLease != nil {
|
||||
cleanupLease.release()
|
||||
t.Fatal("command plan did not retain its runtime lease")
|
||||
} else if !inUse {
|
||||
t.Fatal("command plan runtime must be marked in use")
|
||||
}
|
||||
if got := envListValue(plan.Env, "HOME", ""); got != plan.PermissionProfile.Runtime.Home {
|
||||
t.Fatalf("HOME = %q, want managed home %q", got, plan.PermissionProfile.Runtime.Home)
|
||||
}
|
||||
foundWriteRoot := false
|
||||
for _, root := range plan.PermissionProfile.FileSystem.WriteRoots {
|
||||
if root.Root == plan.PermissionProfile.Runtime.Root {
|
||||
foundWriteRoot = true
|
||||
}
|
||||
}
|
||||
if !foundWriteRoot {
|
||||
t.Fatalf("runtime root is not writable in profile: %#v", plan.PermissionProfile.FileSystem.WriteRoots)
|
||||
}
|
||||
plan.Cleanup()
|
||||
cleanupLease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(plan.PermissionProfile.Runtime.Root)
|
||||
if err != nil || inUse || cleanupLease == nil {
|
||||
t.Fatalf("runtime lease after plan cleanup = lease %v inUse %t err %v", cleanupLease, inUse, err)
|
||||
}
|
||||
cleanupLease.release()
|
||||
}
|
||||
@@ -219,65 +219,9 @@ func DetectInteractiveCommand(command string, goos string) InteractiveCommandRes
|
||||
}
|
||||
}
|
||||
|
||||
// AST second opinion (issue #473): the hand-written segment split above
|
||||
// mis-handles interactive programs hidden by unusual quoting, command
|
||||
// substitution, subshells, or newline separators. Re-extract the real simple
|
||||
// commands from the parsed shell tree and apply the SAME full pipeline
|
||||
// (interactive segments, sh -c payload recursion, per-program lookup), so a
|
||||
// bypass is caught while every program stays classified exactly as before
|
||||
// (ssh with a trailing command, python -c, etc. remain allowed). A command
|
||||
// the parser cannot handle (Windows cmd.exe, obfuscation) yields no commands
|
||||
// and falls through unchanged — the guard never hard-blocks on a parse error.
|
||||
for _, fields := range astCommandFields(command) {
|
||||
if result, ok := inspectCommandFields(fields, goos); ok {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return InteractiveCommandResult{}
|
||||
}
|
||||
|
||||
// inspectCommandFields applies the full interactive-detection pipeline to one
|
||||
// already-split command's fields: the multi-word interactive segments, then the
|
||||
// `sh -c <payload>` recursion, then the per-program lookup with its
|
||||
// non-interactive suppressions (hasNonInteractiveFlag covers REPL flags and
|
||||
// trailing-command clients like ssh). It mirrors the hand-written passes above
|
||||
// so an AST-extracted command is classified identically to a plainly-split one.
|
||||
func inspectCommandFields(fields []string, goos string) (InteractiveCommandResult, bool) {
|
||||
body := strings.ToLower(commandBody(fields))
|
||||
for _, seg := range interactiveSegments {
|
||||
if body == seg.match || strings.HasPrefix(body, seg.match+" ") {
|
||||
suggestion := seg.suggestion
|
||||
if goos == "windows" && seg.windowsSuggestion != "" {
|
||||
suggestion = seg.windowsSuggestion
|
||||
}
|
||||
return InteractiveCommandResult{Interactive: true, Command: seg.command, Reason: seg.reason, Suggestion: suggestion}, true
|
||||
}
|
||||
}
|
||||
first := firstProgram(fields)
|
||||
if first == "" {
|
||||
return InteractiveCommandResult{}, false
|
||||
}
|
||||
if payload := shellDashCPayload(first, fields); payload != "" {
|
||||
if inner := DetectInteractiveCommand(payload, goos); inner.Interactive {
|
||||
return inner, true
|
||||
}
|
||||
return InteractiveCommandResult{}, false
|
||||
}
|
||||
program, ok := interactivePrograms[first]
|
||||
if !ok || (program.windowsOnly && goos != "windows") {
|
||||
return InteractiveCommandResult{}, false
|
||||
}
|
||||
if hasNonInteractiveFlag(first, fields) {
|
||||
return InteractiveCommandResult{}, false
|
||||
}
|
||||
suggestion := program.suggestion
|
||||
if goos == "windows" && program.windowsSuggestion != "" {
|
||||
suggestion = program.windowsSuggestion
|
||||
}
|
||||
return InteractiveCommandResult{Interactive: true, Command: first, Reason: program.reason, Suggestion: suggestion}, true
|
||||
}
|
||||
|
||||
// wrapperPrograms are launcher prefixes that precede the real program. After
|
||||
// one of these we keep scanning for the actual executable.
|
||||
var wrapperPrograms = map[string]bool{
|
||||
|
||||
@@ -406,87 +406,3 @@ func TestDetectInteractiveMongoEvalAndFullPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The hand-written segment splitter misses interactive programs hidden by
|
||||
// constructs only a real shell parser resolves (a newline separator collapsed
|
||||
// to a space; a brace group that shifts the real command position). The AST
|
||||
// second opinion must catch them (issue #473).
|
||||
func TestDetectInteractiveCommandCatchesParserBypasses(t *testing.T) {
|
||||
for _, cmd := range []string{
|
||||
"echo hi\nvim file.txt", // newline separator collapsed to a space by the regex path
|
||||
"{ vim file.txt; }", // brace group hides the real program position
|
||||
} {
|
||||
got := DetectInteractiveCommand(cmd, "linux")
|
||||
if !got.Interactive {
|
||||
t.Errorf("DetectInteractiveCommand(%q) = not interactive, want caught (parser bypass)", cmd)
|
||||
continue
|
||||
}
|
||||
if got.Command != "vim" {
|
||||
t.Errorf("DetectInteractiveCommand(%q).Command = %q, want vim", cmd, got.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The AST second opinion must not flag an interactive program NAME that appears
|
||||
// only inside a quoted argument (not a real command position) — the parser
|
||||
// distinguishes program from argument, so this must stay non-interactive.
|
||||
func TestDetectInteractiveCommandNoFalsePositiveOnQuotedArgument(t *testing.T) {
|
||||
got := DetectInteractiveCommand(`echo "please run vim later"`, "linux")
|
||||
if got.Interactive {
|
||||
t.Fatalf("interactive name inside a quoted argument must not be flagged, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The AST second opinion must run the FULL pipeline — multi-word interactive
|
||||
// segments and sh -c payload recursion, not just the per-program lookup — so a
|
||||
// grouped/nested bypass is caught; and it must not fabricate a program name from
|
||||
// a command substitution concatenated with a literal (issue #473, review).
|
||||
func TestDetectInteractiveCommandASTPipelineBoundaries(t *testing.T) {
|
||||
interactive := []struct{ cmd, wantCmd string }{
|
||||
{"{ git rebase -i HEAD~1; }", "git rebase -i"}, // multi-word segment in a brace group
|
||||
{"{ sh -c 'vim file.txt'; }", "vim"}, // sh -c payload in a brace group
|
||||
}
|
||||
for _, tc := range interactive {
|
||||
got := DetectInteractiveCommand(tc.cmd, "linux")
|
||||
if !got.Interactive || got.Command != tc.wantCmd {
|
||||
t.Errorf("DetectInteractiveCommand(%q) = %+v, want interactive Command=%q", tc.cmd, got, tc.wantCmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The AST pass must not fabricate a program name from a dynamic (non-literal)
|
||||
// program word: `$(printf '%s' foo)vim` runs as `foovim`, not vim. Tested at the
|
||||
// extractor so the hand-written pass (which pre-empts DetectInteractiveCommand
|
||||
// with its own, separate handling of that construct) does not mask the guard.
|
||||
func TestAstCommandFieldsSkipsDynamicProgram(t *testing.T) {
|
||||
for _, fields := range astCommandFields("$(printf '%s' foo)vim file.txt") {
|
||||
if firstProgram(fields) == "vim" {
|
||||
t.Fatalf("astCommandFields fabricated a vim command from a substitution: %v", fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The literalness guard covers every word, not just the program name. A dynamic
|
||||
// ARGUMENT is dropped by wordText the same way a dynamic program word is, so
|
||||
// `git $(printf foo)rebase -i HEAD~1` (which runs as `git foorebase -i`, a
|
||||
// non-existent and non-interactive subcommand) would otherwise be reconstructed
|
||||
// as `git rebase -i` and blocked — a false positive on a command the user never
|
||||
// wrote. Asserted through DetectInteractiveCommand as well as the extractor,
|
||||
// because the whole point is what the caller ends up blocking.
|
||||
func TestAstCommandFieldsSkipsDynamicArgument(t *testing.T) {
|
||||
const dynamic = "{ git $(printf foo)rebase -i HEAD~1 ; }"
|
||||
for _, fields := range astCommandFields(dynamic) {
|
||||
if firstProgram(fields) == "git" {
|
||||
t.Fatalf("astCommandFields reconstructed a git command from a dynamic argument: %v", fields)
|
||||
}
|
||||
}
|
||||
if got := DetectInteractiveCommand(dynamic, "linux"); got.Interactive {
|
||||
t.Errorf("DetectInteractiveCommand(%q) = %+v, want not interactive", dynamic, got)
|
||||
}
|
||||
// The genuine form must still be detected — the guard skips lossy
|
||||
// reconstructions, it does not weaken real detection.
|
||||
const genuine = "{ git rebase -i HEAD~1 ; }"
|
||||
if got := DetectInteractiveCommand(genuine, "linux"); !got.Interactive || got.Command != "git rebase -i" {
|
||||
t.Errorf("DetectInteractiveCommand(%q) = %+v, want interactive Command=%q", genuine, got, "git rebase -i")
|
||||
}
|
||||
}
|
||||
@@ -82,24 +82,6 @@ var networkDenySyscallsAARCH64 = []uint32{
|
||||
427, // io_uring_register
|
||||
}
|
||||
|
||||
var isolatedNetworkGuardSyscallsX86_64 = []uint32{
|
||||
101, // ptrace
|
||||
310, // process_vm_readv
|
||||
311, // process_vm_writev
|
||||
425, // io_uring_setup
|
||||
426, // io_uring_enter
|
||||
427, // io_uring_register
|
||||
}
|
||||
|
||||
var isolatedNetworkGuardSyscallsAARCH64 = []uint32{
|
||||
117, // ptrace
|
||||
270, // process_vm_readv
|
||||
271, // process_vm_writev
|
||||
425, // io_uring_setup
|
||||
426, // io_uring_enter
|
||||
427, // io_uring_register
|
||||
}
|
||||
|
||||
// unixSocketBlockFilter builds a classic-BPF seccomp program that denies
|
||||
// socket(2)/AF_UNIX with EPERM on x86-64 and arm64 and allows everything else.
|
||||
// An unrecognized architecture is allowed (fail-open on arch is intentional: the
|
||||
@@ -152,35 +134,6 @@ func networkDenySeccompFilter() []sockFilter {
|
||||
return program
|
||||
}
|
||||
|
||||
// isolatedNetworkGuardFilter preserves the non-network defense-in-depth rules
|
||||
// from networkDenySeccompFilter without blocking sockets. The bubblewrap outer
|
||||
// stage supplies network isolation with a private namespace, so socket, bind,
|
||||
// and connect must remain available for localhost-only test servers.
|
||||
func isolatedNetworkGuardFilter() []sockFilter {
|
||||
x86Section := syscallDenySection(isolatedNetworkGuardSyscallsX86_64)
|
||||
armSection := syscallDenySection(isolatedNetworkGuardSyscallsAARCH64)
|
||||
program := []sockFilter{
|
||||
{Code: bpfLDWABS, K: seccompOffsetArch},
|
||||
{Code: bpfJEQK, K: auditArchX86_64, Jt: 0, Jf: uint8(len(x86Section))},
|
||||
}
|
||||
program = append(program, x86Section...)
|
||||
program = append(program, sockFilter{Code: bpfJEQK, K: auditArchAARCH64, Jt: 0, Jf: uint8(len(armSection))})
|
||||
program = append(program, armSection...)
|
||||
program = append(program, sockFilter{Code: bpfRETK, K: seccompRetAllow})
|
||||
return program
|
||||
}
|
||||
|
||||
func syscallDenySection(deniedSyscalls []uint32) []sockFilter {
|
||||
section := []sockFilter{{Code: bpfLDWABS, K: seccompOffsetNr}}
|
||||
for _, nr := range deniedSyscalls {
|
||||
section = append(section,
|
||||
sockFilter{Code: bpfJEQK, K: nr, Jt: 0, Jf: 1},
|
||||
sockFilter{Code: bpfRETK, K: seccompRetErrno | errnoEPERM},
|
||||
)
|
||||
}
|
||||
return append(section, sockFilter{Code: bpfRETK, K: seccompRetAllow})
|
||||
}
|
||||
|
||||
func networkDenySection(socketNr uint32, socketpairNr uint32, deniedSyscalls []uint32) []sockFilter {
|
||||
section := []sockFilter{{Code: bpfLDWABS, K: seccompOffsetNr}}
|
||||
for _, nr := range deniedSyscalls {
|
||||
|
||||
@@ -27,10 +27,6 @@ func ApplyLinuxNetworkDeny() error {
|
||||
return applySeccompFilter(networkDenySeccompFilter(), "network deny")
|
||||
}
|
||||
|
||||
func ApplyLinuxIsolatedNetworkGuard() error {
|
||||
return applySeccompFilter(isolatedNetworkGuardFilter(), "isolated network guard")
|
||||
}
|
||||
|
||||
func applySeccompFilter(filters []sockFilter, name string) error {
|
||||
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
|
||||
return fmt.Errorf("seccomp: set no_new_privs: %w", err)
|
||||
|
||||
@@ -8,14 +8,8 @@ import "errors"
|
||||
// platforms, where seccomp BPF is unavailable.
|
||||
var ErrSeccompUnsupported = errors.New("seccomp Unix-socket blocking is only supported on Linux")
|
||||
|
||||
// ApplyUnixSocketBlock is unsupported on non-Linux platforms and always
|
||||
// returns ErrSeccompUnsupported.
|
||||
// ApplyUnixSocketBlock is a no-op on non-Linux platforms.
|
||||
func ApplyUnixSocketBlock() error { return ErrSeccompUnsupported }
|
||||
|
||||
// ApplyLinuxNetworkDeny is unsupported on non-Linux platforms and always
|
||||
// returns ErrSeccompUnsupported.
|
||||
// ApplyLinuxNetworkDeny is a no-op on non-Linux platforms.
|
||||
func ApplyLinuxNetworkDeny() error { return ErrSeccompUnsupported }
|
||||
|
||||
// ApplyLinuxIsolatedNetworkGuard is unsupported on non-Linux platforms and
|
||||
// always returns ErrSeccompUnsupported.
|
||||
func ApplyLinuxIsolatedNetworkGuard() error { return ErrSeccompUnsupported }
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user