Compare commits

..
Author SHA1 Message Date
Gnanam 1141d258ec feat(exec): container-trusted sandbox-off mode + run deadline
Two opt-in controls for `zero exec`, for headless/automated runs inside an
already-isolated environment (container/CI sandbox):

- `--sandbox off` (env ZERO_SANDBOX off|disabled|none|0): disables ZERO's
  in-process filesystem + network confinement so the agent can write outside the
  workspace and reach the network. OFF BY DEFAULT — the default policy stays
  enforcing (workspace-confined, network-denied), guarded by
  TestExecSandboxPolicyDefaultUnchanged. Emits a loud warning when enabled; safe
  only inside an isolated container, never on a host.
- `--deadline <dur>` (env ZERO_DEADLINE): wall-clock budget; ZERO yields a
  best-effort result + flushed output at the deadline instead of running
  unbounded, and reports a deadline stop distinctly from an interrupt.

Both use flag>env precedence; invalid/empty values are inert (no deadline /
sandbox stays on). Tests cover the policy mapping, value resolution, and arg
parsing for both flag and inline =value spellings.
2026-06-22 10:43:07 +05:30
248 changed files with 2446 additions and 22779 deletions

No files matched your search

+3 -8
View File
@@ -20,15 +20,10 @@ jobs:
strategy:
fail-fast: false
matrix:
# `zero-release package` builds + smoke-tests natively (host==target), so each
# target platform needs its own native runner. These five cover the platforms
# install.sh / install.ps1 / the npm wrapper resolve.
os:
- ubuntu-latest # linux-x64
- ubuntu-24.04-arm # linux-arm64
- macos-latest # macos-arm64 (Apple Silicon)
- macos-15-intel # macos-x64 (Intel)
- windows-latest # windows-x64
- ubuntu-latest
- macos-latest
- windows-latest
steps:
- name: Checkout
+1 -1
View File
@@ -6,7 +6,7 @@ alwaysApply: false
# Extending Zero
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
Zero is an open-source terminal coding agent in the same family as Claude Code, Codex, and Droid. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Gitlawb
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -2
View File
@@ -17,7 +17,6 @@
![providers](https://img.shields.io/badge/providers-25%2B-34E2EA)
![tests](https://img.shields.io/badge/test%20files-200%2B-43D17A)
![status](https://img.shields.io/badge/status-active%20development-E8B84B)
[![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
</div>
@@ -355,7 +354,7 @@ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Run `go te
## License
Zero is released under the [MIT License](LICENSE) — © 2026 Gitlawb.
License is being finalized; a `LICENSE` file will be added before the public release.
---
-49
View File
@@ -9,49 +9,8 @@ function zeroBinaryName(platform = process.platform) {
return platform === 'win32' ? 'zero.exe' : 'zero';
}
function helperShimNames(name, platform = process.platform) {
if (platform === 'win32') {
return [`${name}.cmd`, `${name}.exe`, name];
}
return [name];
}
function commandForShim(path, platform = process.platform) {
if (platform === 'win32' && path.toLowerCase().endsWith('.cmd')) {
return {
command: process.env.ComSpec || 'cmd.exe',
prefixArgs: ['/d', '/s', '/c', `"${path.replace(/"/g, '""')}"`],
};
}
return { command: path, prefixArgs: [] };
}
function resolveHelper(packageRoot, name) {
const binDir = join(packageRoot, 'node_modules', '.bin');
for (const shimName of helperShimNames(name)) {
const candidate = join(binDir, shimName);
if (!existsSync(candidate)) continue;
return {
...commandForShim(candidate),
pathPrepend: [binDir],
};
}
return null;
}
function localControlHelperManifest(packageRoot) {
const helpers = {};
for (const name of ['agent-browser', 'tuistory']) {
const helper = resolveHelper(packageRoot, name);
if (helper) helpers[name] = helper;
}
if (Object.keys(helpers).length === 0) return '';
return JSON.stringify({ version: 1, helpers });
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const nativePath = join(packageRoot, zeroBinaryName());
const localControlHelpers = localControlHelperManifest(packageRoot);
if (!existsSync(nativePath)) {
console.error(
@@ -60,16 +19,8 @@ if (!existsSync(nativePath)) {
process.exit(1);
}
const env = { ...process.env };
if (localControlHelpers) {
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
} else {
delete env.ZERO_LOCAL_CONTROL_HELPERS;
}
const child = spawnSync(nativePath, process.argv.slice(2), {
stdio: 'inherit',
env,
});
if (child.error) {
+2 -2
View File
@@ -1,7 +1,7 @@
# Zero — Product Requirements Document (v1.0)
**Status:** DRAFT v1.0
**Target:** Compete with Claude Code + Cursor + other multi-provider terminal agents
**Target:** Compete with Claude Code + Cursor + OpenCode
**Timeline:** 6-12 months
**Stack:** Go native core + npm distribution wrapper
@@ -14,7 +14,7 @@
It combines the best of:
- **Claude Code** — terminal-native agentic coding
- **Cursor** — IDE integration + smart editing
- **Multi-provider terminal agents** — provider-agnostic + headless
- **OpenCode** — multi-provider + headless
**One Go-native agent core. Three surfaces. Zero lock-in.**
+1 -1
View File
@@ -170,7 +170,7 @@ preset (no API key — the proxy authenticates):
"name": "chatgpt",
"catalogID": "chatgpt-proxy", // OpenAI-compatible, local, no key
"baseURL": "http://localhost:10531/v1", // override for your proxy's port
"model": "gpt-5.5" // whatever model your proxy serves
"model": "gpt-5" // whatever model your proxy serves
}
]
}
-1
View File
@@ -10,7 +10,6 @@ require (
charm.land/lipgloss/v2 v2.0.4
github.com/alecthomas/chroma/v2 v2.27.0
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.7
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
-228
View File
@@ -1,228 +0,0 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
t.Helper()
// checker/verifier nil → no LSP / project-test pass runs; we only need a
// non-nil SelfCorrector to enable the task-grounded acceptance gate.
return NewSelfCorrector(t.TempDir(), nil, nil, SelfCorrectConfig{Enabled: true})
}
// BUG #2(a): a final message that admits the model guessed / could not meet the
// objective must be reported as INCOMPLETE, never success — and immediately
// (an admitted guess is not worth re-prompting).
func TestAcceptanceSelfReportAdmissionIsIncomplete(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn("I couldn't analyze the board image, so I wrote the common opening move e2e4 as my best guess."),
}}
result, err := Run(context.Background(), "write the best chess move", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
SelfCorrect: enabledSelfCorrector(t),
})
if err != nil {
t.Fatal(err)
}
if !result.Incomplete {
t.Fatalf("admitted guess must be Incomplete, got success; final=%q", result.FinalAnswer)
}
if !strings.Contains(result.IncompleteReason, "objective was not met") {
t.Fatalf("IncompleteReason = %q, want it to cite the admission", result.IncompleteReason)
}
if len(provider.requests) != 1 {
t.Fatalf("an admission should downgrade immediately (1 turn), got %d", len(provider.requests))
}
}
// BUG #2(a): the inability detector must generalize over the verb the model uses
// (so re-phrasing can't defeat it) while NOT misreading success-y negations
// ("could not find any issues") as admissions.
func TestSelfReportedIncompletionMatching(t *testing.T) {
cases := []struct {
text string
admit bool
}{
// real chess re-run wording that slipped a fixed phrase list:
{"…which I cannot do without proper image analysis capabilities.", true},
{"I cannot visually analyze the chess board image.", true},
{"I could not recover the original values, so this is my best guess.", true},
{"I was unable to complete the optimization.", true},
{"The result may not be correct.", true},
// multi-occurrence: an early guarded use ("could not find any") must NOT mask a
// later genuine admission with the same stem (review #5):
{"I could not find any examples in the codebase, so I could not implement the feature.", true},
// genuine successes that must NOT be flagged:
{"I implemented the function and all tests pass.", false},
{"I verified the fix; I could not find any remaining issues.", false},
{"I cannot reproduce the bug, so the fix holds.", false},
{"Done — the output matches the required format and values.", false},
// behavior descriptions that must NOT be flagged — these phrases describe
// implemented behavior, not an admission (review #1, removed from the list):
{"The parser will fall back to UTF-8 when no encoding is set.", false},
{"I replaced the placeholder value with the computed key.", false},
{"The retry uses exponential backoff as a fallback.", false},
{"Without proper error handling the function would crash, so I added it.", false},
}
for _, c := range cases {
got := selfReportedIncompletion(c.text) != ""
if got != c.admit {
t.Errorf("selfReportedIncompletion(%q) admitted=%v, want %v", c.text, got, c.admit)
}
}
}
// BUG #2(a): a "cannot analyze / cannot determine" admission (present tense) must
// be caught even when the model also stamps a bogus "PASS" based on a shape check.
// Mirrors the chess-best-move final ("I cannot analyze the position… PASS: I wrote
// the move and verified by reading it back").
func TestAcceptanceCannotPhrasingIsIncomplete(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn("I cannot analyze the specific position from the image, so I cannot determine the best move. PASS: I wrote e2e4 to the file and verified it by reading it back."),
}}
result, err := Run(context.Background(), "write the best move", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
SelfCorrect: enabledSelfCorrector(t),
})
if err != nil {
t.Fatal(err)
}
if !result.Incomplete {
t.Fatalf("a 'cannot analyze/determine' admission must be Incomplete despite the bogus PASS; final=%q", result.FinalAnswer)
}
}
// A pending/in_progress update_plan item is an AMBIGUOUS signal (the model may have
// finished without re-marking the step), so it must NOT by itself force INCOMPLETE.
// The gate nudges, but if the model keeps claiming completion with no continuation
// cue and no admission, the run finalizes as SUCCESS — trusting the completion claim
// over stale plan bookkeeping. (Only a continuation cue or self-report admission
// forces INCOMPLETE; see TestCompletionGate* and TestAcceptance*Admission*.)
func TestPendingPlanAloneDoesNotForceIncomplete(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
done := "All set." // confident; no cue, no admission — only the plan is stale
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
planTurn("completed", "in_progress"),
textTurn(done), textTurn(done), textTurn(done), textTurn(done), textTurn(done),
}}
result, err := Run(context.Background(), "do the multi-step task", provider, Options{
Registry: registry,
MaxTurns: 10,
RequireCompletionSignal: true,
SelfCorrect: enabledSelfCorrector(t),
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("pending-plan alone must NOT force Incomplete (stale bookkeeping != unfinished); reason=%q", result.IncompleteReason)
}
if result.FinalAnswer != done {
t.Fatalf("final answer = %q, want the completion claim %q", result.FinalAnswer, done)
}
if !someRequestContains(provider.requests, continueNudgeMarker) {
t.Fatalf("expected at least one continue-nudge before accepting completion")
}
}
// BUG #2(b): when self-correct is on, the run must pass a one-time task-grounded
// acceptance check before success. A model that, when challenged, confirms its
// work meets the criterion (no admission) then finalizes as success — no regression.
func TestAcceptanceGroundedCompletionSucceeds(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn("I implemented the function."),
textTurn("PASS. I ran the verification and it passes; the output meets the task requirement."),
}}
result, err := Run(context.Background(), "implement the function", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
SelfCorrect: enabledSelfCorrector(t),
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("a verified completion must succeed, got Incomplete (%q)", result.IncompleteReason)
}
if result.FinalAnswer != "PASS. I ran the verification and it passes; the output meets the task requirement." {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
// Exactly one acceptance pass: the first "done" turn was challenged, the second confirmed.
if len(provider.requests) != 2 {
t.Fatalf("expected 2 turns (1 acceptance challenge + 1 confirmation), got %d", len(provider.requests))
}
if !someRequestContains(provider.requests, acceptanceNudgeMarker) {
t.Fatalf("expected the task-grounded acceptance check to be demanded")
}
}
// With the gate OFF (interactive/TUI default), even an admission is accepted as the
// final answer exactly as before — proving no behavior change for non-headless callers.
func TestAcceptanceGateOffLeavesAdmissionAsSuccess(t *testing.T) {
admission := "I couldn't determine the value, so this is a guess."
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn(admission),
}}
result, err := Run(context.Background(), "do a thing", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
// RequireCompletionSignal false, SelfCorrect nil — the legacy path.
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
}
if result.FinalAnswer != admission {
t.Fatalf("final answer = %q, want the text verbatim (legacy behavior)", result.FinalAnswer)
}
if len(provider.requests) != 1 {
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
}
}
// The acceptance check is scoped to runs with self-correct on: a headless run
// WITHOUT a SelfCorrector accepts a confident completion immediately (the (b) gate
// is inert), so enabling RequireCompletionSignal alone adds no acceptance turn.
func TestAcceptanceGateRequiresSelfCorrect(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn("Done. Implemented and it works."),
}}
result, err := Run(context.Background(), "implement it", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
// SelfCorrect deliberately nil → acceptance gate (b) must not fire.
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("confident completion without self-correct must succeed; reason=%q", result.IncompleteReason)
}
if len(provider.requests) != 1 {
t.Fatalf("acceptance gate must not fire without self-correct; got %d turns", len(provider.requests))
}
if someRequestContains(provider.requests, acceptanceNudgeMarker) {
t.Fatalf("acceptance nudge must not be injected when self-correct is off")
}
}
+22 -358
View File
@@ -1,11 +1,9 @@
package agent
import (
"fmt"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"mvdan.cc/sh/v3/syntax"
)
@@ -17,106 +15,43 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
if !ok {
return nil
}
segments, ok := safeShellCommandSegments(command)
tokens, ok := safeShellCommandTokens(command)
if !ok {
return nil
}
if requested, ok := requestedPrefixRule(args); ok {
if safeRequestedPrefixForSegments(requested, segments) {
if safeRequestedPrefix(requested, tokens) {
return requested
}
return nil
}
for _, tokens := range segments {
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
return append([]string(nil), tokens...)
}
}
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
if !sandbox.ValidCommandPrefix(tokens) {
return nil
}
return append([]string(nil), segments[0]...)
return append([]string(nil), tokens...)
}
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
if !isShellCommandTool(toolName) || options.Sandbox == nil {
return sandbox.CommandPrefixGrant{}, false, false
}
if shellCommandAdditionalPermissionsRequested(args) {
return sandbox.CommandPrefixGrant{}, false, false
}
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
if !ok {
return sandbox.CommandPrefixGrant{}, false, false
}
segments, ok := safeShellCommandSegments(command)
tokens, ok := safeShellCommandTokens(command)
if !ok {
return sandbox.CommandPrefixGrant{}, false, false
}
var matched sandbox.CommandPrefixGrant
matchedAny := false
matchedSession := false
for _, tokens := range segments {
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
if !matchedAny {
matched = grant
}
matchedAny = true
continue
}
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
if !matchedAny {
matched = grant
}
matchedAny = true
matchedSession = true
continue
}
if knownSafeCommandSegment(tokens) {
continue
}
return sandbox.CommandPrefixGrant{}, false, false
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
return grant, true, false
}
if matchedAny {
return matched, true, matchedSession
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
return grant, true, true
}
return sandbox.CommandPrefixGrant{}, false, false
}
func shellExecutionArgsForApproval(toolName string, args map[string]any, action PermissionDecisionAction, options Options) map[string]any {
if !isShellCommandTool(toolName) || !shellPrefixApprovalBypassesSandbox(action) {
return args
}
if options.Sandbox == nil || !options.Sandbox.UnsandboxedExecutionAllowed() {
return args
}
if shellCommandAdditionalPermissionsRequested(args) || shellCommandRequiresEscalated(args) {
return args
}
planned := cloneArgs(args)
if planned == nil {
planned = map[string]any{}
}
planned["sandbox_permissions"] = string(tools.SandboxPermissionsRequireEscalated)
return planned
}
func shellPrefixApprovalBypassesSandbox(action PermissionDecisionAction) bool {
return action == PermissionDecisionAllowPrefix || action == PermissionDecisionAlwaysAllowPrefix
}
func shellCommandRequiresEscalated(args map[string]any) bool {
raw, ok := args["sandbox_permissions"]
if !ok || raw == nil {
return false
}
value, ok := raw.(string)
if !ok {
value = fmt.Sprint(raw)
}
return strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated)
}
func isShellCommandTool(toolName string) bool {
return toolName == "bash" || toolName == "exec_command"
}
@@ -169,92 +104,32 @@ func cleanPrefixRule(prefix []string) []string {
}
func safeRequestedPrefix(prefix []string, command []string) bool {
return safeRequestedPrefixForSegments(prefix, [][]string{command})
}
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) || len(prefix) > len(command) {
return false
}
matched := false
for _, command := range segments {
if len(prefix) > len(command) {
if knownSafeCommandSegment(command) {
continue
}
for index := range prefix {
if prefix[index] != command[index] {
return false
}
if hasStringPrefix(command, prefix) {
matched = true
continue
}
if knownSafeCommandSegment(command) {
continue
}
return false
}
return matched
return true
}
func safeShellCommandTokens(command string) ([]string, bool) {
segments, ok := safeShellCommandSegments(command)
if !ok || len(segments) != 1 {
return nil, false
}
return segments[0], true
}
func safeShellCommandSegments(command string) ([][]string, bool) {
command = strings.TrimSpace(command)
if command == "" {
return nil, false
}
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
if err != nil || len(file.Stmts) == 0 {
if err != nil || len(file.Stmts) != 1 {
return nil, false
}
segments := make([][]string, 0, len(file.Stmts))
for _, stmt := range file.Stmts {
if !collectSafeShellStatement(stmt, &segments) {
return nil, false
}
}
if len(segments) == 0 {
stmt := file.Stmts[0]
if stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || stmt.Semicolon.IsValid() || len(stmt.Redirs) > 0 {
return nil, false
}
return segments, true
}
func collectSafeShellStatement(stmt *syntax.Stmt, segments *[][]string) bool {
if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || len(stmt.Redirs) > 0 {
return false
}
return collectSafeShellCommand(stmt.Cmd, segments)
}
func collectSafeShellCommand(cmd syntax.Command, segments *[][]string) bool {
switch node := cmd.(type) {
case *syntax.CallExpr:
tokens, ok := literalCallTokens(node)
if !ok {
return false
}
*segments = append(*segments, tokens)
return true
case *syntax.BinaryCmd:
switch node.Op {
case syntax.AndStmt, syntax.OrStmt, syntax.Pipe:
default:
return false
}
return collectSafeShellStatement(node.X, segments) && collectSafeShellStatement(node.Y, segments)
default:
return false
}
}
func literalCallTokens(call *syntax.CallExpr) ([]string, bool) {
if call == nil || len(call.Assigns) > 0 || len(call.Args) == 0 {
call, ok := stmt.Cmd.(*syntax.CallExpr)
if !ok || len(call.Assigns) > 0 || len(call.Args) == 0 {
return nil, false
}
tokens := make([]string, 0, len(call.Args))
@@ -263,228 +138,17 @@ func literalCallTokens(call *syntax.CallExpr) ([]string, bool) {
return nil, false
}
lit, ok := word.Parts[0].(*syntax.Lit)
if !ok || strings.ContainsAny(lit.Value, "*?[]{}") {
if !ok {
return nil, false
}
tokens = append(tokens, lit.Value)
}
if !sandbox.ValidCommandPrefix(tokens) {
return nil, false
}
return tokens, true
}
func knownSafeCommandSegment(command []string) bool {
if len(command) == 0 {
return false
}
switch commandName(command[0]) {
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
"true", "uname", "uniq", "wc", "which", "whoami":
return true
case "base64":
return safeBase64Command(command[1:])
case "find":
return safeFindCommand(command[1:])
case "rg":
return safeRipgrepCommand(command[1:])
case "sed":
return safeSedCommand(command[1:])
case "git":
return safeGitCommand(command)
default:
return false
}
}
func safeBase64Command(args []string) bool {
for _, arg := range args {
if arg == "-o" || arg == "--output" || strings.HasPrefix(arg, "--output=") || (strings.HasPrefix(arg, "-o") && arg != "-o") {
return false
}
}
return true
}
func safeFindCommand(args []string) bool {
unsafe := map[string]bool{
"-exec": true, "-execdir": true, "-ok": true, "-okdir": true,
"-delete": true,
"-fls": true, "-fprint": true, "-fprint0": true, "-fprintf": true,
}
for _, arg := range args {
if unsafe[arg] {
return false
}
}
return true
}
func safeRipgrepCommand(args []string) bool {
for _, arg := range args {
if arg == "--search-zip" || arg == "-z" || arg == "--pre" || arg == "--hostname-bin" ||
strings.HasPrefix(arg, "--pre=") || strings.HasPrefix(arg, "--hostname-bin=") {
return false
}
}
return true
}
func safeSedCommand(args []string) bool {
if len(args) < 2 || len(args) > 3 || args[0] != "-n" {
return false
}
return validSedPrintArg(args[1])
}
func validSedPrintArg(arg string) bool {
if !strings.HasSuffix(arg, "p") {
return false
}
body := strings.TrimSuffix(arg, "p")
if body == "" {
return false
}
for _, part := range strings.Split(body, ",") {
if part == "" {
return false
}
for _, r := range part {
if r < '0' || r > '9' {
return false
}
}
}
return true
}
func safeGitCommand(command []string) bool {
subIndex, subcommand, ok := gitSubcommand(command)
if !ok {
return false
}
if gitHasUnsafeGlobalOption(command[1:subIndex]) {
return false
}
args := command[subIndex+1:]
switch subcommand {
case "status", "log", "diff", "show":
return gitArgsReadOnly(args)
case "branch":
return gitArgsReadOnly(args) && gitBranchReadOnly(args)
default:
return false
}
}
func gitSubcommand(command []string) (int, string, bool) {
for index := 1; index < len(command); index++ {
arg := command[index]
if gitOptionConsumesValue(arg) {
index++
continue
}
if gitOptionHasInlineValue(arg) || arg == "--" || strings.HasPrefix(arg, "-") {
continue
}
switch arg {
case "status", "log", "diff", "show", "branch":
return index, arg, true
default:
return 0, "", false
}
}
return 0, "", false
}
func gitOptionConsumesValue(arg string) bool {
switch arg {
case "-C", "-c", "--config-env", "--exec-path", "--git-dir", "--namespace", "--super-prefix", "--work-tree":
return true
default:
return false
}
}
func gitOptionHasInlineValue(arg string) bool {
return strings.HasPrefix(arg, "--config-env=") ||
strings.HasPrefix(arg, "--exec-path=") ||
strings.HasPrefix(arg, "--git-dir=") ||
strings.HasPrefix(arg, "--namespace=") ||
strings.HasPrefix(arg, "--super-prefix=") ||
strings.HasPrefix(arg, "--work-tree=") ||
((strings.HasPrefix(arg, "-C") || strings.HasPrefix(arg, "-c")) && len(arg) > 2)
}
func gitHasUnsafeGlobalOption(args []string) bool {
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case strings.HasPrefix(arg, "--upload-pack"):
return true
case arg == "-C" || strings.HasPrefix(arg, "-C"):
return true
case gitOptionConsumesValue(arg):
index++
}
}
return false
}
func gitArgsReadOnly(args []string) bool {
for _, arg := range args {
switch {
case strings.HasPrefix(arg, "--output="), strings.HasPrefix(arg, "--exec="):
return false
case arg == "--output", arg == "--exec":
return false
}
}
return true
}
func gitBranchReadOnly(args []string) bool {
if len(args) == 0 {
return true
}
for _, arg := range args {
if strings.HasPrefix(arg, "--format=") {
continue
}
switch arg {
case "--list", "-l", "--show-current", "-a", "--all", "-r", "--remotes", "-v", "-vv", "--verbose":
continue
default:
return false
}
}
return true
}
func commandName(raw string) string {
name := strings.TrimSpace(raw)
if index := strings.LastIndexAny(name, `/\`); index >= 0 {
name = name[index+1:]
}
name = strings.ToLower(name)
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
if strings.HasSuffix(name, suffix) {
return strings.TrimSuffix(name, suffix)
}
}
return name
}
func hasStringPrefix(values []string, prefix []string) bool {
if len(prefix) == 0 || len(prefix) > len(values) {
return false
}
for index := range prefix {
if values[index] != prefix[index] {
return false
}
}
return true
}
func equalStringSlices(left []string, right []string) bool {
if len(left) != len(right) {
return false
+2 -53
View File
@@ -1,10 +1,6 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
)
import "testing"
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{"command": "go test ./..."})
@@ -33,35 +29,6 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
}
}
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
want := []string{"ps", "aux"}
if !equalStringSlices(got, want) {
t.Fatalf("prefix = %#v, want %#v", got, want)
}
}
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "git status --short && git status --branch",
"prefix_rule": []any{"git", "status"},
})
want := []string{"git", "status"}
if !equalStringSlices(got, want) {
t.Fatalf("prefix = %#v, want %#v", got, want)
}
}
func TestProposedCommandPrefixRejectsRequestedPrefixThatDoesNotCoverSegments(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "ps aux && npm install",
"prefix_rule": []any{"ps", "aux"},
})
if got != nil {
t.Fatalf("partial requested prefix should be rejected, got %#v", got)
}
}
func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "git status --short",
@@ -74,6 +41,7 @@ func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
cases := []string{
"echo hi && echo bye",
"cat < in > out",
"FOO=bar go test",
"echo $(whoami)",
@@ -117,25 +85,6 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
}
}
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
}
}
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
if grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}, Options{Sandbox: engine}); ok {
t.Fatalf("match = %#v session=%v, want no match because npm segment is uncovered", grant, session)
}
}
func TestProposedCommandPrefixRejectsRequestedUnsafeLauncherPrefix(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "find . -type f",
-205
View File
@@ -1,205 +0,0 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// someRequestContains reports whether any message sent to the provider across all
// turns contains substr — used to assert a continue nudge was actually injected.
func someRequestContains(requests []zeroruntime.CompletionRequest, substr string) bool {
for _, req := range requests {
for _, msg := range req.Messages {
if strings.Contains(msg.Content, substr) {
return true
}
}
}
return false
}
// planTurn is a turn that calls update_plan with the given item statuses (reusing
// the package's shared toolTurn helper).
func planTurn(statuses ...string) []zeroruntime.StreamEvent {
items := make([]string, len(statuses))
for i, s := range statuses {
items[i] = `{"content":"step ` + s + `","status":"` + s + `"}`
}
return toolTurn("plan", "update_plan", `{"plan":[`+strings.Join(items, ",")+`]}`)
}
// BUG #1 regression: a no-tool-call turn that ends mid-step while plan items are
// still pending must NOT be accepted as success. The loop must re-prompt (bounded)
// and, if the model keeps stalling, finalize as INCOMPLETE — never success.
func TestCompletionGatePendingPlanContinuesThenIncomplete(t *testing.T) {
// Mirrors the git-multibranch failure: plan with pending steps, then the model
// keeps emitting "…Let me check the SSH configuration:" without acting.
cue := "Now I need to configure the SSH server. Let me check the current SSH configuration:"
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
planTurn("completed", "pending", "pending"),
textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue),
}}
result, err := Run(context.Background(), "set up a git server", provider, Options{
Registry: registry,
MaxTurns: 10,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if !result.Incomplete {
t.Fatalf("expected Incomplete=true (model stalled with pending plan), got false; final=%q turns=%d", result.FinalAnswer, result.Turns)
}
// 1 plan turn + maxContinueNudges(3) nudged turns + 1 final stalling turn = 5.
// Critically it did NOT stop at the first text turn (request 2) as success.
if len(provider.requests) != 1+maxContinueNudges+1 {
t.Fatalf("expected %d provider turns (1 plan + %d nudges + 1 final), got %d",
1+maxContinueNudges+1, maxContinueNudges, len(provider.requests))
}
if !someRequestContains(provider.requests, continueNudgeMarker) {
t.Fatalf("expected a continue nudge (%q) to be injected into the conversation", continueNudgeMarker)
}
}
// A genuinely-complete single-turn answer (no plan, no continuation cue) must
// still finalize as success — the gate must not break short/read-only tasks.
func TestCompletionGateAcceptsGenuineCompletion(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn("The file contains 42 lines."),
}}
result, err := Run(context.Background(), "count the lines", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("genuine completion wrongly marked Incomplete; final=%q", result.FinalAnswer)
}
if result.FinalAnswer != "The file contains 42 lines." {
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
}
if len(provider.requests) != 1 {
t.Fatalf("expected exactly 1 turn (no spurious re-prompt), got %d", len(provider.requests))
}
}
// A continuation-cue turn triggers a re-prompt, but once the model actually
// finishes (clean answer, no cue, no pending plan) the run exits as success — the
// nudge gives the model a path to a legitimate completion.
func TestCompletionGateContinuesOnCueThenSucceeds(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn("Let me read the file:"),
textTurn("Done. The file has 42 lines."),
}}
result, err := Run(context.Background(), "count the lines", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("run wrongly marked Incomplete after the model completed; final=%q", result.FinalAnswer)
}
if result.FinalAnswer != "Done. The file has 42 lines." {
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 turns (1 cue re-prompted + 1 completion), got %d", len(provider.requests))
}
if !someRequestContains(provider.requests, continueNudgeMarker) {
t.Fatalf("expected a continue nudge after the continuation-cue turn")
}
}
// With the gate OFF (the interactive/TUI default), a continuation-cue turn is
// accepted as the final answer exactly as before — guaranteeing no behavior
// change for non-headless callers.
func TestCompletionGateOffPreservesLegacyBehavior(t *testing.T) {
cue := "Let me check the config:"
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn(cue),
}}
result, err := Run(context.Background(), "do a thing", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
// RequireCompletionSignal deliberately left false.
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
}
if result.FinalAnswer != cue {
t.Fatalf("final answer = %q, want %q (legacy: text-only turn is the answer)", result.FinalAnswer, cue)
}
if len(provider.requests) != 1 {
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
}
}
// review #6: the continuation-cue detector must catch a mid-line action announcement
// that stops on a colon (the git-multibranch case), without flagging genuine closers
// — a recommendation, a plain summary colon, or a sign-off.
func TestContinuationCueMatching(t *testing.T) {
cases := []struct {
text string
cue bool
}{
{"Now I need to configure the SSH server. Let me check the current SSH configuration:", true},
{"Let me read the file:", true},
{"Now I'll run the tests:", true},
{"Next, I suggest reviewing the changes.", false}, // recommendation, no colon
{"Here is the summary:", false}, // summary colon, no action lead-in
{"Let me know if you need anything:", false}, // sign-off
{"The function is implemented and all tests pass.", false},
}
for _, c := range cases {
if got := endsWithContinuationCue(c.text); got != c.cue {
t.Errorf("endsWithContinuationCue(%q) = %v, want %v", c.text, got, c.cue)
}
}
}
// review #4: a run that loops to the MaxTurns ceiling (always calling a tool, so it
// never reaches the no-tool-call gate) was reported as success. Under the headless
// gate, a max-turns cutoff is INCOMPLETE — the agent was stopped mid-run, not done.
func TestMaxTurnsCutoffIsIncompleteUnderGate(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
toolEvery := toolTurn("c", "update_plan", `{"plan":[{"content":"step","status":"in_progress"}]}`)
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
toolEvery, toolEvery, toolEvery, toolEvery,
}}
result, err := Run(context.Background(), "keep working", provider, Options{
Registry: registry,
MaxTurns: 2,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if !result.Incomplete {
t.Fatalf("a max-turns cutoff under the gate must be Incomplete; final=%q", result.FinalAnswer)
}
if !strings.Contains(result.IncompleteReason, "max-turns") {
t.Fatalf("IncompleteReason = %q, want it to cite max-turns", result.IncompleteReason)
}
}
-8
View File
@@ -81,11 +81,3 @@ Otherwise, confirm before executing.
6. Avoid redundant confirmations if you already confirmed and there is no material new risk.
7. Write/shell operations inside the workspace do not need the same scrutiny as operations outside it.
8. Interactive programs (editors, pagers, REPLs, `ssh`/`psql`/`mysql` without a command, `top`/`htop`, `git rebase -i`) will be blocked because they hang the agent; always use the non-interactive alternative.
## Sandbox Permission Requests
- Prefer sandboxed additional permissions over full escalation: use `sandbox_permissions: "with_additional_permissions"` with the required file-system or network permission when that can satisfy the command.
- Use `sandbox_permissions: "require_escalated"` only when the command must run outside the sandbox, such as inspecting or controlling host/global state that the sandbox intentionally hides.
- If a sandboxed shell command returns only sandbox-local state when the user asked for host/global state, do not answer from that misleading output. Rerun the needed command with `sandbox_permissions: "require_escalated"`, a short user-facing `justification`, and a narrow `prefix_rule` when appropriate.
- For `require_escalated`, include a short user-facing `justification`. When a reusable approval is appropriate, include a narrow `prefix_rule`; do not suggest broad shell, interpreter, or launcher prefixes.
- Shell commands are evaluated as independent command segments across `|`, `&&`, `||`, and `;`. A reusable prefix must apply safely to the command segment it is meant to authorize.
+70 -102
View File
@@ -129,24 +129,6 @@ func (fakeToolSearchTool) Run(_ context.Context, _ map[string]any) tools.Result
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func toolDefinitionByName(definitions []zeroruntime.ToolDefinition, name string) *zeroruntime.ToolDefinition {
for i := range definitions {
if definitions[i].Name == name {
return &definitions[i]
}
}
return nil
}
func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.CompletionRequest) {
t.Helper()
for _, message := range request.Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.Contains(message.Content, "Deferred tools:") {
t.Fatalf("deferred discovery must not be appended as a user message: %q", message.Content)
}
}
}
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
@@ -237,8 +219,8 @@ func TestPartitionToolsBelowThresholdInactive(t *testing.T) {
// FIX 6(a): a deferred tool removed by DisabledTools must NOT count toward the
// eligible total. With N deferred registered and threshold == N, disabling one
// drops the surviving eligible count to N-1 < threshold, so the partition takes
// the INACTIVE path (empty discovery text, all VISIBLE tools exposed with full
// schemas, the disabled one filtered out entirely).
// the INACTIVE path (empty reminder, all VISIBLE tools exposed with full schemas,
// the disabled one filtered out entirely).
func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
@@ -264,9 +246,9 @@ func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T)
}
// FIX 6(b): with deferral ACTIVE, a DisabledTools-hidden deferred tool must never
// influence discovery NOR be exposed — it is filtered out before the partition
// appear in the reminder NOR be exposed — it is filtered out before the partition
// even considers it.
func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *testing.T) {
func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
@@ -274,31 +256,25 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *
registry.Register(fakeToolSearchTool{}) // usable loader => deferral can activate
// 3 deferred, disable beta => 2 surviving eligible, threshold 2 => active.
exposed, discovery := partitionTools(registry, PermissionModeAuto, Options{
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{
DeferThreshold: 2,
DisabledTools: []string{"mcp__srv__beta"},
}, map[string]bool{})
if discovery == "" {
t.Fatalf("expected active path with discovery text for the unloaded tools")
if reminder == "" {
t.Fatalf("expected active path with a non-empty reminder for the unloaded tools")
}
if strings.Contains(discovery, "mcp__srv__beta") {
t.Fatalf("disabled deferred tool must not appear in discovery text, got %q", discovery)
if strings.Contains(reminder, "mcp__srv__beta") {
t.Fatalf("disabled deferred tool must not appear in the reminder, got %q", reminder)
}
for _, def := range exposed {
if def.Name == "mcp__srv__beta" {
t.Fatalf("disabled deferred tool must not be exposed, got %#v", exposed)
}
}
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
if search == nil {
t.Fatalf("tool_search must be exposed on active path, got %#v", exposed)
}
if search.Description != discovery {
t.Fatalf("tool_search description must carry discovery text\n got: %q\nwant: %q", search.Description, discovery)
}
if !strings.Contains(discovery, "mcp") {
t.Fatalf("discovery text must list the surviving deferred source, got %q", discovery)
// The two surviving deferred tools are hidden (unloaded) and named in the reminder.
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__gamma") {
t.Fatalf("reminder must list the surviving unloaded deferred tools, got %q", reminder)
}
}
@@ -313,7 +289,7 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
loaded := map[string]bool{"mcp__srv__alpha": true}
// 2 eligible deferred tools, threshold 2 => active.
exposed, discovery := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
exposedNames := map[string]bool{}
for _, def := range exposed {
@@ -331,21 +307,14 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
if exposedNames["mcp__srv__beta"] {
t.Fatalf("unloaded deferred tool must be hidden from exposed, got %#v", exposed)
}
if discovery == "" {
t.Fatalf("expected non-empty discovery text for the hidden tool")
if reminder == "" {
t.Fatalf("expected a non-empty reminder for the hidden tool")
}
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
if search == nil {
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
if !strings.Contains(reminder, "mcp__srv__beta") {
t.Fatalf("expected reminder to list the hidden tool, got %q", reminder)
}
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
}
if strings.Contains(search.Description, "mcp__srv__alpha") {
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
}
if !strings.Contains(search.Description, "mcp__srv__beta") {
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
if strings.Contains(reminder, "mcp__srv__alpha") {
t.Fatalf("loaded tool must not appear in the reminder, got %q", reminder)
}
}
@@ -368,9 +337,9 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
if !exposedNames["tool_search"] {
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
}
// No hidden tools means no dynamic discovery text is needed.
// BuildDeferredReminder returns "" for no hidden lines.
if reminder != "" {
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
t.Fatalf("expected empty reminder when nothing is hidden, got %q", reminder)
}
}
@@ -407,8 +376,7 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
t.Fatalf("expected two turns, got %d", len(provider.requests))
}
// Turn 1: alpha is hidden (not advertised), and discovery lives on the
// tool_search definition rather than a trailing user message.
// Turn 1: alpha is hidden (not advertised) and the reminder is a trailing user msg.
turn1Tools := map[string]bool{}
for _, def := range provider.requests[0].Tools {
turn1Tools[def.Name] = true
@@ -416,13 +384,9 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
if turn1Tools["mcp__srv__alpha"] {
t.Fatalf("turn 1 must not advertise the not-yet-loaded deferred tool")
}
assertNoDeferredDiscoveryMessage(t, provider.requests[0])
search1 := toolDefinitionByName(provider.requests[0].Tools, tools.ToolSearchToolName)
if search1 == nil {
t.Fatalf("turn 1 must expose tool_search, got %#v", provider.requests[0].Tools)
}
if !strings.Contains(search1.Description, "Tool discovery") || !strings.Contains(search1.Description, "mcp") {
t.Fatalf("tool_search description must carry deferred discovery, got %q", search1.Description)
last1 := provider.requests[0].Messages[len(provider.requests[0].Messages)-1]
if last1.Role != zeroruntime.MessageRoleUser || !strings.Contains(last1.Content, "tool_search") {
t.Fatalf("turn 1 request must end with the deferred-tools reminder, got role=%s content=%q", last1.Role, last1.Content)
}
// Turn 2: alpha is now loaded and advertised with a full schema.
@@ -437,20 +401,20 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
t.Fatalf("beta was never loaded; it must stay hidden in turn 2")
}
// The discovery text must NOT persist into the returned message history.
// The reminder must NOT persist into the returned message history.
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
t.Fatalf("deferred discovery must not be persisted in result.Messages")
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
}
}
}
// TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery drives a mid-run
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder drives a mid-run
// context-limit error that triggers reactive compaction+retry while deferral is
// ACTIVE and a deferred tool is already loaded. The retried turn must NOT be
// degraded to the empty-loaded/no-discovery state: it must still advertise the
// loaded tool's FULL schema and keep tool_search discovery for hidden tools.
func TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
// degraded to the empty-loaded/no-reminder state: it must still advertise the
// loaded tool's FULL schema and carry the <system-reminder> for the hidden tool.
func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
registry := tools.NewRegistry()
// load_signal asks the loop to load mcp__srv__alpha next turn.
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
@@ -528,20 +492,24 @@ func TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
}
assertNoDeferredDiscoveryMessage(t, retry)
search := toolDefinitionByName(retry.Tools, tools.ToolSearchToolName)
if search == nil {
t.Fatalf("retry must expose tool_search for still-hidden tools, got %#v", retry.Tools)
// The retry must carry the deferred-tools reminder as its trailing user
// message (the hidden tool is mcp__srv__beta) — not the no-reminder state.
last := retry.Messages[len(retry.Messages)-1]
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
}
if !strings.Contains(search.Description, "Tool discovery") || !strings.Contains(search.Description, "mcp") {
t.Fatalf("retry tool_search description must carry deferred discovery, got %q", search.Description)
if !strings.Contains(last.Content, "mcp__srv__beta") {
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
}
if strings.Contains(last.Content, "mcp__srv__alpha") {
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
}
// The discovery text must NOT persist into the returned message history, even on
// The reminder must NOT persist into the returned message history, even on
// the reactive-retry path.
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
t.Fatalf("deferred discovery must not be persisted in result.Messages")
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
}
}
}
@@ -576,15 +544,15 @@ func (provider *connectErrorProvider) StreamCompletion(_ context.Context, reques
return ch, nil
}
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery mirrors
// TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery but drives the
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder mirrors
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder but drives the
// CONNECT-TIME error path: StreamCompletion itself returns a non-nil
// context-limit error (rather than a mid-stream StreamEventError). With deferral
// ACTIVE and a deferred tool already loaded, the rebuilt retry request must still
// advertise the loaded tool's FULL schema AND carry tool_search discovery —
// i.e. the first reactive block reuses exposed, not the empty-loaded
// advertise the loaded tool's FULL schema AND carry the deferred-tools reminder —
// i.e. the first reactive block reuses exposed/reminder, not the empty-loaded
// partition.
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
@@ -657,19 +625,22 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testi
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
}
assertNoDeferredDiscoveryMessage(t, retry)
search := toolDefinitionByName(retry.Tools, tools.ToolSearchToolName)
if search == nil {
t.Fatalf("retry must expose tool_search for still-hidden tools, got %#v", retry.Tools)
// The retry must carry the deferred-tools reminder as its trailing user message.
last := retry.Messages[len(retry.Messages)-1]
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
}
if !strings.Contains(search.Description, "Tool discovery") || !strings.Contains(search.Description, "mcp") {
t.Fatalf("retry tool_search description must carry deferred discovery, got %q", search.Description)
if !strings.Contains(last.Content, "mcp__srv__beta") {
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
}
if strings.Contains(last.Content, "mcp__srv__alpha") {
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
}
// The discovery text must NOT persist into the returned message history.
// The reminder must NOT persist into the returned message history.
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
t.Fatalf("deferred discovery must not be persisted in result.Messages")
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
}
}
}
@@ -678,7 +649,8 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testi
// regression: N deferred tools are registered alongside tool_search, the operator
// allowlists ONLY the N deferred names (NOT tool_search), and DeferThreshold == N.
// Deferral must ACTIVATE, the partition must STILL expose tool_search (the gateway
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED.
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED — so
// the reminder never points the model at an unreachable tool.
func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
registry := tools.NewRegistry()
deferredNames := []string{"mcp__srv__alpha", "mcp__srv__beta"}
@@ -695,14 +667,14 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
EnabledTools: append([]string{}, deferredNames...),
}
exposed, discovery := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
// Deferral active => non-empty tool_search discovery for hidden deferred tools.
if discovery == "" {
t.Fatalf("expected deferral active (non-empty discovery) at threshold N, got empty")
// Deferral active => non-empty reminder advertising the hidden deferred tools.
if reminder == "" {
t.Fatalf("expected deferral active (non-empty reminder) at threshold N, got empty")
}
if !strings.Contains(discovery, "tool_search") {
t.Fatalf("discovery must instruct the model to call tool_search, got %q", discovery)
if !strings.Contains(reminder, "tool_search") {
t.Fatalf("reminder must instruct the model to call tool_search, got %q", reminder)
}
// FIX 2(a): tool_search is exposed even though the allowlist omits it.
@@ -713,10 +685,6 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
if !exposedNames["tool_search"] {
t.Fatalf("tool_search must be exposed on the active path despite the allowlist omitting it, got %#v", exposed)
}
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
if search == nil || search.Description != discovery {
t.Fatalf("tool_search description must carry discovery text, got %#v discovery=%q", search, discovery)
}
// The allowlisted-but-unloaded deferred tools are hidden (not exposed).
for _, name := range deferredNames {
if exposedNames[name] {
-219
View File
@@ -1,7 +1,6 @@
package agent
import (
"encoding/json"
"strconv"
"strings"
@@ -45,192 +44,8 @@ const (
// with the same error, so NO model (weak or strong) burns turns looping on a
// bad call.
toolFailureStopAt = 4
// maxContinueNudges bounds how many times the headless completion gate
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
// tool call while work clearly remained. Once spent, the run finalizes as
// INCOMPLETE rather than nudging forever (and it is still bounded by maxTurns
// and the run deadline).
maxContinueNudges = 3
)
// continueNudgeMarker is a stable substring for tests.
const continueNudgeMarker = "the task is not finished"
// continueNudge tells a model that stopped without a tool call — while work
// clearly remained — to keep going, or to mark the plan complete and summarize if
// it is genuinely done. The second path gives it a clean route to a legitimate
// completion (a finished plan + no continuation cue then exits as success).
func continueNudge(reason string) string {
return "You stopped without calling a tool, but " + continueNudgeMarker + " (" + reason + "). " +
"Do not stop here: take the next concrete action with a tool now. " +
"If you are genuinely finished, first mark the plan complete with update_plan, then give your final summary."
}
// endsWithContinuationCue reports whether an assistant message ends mid-thought —
// the model announcing its OWN next action and then stopping on a colon
// ("…Let me check the SSH configuration:") rather than concluding. It requires
// BOTH a trailing colon AND an action lead-in on the last line, so genuine closers
// are NOT flagged: a forward-looking recommendation ("Next, I suggest reviewing
// the changes."), a sign-off ("Let me know if you need anything"), or a summary
// that merely ends in a colon ("Here is the summary:"). A bare trailing colon alone
// is too common in legitimate final answers to use as a signal.
func endsWithContinuationCue(text string) bool {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return false
}
lines := strings.Split(trimmed, "\n")
last := ""
for i := len(lines) - 1; i >= 0; i-- {
if s := strings.TrimSpace(lines[i]); s != "" {
last = strings.ToLower(s)
break
}
}
if !strings.HasSuffix(last, ":") {
return false
}
// Inspect the final clause (after the last sentence break) so a mid-line action
// announcement is caught ("…configure the server. Let me check the config:")
// while a plain summary colon ("Here is the summary:") or a recommendation is not.
clause := last
if idx := strings.LastIndex(last, ". "); idx >= 0 {
clause = strings.TrimSpace(last[idx+2:])
}
if strings.HasPrefix(clause, "let me know") { // sign-off, not a mid-step action
return false
}
for _, cue := range []string{
"let me ", "let's ", "now i'll ", "now i will ", "now let me ", "let me now ",
"i'll now ", "i will now ", "next i'll ", "next, i'll ", "first, i'll ", "first let me ",
} {
if strings.HasPrefix(clause, cue) {
return true
}
}
return false
}
// planStatusRemaining reports whether a raw update_plan status string represents
// unfinished work. Anything not clearly completed/failed (incl. empty/unknown,
// which the update_plan tool coerces to "pending") counts as remaining, matching
// that tool's own status normalization.
func planStatusRemaining(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]",
"failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
return false
default:
return true
}
}
// acceptanceNudgeMarker is a stable substring for tests; it is embedded verbatim
// in acceptanceVerificationNudge.
const acceptanceNudgeMarker = "verify your work against the task's stated success criterion"
// selfReportPhrases are high-signal admissions of guessing/fabrication or stated
// uncertainty about the produced result. They are matched by plain substring with
// NO context guard, so every entry must be FIRST-PERSON or unambiguously about the
// model's own output — NOT a phrase that also describes implemented behavior.
// (Earlier versions included "fall back to" / "placeholder value" / "as a
// fallback" / bare "best guess" / "without proper", which match legitimate final
// answers like "the parser will fall back to UTF-8" or "I replaced the placeholder
// value", wrongly downgrading completed runs — so those are removed. Admissions of
// INABILITY are handled separately by inabilityStems, which carry a guard.)
var selfReportPhrases = []string{
// first-person admissions of guessing / fabrication / assumption
"i guessed", "my best guess", "i'm guessing", "this is a guess", "just a guess",
"i made it up", "i fabricated", "i assumed a", "i had to assume",
// first-person lack of capability (inability stems also cover cannot/could-not)
"i do not have the ability", "i don't have the ability", "i lack the ability",
"no way for me to", "not possible for me to",
// stated uncertainty about the correctness of the produced result
"may not be correct", "might not be correct", "may be incorrect", "might be incorrect",
"this may not work", "this might not work", "not fully functional", "not fully working",
}
// inabilityStems are first-person "I cannot / can't / could not / am unable to /
// do not have" stems. Matching the STEM (not a fixed verb) generalizes over
// whatever action the model claims it could not perform — "analyze", "determine",
// "do", "complete", "verify", "see", etc. — so the detector is not defeated by
// re-phrasing (the chess case slipped a fixed "cannot analyze" list by writing
// "…which I cannot do without proper image analysis capabilities").
var inabilityStems = []string{
"i cannot ", "i can't ", "i can not ", "i could not ", "i couldn't ",
"i am unable to", "i'm unable to", "i was unable to", "i wasn't able to",
"i was not able to", "i do not have", "i don't have", "unable to ",
"without being able to",
}
// successNegationTails are negated phrasings that indicate SUCCESS, not an
// admission ("I could not find any remaining issues", "I cannot reproduce the
// bug"). When an inability stem is immediately followed by one of these, it is not
// treated as an admission, so a clean result is not misreported as INCOMPLETE.
var successNegationTails = []string{
"find any", "found any", "find a ", "see any", "detect any", "identify any",
"reproduce", "spot any", "locate any",
}
// selfReportedIncompletion returns a short reason when the model's final text
// admits it guessed or could not meet the objective, else "". Case-insensitive.
func selfReportedIncompletion(text string) string {
lower := strings.ToLower(text)
for _, phrase := range selfReportPhrases {
if strings.Contains(lower, phrase) {
return selfReportReason(phrase)
}
}
for _, stem := range inabilityStems {
// Scan EVERY occurrence of the stem, not just the first: an earlier
// success-negation use ("I could not find any examples, so I could not
// implement it") must not mask a later genuine admission with the same stem.
for start := 0; ; {
rel := strings.Index(lower[start:], stem)
if rel < 0 {
break
}
abs := start + rel
tail := strings.TrimSpace(lower[abs+len(stem):])
if !hasAnyPrefix(tail, successNegationTails) {
return selfReportReason(strings.TrimSpace(stem) + " …")
}
start = abs + len(stem)
}
}
return ""
}
func selfReportReason(marker string) string {
return `the final message admits the objective was not met ("` + marker + `")`
}
func hasAnyPrefix(s string, prefixes []string) bool {
for _, p := range prefixes {
if strings.HasPrefix(s, p) {
return true
}
}
return false
}
// acceptanceVerificationNudge forces a TASK-GROUNDED acceptance check before a run
// may finalize as success. It explicitly rejects the three false-success patterns
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
// passing treated as the objective being met; and a result that merely matches a
// baseline the task asked to beat or improve. General — no task-specific content.
func acceptanceVerificationNudge() string {
return "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
"result merely matches a baseline you were asked to beat or improve. " +
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
"execute the program or tests that demonstrate the required behavior, or directly probe the specific " +
"thing the task asked you to produce, recover, fix, or optimize. " +
"If that check passes, reply PASS and cite the evidence. " +
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
}
// toolFailureHintMarker is a stable substring for tests.
const toolFailureHintMarker = "kept failing with the same error"
@@ -362,10 +177,6 @@ type guardState struct {
staleReminderSent bool
toolOnlyTurns int
toolOnlyReminderSent bool
// planItemsPending is the number of remaining (pending/in_progress) items in
// the most recent update_plan call, so the headless completion gate can tell
// whether work is unfinished when the model stops without a tool call.
planItemsPending int
// toolFailures tracks consecutive same-error failures per tool, keyed by tool
// name, so the loop can hint then halt instead of looping forever.
toolFailures map[string]*toolFailureRecord
@@ -434,9 +245,6 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
state.toolCallsSincePlanUpdate = 0
// A fresh plan update opens a new stale interval.
state.staleReminderSent = false
// Record how many items remain so the completion gate knows whether
// work is unfinished if the model later stops without a tool call.
state.observePlanUpdate(call.Arguments)
} else {
state.toolCallsSincePlanUpdate++
}
@@ -445,33 +253,6 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
return state.emptyTurns >= maxEmptyTurns
}
// pendingPlanItems reports whether the most recent update_plan call still has
// unfinished (pending/in_progress) items. False when no plan was ever recorded.
func (state *guardState) pendingPlanItems() bool {
return state.planItemsPending > 0
}
// observePlanUpdate parses an update_plan call's raw arguments and records how
// many items are still remaining. Malformed arguments leave the prior count
// unchanged (best-effort — the plan panel itself tolerates the same).
func (state *guardState) observePlanUpdate(arguments string) {
var parsed struct {
Plan []struct {
Status string `json:"status"`
} `json:"plan"`
}
if json.Unmarshal([]byte(arguments), &parsed) != nil {
return
}
pending := 0
for _, item := range parsed.Plan {
if planStatusRemaining(item.Status) {
pending++
}
}
state.planItemsPending = pending
}
func (state *guardState) progressReminder() string {
if state.toolOnlyReminderSent || state.toolOnlyTurns < toolOnlyProgressReminderAt {
return ""
+60 -549
View File
@@ -130,14 +130,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
loaded := map[string]bool{}
// continueNudges counts how many times the headless completion gate
// (Options.RequireCompletionSignal) has re-prompted a no-tool-call turn that
// stopped with work still unfinished. Bounded by maxContinueNudges.
continueNudges := 0
// acceptanceRequested records that the one-time task-grounded acceptance check
// has already been demanded this run, so it fires at most once.
acceptanceRequested := false
result := Result{Messages: copyMessages(messages)}
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
@@ -146,7 +138,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// the tool-definition tokens (they ride on every request) in its estimate.
// partitionTools depends only on registry/permissions/options/loaded, not on
// the messages, so computing it before compaction is safe.
exposed, _ := partitionTools(registry, permissionMode, options, loaded)
exposed, reminder := partitionTools(registry, permissionMode, options, loaded)
// PROACTIVE compaction: if the history is approaching the model's
// context window, summarize the oldest middle before building the
@@ -157,6 +149,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
if reminder != "" {
// Append to the per-turn request copy only — NEVER to persistent
// messages — so the reminder refreshes each turn and never accumulates
// in the saved transcript.
request.Messages = append(request.Messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
// Report the per-category context budget for this turn so a surface can
// show utilization. Opt-in: a no-op when OnContext is unset.
@@ -183,15 +184,24 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
return result, retryErr
}
// Rebuild from the compacted messages but reuse the SAME active-mode
// partition computed for this turn: it depends
// partition (exposed) and reminder computed for this turn: they depend
// on registry+loaded, not on the messages, so they stay valid after
// compaction. Using the bare toolDefinitions here would route through an
// empty-loaded partition, re-hiding every already-loaded deferred tool.
// empty-loaded partition, re-hiding every already-loaded deferred tool
// and dropping the reminder when deferral is active.
request = zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
if reminder != "" {
// Append to the per-turn retry copy only — NEVER to persistent
// messages — matching the main path's reminder-not-persisted invariant.
request.Messages = append(request.Messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
// Pre-content connect after a context-limit compaction: route through the
// reconnect helper so a transient upstream hiccup here doesn't fail the
// whole run and re-burn every token (AUDIT-L1).
@@ -225,16 +235,24 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, retryErr
}
// Reuse the SAME active-mode partition (exposed) from this
// turn rather than the bare toolDefinitions: exposed depends on
// Reuse the SAME active-mode partition (exposed) and reminder from this
// turn rather than the bare toolDefinitions: exposed/reminder depend on
// registry+loaded (not the messages), so they stay valid after compaction.
// Routing through an empty-loaded partition here would re-hide every
// already-loaded deferred tool.
// already-loaded deferred tool and drop the reminder when deferral is active.
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
if reminder != "" {
// Append to the per-turn retry copy only — NEVER to persistent
// messages — matching the main path's reminder-not-persisted invariant.
retryRequest.Messages = append(retryRequest.Messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
// Pre-content reconnect of a mid-stream disconnect: route the connect
// through the reconnect helper too (AUDIT-L1).
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
@@ -309,72 +327,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
continue
}
// Completion gate (headless): a turn with text but no tool call is the
// model's final answer ONLY when the work is actually done. Default off
// (RequireCompletionSignal), so interactive runs stay byte-identical.
if options.RequireCompletionSignal {
// (1) Self-report downgrade (strongest, unambiguous): the model's own
// final message admits it guessed / could not meet the objective. Checked
// FIRST so an admitted-impossible task is downgraded immediately (no wasted
// continue-nudges) and reports the accurate reason.
if reason := selfReportedIncompletion(collected.Text); reason != "" {
result.Incomplete = true
result.IncompleteReason = reason
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
}
// (2) The model stopped without a tool call while work may be unfinished:
// - a continuation cue ("…Let me check the config:") is an unambiguous
// mid-step stop;
// - pending update_plan items are a WEAK, ambiguous signal (the model may
// have finished without re-marking the last step).
// Nudge to continue (bounded). After the budget: a persisted continuation
// cue finalizes INCOMPLETE; pending-plan WITHOUT a cue does NOT (that would
// false-fail a completed run with stale bookkeeping) — fall through to the
// acceptance check / success.
cue := endsWithContinuationCue(collected.Text)
planPending := guards.pendingPlanItems()
if cue || planPending {
if continueNudges < maxContinueNudges {
continueNudges++
reason := "your message ended mid-step"
if !cue {
reason = "pending plan items remain — finish them, or mark them complete with update_plan if you are done"
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: continueNudge(reason),
})
continue
}
if cue {
result.Incomplete = true
result.IncompleteReason = "your message ended mid-step"
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
}
// pending-plan only, budget spent: trust the model's completion claim
// over stale plan bookkeeping; fall through.
}
// (3) Task-grounded acceptance: before accepting a "done" turn as success,
// require ONE acceptance check grounded in the task's stated criterion
// (only when self-correct is on). Rejects "well-formed == correct",
// "existing-tests-pass == objective met", and "result == baseline" false
// successes. Bounded to a single pass; a genuine post-check completion
// (no admission, no cue) then finalizes as success on the next turn.
if options.SelfCorrect != nil && !acceptanceRequested {
acceptanceRequested = true
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: acceptanceVerificationNudge(),
})
continue
}
}
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
@@ -550,27 +502,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
// Reaching here means the loop hit the maxTurns ceiling — the agent was cut off
// mid-run, not a natural completion (a finished run returns via the no-tool-call
// success path before this). Under the headless completion gate that is INCOMPLETE,
// not success, so a run that loops to the turn limit isn't reported as done.
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
result.Messages = copyMessages(finalMessages)
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "reached the max-turns limit without completing"
}
return result, nil
}
result.FinalAnswer = maxTurnsAnswer
result.Messages = copyMessages(messages)
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "reached the max-turns limit without a final answer"
}
return result, nil
}
@@ -655,7 +595,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
// tool_search is the gateway to the allowlisted deferred tools, so a non-empty
// EnabledTools allowlist that omits it must NOT reject the call — otherwise the
// discovery tool exposed to the model rejects at dispatch time (an inescapable
// reminder points the model at a tool the dispatch gate rejects (an inescapable
// dead-end). The allowlist is exempted; an explicit DisabledTools entry for
// tool_search is still honored (only the allowlist is exempted, not the denylist).
toolSearchAllowed := call.Name == tools.ToolSearchToolName && !containsToolName(options.DisabledTools, tools.ToolSearchToolName)
@@ -732,7 +672,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
preflightDecision = &decision
}
if toolFound && options.OnPermissionRequest != nil && shouldRequestPermission(tool, args, permissionGranted, preflightDecision) {
if toolFound && options.OnPermissionRequest != nil && shouldRequestPermission(tool, permissionGranted, preflightDecision) {
requestEvent, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, preflightDecision)
if !ok {
requestEvent = fallbackPermissionEvent(call, tool, args, permissionMode, options)
@@ -756,13 +696,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
return deniedPermissionResult(call, reason, requestEvent), nil
}
permissionCleanups = append(permissionCleanups, cleanup)
cleanup, err = grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeTurn, options)
if err != nil {
reason := "failed to grant additional permissions: " + err.Error()
emitDeniedPermission(options, call, requestEvent, reason)
return deniedPermissionResult(call, reason, requestEvent), nil
}
permissionCleanups = append(permissionCleanups, cleanup)
cleanup, err = grantFilesystemForSandboxPrompt(requestEvent, sandbox.PermissionGrantScopeTurn, options)
if err != nil {
reason := "failed to grant filesystem permission: " + err.Error()
@@ -773,15 +706,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
case PermissionDecisionAllowForSession:
permissionGranted = true
requestEvent.DecisionAction = decision.Action
if shellCommandAdditionalPermissionsRequested(args) {
cleanup, err := grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeSession, options)
if err != nil {
reason := "failed to grant session additional permissions: " + err.Error()
emitDeniedPermission(options, call, requestEvent, reason)
return deniedPermissionResult(call, reason, requestEvent), nil
}
permissionCleanups = append(permissionCleanups, cleanup)
} else if networkSandboxPrompt(requestEvent) {
if networkSandboxPrompt(requestEvent) {
cleanup, err := grantNetworkForSandboxPrompt(requestEvent, sandbox.PermissionGrantScopeSession, options)
if err != nil {
reason := "failed to grant session network permission: " + err.Error()
@@ -875,7 +800,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
return blockedByHookResult(call, outcome), nil
}
}
args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options)
// Task tool: wire progress callback so the TUI sees live tool-call events
// from the specialist child process.
@@ -910,18 +834,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// The sandbox decision (if any) is returned synchronously on the Result and
// used here for permission event building.
})
if retryResult, directResult, retried, action, reason, prefix, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(ctx, registry, call, tool, args, result, permissionMode, options, progressCallback); retried || directResult != nil || abortErr != nil {
if directResult != nil {
return *directResult, abortErr
}
result = retryResult
permissionGranted = true
decisionAction = action
decisionReason = reason
if len(prefix) > 0 {
decisionCommandPrefix = append([]string(nil), prefix...)
}
}
sandboxDecision := result.SandboxDecision
if toolFound && options.OnPermission != nil {
if event, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, sandboxDecision); ok {
@@ -967,279 +879,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}, nil
}
const sandboxNamespaceLimitedReason = "sandbox output is limited to the sandbox PID namespace; host/global state requires approval"
func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) (tools.Result, *ToolResult, bool, PermissionDecisionAction, string, []string, error) {
if retryResult, directResult, retried, action, reason, abortErr := maybeRetryWithNetworkAfterSandboxDenial(ctx, registry, call, tool, args, result, permissionMode, options, progressCallback); retried || directResult != nil || abortErr != nil {
return retryResult, directResult, retried, action, reason, nil, abortErr
}
if !sandboxRestrictedShellRetryCandidate(call, args, result, options) {
return result, nil, false, "", "", nil, nil
}
requestEvent := sandboxRestrictionRetryEvent(call, tool, args, permissionMode, options, result)
request := permissionRequestFromEvent(requestEvent, args, options)
if permissionMode == PermissionModeUnsafe {
retryArgs := unsandboxedRetryArgs(args)
retry := runToolForUnsandboxedRetry(ctx, registry, call.Name, call.ID, retryArgs, permissionMode, options, progressCallback)
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits unsandboxed retry", nil, nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
}
decision.Action = normalizePermissionDecisionAction(decision.Action)
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = request.Reason
}
switch decision.Action {
case PermissionDecisionAllow, PermissionDecisionAllowStrict, PermissionDecisionAllowForSession, PermissionDecisionAllowPrefix, PermissionDecisionAlwaysAllowPrefix:
prefix := []string(nil)
if decision.Action == PermissionDecisionAllowPrefix || decision.Action == PermissionDecisionAlwaysAllowPrefix {
if len(request.CommandPrefix) == 0 {
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
return result, &denied, true, decision.Action, reason, nil, nil
}
prefix = append([]string(nil), request.CommandPrefix...)
if options.Sandbox != nil {
if decision.Action == PermissionDecisionAlwaysAllowPrefix {
if grant, err := persistCommandPrefixGrant(call.Name, prefix, reason, options); err == nil {
prefix = append([]string(nil), grant.Prefix...)
}
} else {
options.Sandbox.GrantCommandPrefixForSession(call.Name, prefix)
}
}
}
retryArgs := unsandboxedRetryArgs(args)
retry := runToolForUnsandboxedRetry(ctx, registry, call.Name, call.ID, retryArgs, permissionMode, options, progressCallback)
return retry, nil, true, decision.Action, reason, prefix, nil
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, reason)
canceled := canceledPermissionResult(call, reason, requestEvent)
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
return result, &denied, true, decision.Action, reason, nil, nil
}
}
func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) (tools.Result, *ToolResult, bool, PermissionDecisionAction, string, error) {
if !sandboxDeniedNetworkRetryCandidate(call, args, result, options) {
return result, nil, false, "", "", nil
}
requestEvent := sandboxDeniedNetworkRetryEvent(call, tool, args, permissionMode, options, result)
request := permissionRequestFromEvent(requestEvent, args, options)
if permissionMode == PermissionModeUnsafe {
retry := runToolForNetworkRetry(ctx, registry, call.Name, call.ID, args, permissionMode, options, progressCallback)
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits sandbox network retry", nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
}
decision.Action = normalizePermissionDecisionAction(decision.Action)
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = request.Reason
}
switch decision.Action {
case PermissionDecisionAllow, PermissionDecisionAllowStrict, PermissionDecisionAllowForSession:
scope := sandbox.PermissionGrantScopeTurn
if decision.Action == PermissionDecisionAllowForSession {
scope = sandbox.PermissionGrantScopeSession
}
cleanup, err := grantNetworkForSandboxPrompt(requestEvent, scope, options)
if err != nil {
reason := "failed to grant network permission: " + err.Error()
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
return result, &denied, true, decision.Action, reason, nil
}
if cleanup != nil {
defer cleanup()
}
retry := runToolForNetworkRetry(ctx, registry, call.Name, call.ID, args, permissionMode, options, progressCallback)
return retry, nil, true, decision.Action, reason, nil
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, reason)
canceled := canceledPermissionResult(call, reason, requestEvent)
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
return result, &denied, true, decision.Action, reason, nil
}
}
func sandboxDeniedNetworkRetryCandidate(call ToolCall, args map[string]any, result tools.Result, options Options) bool {
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
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
}
return true
}
func sandboxDeniedNetworkRetryEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionMode PermissionMode, options Options, result tools.Result) PermissionEvent {
risk := sandbox.Classify(sandbox.Request{
ToolName: call.Name,
SideEffect: sandbox.SideEffect(tool.Safety().SideEffect),
Permission: sandbox.Permission(tool.Safety().Permission),
PermissionGranted: false,
PermissionMode: sandbox.PermissionMode(permissionMode),
Args: args,
Reason: sandbox.ReasonNetworkBlocked,
})
risk = ensureRiskCategory(risk, "network", sandbox.RiskCritical)
reason := sandbox.ReasonNetworkBlocked
if keyword := strings.TrimSpace(result.Meta[tools.SandboxDenialKeywordMeta]); keyword != "" {
reason += " (" + keyword + ")"
}
return PermissionEvent{
ToolCallID: call.ID,
ToolName: call.Name,
Action: PermissionActionPrompt,
Permission: string(tools.PermissionPrompt),
PermissionMode: permissionMode,
Autonomy: options.Autonomy,
SideEffect: string(tools.SideEffectShell),
Reason: sandbox.ReasonNetworkBlocked,
DecisionReason: reason,
Scope: permissionScope(call.Name, args),
Risk: risk,
CommandPrefix: proposedCommandPrefix(call.Name, args),
}
}
func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, result tools.Result, options Options) bool {
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
return false
}
if !options.Sandbox.UnsandboxedExecutionAllowed() {
return false
}
if value, _ := args["sandbox_permissions"].(string); strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated) {
return false
}
return sandboxDeniedShellResult(result) || sandboxNamespaceLimitedShellResult(result)
}
func sandboxDeniedShellResult(result tools.Result) bool {
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
}
func sandboxNamespaceLimitedShellResult(result tools.Result) bool {
if result.Status != tools.StatusOK {
return false
}
if result.Meta["sandbox_wrapped"] != "true" {
return false
}
if result.Meta["sandbox_backend"] != string(sandbox.BackendLinuxBwrap) && result.Meta["sandbox_target_backend"] != string(sandbox.BackendLinuxBwrap) {
return false
}
output := strings.ToLower(result.Output)
return strings.Contains(output, "bwrap ") &&
strings.Contains(output, "--unshare-pid") &&
strings.Contains(output, "-- /bin/sh -c")
}
func sandboxRestrictionRetryEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionMode PermissionMode, options Options, result tools.Result) PermissionEvent {
reason := sandboxRestrictionRetryReason(result)
if keyword := strings.TrimSpace(result.Meta[tools.SandboxDenialKeywordMeta]); keyword != "" {
reason += " (" + keyword + ")"
}
risk := sandbox.Classify(sandbox.Request{
ToolName: call.Name,
SideEffect: sandbox.SideEffect(tool.Safety().SideEffect),
Permission: sandbox.Permission(tool.Safety().Permission),
PermissionGranted: false,
PermissionMode: sandbox.PermissionMode(permissionMode),
Args: args,
Reason: reason,
})
return PermissionEvent{
ToolCallID: call.ID,
ToolName: call.Name,
Action: PermissionActionPrompt,
Permission: string(tools.PermissionPrompt),
PermissionMode: permissionMode,
Autonomy: options.Autonomy,
SideEffect: string(tools.SideEffectShell),
Reason: reason,
Scope: permissionScope(call.Name, args),
Risk: risk,
CommandPrefix: proposedCommandPrefix(call.Name, args),
}
}
func sandboxRestrictionRetryReason(result tools.Result) string {
if sandboxDeniedShellResult(result) {
if reason := strings.TrimSpace(result.Meta[tools.SandboxDenialReasonMeta]); reason != "" {
return reason
}
return "sandbox blocked command execution"
}
if sandboxNamespaceLimitedShellResult(result) {
return sandboxNamespaceLimitedReason
}
return "sandbox blocked command execution"
}
func runToolForNetworkRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
return registry.RunWithOptions(ctx, name, cloneArgs(args), tools.RunOptions{
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: toolCallID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
FileTracker: options.FileTracker,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
Progress: progressCallback,
})
}
func unsandboxedRetryArgs(args map[string]any) map[string]any {
retryArgs := cloneArgs(args)
if retryArgs == nil {
retryArgs = map[string]any{}
}
retryArgs["sandbox_permissions"] = string(tools.SandboxPermissionsRequireEscalated)
return retryArgs
}
func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
return registry.RunWithOptions(ctx, name, args, tools.RunOptions{
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: toolCallID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
FileTracker: options.FileTracker,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
Progress: progressCallback,
})
}
func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
output, outputRedacted := scrubInterceptedOutput(result.Output)
display := result.Display
@@ -1503,12 +1142,9 @@ func toAgentAskUserQuestions(questions []tools.AskUserQuestion) []AskUserQuestio
converted := make([]AskUserQuestion, len(questions))
for index, question := range questions {
converted[index] = AskUserQuestion{
Question: question.Question,
Header: question.Header,
Options: append([]string{}, question.Options...),
OptionDescriptions: append([]string{}, question.OptionDescriptions...),
Recommended: question.Recommended,
MultiSelect: question.MultiSelect,
Question: question.Question,
Options: append([]string{}, question.Options...),
MultiSelect: question.MultiSelect,
}
}
return converted
@@ -1539,16 +1175,13 @@ func effectivePermission(tool tools.Tool, args map[string]any) tools.Permission
return tool.Safety().Permission
}
func shouldRequestPermission(tool tools.Tool, args map[string]any, permissionGranted bool, decision *sandbox.Decision) bool {
func shouldRequestPermission(tool tools.Tool, permissionGranted bool, decision *sandbox.Decision) bool {
if decision != nil && decision.Action == sandbox.ActionPrompt {
return true
}
if tool.Safety().Permission != tools.PermissionPrompt {
return false
}
if !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
return true
}
if decision != nil {
if sandboxDecisionRequiresExplicitPermission(decision) {
return true
@@ -2033,12 +1666,6 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
return PermissionEvent{}, false
}
scopeText := permissionScope(call.Name, args)
if profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd); ok && err == nil {
scopeText = requestPermissionsScope(profile)
}
reason = userFacingPermissionReason(call.Name, args, reason)
return PermissionEvent{
ToolCallID: call.ID,
ToolName: call.Name,
@@ -2050,7 +1677,7 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
Autonomy: options.Autonomy,
SideEffect: string(safety.SideEffect),
Reason: reason,
Scope: scopeText,
Scope: permissionScope(call.Name, args),
Risk: risk,
Block: block,
GrantMatched: grantMatched,
@@ -2059,20 +1686,6 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
}, true
}
func userFacingPermissionReason(toolName string, args map[string]any, reason string) string {
reason = strings.TrimSpace(reason)
if !isShellCommandTool(toolName) || !shellCommandRequiresEscalated(args) {
return reason
}
if justification, ok := firstStringArg(args, "justification"); ok {
return justification
}
if reason == "" || reason == sandbox.ReasonEscalatedSandboxRequired {
return "This command needs to run outside the sandbox."
}
return reason
}
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
if grant == nil {
return ""
@@ -2108,25 +1721,24 @@ func permissionRequestFromEvent(event PermissionEvent, args map[string]any, opti
GrantMatched: event.GrantMatched,
Grant: event.Grant,
CommandPrefix: append([]string(nil), event.CommandPrefix...),
AvailableDecisions: availablePermissionDecisions(event, args, options),
AvailableDecisions: availablePermissionDecisions(event, options),
}
}
func availablePermissionDecisions(event PermissionEvent, args map[string]any, options Options) []PermissionDecisionAction {
func availablePermissionDecisions(event PermissionEvent, options Options) []PermissionDecisionAction {
if event.Action != PermissionActionPrompt {
return nil
}
decisions := []PermissionDecisionAction{PermissionDecisionAllow}
inlineAdditionalPermissions := shellCommandAdditionalPermissionsRequested(args)
if options.Sandbox != nil {
decisions = append(decisions, PermissionDecisionAllowForSession)
if isShellCommandTool(event.ToolName) && len(event.CommandPrefix) > 0 && !networkSandboxPrompt(event) && !inlineAdditionalPermissions {
if isShellCommandTool(event.ToolName) && len(event.CommandPrefix) > 0 && !networkSandboxPrompt(event) {
decisions = append(decisions, PermissionDecisionAllowPrefix)
if options.Sandbox.CanPersistGrants() {
decisions = append(decisions, PermissionDecisionAlwaysAllowPrefix)
}
}
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) && !filesystemSandboxPrompt(event) && !inlineAdditionalPermissions {
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) && !filesystemSandboxPrompt(event) {
decisions = append(decisions, PermissionDecisionAlwaysAllow)
}
}
@@ -2147,74 +1759,6 @@ func networkSandboxPrompt(event PermissionEvent) bool {
sandbox.HasRiskCategory(event.Risk, "network")
}
func ensureRiskCategory(risk sandbox.Risk, category string, level sandbox.RiskLevel) sandbox.Risk {
if !sandbox.HasRiskCategory(risk, category) {
risk.Categories = append(risk.Categories, category)
sort.Strings(risk.Categories)
}
risk.Level = level
if len(risk.Categories) > 0 {
risk.Reason = fmt.Sprintf("%s risk: %s", risk.Level, strings.Join(risk.Categories, ", "))
}
return risk
}
func shellCommandAdditionalPermissionsRequested(args map[string]any) bool {
raw, ok := args["sandbox_permissions"]
if !ok || raw == nil {
return false
}
value, ok := raw.(string)
if !ok {
value = fmt.Sprint(raw)
}
return strings.TrimSpace(value) == string(tools.SandboxPermissionsWithAdditionalPermissions)
}
func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (sandbox.RequestPermissionProfile, bool, error) {
if !shellCommandAdditionalPermissionsRequested(args) {
if _, exists := args["additional_permissions"]; exists {
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf("additional_permissions requires sandbox_permissions set to %q", tools.SandboxPermissionsWithAdditionalPermissions)
}
return sandbox.RequestPermissionProfile{}, false, nil
}
raw, ok := args["additional_permissions"]
if !ok || raw == nil {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
}
data, err := json.Marshal(raw)
if err != nil {
return sandbox.RequestPermissionProfile{}, true, err
}
var profile sandbox.RequestPermissionProfile
if err := json.Unmarshal(data, &profile); err != nil {
return sandbox.RequestPermissionProfile{}, true, err
}
normalized, err := sandbox.NormalizeRequestPermissionProfile(profile, basePath)
if err != nil {
return sandbox.RequestPermissionProfile{}, true, err
}
if normalized.Empty() {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
}
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
if err != nil {
return sandbox.RequestPermissionProfile{}, true, err
}
return grantProfile, true, nil
}
func grantInlineAdditionalPermissions(args map[string]any, scope sandbox.PermissionGrantScope, options Options) (func(), error) {
profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd)
if err != nil || !ok {
return nil, err
}
if options.Sandbox == nil {
return nil, errors.New("sandbox engine is not configured")
}
return options.Sandbox.GrantRequestPermissions(profile, scope)
}
func grantNetworkForSandboxPrompt(event PermissionEvent, scope sandbox.PermissionGrantScope, options Options) (func(), error) {
if !networkSandboxPrompt(event) || options.Sandbox == nil {
return nil, nil
@@ -2255,8 +1799,6 @@ func permissionSupportsPersistentDecision(toolName string) bool {
switch toolName {
case "bash", "exec_command", "write_stdin", "apply_patch":
return false
case "browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "browser_action", "desktop_windows", "desktop_snapshot", "desktop_action", "terminal_session", "capture_artifact":
return false
default:
return true
}
@@ -2284,15 +1826,15 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
}
}
// partitionTools builds the per-turn advertised tool list and optional
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
// partitionTools builds the per-turn advertised tool list and an optional
// deferred-tools reminder. INACTIVE (DeferThreshold <= 0 or the eligible count is
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
// (dropped so it is never advertised when it cannot help), and the discovery text is
// (dropped so it is never advertised when it cannot help), and the reminder is
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
// through tool_search. Non-deferred tools (including tool_search) are always
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
// so the inactive path is stable.
// tool is exposed only when loaded[name]; otherwise it is hidden and its compact
// line goes into the reminder. Non-deferred tools (including tool_search) are
// always exposed. The exposed slice is alpha-sorted by name, matching the legacy
// order so the inactive path is stable.
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
registeredTools := registry.All()
@@ -2303,12 +1845,7 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
continue
}
visible = append(visible, tool)
// Count by deferral-ELIGIBILITY, not current deferred state: a tool that
// un-defers at runtime (e.g. swarm coordination tools once a swarm is
// active) still counts, so it can't drop `eligible` below the threshold
// and force-expose every other deferred tool. Whether a tool is actually
// hidden is decided by IsDeferred in the exposure loop below.
if tools.IsDeferralEligible(tool) {
if tools.IsDeferred(tool) {
eligible++
}
}
@@ -2330,7 +1867,7 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
exposedNames := make(map[string]bool, len(visible))
var hiddenTools []tools.Tool
var hiddenLines []string
for _, tool := range visible {
name := tool.Name()
deferred := tools.IsDeferred(tool)
@@ -2351,7 +1888,7 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
// Active path.
if deferred && !loaded[name] {
hiddenTools = append(hiddenTools, tool)
hiddenLines = append(hiddenLines, tools.DeferredLine(tool))
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
@@ -2368,34 +1905,23 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
// tools, not the loader). Expose its full definition whenever it is not
// already in the exposed set. This never runs on the inactive path, so the
// byte-identical below-threshold output is preserved.
discovery := ""
if active && len(hiddenTools) > 0 {
discovery = tools.BuildToolSearchDescription(hiddenTools)
}
if active && !exposedNames[tools.ToolSearchToolName] {
description := loader.Description()
if discovery != "" {
description = discovery
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: description,
Description: loader.Description(),
Parameters: schemaToRuntimeMap(loader.Parameters()),
})
} else if active && discovery != "" {
for i := range definitions {
if definitions[i].Name == tools.ToolSearchToolName {
definitions[i].Description = discovery
break
}
}
}
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
return definitions, discovery
reminder := ""
if active {
reminder = tools.BuildDeferredReminder(hiddenLines)
}
return definitions, reminder
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
@@ -2489,21 +2015,6 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
if permissionMode == PermissionModeAuto {
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
}
if permissionMode == PermissionModeMemberAuto {
// Like Auto, plus the in-workspace mutators a headless member needs to
// build. The sandbox engine still decides at call time: in-workspace writes
// and sandbox-backed shell auto-allow, while out-of-workspace writes,
// network, and destructive commands prompt → denied headless. So this
// advertises capability without widening sandbox authority.
if tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto {
return true
}
switch tool.Safety().SideEffect {
case tools.SideEffectWrite, tools.SideEffectShell:
return true
}
return false
}
return true
}
+9 -630
View File
@@ -37,365 +37,6 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
return ch, nil
}
type sandboxDeniedRetryTool struct {
calls []map[string]any
}
func (tool *sandboxDeniedRetryTool) Name() string { return "bash" }
func (tool *sandboxDeniedRetryTool) Description() string { return "test shell tool" }
func (tool *sandboxDeniedRetryTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"command": {Type: "string"},
"sandbox_permissions": {Type: "string"},
"justification": {Type: "string"},
},
Required: []string{"command"},
AdditionalProperties: false,
}
}
func (tool *sandboxDeniedRetryTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
}
func (tool *sandboxDeniedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
return tools.Result{Status: tools.StatusOK, Output: "installed"}
}
return tools.Result{
Status: tools.StatusError,
Output: "touch: cannot touch '/home/user/.npm/cache': Read-only file system",
Meta: map[string]string{
"exit_code": "1",
tools.SandboxLikelyDeniedMeta: "true",
tools.SandboxDenialReasonMeta: "sandbox blocked command execution",
tools.SandboxDenialKeywordMeta: "read-only file system",
},
}
}
type sandboxDeniedExecCommandRetryTool struct {
sandboxDeniedRetryTool
}
func (tool *sandboxDeniedExecCommandRetryTool) Name() string { return "exec_command" }
type sandboxNetworkDeniedRetryTool struct {
calls []map[string]any
}
func (tool *sandboxNetworkDeniedRetryTool) Name() string { return "bash" }
func (tool *sandboxNetworkDeniedRetryTool) Description() string { return "test shell tool" }
func (tool *sandboxNetworkDeniedRetryTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"command": {Type: "string"},
},
Required: []string{"command"},
AdditionalProperties: false,
}
}
func (tool *sandboxNetworkDeniedRetryTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
}
func (tool *sandboxNetworkDeniedRetryTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tool.RunWithOptions(ctx, args, tools.RunOptions{})
}
func (tool *sandboxNetworkDeniedRetryTool) RunWithOptions(ctx context.Context, args map[string]any, options tools.RunOptions) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
if shellNetworkAllowed(ctx, options.Sandbox, args) {
return tools.Result{Status: tools.StatusOK, Output: "server started"}
}
return tools.Result{
Status: tools.StatusOK,
Output: "Cannot open a network socket.",
Meta: map[string]string{
"exit_code": "0",
tools.SandboxLikelyDeniedMeta: "true",
tools.SandboxDenialKindMeta: tools.SandboxDenialKindNetwork,
tools.SandboxDenialReasonMeta: "sandbox blocked command execution",
tools.SandboxDenialKeywordMeta: "cannot open a network socket",
},
}
}
func shellNetworkAllowed(ctx context.Context, engine *sandbox.Engine, args map[string]any) bool {
if engine == nil {
return false
}
decision := engine.Evaluate(ctx, sandbox.Request{
ToolName: "bash",
SideEffect: sandbox.SideEffectShell,
Permission: sandbox.PermissionPrompt,
PermissionGranted: true,
PermissionMode: sandbox.PermissionModeAsk,
Args: map[string]any{"command": "curl https://example.com"},
})
return decision.Action == sandbox.ActionAllow
}
func agentNativeBackendStub() sandbox.Backend {
return sandbox.Backend{
Name: sandbox.BackendLinuxBwrap,
Available: true,
Executable: "/nonexistent/zero-linux-sandbox-stub",
CommandWrapping: true,
NativeIsolation: true,
}
}
type sandboxNamespaceLimitedRetryTool struct {
calls []map[string]any
}
func (tool *sandboxNamespaceLimitedRetryTool) Name() string { return "bash" }
func (tool *sandboxNamespaceLimitedRetryTool) Description() string { return "test shell tool" }
func (tool *sandboxNamespaceLimitedRetryTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"command": {Type: "string"},
"sandbox_permissions": {Type: "string"},
},
Required: []string{"command"},
AdditionalProperties: false,
}
}
func (tool *sandboxNamespaceLimitedRetryTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
}
func (tool *sandboxNamespaceLimitedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
return tools.Result{Status: tools.StatusOK, Output: "stdout:\nUSER PID COMMAND\nanaxy 42 firefox\nanaxy 43 Discord"}
}
return tools.Result{
Status: tools.StatusOK,
Output: "stdout:\nUSER PID COMMAND\nanaxy 1 bwrap --new-session --die-with-parent --unshare-user --unshare-pid --unshare-net --proc /proc -- /bin/sh -c ps aux\nanaxy 2 ps aux",
Meta: map[string]string{
"sandbox_backend": string(sandbox.BackendLinuxBwrap),
"sandbox_target_backend": string(sandbox.BackendLinuxBwrap),
"sandbox_wrapped": "true",
},
}
}
func TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput(t *testing.T) {
root := t.TempDir()
retryTool := &sandboxNamespaceLimitedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
var events []PermissionEvent
result, err := Run(context.Background(), "check running applications", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: agentNativeBackendStub(),
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "inspect host processes"}, nil
},
OnPermission: func(event PermissionEvent) {
events = append(events, event)
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(retryTool.calls) != 2 {
t.Fatalf("tool calls = %#v, want sandboxed attempt plus unsandboxed retry", retryTool.calls)
}
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
}
if len(requests) != 1 {
t.Fatalf("permission requests = %#v, want one unsandboxed retry approval", requests)
}
if !strings.Contains(requests[0].Reason, "sandbox PID namespace") {
t.Fatalf("permission reason = %q, want sandbox namespace reason", requests[0].Reason)
}
if !equalStringSlices(requests[0].CommandPrefix, []string{"ps", "aux"}) {
t.Fatalf("request command prefix = %#v, want ps aux", requests[0].CommandPrefix)
}
if len(events) != 1 || events[0].Action != PermissionActionAllow || events[0].DecisionAction != PermissionDecisionAllowPrefix {
t.Fatalf("permission events = %#v, want approved unsandboxed retry", events)
}
if len(provider.requests) < 2 {
t.Fatalf("provider requests = %d, want retry result sent back to model", len(provider.requests))
}
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(last.Content, "firefox") || strings.Contains(last.Content, "bwrap --new-session") {
t.Fatalf("tool result sent to model = %q, want unsandboxed retry output only", last.Content)
}
}
func TestRunUsesEscalatedJustificationForPermissionPrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(&sandboxDeniedRetryTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux","sandbox_permissions":"require_escalated","justification":"Need host process visibility."}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requestReasons []string
_, err := Run(context.Background(), "check host processes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: agentNativeBackendStub(),
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requestReasons = append(requestReasons, request.Reason)
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "not needed"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if len(requestReasons) != 1 {
t.Fatalf("permission reasons = %#v, want one request", requestReasons)
}
if requestReasons[0] != "Need host process visibility." {
t.Fatalf("permission reason = %q, want model justification", requestReasons[0])
}
}
func TestRunUsesUserFacingEscalatedFallbackForPermissionPrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(&sandboxDeniedExecCommandRetryTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "exec_command"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux","sandbox_permissions":"require_escalated"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requestReasons []string
_, err := Run(context.Background(), "check host processes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: agentNativeBackendStub(),
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requestReasons = append(requestReasons, request.Reason)
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "not needed"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if len(requestReasons) != 1 {
t.Fatalf("permission reasons = %#v, want one request", requestReasons)
}
if requestReasons[0] != "This command needs to run outside the sandbox." {
t.Fatalf("permission reason = %q, want user-facing fallback", requestReasons[0])
}
if strings.Contains(requestReasons[0], sandbox.ReasonEscalatedSandboxRequired) {
t.Fatalf("permission reason leaked internal sandbox copy: %q", requestReasons[0])
}
}
func TestRunRetriesNetworkDeniedShellWithNetworkGrant(t *testing.T) {
root := t.TempDir()
retryTool := &sandboxNetworkDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"node server.js"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
result, err := Run(context.Background(), "start server", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: agentNativeBackendStub(),
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "approve network"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(retryTool.calls) != 2 {
t.Fatalf("tool calls = %#v, want denied attempt plus network-granted retry", retryTool.calls)
}
if _, escalated := retryTool.calls[1]["sandbox_permissions"]; escalated {
t.Fatalf("network retry must stay sandboxed, got retry args %#v", retryTool.calls[1])
}
if len(requests) != 1 || requests[0].Reason != sandbox.ReasonNetworkBlocked {
t.Fatalf("permission requests = %#v, want one network approval", requests)
}
if len(provider.requests) < 2 {
t.Fatalf("provider requests = %d, want retry result sent back to model", len(provider.requests))
}
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(last.Content, "server started") {
t.Fatalf("tool result sent to model = %q, want network retry output", last.Content)
}
}
func TestIsRetriableToolError(t *testing.T) {
cases := []struct {
name string
@@ -423,145 +64,6 @@ func TestIsRetriableToolError(t *testing.T) {
}
}
func TestRunRetriesShellUnsandboxedAfterSandboxDeniedExit(t *testing.T) {
root := t.TempDir()
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"npm install --save http-server"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
var events []PermissionEvent
result, err := Run(context.Background(), "install package", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: agentNativeBackendStub(),
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "retry outside sandbox"}, nil
},
OnPermission: func(event PermissionEvent) {
events = append(events, event)
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(retryTool.calls) != 2 {
t.Fatalf("tool calls = %#v, want sandboxed attempt plus unsandboxed retry", retryTool.calls)
}
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
}
if len(requests) != 2 {
t.Fatalf("permission requests = %#v, want network approval plus retry approval", requests)
}
if requests[0].Reason != sandbox.ReasonNetworkBlocked {
t.Fatalf("first request reason = %q, want network approval", requests[0].Reason)
}
if !strings.Contains(requests[1].Reason, "sandbox blocked command execution") || !strings.Contains(requests[1].Reason, "read-only file system") {
t.Fatalf("retry request reason = %q", requests[1].Reason)
}
if len(events) != 1 || events[0].Action != PermissionActionAllow || events[0].DecisionAction != PermissionDecisionAllow {
t.Fatalf("permission events = %#v, want approved unsandboxed retry", events)
}
if len(provider.requests) < 2 {
t.Fatalf("provider requests = %d, want retry result sent back to model", len(provider.requests))
}
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(last.Content, "installed") {
t.Fatalf("tool result sent to model = %q, want retry output", last.Content)
}
}
func TestRunDoesNotRetryUnsandboxedWhenDeniedReadsActive(t *testing.T) {
root := t.TempDir()
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"touch cache-file"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
var events []PermissionEvent
policy := sandbox.DefaultPolicy()
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
result, err := Run(context.Background(), "touch cache", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: policy,
Backend: agentNativeBackendStub(),
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "unexpected"}, nil
},
OnPermission: func(event PermissionEvent) {
events = append(events, event)
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(retryTool.calls) != 1 {
t.Fatalf("tool calls = %#v, want only the original sandboxed attempt", retryTool.calls)
}
if len(requests) != 0 {
t.Fatalf("permission requests = %#v, want no unsandboxed retry request", requests)
}
for _, event := range events {
if strings.Contains(event.Reason, "sandbox blocked command execution") {
t.Fatalf("permission events = %#v, want no unsandboxed retry event", events)
}
}
if len(provider.requests) < 2 {
t.Fatalf("provider requests = %d, want tool result sent back to model", len(provider.requests))
}
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(last.Content, "Read-only file system") {
t.Fatalf("tool result sent to model = %q, want original sandbox denial", last.Content)
}
}
func TestRunReturnsProviderText(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
@@ -1523,7 +1025,7 @@ func TestAvailablePermissionDecisionsSplitDenyAndCancel(t *testing.T) {
got := availablePermissionDecisions(PermissionEvent{
ToolName: tc.tool,
Action: PermissionActionPrompt,
}, nil, options)
}, options)
if !equalPermissionDecisions(got, tc.want) {
t.Fatalf("decisions = %#v, want %#v", got, tc.want)
}
@@ -1758,129 +1260,6 @@ func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
}
}
func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing.T) {
root := t.TempDir()
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo prefix-ok"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"echo prefix-ok again"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
result, err := Run(context.Background(), "run twice", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "trust this command prefix"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
}
if len(requests) != 1 {
t.Fatalf("expected only first command to request prefix approval, got %#v", requests)
}
if len(retryTool.calls) != 2 {
t.Fatalf("tool calls = %#v, want two first-attempt executions", retryTool.calls)
}
for index, call := range retryTool.calls {
if call["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
t.Fatalf("call %d args = %#v, want prefix-approved require_escalated execution", index, call)
}
}
}
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
root := t.TempDir()
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
result, err := Run(context.Background(), "inspect processes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
if !equalStringSlices(request.CommandPrefix, []string{"ps", "aux"}) {
t.Fatalf("request command prefix = %#v, want ps aux", request.CommandPrefix)
}
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "trust this command prefix"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
}
if len(requests) != 1 {
t.Fatalf("expected only first command to request approval, got %#v", requests)
}
if len(retryTool.calls) != 2 {
t.Fatalf("tool calls = %#v, want two first-attempt executions", retryTool.calls)
}
for index, call := range retryTool.calls {
if call["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
t.Fatalf("call %d args = %#v, want require_escalated execution", index, call)
}
}
}
func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T) {
root := t.TempDir()
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
@@ -2115,8 +1494,8 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
t.Fatalf("expected tool result to be sent back to provider, got %d requests", len(provider.requests))
}
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(lastMessage.Content, "fake curl https://example.com") {
t.Fatalf("expected approved network command output after degraded execution, got %q", lastMessage.Content)
if !strings.Contains(lastMessage.Content, "native sandbox backend is unavailable") {
t.Fatalf("expected native sandbox unavailable error after approval, got %q", lastMessage.Content)
}
}
@@ -2128,7 +1507,7 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo hi && npm install","prefix_rule":["echo"]}`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo hi && echo bye","prefix_rule":["echo"]}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2224,8 +1603,8 @@ func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
t.Fatalf("expected tool result to be sent back to provider, got %d requests", len(provider.requests))
}
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(lastMessage.Content, "rm -rf /") {
t.Fatalf("expected approved shell command output after degraded execution, got %q", lastMessage.Content)
if !strings.Contains(lastMessage.Content, "native sandbox backend is unavailable") {
t.Fatalf("expected native sandbox unavailable error after approval, got %q", lastMessage.Content)
}
}
@@ -2396,7 +1775,7 @@ func TestRunEmitsPermissionEventForPersistentSandboxGrant(t *testing.T) {
func TestRunPromptsAndAllowsOutsideWorkspaceWrite(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
outside := filepath.Join(t.TempDir(), "escape.txt")
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
@@ -2458,7 +1837,7 @@ func TestRunPromptsAndAllowsOutsideWorkspaceWrite(t *testing.T) {
func TestRunSessionAllowsLaterOutsideWorkspaceWrite(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
outside := filepath.Join(t.TempDir(), "escape.txt")
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
@@ -2528,7 +1907,7 @@ func TestRunSessionAllowsLaterOutsideWorkspaceWrite(t *testing.T) {
func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
outside := filepath.Join(t.TempDir(), "escape.txt")
registry := tools.NewRegistry()
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWritePathThenAnswer(outside, "sandbox handled")
-51
View File
@@ -1,51 +0,0 @@
package agent
import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
type safetyTool struct {
name string
safety tools.Safety
}
func (t safetyTool) Name() string { return t.name }
func (t safetyTool) Description() string { return "test tool" }
func (t safetyTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (t safetyTool) Safety() tools.Safety { return t.safety }
func (t safetyTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK}
}
// member-auto advertises the in-workspace mutators a headless member needs to
// build (write/edit + shell) that plain Auto hides, but NOT network or denied
// tools — the sandbox still gates the advertised ones at call time.
func TestToolAdvertisedMemberAuto(t *testing.T) {
write := safetyTool{name: "write_file", safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionPrompt}}
shell := safetyTool{name: "bash", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt}}
read := safetyTool{name: "read_file", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}}
network := safetyTool{name: "net_tool", safety: tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionPrompt}}
denied := safetyTool{name: "blocked", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionDeny}}
// Plain Auto hides prompt-requiring mutators (the read-only member problem).
if ToolAdvertised(write, PermissionModeAuto) || ToolAdvertised(shell, PermissionModeAuto) {
t.Fatal("Auto must NOT advertise write/shell prompt tools")
}
for _, tool := range []tools.Tool{write, shell, read} {
if !ToolAdvertised(tool, PermissionModeMemberAuto) {
t.Fatalf("member-auto must advertise %q", tool.Name())
}
}
if ToolAdvertised(network, PermissionModeMemberAuto) {
t.Fatal("member-auto must NOT advertise a network prompt tool")
}
if ToolAdvertised(denied, PermissionModeMemberAuto) {
t.Fatal("member-auto must NOT advertise a denied tool")
}
}
+1 -66
View File
@@ -15,7 +15,7 @@ import (
func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
workspace := t.TempDir()
outside := tempDirOutsideDefaultTemp(t)
outside := t.TempDir()
target := filepath.Join(outside, "vegetables.txt")
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
@@ -120,71 +120,6 @@ func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
}
}
func tempDirOutsideDefaultTemp(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp(".", ".zero-sandbox-outside-")
if err != nil {
t.Fatalf("MkdirTemp outside default temp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
abs, err := filepath.Abs(dir)
if err != nil {
t.Fatalf("Abs(%q): %v", dir, err)
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return resolved
}
return filepath.Clean(abs)
}
func TestInlineAdditionalPermissionsGrantAllowsNetworkCommand(t *testing.T) {
workspace := t.TempDir()
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspace,
Policy: sandbox.DefaultPolicy(),
})
args := map[string]any{
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": true},
},
}
cleanup, err := grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeTurn, Options{
Sandbox: engine,
Cwd: workspace,
})
if err != nil {
t.Fatalf("grantInlineAdditionalPermissions: %v", err)
}
defer cleanup()
decision := engine.Evaluate(context.Background(), sandbox.Request{
WorkspaceRoot: workspace,
ToolName: "exec_command",
SideEffect: sandbox.SideEffectShell,
Permission: sandbox.PermissionPrompt,
PermissionGranted: true,
PermissionMode: sandbox.PermissionModeAsk,
Args: map[string]any{"cmd": "python3 -m http.server 8080 --bind 127.0.0.1"},
})
if decision.Action != sandbox.ActionAllow {
t.Fatalf("inline network grant should allow shell network command, got %#v", decision)
}
}
func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
args := map[string]any{
"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(tools.NewBashTool(t.TempDir()), args, false, decision) {
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
}
}
func TestRequestPermissionsDenyReturnsEmptyGrantAndContinues(t *testing.T) {
workspace := t.TempDir()
registry := tools.NewRegistry()
+6 -30
View File
@@ -16,12 +16,7 @@ work.
implementation, verification, and a clear summary.
- Only stop to ask the user (via the ask_user tool) when you are genuinely
blocked on a decision that is theirs to make and that you cannot resolve from
the code, the request, or sensible defaults. When the answer is likely one of a
small set, include 2-4 suggested `options` and mark the best as `recommended`
(it must be one of the options) so the user can pick quickly; give each option a
short `optionDescriptions` line when a one-word label needs context, and a short
`header` (2-3 words) per question as its tab label when you ask several. Omit
options for genuinely open-ended questions.
the code, the request, or sensible defaults.
## Workflow: plan then act
@@ -31,29 +26,15 @@ work.
tests, or config before you modify behavior. Never edit a file you have not
read.
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
keep it live. The plan bar is the user's progress signal — call update_plan
after EACH concrete unit of work (every file written, every command run), not
just at coarse milestones: mark the finished item completed and the next one
in_progress before you start it. A plan stuck at 0/N while files are landing is
a bug, not economy — these calls are cheap, expected, and the update_plan cards
are hidden from the transcript, so frequent updates cost the user nothing and
never clutter the conversation. Keep at most one item in_progress, and never
batch the updates to the end of the turn. Skip the plan for trivial one-step
tasks.
keep it current as you go. Skip the plan for trivial one-step tasks.
3. **Implement.** Make focused changes that match the surrounding code's style,
naming, and conventions. Prefer the smallest change that fully solves the
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
formatting-only edits unless the user asked for them.
4. **Verify.** Verify after edits; see the testing gate below. This is
mandatory.
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
summary — never a single throwaway line. Use short headings and bullets: a
one-line "what I built/changed"; **Where it lives** — the key files and how they
fit together; **How each requirement is met** — walk through each requirement
from the request and name the specifics that satisfy it; what validation ran and
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
the depth to the work — a big build earns several sections, a trivial fix earns
a line or two.
5. **Summarize.** Give a concise final response: what changed, why, which files
matter, and what validation ran. State plainly what was not verified.
## Editing discipline
@@ -82,13 +63,8 @@ work.
## Tool use
- Lead a multi-step task with a one- or two-sentence plain-language preamble on
your approach, so the user can follow what you're about to do. Then keep a brief
running account: drop a short, single-line note before each SIGNIFICANT step
(e.g. "Now the stylesheet.", "Let me wire up the cart and filters.") and explain
the outcome. Use tools to act, not to narrate — don't announce every individual
call or read; narrate only the steps a person would care about, and skip
narration entirely for trivial one-step tasks.
- Use tools to act, not to narrate. Do not announce each call; just do the work
and explain the outcome.
- Run independent, read-only lookups together when you can, rather than one at a
time, to move faster.
- exec_command is for commands that have no native tool (build, test, git,
+1 -14
View File
@@ -23,7 +23,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"avoid broad refactors",
"search the web before answering",
"do not recognize",
"how each requirement is met",
"final response",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
@@ -31,19 +31,6 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
}
}
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
prompt := strings.ToLower(buildSystemPrompt(Options{}))
for _, want := range []string{
"sandboxed shell command returns only sandbox-local state",
"sandbox_permissions: \"require_escalated\"",
"prefix_rule",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected sandbox escalation guidance %q, got:\n%s", want, buildSystemPrompt(Options{}))
}
}
}
func TestBuildSystemPromptIncludesWorkspaceSeedFromCwd(t *testing.T) {
cwd := t.TempDir()
writeSystemPromptTestFile(t, cwd, "go.mod", "module example.test/zero\n")
+4 -43
View File
@@ -24,15 +24,6 @@ const (
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
// advertises the in-workspace mutators a member needs to build (write/edit +
// shell) on top of the Auto set, while the sandbox engine still gates them at
// call time — in-workspace writes and sandbox-backed shell auto-allow, but
// out-of-workspace writes, network, and destructive commands still prompt (and
// a headless member has no approver, so they are denied). It normalizes to Auto
// everywhere except ToolAdvertised, so authority is never widened beyond what an
// interactive auto agent already has inside the sandbox.
PermissionModeMemberAuto PermissionMode = "member-auto"
)
type StopReason string
@@ -138,19 +129,11 @@ type PermissionEvent struct {
CommandPrefix []string `json:"commandPrefix,omitempty"`
}
// AskUserQuestion is one clarifying question the agent wants answered. Options are
// optional suggested answers an interactive front-end can render as a picker;
// Recommended (when set) is the suggested default — it should match one of Options.
// Header is an optional short tab title for a multi-question prompt (falls back to
// the question text). OptionDescriptions, when present, holds a one-line description
// per option aligned by index to Options (empty string = no description).
// AskUserQuestion is one clarifying question the agent wants answered.
type AskUserQuestion struct {
Question string `json:"question"`
Header string `json:"header,omitempty"`
Options []string `json:"options,omitempty"`
OptionDescriptions []string `json:"optionDescriptions,omitempty"`
Recommended string `json:"recommended,omitempty"`
MultiSelect bool `json:"multiSelect,omitempty"`
Question string `json:"question"`
Options []string `json:"options,omitempty"`
MultiSelect bool `json:"multiSelect,omitempty"`
}
// AskUserRequest is handed to OnAskUser when the model invokes the ask_user tool.
@@ -270,17 +253,6 @@ type Options struct {
// byte-identical to before). One instance per run — it holds attempt state.
SelfCorrect *SelfCorrector
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
// any assistant turn that produces text but no tool call is accepted as the
// final answer. With it, a no-tool-call turn is NOT treated as "done" while
// work clearly remains — pending update_plan items, or a message that ends on a
// continuation cue ("…Let me check the config:"). The loop then nudges the
// model to continue instead, bounded by maxContinueNudges (and still by
// MaxTurns and the run deadline); if the model keeps stalling, the run
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
RequireCompletionSignal bool
runPermissions *permissionRunState
}
@@ -294,17 +266,6 @@ type Result struct {
// hit the token cap, FinishReasonContentFilter when it was filtered. Empty for
// a normal completion.
FinishReason string
// Incomplete reports that a headless run (RequireCompletionSignal) stopped with
// work clearly unfinished: the model ended a turn with no tool call while plan
// items were pending or the message ended mid-step, the model admitted it
// guessed / could not meet the objective, and/or it failed a task-grounded
// acceptance check. Callers map it to a non-success terminal status / exit
// code. False for every normal completion.
Incomplete bool
// IncompleteReason is a short, model-derived explanation of why the run was
// marked Incomplete (e.g. "pending plan items remain"). Empty when Incomplete
// is false. Surfaced in logs / run_end so an abandoned run is debuggable.
IncompleteReason string
}
// Truncated reports whether the final response ended abnormally (cut off at the
+2 -4
View File
@@ -52,13 +52,11 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
if err != nil {
return nil, nil, err
}
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope, false)
if err != nil {
return nil, nil, err
}
registry := newCoreRegistryScoped(workspaceRoot, scope)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
return registry, engine, nil
return newCoreRegistryScoped(workspaceRoot, scope), engine, nil
},
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
Store: deps.newSessionStore(),
+2 -84
View File
@@ -17,7 +17,6 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/localcontrol"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/plugins"
@@ -514,13 +513,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
if err != nil {
return writeAppError(stderr, "failed to resolve user config path: "+err.Error(), 1)
}
// Fail-soft, one-time: move any inline plaintext API keys in config.json into
// the encrypted credential store (interactive runs only; headless exec keeps
// its existing behavior). Non-fatal — a missing keyring or write error leaves
// the inline key in place and this run still uses the already-resolved key.
if store, storeErr := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)); storeErr == nil {
_, _ = config.MigratePlaintextProviderKeys(userConfigPath, store)
}
doctorUserConfigPath := ""
projectConfigPath := ""
if resolveOptions, optErr := config.DefaultResolveOptions(workspaceRoot); optErr == nil {
@@ -561,7 +553,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
registry := newCoreRegistryScoped(workspaceRoot, scope)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
if err != nil {
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
@@ -647,9 +638,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
ProviderName: resolved.Provider.Name,
ModelName: resolved.Provider.Model,
ProviderProfile: resolved.Provider,
SavedProviders: usableSavedProviders(resolved.Providers),
FavoriteModels: resolved.Preferences.FavoriteModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
Provider: provider,
NewProvider: deps.newProvider,
ProbeProviderHealth: deps.probeProviderHealth,
@@ -723,16 +712,7 @@ func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Pr
if !config.HasProviderProfile(resolved.Provider) {
return nil, nil
}
// Load the API key from the encrypted credential store when it isn't already
// resolved from inline config or an env var. This keeps the resolver pure (no
// I/O) and is a no-op for env/inline-key providers and when nothing is stored.
profile := resolved.Provider
if path, perr := deps.userConfigPath(); perr == nil {
if store, err := config.ProviderKeyStoreAt(filepath.Dir(path)); err == nil {
profile = config.ApplyStoredAPIKey(profile, store)
}
}
return deps.newProvider(profile)
return deps.newProvider(resolved.Provider)
}
func newCoreRegistry(workspaceRoot string) *tools.Registry {
@@ -744,71 +724,9 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
for _, tool := range tools.CoreToolsScoped(workspaceRoot, scope) {
registry.Register(tool)
}
registerLocalControlTools(registry, workspaceRoot, config.LocalControlConfig{})
return registry
}
func registerLocalControlTools(registry *tools.Registry, workspaceRoot string, cfg config.LocalControlConfig) {
if registry == nil {
return
}
browserOptions := localBrowserOptionsFromConfig(cfg)
desktopOptions := localDesktopOptionsFromConfig(cfg)
terminalOptions := localTerminalOptionsFromConfig(cfg)
for _, tool := range tools.NewLocalBrowserTools(browserOptions) {
registry.Register(tool)
}
for _, tool := range tools.NewLocalDesktopTools(desktopOptions) {
registry.Register(tool)
}
for _, tool := range tools.NewLocalTerminalTools(terminalOptions) {
registry.Register(tool)
}
for _, tool := range tools.NewLocalControlArtifactTools(tools.LocalControlArtifactOptions{
Browser: browserOptions,
Desktop: desktopOptions,
Terminal: terminalOptions,
ArtifactsDir: localArtifactsDirFromConfig(workspaceRoot, cfg),
}) {
registry.Register(tool)
}
}
func localBrowserOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.BrowserOptions {
return localcontrol.BrowserOptions{
Enabled: cfg.BrowserEnabled(),
Driver: cfg.Browser.Driver,
HelperPath: cfg.Browser.HelperPath,
}
}
func localDesktopOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.DesktopOptions {
return localcontrol.DesktopOptions{
Enabled: cfg.DesktopEnabled(),
Driver: cfg.Desktop.Driver,
HelperPath: cfg.Desktop.HelperPath,
}
}
func localTerminalOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.TerminalOptions {
return localcontrol.TerminalOptions{
Enabled: cfg.TerminalEnabled(),
Driver: cfg.Terminal.Driver,
HelperPath: cfg.Terminal.HelperPath,
}
}
func localArtifactsDirFromConfig(workspaceRoot string, cfg config.LocalControlConfig) string {
dir := strings.TrimSpace(cfg.ArtifactsDir)
if dir == "" {
dir = filepath.Join(".zero", "artifacts")
}
if filepath.IsAbs(dir) {
return dir
}
return filepath.Join(workspaceRoot, dir)
}
// agentToolRuntime bundles the specialist runtime with the swarm it backs so
// their lifetimes (and shutdown) stay paired: registerSpecialistTools brings up
// both, closeSpecialistRuntime tears down both.
@@ -1068,7 +986,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
// setup result is not env-resolved, so checking APIKey alone would wrongly flag every
// env-var-based provider as keyless.
func profileHasCredential(profile config.ProviderProfile) bool {
if strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != "" {
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
return true
}
if env := strings.TrimSpace(profile.APIKeyEnv); env != "" {
+6 -7
View File
@@ -617,6 +617,9 @@ func TestRunAddDirWiresExtraWriteRootIntoTUISandboxScope(t *testing.T) {
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
}
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
if len(roots) != 2 {
t.Fatalf("scope roots = %v, want exactly workspace + extra", roots)
}
// The scope stores symlink-resolved roots (e.g. macOS /var -> /private/var),
// so compare against the resolved extra dir.
resolvedExtra, err := filepath.EvalSymlinks(extra)
@@ -676,14 +679,10 @@ func TestRunSkipPermissionsUnsafeMergesAddDirGrants(t *testing.T) {
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
}
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
found := false
for _, root := range roots {
if root == resolvedExtra {
found = true
break
}
if len(roots) != 2 {
t.Fatalf("scope roots = %v, want exactly workspace + extra", roots)
}
if !found {
if roots[1] != resolvedExtra {
t.Fatalf("scope roots = %v, want extra root %q", roots, resolvedExtra)
}
})
+2 -16
View File
@@ -9,7 +9,6 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/redaction"
@@ -138,7 +137,7 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
if _, err := fmt.Fprint(stdout, statuses); err != nil {
return exitCrash
}
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5.5\n"); err != nil {
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5\n"); err != nil {
return exitCrash
}
return exitSuccess
@@ -337,19 +336,6 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
// Also drop any stored API key and its marker so `auth logout` clears the whole
// credential (OAuth token AND key), not just the OAuth side. Surface deletion
// failures rather than reporting success while a credential remains.
keyRemoved, keyErr := config.ForgetProviderKey(provider)
if keyErr != nil {
return writeAppError(stderr, redaction.ErrorMessage(keyErr, redaction.Options{}), exitCrash)
}
if configPath, perr := deps.userConfigPath(); perr == nil {
if _, clearErr := config.ClearProviderKeyStored(configPath, provider); clearErr != nil {
return writeAppError(stderr, redaction.ErrorMessage(clearErr, redaction.Options{}), exitCrash)
}
}
removed = removed || keyRemoved
if parsed.json {
payload := struct {
Provider string `json:"provider"`
@@ -360,7 +346,7 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe
}
return exitSuccess
}
msg := fmt.Sprintf("No stored credential for %s.\n", provider)
msg := fmt.Sprintf("No OAuth login stored for %s.\n", provider)
if removed {
msg = fmt.Sprintf("Logged out of %s.\n", provider)
}
+1 -1
View File
@@ -72,7 +72,7 @@ func TestRunAuthLogoutNothing(t *testing.T) {
if code := runWithDeps([]string{"auth", "logout", "demo"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "No stored credential for demo") {
if !strings.Contains(stdout.String(), "No OAuth login stored for demo") {
t.Fatalf("logout output = %q", stdout.String())
}
}
+5 -4
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
@@ -127,8 +128,8 @@ func TestValidateExecToolFiltersAllowsToolSearch(t *testing.T) {
// TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch verifies that
// `exec --list-tools` lists the core + MCP tools WITHOUT tool_search. This is
// independent of the deferral threshold: --list-tools short-circuits and returns
// before registerToolSearchIfEligible runs (see exec.go), so tool_search is
// never registered on this path. The threshold gate itself is
// before resolveConfig and registerToolSearchIfEligible ever run (see exec.go),
// so tool_search is never registered on this path. The threshold gate itself is
// exercised by TestRegisterToolSearchIfEligible{RegistersAtThreshold,
// SkipsBelowThreshold,SkipsWhenThresholdZero} and the end-to-end at-threshold
// path in TestTUIRunThreadsDeferThresholdAndRegistersToolSearch.
@@ -140,7 +141,7 @@ func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
exitCode := runWithDeps([]string{"exec", "--list-tools"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
},
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
@@ -175,7 +176,7 @@ func TestRunExecListToolsHonorsJSONFormat(t *testing.T) {
exitCode := runWithDeps([]string{"exec", "--list-tools", "-o", "json"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
},
resolveMCPConfig: func(string) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
+141 -165
View File
@@ -16,8 +16,6 @@ import (
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
@@ -34,12 +32,6 @@ const (
exitCrash = 1
exitUsage = 2
exitProvider = 3
// exitIncomplete marks a headless run that stopped with work clearly
// unfinished — the completion gate exhausted its continue budget on a
// no-tool-call turn while plan items were pending or the model ended mid-step.
// Distinct from success so callers/benchmarks don't treat an abandoned run as
// finished.
exitIncomplete = 4
// exitInterrupted is the conventional shell exit code for a process stopped by
// SIGINT (128 + signal number 2).
exitInterrupted = 130
@@ -113,6 +105,16 @@ type execOptions struct {
// additional write roots for this run. Unioned with
// config.SandboxConfig.AdditionalWriteRoots at scope construction time.
addDirs []string
// sandboxMode is the raw --sandbox flag value ("" = default enforce, "off"/
// "disabled" = container-trusted). Resolved (with the ZERO_SANDBOX env
// fallback) into a disable decision in runExec. Off by default; the disabled
// path is an EXPLICIT, loudly-warned opt-in meant ONLY for use inside an
// already-isolated container (CI / benchmark).
sandboxMode string
// deadlineRaw is the raw --deadline flag value (a Go duration like "900s").
// Resolved (with the ZERO_DEADLINE env fallback) into a wall-clock budget for
// the run. Empty = no deadline (current behavior).
deadlineRaw string
}
type execUsageError struct {
@@ -208,44 +210,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.useSpec {
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
}
// Build the model registry once and reuse it across every model-aware
// lookup in this exec run (model resolution, reasoning-effort advisory, and
// context-window sizing). DefaultRegistry builds the full catalog and
// compiles its match patterns, so rebuilding it per lookup is wasteful.
// modelRegistry is the zero Registry when the catalog fails to build; the
// helpers below degrade to safe no-op behavior in that case.
modelRegistry, _ := modelregistry.DefaultRegistry()
overrides := config.Overrides{}
modelOverride := options.model
if options.useSpec && options.specModel != "" {
modelOverride = options.specModel
}
if modelOverride != "" {
resolvedModel, notice := resolveSelectedModel(modelRegistry, modelOverride)
overrides.Provider.Model = resolvedModel
if notice != "" {
if _, err := fmt.Fprintln(stderr, notice); err != nil {
return exitCrash
}
}
}
if options.maxTurns > 0 {
overrides.MaxTurns = options.maxTurns
}
resolved, err := deps.resolveConfig(workspaceRoot, overrides)
if err != nil {
if !options.listTools {
if preflightErr := preflightExecSession(options); preflightErr != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, preflightErr.Error())
}
if _, _, promptErr := resolveExecPrompt(options, workspaceRoot, deps.stdin); promptErr != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, promptErr.Error())
}
}
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
if err := validateExecToolFilters(options, registry); err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
@@ -274,6 +238,35 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
sessionTitle := execSessionTitle(options, prompt)
// Build the model registry once and reuse it across every model-aware
// lookup in this exec run (model resolution, reasoning-effort advisory, and
// context-window sizing). DefaultRegistry builds the full catalog and
// compiles its match patterns, so rebuilding it per lookup is wasteful.
// modelRegistry is the zero Registry when the catalog fails to build; the
// helpers below degrade to safe no-op behavior in that case.
modelRegistry, _ := modelregistry.DefaultRegistry()
overrides := config.Overrides{}
modelOverride := options.model
if options.useSpec && options.specModel != "" {
modelOverride = options.specModel
}
if modelOverride != "" {
resolvedModel, notice := resolveSelectedModel(modelRegistry, modelOverride)
overrides.Provider.Model = resolvedModel
if notice != "" {
if _, err := fmt.Fprintln(stderr, notice); err != nil {
return exitCrash
}
}
}
if options.maxTurns > 0 {
overrides.MaxTurns = options.maxTurns
}
resolved, err := deps.resolveConfig(workspaceRoot, overrides)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
if !config.HasProviderProfile(resolved.Provider) {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", "No provider configured. Run `zero setup` (guided), `zero auth` (OAuth providers), set a provider API key env var (e.g. OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY), or add .zero/config.json.")
}
@@ -330,7 +323,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
registry.Register(tool)
}
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
sandboxOff := resolveSandboxOff(options.sandboxMode)
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope, sandboxOff)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
@@ -475,6 +469,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return exitCrash
}
}
if sandboxOff {
writer.warning("Sandbox disabled (container-trusted mode): filesystem and network are unrestricted. Use only inside an isolated container.")
if writer.err != nil {
return exitCrash
}
}
sessionRecorder := execSessionRecorder{prepared: preparedSession}
// Surface a best-effort session-recording failure once, on every exit path.
@@ -492,6 +492,15 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// cleanly (the loop honors context cancellation) instead of being killed.
runCtx, stopSignals := signalContext()
defer stopSignals()
// --deadline / ZERO_DEADLINE bound the run by wall-clock time (Terminal-Bench
// budgets by time, not turns). When the deadline fires, runCtx is cancelled,
// the loop stops, and the run_end below reports status "deadline" with the
// trajectory already flushed. Empty = no deadline (unchanged behavior).
if runDeadline := resolveDeadline(options.deadlineRaw); runDeadline > 0 {
var cancelDeadline context.CancelFunc
runCtx, cancelDeadline = context.WithTimeout(runCtx, runDeadline)
defer cancelDeadline()
}
// --self-correct opts the run into the post-edit verify-and-correct loop. Off
// by default the corrector is nil, leaving the agent loop byte-identical. When
// on we verify with both the workspace test plan and LSP diagnostics over the
@@ -502,7 +511,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
defer lspShutdown()
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
DeferThreshold: effectiveDeferThreshold,
Specialists: specialistRuntime.specialistInfos(),
SessionID: preparedSession.Session.SessionID,
@@ -521,17 +530,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
// if the model keeps stalling. The interactive TUI leaves this off.
RequireCompletionSignal: true,
Sandbox: sandboxEngine,
FileTracker: tools.NewFileTracker(),
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
EnabledTools: options.enabledTools,
DisabledTools: options.disabledTools,
OnText: writer.text,
Sandbox: sandboxEngine,
FileTracker: tools.NewFileTracker(),
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
EnabledTools: options.enabledTools,
DisabledTools: options.disabledTools,
OnText: writer.text,
OnToolCall: func(call agent.ToolCall) {
writer.toolCall(call, registry)
sessionRecorder.append(sessions.EventToolCall, map[string]any{
@@ -587,11 +591,18 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err != nil {
// A Ctrl+C / SIGTERM cancellation is a clean shutdown, not a provider error.
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
// Distinguish a wall-clock --deadline stop (status "deadline") from a
// Ctrl+C / SIGTERM interrupt; both are clean shutdowns, not errors, and
// the trajectory is flushed before exit either way.
cancelStatus, cancelMsg := "interrupted", "run cancelled by signal"
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
cancelStatus, cancelMsg = "deadline", "run stopped: wall-clock deadline reached"
}
sessionRecorder.append(sessions.EventError, map[string]any{"message": cancelStatus})
switch options.outputFormat {
case execOutputStreamJSON:
writer.errorEvent("interrupted", "run cancelled by signal", false)
writer.runEnd("interrupted", exitInterrupted)
writer.errorEvent(cancelStatus, cancelMsg, false)
writer.runEnd(cancelStatus, exitInterrupted)
if writer.err != nil {
return exitCrash
}
@@ -635,43 +646,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if notice := result.TruncationNotice(); notice != "" {
writer.warning(notice)
}
// A headless run the completion gate marked INCOMPLETE (no-tool-call stall,
// self-reported non-completion, or max-turns cutoff) must NOT be reported as a
// success: exit 4 AND a machine-readable terminal event saying so. Handled per
// output format BEFORE writer.final(), because for -o json final() emits a
// {"type":"done","exit_code":0} that would otherwise mask the incomplete exit.
// An `error` event (not just a warning) is emitted so log/cron consumers that
// scan for type=="error" can recover the reason.
if result.Incomplete {
reason := result.IncompleteReason
if reason == "" {
reason = "run stopped with work unfinished"
}
sessionRecorder.append(sessions.EventError, map[string]any{"message": "incomplete: " + reason})
switch options.outputFormat {
case execOutputStreamJSON:
writer.final(result.FinalAnswer)
writer.errorEvent("incomplete", reason, false)
writer.runEnd("incomplete", exitIncomplete)
if writer.err != nil {
return exitCrash
}
case execOutputJSON:
if writeErr := writeJSONLine(stdout, map[string]any{"type": "final", "text": result.FinalAnswer}); writeErr != nil {
return exitCrash
}
if writeErr := writeJSONLine(stdout, map[string]any{"type": "error", "code": "incomplete", "message": reason}); writeErr != nil {
return exitCrash
}
if writeErr := writeJSONLine(stdout, map[string]any{"type": "done", "exit_code": exitIncomplete}); writeErr != nil {
return exitCrash
}
default:
writer.final(result.FinalAnswer)
fmt.Fprintln(stderr, "Run incomplete (not reported as success): "+reason)
}
return exitIncomplete
}
writer.final(result.FinalAnswer)
writer.runEnd("success", exitSuccess)
if writer.err != nil {
@@ -718,11 +692,7 @@ func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
count := 0
for _, tool := range registry.All() {
// Count by deferral-eligibility (not current deferred state) to match
// partitionTools' active-gate: a tool that un-defers at runtime still
// participates in the threshold. At registration time the swarm is empty
// so this equals IsDeferred, but it keeps the two count sites consistent.
if !tools.IsDeferralEligible(tool) {
if !tools.IsDeferred(tool) {
continue
}
if !agent.ToolVisible(tool, permissionMode, enabledTools, disabledTools) {
@@ -751,12 +721,63 @@ func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int,
registry.Register(tools.NewToolSearchTool(registry))
}
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps, scope *sandbox.Scope) (*sandbox.Engine, error) {
// resolveSandboxOff decides whether to disable confinement for an exec run.
// Precedence: the --sandbox flag value wins; otherwise the ZERO_SANDBOX env var.
// Recognized "off" values: off / disabled / none / 0 (case-insensitive). Anything
// else, including empty, keeps the default enforcing sandbox.
func resolveSandboxOff(flagValue string) bool {
value := strings.TrimSpace(flagValue)
if value == "" {
value = strings.TrimSpace(os.Getenv("ZERO_SANDBOX"))
}
switch strings.ToLower(value) {
case "off", "disabled", "none", "0":
return true
default:
return false
}
}
// resolveDeadline parses the run's wall-clock budget. Precedence: the --deadline
// flag wins; otherwise the ZERO_DEADLINE env var. Empty = no deadline. An
// unparseable or non-positive value is treated as no deadline.
func resolveDeadline(flagValue string) time.Duration {
value := strings.TrimSpace(flagValue)
if value == "" {
value = strings.TrimSpace(os.Getenv("ZERO_DEADLINE"))
}
if value == "" {
return 0
}
d, err := time.ParseDuration(value)
if err != nil || d <= 0 {
return 0
}
return d
}
// execSandboxPolicy builds the sandbox policy for an exec run. By default it is
// the configured, enforcing policy (DefaultPolicy + config overlay). With
// disableSandbox it becomes container-trusted: Mode=disabled, Network=allow,
// EnforceWorkspace=false — flipping all three gates so both the in-process
// Evaluate path and the OS wrapping are permissive. Pure, so the default-unchanged
// and off-mode behavior are unit-tested directly.
func execSandboxPolicy(cfg config.SandboxConfig, disableSandbox bool) sandbox.Policy {
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), cfg)
if disableSandbox {
policy.Mode = sandbox.ModeDisabled
policy.Network = sandbox.NetworkAllow
policy.EnforceWorkspace = false
}
return policy
}
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps, scope *sandbox.Scope, disableSandbox bool) (*sandbox.Engine, error) {
store, err := deps.newSandboxStore()
if err != nil {
return nil, err
}
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox)
policy := execSandboxPolicy(resolved.Sandbox, disableSandbox)
backend := deps.selectSandboxBackend(sandbox.BackendOptions{})
return sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
@@ -1000,71 +1021,30 @@ func resolveSelectedModel(registry modelregistry.Registry, input string) (string
return entry.ID, notice
}
// modelContextWindow returns the resolved model's exact context window (max input
// tokens) from the registry, or 0 when the model isn't catalogued. Compaction call
// sites wrap this in modelregistry.AgentContextWindow to apply a positive fallback;
// display/report sites use the raw value so an unknown model shows no denominator.
// modelContextWindow returns the resolved model's context window (max input
// tokens) from the model registry, used to enable agent-loop compaction. An
// unknown model (e.g. a custom openai-compatible name) returns 0, which leaves
// compaction DISABLED — a safe default that never compacts unexpectedly.
func modelContextWindow(registry modelregistry.Registry, modelID string) int {
trimmed := strings.TrimSpace(modelID)
if trimmed == "" {
return 0
}
if entry, ok := registry.Resolve(trimmed); ok {
return entry.ContextLimits.ContextWindow
}
return 0
}
// resolveAgentContextWindow returns the context window used to enable/size agent
// compaction: the exact registry value when catalogued, else a window learned from
// live provider discovery (so an uncatalogued proxy/custom model gets its real
// window instead of the generic fallback), else the positive FallbackContextWindow.
// Discovery runs only on a registry miss (catalogued models pay no latency), is
// bounded by a short timeout, and degrades to the fallback on any error — so a
// headless run is never blocked or failed by it.
func resolveAgentContextWindow(ctx context.Context, registry modelregistry.Registry, profile config.ProviderProfile) int {
if window := modelContextWindow(registry, profile.Model); window > 0 {
return window
}
if window := discoveredModelContextWindow(ctx, profile); window > 0 {
return window
}
return modelregistry.AgentContextWindow(0)
}
// discoveredModelContextWindow queries the provider's live model list for the
// active model's context window. Returns 0 when the provider isn't catalogued, the
// credential can't be resolved, discovery fails, or the model reports no window.
func discoveredModelContextWindow(ctx context.Context, profile config.ProviderProfile) int {
descriptor, ok := providercatalog.Get(strings.TrimSpace(profile.CatalogID))
entry, ok := registry.Resolve(trimmed)
if !ok {
return 0
}
// Authenticate discovery with the resolved key (inline, then stored, then env).
authed := profile
if strings.TrimSpace(authed.APIKey) == "" {
if store, err := config.ProviderKeyStore(); err == nil {
authed = config.ApplyStoredAPIKey(authed, store)
}
}
if strings.TrimSpace(authed.APIKey) == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
authed.APIKey = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
}
dctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
models, err := providermodeldiscovery.DiscoverCatalog(dctx, descriptor, authed, providermodeldiscovery.Options{})
if err != nil {
return 0
}
target := strings.TrimSpace(profile.Model)
for _, model := range models {
if strings.EqualFold(strings.TrimSpace(model.ID), target) && model.ContextWindow > 0 {
return model.ContextWindow
}
}
return 0
return entry.ContextLimits.ContextWindow
}
// reasoningEffortNotice resolves the requested --reasoning-effort against the
// selected model's supported efforts via EffectiveReasoningEffort and returns a
// short advisory when the requested value is unsupported (and was coerced to the
// model default).
//
// NOTE: the effective effort is not yet forwarded to the provider request — the
// zeroruntime.CompletionRequest / provider wire schemas carry no effort field.
// Full provider-request propagation is deferred (see slice-3 report).
// forwardedReasoningEffort returns the effort to send on the provider request.
// It mirrors reasoningEffortNotice: a known model that does not support reasoning
// yields "" (matching the "ignoring" advisory, so the request never carries an
@@ -1087,10 +1067,6 @@ func forwardedReasoningEffort(registry modelregistry.Registry, modelID string, r
return string(effective)
}
// reasoningEffortNotice resolves the requested --reasoning-effort against the
// selected model's supported efforts via EffectiveReasoningEffort and returns a
// short advisory when the requested value is unsupported (and was coerced to the
// model default).
func reasoningEffortNotice(registry modelregistry.Registry, modelID string, requested string) string {
trimmed := strings.TrimSpace(modelID)
if trimmed == "" {
+18
View File
@@ -52,6 +52,24 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
index = next
case strings.HasPrefix(arg, "--auto="):
options.autonomy = strings.TrimSpace(strings.TrimPrefix(arg, "--auto="))
case arg == "--sandbox":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.sandboxMode = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--sandbox="):
options.sandboxMode = strings.TrimSpace(strings.TrimPrefix(arg, "--sandbox="))
case arg == "--deadline":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.deadlineRaw = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--deadline="):
options.deadlineRaw = strings.TrimSpace(strings.TrimPrefix(arg, "--deadline="))
case arg == "--enabled-tools":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
+8 -27
View File
@@ -51,18 +51,13 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
providerBuilt := false
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "read_file,grep"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
providerBuilt = true
return nil, errors.New("provider should not be constructed for --list-tools")
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
},
})
@@ -72,9 +67,6 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
if providerBuilt {
t.Fatal("provider should not be constructed for --list-tools")
}
for _, want := range []string{"Tools visible to model", "read_file", "grep"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("expected tool list to contain %q, got %q", want, stdout.String())
@@ -89,18 +81,13 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
providerBuilt := false
exitCode := runWithDeps([]string{"exec", "--list-tools", "--output-format", "stream-json", "--enabled-tools", "read_file"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
providerBuilt = true
return nil, errors.New("provider should not be constructed for --list-tools")
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
},
})
@@ -110,9 +97,6 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
if providerBuilt {
t.Fatal("provider should not be constructed for --list-tools")
}
events := decodeJSONLines(t, stdout.String())
if len(events) != 3 {
t.Fatalf("expected run_start, final, run_end events, got %#v", events)
@@ -131,11 +115,11 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
}
}
func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
func TestRunExecListsMCPToolsWithoutProviderResolution(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
providerBuilt := false
providerResolved := false
closed := false
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "mcp_docs_lookup"}, &stdout, &stderr, appDeps{
@@ -143,11 +127,8 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
providerBuilt = true
return nil, errors.New("provider should not be constructed for --list-tools")
providerResolved = true
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
},
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
@@ -169,8 +150,8 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if providerBuilt {
t.Fatal("provider should not be constructed for --list-tools")
if providerResolved {
t.Fatal("provider config should not be resolved for --list-tools")
}
if !closed {
t.Fatal("MCP runtime was not closed after --list-tools")
+112
View File
@@ -0,0 +1,112 @@
package cli
import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
)
// TestExecSandboxPolicyDefaultUnchanged is the load-bearing guard for real users:
// WITHOUT --sandbox off / ZERO_SANDBOX, the exec policy MUST be the default
// enforcing one (workspace-confined, network denied). If this ever changes, the
// container-trusted off-switch has leaked into the default.
func TestExecSandboxPolicyDefaultUnchanged(t *testing.T) {
policy := execSandboxPolicy(config.SandboxConfig{}, false)
if policy.Mode != sandbox.ModeEnforce {
t.Errorf("Mode = %q, want %q (default must not change)", policy.Mode, sandbox.ModeEnforce)
}
if policy.Network != sandbox.NetworkDeny {
t.Errorf("Network = %q, want %q (default must not change)", policy.Network, sandbox.NetworkDeny)
}
if !policy.EnforceWorkspace {
t.Error("EnforceWorkspace = false, want true (default must not change)")
}
}
// TestExecSandboxPolicyOff: with the off-switch, all three confinement gates drop
// so writes-anywhere and network both work inside an isolated container.
func TestExecSandboxPolicyOff(t *testing.T) {
policy := execSandboxPolicy(config.SandboxConfig{}, true)
if policy.Mode != sandbox.ModeDisabled {
t.Errorf("Mode = %q, want %q", policy.Mode, sandbox.ModeDisabled)
}
if policy.Network != sandbox.NetworkAllow {
t.Errorf("Network = %q, want %q", policy.Network, sandbox.NetworkAllow)
}
if policy.EnforceWorkspace {
t.Error("EnforceWorkspace = true, want false")
}
}
func TestResolveSandboxOff(t *testing.T) {
cases := []struct {
flag, env string
want bool
}{
{"", "", false},
{"off", "", true},
{"disabled", "", true},
{"none", "", true},
{"0", "", true},
{"OFF", "", true}, // case-insensitive
{"enforce", "", false},
{"", "off", true}, // env fallback
{"", "disabled", true},
{"", "1", false}, // unrecognized env value
{"enforce", "off", false}, // flag wins over env
{"off", "enforce", true}, // flag wins over env
}
for _, c := range cases {
t.Setenv("ZERO_SANDBOX", c.env)
if got := resolveSandboxOff(c.flag); got != c.want {
t.Errorf("resolveSandboxOff(flag=%q, env=%q) = %v, want %v", c.flag, c.env, got, c.want)
}
}
}
func TestResolveDeadline(t *testing.T) {
cases := []struct {
flag, env string
want time.Duration
}{
{"", "", 0},
{"900s", "", 15 * time.Minute},
{"15m", "", 15 * time.Minute},
{"", "300s", 5 * time.Minute}, // env fallback
{"60s", "300s", time.Minute}, // flag wins over env
{"bogus", "", 0}, // unparseable -> no deadline
{"-5s", "", 0}, // non-positive -> no deadline
{"0s", "", 0}, // zero -> no deadline
}
for _, c := range cases {
t.Setenv("ZERO_DEADLINE", c.env)
if got := resolveDeadline(c.flag); got != c.want {
t.Errorf("resolveDeadline(flag=%q, env=%q) = %v, want %v", c.flag, c.env, got, c.want)
}
}
}
// TestParseExecArgsSandboxAndDeadline: both --flag value and --flag=value spellings
// thread through to the options.
func TestParseExecArgsSandboxAndDeadline(t *testing.T) {
opts, _, err := parseExecArgs([]string{"--sandbox", "off", "--deadline", "900s", "do the task"})
if err != nil {
t.Fatalf("parseExecArgs: %v", err)
}
if opts.sandboxMode != "off" {
t.Errorf("sandboxMode = %q, want off", opts.sandboxMode)
}
if opts.deadlineRaw != "900s" {
t.Errorf("deadlineRaw = %q, want 900s", opts.deadlineRaw)
}
opts2, _, err := parseExecArgs([]string{"--sandbox=disabled", "--deadline=15m", "x"})
if err != nil {
t.Fatalf("parseExecArgs inline: %v", err)
}
if opts2.sandboxMode != "disabled" || opts2.deadlineRaw != "15m" {
t.Errorf("inline parse: sandboxMode=%q deadlineRaw=%q, want disabled/15m", opts2.sandboxMode, opts2.deadlineRaw)
}
}
+3 -20
View File
@@ -44,7 +44,7 @@ func TestRunAddDirDispatchForwardsGrantIntoExecScope(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
cwd := t.TempDir()
extra := tempDirOutsideDefaultTemp(t)
extra := t.TempDir()
target := filepath.Join(extra, "granted.txt")
exitCode, stderr := runExecAddDirWriteProbe(t, cwd, tc.args(extra), target)
@@ -117,7 +117,7 @@ func runExecAddDirWriteProbe(t *testing.T, cwd string, args []string, target str
// prove both the overwrite and the scoped enforcement it ships.
func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
root := t.TempDir()
extra := tempDirOutsideDefaultTemp(t)
extra := t.TempDir()
inside := filepath.Join(extra, "inside.txt")
registry := newCoreRegistry(root)
@@ -160,7 +160,7 @@ func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
}
// A path outside every scope root must still be denied after re-registration.
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "outside.txt")
outside := filepath.Join(t.TempDir(), "outside.txt")
deniedOutside := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": outside,
"content": "never",
@@ -175,20 +175,3 @@ func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
t.Fatalf("expected outside file to remain absent, stat err=%v", err)
}
}
func tempDirOutsideDefaultTemp(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp(".", ".zero-sandbox-outside-")
if err != nil {
t.Fatalf("MkdirTemp outside default temp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
abs, err := filepath.Abs(dir)
if err != nil {
t.Fatalf("Abs(%q): %v", dir, err)
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return resolved
}
return filepath.Clean(abs)
}
+1 -1
View File
@@ -98,7 +98,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
defer stopSignals()
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
MaxTurns: run.resolved.MaxTurns,
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
ContextWindow: modelContextWindow(run.modelRegistry, run.resolved.Provider.Model),
SessionID: draftSession.SessionID,
SessionTitle: run.sessionTitle,
ProviderName: run.resolved.Provider.Name,
+2 -45
View File
@@ -465,7 +465,7 @@ func TestRunExecModeModelSurfacesDeprecationNoticeViaSharedPath(t *testing.T) {
func TestRunExecListToolsAppliesModeBeforeListing(t *testing.T) {
// applyExecMode now runs before tool-filter validation and the --list-tools
// branch, so a --mode preset is expanded for --list-tools. Combining a mode
// with --list-tools must still succeed without constructing a provider.
// with --list-tools must still succeed and never resolve a provider.
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -475,7 +475,7 @@ func TestRunExecListToolsAppliesModeBeforeListing(t *testing.T) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
},
})
@@ -505,49 +505,6 @@ func TestRunExecModeToolFilterReflectedInListTools(t *testing.T) {
}
}
func TestResolveExecPermissionModeMember(t *testing.T) {
cases := []struct {
autonomy string
want agent.PermissionMode
}{
{"", agent.PermissionModeAuto},
{"low", agent.PermissionModeAuto},
{"medium", agent.PermissionModeAuto},
{"member", agent.PermissionModeMemberAuto}, // headless members: write + sandboxed shell
{"high", agent.PermissionModeUnsafe},
}
for _, c := range cases {
got, err := resolveExecPermissionMode(execOptions{autonomy: c.autonomy})
if err != nil {
t.Fatalf("resolveExecPermissionMode(%q): %v", c.autonomy, err)
}
if got != c.want {
t.Errorf("resolveExecPermissionMode(%q) = %q, want %q", c.autonomy, got, c.want)
}
}
if _, err := resolveExecPermissionMode(execOptions{autonomy: "bogus"}); err == nil {
t.Fatal("an unknown autonomy level must still be rejected")
}
}
// A member-auto headless tool list must include the in-workspace mutators that
// plain Auto hides, so a swarm member can actually build. Match the tool ENTRY
// line (" write_file [") — a bare substring would false-match tool descriptions.
func TestExecMemberAutoToolListIncludesMutators(t *testing.T) {
registry := newCoreRegistry(t.TempDir())
const writeEntry = "\n write_file ["
member := formatExecToolList(registry, execOptions{}, agent.PermissionModeMemberAuto)
if !strings.Contains(member, writeEntry) {
t.Fatalf("member-auto tool list must include write_file, got %q", member)
}
// Plain Auto must still hide it (unchanged behavior — this is the read-only gate).
auto := formatExecToolList(registry, execOptions{}, agent.PermissionModeAuto)
if strings.Contains(auto, writeEntry) {
t.Fatalf("plain Auto must still hide write_file, got %q", auto)
}
}
func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
exitCode, stdout, stderr := runExecWithEcho(t, []string{
"exec",
-5
View File
@@ -81,11 +81,6 @@ func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error
switch strings.ToLower(strings.TrimSpace(options.autonomy)) {
case "", "low", "medium":
mode = agent.PermissionModeAuto
case "member":
// Internal autonomy for headless swarm/specialist members: Auto plus
// advertised in-workspace mutators (see PermissionModeMemberAuto). The
// swarm launcher sets this; it is not part of the public low|medium|high set.
mode = agent.PermissionModeMemberAuto
case "high":
mode = agent.PermissionModeUnsafe
default:
-111
View File
@@ -1,111 +0,0 @@
package cli
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/tools"
)
func TestCoreRegistryIncludesDefaultLocalControlTools(t *testing.T) {
registry := newCoreRegistry(t.TempDir())
for _, name := range []string{"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "terminal_session", "capture_artifact"} {
tool, ok := registry.Get(name)
if !ok {
t.Fatalf("%s not registered", name)
}
if tool.Safety().Permission != tools.PermissionPrompt {
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
}
}
desktop, ok := registry.Get("desktop_action")
if !ok {
t.Fatal("desktop_action not registered")
}
if desktop.Safety().Permission != tools.PermissionDeny {
t.Fatalf("desktop_action permission = %s, want deny", desktop.Safety().Permission)
}
}
func TestCoreRegistryHonorsExplicitLocalControlDisable(t *testing.T) {
registry := newCoreRegistry(t.TempDir())
var cfg config.LocalControlConfig
if err := json.Unmarshal([]byte(`{"enabled":false}`), &cfg); err != nil {
t.Fatalf("unmarshal local control config: %v", err)
}
registerLocalControlTools(registry, t.TempDir(), cfg)
for _, name := range []string{"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "desktop_action", "terminal_session", "capture_artifact"} {
tool, ok := registry.Get(name)
if !ok {
t.Fatalf("%s not registered", name)
}
if tool.Safety().Permission != tools.PermissionDeny {
t.Fatalf("%s permission = %s, want deny", name, tool.Safety().Permission)
}
}
}
func TestRunExecListToolsHonorsDisabledLocalControlConfig(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cfg := execResolvedConfig()
var localControl config.LocalControlConfig
if err := json.Unmarshal([]byte(`{"enabled":false}`), &localControl); err != nil {
t.Fatalf("unmarshal local control config: %v", err)
}
cfg.LocalControl = localControl
exitCode := runWithDeps([]string{"exec", "--list-tools", "-o", "json"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return t.TempDir(), nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return cfg, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
var payload struct {
Tools []struct {
Name string `json:"name"`
Permission string `json:"permission"`
} `json:"tools"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &payload); err != nil {
t.Fatalf("parse tools JSON: %v\n%s", err, stdout.String())
}
for _, tool := range payload.Tools {
if tool.Name == "browser_open" {
t.Fatalf("browser_open should be hidden when local control is disabled, got permission %s", tool.Permission)
}
}
}
func TestRegisterLocalControlToolsAppliesDefaults(t *testing.T) {
workspaceRoot := t.TempDir()
registry := newCoreRegistry(workspaceRoot)
registerLocalControlTools(registry, workspaceRoot, config.LocalControlConfig{Enabled: true})
for _, name := range []string{"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "terminal_session", "capture_artifact"} {
tool, ok := registry.Get(name)
if !ok {
t.Fatalf("%s not registered", name)
}
if tool.Safety().Permission != tools.PermissionPrompt {
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
}
}
desktop, ok := registry.Get("desktop_action")
if !ok {
t.Fatal("desktop_action not registered")
}
if desktop.Safety().Permission != tools.PermissionDeny {
t.Fatalf("desktop_action permission = %s, want deny without nested desktop opt-in", desktop.Safety().Permission)
}
}
+2 -4
View File
@@ -51,9 +51,7 @@ func runProvidersAdd(args []string, stdout io.Writer, stderr io.Writer, deps app
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
// Persist with the key moved into the encrypted credential store (capture flip);
// the local profile keeps the key for the verification build below.
cfg, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), options.setActive)
cfg, err := config.UpsertProvider(configPath, profile, options.setActive)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
@@ -488,7 +486,7 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
}
func providerProfileHasCredential(profile config.ProviderProfile) bool {
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
return strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != ""
}
func providerKindForDescriptor(descriptor providercatalog.Descriptor) config.ProviderKind {
+2 -2
View File
@@ -31,8 +31,8 @@ func TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite(t *testing.T) {
deps, _ := sandboxCheckDeps(t)
// An absolute path outside the workspace, portable across OSes: a Unix literal
// like /etc/passwd is not absolute on Windows (it would be joined into the
// workspace and allowed), so build one under a non-temp test dir instead.
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "outside.txt")
// workspace and allowed), so build one under a separate temp dir instead.
outside := filepath.Join(t.TempDir(), "outside.txt")
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "check", "write_file", "--side-effect", "write", "--path", outside, "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
+9 -112
View File
@@ -510,94 +510,15 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
got := stdout.String()
got = replacePathToken(got, workspace, "$WORKSPACE")
got = replacePathToken(got, store.FilePath(), "$GRANTS")
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace)
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_unavailable.golden.json"))
if err != nil {
t.Fatalf("read golden: %v", err)
}
if !jsonValuesEqual(t, wantBytes, gotBytes) {
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), string(gotBytes))
if !jsonValuesEqual(t, wantBytes, []byte(got)) {
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), got)
}
}
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string) []byte {
t.Helper()
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope(%q): %v", workspace, err)
}
roots := scope.Roots()
if len(roots) <= 1 {
return gotBytes
}
tempRoots := map[string]struct{}{}
for _, root := range roots[1:] {
tempRoots[root] = struct{}{}
}
var value any
if err := json.Unmarshal(gotBytes, &value); err != nil {
t.Fatalf("decode got JSON for normalization: %v\n%s", err, string(gotBytes))
}
plan, _ := value.(map[string]any)["plan"].(map[string]any)
profile, _ := plan["permissionProfile"].(map[string]any)
fileSystem, _ := profile["fileSystem"].(map[string]any)
fileSystem["readRoots"] = filterJSONStringRoots(fileSystem["readRoots"], tempRoots)
fileSystem["writeRoots"] = filterJSONWriteRoots(fileSystem["writeRoots"], tempRoots)
normalized, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatalf("encode normalized policy JSON: %v", err)
}
return append(normalized, '\n')
}
func filterJSONStringRoots(value any, excluded map[string]struct{}) any {
roots, ok := value.([]any)
if !ok {
return value
}
out := make([]any, 0, len(roots))
for _, root := range roots {
text, ok := root.(string)
if !ok {
out = append(out, root)
continue
}
if _, skip := excluded[text]; !skip {
out = append(out, root)
}
}
return out
}
func filterJSONWriteRoots(value any, excluded map[string]struct{}) any {
roots, ok := value.([]any)
if !ok {
return value
}
out := make([]any, 0, len(roots))
for _, root := range roots {
entry, ok := root.(map[string]any)
if !ok {
out = append(out, root)
continue
}
text, _ := entry["root"].(string)
if _, skip := excluded[text]; !skip {
out = append(out, root)
}
}
return out
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
t.Helper()
var wantValue any
@@ -608,30 +529,9 @@ func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
if err := json.Unmarshal(gotBytes, &gotValue); err != nil {
t.Fatalf("decode got JSON: %v\n%s", err, string(gotBytes))
}
normalizePortableJSONRootString(&wantValue)
normalizePortableJSONRootString(&gotValue)
return reflect.DeepEqual(wantValue, gotValue)
}
func normalizePortableJSONRootString(value *any) {
switch current := (*value).(type) {
case string:
if current == `\` {
*value = "/"
}
case []any:
for index := range current {
normalizePortableJSONRootString(&current[index])
}
case map[string]any:
for key := range current {
entry := current[key]
normalizePortableJSONRootString(&entry)
current[key] = entry
}
}
}
func replacePathToken(value string, path string, token string) string {
replace := func(value string, path string) string {
value = strings.ReplaceAll(value, path, token)
@@ -754,7 +654,7 @@ func TestEffectiveSandboxPolicyShowsWriteRootsError(t *testing.T) {
func TestRunSandboxPolicyEffectiveListsConfiguredWriteRoots(t *testing.T) {
store := newSandboxTestStore(t)
extra := tempDirOutsideDefaultTemp(t)
extra := t.TempDir()
resolvedExtra, err := filepath.EvalSymlinks(extra)
if err != nil {
t.Fatalf("EvalSymlinks(%q) returned error: %v", extra, err)
@@ -793,11 +693,11 @@ func TestRunSandboxPolicyEffectiveListsConfiguredWriteRoots(t *testing.T) {
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
}
if len(payload.WriteRoots) < 2 {
t.Fatalf("writeRoots = %#v, want workspace root, extra root, and default temp roots", payload.WriteRoots)
if len(payload.WriteRoots) != 2 {
t.Fatalf("writeRoots = %#v, want workspace root + extra root", payload.WriteRoots)
}
if !containsString(payload.WriteRoots, resolvedExtra) {
t.Fatalf("writeRoots = %#v, want configured extra root %q", payload.WriteRoots, resolvedExtra)
if payload.WriteRoots[1] != resolvedExtra {
t.Fatalf("writeRoots[1] = %q, want %q", payload.WriteRoots[1], resolvedExtra)
}
if strings.Contains(stdout.String(), "writeRootsError") {
t.Fatalf("unexpected writeRootsError key for valid roots:\n%s", stdout.String())
@@ -845,11 +745,8 @@ func TestRunSandboxPolicyEffectiveWriteRootsFailSoft(t *testing.T) {
if !strings.Contains(payload.WriteRootsError, missing) {
t.Fatalf("writeRootsError = %q, want it to name the stale root %q", payload.WriteRootsError, missing)
}
if len(payload.WriteRoots) == 0 {
t.Fatalf("writeRoots = %#v, want workspace/default-temp fallback", payload.WriteRoots)
}
if containsString(payload.WriteRoots, missing) {
t.Fatalf("writeRoots = %#v, must not include stale root %q", payload.WriteRoots, missing)
if len(payload.WriteRoots) != 1 {
t.Fatalf("writeRoots = %#v, want workspace-only fallback", payload.WriteRoots)
}
}
+1 -47
View File
@@ -263,9 +263,7 @@ func saveSetupProvider(deps appDeps, selection tui.SetupSelection, options setup
if err != nil {
return tui.SetupResult{}, err
}
// Persist with the key moved into the encrypted credential store (capture flip);
// the returned profile keeps the key for this run's immediate use.
if _, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), true); err != nil {
if _, err := config.UpsertProvider(configPath, profile, true); err != nil {
return tui.SetupResult{}, err
}
return tui.SetupResult{ConfigPath: configPath, Provider: profile}, nil
@@ -383,50 +381,6 @@ func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderPro
return config.ProviderProfile{}, false
}
// usableSavedProviders filters configured providers to those the user can actually
// use: an inline/stored/env key, an auth header, a stored OAuth login, or a no-auth
// local provider. This keeps /model from listing providers that are merely present
// in config.json but never authenticated (e.g. a default openai entry with no key).
func usableSavedProviders(providers []config.ProviderProfile) []config.ProviderProfile {
logins := oauthLoggedInProviders()
store, storeErr := config.ProviderKeyStore()
usable := make([]config.ProviderProfile, 0, len(providers))
for _, profile := range providers {
if !config.HasProviderProfile(profile) {
continue
}
// A provider whose only credential is the APIKeyStored marker counts as usable
// only when the key is actually retrievable — the keyring/file entry may have
// been deleted, leaving a stale marker that would otherwise list it in /model.
if profile.APIKeyStored && strings.TrimSpace(profile.APIKey) == "" {
if storeErr == nil {
if key, ok, err := store.Get(profile.Name); err == nil && ok && strings.TrimSpace(key) != "" {
usable = append(usable, profile)
continue
}
}
// Marker present but key missing/unreadable: fall through and judge the
// profile on its other signals (env var, OAuth, local) with the marker off.
withoutMarker := profile
withoutMarker.APIKeyStored = false
if _, missing := setupMissingCredentialEnv(withoutMarker); !missing {
usable = append(usable, profile)
} else if providerHasOAuthLogin(profile, logins) {
usable = append(usable, profile)
}
continue
}
if _, missing := setupMissingCredentialEnv(profile); !missing {
usable = append(usable, profile)
continue
}
if providerHasOAuthLogin(profile, logins) {
usable = append(usable, profile)
}
}
return usable
}
// providerProfileIsLocal reports whether a provider points at a local endpoint
// (a loopback URL or a catalog entry flagged Local), so the fallback prefers a
// remote keyed provider that is more likely reachable.
+2 -17
View File
@@ -103,9 +103,6 @@ func TestSetupProviderOptionsUseRuntimeSupportedCatalog(t *testing.T) {
}
func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
// Use the encrypted-file backend in the temp config dir so the test never
// touches the real OS keychain.
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
result, err := saveSetupProvider(appDeps{
@@ -135,20 +132,8 @@ func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
if len(cfg.Providers) != 1 {
t.Fatalf("Providers = %#v, want one provider", cfg.Providers)
}
// The capture flip: config.json must NOT hold the cleartext key — it carries the
// APIKeyStored marker, and the secret lives in the co-located credential store.
if cfg.Providers[0].APIKey != "" || cfg.Providers[0].APIKeyEnv != "" {
t.Fatalf("config must not persist the key: APIKey %q APIKeyEnv %q", cfg.Providers[0].APIKey, cfg.Providers[0].APIKeyEnv)
}
if !cfg.Providers[0].APIKeyStored {
t.Fatal("expected APIKeyStored marker in config")
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
t.Fatal(err)
}
if key, ok, _ := store.Get("ollama-cloud"); !ok || key != "sk-pasted-secret" {
t.Fatalf("stored key = %q,%v; want sk-pasted-secret in the credential store", key, ok)
if cfg.Providers[0].APIKey != "sk-pasted-secret" || cfg.Providers[0].APIKeyEnv != "" {
t.Fatalf("stored provider credentials = APIKey %q APIKeyEnv %q, want pasted key only", cfg.Providers[0].APIKey, cfg.Providers[0].APIKeyEnv)
}
}
@@ -34,7 +34,6 @@
"fileSystem": {
"kind": "restricted",
"readRoots": [
"/",
"$WORKSPACE"
],
"writeRoots": [
-177
View File
@@ -1,177 +0,0 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/credstore"
)
// ProviderKeyStoreAt opens the encrypted credential store whose file backend lives
// in dir. The backend resolves keyring-first, then encrypted-file, with a plaintext
// opt-out via ZERO_CRED_STORAGE. The dir parameter exists so tests can point the file
// backend at a temp directory; production always uses the user config directory
// (ProviderKeyStore) because provider API keys are user-scoped by design — they are
// only ever captured under the user config, never project config (a cloned repo must
// not carry keys), so runtime lookups deliberately use the user store regardless of
// where a provider profile was resolved from.
func ProviderKeyStoreAt(dir string) (*credstore.Store, error) {
return credstore.New(credstore.Options{Dir: dir})
}
// ProviderKeyStore opens the credential store beside the default user config.
func ProviderKeyStore() (*credstore.Store, error) {
configPath, err := DefaultUserConfigPath()
if err != nil {
return nil, err
}
return ProviderKeyStoreAt(filepath.Dir(configPath))
}
// APIKeyGetter is the read surface of the credential store; *credstore.Store
// satisfies it. Kept here so callers can inject a fake in tests.
type APIKeyGetter interface {
Get(provider string) (string, bool, error)
}
// APIKeySetter is the write surface of the credential store.
type APIKeySetter interface {
Set(provider, key string) error
}
// SecureProviderProfile moves an inline APIKey on the profile into the credential
// store co-located with configPath, returning a profile with APIKeyStored set and
// APIKey cleared so the secret is never written to config.json. On any store error
// (e.g. no keyring) it returns the profile unchanged — the inline key persists and
// the startup migration retries later — so capturing a provider never fails on a
// flaky credential backend.
func SecureProviderProfile(profile ProviderProfile, configPath string) ProviderProfile {
if strings.TrimSpace(profile.APIKey) == "" || strings.TrimSpace(profile.Name) == "" {
return profile
}
store, err := ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return profile
}
if err := store.Set(profile.Name, profile.APIKey); err != nil {
return profile
}
secured := profile
secured.APIKey = ""
secured.APIKeyStored = true
return secured
}
// ForgetProviderKey removes a provider's stored API key from the credential store,
// reporting whether one existed. Used by the lifecycle "remove key" / auth logout.
func ForgetProviderKey(provider string) (bool, error) {
store, err := ProviderKeyStore()
if err != nil {
return false, err
}
return store.Delete(provider)
}
// ClearProviderKeyStored unsets the APIKeyStored marker for a provider in the
// config at path, so credential checks no longer claim a stored key after one is
// removed. No-op when path/provider is empty, the config is absent, or the marker
// is already unset.
func ClearProviderKeyStored(path, provider string) (bool, error) {
path = strings.TrimSpace(path)
provider = strings.TrimSpace(provider)
if path == "" || provider == "" {
return false, nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("read config %s: %w", path, err)
}
var cfg FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return false, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
changed := false
for index := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), provider) && cfg.Providers[index].APIKeyStored {
cfg.Providers[index].APIKeyStored = false
changed = true
}
}
if !changed {
return false, nil
}
return true, writeConfigFile(path, cfg)
}
// MigratePlaintextProviderKeys moves any inline plaintext API key in the config at
// path into the credential store, marking the profile APIKeyStored and stripping
// the inline secret — but ONLY after the store write succeeds, so a failed Set
// leaves the plaintext key in place and never strands a credential. Returns how
// many keys were migrated; a no-op (0, nil) when path is empty/absent or nothing
// needs migrating. Safe to run on every startup (idempotent).
func MigratePlaintextProviderKeys(path string, store APIKeySetter) (int, error) {
path = strings.TrimSpace(path)
if path == "" || store == nil {
return 0, nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("read config %s: %w", path, err)
}
var cfg FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return 0, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
migrated := 0
for index := range cfg.Providers {
profile := &cfg.Providers[index]
key := strings.TrimSpace(profile.APIKey)
if key == "" || strings.TrimSpace(profile.Name) == "" {
continue
}
if err := store.Set(profile.Name, key); err != nil {
// Leave the plaintext key untouched; a failed Set must not strand it.
continue
}
profile.APIKey = ""
profile.APIKeyStored = true
migrated++
}
if migrated == 0 {
return 0, nil
}
if err := writeConfigFile(path, cfg); err != nil {
return migrated, fmt.Errorf("rewrite config after migration %s: %w", path, err)
}
return migrated, nil
}
// ApplyStoredAPIKey fills profile.APIKey from the credential store when it is not
// already resolved — an inline config key or a resolved APIKeyEnv always wins, so
// this only supplies a key for providers whose secret lives in the store. A nil
// store, a miss, or a store error leaves the profile unchanged.
func ApplyStoredAPIKey(profile ProviderProfile, store APIKeyGetter) ProviderProfile {
// Only load for profiles that opted into stored-key auth (APIKeyStored). Without
// this gate a stale keyring/file entry could silently reactivate credentials for a
// profile that no longer uses the store.
if store == nil || !profile.APIKeyStored || strings.TrimSpace(profile.APIKey) != "" {
return profile
}
name := strings.TrimSpace(profile.Name)
if name == "" {
return profile
}
if key, ok, err := store.Get(name); err == nil && ok && strings.TrimSpace(key) != "" {
profile.APIKey = key
}
return profile
}
-174
View File
@@ -1,174 +0,0 @@
package config
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
type fakeKeyGetter struct {
keys map[string]string
err error
}
func (f fakeKeyGetter) Get(provider string) (string, bool, error) {
if f.err != nil {
return "", false, f.err
}
v, ok := f.keys[provider]
return v, ok, nil
}
// fakeKeySetter records Set calls; setErr (when non-nil) makes Set fail.
type fakeKeySetter struct {
keys map[string]string
setErr error
}
func (f *fakeKeySetter) Set(provider, key string) error {
if f.setErr != nil {
return f.setErr
}
f.keys[provider] = key
return nil
}
func TestApplyStoredAPIKey(t *testing.T) {
store := fakeKeyGetter{keys: map[string]string{"openai": "sk-stored"}}
// Stored marker + empty key => filled from the store.
got := ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true}, store)
if got.APIKey != "sk-stored" {
t.Fatalf("expected stored key to fill empty APIKey, got %q", got.APIKey)
}
// No APIKeyStored marker => store is NOT consulted (don't reactivate a stale key).
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai"}, store)
if got.APIKey != "" {
t.Fatalf("expected no load without APIKeyStored, got %q", got.APIKey)
}
// Inline key present => store is NOT consulted (inline wins).
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true, APIKey: "sk-inline"}, store)
if got.APIKey != "sk-inline" {
t.Fatalf("inline key must win, got %q", got.APIKey)
}
// Marker set but no stored key for this provider => unchanged (empty).
got = ApplyStoredAPIKey(ProviderProfile{Name: "anthropic", APIKeyStored: true}, store)
if got.APIKey != "" {
t.Fatalf("expected no key for unstored provider, got %q", got.APIKey)
}
// Nil store => unchanged.
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true}, nil)
if got.APIKey != "" {
t.Fatalf("nil store must leave profile unchanged, got %q", got.APIKey)
}
// Empty name => unchanged (don't query the store).
got = ApplyStoredAPIKey(ProviderProfile{APIKeyStored: true}, store)
if got.APIKey != "" {
t.Fatalf("empty name must not be filled, got %q", got.APIKey)
}
}
func TestMigratePlaintextProviderKeys(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
cfgJSON := `{"activeProvider":"openai","providers":[` +
`{"name":"openai","apiKey":"sk-PLAINTEXT","model":"gpt"},` +
`{"name":"local","baseURL":"http://localhost"},` +
`{"name":"acme","apiKeyEnv":"ACME_KEY"}]}`
if err := os.WriteFile(path, []byte(cfgJSON), 0o600); err != nil {
t.Fatal(err)
}
store := &fakeKeySetter{keys: map[string]string{}}
n, err := MigratePlaintextProviderKeys(path, store)
if err != nil || n != 1 {
t.Fatalf("migrate = %d,%v; want 1,nil", n, err)
}
if store.keys["openai"] != "sk-PLAINTEXT" {
t.Fatalf("key not moved to store: %v", store.keys)
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), "sk-PLAINTEXT") {
t.Fatalf("plaintext key still in config.json:\n%s", raw)
}
if !strings.Contains(string(raw), "apiKeyStored") {
t.Fatalf("apiKeyStored marker not written:\n%s", raw)
}
// Idempotent: a second run migrates nothing.
if n2, _ := MigratePlaintextProviderKeys(path, store); n2 != 0 {
t.Fatalf("second migrate = %d, want 0", n2)
}
}
func TestMigrateLeavesKeyWhenStoreSetFails(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"providers":[{"name":"openai","apiKey":"sk-KEEP"}]}`), 0o600); err != nil {
t.Fatal(err)
}
store := &fakeKeySetter{keys: map[string]string{}, setErr: errors.New("keychain locked")}
n, err := MigratePlaintextProviderKeys(path, store)
if err != nil || n != 0 {
t.Fatalf("migrate with failing store = %d,%v; want 0,nil", n, err)
}
raw, _ := os.ReadFile(path)
if !strings.Contains(string(raw), "sk-KEEP") {
t.Fatalf("a failed Set must not strand the key; config.json:\n%s", raw)
}
}
func TestClearProviderKeyStored(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"providers":[{"name":"openai","apiKeyStored":true},{"name":"other","apiKeyStored":true}]}`), 0o600); err != nil {
t.Fatal(err)
}
cleared, err := ClearProviderKeyStored(path, "openai")
if err != nil || !cleared {
t.Fatalf("clear = %v,%v; want true,nil", cleared, err)
}
raw, _ := os.ReadFile(path)
// openai's marker is gone; other's remains.
var cfg FileConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatal(err)
}
for _, p := range cfg.Providers {
if p.Name == "openai" && p.APIKeyStored {
t.Fatal("openai marker should be cleared")
}
if p.Name == "other" && !p.APIKeyStored {
t.Fatal("other marker should be untouched")
}
}
// Idempotent: clearing again reports no change.
if cleared, _ := ClearProviderKeyStored(path, "openai"); cleared {
t.Fatal("second clear should report no change")
}
// Unknown provider: no change.
if cleared, _ := ClearProviderKeyStored(path, "nope"); cleared {
t.Fatal("unknown provider should report no change")
}
}
func TestProviderProfileAPIKeyStoredRoundTrips(t *testing.T) {
// The apiKeyStored marker survives JSON decode (custom UnmarshalJSON).
var p ProviderProfile
if err := p.UnmarshalJSON([]byte(`{"name":"openai","apiKeyStored":true}`)); err != nil {
t.Fatal(err)
}
if !p.APIKeyStored {
t.Fatal("expected apiKeyStored=true to decode")
}
if p.APIKey != "" {
t.Fatalf("no inline key expected, got %q", p.APIKey)
}
}
-130
View File
@@ -1,130 +0,0 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolveLocalControlFromUserConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{
"localControl": {
"enabled": true,
"browser": {"driver": "agent-browser"},
"desktop": {"enabled": true},
"terminal": {"enabled": false}
}
}`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if !resolved.LocalControl.Enabled {
t.Fatal("localControl.enabled = false, want true")
}
if !resolved.LocalControl.BrowserEnabled() {
t.Fatal("BrowserEnabled = false, want true by default under localControl.enabled")
}
if !resolved.LocalControl.DesktopEnabled() {
t.Fatal("DesktopEnabled = false, want explicit true")
}
if resolved.LocalControl.TerminalEnabled() {
t.Fatal("TerminalEnabled = true, want explicit false")
}
if resolved.LocalControl.Browser.Driver != "agent-browser" {
t.Fatalf("browser driver = %q, want agent-browser", resolved.LocalControl.Browser.Driver)
}
}
func TestResolveLocalControlIgnoresProjectOptIn(t *testing.T) {
projectPath := filepath.Join(t.TempDir(), "project.json")
if err := os.WriteFile(projectPath, []byte(`{"localControl":{"enabled":true,"desktop":{"enabled":true}}}`), 0o600); err != nil {
t.Fatalf("write project config: %v", err)
}
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: projectPath})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if resolved.LocalControl.Enabled || resolved.LocalControl.DesktopEnabled() {
t.Fatalf("project config enabled gated local control: %#v", resolved.LocalControl)
}
if !resolved.LocalControl.BrowserEnabled() || !resolved.LocalControl.TerminalEnabled() {
t.Fatalf("browser/terminal should remain available by default: %#v", resolved.LocalControl)
}
}
func TestLocalControlDefaultsBrowserAndTerminalOn(t *testing.T) {
resolved, err := Resolve(ResolveOptions{})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if !resolved.LocalControl.BrowserEnabled() {
t.Fatal("BrowserEnabled = false, want default true")
}
if !resolved.LocalControl.TerminalEnabled() {
t.Fatal("TerminalEnabled = false, want default true")
}
if resolved.LocalControl.DesktopEnabled() {
t.Fatal("DesktopEnabled = true, want explicit opt-in")
}
}
func TestLocalControlExplicitGlobalDisable(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"localControl":{"enabled":false}}`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if resolved.LocalControl.BrowserEnabled() || resolved.LocalControl.TerminalEnabled() || resolved.LocalControl.DesktopEnabled() {
t.Fatalf("local control not disabled globally: %#v", resolved.LocalControl)
}
}
func TestLocalControlExplicitFalseSurvivesConfigRewrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{
"activeProvider": "openai",
"providers": [{"name": "openai", "provider_kind": "openai", "model": "gpt-4.1"}],
"localControl": {
"enabled": true,
"browser": {"enabled": false},
"terminal": {"enabled": false}
}
}`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
if _, err := SetFavoriteModels(path, []string{"gpt-4.1"}); err != nil {
t.Fatalf("SetFavoriteModels returned error: %v", err)
}
persisted := readConfigFixture(t, path)
if !persisted.LocalControl.enabledSet || !persisted.LocalControl.Enabled {
t.Fatalf("localControl enabled not preserved: %#v", persisted.LocalControl)
}
if !persisted.LocalControl.Browser.enabledSet || persisted.LocalControl.Browser.Enabled {
t.Fatalf("browser explicit false not preserved: %#v", persisted.LocalControl.Browser)
}
if !persisted.LocalControl.Terminal.enabledSet || persisted.LocalControl.Terminal.Enabled {
t.Fatalf("terminal explicit false not preserved: %#v", persisted.LocalControl.Terminal)
}
}
func TestEmptyLocalControlIsOmittedFromConfigJSON(t *testing.T) {
data, err := json.Marshal(FileConfig{})
if err != nil {
t.Fatalf("marshal config: %v", err)
}
if strings.Contains(string(data), "localControl") {
t.Fatalf("empty localControl should be omitted, got %s", string(data))
}
}
-35
View File
@@ -109,7 +109,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
LocalControl: cfg.LocalControl,
}, nil
}
@@ -183,10 +182,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if src.Preferences.FavoriteModels != nil {
dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels)
}
if src.Preferences.Recaps != nil {
dst.Preferences.Recaps = src.Preferences.Recaps
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
}
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
@@ -230,9 +225,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if src.Swarm.MaxTeamSize != 0 {
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
}
// Local control is intentionally user-config/override only. A cloned project
// must not be able to make browser, desktop, or terminal automation tools
// appear in the model's tool surface.
return nil
}
@@ -563,7 +555,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
cfg.Tools.DeferThreshold = overrides.Tools.DeferThreshold
cfg.Tools.deferThresholdSet = true
}
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
for _, provider := range overrides.Providers {
mergeProvider(cfg, provider)
}
@@ -573,32 +564,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
mergeMCPConfig(&cfg.MCP, overrides.MCP)
}
func mergeLocalControlConfig(dst *LocalControlConfig, src LocalControlConfig) {
if src.enabledSet {
dst.Enabled = src.Enabled
dst.enabledSet = true
}
mergeLocalControlDriverConfig(&dst.Browser, src.Browser)
mergeLocalControlDriverConfig(&dst.Desktop, src.Desktop)
mergeLocalControlDriverConfig(&dst.Terminal, src.Terminal)
if artifactsDir := strings.TrimSpace(src.ArtifactsDir); artifactsDir != "" {
dst.ArtifactsDir = artifactsDir
}
}
func mergeLocalControlDriverConfig(dst *LocalControlDriverConfig, src LocalControlDriverConfig) {
if src.enabledSet {
dst.Enabled = src.Enabled
dst.enabledSet = true
}
if helperPath := strings.TrimSpace(src.HelperPath); helperPath != "" {
dst.HelperPath = helperPath
}
if driver := strings.TrimSpace(src.Driver); driver != "" {
dst.Driver = driver
}
}
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
+16 -207
View File
@@ -21,18 +21,13 @@ const (
)
type ProviderProfile struct {
Name string `json:"name"`
Provider string `json:"provider,omitempty"`
ProviderKind ProviderKind `json:"provider_kind,omitempty"`
CatalogID string `json:"catalogID,omitempty"`
BaseURL string `json:"baseURL,omitempty"`
APIKey string `json:"apiKey,omitempty"`
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
// APIKeyStored marks that this provider's API key lives in the encrypted
// credential store (internal/credstore), not inline in APIKey. The effective
// key is loaded from the store at provider-build time; config.json holds only
// this marker, never the secret.
APIKeyStored bool `json:"apiKeyStored,omitempty"`
Name string `json:"name"`
Provider string `json:"provider,omitempty"`
ProviderKind ProviderKind `json:"provider_kind,omitempty"`
CatalogID string `json:"catalogID,omitempty"`
BaseURL string `json:"baseURL,omitempty"`
APIKey string `json:"apiKey,omitempty"`
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
APIFormat string `json:"apiFormat,omitempty"`
AuthHeader string `json:"authHeader,omitempty"`
AuthScheme string `json:"authScheme,omitempty"`
@@ -97,66 +92,6 @@ type ToolsConfig struct {
type PreferencesConfig struct {
FavoriteModels []string `json:"favoriteModels,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
Recaps *bool `json:"recaps,omitempty"`
}
// RecapsEnabled reports whether post-turn recaps are on. Unset defaults to ON.
func (p PreferencesConfig) RecapsEnabled() bool {
return p.Recaps == nil || *p.Recaps
}
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
// Helpers are discovered lazily by the tool that needs them; no setup command or
// background probe is required during startup.
type LocalControlConfig struct {
Enabled bool `json:"enabled,omitempty"`
Browser LocalControlDriverConfig `json:"browser,omitempty"`
Desktop LocalControlDriverConfig `json:"desktop,omitempty"`
Terminal LocalControlDriverConfig `json:"terminal,omitempty"`
ArtifactsDir string `json:"artifactsDir,omitempty"`
enabledSet bool
}
type LocalControlDriverConfig struct {
Enabled bool `json:"enabled,omitempty"`
HelperPath string `json:"helperPath,omitempty"`
Driver string `json:"driver,omitempty"`
enabledSet bool
}
func (cfg LocalControlConfig) BrowserEnabled() bool {
return !cfg.Disabled() && (!cfg.Browser.enabledSet || cfg.Browser.Enabled)
}
func (cfg LocalControlConfig) DesktopEnabled() bool {
return !cfg.Disabled() && cfg.Desktop.enabledSet && cfg.Desktop.Enabled
}
func (cfg LocalControlConfig) TerminalEnabled() bool {
return !cfg.Disabled() && (!cfg.Terminal.enabledSet || cfg.Terminal.Enabled)
}
func (cfg LocalControlConfig) Disabled() bool {
return cfg.enabledSet && !cfg.Enabled
}
func (cfg LocalControlConfig) Empty() bool {
return !cfg.Enabled &&
!cfg.enabledSet &&
cfg.ArtifactsDir == "" &&
cfg.Browser.Empty() &&
cfg.Desktop.Empty() &&
cfg.Terminal.Empty()
}
func (cfg LocalControlDriverConfig) Empty() bool {
return !cfg.Enabled &&
!cfg.enabledSet &&
cfg.HelperPath == "" &&
cfg.Driver == ""
}
// SwarmConfig tunes the multi-agent swarm. MaxTeamSize caps how many members run
@@ -193,46 +128,15 @@ func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
}
type FileConfig struct {
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
LocalControl LocalControlConfig `json:"localControl,omitempty"`
}
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
type rawConfig struct {
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
}
raw := rawConfig{
ActiveProvider: cfg.ActiveProvider,
Providers: cfg.Providers,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
}
if !cfg.LocalControl.Empty() {
raw.LocalControl = &cfg.LocalControl
}
return json.Marshal(raw)
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
}
type ResolveOptions struct {
@@ -252,7 +156,6 @@ type Overrides struct {
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
LocalControl LocalControlConfig
}
type ResolvedConfig struct {
@@ -266,7 +169,6 @@ type ResolvedConfig struct {
Tools ToolsConfig
Swarm SwarmConfig
Preferences PreferencesConfig
LocalControl LocalControlConfig
}
type MCPConfig struct {
@@ -312,7 +214,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
Tools ToolsConfig `json:"tools"`
Swarm SwarmConfig `json:"swarm"`
Preferences PreferencesConfig `json:"preferences"`
LocalControl LocalControlConfig `json:"localControl"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
}
@@ -338,7 +239,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.Tools = raw.Tools
cfg.Swarm = raw.Swarm
cfg.Preferences = raw.Preferences
cfg.LocalControl = raw.LocalControl
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
}
@@ -354,94 +254,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
return nil
}
func (cfg *LocalControlConfig) UnmarshalJSON(data []byte) error {
type rawLocalControl struct {
Enabled *bool `json:"enabled"`
Browser LocalControlDriverConfig `json:"browser"`
Desktop LocalControlDriverConfig `json:"desktop"`
Terminal LocalControlDriverConfig `json:"terminal"`
ArtifactsDir string `json:"artifactsDir"`
}
var raw rawLocalControl
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*cfg = LocalControlConfig{
Browser: raw.Browser,
Desktop: raw.Desktop,
Terminal: raw.Terminal,
ArtifactsDir: raw.ArtifactsDir,
}
if raw.Enabled != nil {
cfg.Enabled = *raw.Enabled
cfg.enabledSet = true
}
return nil
}
func (cfg LocalControlConfig) MarshalJSON() ([]byte, error) {
type rawLocalControl struct {
Enabled *bool `json:"enabled,omitempty"`
Browser *LocalControlDriverConfig `json:"browser,omitempty"`
Desktop *LocalControlDriverConfig `json:"desktop,omitempty"`
Terminal *LocalControlDriverConfig `json:"terminal,omitempty"`
ArtifactsDir string `json:"artifactsDir,omitempty"`
}
raw := rawLocalControl{
ArtifactsDir: cfg.ArtifactsDir,
}
if cfg.enabledSet || cfg.Enabled {
raw.Enabled = &cfg.Enabled
}
if !cfg.Browser.Empty() {
raw.Browser = &cfg.Browser
}
if !cfg.Desktop.Empty() {
raw.Desktop = &cfg.Desktop
}
if !cfg.Terminal.Empty() {
raw.Terminal = &cfg.Terminal
}
return json.Marshal(raw)
}
func (cfg *LocalControlDriverConfig) UnmarshalJSON(data []byte) error {
type rawDriver struct {
Enabled *bool `json:"enabled"`
HelperPath string `json:"helperPath"`
Driver string `json:"driver"`
}
var raw rawDriver
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*cfg = LocalControlDriverConfig{
HelperPath: raw.HelperPath,
Driver: raw.Driver,
}
if raw.Enabled != nil {
cfg.Enabled = *raw.Enabled
cfg.enabledSet = true
}
return nil
}
func (cfg LocalControlDriverConfig) MarshalJSON() ([]byte, error) {
type rawDriver struct {
Enabled *bool `json:"enabled,omitempty"`
HelperPath string `json:"helperPath,omitempty"`
Driver string `json:"driver,omitempty"`
}
raw := rawDriver{
HelperPath: cfg.HelperPath,
Driver: cfg.Driver,
}
if cfg.enabledSet || cfg.Enabled {
raw.Enabled = &cfg.Enabled
}
return json.Marshal(raw)
}
func (server *MCPServerConfig) UnmarshalJSON(data []byte) error {
type rawServer struct {
Type string `json:"type"`
@@ -490,8 +302,6 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
APIKeySnake string `json:"api_key"`
APIKeyEnv string `json:"apiKeyEnv"`
APIKeyEnvSnake string `json:"api_key_env"`
APIKeyStored bool `json:"apiKeyStored"`
APIKeyStoredSnake bool `json:"api_key_stored"`
APIFormat string `json:"apiFormat"`
APIFormatSnake string `json:"api_format"`
AuthHeader string `json:"authHeader"`
@@ -521,7 +331,6 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
profile.BaseURL = strings.TrimSpace(firstNonEmpty(raw.BaseURL, raw.BaseURLSnake))
profile.APIKey = firstNonEmpty(raw.APIKey, raw.APIKeySnake)
profile.APIKeyEnv = strings.TrimSpace(firstNonEmpty(raw.APIKeyEnv, raw.APIKeyEnvSnake))
profile.APIKeyStored = raw.APIKeyStored || raw.APIKeyStoredSnake
profile.APIFormat = strings.TrimSpace(firstNonEmpty(raw.APIFormat, raw.APIFormatSnake))
profile.AuthHeader = strings.TrimSpace(firstNonEmpty(raw.AuthHeader, raw.AuthHeaderSnake))
profile.AuthScheme = strings.TrimSpace(firstNonEmpty(raw.AuthScheme, raw.AuthSchemeSnake))
-23
View File
@@ -131,29 +131,6 @@ func SetFavoriteModels(path string, models []string) (FileConfig, error) {
return cfg, nil
}
// SetRecapsEnabled persists the post-turn recap preference, mirroring
// SetFavoriteModels (read-modify-atomic-write).
func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
v := enabled
cfg.Preferences.Recaps = &v
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
func normalizeFavoriteModels(models []string) []string {
seen := map[string]bool{}
favorites := make([]string, 0, len(models))
-38
View File
@@ -272,44 +272,6 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
}
}
func TestRecapsPreferenceRoundTrips(t *testing.T) {
// Default (unset) is ON.
if !(PreferencesConfig{}).RecapsEnabled() {
t.Error("unset recaps should default to ON")
}
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
// Persist OFF, then read it back.
cfg, err := SetRecapsEnabled(path, false)
if err != nil {
t.Fatalf("SetRecapsEnabled(false) error = %v", err)
}
if cfg.Preferences.RecapsEnabled() {
t.Error("after SetRecapsEnabled(false), RecapsEnabled() should be false")
}
persisted := readConfigFixture(t, path)
if persisted.Preferences.Recaps == nil || *persisted.Preferences.Recaps {
t.Errorf("persisted recaps should be explicit false, got %v", persisted.Preferences.Recaps)
}
if persisted.ActiveProvider != "openai" {
t.Errorf("unrelated config must be preserved, got %q", persisted.ActiveProvider)
}
// Flip back ON — the write must succeed and persist an explicit true.
cfg, err = SetRecapsEnabled(path, true)
if err != nil {
t.Fatalf("SetRecapsEnabled(true) error = %v", err)
}
if !cfg.Preferences.RecapsEnabled() {
t.Error("after SetRecapsEnabled(true), RecapsEnabled() should be true")
}
if reread := readConfigFixture(t, path); reread.Preferences.Recaps == nil || !*reread.Preferences.Recaps {
t.Errorf("re-enable should persist an explicit true, got %v", reread.Preferences.Recaps)
}
}
func TestSetFavoriteModelsCreatesMissingConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero", "config.json")
+1 -6
View File
@@ -221,12 +221,7 @@ func estimateRegistryTools(registry *tools.Registry) (int, int) {
return all[left].Name() < all[right].Name()
})
total := 0
count := 0
for _, tool := range all {
if tool.Safety().Permission == tools.PermissionDeny {
continue
}
count++
total += estimateTextTokens(tool.Name())
total += estimateTextTokens(tool.Description())
if encoded, err := json.Marshal(tool.Parameters()); err == nil {
@@ -234,7 +229,7 @@ func estimateRegistryTools(registry *tools.Registry) (int, int) {
}
total += toolDefinitionOverheadTokens
}
return count, total
return len(all), total
}
func readProjectGuidelines(root string, names []string) (string, string) {
-265
View File
@@ -1,265 +0,0 @@
// Package credstore persists provider API keys outside config.json so secrets are
// not written to disk in cleartext. It resolves one backend at construction: the OS
// keyring on macOS (the `security` keychain), otherwise an AES-256-GCM encrypted file
// (the Linux secret-tool keyring needs a running secret service that is absent on
// headless/CI machines, so it is opt-in via ZERO_CRED_STORAGE=keyring). A plaintext
// file is available only as an explicit ZERO_CRED_STORAGE=file opt-out. OAuth tokens
// stay in internal/oauth; this store is for raw API keys.
package credstore
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/keyring"
"github.com/Gitlawb/zero/internal/securefile"
)
const (
keyringService = "zero"
keyringPrefix = "apikey:" // keyring account = "apikey:<provider>"
encryptedFileName = "credentials.enc"
plaintextFileName = "credentials.json"
)
// KeyringClient is the minimal OS-keyring surface the store needs;
// *keyring.Keyring satisfies it. Injectable for tests.
type KeyringClient interface {
Available() bool
Set(service, account, secret string) error
Get(service, account string) (string, bool, error)
Delete(service, account string) (bool, error)
}
// Options configures the credential store.
type Options struct {
// Dir is the per-user data directory for the file backends.
Dir string
// Storage selects the backend: "" (auto), "keyring", "encrypted-file", or "file"
// (plaintext opt-out). When empty it falls back to ZERO_CRED_STORAGE, then auto:
// keyring on macOS (the `security` keychain is reliable and needs no daemon),
// encrypted-file everywhere else (Linux `secret-tool` needs a running secret
// service that is absent on headless/CI boxes and would block/error). Linux/other
// users can still opt into the keyring with ZERO_CRED_STORAGE=keyring.
Storage string
// Keyring is the keyring client; nil => keyring.New().
Keyring KeyringClient
// Env reads environment variables; nil => os.Getenv.
Env func(string) string
// GOOS overrides the platform for auto backend resolution; "" => runtime.GOOS.
// Exists so tests can exercise the per-OS default deterministically.
GOOS string
}
// Store reads/writes provider API keys via the resolved backend.
type Store struct {
backend string // "keyring", "encrypted-file", or "file"
kr KeyringClient
file string
crypter *securefile.Crypter // non-nil only for "encrypted-file"
}
// New resolves the backend. It never silently downgrades to plaintext: "file" must
// be chosen explicitly; auto mode picks keyring then encrypted-file.
func New(options Options) (*Store, error) {
env := options.Env
if env == nil {
env = os.Getenv
}
kr := options.Keyring
if kr == nil {
kr = keyring.New()
}
goos := strings.TrimSpace(options.GOOS)
if goos == "" {
goos = runtime.GOOS
}
storage := strings.TrimSpace(options.Storage)
if storage == "" {
storage = strings.TrimSpace(env("ZERO_CRED_STORAGE"))
}
if storage == "" {
// Keyring is the default only on macOS, where `security` is reliable and needs
// no running daemon. Elsewhere (Linux secret-tool, headless/CI) default to the
// encrypted file; keyring stays available via an explicit ZERO_CRED_STORAGE.
if goos == "darwin" && kr.Available() {
storage = "keyring"
} else {
storage = "encrypted-file"
}
}
switch storage {
case "keyring":
if !kr.Available() {
return nil, fmt.Errorf("credstore: keyring requested but no OS keyring backend on this platform; use encrypted-file")
}
return &Store{backend: "keyring", kr: kr}, nil
case "encrypted-file":
if strings.TrimSpace(options.Dir) == "" {
return nil, fmt.Errorf("credstore: Dir is required for encrypted-file storage")
}
path := filepath.Join(options.Dir, encryptedFileName)
return &Store{backend: "encrypted-file", file: path, crypter: securefile.NewCrypter(path + ".secret")}, nil
case "file":
if strings.TrimSpace(options.Dir) == "" {
return nil, fmt.Errorf("credstore: Dir is required for file storage")
}
return &Store{backend: "file", file: filepath.Join(options.Dir, plaintextFileName)}, nil
default:
return nil, fmt.Errorf("credstore: unknown storage %q (want \"keyring\", \"encrypted-file\", or \"file\")", storage)
}
}
// Backend reports the resolved backend ("keyring", "encrypted-file", "file"), for
// display ("how is this key stored").
func (s *Store) Backend() string { return s.backend }
// Encrypted reports whether secrets are protected at rest (keyring or AES file).
func (s *Store) Encrypted() bool { return s.backend == "keyring" || s.backend == "encrypted-file" }
// Set stores (or overwrites) the API key for provider.
func (s *Store) Set(provider, key string) error {
provider = normalizeProvider(provider)
if provider == "" {
return fmt.Errorf("credstore: provider is required")
}
if s.backend == "keyring" {
return s.kr.Set(keyringService, keyringPrefix+provider, key)
}
data, err := s.read()
if err != nil {
return err
}
data[provider] = key
return s.write(data)
}
// Get returns the API key for provider and whether one is stored.
func (s *Store) Get(provider string) (string, bool, error) {
provider = normalizeProvider(provider)
if provider == "" {
return "", false, nil
}
if s.backend == "keyring" {
return s.kr.Get(keyringService, keyringPrefix+provider)
}
data, err := s.read()
if err != nil {
return "", false, err
}
key, ok := data[provider]
return key, ok, nil
}
// Delete removes the API key for provider, reporting whether one existed.
func (s *Store) Delete(provider string) (bool, error) {
provider = normalizeProvider(provider)
if provider == "" {
return false, nil
}
if s.backend == "keyring" {
return s.kr.Delete(keyringService, keyringPrefix+provider)
}
data, err := s.read()
if err != nil {
return false, err
}
if _, ok := data[provider]; !ok {
return false, nil
}
delete(data, provider)
return true, s.write(data)
}
// Providers lists providers with a stored key (sorted), for display/audit.
func (s *Store) Providers() ([]string, error) {
if s.backend == "keyring" {
// The OS keyring has no enumerate-by-prefix in our minimal surface; callers
// that need a list pass known provider names to Get. Return empty here.
return nil, nil
}
data, err := s.read()
if err != nil {
return nil, err
}
names := make([]string, 0, len(data))
for name := range data {
names = append(names, name)
}
sort.Strings(names)
return names, nil
}
// --- file backends (encrypted-file / file) ---------------------------------
func (s *Store) read() (map[string]string, error) {
raw, err := os.ReadFile(s.file)
if err != nil {
if os.IsNotExist(err) {
return map[string]string{}, nil
}
return nil, fmt.Errorf("credstore: read %s: %w", s.file, err)
}
if s.crypter != nil {
raw, err = s.crypter.Open(raw)
if err != nil {
return nil, err
}
}
data := map[string]string{}
if len(raw) == 0 {
return data, nil
}
if err := json.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("credstore: parse %s: %w", s.file, err)
}
return data, nil
}
func (s *Store) write(data map[string]string) error {
payload, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("credstore: encode: %w", err)
}
if s.crypter != nil {
payload, err = s.crypter.Seal(payload)
if err != nil {
return err
}
}
if err := os.MkdirAll(filepath.Dir(s.file), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(s.file), filepath.Base(s.file)+".*.tmp")
if err != nil {
return fmt.Errorf("credstore: temp file: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return fmt.Errorf("credstore: chmod: %w", err)
}
if _, err := tmp.Write(payload); err != nil {
_ = tmp.Close()
return fmt.Errorf("credstore: write: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("credstore: write: %w", err)
}
if err := os.Rename(tmpPath, s.file); err != nil {
return fmt.Errorf("credstore: publish: %w", err)
}
return nil
}
func normalizeProvider(provider string) string {
return strings.ToLower(strings.TrimSpace(provider))
}
-174
View File
@@ -1,174 +0,0 @@
package credstore
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// fakeKeyring is an in-memory KeyringClient so tests never touch the real OS
// keychain.
type fakeKeyring struct {
available bool
data map[string]string
}
func newFakeKeyring(available bool) *fakeKeyring {
return &fakeKeyring{available: available, data: map[string]string{}}
}
func (f *fakeKeyring) Available() bool { return f.available }
func (f *fakeKeyring) Set(service, account, secret string) error {
f.data[service+"/"+account] = secret
return nil
}
func (f *fakeKeyring) Get(service, account string) (string, bool, error) {
v, ok := f.data[service+"/"+account]
return v, ok, nil
}
func (f *fakeKeyring) Delete(service, account string) (bool, error) {
k := service + "/" + account
_, ok := f.data[k]
delete(f.data, k)
return ok, nil
}
func noEnv(string) string { return "" }
func roundTrip(t *testing.T, s *Store) {
t.Helper()
if _, ok, _ := s.Get("openai"); ok {
t.Fatal("expected no key initially")
}
if err := s.Set("OpenAI", "sk-abc-123"); err != nil { // mixed case normalizes
t.Fatalf("set: %v", err)
}
got, ok, err := s.Get("openai")
if err != nil || !ok || got != "sk-abc-123" {
t.Fatalf("get = %q,%v,%v; want sk-abc-123,true,nil", got, ok, err)
}
if err := s.Set("openai", "sk-new-456"); err != nil { // overwrite
t.Fatal(err)
}
if got, _, _ := s.Get("openai"); got != "sk-new-456" {
t.Fatalf("overwrite get = %q, want sk-new-456", got)
}
existed, err := s.Delete("openai")
if err != nil || !existed {
t.Fatalf("delete = %v,%v; want true,nil", existed, err)
}
if _, ok, _ := s.Get("openai"); ok {
t.Fatal("expected key gone after delete")
}
if existed, _ := s.Delete("openai"); existed {
t.Fatal("delete of absent key should report not-existed")
}
}
func TestKeyringBackendRoundTrip(t *testing.T) {
s, err := New(Options{Storage: "keyring", Keyring: newFakeKeyring(true), Env: noEnv})
if err != nil {
t.Fatal(err)
}
if s.Backend() != "keyring" || !s.Encrypted() {
t.Fatalf("backend=%q encrypted=%v", s.Backend(), s.Encrypted())
}
roundTrip(t, s)
}
func TestEncryptedFileBackendRoundTripAndAtRest(t *testing.T) {
dir := t.TempDir()
s, err := New(Options{Storage: "encrypted-file", Dir: dir, Keyring: newFakeKeyring(false), Env: noEnv})
if err != nil {
t.Fatal(err)
}
if !s.Encrypted() {
t.Fatal("encrypted-file must report Encrypted()")
}
if err := s.Set("anthropic", "sk-ant-SECRET"); err != nil {
t.Fatal(err)
}
// The on-disk file must NOT contain the plaintext key, and must be 0600.
raw, err := os.ReadFile(filepath.Join(dir, encryptedFileName))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "sk-ant-SECRET") {
t.Fatal("encrypted-file leaked the plaintext key on disk")
}
if runtime.GOOS != "windows" { // Windows doesn't honor Unix permission bits
info, _ := os.Stat(filepath.Join(dir, encryptedFileName))
if info.Mode().Perm() != 0o600 {
t.Fatalf("perm = %o, want 600", info.Mode().Perm())
}
}
if got, ok, _ := s.Get("anthropic"); !ok || got != "sk-ant-SECRET" {
t.Fatalf("get = %q,%v", got, ok)
}
roundTrip(t, s)
}
func TestPlaintextFileBackendIsOptOut(t *testing.T) {
dir := t.TempDir()
s, err := New(Options{Storage: "file", Dir: dir, Keyring: newFakeKeyring(false), Env: noEnv})
if err != nil {
t.Fatal(err)
}
if s.Encrypted() {
t.Fatal("plaintext file must NOT report Encrypted()")
}
roundTrip(t, s)
}
func TestAutoPrefersKeyringThenEncryptedFile(t *testing.T) {
// macOS + keyring available -> keyring
s, err := New(Options{Dir: t.TempDir(), Keyring: newFakeKeyring(true), GOOS: "darwin", Env: noEnv})
if err != nil || s.Backend() != "keyring" {
t.Fatalf("auto on darwin with keyring = %q,%v; want keyring", s.Backend(), err)
}
// macOS but keyring unavailable -> encrypted-file (never plaintext)
s2, err := New(Options{Dir: t.TempDir(), Keyring: newFakeKeyring(false), GOOS: "darwin", Env: noEnv})
if err != nil || s2.Backend() != "encrypted-file" {
t.Fatalf("auto on darwin without keyring = %q,%v; want encrypted-file", s2.Backend(), err)
}
// Non-macOS (e.g. Linux) -> encrypted-file even when a keyring is available, since
// secret-tool needs a running secret service (keyring stays opt-in via env).
s3, err := New(Options{Dir: t.TempDir(), Keyring: newFakeKeyring(true), GOOS: "linux", Env: noEnv})
if err != nil || s3.Backend() != "encrypted-file" {
t.Fatalf("auto on linux = %q,%v; want encrypted-file", s3.Backend(), err)
}
}
func TestKeyringRequestedButUnavailableErrors(t *testing.T) {
if _, err := New(Options{Storage: "keyring", Keyring: newFakeKeyring(false), Env: noEnv}); err == nil {
t.Fatal("expected error when keyring requested but unavailable")
}
}
func TestEnvSelectsStorage(t *testing.T) {
dir := t.TempDir()
env := func(k string) string {
if k == "ZERO_CRED_STORAGE" {
return "encrypted-file"
}
return ""
}
s, err := New(Options{Dir: dir, Keyring: newFakeKeyring(true), Env: env}) // keyring available, but env forces encrypted-file
if err != nil || s.Backend() != "encrypted-file" {
t.Fatalf("env override = %q,%v; want encrypted-file", s.Backend(), err)
}
}
func TestProvidersListsFileBackend(t *testing.T) {
s, _ := New(Options{Storage: "encrypted-file", Dir: t.TempDir(), Keyring: newFakeKeyring(false), Env: noEnv})
_ = s.Set("openai", "a")
_ = s.Set("gemini", "b")
names, err := s.Providers()
if err != nil {
t.Fatal(err)
}
if len(names) != 2 || names[0] != "gemini" || names[1] != "openai" {
t.Fatalf("providers = %v, want [gemini openai]", names)
}
}
@@ -31,9 +31,6 @@ func TestUnixInstallerScriptMatchesReleaseContracts(t *testing.T) {
`tar -xzf "$archive_path" -C "$extract_dir"`,
`find_extracted_binary "$extract_dir"`,
`cp "$binary_path" "$ZERO_INSTALL_DIR/zero"`,
`copy_optional_file "zero-linux-sandbox"`,
`copy_optional_file "zero-seccomp"`,
`copy_optional_dir "helpers"`,
})
}
@@ -51,8 +48,6 @@ func TestPowerShellInstallerScriptMatchesWindowsReleaseContracts(t *testing.T) {
`"zero-windows-command-runner.exe"`,
`"zero-windows-sandbox-setup.exe"`,
`Copy-Item -Path $sourcePath -Destination (Join-Path $InstallDir $fileName) -Force`,
`Find-ZeroOptionalExtractedDirectory -Root $extractDir -DirectoryName "helpers"`,
`Copy-Item -Path $helpersPath -Destination $targetHelpersPath -Recurse -Force`,
})
}
@@ -73,12 +68,6 @@ func TestUnixInstallerInstallsFromPrefixedReleaseArchiveWithoutNetwork(t *testin
if !strings.Contains(string(installed), "mock-zero") {
t.Fatalf("installed binary = %q, want mock-zero script", string(installed))
}
if _, err := os.Stat(filepath.Join(fixture.installDir, "helpers", "node_modules", ".bin", "agent-browser")); err != nil {
t.Fatalf("installed helper package missing: %v", err)
}
if _, err := os.Stat(filepath.Join(fixture.installDir, "zero-linux-sandbox")); err != nil {
t.Fatalf("installed linux sandbox helper missing: %v", err)
}
}
func TestUnixInstallerRejectsChecksumMismatchWithoutNetwork(t *testing.T) {
@@ -138,16 +127,7 @@ func newUnixInstallFixture(t *testing.T) unixInstallFixture {
mustMkdirAll(t, packageDir)
mustMkdirAll(t, filepath.Dir(fixture.archivePath))
mustMkdirAll(t, fixture.installDir)
mustMkdirAll(t, filepath.Join(packageDir, "helpers", "node_modules", ".bin"))
writeFile(t, filepath.Join(packageDir, "zero"), []byte("#!/usr/bin/env sh\necho mock-zero\n"), 0o755)
writeFile(t, filepath.Join(packageDir, "zero-linux-sandbox"), []byte("#!/usr/bin/env sh\n"), 0o755)
writeFile(t, filepath.Join(packageDir, "zero-seccomp"), []byte("#!/usr/bin/env sh\n"), 0o755)
mustMkdirAll(t, filepath.Join(packageDir, "helpers", "node_modules", "agent-browser", "bin"))
writeFile(t, filepath.Join(packageDir, "helpers", "node_modules", "agent-browser", "bin", "agent-browser.js"), []byte("#!/usr/bin/env node\n"), 0o755)
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(packageDir, "helpers", "node_modules", ".bin", "agent-browser")); err != nil {
t.Fatalf("Symlink agent-browser helper: %v", err)
}
writeFile(t, filepath.Join(packageDir, "helpers", "node_modules", ".bin", "tuistory"), []byte("#!/usr/bin/env sh\n"), 0o755)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
-189
View File
@@ -1,189 +0,0 @@
package localcontrol
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
)
const (
DefaultDevToolsPort = 9222
defaultDevToolsWaitTimeout = 20 * time.Second
)
type BrowserAppLaunchOptions struct {
Runner BrowserAppRunner
WaitTimeout time.Duration
}
type BrowserAppLaunchRequest struct {
App string
DebugPort int
StopExisting bool
Wait bool
}
type BrowserAppLaunchResult struct {
App string
Command string
Args []string
PID int
DebugPort int
DevToolsURL string
}
type BrowserAppRunner interface {
Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error)
StartDetached(ctx context.Context, path string, args []string, env []string) (int, error)
}
type BrowserAppLauncher struct {
runner BrowserAppRunner
waitTimeout time.Duration
}
type browserAppSpec struct {
name string
command string
args []string
env []string
stopPath string
stopArgs []string
}
func NewBrowserAppLauncher(options BrowserAppLaunchOptions) BrowserAppLauncher {
if options.Runner == nil {
options.Runner = ExecBrowserAppRunner{}
}
if options.WaitTimeout <= 0 {
options.WaitTimeout = defaultDevToolsWaitTimeout
}
return BrowserAppLauncher{
runner: options.Runner,
waitTimeout: options.WaitTimeout,
}
}
func (launcher BrowserAppLauncher) LaunchBrowserApp(ctx context.Context, request BrowserAppLaunchRequest) (BrowserAppLaunchResult, error) {
if ctx == nil {
ctx = context.Background()
}
port := request.DebugPort
if port == 0 {
port = DefaultDevToolsPort
}
if port < 1024 || port > 65535 {
return BrowserAppLaunchResult{}, fmt.Errorf("debug_port must be between 1024 and 65535")
}
spec, err := browserAppLaunchSpec(request.App, port)
if err != nil {
return BrowserAppLaunchResult{}, err
}
if request.StopExisting && spec.stopPath != "" {
_, _ = launcher.runner.Run(ctx, spec.stopPath, spec.stopArgs, nil, 5*time.Second)
}
pid, err := launcher.runner.StartDetached(ctx, spec.command, spec.args, spec.env)
if err != nil {
return BrowserAppLaunchResult{}, fmt.Errorf("launch %s: %w", spec.name, err)
}
devToolsURL := "http://127.0.0.1:" + strconv.Itoa(port)
if request.Wait {
if err := waitForDevTools(ctx, devToolsURL, launcher.waitTimeout); err != nil {
return BrowserAppLaunchResult{}, err
}
}
return BrowserAppLaunchResult{
App: spec.name,
Command: spec.command,
Args: append([]string(nil), spec.args...),
PID: pid,
DebugPort: port,
DevToolsURL: devToolsURL,
}, nil
}
func browserAppLaunchSpec(app string, port int) (browserAppSpec, error) {
switch strings.ToLower(strings.TrimSpace(app)) {
case "discord":
if runtime.GOOS != "linux" {
return browserAppSpec{}, fmt.Errorf("discord launch is only supported for Linux flatpak installs")
}
return browserAppSpec{
name: "discord",
command: "flatpak",
args: []string{"run", "--command=com.discordapp.Discord", "com.discordapp.Discord", "--remote-debugging-port=" + strconv.Itoa(port)},
stopPath: "flatpak",
stopArgs: []string{"kill", "com.discordapp.Discord"},
}, nil
default:
return browserAppSpec{}, fmt.Errorf("app must be one of: discord")
}
}
func waitForDevTools(ctx context.Context, baseURL string, timeout time.Duration) error {
if timeout <= 0 {
timeout = defaultDevToolsWaitTimeout
}
deadline := time.Now().Add(timeout)
client := http.Client{Timeout: 750 * time.Millisecond}
endpoint := strings.TrimRight(baseURL, "/") + "/json/version"
var lastErr error
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
lastErr = fmt.Errorf("DevTools endpoint returned HTTP %d", resp.StatusCode)
} else {
lastErr = err
}
if time.Now().After(deadline) {
return fmt.Errorf("DevTools endpoint %s was not ready after %dms: %w", endpoint, timeout.Milliseconds(), lastErr)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(300 * time.Millisecond):
}
}
}
type ExecBrowserAppRunner struct{}
func (ExecBrowserAppRunner) Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error) {
return ExecRunner{}.Run(ctx, path, args, env, timeout)
}
func (ExecBrowserAppRunner) StartDetached(_ context.Context, path string, args []string, env []string) (int, error) {
if strings.TrimSpace(path) == "" {
return 0, errors.New("launch command is required")
}
cmd := exec.Command(path, args...)
cmd.Env = mergeEnv(os.Environ(), env)
configureDetachedProcess(cmd)
if err := cmd.Start(); err != nil {
return 0, err
}
pid := cmd.Process.Pid
if err := cmd.Process.Release(); err != nil {
return pid, err
}
return pid, nil
}
-129
View File
@@ -1,129 +0,0 @@
package localcontrol
import (
"context"
"errors"
"reflect"
"runtime"
"strings"
"testing"
"time"
)
type fakeBrowserAppRunner struct {
runPath string
runArgs []string
startPath string
startArgs []string
pid int
err error
}
func (runner *fakeBrowserAppRunner) Run(_ context.Context, path string, args []string, _ []string, _ time.Duration) (CommandResult, error) {
runner.runPath = path
runner.runArgs = append([]string(nil), args...)
return CommandResult{Path: path, Args: append([]string(nil), args...), ExitCode: 0}, nil
}
func (runner *fakeBrowserAppRunner) StartDetached(_ context.Context, path string, args []string, _ []string) (int, error) {
runner.startPath = path
runner.startArgs = append([]string(nil), args...)
if runner.err != nil {
return 0, runner.err
}
if runner.pid == 0 {
runner.pid = 4321
}
return runner.pid, nil
}
func TestBrowserAppLauncherMapsDiscordToFlatpakDevToolsLaunch(t *testing.T) {
requireLinuxFlatpakLaunch(t)
runner := &fakeBrowserAppRunner{}
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: runner})
result, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{
App: "discord",
DebugPort: 9222,
StopExisting: true,
Wait: false,
})
if err != nil {
t.Fatalf("LaunchBrowserApp returned error: %v", err)
}
if runner.runPath != "flatpak" {
t.Fatalf("stop path = %q, want flatpak", runner.runPath)
}
if want := []string{"kill", "com.discordapp.Discord"}; !reflect.DeepEqual(runner.runArgs, want) {
t.Fatalf("stop args = %#v, want %#v", runner.runArgs, want)
}
if runner.startPath != "flatpak" {
t.Fatalf("start path = %q, want flatpak", runner.startPath)
}
wantStartArgs := []string{"run", "--command=com.discordapp.Discord", "com.discordapp.Discord", "--remote-debugging-port=9222"}
if !reflect.DeepEqual(runner.startArgs, wantStartArgs) {
t.Fatalf("start args = %#v, want %#v", runner.startArgs, wantStartArgs)
}
if result.PID != 4321 || result.DevToolsURL != "http://127.0.0.1:9222" {
t.Fatalf("result = %#v, want pid/devtools URL", result)
}
}
func TestBrowserAppLauncherDefaultsPortAndCanSkipStop(t *testing.T) {
requireLinuxFlatpakLaunch(t)
runner := &fakeBrowserAppRunner{}
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: runner})
result, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{
App: "discord",
Wait: false,
})
if err != nil {
t.Fatalf("LaunchBrowserApp returned error: %v", err)
}
if runner.runPath != "" {
t.Fatalf("stop path = %q, want no stop", runner.runPath)
}
wantStartArgs := []string{"run", "--command=com.discordapp.Discord", "com.discordapp.Discord", "--remote-debugging-port=9222"}
if !reflect.DeepEqual(runner.startArgs, wantStartArgs) {
t.Fatalf("start args = %#v, want %#v", runner.startArgs, wantStartArgs)
}
if result.DebugPort != DefaultDevToolsPort {
t.Fatalf("debug port = %d, want %d", result.DebugPort, DefaultDevToolsPort)
}
}
func TestBrowserAppLauncherRejectsUnsupportedApp(t *testing.T) {
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: &fakeBrowserAppRunner{}})
_, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{App: "telegram"})
if err == nil || !strings.Contains(err.Error(), "app must be one of") {
t.Fatalf("error = %v, want unsupported app", err)
}
}
func TestBrowserAppLauncherReportsStartFailure(t *testing.T) {
requireLinuxFlatpakLaunch(t)
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: &fakeBrowserAppRunner{err: errors.New("boom")}})
_, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{App: "discord", Wait: false})
if err == nil || !strings.Contains(err.Error(), "launch discord") {
t.Fatalf("error = %v, want launch failure", err)
}
}
func TestBrowserAppLauncherRejectsDiscordOnUnsupportedPlatform(t *testing.T) {
if runtime.GOOS == "linux" {
t.Skip("discord launch is supported on Linux")
}
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: &fakeBrowserAppRunner{}})
_, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{App: "discord", Wait: false})
if err == nil || !strings.Contains(err.Error(), "only supported") {
t.Fatalf("error = %v, want unsupported platform", err)
}
}
func requireLinuxFlatpakLaunch(t *testing.T) {
t.Helper()
if runtime.GOOS != "linux" {
t.Skip("discord launch uses Linux flatpak")
}
}
@@ -1,15 +0,0 @@
//go:build !windows
package localcontrol
import (
"os/exec"
"syscall"
)
func configureDetachedProcess(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setsid = true
}
@@ -1,17 +0,0 @@
//go:build windows
package localcontrol
import (
"os/exec"
"syscall"
)
const detachedProcess = 0x00000008
func configureDetachedProcess(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess
}
-451
View File
@@ -1,451 +0,0 @@
package localcontrol
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
const (
DefaultBrowserDriver = "agent-browser"
DefaultDesktopDriver = "cua-driver"
DefaultTerminalDriver = "tuistory"
DefaultTimeout = 30 * time.Second
EnvHelperManifest = "ZERO_LOCAL_CONTROL_HELPERS"
)
type HelperOptions struct {
Enabled bool
Driver string
HelperPath string
ConfigPath string
DisabledMessage string
MissingHint string
Timeout time.Duration
Runner CommandRunner
}
type BrowserOptions = HelperOptions
type DesktopOptions = HelperOptions
type TerminalOptions = HelperOptions
type CommandResult struct {
Path string
Args []string
Stdout string
Stderr string
ExitCode int
}
func (result CommandResult) Output() string {
stdout := strings.TrimRight(result.Stdout, "\n")
stderr := strings.TrimRight(result.Stderr, "\n")
switch {
case stdout == "" && stderr == "":
return ""
case stdout == "":
return stderr
case stderr == "":
return stdout
default:
return stdout + "\n" + stderr
}
}
type CommandRunner interface {
Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error)
}
type ResolvedHelper struct {
Command string
PrefixArgs []string
Env []string
}
type Helper struct {
options HelperOptions
}
type Browser struct {
helper Helper
}
type Desktop struct {
helper Helper
}
type Terminal struct {
helper Helper
}
func NewHelper(options HelperOptions) Helper {
if options.Timeout <= 0 {
options.Timeout = DefaultTimeout
}
if options.Runner == nil {
options.Runner = ExecRunner{}
}
return Helper{options: options}
}
func NewBrowser(options BrowserOptions) Browser {
if strings.TrimSpace(options.Driver) == "" {
options.Driver = DefaultBrowserDriver
}
options.ConfigPath = "localControl.browser.helperPath"
options.DisabledMessage = "local browser control is disabled by user config"
options.MissingHint = "install the agent-browser package or set localControl.browser.helperPath"
return Browser{helper: NewHelper(options)}
}
func NewDesktop(options DesktopOptions) Desktop {
if strings.TrimSpace(options.Driver) == "" {
options.Driver = DefaultDesktopDriver
}
options.ConfigPath = "localControl.desktop.helperPath"
options.DisabledMessage = "local desktop control is disabled; enable localControl.desktop.enabled in user config"
options.MissingHint = "install cua-driver or set localControl.desktop.helperPath"
return Desktop{helper: NewHelper(options)}
}
func NewTerminal(options TerminalOptions) Terminal {
if strings.TrimSpace(options.Driver) == "" {
options.Driver = DefaultTerminalDriver
}
options.ConfigPath = "localControl.terminal.helperPath"
options.DisabledMessage = "local terminal control is disabled by user config"
options.MissingHint = "install the tuistory package or set localControl.terminal.helperPath"
return Terminal{helper: NewHelper(options)}
}
func (helper Helper) Enabled() bool {
return helper.options.Enabled
}
func (helper Helper) Run(ctx context.Context, args ...string) (CommandResult, error) {
if !helper.options.Enabled {
message := strings.TrimSpace(helper.options.DisabledMessage)
if message == "" {
message = "local control helper is disabled"
}
return CommandResult{}, errors.New(message)
}
resolved, err := helper.resolvedHelper()
if err != nil {
return CommandResult{}, err
}
runArgs := append([]string{}, resolved.PrefixArgs...)
runArgs = append(runArgs, args...)
return helper.options.Runner.Run(ctx, resolved.Command, runArgs, resolved.Env, helper.options.Timeout)
}
func (helper Helper) resolvedHelper() (ResolvedHelper, error) {
label := helper.helperLabel()
if helperPath := strings.TrimSpace(helper.options.HelperPath); helperPath != "" {
if !filepath.IsAbs(helperPath) {
abs, err := filepath.Abs(helperPath)
if err != nil {
return ResolvedHelper{}, fmt.Errorf("resolve %s helper path: %w", label, err)
}
helperPath = abs
}
if err := validateHelperFile(label, helperPath); err != nil {
return ResolvedHelper{}, err
}
return ResolvedHelper{Command: helperPath}, nil
}
driver := strings.TrimSpace(helper.options.Driver)
if driver == "" {
driver = DefaultBrowserDriver
}
if resolved, ok, err := helperFromManifest(driver); ok || err != nil {
return resolved, err
}
if resolved, ok, err := adjacentHelper(driver); ok || err != nil {
return resolved, err
}
path, err := exec.LookPath(driver)
if err != nil {
hint := strings.TrimSpace(helper.options.MissingHint)
if hint == "" {
hint = "install it or configure the helper path"
}
return ResolvedHelper{}, fmt.Errorf("%s was not found on PATH; %s", driver, hint)
}
return ResolvedHelper{Command: path}, nil
}
func (helper Helper) helperLabel() string {
driver := strings.TrimSpace(helper.options.Driver)
if driver == "" {
return "local control"
}
return driver
}
func (browser Browser) Enabled() bool {
return browser.helper.Enabled()
}
func (browser Browser) Run(ctx context.Context, args ...string) (CommandResult, error) {
return browser.helper.Run(ctx, args...)
}
func (desktop Desktop) Run(ctx context.Context, args ...string) (CommandResult, error) {
return desktop.helper.Run(ctx, args...)
}
func (desktop Desktop) Enabled() bool {
return desktop.helper.Enabled()
}
func (terminal Terminal) Run(ctx context.Context, args ...string) (CommandResult, error) {
return terminal.helper.Run(ctx, args...)
}
func (terminal Terminal) Enabled() bool {
return terminal.helper.Enabled()
}
type ExecRunner struct{}
var executablePath = os.Executable
func (ExecRunner) Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error) {
if ctx == nil {
ctx = context.Background()
}
if timeout <= 0 {
timeout = DefaultTimeout
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(runCtx, path, args...)
cmd.Env = mergeEnv(os.Environ(), env)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
result := CommandResult{
Path: path,
Args: append([]string(nil), args...),
Stdout: stdout.String(),
Stderr: stderr.String(),
}
if cmd.ProcessState != nil {
result.ExitCode = cmd.ProcessState.ExitCode()
} else if err != nil {
result.ExitCode = -1
}
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
return result, fmt.Errorf("local control helper timed out after %dms", timeout.Milliseconds())
}
if err != nil {
return result, err
}
return result, nil
}
type helperManifest struct {
Version int `json:"version"`
Helpers map[string]manifestEntry `json:"helpers"`
}
type manifestEntry struct {
Command string `json:"command"`
PrefixArgs []string `json:"prefixArgs"`
PathPrepend []string `json:"pathPrepend"`
Env map[string]string `json:"env"`
}
func helperFromManifest(driver string) (ResolvedHelper, bool, error) {
raw := strings.TrimSpace(os.Getenv(EnvHelperManifest))
if raw == "" {
return ResolvedHelper{}, false, nil
}
manifest, err := parseHelperManifest(raw)
if err != nil {
return ResolvedHelper{}, true, err
}
entry, ok := manifest.Helpers[driver]
if !ok {
return ResolvedHelper{}, false, nil
}
command := strings.TrimSpace(entry.Command)
if command == "" {
return ResolvedHelper{}, true, fmt.Errorf("%s entry in %s is missing command", driver, EnvHelperManifest)
}
if filepath.IsAbs(command) {
if err := validateHelperFile(driver, command); err != nil {
return ResolvedHelper{}, true, err
}
}
return ResolvedHelper{
Command: command,
PrefixArgs: cleanStringSlice(entry.PrefixArgs),
Env: manifestEnv(entry),
}, true, nil
}
func parseHelperManifest(raw string) (helperManifest, error) {
var manifest helperManifest
if err := json.Unmarshal([]byte(raw), &manifest); err != nil {
return helperManifest{}, fmt.Errorf("invalid %s JSON: %w", EnvHelperManifest, err)
}
if manifest.Helpers == nil {
manifest.Helpers = map[string]manifestEntry{}
}
return manifest, nil
}
func validateHelperFile(label string, path string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s helper not found at %s: %w", label, path, err)
}
if info.IsDir() {
return fmt.Errorf("%s helper path is a directory: %s", label, path)
}
return nil
}
func adjacentHelper(driver string) (ResolvedHelper, bool, error) {
executable, err := executablePath()
if err != nil {
return ResolvedHelper{}, false, nil
}
executableDir := filepath.Dir(executable)
for _, dir := range []string{
executableDir,
filepath.Join(executableDir, "helpers"),
filepath.Join(executableDir, "helpers", "node_modules", ".bin"),
filepath.Join(executableDir, "node_modules", ".bin"),
} {
for _, name := range helperExecutableNames(driver) {
path := filepath.Join(dir, name)
if err := validateHelperFile(driver, path); err == nil {
return helperCommandForPath(path), true, nil
}
}
}
return ResolvedHelper{}, false, nil
}
func helperExecutableNames(driver string) []string {
if runtime.GOOS == "windows" {
return []string{driver + ".cmd", driver + ".exe", driver}
}
return []string{driver}
}
func helperCommandForPath(path string) ResolvedHelper {
if runtime.GOOS == "windows" && strings.EqualFold(filepath.Ext(path), ".cmd") {
command := os.Getenv("ComSpec")
if strings.TrimSpace(command) == "" {
command = "cmd.exe"
}
return ResolvedHelper{
Command: command,
PrefixArgs: []string{"/d", "/s", "/c", `"` + strings.ReplaceAll(path, `"`, `""`) + `"`},
}
}
return ResolvedHelper{Command: path}
}
func manifestEnv(entry manifestEntry) []string {
env := map[string]string{}
for key, value := range entry.Env {
key = strings.TrimSpace(key)
if key != "" {
env[key] = value
}
}
pathPrepend := cleanStringSlice(entry.PathPrepend)
if len(pathPrepend) > 0 {
basePath := env["PATH"]
if basePath == "" {
basePath = os.Getenv("PATH")
}
prefix := strings.Join(pathPrepend, string(os.PathListSeparator))
if basePath != "" {
env["PATH"] = prefix + string(os.PathListSeparator) + basePath
} else {
env["PATH"] = prefix
}
}
return envMapToList(env)
}
func normalizeEnvKey(key string) string {
if runtime.GOOS == "windows" {
return strings.ToUpper(key)
}
return key
}
func mergeEnv(base []string, overlay []string) []string {
if len(overlay) == 0 {
return base
}
merged := append([]string{}, base...)
index := map[string]int{}
for i, item := range merged {
if key, _, ok := strings.Cut(item, "="); ok {
index[normalizeEnvKey(key)] = i
}
}
for _, item := range overlay {
key, _, ok := strings.Cut(item, "=")
if !ok || key == "" {
continue
}
normalized := normalizeEnvKey(key)
if existing, ok := index[normalized]; ok {
merged[existing] = item
} else {
index[normalized] = len(merged)
merged = append(merged, item)
}
}
return merged
}
func cleanStringSlice(values []string) []string {
cleaned := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
cleaned = append(cleaned, value)
}
}
return cleaned
}
func envMapToList(env map[string]string) []string {
if len(env) == 0 {
return nil
}
keys := make([]string, 0, len(env))
for key := range env {
keys = append(keys, key)
}
sort.Strings(keys)
values := make([]string, 0, len(keys))
for _, key := range keys {
values = append(values, key+"="+env[key])
}
return values
}
-237
View File
@@ -1,237 +0,0 @@
package localcontrol
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
)
type fakeCommandRunner struct {
path string
args []string
env []string
}
func (runner *fakeCommandRunner) Run(_ context.Context, path string, args []string, env []string, _ time.Duration) (CommandResult, error) {
runner.path = path
runner.args = append([]string(nil), args...)
runner.env = append([]string(nil), env...)
return CommandResult{
Path: path,
Args: append([]string(nil), args...),
Stdout: "ok\n",
ExitCode: 0,
}, nil
}
func TestBrowserRunUsesConfiguredHelperPath(t *testing.T) {
dir := t.TempDir()
helper := filepath.Join(dir, "agent-browser")
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}
runner := &fakeCommandRunner{}
browser := NewBrowser(BrowserOptions{
Enabled: true,
HelperPath: helper,
Runner: runner,
})
result, err := browser.Run(context.Background(), "open", "https://example.com")
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if result.Output() != "ok" {
t.Fatalf("Output = %q, want ok", result.Output())
}
if runner.path != helper {
t.Fatalf("runner path = %q, want %q", runner.path, helper)
}
if want := []string{"open", "https://example.com"}; !reflect.DeepEqual(runner.args, want) {
t.Fatalf("runner args = %#v, want %#v", runner.args, want)
}
}
func TestBrowserRunDisabledFailsBeforeDiscovery(t *testing.T) {
browser := NewBrowser(BrowserOptions{Enabled: false, HelperPath: "/does/not/exist"})
_, err := browser.Run(context.Background(), "snapshot")
if err == nil || !strings.Contains(err.Error(), "disabled") {
t.Fatalf("Run error = %v, want disabled", err)
}
}
func TestBrowserRunMissingHelperPathIsActionable(t *testing.T) {
browser := NewBrowser(BrowserOptions{Enabled: true, HelperPath: filepath.Join(t.TempDir(), "missing")})
_, err := browser.Run(context.Background(), "snapshot")
if err == nil || !strings.Contains(err.Error(), "helper not found") {
t.Fatalf("Run error = %v, want missing helper", err)
}
}
func TestBrowserRunUsesHelperManifest(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "node_modules", ".bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
helper := filepath.Join(binDir, "agent-browser")
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}
t.Setenv(EnvHelperManifest, `{"version":1,"helpers":{"agent-browser":{"command":`+quoteJSON(helper)+`,"pathPrepend":[`+quoteJSON(binDir)+`],"env":{"ZERO_HELPER_TEST":"1"}}}}`)
runner := &fakeCommandRunner{}
browser := NewBrowser(BrowserOptions{
Enabled: true,
Runner: runner,
})
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if runner.path != helper {
t.Fatalf("runner path = %q, want manifest helper %q", runner.path, helper)
}
if !envContains(runner.env, "ZERO_HELPER_TEST=1") {
t.Fatalf("env = %#v, want ZERO_HELPER_TEST", runner.env)
}
pathValue := envValue(runner.env, "PATH")
if !strings.HasPrefix(pathValue, binDir+string(os.PathListSeparator)) && pathValue != binDir {
t.Fatalf("PATH overlay = %q, want prefix %q", pathValue, binDir)
}
}
func TestBrowserRunUsesManifestPrefixArgs(t *testing.T) {
runner := &fakeCommandRunner{}
t.Setenv(EnvHelperManifest, `{"version":1,"helpers":{"agent-browser":{"command":"cmd.exe","prefixArgs":["/d","/s","/c","C:\\zero\\agent-browser.cmd"]}}}`)
browser := NewBrowser(BrowserOptions{
Enabled: true,
Runner: runner,
})
if _, err := browser.Run(context.Background(), "open", "https://example.com"); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if runner.path != "cmd.exe" {
t.Fatalf("runner path = %q, want cmd.exe", runner.path)
}
want := []string{"/d", "/s", "/c", `C:\zero\agent-browser.cmd`, "open", "https://example.com"}
if !reflect.DeepEqual(runner.args, want) {
t.Fatalf("runner args = %#v, want %#v", runner.args, want)
}
}
func TestMergeEnvReplacesPathCaseInsensitivelyOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows env keys are case-insensitive")
}
env := mergeEnv([]string{`Path=C:\Windows`, "ZERO=1"}, []string{`PATH=C:\Zero`})
pathCount := 0
for _, item := range env {
key, value, ok := strings.Cut(item, "=")
if !ok || !strings.EqualFold(key, "PATH") {
continue
}
pathCount++
if value != `C:\Zero` {
t.Fatalf("PATH value = %q, want C:\\Zero", value)
}
}
if pathCount != 1 {
t.Fatalf("PATH entries = %d in %#v, want 1", pathCount, env)
}
}
func TestBrowserRunUsesAdjacentPackagedHelper(t *testing.T) {
root := t.TempDir()
native := filepath.Join(root, "zero")
if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write native: %v", err)
}
helpersDir := filepath.Join(root, "helpers")
if err := os.MkdirAll(helpersDir, 0o755); err != nil {
t.Fatalf("mkdir helpers: %v", err)
}
helper := filepath.Join(helpersDir, "agent-browser")
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}
oldExecutablePath := executablePath
executablePath = func() (string, error) { return native, nil }
t.Cleanup(func() { executablePath = oldExecutablePath })
runner := &fakeCommandRunner{}
browser := NewBrowser(BrowserOptions{
Enabled: true,
Runner: runner,
})
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if runner.path != helper {
t.Fatalf("runner path = %q, want adjacent helper %q", runner.path, helper)
}
}
func TestBrowserRunUsesAdjacentPackagedNodeBinHelper(t *testing.T) {
root := t.TempDir()
native := filepath.Join(root, "zero")
if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write native: %v", err)
}
binDir := filepath.Join(root, "helpers", "node_modules", ".bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir helper bin: %v", err)
}
helper := filepath.Join(binDir, "agent-browser")
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}
oldExecutablePath := executablePath
executablePath = func() (string, error) { return native, nil }
t.Cleanup(func() { executablePath = oldExecutablePath })
runner := &fakeCommandRunner{}
browser := NewBrowser(BrowserOptions{
Enabled: true,
Runner: runner,
})
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if runner.path != helper {
t.Fatalf("runner path = %q, want packaged node bin helper %q", runner.path, helper)
}
}
func quoteJSON(value string) string {
data, err := json.Marshal(value)
if err != nil {
panic(err)
}
return string(data)
}
func envContains(env []string, want string) bool {
for _, item := range env {
if item == want {
return true
}
}
return false
}
func envValue(env []string, key string) string {
for _, item := range env {
if got, value, ok := strings.Cut(item, "="); ok && got == key {
return value
}
}
return ""
}
+4 -61
View File
@@ -1,33 +1,9 @@
package modelregistry
import (
"fmt"
"strings"
)
import "fmt"
const DefaultModelID = "gpt-4.1"
// FallbackContextWindow is the assumed context window (max input tokens) for a
// model that isn't in the curated registry and wasn't resolved from live provider
// discovery — e.g. proxy/custom models like the GPT-5 Codex, xAI, or Ollama-cloud
// variants. A positive value here is what enables agent-loop compaction (both the
// proactive ~80% trigger and the reactive post-overflow recovery) for those models;
// without it compaction stays disabled and long sessions overflow. It is only a
// backstop: an exact window from the registry or live discovery always wins.
const FallbackContextWindow = 200_000
// AgentContextWindow turns a resolved context window into the value used to enable
// agent-loop compaction: the resolved window when known, else FallbackContextWindow
// so compaction is enabled even for uncatalogued models. Kept separate from the
// raw resolver so display gauges still show nothing (not a guessed denominator)
// when the real window is unknown.
func AgentContextWindow(resolved int) int {
if resolved <= 0 {
return FallbackContextWindow
}
return resolved
}
const sourceLastVerified = "2026-06-04"
const (
@@ -193,44 +169,11 @@ func (registry Registry) SupportsCapability(pattern string, capability ModelCapa
}
func (registry Registry) ReasoningEfforts(pattern string) []ReasoningEffort {
if model, ok := registry.Get(pattern); ok {
return append([]ReasoningEffort{}, effectiveReasoningEfforts(model)...)
}
// Unknown model not in the curated catalog — e.g. a GPT-5 / Codex / o-series
// variant served via the ChatGPT proxy or a custom OpenAI-compatible endpoint.
// Infer from the name so /effort still shows controls for it.
return reasoningEffortsForModelName(pattern)
}
// reasoningEffortsForModelName infers reasoning-effort controls from a model name
// for known reasoning families not present in the catalog. It returns nil for
// non-reasoning models (e.g. GPT-4.1 / GPT-4o), so /effort stays empty there.
func reasoningEffortsForModelName(name string) []ReasoningEffort {
n := strings.ToLower(strings.TrimSpace(name))
if i := strings.IndexByte(n, ':'); i >= 0 { // drop a "provider:" qualifier
n = n[i+1:]
}
switch {
case strings.HasPrefix(n, "gpt-5") || strings.HasPrefix(n, "gpt5"):
// GPT-5 / Codex (gpt-5.x) add a "minimal" tier below low.
return []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
case strings.Contains(n, "codex") || isOSeriesModelName(n):
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
default:
model, ok := registry.Get(pattern)
if !ok {
return nil
}
}
// isOSeriesModelName reports whether name is an OpenAI o-series reasoning model
// (o1/o3/o4, optionally with a -mini/-pro suffix), without matching unrelated
// names that merely start with the same letters.
func isOSeriesModelName(n string) bool {
for _, prefix := range []string{"o1", "o3", "o4"} {
if n == prefix || strings.HasPrefix(n, prefix+"-") || strings.HasPrefix(n, prefix+"mini") {
return true
}
}
return false
return append([]ReasoningEffort{}, model.ReasoningEfforts...)
}
func (registry Registry) RequireProvider(pattern string, provider ProviderKind) (ModelEntry, error) {
+2 -1
View File
@@ -92,10 +92,11 @@ func CalculateCost(model ModelEntry, usage zeroruntime.Usage) (CostBreakdown, er
if uncachedInputTokens < 0 {
uncachedInputTokens = 0
}
billableOutputTokens := outputTokens + reasoningTokens
inputCost := costForTokens(uncachedInputTokens, inputRate)
cachedInputCost := costForTokens(cachedInputTokens, cachedRate)
cacheWriteCost := costForTokens(cacheWriteTokens, cacheWriteRate)
outputCost := costForTokens(outputTokens, outputRate)
outputCost := costForTokens(billableOutputTokens, outputRate)
breakdown := CostBreakdown{
ModelID: model.ID,
-27
View File
@@ -94,33 +94,6 @@ func TestRegistryCostIgnoresCachedInputWithoutCachePricing(t *testing.T) {
assertClose(t, cost.OutputCost, 0.03)
}
func TestRegistryCostTreatsReasoningAsOutputBreakdown(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
withReasoning, err := registry.EstimateCost("gpt-4.1", zeroruntime.Usage{
InputTokens: 1_000,
OutputTokens: 1_000,
ReasoningTokens: 400,
})
if err != nil {
t.Fatalf("EstimateCost(withReasoning) returned error: %v", err)
}
plain, err := registry.EstimateCost("gpt-4.1", zeroruntime.Usage{
InputTokens: 1_000,
OutputTokens: 1_000,
})
if err != nil {
t.Fatalf("EstimateCost(plain) returned error: %v", err)
}
if withReasoning.OutputCost != plain.OutputCost || withReasoning.TotalCost != plain.TotalCost {
t.Fatalf("reasoning should be a breakdown of output cost, with=%#v plain=%#v", withReasoning, plain)
}
}
func TestRegistryCostSelectsTierForLongContextPricing(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
@@ -1,34 +0,0 @@
package modelregistry
import "testing"
func TestReasoningEffortsFallbackForGPT5AndOSeries(t *testing.T) {
reg, err := DefaultRegistry()
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
want []ReasoningEffort
}{
{"gpt-5", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"gpt-5.5", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"gpt-5.4-mini", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"o3-mini", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"gpt-5.3-codex-spark", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"gpt-4.1", nil}, // non-reasoning, registered: stays empty
{"gpt-4o-mini", nil},
{"ollama/llama3.1", nil},
}
for _, c := range cases {
got := reg.ReasoningEfforts(c.name)
if len(got) != len(c.want) {
t.Fatalf("%s: got %v, want %v", c.name, got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Fatalf("%s: got %v, want %v", c.name, got, c.want)
}
}
}
}
+4 -22
View File
@@ -47,13 +47,10 @@ func (registry Registry) ResolveWithFallback(input string) (ModelEntry, string,
// EffectiveReasoningEffort returns the effort to use for a model: the requested
// value if the model supports it, otherwise the model's default (or first
// supported, or none). It resolves the supported set through
// effectiveReasoningEfforts so it sees the same name-based fallback the /effort
// picker uses — the two must never disagree about which tiers a model supports.
// supported, or none).
func EffectiveReasoningEffort(model ModelEntry, requested ReasoningEffort) ReasoningEffort {
efforts := effectiveReasoningEfforts(model)
if requested != "" {
for _, effort := range efforts {
for _, effort := range model.ReasoningEfforts {
if effort == requested {
return requested
}
@@ -62,23 +59,8 @@ func EffectiveReasoningEffort(model ModelEntry, requested ReasoningEffort) Reaso
if model.DefaultReasoningEffort != "" {
return model.DefaultReasoningEffort
}
if len(efforts) > 0 {
return efforts[0]
if len(model.ReasoningEfforts) > 0 {
return model.ReasoningEfforts[0]
}
return ReasoningEffortNone
}
// effectiveReasoningEfforts returns a model's supported reasoning efforts, falling
// back to name-based inference (reasoningEffortsForModelName) when the catalog
// entry enumerates none. Both the /effort picker (Registry.ReasoningEfforts) and
// the run-time resolver (EffectiveReasoningEffort) read efforts through this
// single helper, so the picker can never advertise a tier the resolver drops.
func effectiveReasoningEfforts(model ModelEntry) []ReasoningEffort {
if len(model.ReasoningEfforts) > 0 {
return model.ReasoningEfforts
}
if efforts := reasoningEffortsForModelName(model.ID); len(efforts) > 0 {
return efforts
}
return reasoningEffortsForModelName(model.APIModel)
}
-40
View File
@@ -79,43 +79,3 @@ func TestEffectiveReasoningEffort(t *testing.T) {
t.Errorf("empty effort should use default low, got %q", got)
}
}
// TestEffectiveReasoningEffortUsesNameFallback pins that the run-time resolver
// honors the same name-based fallback the /effort picker uses. A model that the
// catalog enumerates with no efforts but whose name is a known reasoning family
// (e.g. a GPT-5 variant served via a proxy) must have its requested effort
// honored — not silently coerced to "none" while the picker advertises controls.
func TestEffectiveReasoningEffortUsesNameFallback(t *testing.T) {
// Registered model, no explicit efforts, GPT-5 api model -> fallback infers
// {minimal, low, medium, high}.
gpt5 := ModelEntry{ID: "gpt-5-proxy", APIModel: "gpt-5", Provider: ProviderOpenAI}
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortHigh); got != ReasoningEffortHigh {
t.Errorf("supported (via name fallback) effort = %q; want high", got)
}
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortMinimal); got != ReasoningEffortMinimal {
t.Errorf("minimal (via name fallback) = %q; want minimal", got)
}
// xhigh is outside the inferred set and there is no declared default, so it
// coerces to the first inferred tier rather than to "none".
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortXHigh); got != ReasoningEffortMinimal {
t.Errorf("unsupported effort on a fallback model = %q; want minimal (first inferred)", got)
}
// Non-reasoning model: name matches nothing, stays "none".
gpt4o := ModelEntry{ID: "gpt-4o", APIModel: "gpt-4o", Provider: ProviderOpenAI}
if got := EffectiveReasoningEffort(gpt4o, ReasoningEffortHigh); got != ReasoningEffortNone {
t.Errorf("non-reasoning model = %q; want none", got)
}
// The picker and the resolver must agree on the supported set for the same id.
reg := resolveTestRegistry(t)
picker := reg.ReasoningEfforts("gpt-5") // unknown -> name fallback
if len(picker) == 0 {
t.Fatal("picker should advertise efforts for a gpt-5 name")
}
for _, tier := range picker {
if got := EffectiveReasoningEffort(gpt5, tier); got != tier {
t.Errorf("picker advertises %q but resolver returns %q", tier, got)
}
}
}
-117
View File
@@ -25,7 +25,6 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
Scripts map[string]string `json:"scripts"`
License string `json:"license"`
Files []string `json:"files"`
Deps map[string]string `json:"dependencies"`
Repository json.RawMessage `json:"repository"`
Engines map[string]string `json:"engines"`
}
@@ -61,11 +60,6 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
if pkg.Engines["node"] == "" {
t.Fatalf("package.json engines.node is empty; the wrapper and installer require a modern Node")
}
for _, name := range []string{"agent-browser", "tuistory"} {
if pkg.Deps[name] == "" {
t.Fatalf("package.json dependencies is missing %q", name)
}
}
wantFiles := map[string]bool{"bin/zero.js": false, "scripts/postinstall.mjs": false}
for _, f := range pkg.Files {
if _, ok := wantFiles[f]; ok {
@@ -224,7 +218,6 @@ func TestNodeWrapperReportsMissingNativeBinary(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), "ZERO_LOCAL_CONTROL_HELPERS=")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out reporting missing native binary: %v; output: %s", ctx.Err(), output)
@@ -268,116 +261,6 @@ func TestNodeWrapperLaunchesNativeBinary(t *testing.T) {
}
}
func TestNodeWrapperPassesLocalControlHelperManifest(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
nativePath := filepath.Join(root, "zero")
if err := os.WriteFile(nativePath, []byte("#!/usr/bin/env sh\nprintf '%s\\n' \"$ZERO_LOCAL_CONTROL_HELPERS\"\n"), 0o755); err != nil {
t.Fatalf("WriteFile native fixture: %v", err)
}
binDir := filepath.Join(root, "node_modules", ".bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("MkdirAll node_modules/.bin: %v", err)
}
for _, name := range []string{"agent-browser", "tuistory"} {
if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/usr/bin/env sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile helper %s: %v", name, err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out launching native binary: %v; output: %s", ctx.Err(), output)
}
if err != nil {
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
}
var manifest struct {
Version int `json:"version"`
Helpers map[string]struct {
Command string `json:"command"`
PrefixArgs []string `json:"prefixArgs"`
PathPrepend []string `json:"pathPrepend"`
} `json:"helpers"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(string(output))), &manifest); err != nil {
t.Fatalf("manifest JSON = %q: %v", output, err)
}
if manifest.Version != 1 {
t.Fatalf("manifest version = %d, want 1", manifest.Version)
}
for _, name := range []string{"agent-browser", "tuistory"} {
helper, ok := manifest.Helpers[name]
if !ok {
t.Fatalf("manifest missing helper %q: %#v", name, manifest.Helpers)
}
wantCommand := canonicalTestPath(t, filepath.Join(binDir, name))
if helper.Command != wantCommand {
t.Fatalf("%s command = %q, want %q", name, helper.Command, wantCommand)
}
wantBinDir := canonicalTestPath(t, binDir)
if len(helper.PathPrepend) != 1 || helper.PathPrepend[0] != wantBinDir {
t.Fatalf("%s pathPrepend = %#v, want [%q]", name, helper.PathPrepend, wantBinDir)
}
}
}
func TestNodeWrapperClearsInheritedLocalControlHelperManifestWhenNoHelpers(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
nativePath := filepath.Join(root, "zero")
if err := os.WriteFile(nativePath, []byte("#!/usr/bin/env sh\nif [ -n \"${ZERO_LOCAL_CONTROL_HELPERS+x}\" ]; then printf 'set\\n'; else printf 'unset\\n'; fi\n"), 0o755); err != nil {
t.Fatalf("WriteFile native fixture: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), `ZERO_LOCAL_CONTROL_HELPERS={"version":1,"helpers":{"agent-browser":{"command":"stale"}}}`)
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out launching native binary: %v; output: %s", ctx.Err(), output)
}
if err != nil {
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
}
if got := strings.TrimSpace(string(output)); got != "unset" {
t.Fatalf("ZERO_LOCAL_CONTROL_HELPERS state = %q, want unset", got)
}
}
func canonicalTestPath(t *testing.T, path string) string {
t.Helper()
realPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatalf("EvalSymlinks %s: %v", path, err)
}
return realPath
}
func withoutEnvKey(env []string, key string) []string {
prefix := key + "="
out := make([]string, 0, len(env))
for _, item := range env {
if strings.HasPrefix(item, prefix) {
continue
}
out = append(out, item)
}
return out
}
func copyWrapperFixture(t *testing.T) string {
t.Helper()
root := repoRoot(t)
+1 -1
View File
@@ -104,7 +104,7 @@ var descriptors = []Descriptor{
// way in; RequiresAuth is forced true so catalog consumers know the provider
// needs an interactive login before a request will succeed.
func() Descriptor {
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5.5", nil)
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5", nil)
d.RequiresAuth = true
return oauthProvider(d, false, false)
}(),
-6
View File
@@ -56,12 +56,6 @@ var curatedModels = map[string][]Model{
{ID: "minimax/minimax-m2.1", Description: "agentic coding model"},
{ID: "deepseek/deepseek-chat", Description: "coding model"},
},
"chatgpt": {
{ID: "gpt-5.5", Description: "recommended Codex model"},
{ID: "gpt-5.4", Description: "strong Codex model"},
{ID: "gpt-5.4-mini", Description: "fast Codex model"},
{ID: "gpt-5.3-codex-spark", Description: "research preview fast Codex model"},
},
"deepseek": {
{ID: "deepseek-chat", Description: "catalog default"},
{ID: "deepseek-reasoner", Description: "reasoning model"},
@@ -27,11 +27,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
want: []string{"llama-3.3-70b-versatile", "openai/gpt-oss-120b"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "chatgpt",
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
notWant: []string{"gpt-5", "gpt-4.1", "openai/gpt-4.1"},
},
{
provider: "mistral",
want: []string{"mistral-large-latest", "codestral-latest"},
+1 -1
View File
@@ -212,8 +212,8 @@ func (provider *Provider) emitPayload(ctx context.Context, data string, state *s
}
if payload.UsageMetadata != nil {
state.inputTokens = payload.UsageMetadata.PromptTokenCount
state.outputTokens = payload.UsageMetadata.CandidatesTokenCount
state.reasoningTokens = payload.UsageMetadata.ThoughtsTokenCount
state.outputTokens = payload.UsageMetadata.CandidatesTokenCount + state.reasoningTokens
state.cachedTokens = payload.UsageMetadata.CachedContentTokenCount
state.hasUsage = true
}
+1 -1
View File
@@ -288,7 +288,7 @@ func TestStreamCompletionEmitsTextUsageAndReasoningTokens(t *testing.T) {
want := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "Hello"},
{Type: zeroruntime.StreamEventText, Content: " Zero"},
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 25, OutputTokens: 18, PromptTokens: 25, CompletionTokens: 18, ReasoningTokens: 3, CachedInputTokens: 7}},
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 25, OutputTokens: 15, PromptTokens: 25, CompletionTokens: 15, ReasoningTokens: 3, CachedInputTokens: 7}},
{Type: zeroruntime.StreamEventDone},
}
if !reflect.DeepEqual(events, want) {
+14 -28
View File
@@ -71,21 +71,11 @@ const (
// responsesRequest is the wire shape POSTed to {baseURL}/responses.
type responsesRequest struct {
Model string `json:"model"`
Instructions string `json:"instructions"`
Input []inputItem `json:"input"`
Stream bool `json:"stream"`
Store bool `json:"store"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Tools []responsesTool `json:"tools,omitempty"`
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
}
// responsesReasoning carries the reasoning controls for the Responses API. The
// chat-completions `reasoning_effort` field is nested under `reasoning.effort`
// here; omitted entirely when the caller requests no (or an unsupported) effort.
type responsesReasoning struct {
Effort string `json:"effort,omitempty"`
Model string `json:"model"`
Input []inputItem `json:"input"`
Stream bool `json:"stream"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Tools []responsesTool `json:"tools,omitempty"`
}
// inputItem is one element of the Responses `input` array. The Type field
@@ -213,13 +203,18 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
Stream: true,
MaxOutputTokens: p.inner.maxTokens,
}
instructions := []string{}
for _, msg := range request.Messages {
switch msg.Role {
case zeroruntime.MessageRoleSystem:
if content := strings.TrimSpace(msg.Content); content != "" {
instructions = append(instructions, content)
}
// System → first-class user message in Responses API. Keeping it as
// an input message (rather than a separate `instructions` field)
// matches the chat-completions semantics the runtime was already
// producing — every system turn reaches the model in order.
req.Input = append(req.Input, inputItem{
Type: "message",
Role: "system",
Content: []contentItem{{Type: "input_text", Text: msg.Content}},
})
case zeroruntime.MessageRoleUser:
req.Input = append(req.Input, p.userInputItem(msg))
case zeroruntime.MessageRoleAssistant:
@@ -241,7 +236,6 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
return nil, fmt.Errorf("codex provider: unsupported message role %q", msg.Role)
}
}
req.Instructions = strings.Join(instructions, "\n\n")
for _, tool := range request.Tools {
req.Tools = append(req.Tools, responsesTool{
Type: "function",
@@ -250,14 +244,6 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
Parameters: tool.Parameters,
})
}
// Codex / o-series reasoning models take the effort tier nested under
// `reasoning` on the Responses API (the chat-completions `reasoning_effort`
// moved here). Reuse the chat normalizer so only API-accepted values are sent
// and an empty or unsupported effort simply omits the field — without this the
// caller's chosen effort was silently dropped for every Codex model.
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
req.Reasoning = &responsesReasoning{Effort: effort}
}
return req, nil
}
+12 -76
View File
@@ -385,10 +385,9 @@ func TestCodexProviderOmitsAccountIDWhenResolverSaysNo(t *testing.T) {
func TestCodexProviderSendsResponsesRequestShape(t *testing.T) {
// The Codex provider speaks the Responses API, not the chat-completions
// API. The request body must carry top-level `instructions`, `input` items
// (not `messages`), a `stream: true` flag, and `tools` (when the caller
// supplies any) — a chat-completions body would be rejected by the live
// Codex backend.
// API. The request body must carry `input` items (not `messages`), a
// `stream: true` flag, and `tools` (when the caller supplies any) — a
// chat-completions body would be rejected by the live Codex backend.
var rec codexRequest
srv := newCodexTestServer(t, &rec)
defer srv.Close()
@@ -424,25 +423,23 @@ func TestCodexProviderSendsResponsesRequestShape(t *testing.T) {
if rec.body["stream"] != true {
t.Fatalf("body.stream = %#v, want true", rec.body["stream"])
}
if store, ok := rec.body["store"].(bool); !ok || store {
t.Fatalf("body.store = %#v, want explicit false", rec.body["store"])
}
if _, ok := rec.body["messages"]; ok {
t.Fatalf("body must not carry chat-completions `messages` (got %#v); the Codex backend serves the Responses API", rec.body["messages"])
}
if rec.body["instructions"] != "sys" {
t.Fatalf("body.instructions = %#v, want system prompt", rec.body["instructions"])
}
input, ok := rec.body["input"].([]any)
if !ok {
t.Fatalf("body.input = %#v, want []any with user message item", rec.body["input"])
t.Fatalf("body.input = %#v, want []any with two message items", rec.body["input"])
}
if len(input) != 1 {
t.Fatalf("body.input has %d items, want 1 (user)", len(input))
if len(input) != 2 {
t.Fatalf("body.input has %d items, want 2 (system + user)", len(input))
}
user, _ := input[0].(map[string]any)
system, _ := input[0].(map[string]any)
if system["type"] != "message" || system["role"] != "system" {
t.Fatalf("body.input[0] = %#v, want type=message role=system", system)
}
user, _ := input[1].(map[string]any)
if user["type"] != "message" || user["role"] != "user" {
t.Fatalf("body.input[0] = %#v, want type=message role=user", user)
t.Fatalf("body.input[1] = %#v, want type=message role=user", user)
}
tools, ok := rec.body["tools"].([]any)
if !ok || len(tools) != 1 {
@@ -454,67 +451,6 @@ func TestCodexProviderSendsResponsesRequestShape(t *testing.T) {
}
}
func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
// A reasoning effort must reach the Responses backend nested under
// `reasoning.effort` (where the chat-completions `reasoning_effort` moved).
// Without this the user's chosen effort is silently dropped for Codex models.
var rec codexRequest
srv := newCodexTestServer(t, &rec)
defer srv.Close()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
AccountID: "acc-x",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
ReasoningEffort: "high",
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
drainCodexEvents(t, stream)
reasoning, ok := rec.body["reasoning"].(map[string]any)
if !ok {
t.Fatalf("body.reasoning = %#v, want an object carrying the effort", rec.body["reasoning"])
}
if reasoning["effort"] != "high" {
t.Fatalf("body.reasoning.effort = %#v, want high", reasoning["effort"])
}
}
func TestCodexProviderOmitsReasoningWhenUnset(t *testing.T) {
// No effort (and unsupported values) must omit the `reasoning` field entirely
// rather than send an empty object, which the backend would reject.
var rec codexRequest
srv := newCodexTestServer(t, &rec)
defer srv.Close()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
AccountID: "acc-x",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
ReasoningEffort: "", // auto
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
drainCodexEvents(t, stream)
if _, ok := rec.body["reasoning"]; ok {
t.Fatalf("body.reasoning must be omitted when no effort is requested, got %#v", rec.body["reasoning"])
}
}
func TestCodexProviderRetriesHeadersAfter401(t *testing.T) {
var hits atomic.Int32
var rec1, rec2 codexRequest
+5 -15
View File
@@ -310,7 +310,6 @@ func (provider *Provider) emitChunk(
PromptTokens: chunk.Usage.PromptTokens,
CompletionTokens: chunk.Usage.CompletionTokens,
CachedInputTokens: chunk.Usage.PromptTokensDetails.CachedTokens,
ReasoningTokens: chunk.Usage.CompletionTokensDetails.ReasoningTokens,
},
})
}
@@ -385,14 +384,6 @@ func sendBufferedEvent(events chan<- zeroruntime.StreamEvent, event zeroruntime.
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
messages := make([]chatMessage, 0, len(request.Messages))
for _, message := range request.Messages {
// Drop a degenerate assistant turn that carries neither text nor tool calls
// (e.g. a sub-agent that failed with no output). The Anthropic/Gemini mappers
// already skip empty turns; without this, the contentless message reaches
// strict OpenAI-compatible servers and is rejected.
if message.Role == zeroruntime.MessageRoleAssistant &&
strings.TrimSpace(message.Content) == "" && len(message.ToolCalls) == 0 {
continue
}
messages = append(messages, mapMessage(message))
}
@@ -453,12 +444,11 @@ func mapMessage(message zeroruntime.Message) chatMessage {
// message that happens to carry Images keeps the plain string/nil content
// path (its images are simply not serialized).
if len(message.Images) == 0 || message.Role != zeroruntime.MessageRoleUser {
// Always set content (to "" when empty) so it serializes as `"content":""`
// rather than being dropped. Strict OpenAI-compatible servers reject a
// message with no content field; tool results and assistant-with-tool-calls
// turns must still carry an (empty) content. Truly empty assistant turns are
// dropped upstream in openAIRequest.
mapped.Content = message.Content
// Preserve the legacy behavior: only non-empty text is serialized, so
// empty content is omitted by the `omitempty` tag.
if message.Content != "" {
mapped.Content = message.Content
}
} else {
parts := make([]contentPart, 0, len(message.Images)+1)
if message.Content != "" {
+85 -65
View File
@@ -1000,6 +1000,57 @@ func TestStreamCompletionEmitsDroppedOnNamelessToolCall(t *testing.T) {
}
}
func TestOpenAIRequestTextOnlyOmitsEmptyContent(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "hello"},
{
Role: zeroruntime.MessageRoleAssistant,
Content: "", // assistant turn that is *only* a tool call -> empty content must drop
ToolCalls: []zeroruntime.ToolCall{{
ID: "call_1",
Name: "read_file",
Arguments: `{"path":"README.md"}`,
}},
},
{Role: zeroruntime.MessageRoleTool, Content: "contents", ToolCallID: "call_1"},
},
}))
if err != nil {
t.Fatalf("marshal request: %v", err)
}
// String content round-trips as a JSON string, and empty content is omitted.
if !strings.Contains(string(got), `"content":"hello"`) {
t.Fatalf("user content not serialized as string: %s", got)
}
if strings.Contains(string(got), `"content":""`) {
t.Fatalf("empty assistant content must be omitted, got: %s", got)
}
// Decode the assistant message and assert no content key at all.
var decoded chatCompletionRequest
var raw struct {
Messages []map[string]any `json:"messages"`
}
if err := json.Unmarshal(got, &raw); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
_ = decoded
if _, present := raw.Messages[2]["content"]; present {
t.Fatalf("assistant (tool-only) message must omit content key, got: %#v", raw.Messages[2])
}
if raw.Messages[1]["content"] != "hello" {
t.Fatalf("user content = %#v, want \"hello\"", raw.Messages[1]["content"])
}
}
func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
parts := []contentPart{
{Type: "text", Text: "look"},
@@ -1025,6 +1076,38 @@ func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
}
}
func TestOpenAIRequestEmptyContentStaysOmittedAfterAnyRefactor(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{
Role: zeroruntime.MessageRoleAssistant,
Content: "",
ToolCalls: []zeroruntime.ToolCall{{
ID: "call_1",
Name: "read_file",
Arguments: `{}`,
}},
},
},
}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
var raw struct {
Messages []map[string]any `json:"messages"`
}
if err := json.Unmarshal(got, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, present := raw.Messages[0]["content"]; present {
t.Fatalf("empty content boxed in any must stay omitted, got: %#v", raw.Messages[0])
}
}
func TestMapMessageBuildsImageURLContentParts(t *testing.T) {
msg := mapMessage(zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
@@ -1082,8 +1165,8 @@ func TestMapMessageTextOnlyKeepsStringContent(t *testing.T) {
t.Fatalf("Content = %#v, want string \"hi\"", msg.Content)
}
empty := mapMessage(zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: ""})
if got, ok := empty.Content.(string); !ok || got != "" {
t.Fatalf("empty text content = %#v, want \"\" so it serializes as content:\"\" (strict servers reject a missing/null content)", empty.Content)
if empty.Content != nil {
t.Fatalf("empty text content = %#v, want nil so omitempty drops it", empty.Content)
}
}
@@ -1163,66 +1246,3 @@ func TestStreamCompletionSerializesImageContentParts(t *testing.T) {
t.Fatalf("image url = %#v, want data:image/png;base64,QUJD", imageURL["url"])
}
}
// TestOpenAIRequestEmptyContentHandling locks in the fix for strict OpenAI-
// compatible servers (e.g. glm-* on Ollama-cloud) that reject a message whose
// `content` is absent/null with "invalid message content type: <nil>": a
// contentless assistant turn with no tool calls is dropped, and every other
// message serializes an explicit `"content"` field (empty string when there is
// no text) instead of omitting it.
func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
req := provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "hi"},
// Degenerate empty assistant turn (e.g. a sub-agent that failed with no
// output): must be dropped, not sent.
{Role: zeroruntime.MessageRoleAssistant, Content: " "},
// Assistant with tool calls but no text: kept, content present as "".
{Role: zeroruntime.MessageRoleAssistant, Content: "", ToolCalls: []zeroruntime.ToolCall{{
ID: "call_1", Name: "read_file", Arguments: "{}",
}}},
// Tool result with empty content: kept, content present as "".
{Role: zeroruntime.MessageRoleTool, Content: "", ToolCallID: "call_1"},
},
})
if len(req.Messages) != 3 {
t.Fatalf("empty assistant turn should be dropped; got %d messages: %#v", len(req.Messages), req.Messages)
}
// No message may serialize with an absent/null content field.
for i, message := range req.Messages {
if message.Content == nil {
t.Fatalf("message %d has nil content (would serialize as null/omitted): %#v", i, message)
}
data, err := json.Marshal(message)
if err != nil {
t.Fatalf("marshal message %d: %v", i, err)
}
if !strings.Contains(string(data), `"content":`) {
t.Fatalf("message %d omits the content field: %s", i, data)
}
}
if req.Messages[0].Content != "hi" {
t.Fatalf("user content = %#v, want \"hi\"", req.Messages[0].Content)
}
// The kept-but-textless messages must send content as an explicit empty string.
for _, idx := range []int{1, 2} {
data, err := json.Marshal(req.Messages[idx])
if err != nil {
t.Fatalf("marshal message %d: %v", idx, err)
}
if !strings.Contains(string(data), `"content":""`) {
t.Fatalf("message %d should send content:\"\", got %s", idx, data)
}
}
if len(req.Messages[1].ToolCalls) != 1 {
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
}
}
+5 -15
View File
@@ -18,13 +18,8 @@ type streamOptions struct {
}
type chatMessage struct {
Role string `json:"role"`
// Content has no omitempty: strict OpenAI-compatible servers (e.g. some
// Ollama-cloud models like glm-*) reject a message whose `content` is absent
// or null with "invalid message content type: <nil>". mapMessage always sets
// this (to "" when there's no text), so a contentless message serializes as
// `"content":""` rather than being dropped.
Content any `json:"content"`
Role string `json:"role"`
Content any `json:"content,omitempty"`
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
@@ -93,20 +88,15 @@ type streamFunctionDelta struct {
}
type usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
PromptTokensDetails promptTokenDetails `json:"prompt_tokens_details"`
CompletionTokensDetails completionTokenDetails `json:"completion_tokens_details"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
PromptTokensDetails promptTokenDetails `json:"prompt_tokens_details"`
}
type promptTokenDetails struct {
CachedTokens int `json:"cached_tokens"`
}
type completionTokenDetails struct {
ReasoningTokens int `json:"reasoning_tokens"`
}
type apiError struct {
Message string `json:"message"`
Type string `json:"type"`
+2 -111
View File
@@ -243,9 +243,6 @@ func Package(ctx context.Context, options PackageOptions) (PackageResult, error)
if err := copyPackageFiles(rootDir, stagingDir, artifactPath, stagedBinaryPath, goos, version, helperArtifacts); err != nil {
return PackageResult{}, err
}
if err := stageLocalControlHelpers(ctx, rootDir, filepath.Join(stagingDir, "helpers")); err != nil {
return PackageResult{}, err
}
if err := createArchive(stagingDir, archivePath, goos); err != nil {
return PackageResult{}, err
}
@@ -710,105 +707,6 @@ func copyPackageFiles(rootDir string, stagingDir string, artifactPath string, st
return nil
}
var localControlHelperPackages = []string{"agent-browser", "tuistory"}
func stageLocalControlHelpers(ctx context.Context, rootDir string, helpersDir string) error {
dependencies, err := localControlHelperDependencies(rootDir)
if err != nil {
return err
}
if err := os.MkdirAll(helpersDir, 0o755); err != nil {
return fmt.Errorf("create local-control helper staging dir: %w", err)
}
manifest := map[string]any{
"private": true,
"type": "module",
"dependencies": dependencies,
}
bytes, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(helpersDir, "package.json"), append(bytes, '\n'), 0o644); err != nil {
return fmt.Errorf("write local-control helper package.json: %w", err)
}
lockfilePath := filepath.Join(rootDir, "package-lock.json")
if _, err := os.Stat(lockfilePath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("package-lock.json is required to package local-control helpers")
}
return fmt.Errorf("stat package-lock.json: %w", err)
}
if err := copyFile(lockfilePath, filepath.Join(helpersDir, "package-lock.json"), 0o644); err != nil {
return fmt.Errorf("copy local-control helper package-lock.json: %w", err)
}
npmPath, err := exec.LookPath("npm")
if err != nil {
return fmt.Errorf("npm is required to package local-control helpers: %w", err)
}
command := exec.CommandContext(ctx, npmPath, "ci", "--omit=dev", "--no-audit", "--no-fund", "--loglevel=error")
command.Dir = helpersDir
command.Env = append(os.Environ(), "npm_config_update_notifier=false")
output, err := command.CombinedOutput()
if err != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = err.Error()
}
return fmt.Errorf("install local-control helper packages: %s", message)
}
if err := verifyStagedLocalControlHelpers(helpersDir, runtime.GOOS); err != nil {
return err
}
return nil
}
func localControlHelperDependencies(rootDir string) (map[string]string, error) {
bytes, err := os.ReadFile(filepath.Join(rootDir, "package.json"))
if err != nil {
return nil, err
}
var payload struct {
Dependencies map[string]string `json:"dependencies"`
}
if err := json.Unmarshal(bytes, &payload); err != nil {
return nil, fmt.Errorf("parse package.json: %w", err)
}
dependencies := map[string]string{}
for _, name := range localControlHelperPackages {
version := strings.TrimSpace(payload.Dependencies[name])
if version == "" {
return nil, fmt.Errorf("package.json dependency %q is required for packaged local-control helpers", name)
}
dependencies[name] = version
}
return dependencies, nil
}
func verifyStagedLocalControlHelpers(helpersDir string, goos string) error {
for _, name := range localControlHelperPackages {
found := false
for _, shimName := range localControlHelperShimNames(name, goos) {
if info, err := os.Stat(filepath.Join(helpersDir, "node_modules", ".bin", shimName)); err == nil && !info.IsDir() {
found = true
break
}
}
if !found {
return fmt.Errorf("local-control helper package did not create a %s executable shim", name)
}
}
return nil
}
func localControlHelperShimNames(name string, goos string) []string {
if goos == "windows" {
return []string{name + ".cmd", name + ".exe", name}
}
return []string{name}
}
func copyFile(source string, destination string, mode fs.FileMode) error {
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
return err
@@ -865,14 +763,7 @@ func createTarGzArchive(stagingDir string, archivePath string) error {
if err != nil {
return err
}
linkTarget := ""
if info.Mode()&os.ModeSymlink != 0 {
linkTarget, err = os.Readlink(path)
if err != nil {
return err
}
}
header, err := tar.FileInfoHeader(info, linkTarget)
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
@@ -880,7 +771,7 @@ func createTarGzArchive(stagingDir string, archivePath string) error {
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if entry.IsDir() || info.Mode()&os.ModeSymlink != 0 {
if entry.IsDir() {
return nil
}
entryFile, err := os.Open(path)
+3 -166
View File
@@ -357,32 +357,6 @@ func TestCreateArchivesWithRootPackageFiles(t *testing.T) {
})
}
func TestCreateTarArchivePreservesSymlinkTargets(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix symlink archive behavior")
}
stagingDir := packageStagingFixture(t, "zero")
linkPath := filepath.Join(stagingDir, "helpers", "node_modules", ".bin", "agent-browser")
if err := os.MkdirAll(filepath.Dir(linkPath), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
linkTarget := "../agent-browser/bin/agent-browser.js"
if err := os.Symlink(linkTarget, linkPath); err != nil {
t.Fatalf("Symlink: %v", err)
}
archivePath := filepath.Join(t.TempDir(), "zero-v0.1.0-linux-x64.tar.gz")
if err := createArchive(stagingDir, archivePath, "linux"); err != nil {
t.Fatalf("createArchive returned error: %v", err)
}
header := tarArchiveHeaders(t, archivePath)["helpers/node_modules/.bin/agent-browser"]
if header == nil {
t.Fatal("archive missing symlink header")
}
if header.Typeflag != tar.TypeSymlink || header.Linkname != linkTarget {
t.Fatalf("symlink header type/link = %v/%q, want %v/%q", header.Typeflag, header.Linkname, tar.TypeSymlink, linkTarget)
}
}
func TestCopyPackageFilesStagesLinuxSandboxHelper(t *testing.T) {
root := t.TempDir()
staging := t.TempDir()
@@ -443,95 +417,6 @@ func TestCopyPackageFilesStagesWindowsSandboxHelpers(t *testing.T) {
}
}
func TestStageLocalControlHelpersUsesPackageDependencies(t *testing.T) {
root := t.TempDir()
helpers := filepath.Join(t.TempDir(), "helpers")
mustWriteFile(t, filepath.Join(root, "package.json"), `{
"version": "0.1.0",
"dependencies": {
"agent-browser": "0.30.1",
"tuistory": "0.10.0"
}
}`)
mustWriteFile(t, filepath.Join(root, "package-lock.json"), `{
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"agent-browser": "0.30.1",
"tuistory": "0.10.0"
}
},
"node_modules/agent-browser": {
"version": "0.30.1"
},
"node_modules/tuistory": {
"version": "0.10.0"
}
}
}`)
fakeBin := t.TempDir()
writeFakeNPM(t, fakeBin)
t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
if err := stageLocalControlHelpers(context.Background(), root, helpers); err != nil {
t.Fatalf("stageLocalControlHelpers: %v", err)
}
manifestBytes, err := os.ReadFile(filepath.Join(helpers, "package.json"))
if err != nil {
t.Fatalf("ReadFile helper package.json: %v", err)
}
manifest := string(manifestBytes)
for _, want := range []string{`"agent-browser": "0.30.1"`, `"tuistory": "0.10.0"`} {
if !strings.Contains(manifest, want) {
t.Fatalf("helper package.json missing %s:\n%s", want, manifest)
}
}
if _, err := os.Stat(filepath.Join(helpers, "package-lock.json")); err != nil {
t.Fatalf("helper package-lock.json was not copied: %v", err)
}
for _, name := range localControlHelperPackages {
found := false
for _, shimName := range localControlHelperShimNames(name, runtime.GOOS) {
if _, err := os.Stat(filepath.Join(helpers, "node_modules", ".bin", shimName)); err == nil {
found = true
break
}
}
if !found {
t.Fatalf("missing shim for %s", name)
}
}
}
func TestStageLocalControlHelpersRequiresPackageLock(t *testing.T) {
root := t.TempDir()
helpers := filepath.Join(t.TempDir(), "helpers")
mustWriteFile(t, filepath.Join(root, "package.json"), `{
"version": "0.1.0",
"dependencies": {
"agent-browser": "0.30.1",
"tuistory": "0.10.0"
}
}`)
err := stageLocalControlHelpers(context.Background(), root, helpers)
if err == nil || !strings.Contains(err.Error(), "package-lock.json is required") {
t.Fatalf("stageLocalControlHelpers error = %v, want package-lock requirement", err)
}
}
func TestLocalControlHelperDependenciesRequiresConfiguredPackages(t *testing.T) {
root := t.TempDir()
mustWriteFile(t, filepath.Join(root, "package.json"), `{"version":"0.1.0","dependencies":{"agent-browser":"0.30.1"}}`)
_, err := localControlHelperDependencies(root)
if err == nil || !strings.Contains(err.Error(), `dependency "tuistory"`) {
t.Fatalf("localControlHelperDependencies error = %v, want missing tuistory", err)
}
}
func packageStagingFixture(t *testing.T, binaryName string) string {
t.Helper()
dir := t.TempDir()
@@ -553,43 +438,6 @@ func packageStagingFixture(t *testing.T, binaryName string) string {
return dir
}
func writeFakeNPM(t *testing.T, dir string) {
t.Helper()
if runtime.GOOS == "windows" {
path := filepath.Join(dir, "npm.cmd")
content := `@echo off
if not "%1"=="ci" (
echo expected npm ci 1>&2
exit /b 2
)
mkdir node_modules 2>NUL
mkdir node_modules\.bin 2>NUL
echo @echo off> node_modules\.bin\agent-browser.cmd
echo @echo off> node_modules\.bin\tuistory.cmd
exit /b 0
`
mustWriteFile(t, path, content)
return
}
path := filepath.Join(dir, "npm")
content := `#!/usr/bin/env sh
set -eu
if [ "${1:-}" != "ci" ]; then
echo "expected npm ci" >&2
exit 2
fi
mkdir -p node_modules/.bin
for name in agent-browser tuistory; do
printf '#!/usr/bin/env sh\n' > "node_modules/.bin/$name"
chmod 755 "node_modules/.bin/$name"
done
`
mustWriteFile(t, path, content)
if err := os.Chmod(path, 0o755); err != nil {
t.Fatalf("Chmod fake npm: %v", err)
}
}
func mustWriteFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
@@ -601,16 +449,6 @@ func mustWriteFile(t *testing.T, path string, content string) {
}
func tarArchiveNames(t *testing.T, archivePath string) map[string]bool {
t.Helper()
headers := tarArchiveHeaders(t, archivePath)
names := map[string]bool{}
for name := range headers {
names[name] = true
}
return names
}
func tarArchiveHeaders(t *testing.T, archivePath string) map[string]*tar.Header {
t.Helper()
file, err := os.Open(archivePath)
if err != nil {
@@ -627,7 +465,7 @@ func tarArchiveHeaders(t *testing.T, archivePath string) map[string]*tar.Header
_ = gzipReader.Close()
}()
reader := tar.NewReader(gzipReader)
headers := map[string]*tar.Header{}
names := map[string]bool{}
for {
header, err := reader.Next()
if err != nil {
@@ -636,10 +474,9 @@ func tarArchiveHeaders(t *testing.T, archivePath string) map[string]*tar.Header
}
t.Fatalf("tar Next: %v", err)
}
copied := *header
headers[header.Name] = &copied
names[header.Name] = true
}
return headers
return names
}
func zipArchiveNames(t *testing.T, archivePath string) map[string]bool {
-65
View File
@@ -1,65 +0,0 @@
package reltime
import "fmt"
// RelTime renders a duration in seconds as a human-readable relative-time
// string. This is the ORIGINAL implementation, kept verbatim so
// characterization tests can be pinned against it before refactoring.
func RelTime(d int) string {
if d < 0 {
d = -d
if d < 60 {
if d == 1 {
return "1 second ago"
}
return fmt.Sprintf("%d seconds ago", d)
}
if d < 3600 {
m := d / 60
if m == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
}
if d < 86400 {
h := d / 3600
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
}
x := d / 86400
if x == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", x)
} else if d == 0 {
return "just now"
} else {
if d < 60 {
if d == 1 {
return "in 1 second"
}
return fmt.Sprintf("in %d seconds", d)
}
if d < 3600 {
m := d / 60
if m == 1 {
return "in 1 minute"
}
return fmt.Sprintf("in %d minutes", m)
}
if d < 86400 {
h := d / 3600
if h == 1 {
return "in 1 hour"
}
return fmt.Sprintf("in %d hours", h)
}
x := d / 86400
if x == 1 {
return "in 1 day"
}
return fmt.Sprintf("in %d days", x)
}
}
+12 -61
View File
@@ -34,15 +34,6 @@ var networkPrograms = map[string]bool{
"ftp": true,
}
var localServerPrograms = map[string]bool{
"http-server": true,
"serve": true,
"vite": true,
"next": true,
"nuxt": true,
"astro": true,
}
// AnalyzeCommand parses script and reports interactive/destructive/network usage
// from the shell AST. A script that cannot be parsed yields TooComplex (with no
// other flags set) so the caller can decide how to treat an unanalyzable command.
@@ -114,37 +105,25 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
return true
}
words := literalWordTexts(args)
if localServerPrograms[prog] {
return true
}
switch prog {
case "python", "python2", "python3", "py":
return pythonModuleUsesNetwork(words)
case "npm":
return packageManagerUsesNetwork(words, map[string]string{
"run": "run",
"exec": "exec",
"x": "exec",
})
return firstSubcommand(words, nil) == "install" ||
firstSubcommand(words, nil) == "add" ||
firstSubcommand(words, nil) == "publish" ||
firstSubcommand(words, nil) == "login"
case "pnpm":
return packageManagerUsesNetwork(words, map[string]string{
"run": "run",
"exec": "exec",
"dlx": "exec",
})
return firstSubcommand(words, nil) == "install" ||
firstSubcommand(words, nil) == "add" ||
firstSubcommand(words, nil) == "publish"
case "yarn":
return packageManagerUsesNetwork(words, map[string]string{
"run": "run",
"exec": "exec",
"dlx": "exec",
})
return firstSubcommand(words, nil) == "add" ||
firstSubcommand(words, nil) == "publish"
case "bun":
return packageManagerUsesNetwork(words, map[string]string{
"run": "run",
"x": "exec",
})
case "npx":
return npxUsesNetwork(words)
return firstSubcommand(words, nil) == "add" ||
firstSubcommand(words, nil) == "install" ||
firstSubcommand(words, nil) == "publish"
case "pip", "pip2", "pip3":
return firstSubcommand(words, nil) == "install"
case "go":
@@ -158,34 +137,6 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
}
}
func packageManagerUsesNetwork(words []string, aliases map[string]string) bool {
first := firstSubcommand(words, aliases)
switch first {
case "install", "add", "publish", "login":
return true
case "start", "serve", "dev", "preview":
return true
case "run":
second := secondSubcommand(words)
return second == "start" || second == "serve" || second == "dev" || second == "preview"
case "exec":
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 npxUsesNetwork(_ []string) bool {
return true
}
func literalWordTexts(args []*syntax.Word) []string {
words := make([]string, 0, len(args))
for _, arg := range args {
-7
View File
@@ -49,17 +49,10 @@ 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 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},
{name: "direct http server", script: "http-server public -p 8080 -a 127.0.0.1", network: true},
{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: "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},
{name: "process listing is not special-cased", script: "ps aux", network: false},
{name: "unparseable", script: `'unterminated quote`, tooComplex: true},
}
+6 -13
View File
@@ -103,16 +103,12 @@ func TestCommandPlanCarriesSandboxMetadata(t *testing.T) {
Policy: DefaultPolicy(),
Backend: Backend{Name: BackendUnavailable, Platform: "linux", Fallback: true, Message: "native sandbox unavailable"},
})
degraded, err := unavailable.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root})
if err != nil {
t.Fatalf("BuildCommandPlan unavailable auto plan: %v", err)
}
if degraded.Wrapped || degraded.EnforcementLevel != EnforcementDegraded || degraded.DowngradeReason != "native sandbox unavailable" {
t.Fatalf("unavailable command metadata = %#v, want degraded direct plan", degraded)
if _, err := unavailable.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root}); !errors.Is(err, errNativeSandboxUnavailable) {
t.Fatalf("BuildCommandPlan unavailable error = %v, want native sandbox unavailable", err)
}
}
func TestUnavailableBackendsDegradeForTargetPlatforms(t *testing.T) {
func TestUnavailableFailClosedForTargetPlatforms(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
tests := []struct {
@@ -140,12 +136,9 @@ func TestUnavailableBackendsDegradeForTargetPlatforms(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy, Backend: test.backend})
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root})
if err != nil {
t.Fatalf("BuildCommandPlan degraded auto plan: %v", err)
}
if plan.Wrapped || plan.EnforcementLevel != EnforcementDegraded || !plan.RequiresPlatformSandbox {
t.Fatalf("BuildCommandPlan = %#v, want degraded direct plan requiring platform sandbox metadata", plan)
_, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root})
if !errors.Is(err, errNativeSandboxUnavailable) {
t.Fatalf("BuildCommandPlan error = %v, want native sandbox unavailable", err)
}
})
}
-28
View File
@@ -40,34 +40,6 @@ func TestSandboxedBashAutoAllowedWhenSandboxActive(t *testing.T) {
}
}
func TestSandboxedBashRequireEscalatedStillPromptsWhenSandboxActive(t *testing.T) {
engine := sandboxedShellEngine(t, nativeWrappingBackend)
request := bashRequest()
request.Args["sandbox_permissions"] = "require_escalated"
decision := engine.Evaluate(context.Background(), request)
if decision.Action != ActionPrompt || decision.Reason != ReasonEscalatedSandboxRequired {
t.Fatalf("decision = %#v, want require_escalated prompt", decision)
}
}
func TestSandboxedBashProcessListingAutoAllowedWhenSandboxActive(t *testing.T) {
engine := sandboxedShellEngine(t, nativeWrappingBackend)
request := bashRequest()
request.Args["command"] = "ps aux"
decision := engine.Evaluate(context.Background(), request)
if decision.Action != ActionAllow {
t.Fatalf("decision = %#v, want allow (sandbox active)", decision)
}
if len(decision.Risk.Categories) != 1 || !HasRiskCategory(decision.Risk, "shell") {
t.Fatalf("risk = %#v, want only shell category", decision.Risk)
}
if !decision.AutoAllowed {
t.Fatalf("sandboxed shell command should be auto-allowed: %#v", decision)
}
}
func TestSandboxedBashStillPromptsWithoutSandbox(t *testing.T) {
engine := sandboxedShellEngine(t, Backend{Name: BackendUnavailable})
decision := engine.Evaluate(context.Background(), bashRequest())
+2 -37
View File
@@ -46,7 +46,7 @@ func NewEngine(options EngineOptions) *Engine {
}
}
if scope == nil && workspaceRoot != "" {
scope = newScopeBestEffort(workspaceRoot)
scope = &Scope{workspaceRoot: normalizeWorkspaceRootBestEffort(workspaceRoot)}
}
return &Engine{
workspaceRoot: workspaceRoot,
@@ -170,19 +170,6 @@ func (engine *Engine) effectiveNetworkMode(policy Policy) NetworkMode {
return NormalizeNetworkMode(policy.Network)
}
// UnsandboxedExecutionAllowed reports whether an escalated shell attempt may
// bypass the native sandbox without dropping active denied-read restrictions.
func (engine *Engine) UnsandboxedExecutionAllowed() bool {
if engine == nil {
return true
}
policy := engine.effectivePolicy(engine.policy)
if policy.Mode == ModeDisabled {
return true
}
return len(normalizeProfilePaths(policy.DenyRead)) == 0
}
// toolNetworkExempt reports whether a request is exempt from the engine-level
// network deny because it is a first-party, in-process network tool. Such tools
// do not use sandboxed shell egress; they keep their own SSRF/host safeguards. A
@@ -203,15 +190,7 @@ func (engine *Engine) scopeFor(requestRoot string) *Scope {
if engine.scope != nil && requestRoot == engine.workspaceRoot {
return engine.scope
}
return newScopeBestEffort(requestRoot)
}
func newScopeBestEffort(workspaceRoot string) *Scope {
scope, err := NewScope(workspaceRoot, nil)
if err == nil {
return scope
}
return &Scope{workspaceRoot: normalizeWorkspaceRootBestEffort(workspaceRoot)}
return &Scope{workspaceRoot: requestRoot}
}
// shellSandboxActive reports whether a native wrapping sandbox would actually
@@ -361,16 +340,6 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
return deny(request, risk, BlockDestructiveCommand, "", "destructive shell command requires approval", false)
}
}
if request.SideEffect == SideEffectShell && requestRequiresEscalatedSandbox(request) {
if !request.PermissionGranted && request.PermissionMode != PermissionUnsafe {
return Decision{Action: ActionPrompt, Risk: risk, Reason: ReasonEscalatedSandboxRequired}
}
// Unsafe mode may auto-allow ordinary shell prompts, but it must not
// silently convert a sandboxed shell run into an unsandboxed one.
if !request.PermissionGranted {
return deny(request, risk, BlockDeniedPermission, "", ReasonEscalatedSandboxRequired, false)
}
}
if persistentAllow != nil {
return Decision{
Action: ActionAllow,
@@ -411,10 +380,6 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
return Decision{Action: ActionPrompt, Risk: risk, Reason: permissionReason(request)}
}
func requestRequiresEscalatedSandbox(request Request) bool {
return strings.TrimSpace(firstArgString(request.Args, "sandbox_permissions")) == "require_escalated"
}
func (engine *Engine) Grant(input GrantInput) (Grant, error) {
if engine == nil || engine.store == nil {
return Grant{}, errors.New("sandbox grant store is not configured")
+12 -57
View File
@@ -70,23 +70,6 @@ func TestEngineBashAllowGrantDoesNotBypassNetworkPrompt(t *testing.T) {
}
}
func TestUnsandboxedExecutionAllowedPreservesDeniedReads(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy})
if engine.UnsandboxedExecutionAllowed() {
t.Fatal("denied-read policy must not allow unsandboxed escalation")
}
policy.DenyRead = nil
engine = NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy})
if !engine.UnsandboxedExecutionAllowed() {
t.Fatal("policy without denied reads should allow unsandboxed escalation")
}
}
func TestEngineEvaluatesReadPromptAndPersistentDecisions(t *testing.T) {
root := t.TempDir()
store, err := NewGrantStore(StoreOptions{
@@ -239,7 +222,7 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) {
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{"path": outsideDefaultTempPath(root, "escape.txt")},
Args: map[string]any{"path": filepath.Join(t.TempDir(), "escape.txt")},
})
if outside.Action != ActionPrompt || outside.Block == nil || outside.Block.Code != BlockOutsideWorkspace || !outside.Block.Recoverable {
t.Fatalf("outside workspace mutation decision = %#v, want recoverable prompt", outside)
@@ -325,7 +308,7 @@ func TestEngineSessionGrantDoesNotMatchSiblingFile(t *testing.T) {
func TestEnginePersistentGrantAllowsPromptableOutsideWorkspacePath(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "escape.txt")
outside := filepath.Join(t.TempDir(), "escape.txt")
store, err := NewGrantStore(StoreOptions{
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
Now: fixedSandboxTime("2026-06-05T14:00:00Z"),
@@ -362,7 +345,7 @@ func TestEnginePersistentGrantAllowsPromptableOutsideWorkspacePath(t *testing.T)
func TestEnginePersistentGrantDoesNotBypassSymlinkTraversal(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "symlink-target")
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
@@ -401,7 +384,7 @@ func TestEnginePersistentGrantDoesNotBypassSymlinkTraversal(t *testing.T) {
func TestEngineDeniesAutoAllowedSymlinkEscape(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "symlink-target")
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
@@ -460,7 +443,7 @@ func TestEngineGrantScopesWebFetchToHost(t *testing.T) {
func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "escape.txt")
outside := filepath.Join(t.TempDir(), "escape.txt")
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
decision := engine.Evaluate(context.Background(), Request{
@@ -484,7 +467,7 @@ func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "escape.txt")
outside := filepath.Join(t.TempDir(), "escape.txt")
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
// A denied request reports its block without running anything.
@@ -519,7 +502,7 @@ func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
func TestEngineDeniesWorkspaceSymlinkTraversal(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "symlink-target")
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
@@ -642,7 +625,7 @@ func TestEngineReportsContextCancellation(t *testing.T) {
func TestEvaluateOverrideRootDoesNotInheritEngineScope(t *testing.T) {
workspace := t.TempDir()
extra := tempDirOutsideDefaultTemp(t)
extra := t.TempDir()
scope, err := NewScope(workspace, []string{extra})
if err != nil {
t.Fatalf("NewScope: %v", err)
@@ -702,7 +685,7 @@ func promptWorkspaceWritePolicy() Policy {
func TestEvaluateAllowsWritesInsideExtraScopeRoot(t *testing.T) {
workspace := t.TempDir()
extra := tempDirOutsideDefaultTemp(t)
extra := t.TempDir()
scope, err := NewScope(workspace, []string{extra})
if err != nil {
t.Fatalf("NewScope: %v", err)
@@ -730,7 +713,7 @@ func TestEvaluateAllowsWritesInsideExtraScopeRoot(t *testing.T) {
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionAllow,
Args: map[string]any{"path": outsideDefaultTempPath(workspace, "escape.txt")},
Args: map[string]any{"path": filepath.Join(t.TempDir(), "escape.txt")},
})
if outside.Action != ActionPrompt || outside.Block == nil || !outside.Block.Recoverable {
t.Fatalf("outside write Action=%q, want recoverable prompt with block", outside.Action)
@@ -747,7 +730,7 @@ func TestEvaluateAllowsWritesInsideExtraScopeRoot(t *testing.T) {
// skip, turning the engine into an escape hatch.
func TestNewEngineDerivesWorkspaceRootFromScope(t *testing.T) {
workspace := t.TempDir()
extra := tempDirOutsideDefaultTemp(t)
extra := t.TempDir()
scope, err := NewScope(workspace, []string{extra})
if err != nil {
t.Fatalf("NewScope: %v", err)
@@ -764,7 +747,7 @@ func TestNewEngineDerivesWorkspaceRootFromScope(t *testing.T) {
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionAllow,
Args: map[string]any{"path": outsideDefaultTempPath(workspace, "escape.txt")},
Args: map[string]any{"path": filepath.Join(t.TempDir(), "escape.txt")},
})
if outside.Action != ActionPrompt || outside.Block == nil {
t.Fatalf("scope-only engine, out-of-scope write Action=%q, want prompt with block", outside.Action)
@@ -781,31 +764,3 @@ func TestNewEngineDerivesWorkspaceRootFromScope(t *testing.T) {
t.Fatalf("scope-only engine, in-workspace write Action=%q (%s), want allow", inside.Action, inside.Reason)
}
}
func TestEvaluateAllowsWritesInsideDefaultTempRoot(t *testing.T) {
workspace := t.TempDir()
tempRoot := t.TempDir()
t.Setenv("TMPDIR", tempRoot)
scope, err := NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
engine := NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Scope: scope,
})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionAllow,
Args: map[string]any{"path": filepath.Join(tempRoot, "zero-toolcheck", "go.mod")},
})
if decision.Action != ActionAllow {
t.Fatalf("temp-root write Action=%q (%s), want allow", decision.Action, decision.Reason)
}
if HasRiskCategory(decision.Risk, "out_of_workspace") {
t.Fatalf("temp-root write risk=%v, must not be out_of_workspace", decision.Risk)
}
}
-69
View File
@@ -41,11 +41,6 @@ var scopeArgKeys = []struct {
// no scoped argument is present, the value is not a string, or the value points
// at the workspace root (".") -- in those cases the grant is plainly tool-wide.
func DeriveScope(toolName string, args map[string]any) (string, ScopeKind) {
if toolName == "browser_open" {
if host, ok := deriveBrowserHostScope(args["url"]); ok {
return host, ScopeHost
}
}
if toolName == "web_fetch" {
if host, ok := deriveHostScope(args["url"]); ok {
return host, ScopeHost
@@ -83,70 +78,6 @@ func deriveHostScope(value any) (string, bool) {
return host, host != ""
}
func deriveBrowserHostScope(value any) (string, bool) {
raw, ok := value.(string)
if !ok {
return "", false
}
trimmed := strings.TrimSpace(raw)
if trimmed != "" && !strings.Contains(trimmed, "://") {
if !browserURLLooksLikeBareHost(trimmed) {
return "", false
}
trimmed = "https://" + trimmed
}
return deriveHostScope(trimmed)
}
func browserURLLooksLikeBareHost(raw string) bool {
if raw == "" || strings.HasPrefix(raw, "//") {
return false
}
authority := raw
if cut := strings.IndexAny(authority, "/?#"); cut >= 0 {
authority = authority[:cut]
}
if authority == "" || strings.Contains(authority, "@") {
return false
}
host := authority
if strings.HasPrefix(authority, "[") {
end := strings.Index(authority, "]")
if end <= 1 {
return false
}
host = authority[1:end]
if rest := authority[end+1:]; rest != "" {
if !strings.HasPrefix(rest, ":") || !isDecimalPort(rest[1:]) {
return false
}
}
} else if strings.Contains(authority, ":") {
before, after, _ := strings.Cut(authority, ":")
if before == "" || !isDecimalPort(after) {
return false
}
host = before
}
host = strings.TrimSpace(host)
if host == "" {
return false
}
return host == "localhost" || strings.Contains(host, ".") || net.ParseIP(host) != nil
}
func isDecimalPort(raw string) bool {
if raw == "" {
return false
}
for _, r := range raw {
if r < '0' || r > '9' {
return false
}
}
return true
}
// resolveScopeAbs converts a raw scope to an absolute, cleaned path. A relative
// scope is anchored to workspaceRoot; when workspaceRoot is empty it falls back
// to filepath.Abs (process working directory). An empty scope resolves to "".
-5
View File
@@ -140,11 +140,6 @@ func TestDeriveScope(t *testing.T) {
{name: "whitespace path is tool-wide", tool: "write_file", args: map[string]any{"path": " "}, wantRaw: "", wantKind: ScopeToolWide},
{name: "web fetch url host", tool: "web_fetch", args: map[string]any{"url": "https://Example.COM:443/path?q=1"}, wantRaw: "example.com", wantKind: ScopeHost},
{name: "web fetch invalid url is tool-wide", tool: "web_fetch", args: map[string]any{"url": "://bad"}, wantRaw: "", wantKind: ScopeToolWide},
{name: "browser open url host", tool: "browser_open", args: map[string]any{"url": "https://Example.COM/path?q=1"}, wantRaw: "example.com", wantKind: ScopeHost},
{name: "browser open bare host", tool: "browser_open", args: map[string]any{"url": "Example.COM/path"}, wantRaw: "example.com", wantKind: ScopeHost},
{name: "browser open loopback bare host with port", tool: "browser_open", args: map[string]any{"url": "localhost:8080/path"}, wantRaw: "localhost", wantKind: ScopeHost},
{name: "browser open about blank is tool-wide", tool: "browser_open", args: map[string]any{"url": "about:blank"}, wantRaw: "", wantKind: ScopeToolWide},
{name: "browser open javascript url is tool-wide", tool: "browser_open", args: map[string]any{"url": "javascript:alert(1)"}, wantRaw: "", wantKind: ScopeToolWide},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+2 -3
View File
@@ -96,9 +96,8 @@ func TestSeatbeltProfileAllowsUserToolAncestorMetadata(t *testing.T) {
}
}
// On macOS the sandbox environment preserves the caller env while still exposing
// the Homebrew bin dir so `python3`/`node` resolve to the user's install, not a
// system stub.
// On macOS the scrubbed sandbox environment must still expose the Homebrew bin
// dir so `python3`/`node` resolve to the user's install, not a system stub.
func TestSandboxEnvironmentExposesHomebrewOnMac(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("PATH augmentation is macOS-only")
+26 -79
View File
@@ -10,7 +10,6 @@ import (
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
)
@@ -159,17 +158,15 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
args := []string{
"--new-session",
"--die-with-parent",
"--unshare-user",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
}
args = append(args, linuxBwrapFilesystemArgs(config.PermissionProfile)...)
if pathExists(helperPath) {
args = append(args, "--ro-bind", helperPath, helperPath)
}
args = append(args,
"--unshare-user",
"--unshare-pid",
)
// Keep IPC and UTS shared for compatibility with the host CLI environment;
// network isolation is still applied below when the policy denies egress.
if shouldUnshareLinuxNetwork(config.PermissionProfile.Network) {
args = append(args, "--unshare-net")
}
@@ -177,7 +174,8 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
args = append(args, "--proc", "/proc")
}
args = append(args, "--chdir", commandCWD)
for _, env := range linuxHelperSandboxEnvironmentOverrides(config.PermissionProfile) {
args = append(args, "--clearenv")
for _, env := range linuxHelperSandboxEnvironment(config.PermissionProfile, commandCWD) {
key, value, ok := strings.Cut(env, "=")
if ok {
args = append(args, "--setenv", key, value)
@@ -191,10 +189,10 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
fs := profile.FileSystem
if fs.Kind == FileSystemUnrestricted {
// Disabled filesystem policy means no write jail: expose the host root
// read-write, including the host /dev tree, rather than synthesizing a
// restricted bubblewrap filesystem.
args := []string{"--bind", "/", "/"}
args := []string{"--ro-bind", "/", "/", "--dev", "/dev"}
if fs.AllowTemp {
args = append(args, "--tmpfs", "/tmp")
}
for _, root := range fs.WriteRoots {
if pathExists(root.Root) {
args = append(args, "--bind", root.Root, root.Root)
@@ -203,26 +201,21 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
return args
}
args := []string{}
if linuxProfileHasFullReadRoot(fs) {
args = append(args, "--ro-bind", "/", "/", "--dev", "/dev")
} else {
args = append(args, "--tmpfs", "/", "--dev", "/dev")
if fs.IncludePlatformRoots {
for _, root := range linuxPlatformReadRoots() {
args = append(args, "--ro-bind", root, root)
}
args := []string{"--tmpfs", "/", "--dev", "/dev"}
if fs.IncludePlatformRoots {
for _, root := range linuxPlatformReadRoots() {
args = append(args, "--ro-bind", root, root)
}
for _, root := range fs.ReadRoots {
if pathExists(root) {
args = append(args, "--ro-bind", root, root)
}
}
for _, root := range fs.ReadRoots {
if pathExists(root) {
args = append(args, "--ro-bind", root, root)
}
}
if fs.AllowTemp {
fs.WriteRoots = linuxWriteRootsWithTemp(fs)
args = append(args, "--tmpfs", "/tmp")
}
for _, root := range linuxSortedWriteRoots(fs.WriteRoots) {
for _, root := range fs.WriteRoots {
if !pathExists(root.Root) {
continue
}
@@ -243,52 +236,6 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
return args
}
func linuxWriteRootsWithTemp(fs FileSystemPolicy) []WritableRoot {
roots := append([]WritableRoot{}, fs.WriteRoots...)
for _, tempRoot := range defaultTempWriteRoots() {
found := false
for _, root := range roots {
if filepath.Clean(root.Root) == filepath.Clean(tempRoot) {
found = true
break
}
}
if !found {
roots = append(roots, WritableRoot{Root: tempRoot})
}
}
return roots
}
func linuxSortedWriteRoots(roots []WritableRoot) []WritableRoot {
sorted := append([]WritableRoot{}, roots...)
sort.SliceStable(sorted, func(i, j int) bool {
return pathDepth(sorted[i].Root) < pathDepth(sorted[j].Root)
})
return sorted
}
func pathDepth(path string) int {
cleaned := filepath.Clean(path)
if cleaned == "" || filepath.Dir(cleaned) == cleaned {
return 0
}
trimmed := strings.Trim(cleaned, string(filepath.Separator))
if trimmed == "" {
return 0
}
return strings.Count(trimmed, string(filepath.Separator)) + 1
}
func linuxProfileHasFullReadRoot(fs FileSystemPolicy) bool {
for _, root := range fs.ReadRoots {
if filepath.Clean(root) == string(filepath.Separator) {
return true
}
}
return false
}
func linuxPlatformReadRoots() []string {
candidates := []string{"/bin", "/sbin", "/usr", "/etc", "/lib", "/lib64", "/nix/store", "/run/current-system/sw"}
roots := make([]string, 0, len(candidates))
@@ -326,16 +273,16 @@ func shouldUnshareLinuxNetwork(policy NetworkPolicy) bool {
return NormalizeNetworkMode(policy.Mode) == NetworkDeny
}
func linuxHelperSandboxEnvironment(profile PermissionProfile, base []string) []string {
return upsertEnvList(base, linuxHelperSandboxEnvironmentOverrides(profile)...)
}
func linuxHelperSandboxEnvironmentOverrides(profile PermissionProfile) []string {
return []string{
func linuxHelperSandboxEnvironment(profile PermissionProfile, home string) []string {
env := []string{
"HOME=" + home,
"PATH=" + firstEnv("PATH", defaultPath()),
"TERM=" + firstEnv("TERM", "dumb"),
EnvSandboxBackend + "=" + string(linuxSandboxBackendEnv),
"ZERO_SANDBOX_NETWORK=" + string(profile.Network.Mode),
EnvSandboxed + "=1",
}
return env
}
func pathExists(path string) bool {
+2 -13
View File
@@ -10,11 +10,6 @@ import (
"syscall"
)
var (
applyUnixSocketBlockFilter = ApplyUnixSocketBlock
applyLinuxNetworkDenyFilter = ApplyLinuxNetworkDeny
)
func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
config, err := ParseLinuxSandboxHelperArgs(args)
if err != nil {
@@ -69,14 +64,8 @@ func runLinuxSandboxInnerStage(config LinuxSandboxHelperConfig, stderr io.Writer
fmt.Fprintln(stderr, LinuxSandboxHelperName+": inner seccomp stage is incompatible with Landlock mode")
return 2
}
if shouldUnshareLinuxNetwork(config.PermissionProfile.Network) {
if err := applyLinuxNetworkDenyFilter(); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": apply network deny: "+err.Error())
return 125
}
}
if config.BlockUnixSockets {
if err := applyUnixSocketBlockFilter(); err != nil {
if err := ApplyUnixSocketBlock(); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": warning: "+err.Error()+"; running without the Unix-socket filter")
}
}
@@ -102,7 +91,7 @@ func runLinuxSandboxLandlockStage(config LinuxSandboxHelperConfig, stderr io.Wri
fmt.Fprintln(stderr, LinuxSandboxHelperName+": "+err.Error())
return 127
}
if err := syscall.Exec(binary, config.Command, linuxHelperSandboxEnvironment(config.PermissionProfile, os.Environ())); err != nil {
if err := syscall.Exec(binary, config.Command, linuxHelperSandboxEnvironment(config.PermissionProfile, config.CommandCWD)); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": exec command: "+err.Error())
return 126
}
@@ -1,67 +0,0 @@
//go:build linux
package sandbox
import (
"bytes"
"errors"
"testing"
)
func TestLinuxSandboxInnerStageAppliesNetworkDenySeccomp(t *testing.T) {
originalNetworkDeny := applyLinuxNetworkDenyFilter
originalUnixBlock := applyUnixSocketBlockFilter
t.Cleanup(func() {
applyLinuxNetworkDenyFilter = originalNetworkDeny
applyUnixSocketBlockFilter = originalUnixBlock
})
networkDenyCalls := 0
unixBlockCalls := 0
applyLinuxNetworkDenyFilter = func() error {
networkDenyCalls++
return nil
}
applyUnixSocketBlockFilter = func() error {
unixBlockCalls++
return nil
}
var stderr bytes.Buffer
code := runLinuxSandboxInnerStage(LinuxSandboxHelperConfig{
PermissionProfile: PermissionProfile{Network: NetworkPolicy{Mode: NetworkDeny}},
BlockUnixSockets: true,
Command: []string{"definitely-not-a-real-zero-test-command"},
}, &stderr)
if code != 127 {
t.Fatalf("exit code = %d, want lookup failure 127 after filters; stderr=%s", code, stderr.String())
}
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 TestLinuxSandboxInnerStageSkipsNetworkDenySeccompWhenAllowed(t *testing.T) {
originalNetworkDeny := applyLinuxNetworkDenyFilter
t.Cleanup(func() {
applyLinuxNetworkDenyFilter = originalNetworkDeny
})
applyLinuxNetworkDenyFilter = func() error {
return errors.New("network deny filter should not run")
}
var stderr bytes.Buffer
code := runLinuxSandboxInnerStage(LinuxSandboxHelperConfig{
PermissionProfile: PermissionProfile{Network: NetworkPolicy{Mode: NetworkAllow}},
Command: []string{"definitely-not-a-real-zero-test-command"},
}, &stderr)
if code != 127 {
t.Fatalf("exit code = %d, want lookup failure 127 without network filter; stderr=%s", code, stderr.String())
}
}
-147
View File
@@ -5,7 +5,6 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
@@ -117,7 +116,6 @@ func TestBuildLinuxSandboxBwrapArgsWrapsInnerSeccompStage(t *testing.T) {
{"--unshare-user"},
{"--unshare-pid"},
{"--unshare-net"},
{"--ro-bind", "/", "/"},
{"--chdir", "/workspace"},
{"--setenv", EnvSandboxBackend, string(BackendLinuxBwrap)},
{"--ro-bind", helperPath, helperPath},
@@ -128,20 +126,6 @@ func TestBuildLinuxSandboxBwrapArgsWrapsInnerSeccompStage(t *testing.T) {
} {
assertArgsContainSequence(t, bwrapArgs, want...)
}
if argsContainSequence(bwrapArgs, "--tmpfs", "/") {
t.Fatalf("default workspace-write profile must not start from an empty root: %#v", bwrapArgs)
}
if argsContainSequence(bwrapArgs, "--tmpfs", "/tmp") {
t.Fatalf("default workspace-write profile must not replace host /tmp: %#v", bwrapArgs)
}
if stringSliceContains(bwrapArgs, "--clearenv") {
t.Fatalf("Linux bwrap args must preserve caller environment like upstream: %#v", bwrapArgs)
}
for _, unwanted := range []string{"--unshare-ipc", "--unshare-uts"} {
if stringSliceContains(bwrapArgs, unwanted) {
t.Fatalf("Linux bwrap args should match upstream namespace set; found %s in %#v", unwanted, bwrapArgs)
}
}
}
func TestBuildLinuxSandboxBwrapArgsKeepsHostNetworkWhenAllowed(t *testing.T) {
@@ -177,114 +161,6 @@ func TestBuildLinuxSandboxBwrapArgsKeepsHostNetworkWhenAllowed(t *testing.T) {
assertArgsContainSequence(t, bwrapArgs, "--setenv", "ZERO_SANDBOX_NETWORK", string(NetworkAllow))
}
func TestLinuxBwrapRootReadUsesReadOnlyHostRoot(t *testing.T) {
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{Root: "/workspace"}},
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkAllow},
}
args := linuxBwrapFilesystemArgs(profile)
assertArgsContainSequence(t, args, "--ro-bind", "/", "/")
if argsContainSequence(args, "--tmpfs", "/") {
t.Fatalf("root-read profile must not start from an empty root: %#v", args)
}
}
func TestLinuxBwrapTempUsesHostWriteRoots(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Linux bwrap temp root assertions use Unix paths")
}
tmpdir := t.TempDir()
t.Setenv("TMPDIR", tmpdir)
workspace := filepath.Join(tmpdir, "workspace")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("MkdirAll workspace: %v", err)
}
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{Root: workspace, ProtectedMetadataNames: []string{".git"}}},
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkAllow},
}
args := linuxBwrapFilesystemArgs(profile)
if argsContainSequence(args, "--tmpfs", "/tmp") {
t.Fatalf("workspace-write temp access must bind host /tmp, not create private tmpfs: %#v", args)
}
for _, tempRoot := range defaultTempWriteRoots() {
if pathExists(tempRoot) {
assertArgsContainSequence(t, args, "--bind", tempRoot, tempRoot)
}
}
assertArgsContainSequence(t, args, "--bind", workspace, workspace)
if runtime.GOOS == "linux" {
tmpdirBind := argsSequenceIndex(args, "--bind", tmpdir, tmpdir)
workspaceBind := argsSequenceIndex(args, "--bind", workspace, workspace)
if tmpdirBind < 0 || workspaceBind < 0 || tmpdirBind > workspaceBind {
t.Fatalf("broader temp root must be bound before nested workspace root; args=%#v", args)
}
}
}
func TestLinuxBwrapUnrestrictedFilesystemUsesWritableHostRoot(t *testing.T) {
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemUnrestricted,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
args := linuxBwrapFilesystemArgs(profile)
assertArgsContainSequence(t, args, "--bind", "/", "/")
if argsContainSequence(args, "--ro-bind", "/", "/") {
t.Fatalf("unrestricted filesystem profile must not make host root read-only: %#v", args)
}
if argsContainSequence(args, "--tmpfs", "/tmp") {
t.Fatalf("unrestricted filesystem profile must not replace host /tmp: %#v", args)
}
if argsContainSequence(args, "--dev", "/dev") {
t.Fatalf("unrestricted filesystem profile must not replace host /dev: %#v", args)
}
}
func TestLinuxHelperSandboxEnvironmentPreservesCallerEnv(t *testing.T) {
env := linuxHelperSandboxEnvironment(
PermissionProfile{Network: NetworkPolicy{Mode: NetworkDeny}},
[]string{
"PATH=/custom/bin",
"HOME=/home/user",
EnvSandboxed + "=0",
EnvSandboxBackend + "=other",
},
)
for _, want := range []string{
"PATH=/custom/bin",
"HOME=/home/user",
EnvSandboxed + "=1",
EnvSandboxBackend + "=" + string(BackendLinuxBwrap),
"ZERO_SANDBOX_NETWORK=deny",
} {
if !stringSliceContains(env, want) {
t.Fatalf("linux helper env = %#v, missing %q", env, want)
}
}
if stringSliceContains(env, EnvSandboxed+"=0") || stringSliceContains(env, EnvSandboxBackend+"=other") {
t.Fatalf("linux helper env did not replace stale sandbox markers: %#v", env)
}
}
func indexString(values []string, want string) int {
for index, value := range values {
if value == want {
@@ -293,26 +169,3 @@ func indexString(values []string, want string) int {
}
return -1
}
func argsContainSequence(args []string, sequence ...string) bool {
return argsSequenceIndex(args, sequence...) >= 0
}
func argsSequenceIndex(args []string, sequence ...string) int {
if len(sequence) == 0 {
return 0
}
for index := 0; index <= len(args)-len(sequence); index++ {
matched := true
for offset, want := range sequence {
if args[index+offset] != want {
matched = false
break
}
}
if matched {
return index
}
}
return -1
}
+4 -20
View File
@@ -76,17 +76,11 @@ type SandboxExecutionRequest struct {
}
func NewSandboxManager(options SandboxManagerOptions) SandboxManager {
backend := options.Backend
if backend.Name != "" && backend.Platform == "" {
backend.Platform = platformForBackendName(backend.Name)
}
goos := options.GOOS
if goos == "" {
goos = backend.Platform
}
if goos == "" {
goos = runtime.GOOS
}
backend := options.Backend
if backend.Name == "" {
backend = selectPlatformBackend(goos, options.LookupExecutable)
}
@@ -97,19 +91,6 @@ func NewSandboxManager(options SandboxManagerOptions) SandboxManager {
return SandboxManager{goos: goos, backend: backend}
}
func platformForBackendName(name BackendName) string {
switch name {
case BackendMacOSSeatbelt:
return "darwin"
case BackendLinuxBwrap, BackendLinuxLandlock, BackendWSL:
return "linux"
case BackendWindowsRestrictedToken, BackendWindowsElevated:
return "windows"
default:
return ""
}
}
func (manager SandboxManager) Backend() Backend {
return manager.backend
}
@@ -174,6 +155,9 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
}
if request.ValidateExecution && requiresPlatformSandbox && backend.SupportLevel() != BackendSupportNative {
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
}
// Windows: the OS sandbox needs a one-time elevated `zero sandbox setup` (it
// applies WFP network filters + workspace ACLs and writes a marker). Without
// it the command runner hard-fails — so on the default preference, DEGRADE to
+11 -78
View File
@@ -5,7 +5,6 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
@@ -29,17 +28,11 @@ func TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile(t *testing.T) {
if profile.FileSystem.Kind != FileSystemRestricted {
t.Fatalf("filesystem kind = %q, want restricted", profile.FileSystem.Kind)
}
roots := scope.Roots()
if len(profile.FileSystem.WriteRoots) != len(roots) {
t.Fatalf("write roots = %#v, want scope roots %#v", profile.FileSystem.WriteRoots, roots)
if len(profile.FileSystem.WriteRoots) != 2 {
t.Fatalf("write roots = %#v, want workspace + extra", profile.FileSystem.WriteRoots)
}
for i, root := range roots {
if profile.FileSystem.WriteRoots[i].Root != root {
t.Fatalf("write roots = %#v, want scope roots %#v", profile.FileSystem.WriteRoots, roots)
}
}
if !stringSliceContains(profile.FileSystem.ReadRoots, profileRootPath()) {
t.Fatalf("read roots = %#v, want full read root %q", profile.FileSystem.ReadRoots, profileRootPath())
if profile.FileSystem.WriteRoots[0].Root != scope.Roots()[0] || profile.FileSystem.WriteRoots[1].Root != scope.Roots()[1] {
t.Fatalf("write roots = %#v, want scope roots %#v", profile.FileSystem.WriteRoots, scope.Roots())
}
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".git") || !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".zero") {
t.Fatalf("protected metadata names = %#v, want workspace metadata protected", profile.FileSystem.WriteRoots[0].ProtectedMetadataNames)
@@ -55,43 +48,9 @@ func TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile(t *testing.T) {
}
}
func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T) {
tmpdir := t.TempDir()
if runtime.GOOS == "windows" {
t.Setenv("TEMP", tmpdir)
t.Setenv("TMP", tmpdir)
} else {
t.Setenv("TMPDIR", tmpdir)
}
workspace := t.TempDir()
profile := PermissionProfileFromPolicy(workspace, DefaultPolicy(), nil)
if !writeRootsContain(profile.FileSystem.WriteRoots, workspace) {
t.Fatalf("write roots = %#v, want workspace %q", profile.FileSystem.WriteRoots, workspace)
}
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
}
if pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
}
}
func writeRootsContain(roots []WritableRoot, want string) bool {
want = normalizeProfilePath(want)
for _, root := range roots {
if normalizeProfilePath(root.Root) == want {
return true
}
}
return false
}
func TestUnknownNetworkModeFailsClosed(t *testing.T) {
for _, mode := range []NetworkMode{"scoped", "proxy"} {
if got := NormalizeNetworkMode(mode); got != NetworkDeny {
t.Fatalf("NormalizeNetworkMode(%s) = %q, want %q", mode, got, NetworkDeny)
}
if got := NormalizeNetworkMode(NetworkMode("scoped")); got != NetworkDeny {
t.Fatalf("NormalizeNetworkMode(scoped) = %q, want %q", got, NetworkDeny)
}
profile := PermissionProfileFromPolicy(t.TempDir(), Policy{
Mode: ModeEnforce,
@@ -216,11 +175,11 @@ func TestSandboxManagerBuildsCommandPlanThroughWindowsRunner(t *testing.T) {
}
}
func TestSandboxManagerDegradesUnavailableCommandPlan(t *testing.T) {
func TestSandboxManagerRejectsUnavailableCommandPlan(t *testing.T) {
policy := DefaultPolicy()
backend := Backend{Name: BackendUnavailable, Platform: "windows", Fallback: true, Message: "native sandbox unavailable"}
manager := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend})
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
_, err := manager.BuildCommandPlan(SandboxManagerRequest{
WorkspaceRoot: `C:\workspace`,
Command: CommandSpec{Name: "cmd.exe", Args: []string{"/c", "dir"}, Dir: `C:\workspace`},
Policy: policy,
@@ -228,11 +187,8 @@ func TestSandboxManagerDegradesUnavailableCommandPlan(t *testing.T) {
Preference: SandboxPreferenceAuto,
ValidateExecution: true,
})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
if plan.Wrapped || plan.EnforcementLevel != EnforcementDegraded || plan.DowngradeReason != "native sandbox unavailable" {
t.Fatalf("plan = %#v, want degraded direct plan", plan)
if !errors.Is(err, errNativeSandboxUnavailable) {
t.Fatalf("BuildCommandPlan error = %v, want native sandbox unavailable", err)
}
}
@@ -280,29 +236,6 @@ func TestSandboxManagerSelectsPlatformBackend(t *testing.T) {
}
}
func TestSandboxManagerInfersPlatformFromExplicitBackend(t *testing.T) {
tests := []struct {
name string
backend BackendName
wantGOOS string
}{
{name: "linux helper", backend: BackendLinuxBwrap, wantGOOS: "linux"},
{name: "macos seatbelt", backend: BackendMacOSSeatbelt, wantGOOS: "darwin"},
{name: "windows runner", backend: BackendWindowsRestrictedToken, wantGOOS: "windows"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
manager := NewSandboxManager(SandboxManagerOptions{
Backend: Backend{Name: test.backend, Available: true, Executable: "sandbox-helper"},
})
if manager.goos != test.wantGOOS || manager.backend.Platform != test.wantGOOS {
t.Fatalf("manager = %#v, want platform/goos %q", manager, test.wantGOOS)
}
})
}
}
func TestSelectBackendDelegatesToSandboxManagerSelection(t *testing.T) {
backend := SelectBackend(BackendOptions{
GOOS: "linux",
@@ -344,7 +277,7 @@ func TestSandboxManagerFailsClosedWhenNativeRequiredAndUnavailable(t *testing.T)
Command: CommandSpec{Name: "cmd.exe", Dir: "/workspace"},
Policy: policy,
Profile: profile,
Preference: SandboxPreferenceRequire,
Preference: SandboxPreferenceAuto,
ValidateExecution: true,
})
if !errors.Is(err, errNativeSandboxUnavailable) {
+1 -1
View File
@@ -30,7 +30,7 @@ func NormalizePermission(value Permission) Permission {
func NormalizeSideEffect(value SideEffect) SideEffect {
normalized := SideEffect(strings.ToLower(strings.TrimSpace(string(value))))
switch normalized {
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectLocalControl, SideEffectLocalBrowser, SideEffectLocalDesktop, SideEffectLocalTerminal, SideEffectOutOfWorkspace, SideEffectNone:
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectOutOfWorkspace, SideEffectNone:
return normalized
default:
return SideEffectOutOfWorkspace
+4 -4
View File
@@ -84,10 +84,10 @@ func TestWritePrecedenceMatrix(t *testing.T) {
ws := resolvedTempDir(t)
mkdir(t, filepath.Join(ws, "src"))
wsSecret := mkdir(t, filepath.Join(ws, "secret"))
ext := tempDirOutsideDefaultTemp(t)
ext := resolvedTempDir(t)
mkdir(t, filepath.Join(ext, "build"))
extProtected := mkdir(t, filepath.Join(ext, "build", "protected"))
outside := outsideDefaultTempPath(ws, "outside")
outside := resolvedTempDir(t) // never allowed
scope, err := NewScope(ws, nil)
if err != nil {
@@ -348,7 +348,7 @@ func TestEvaluateAppliesReadDeny(t *testing.T) {
// but permitted once the path is on AllowWrite.
func TestEvaluateAppliesWriteAllow(t *testing.T) {
ws := resolvedTempDir(t)
ext := tempDirOutsideDefaultTemp(t)
ext := resolvedTempDir(t)
build := mkdir(t, filepath.Join(ext, "build"))
base := Policy{Mode: ModeEnforce, EnforceWorkspace: true}
@@ -381,7 +381,7 @@ func TestEvaluateAppliesWriteAllow(t *testing.T) {
// backend write binds (so a sandboxed shell can write there).
func TestWriteRootsReflectAllowWrite(t *testing.T) {
ws := resolvedTempDir(t)
ext := tempDirOutsideDefaultTemp(t)
ext := resolvedTempDir(t)
build := mkdir(t, filepath.Join(ext, "build"))
engine := NewEngine(EngineOptions{
+4 -33
View File
@@ -93,21 +93,14 @@ func permissionProfileRoots(workspaceRoot string, scope *Scope) []string {
if scope != nil {
return scope.Roots()
}
var roots []string
if root := normalizeProfilePath(workspaceRoot); root != "" {
roots = append(roots, root)
return []string{root}
}
roots = append(roots, defaultTempWriteRoots()...)
return dedupeStrings(roots)
return nil
}
func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scope, writeRoots []string) []string {
// Workspace-write follows the upstream sandbox model: full disk is readable,
// while writes are narrowed to workspace/extra roots below. This is a
// deliberate read-all/write-jail posture; callers that must hide secrets use
// DenyRead to carve them out.
readRoots := []string{profileRootPath()}
readRoots = append(readRoots, writeRoots...)
readRoots := append([]string{}, writeRoots...)
if scope != nil {
readRoots = dedupeStrings(append(readRoots, scope.ReadRoots()...))
} else if root := normalizeProfilePath(workspaceRoot); root != "" {
@@ -116,29 +109,7 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop
if extra := normalizeProfileDirs(policy.AllowRead); len(extra) > 0 {
readRoots = dedupeStrings(append(readRoots, extra...))
}
return dedupeStrings(readRoots)
}
// userGitConfigReadPaths returns the user's global git config FILES so a
// sandboxed git can read identity and config (user.name/email, aliases) instead
// of failing with "unable to access ~/.gitconfig". It is deliberately the config
// files only — not the ~/.config/git directory, which can hold an XDG credential
// store — so credentials and the rest of HOME stay unreadable. Granted at the
// macOS-seatbelt read rule (not the cross-platform PermissionProfile) so the
// HOME-dependent paths don't leak into the platform-agnostic policy snapshot.
func userGitConfigReadPaths() []string {
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
return nil
}
return []string{
filepath.Join(home, ".gitconfig"),
filepath.Join(home, ".config", "git", "config"),
}
}
func profileRootPath() string {
return filepath.Clean(string(filepath.Separator))
return readRoots
}
func normalizeProfileDirs(entries []string) []string {
+1 -46
View File
@@ -1,9 +1,6 @@
package sandbox
import (
"strings"
"testing"
)
import "testing"
func TestIsAlreadySandboxed(t *testing.T) {
// A lone ZERO_SANDBOXED=1 (no corroborating backend marker) must NOT count as
@@ -50,48 +47,6 @@ func TestSandboxEnvironmentMarksSandboxed(t *testing.T) {
}
}
func TestSandboxEnvironmentPreservesCallerEnv(t *testing.T) {
env := sandboxEnvironmentForCommand(
[]string{
"PATH=/custom/bin",
"TERM=xterm-256color",
"HOME=/home/user",
EnvSandboxed + "=0",
EnvSandboxBackend + "=other",
},
DefaultPolicy(),
BackendMacOSSeatbelt,
)
for _, want := range []string{
"TERM=xterm-256color",
"HOME=/home/user",
EnvSandboxed + "=1",
EnvSandboxBackend + "=" + string(BackendMacOSSeatbelt),
"ZERO_SANDBOX_NETWORK=deny",
} {
if !stringSliceContains(env, want) {
t.Fatalf("sandbox env = %#v, missing %q", env, want)
}
}
path := envListValue(env, "PATH", "")
if !pathListContains(path, "/custom/bin") {
t.Fatalf("sandbox PATH = %q, want it to preserve caller path segment /custom/bin", path)
}
if stringSliceContains(env, EnvSandboxed+"=0") || stringSliceContains(env, EnvSandboxBackend+"=other") {
t.Fatalf("sandbox env did not replace stale sandbox markers: %#v", env)
}
}
func pathListContains(path string, want string) bool {
for _, entry := range strings.Split(path, ":") {
if entry == want {
return true
}
}
return false
}
func TestBuildCommandPlanWrapsWhenNotAlreadySandboxed(t *testing.T) {
// Ensure neither re-entrancy marker is set so we are NOT seen as sandboxed.
t.Setenv(EnvSandboxed, "")
+3 -14
View File
@@ -9,7 +9,7 @@ import (
func TestGrantRequestPermissionsReadDoesNotGrantWrite(t *testing.T) {
workspace := t.TempDir()
outside := tempDirOutsideDefaultTemp(t)
outside := t.TempDir()
target := filepath.Join(outside, "notes.txt")
scope, err := NewScope(workspace, nil)
if err != nil {
@@ -97,17 +97,6 @@ func TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn(t *testing.T) {
if after := engine.Evaluate(context.Background(), request); after.Action != ActionAllow {
t.Fatalf("network turn grant should allow shell network, got %#v", after)
}
plan, err := engine.BuildCommandPlan(CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", "python3 -m http.server 8080 --bind 127.0.0.1"},
Dir: workspace,
})
if err != nil {
t.Fatalf("BuildCommandPlan with network grant: %v", err)
}
if plan.Policy.Network != NetworkAllow || plan.PermissionProfile.Network.Mode != NetworkAllow {
t.Fatalf("network turn grant should build a network-allow command plan, got policy=%s profile=%s", plan.Policy.Network, plan.PermissionProfile.Network.Mode)
}
cleanup()
if afterCleanup := engine.Evaluate(context.Background(), request); afterCleanup.Action != ActionPrompt || afterCleanup.Reason != ReasonNetworkBlocked {
t.Fatalf("network turn grant should be cleaned up, got %#v", afterCleanup)
@@ -116,7 +105,7 @@ func TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn(t *testing.T) {
func TestGrantRequestPermissionsSessionPersists(t *testing.T) {
workspace := t.TempDir()
outside := tempDirOutsideDefaultTemp(t)
outside := t.TempDir()
target := filepath.Join(outside, "notes.txt")
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: DefaultPolicy()})
@@ -142,7 +131,7 @@ func TestGrantRequestPermissionsSessionPersists(t *testing.T) {
func TestPermissionProfileIncludesReadOnlyGrantAsReadRootOnly(t *testing.T) {
workspace := t.TempDir()
outside := tempDirOutsideDefaultTemp(t)
outside := t.TempDir()
scope, err := NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff. Show more