Compare commits

...
Author SHA1 Message Date
Vasanthdev2004 aa7ce57a39 test(tools): assert the stop intent killed the session
The retry loop added to survive a slow PowerShell teardown also made the
test unfalsifiable. The helper sleeps 5s, well inside the 30s deadline, so
a terminate that did nothing at all would let the sleep end on its own and
every existing assertion would still hold: the session reports exited, it
reports an exit code, and the interrupted flag echoes the request rather
than the outcome. Neutering KillProcessTree confirmed it, the test passed.

Assert instead that the helper's completion line never appears, which
distinguishes a killed session from a finished one without depending on
how long the loop took. Output is accumulated across every poll rather
than read off the last result, because each continuation returns only what
the session produced since the previous collection.
2026-07-28 11:50:46 +05:30
Vasanthdev2004 20032342e5 test(tools): stop internal/tools failing on stock Windows
This package fails on every run on a Windows box without PowerShell 7, which
is what a stock install is: PowerShell 7 is a separate download. CI never sees
any of it, because the runners have 7. Three distinct causes, all surfaced by
the move to PowerShell in #804.

The stop-intent test waited a fixed second for a session to die and asserted on
whatever was true by then. Under cmd.exe that was enough; under PowerShell the
tree is slower to stand up and tear down, and the FIRST stop in a fresh process
pays the interpreter's cold start, so it routinely returned with termination
under way but unfinished. The assertion read that as a failed stop. It now
re-sends the stop until the session reports it has exited. Repeating is safe,
since Terminate is a no-op against an already-dead tree, and it keeps the
interrupt flag that a poll with empty chars would drop.

Separately, and pre-existing, every test that starts a session hit a teardown
race: a terminated process does not release its handles the instant the session
reports exited, and Windows refuses to remove a directory with an open handle
where POSIX does not. t.TempDir removes once and fails the test on the sharing
violation, so ordinary teardown timing was reported as a broken test. Those
roots now clean up with a bounded retry, and give up quietly rather than
failing: the process is already terminated, so the worst case is a stale
directory under the OS temp root.

Last, the MSYS guard test asserted a windows_shell_syntax block using a command
containing `&&`. On PowerShell 5.1 the windows_powershell_version preflight
added by #804 fires first and shadows it. The `&&` was incidental to what that
case is about, so it is gone. This one is worth noting as a shape rather than a
typo: a new preflight silently changed which block an older assertion got, and
only on the shell CI does not run.

Verified by running the package repeatedly rather than once: previously failing
on every run, now passing 3 of 3, and the stop-intent test passing 8 of 8 alone
and 6 of 6 under CPU contention. Disabling the terminate path still fails it,
so the assertion continues to bind.
2026-07-27 19:14:02 +05:30
Anandan 81a5e6cb71 fix(sandbox): preserve user config and retry denied writes (#801)
* fix(sandbox): preserve user config and retry denied writes

Keep user identity and configuration paths discoverable while retaining managed cache and temporary storage. Classify native sandbox failures as typed recoverable denials so approved commands can retry outside the sandbox.

Add regression coverage for global Git ignores, typed denial handling, and permission retry behavior.

* fix(sandbox): tighten denial retry safeguards

* test(sandbox): assert denial contract

* fix(sandbox): trust typed denials and restore Cargo state

Stop promoting command output keywords into recoverable unrestricted denials, so only structured adapter facts can enter the approved unsandboxed retry path.

Restore the managed CARGO_HOME directory and environment override so Cargo remains writable while the caller's real home stays read-only.

* fix(sandbox): mask preserved credential configs

Preserving caller configuration made credential-bearing tool files discoverable under Linux's read-all sandbox profile. Add default and environment-overridden npm, GitHub CLI, netrc, Docker, Kubernetes, and Zero config paths to the credential deny set, with regression coverage.

Clarify the typed-denial retry and platform read-model comments without changing the approval behavior.

* fix(sandbox): protect custom gcloud config

CLOUDSDK_CONFIG can relocate gcloud credentials outside the XDG-derived default. Include the configured directory in the credential deny profile and cover the override with a regression test.

* fix(sandbox): deny zero credential directory

* test(sandbox): keep zero credential directory deny path expectations
2026-07-27 15:35:04 +05:30
Anandan 5d1869e7bc feat: add persistent session goals (#803)
* feat: add persistent session goals

* Fix goal run isolation and guards

* Strengthen loop goal isolation test

* Cover goal edit state transitions

* fix(goal): bound automatic continuations

Persist a provider-independent continuation limit, require approval for model-created goals, and keep resume, BTW, and loop runs from starting or charging unrelated goal work.

Also reject trailing lifecycle arguments and bound persisted objective length.

* fix(goal): resume continuations after btw
2026-07-27 13:22:09 +05:30
Anandan 518d2e53ba fix(windows): use PowerShell for command execution (#804)
* fix(windows): prefer PowerShell for command execution

Detect PowerShell 7 or Windows PowerShell before falling back to cmd.exe, and share the selected host shell across agent tools and the interactive shell escape.

Configure UTF-8 output, preserve restricted-sandbox MSYS safeguards, and add Windows coverage for pipelines, workspace writes, and interactive stdin sessions.

* test(windows): align shell coverage with PowerShell

Make Windows command fixtures and guidance assertions follow the selected host shell, preserve cmd fallback coverage, and use a genuine MSYS executable when PowerShell aliases are available.

Remove the unsupported non-TTY PowerShell stdin assertion; Windows interactive stdin requires a separate PTY transport.

* fix(sandbox): classify Windows executable suffixes

Normalize .exe, .cmd, .bat, and .com command names before safety and network lookup so PowerShell invocations such as curl.cmd receive the same network permission handling as curl.

Add analyzer and engine-level regressions for the Windows PowerShell command shape observed in CI.

* fix(sandbox): normalize absolute Windows command paths

Extract drive-letter and UNC basenames before removing shell escape characters so fully qualified Windows executables continue matching canonical safety and network classifications.

Cover both drive and UNC curl paths with analyzer regressions.

* fix(sandbox): normalize relative Windows paths

Recognize drive-relative, explicit relative, and suffixed backslash executable paths before generic shell escape removal so Windows commands continue to match canonical sandbox risk entries.

* fix(windows): harden PowerShell execution

* fix(sandbox): classify PowerShell removal aliases
2026-07-27 13:21:47 +05:30
Vasanth T d82b94defe test(cli): order the prewarm probe against the turn (#809)
* test(cli): order the prewarm probe against the turn

TestRunExecOptimizedSessionUnderGate intermittently sees zero prewarm HEAD
probes on the Windows runner. It polled for up to 5s after the run returned,
reasoning that the deadline exceeded the 3s prewarm timeout so a slow probe
could not flake it. That reasoning does not hold. The probe is issued on the
run's context and runExec cancels that context as it returns, because the stop
function signalContext hands back is a CancelFunc. Once the run is over the
probe is cancelled rather than slow, so polling cannot observe one that has not
already landed, at any deadline.

Against a mock server the turn finishes in microseconds, which leaves the probe
racing teardown. The turn now blocks until the probe arrives, giving it a
window while the run is still alive. The wait is bounded, so a probe that never
fires still fails the assertion with a useful message.

This widens the window rather than closing it: prewarmTimeout is armed inside
the probe goroutine, so holding the turn cannot extend it. The comment says so,
and names prewarmTimeout as the thing to look at if this ever recurs.

* test(cli): fail when the prewarm hold gives up

The bounded wait let the turn proceed after its guard expired, so a probe that
arrived late still left the count at 1 and the test passed. That proves the
probe was eventually received, not that it preceded the turn, which is the only
thing this test claims. Raised by CodeRabbit.

The expiry is now recorded and checked before the count, since the count cannot
tell the two apart.

Verified by suppressing the probe, which makes the guard expire: the assertion
fires with its own message rather than the count assertion, and the run takes
the full guard duration.
2026-07-27 12:12:34 +08:00
88fb7b6192 fix(sandbox): add platform-specific write lock around grant state file (#752) (#755)
* fix(sandbox): add platform-specific write lock around grant state file (#752)

Add grants_lock_unix.go and grants_lock_windows.go with a lockStateFile
method that serializes access to the persisted grant state across
processes. Wire the lock into Grant, GrantCommandPrefix, Revoke,
RevokePath, and Clear.

Also add .gitattributes to force LF line endings for Go files so
gofmt checks pass on Windows.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(sandbox): do not lock the grants file when there is no notice to consume

Addresses jatmn's #755 finding.

ConsumeMigrationNotice took the interprocess lock unconditionally, and
lockStateFile MkdirAlls the grants directory and creates
<grants>.lockfile. Every startup and every 'zero exec' calls this, so it
turned reading the grant state into requiring a WRITABLE grants
directory: a user with no grants file whose ZERO_SANDBOX_GRANTS_PATH (or
its parent config directory) sits on a read-only mount failed outright
with 'failed to migrate sandbox grants', where the pre-lock code read an
empty state and wrote nothing.

Peek at the state without the lock first and return early when no
migration write is required. Only when one is — a schema/policy
migration, or clearing NoticePending — take the lock and re-read under
it, since the unlocked peek is a snapshot another process may have
already consumed or migrated, and is meaningless by contract when
needsMigration is set.

Both regression tests are verified to fail against the pre-fix code.
The read-only-directory test skips on Windows (mode bits do not deny
writes) and as root (permissions are bypassed), so a portable companion
asserts the same requirement by requiring that nothing is created at
all.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-07-27 12:11:35 +08:00
PierrunoYTandClaude Sonnet 5 0125e70d0f Fix pinned Go quality tools on Go 1.26 (#689)
* fix pinned Go quality toolchain

* fix(make): use a POSIX GOTOOLCHAIN prefix for pinned tool targets

The Windows_NT branch used cmd.exe syntax (set "..." &&), but GNU Make
runs recipes through sh even on Windows (MSYS/Git Bash), where that
form fails and breaks make lint-static/deadcode/vulncheck. A plain
GOTOOLCHAIN=... prefix works everywhere, so the OS check is gone.

Also switch GO_VERSION/GO_TOOLCHAIN to recursive expansion so
'go list -m' only runs when a tool target actually needs it, instead
of at parse time for every target. Addresses PR review from CodeRabbit
and Copilot.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(build): make pinned-tool targets shell-neutral, fix README drift

Address code review on PR #689:

- lint-static/deadcode/vulncheck previously set GOTOOLCHAIN via a
  POSIX `VAR=value cmd` recipe prefix. GNU Make on Windows runs
  recipes through whatever SHELL is configured, and under a native
  cmd.exe setup that syntax is invalid — cmd.exe treats the whole
  "GOTOOLCHAIN=go1.26.5" token as the program to run and fails before
  go ever executes (reproduced locally: cmd.exe errors with
  "'MYVAR' is not recognized..." for the equivalent pattern). Switched
  to a target-specific `export`, which sets the variable in the child
  process environment via Make itself, before  is invoked —
  works under sh, cmd.exe, and PowerShell alike (verified against all
  three targets with SHELL := cmd.exe).

- README.md's Code Quality and Security Checks section still told
  contributors to run the raw `go run ...@version` commands this PR
  identifies as selecting the wrong (older) toolchain and failing to
  load this Go 1.26.5 module — only CI and AGENTS.md had been updated
  to the new make targets. Points steps 3-4 at `make lint-static` /
  `make vulncheck` instead, matching AGENTS.md.

Verified end-to-end: `make vulncheck`, `make deadcode`, and `make
lint-static` all run successfully and reach their tools (deadcode's
advisory report and lint-static's 35 pre-existing findings match the
PR's documented baseline).

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(build): support stock macOS make

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-27 12:09:37 +08:00
Vasanth T f5fa4ffc64 fix(config): stop billing provider-command fixtures to the 5s timeout (#810)
Both background-child tests fail intermittently on the Windows runner, and
have since #690. #800 and #802 each relaxed an assertion, which is why neither
held: the deadline itself was the problem.

The tests drove LoadProviderCommand, so the fixture's own startup was billed to
the production 5s budget. On a loaded runner the outer timer won the select in
runProviderCommand, and both symptoms follow from that. The nonzero-exit test
saw a timeout instead of the exit status it asserts, because the timer
preempted the shell's exit. The other test lost its PID file, because the
timer's Terminate killed the background child before it could record a PID,
putting the file permanently out of reach however long the assertion polled.

Neither test is a claim about the 5s budget. They assert which error each
cmd.Wait path yields and that the leftover descendant is killed, so they now
call runProviderCommand directly with a budget of their own and the production
deadline stops competing with process startup. Making the child outlive that
budget lets both drop their elapsed-versus-lifetime bounds as well, so no
wall-clock assertion is left in either test.

That is only worth doing if the two timeout paths can be told apart, and they
could not: the WaitDelay path and the outer-timer path both returned the same
bare sentinel, so asserting on it would have been satisfied by the very timeout
the tests exist to rule out. The WaitDelay path now wraps rather than replaces.
Callers asking errors.Is for the sentinel are unaffected, so no user-visible
message changes.

The Windows fixture also waited for its ready marker by launching a second
PowerShell. That cold start is pure overhead added after the PID is already
written, so the wait now runs in cmd itself and is bounded.

Verified by looping both tests under CPU contention: 7 failures in 16
iterations before, none in 32 after, with per-iteration time going from a
spread of 2.3s to 66s down to a steady 2.1s. Removing the wrap makes the
WaitDelay assertion fail, so it is load-bearing rather than decorative.
2026-07-27 10:00:32 +08:00
Felix-Ayush 9fbdc34155 feat(skills): report hash drift in skill info (#795)
Mirror plugin info: compare on-disk SKILL.md to the skills.lock hash so
edited installs are visible. Also drop the nonexistent skills lock
command from EXTENDING.md.
2026-07-27 10:00:07 +08:00
583e6530bd fix(lint): resolve static analysis baseline (#769)
* fix(lint): resolve static analysis baseline

* fix(lint): clear linux staticcheck findings

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-b2ee-7442-8a31-80b2cf9dd270

Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
2026-07-27 09:59:33 +08:00
Anandan ac50a5a840 test(config): wait for background PID file (#802)
* test(config): wait for background PID file

* test(config): make delayed PID check deterministic
2026-07-25 23:02:07 +05:30
95 changed files with 4204 additions and 431 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
*.go text eol=lf
*.mod text eol=lf
*.sum text eol=lf
+33 -12
View File
@@ -30,6 +30,36 @@ jobs:
go-version-file: go.mod
cache: true
- name: Install Make on Windows
if: matrix.os == 'windows-latest'
shell: pwsh
run: choco install make --no-progress -y
- name: Validate quality targets with stock macOS Make
if: matrix.os == 'macos-latest'
env:
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: grep.patternType
GIT_CONFIG_VALUE_0: fixed
run: make -n vulncheck
- name: Check vulnerabilities through native Windows Make
if: matrix.os == 'windows-latest'
shell: cmd
run: make vulncheck
- name: Check dead code through native Windows Make
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: cmd
run: make deadcode
- name: Run static lint through native Windows Make
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: cmd
run: make lint-static
- name: Check formatting
if: matrix.os == 'ubuntu-latest'
shell: bash
@@ -100,27 +130,18 @@ jobs:
go-version-file: go.mod
cache: true
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
# their own modules, which can drop below the version go.mod requires and
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
# so all three run under it.
- name: Pin toolchain from go.mod
run: |
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
echo "GOTOOLCHAIN=${toolchain:-auto}" >> "$GITHUB_ENV"
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
# published advisory on an unrelated PR — intentional: do not ship known vulns.
- name: govulncheck
run: go run golang.org/x/vuln/cmd/[email protected] ./...
run: make vulncheck
# Advisory: reports functions unreachable from any cmd/* main so dormant
# code is visible in CI. Non-blocking while the dormant subsystems are
# still being wired or removed.
- name: deadcode (advisory)
continue-on-error: true
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
run: make deadcode
# Advisory: catches what deadcode's whole-program reachability analysis
# doesn't, unused private functions/assignments reachable within a
@@ -130,4 +151,4 @@ jobs:
# the repo are cleaned up incrementally (see #527).
- name: golangci-lint (advisory)
continue-on-error: true
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
run: make lint-static
+2 -4
View File
@@ -51,10 +51,8 @@ request, or completing an implementation task:
or run focused tests with `-race`, when concurrency is affected.
4. **Build**: `go run ./cmd/zero-release build`.
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
6. **Advisory lint**:
`go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
7. **Security**:
`go run golang.org/x/vuln/cmd/[email protected] ./...`.
6. **Advisory lint**: `make lint-static`.
7. **Security**: `make vulncheck`.
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
tracked changes).
+26 -4
View File
@@ -1,7 +1,13 @@
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
# lint` before opening a PR" — these targets back those instructions.
# Zero build/test/lint targets. AGENTS.md says to build and run quality checks
# with `make` — these targets back those instructions.
.DEFAULT_GOAL := build
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean baseline help
GO_VERSION = $(word 2,$(shell git grep -G -h "^go[[:space:]]" -- go.mod))
GO_TOOLCHAIN = go$(GO_VERSION)
DEADCODE_VERSION := v0.46.0
GOLANGCI_LINT_VERSION := v2.12.2
GOVULNCHECK_VERSION := v1.3.0
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
# Build the main CLI binary into ./zero.
build:
@@ -33,6 +39,22 @@ fmt-check:
# Lint = formatting check + vet (no extra tooling required).
lint: fmt-check vet
# Versioned tools select the toolchain from their own modules when invoked with
# package@version. Read this module's go directive directly, without invoking
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
# The target-specific export is shell-independent.
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
lint-static:
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
deadcode:
go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -test=false ./...
vulncheck:
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
tidy:
go mod tidy
@@ -55,4 +77,4 @@ baseline: build
--output internal/perfbench/reports/baseline.json
help:
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline"
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
+8 -14
View File
@@ -367,22 +367,16 @@ variable unset and set.
### Code Quality and Security Checks
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
Before committing any changes, ensure all Go code quality and security checks pass. The `make` targets below pin each tool to this module's Go version, so they load correctly even when your default `go` toolchain is older — running the plain `go run ...@version` form yourself can select the tool module's own (older) toolchain and fail to load this module instead.
1. **Formatting**: Run `go fmt ./...` (or `make fmt`).
2. **Vetting**: Run `go vet ./...` (or `make vet`).
3. **Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
4. **Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
1. **Formatting**: Run `make fmt` (or `go fmt ./...`).
2. **Vetting**: Run `make vet` (or `go vet ./...`).
3. **Linting**: Run `make lint-static`.
4. **Vulnerability Scan**: Run `make vulncheck`.
If you prefer to install these tools globally on your path, you can run:
```bash
# Install golangci-lint
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
# Install govulncheck
go install golang.org/x/vuln/cmd/[email protected]
```
Use the repository-managed targets rather than globally installed binaries:
the targets apply the module's required Go toolchain as well as the reviewed,
pinned tool versions.
The installed binaries land in `$GOBIN` when it is set, otherwise in
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
+2 -2
View File
@@ -108,10 +108,10 @@ Discovery roots (earlier wins on name collisions):
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
```text
~/.local/share/zero/skills/ # primary (install/remove/lock target)
~/.local/share/zero/skills/ # primary (install/remove target)
run-benchmarks/
SKILL.md
write-changelog/
+9 -3
View File
@@ -1617,9 +1617,15 @@ func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, re
func sandboxDeniedShellResult(result tools.Result) bool {
if result.ExecutionOutcome != nil {
// Typed denials must be handled by their exact capability path. Never
// turn a structured narrow denial into the legacy unrestricted retry.
return false
// Only a typed platform denial that explicitly requests unrestricted
// recovery may enter the legacy retry path. Narrow capability denials
// remain on their exact capability path.
denial := result.ExecutionOutcome.Denial
return denial != nil &&
denial.Source == execution.DenialSourcePlatformSandbox &&
denial.Capability.Kind == execution.CapabilityUnrestricted &&
denial.Recoverable &&
denial.NextAction == execution.DenialNextActionRequestApproval
}
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
}
+51 -14
View File
@@ -64,6 +64,22 @@ func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
}
nonRecoverableDenial := protectedDenial
nonRecoverableDenial.ExecutionOutcome = &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
Source: execution.DenialSourcePlatformSandbox,
Reason: "sandbox enforcement failed closed",
Recoverable: false,
NextAction: execution.DenialNextActionRequestApproval,
},
}
if sandboxRestrictedShellRetryCandidate(call, nil, nonRecoverableDenial, options) {
t.Fatal("non-recoverable sandbox denial must not become an unrestricted retry")
}
networkDenial := protectedDenial
networkDenial.ExecutionOutcome = &execution.Outcome{
State: execution.StateDenied,
@@ -288,6 +304,18 @@ func (tool *sandboxDeniedRetryTool) Run(_ context.Context, args map[string]any)
return tools.Result{
Status: tools.StatusError,
Output: "touch: cannot touch '/home/user/.npm/cache': Read-only file system",
ExecutionOutcome: &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Exit: &execution.Exit{Code: 1},
Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
Source: execution.DenialSourcePlatformSandbox,
Reason: "sandbox blocked command execution",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
},
},
Meta: map[string]string{
"exit_code": "1",
tools.SandboxLikelyDeniedMeta: "true",
@@ -2404,7 +2432,11 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
root := t.TempDir()
command := "PATH=.:$PATH curl https://example.com"
if runtime.GOOS == "windows" {
command = "set PATH=.;%PATH% && curl https://example.com"
if windowsTestUsesPowerShell() {
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
} else {
command = "set PATH=.;%PATH% && curl https://example.com"
}
fakeCurl := filepath.Join(root, "curl.cmd")
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
t.Fatal(err)
@@ -2525,13 +2557,17 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
root := t.TempDir()
command := "echo rm -rf /"
if runtime.GOOS == "windows" && windowsTestUsesPowerShell() {
command = `Write-Output 'rm -rf /'`
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo rm -rf /"}`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":` + quoteJSONString(command) + `}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2627,6 +2663,12 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
}
}
func windowsTestUsesPowerShell() bool {
executable, _ := tools.HostShellCommand("")
name := strings.ToLower(filepath.Base(executable))
return strings.Contains(name, "powershell") || strings.HasPrefix(name, "pwsh")
}
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
for _, decision := range decisions {
if decision == want {
@@ -3160,22 +3202,17 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
}
wants := []string{"workdir/cwd", "MSYS binaries"}
if windowsTestUsesPowerShell() {
wants = append(wants, "PowerShell", "Get-ChildItem", "Select-String", "$env:NAME", "require_escalated")
} else {
wants = append(wants, "cmd.exe", "double quotes")
}
// The examples must themselves use the safe (double-quoted) form; a
// single-quoted example here would teach the model the exact syntax
// that fails under cmd.exe.
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
for _, want := range wants {
if !strings.Contains(prompt, want) {
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
t.Fatalf("expected selected Windows shell guidance to mention %q, got %q", want, prompt)
}
}
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
}
} else if !strings.Contains(prompt, "/bin/sh syntax") {
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
}
+1 -1
View File
@@ -91,7 +91,7 @@ func (c *profileController) maybeEscalate() (PostureEscalation, bool) {
if c.policy == nil || c.escalated {
return PostureEscalation{}, false
}
if !(c.failureTripped || c.riskTripped || c.scTripped || c.uncertainTrip) {
if !c.failureTripped && !c.riskTripped && !c.scTripped && !c.uncertainTrip {
return PostureEscalation{}, false
}
c.escalated = true
+2 -5
View File
@@ -11,6 +11,7 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspaceseed"
)
@@ -310,11 +311,7 @@ func workspaceContext(cwd string) string {
b.WriteString("<environment>\n")
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
if runtime.GOOS == "windows" {
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
} else {
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
}
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
if branch := gitBranchForPrompt(cwd); branch != "" {
b.WriteString("Git branch: " + branch + "\n")
}
+1 -3
View File
@@ -125,9 +125,7 @@ func Score(suite Suite, input ScoreInput) Report {
if len(task.RequiredTraceEvents) > 0 {
report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input))
}
for _, result := range unknownCommandResults(input.CommandResults, seenCommands, input) {
report.Results = append(report.Results, result)
}
report.Results = append(report.Results, unknownCommandResults(input.CommandResults, seenCommands, input)...)
report.finishSummary()
return report
}
+3 -3
View File
@@ -40,14 +40,14 @@ func FormatFacts(info repoinfo.Info) string {
b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n")
if info.PrimaryLanguage != "" {
b.WriteString(fmt.Sprintf("- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount))
fmt.Fprintf(&b, "- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount)
}
if langs := topLanguages(info.Languages, 5); langs != "" {
b.WriteString("- Languages: " + langs + "\n")
}
b.WriteString(fmt.Sprintf("- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth))
fmt.Fprintf(&b, "- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth)
if info.WorkspaceType != "" && info.WorkspaceType != "none" {
b.WriteString(fmt.Sprintf("- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount))
fmt.Fprintf(&b, "- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount)
}
if len(info.BuildTools) > 0 {
b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n")
+3 -6
View File
@@ -149,12 +149,9 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
// The forked child must be gone too (poll until reaped by init).
deadline := time.Now().Add(2 * time.Second)
for {
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
// wrongly pass the test, so treat it as still-present and keep polling.
if errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
break // child no longer exists
}
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
// wrongly pass the test, so treat it as still-present and keep polling.
for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
if time.Now().After(deadline) {
_ = syscall.Kill(childPID, syscall.SIGKILL)
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
+1 -3
View File
@@ -515,9 +515,7 @@ func agentEvalReportFromBenchmark(suite agenteval.Suite, report agenteval.Benchm
if task.Agent.Truncated {
converted.Truncated = true
}
for _, failure := range agentEvalFailuresFromTaskReport(task) {
converted.Failures = append(converted.Failures, failure)
}
converted.Failures = append(converted.Failures, agentEvalFailuresFromTaskReport(task)...)
}
return converted
}
+1
View File
@@ -139,6 +139,7 @@ func runSkillInfo(args []string, dir string, stdout io.Writer, stderr io.Writer)
}
if info.Hash != "" {
lines = append(lines, " hash: "+info.Hash)
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
}
lines = append(lines, " path: "+info.Skill.Path)
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
+42
View File
@@ -83,6 +83,48 @@ func TestRunSkillAddInfoRemove(t *testing.T) {
if !strings.Contains(info, src) || !strings.Contains(info, "sha256:") {
t.Fatalf("info missing source/hash:\n%s", info)
}
if !strings.Contains(info, "hash drift: false") {
t.Fatalf("info missing hash drift status:\n%s", info)
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy", "--json"}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("skill info --json exit = %d, stderr = %s", exit, stderr.String())
}
var beforeJSON map[string]any
if err := json.Unmarshal(stdout.Bytes(), &beforeJSON); err != nil {
t.Fatalf("parse skill info json: %v\n%s", err, stdout.String())
}
if beforeJSON["hashDrift"] != false {
t.Fatalf("hashDrift before edit = %#v, want false", beforeJSON["hashDrift"])
}
skillPath := filepath.Join(skillsDir, "confirmation-policy", "SKILL.md")
if err := os.WriteFile(skillPath, []byte("---\ndescription: edited\n---\nchanged\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy"}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("skill info after edit exit = %d, stderr = %s", exit, stderr.String())
}
if !strings.Contains(stdout.String(), "hash drift: true") {
t.Fatalf("info missing hash drift after edit:\n%s", stdout.String())
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy", "--json"}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("skill info --json after edit exit = %d, stderr = %s", exit, stderr.String())
}
var afterJSON map[string]any
if err := json.Unmarshal(stdout.Bytes(), &afterJSON); err != nil {
t.Fatalf("parse skill info json after edit: %v\n%s", err, stdout.String())
}
if afterJSON["hashDrift"] != true {
t.Fatalf("hashDrift after edit = %#v, want true", afterJSON["hashDrift"])
}
// remove deletes it
stdout.Reset()
+42 -7
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -24,12 +25,47 @@ func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
cwd := t.TempDir()
var heads, posts atomic.Int64
// Closed when the prewarm probe lands, so the turn can be held open until it
// does. See the POST handler for why that ordering has to be forced.
headSeen := make(chan struct{})
var headOnce sync.Once
var prewarmWaitTimedOut atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodHead:
heads.Add(1)
headOnce.Do(func() { close(headSeen) })
w.WriteHeader(http.StatusMethodNotAllowed)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"):
// Hold the turn open until the prewarm probe arrives. The probe is
// issued on the run's context, and runExec cancels that context the
// moment it returns, because the stop function signalContext hands
// back is a CancelFunc. A probe that has not reached the wire by the
// end of the run therefore never reaches it at all. That is why the
// poll this replaced could not work at any deadline: once the run is
// over the probe is cancelled, not merely slow.
//
// This widens the window, it does not close it. Once the goroutine is
// scheduled it still has only prewarmTimeout to dial loopback and
// write the request, and that timeout is armed inside the goroutine
// itself, so holding the turn here cannot extend it. If this test
// ever fails again, prewarmTimeout is where to look, not this hold.
//
// No self-deadlock: the pinned transport leaves MaxConnsPerHost at 0,
// so the HEAD dials its own connection instead of queueing behind
// this held POST. Do not set a per-host connection limit there.
//
// Bounded, so a probe that genuinely never fires fails the assertion
// below with a useful message instead of hanging the test.
select {
case <-headSeen:
case <-time.After(10 * time.Second):
// Record it. A probe that shows up after this gave up would still
// leave heads at 1, so the count alone proves the probe was
// eventually received, not that it was ordered before the turn,
// which is the whole claim of this test.
prewarmWaitTimedOut.Store(true)
}
posts.Add(1)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n"))
@@ -75,14 +111,13 @@ func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
if got := posts.Load(); got < 1 {
t.Fatalf("server saw %d chat-completions POSTs, want >= 1", got)
}
// The prewarm probe is asynchronous; it fires well before the model turn
// finishes, but poll rather than assuming scheduling order. The deadline
// exceeds the production prewarmTimeout (3s) so a slow probe cannot flake
// this assertion.
deadline := time.Now().Add(5 * time.Second)
for heads.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
// Checked before the count, because the count cannot tell the two apart: a
// probe that arrived only after the guard expired still leaves heads at 1.
if prewarmWaitTimedOut.Load() {
t.Fatal("the turn was not held until the prewarm probe arrived; the probe did not precede it")
}
// No polling: the turn above could not complete until the probe landed, so
// the count is settled by this point.
if got := heads.Load(); got != 1 {
t.Fatalf("server saw %d prewarm HEAD probes, want exactly 1", got)
}
-2
View File
@@ -734,14 +734,12 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
switch {
case legacyFound:
raw = legacyRaw
found = true
case config.IsDefaultMCPServer(name):
// A built-in default server isn't written to the file until the user
// overrides it. Treat it as present with an empty base so disabling it
// writes a minimal {"disabled":true} entry that merges over the default —
// letting `zero mcp disable <default>` work even though it lives in code.
raw = nil
found = true
default:
return false, false, nil
}
+3 -3
View File
@@ -83,10 +83,10 @@ func runProvidersDetect(args []string, stdout io.Writer, stderr io.Writer, deps
func parseProviderDetectArgs(args []string) (providerDetectOptions, bool, error) {
options := providerDetectOptions{}
for _, arg := range args {
switch {
case arg == "-h" || arg == "--help" || arg == "help":
switch arg {
case "-h", "--help", "help":
return options, true, nil
case arg == "--json":
case "--json":
options.json = true
default:
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
+9 -9
View File
@@ -244,19 +244,19 @@ func parseUsageArgs(args []string) (usageOptions, bool, error) {
func FormatReport(report usage.Report, insertions int, deletions int) string {
var builder strings.Builder
builder.WriteString("Usage report (cost is a reconstructed estimate)\n\n")
builder.WriteString(fmt.Sprintf("%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost"))
fmt.Fprintf(&builder, "%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost")
for _, bucket := range report.Buckets {
builder.WriteString(fmt.Sprintf("%-12s %10d %14s %14s\n",
bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost)))
fmt.Fprintf(&builder, "%-12s %10d %14s %14s\n",
bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost))
}
builder.WriteString(fmt.Sprintf("\n%-12s %10d %14s %14s\n",
"total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost)))
fmt.Fprintf(&builder, "\n%-12s %10d %14s %14s\n",
"total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost))
builder.WriteString(fmt.Sprintf("\nnet LOC (estimate): +%d / -%d = %d\n",
insertions, deletions, report.NetLOC))
fmt.Fprintf(&builder, "\nnet LOC (estimate): +%d / -%d = %d\n",
insertions, deletions, report.NetLOC)
if report.NetLOCPositive {
builder.WriteString(fmt.Sprintf("tokens per net LOC: %.1f\n", report.TokensPerNetLOC))
builder.WriteString(fmt.Sprintf("est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC)))
fmt.Fprintf(&builder, "tokens per net LOC: %.1f\n", report.TokensPerNetLOC)
fmt.Fprintf(&builder, "est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC))
} else {
builder.WriteString("tokens per net LOC: n/a (net LOC <= 0)\n")
builder.WriteString("est. cost per net LOC: n/a (net LOC <= 0)\n")
+7 -1
View File
@@ -77,7 +77,13 @@ func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte,
proc.Terminate()
}
if errors.Is(err, exec.ErrWaitDelay) {
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
// Wrap rather than replace. A descendant holding the inherited pipes
// open and the command never finishing at all are different failures
// that both collapsed into the same bare sentinel, which left callers
// and tests unable to tell which one happened. Callers that only ask
// errors.Is(err, errProviderCommandTimeout) are unaffected, so the
// message LoadProviderCommand reports is unchanged.
return stdout.Bytes(), stderr.Bytes(), fmt.Errorf("%w: %w", errProviderCommandTimeout, err)
}
return stdout.Bytes(), stderr.Bytes(), err
case <-timer.C:
+126 -27
View File
@@ -3,6 +3,7 @@ package config
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
@@ -91,6 +92,29 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
assertProcessTerminatedIfStarted(t, pidFile)
}
// providerCommandTestBudget bounds the two background-child tests below, and is
// deliberately NOT providerCommandTimeout. Those tests assert which error each
// cmd.Wait path yields and that the leftover descendant is killed; neither is a
// claim about how fast Windows can start a process. Driving them through
// LoadProviderCommand billed the fixture's own startup to the production 5s
// budget, so on a loaded runner the outer timer won the select in
// runProviderCommand: the nonzero-exit test saw a timeout instead of the exit
// status it asserts, and the timer's Terminate killed the background child
// before it recorded its PID, putting the PID file permanently out of reach no
// matter how long the assertion polled for it.
//
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
// it, since both tests return about one WaitDelay after the fixture exits.
const providerCommandTestBudget = 60 * time.Second
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
// dying of old age is unobservable: any run where runProviderCommand has not
// returned yet trips the budget first. That is what lets both tests drop their
// old elapsed-versus-lifetime bounds. "Still alive when the call returned" is
// now true by construction, so a child found dead afterwards can only have been
// killed by proc.Terminate.
const backgroundChildLifetime = 120 * time.Second
// TestLoadProviderCommandTerminatesBackgroundChild covers a command that
// exits immediately but leaves a detached child holding the inherited
// stdout/stderr pipes open (e.g. `sleep 600 & exit`). cmd.Wait() only
@@ -98,24 +122,22 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
// leftover child instead of just returning and leaking it.
func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "bg.pid")
childLifetime := 10 * time.Second
command := writeCommand(t, commandScript{
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
BackgroundSleepSeconds: int(childLifetime / time.Second),
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
BackgroundPidFile: pidFile,
})
start := time.Now()
_, err := LoadProviderCommand(command)
elapsed := time.Since(start)
_, _, err := runProviderCommand(command, providerCommandTestBudget)
if err == nil {
t.Fatal("LoadProviderCommand() error = nil, want error from WaitDelay-bounded wait")
t.Fatal("runProviderCommand() error = nil, want error from WaitDelay-bounded wait")
}
if !strings.Contains(err.Error(), "timed out after 5s") {
t.Fatalf("error = %q, want timeout", err.Error())
}
if elapsed >= childLifetime {
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
// Name the exact path. The outer-timer path reports the bare sentinel, so
// matching only that, or the "timed out after 5s" text this test used to
// match, would also be satisfied by a slow runner taking precisely the path
// this test exists to rule out.
if !errors.Is(err, errProviderCommandTimeout) || !errors.Is(err, exec.ErrWaitDelay) {
t.Fatalf("error = %v, want errProviderCommandTimeout wrapping exec.ErrWaitDelay", err)
}
assertProcessTerminated(t, pidFile)
}
@@ -128,29 +150,56 @@ func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
// be terminated on that path.
func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "bg-fail.pid")
childLifetime := 10 * time.Second
command := writeCommand(t, commandScript{
Stderr: "boom",
ExitCode: 7,
BackgroundSleepSeconds: int(childLifetime / time.Second),
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
BackgroundPidFile: pidFile,
})
start := time.Now()
_, err := LoadProviderCommand(command)
elapsed := time.Since(start)
if err == nil {
t.Fatal("LoadProviderCommand() error = nil, want failure from nonzero exit")
_, _, err := runProviderCommand(command, providerCommandTestBudget)
// The shell's own exit status has to survive. A timeout here means the
// budget swallowed it, which is exactly what this test kept reporting on CI.
if errors.Is(err, errProviderCommandTimeout) {
t.Fatalf("error = %v, want the shell's exit status rather than a timeout", err)
}
if !strings.Contains(err.Error(), "exit status") {
t.Fatalf("error = %q, want exit status failure", err.Error())
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error = %v, want *exec.ExitError from the nonzero exit", err)
}
if elapsed >= childLifetime {
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
if exitErr.ExitCode() != 7 {
t.Fatalf("exit code = %d, want 7", exitErr.ExitCode())
}
assertProcessTerminated(t, pidFile)
}
func TestAssertProcessTerminatedWaitsForDelayedPIDFile(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "delayed.pid")
firstMissing := make(chan struct{})
writeDone := make(chan error, 1)
go func() {
<-firstMissing
tempFile := pidFile + ".tmp"
if err := os.WriteFile(tempFile, []byte("2147483647\n"), 0o600); err != nil {
writeDone <- err
return
}
writeDone <- os.Rename(tempFile, pidFile)
}()
missingObserved := false
assertProcessTerminatedAfterMissing(t, pidFile, func() {
missingObserved = true
close(firstMissing)
})
if !missingObserved {
t.Fatal("PID file existed before the missing-file polling path was exercised")
}
if err := <-writeDone; err != nil {
t.Fatalf("write delayed pid file: %v", err)
}
}
func assertProcessTerminatedIfStarted(t *testing.T, pidFile string) {
t.Helper()
@@ -167,16 +216,41 @@ func assertProcessTerminatedIfStarted(t *testing.T, pidFile string) {
func assertProcessTerminated(t *testing.T, pidFile string) {
t.Helper()
data, err := os.ReadFile(pidFile)
assertProcessTerminatedAfterMissing(t, pidFile, nil)
}
func assertProcessTerminatedAfterMissing(t *testing.T, pidFile string, onMissing func()) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
var data []byte
var err error
missingObserved := false
for time.Now().Before(deadline) {
data, err = os.ReadFile(pidFile)
if err == nil {
break
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("read sleeper pid file: %v", err)
}
if !missingObserved {
missingObserved = true
if onMissing != nil {
onMissing()
}
}
time.Sleep(10 * time.Millisecond)
}
if err != nil {
t.Fatalf("read sleeper pid file: %v", err)
t.Fatalf("read sleeper pid file before timeout: %v", err)
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
t.Fatalf("parse sleeper pid %q: %v", data, err)
}
deadline := time.Now().Add(3 * time.Second)
deadline = time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if !processAlive(pid) {
return
@@ -253,8 +327,33 @@ func writeCommand(t *testing.T, script commandScript) string {
"Set-Content -LiteralPath '" + psSingleQuote(readyFile) + "' -Value ready -Encoding Ascii; " +
"Start-Sleep -Seconds " + itoa(script.BackgroundSleepSeconds)
lines = append(lines, "start /B powershell -NoProfile -Command \""+bgSleep+"\"")
waitForReady := "while (-not (Test-Path -LiteralPath '" + psSingleQuote(readyFile) + "')) { Start-Sleep -Milliseconds 10 }"
lines = append(lines, "powershell -NoProfile -Command \""+waitForReady+"\"")
// Wait for the child to record its PID before exiting, so the PID is
// on disk while the tree is still alive: runProviderCommand kills the
// tree one WaitDelay after the script exits, and a child that has not
// written by then never will.
//
// This waits in cmd itself. Spawning a second PowerShell just to poll
// cost another interpreter cold start, and that start is pure overhead
// added AFTER the PID is already written. On a loaded runner it pushed
// the script's exit past the 5s provider-command timeout, so the
// nonzero-exit test saw a timeout instead of the exit status it
// asserts. The wait is also bounded now: giving up and letting the PID
// assertion fail is far more diagnosable than blocking until the
// timeout fires and reporting an unrelated error. Each iteration is
// about a second, since `ping -n 2` sleeps between its two echoes,
// so the ceiling is roughly 15s: far longer than any cold PowerShell
// start seen on a runner, and far shorter than
// providerCommandTestBudget, so giving up surfaces a legible
// PID-file failure rather than a timeout that hides the real cause.
lines = append(lines,
":zeroWaitReady",
"if exist \""+readyFile+"\" goto zeroReady",
"set /a ZERO_READY_TRIES+=1",
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
"ping -n 2 127.0.0.1 >nul",
"goto zeroWaitReady",
":zeroReady",
)
}
lines = append(lines, "exit /b "+itoa(script.ExitCode))
if err := os.WriteFile(path, []byte(strings.Join(lines, "\r\n")), 0o700); err != nil {
+1 -1
View File
@@ -87,7 +87,7 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig, canReenable bool
// server the user explicitly enabled. The only layer allowed to
// override an explicit higher-scope decision is the CLI override scope
// (canReenable=true).
if canReenable || !(baseDisabledSet && baseDisabled != next.Disabled) {
if canReenable || !baseDisabledSet || baseDisabled == next.Disabled {
base.Disabled = next.Disabled
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
}
default:
}
return compose(), fmt.Errorf("Deepgram stream error: %w", err)
return compose(), fmt.Errorf("Deepgram stream error: %w", err) //nolint:staticcheck // Preserve established user-facing error text.
}
if typ != websocket.MessageText {
continue
+2 -2
View File
@@ -48,7 +48,7 @@ func buildMinimalPDF(text string) []byte {
buf.WriteString("0 " + strconv.Itoa(len(offsets)+1) + "\n")
buf.WriteString("0000000000 65535 f \n")
for _, off := range offsets {
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\n")
buf.WriteString("startxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
@@ -277,7 +277,7 @@ func buildEmptyTextPDF() []byte {
buf.WriteString("0 " + strconv.Itoa(len(offsets)+1) + "\n")
buf.WriteString("0000000000 65535 f \n")
for _, off := range offsets {
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\n")
buf.WriteString("startxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
+3 -2
View File
@@ -1,6 +1,7 @@
package lsp
import (
"context"
"encoding/json"
"testing"
)
@@ -64,7 +65,7 @@ func TestDecodeSymbols(t *testing.T) {
func TestNavigateUnknownOp(t *testing.T) {
m := NewManager(t.TempDir())
_, _, ok, err := m.Navigate(nil, NavRequest{Op: "bogus", Path: "x.go"})
_, _, ok, err := m.Navigate(context.TODO(), NavRequest{Op: "bogus", Path: "x.go"})
if err == nil {
t.Fatal("expected an error for an unknown nav op")
}
@@ -76,7 +77,7 @@ func TestNavigateUnknownOp(t *testing.T) {
func TestNavigateUnsupportedExtensionDegrades(t *testing.T) {
// A file type with no configured server degrades to ok=false, no error.
m := NewManager(t.TempDir())
_, _, ok, err := m.Navigate(nil, NavRequest{Op: NavDefinition, Path: "notes.unknownext", Line: 1, Character: 1})
_, _, ok, err := m.Navigate(context.TODO(), NavRequest{Op: NavDefinition, Path: "notes.unknownext", Line: 1, Character: 1})
if err != nil {
t.Fatalf("unsupported extension should not error, got %v", err)
}
+4 -4
View File
@@ -391,7 +391,7 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
}
if addr, err := netip.ParseAddr(normalized); err == nil {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
return nil
@@ -407,7 +407,7 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
}
for _, addr := range addrs {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
}
@@ -529,7 +529,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex
return nil, err
}
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
return dialer.DialContext(ctx, network, address)
@@ -542,7 +542,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
}
for _, addr := range addrs {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
}
@@ -62,7 +62,7 @@ func TestDetectLocalRuntimesReportsReachableRuntime(t *testing.T) {
if !detected[0].Reachable {
t.Fatalf("runtime should be reachable: %#v", detected[0])
}
if detected[0].Models == nil || len(detected[0].Models) == 0 || detected[0].Models[0] != "llama3.1" {
if len(detected[0].Models) == 0 || detected[0].Models[0] != "llama3.1" {
t.Fatalf("expected discovered model list, got %#v", detected[0].Models)
}
}
+1 -1
View File
@@ -188,7 +188,7 @@ func writePromptLine(builder *strings.Builder, format string, args ...any) {
if builder.Len() > 0 {
builder.WriteByte('\n')
}
builder.WriteString(fmt.Sprintf(format, args...))
fmt.Fprintf(builder, format, args...)
}
func clampBudget(text string, budget int) string {
+1 -1
View File
@@ -225,7 +225,7 @@ func (backend Backend) SandboxEnvMarkers(policy Policy) []string {
if policy.Mode == ModeDisabled {
return nil
}
if !(backend.CommandWrapping && backend.Available) && backend.Name != BackendWSL {
if (!backend.CommandWrapping || !backend.Available) && backend.Name != BackendWSL {
return nil
}
name := backend.Name
+29 -2
View File
@@ -27,11 +27,22 @@ var destructivePrograms = map[string]bool{
"mkfs": true, "fdisk": true, "shred": true, "dd": true, "parted": true,
}
var powerShellRemoveItemPrograms = map[string]bool{
"remove-item": true,
"ri": true,
"rd": true,
"rmdir": true,
"del": true,
"erase": true,
"rm": true,
}
// networkPrograms are commands that perform network egress/ingress.
var networkPrograms = map[string]bool{
"curl": true, "wget": true, "ssh": true, "scp": true, "sftp": true,
"rsync": true, "nc": true, "ncat": true, "netcat": true, "telnet": true,
"ftp": true,
"ftp": true, "iwr": true, "irm": true, "invoke-webrequest": true,
"invoke-restmethod": true,
}
var localServerPrograms = map[string]bool{
@@ -172,7 +183,10 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
if commandUsesNetwork(prog, rest) {
result.Network = true
}
if destructivePrograms[prog] || (prog == "rm" && hasRecursiveForce(rest)) || (prog == "find" && hasFindDelete(rest)) {
if destructivePrograms[prog] ||
(prog == "rm" && hasRecursiveForce(rest)) ||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
(prog == "find" && hasFindDelete(rest)) {
result.Destructive = true
}
return true
@@ -479,6 +493,19 @@ func hasRecursiveForce(args []*syntax.Word) bool {
return recursive && force
}
func hasPowerShellRecursiveForce(args []*syntax.Word) bool {
recursive, force := false, false
for _, arg := range args {
switch strings.ToLower(wordText(arg)) {
case "-recurse", "-r":
recursive = true
case "-force":
force = true
}
}
return recursive && force
}
func hasFindDelete(args []*syntax.Word) bool {
for _, arg := range args {
if wordText(arg) == "-delete" {
+21
View File
@@ -45,6 +45,27 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "sudo dynamic flag then rm -rf", script: `sudo "$maybe" rm -rf /tmp/x`, destructive: true},
{name: "curl", script: "curl https://example.com", network: true},
{name: "PowerShell iwr alias", script: "iwr https://example.com", network: true},
{name: "PowerShell irm alias", script: "irm https://example.com", network: true},
{name: "PowerShell Invoke-WebRequest", script: "Invoke-WebRequest https://example.com", network: true},
{name: "PowerShell Invoke-RestMethod", script: "Invoke-RestMethod https://example.com", network: true},
{name: "PowerShell Remove-Item recursive force", script: `Remove-Item -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rm recursive force", script: `rm -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ri recursive force", script: `ri -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rd recursive force", script: `rd -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rmdir recursive force", script: `rmdir -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell del recursive force", script: `del -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell erase recursive force", script: `erase -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ambiguous force abbreviation", script: `Remove-Item -Recurse -f 'C:\temp\x'`, destructive: false},
{name: "PowerShell Remove-Item without recurse", script: `Remove-Item -Force 'C:\temp\x'`, destructive: false},
{name: "Windows curl cmd", script: "curl.cmd https://example.com", network: true},
{name: "Windows curl exe", script: "curl.exe https://example.com", network: true},
{name: "Windows drive path curl exe", script: `'C:\tools\curl.exe' https://example.com`, network: true},
{name: "Windows UNC path curl exe", script: `'\\server\share\curl.exe' https://example.com`, network: true},
{name: "Windows dot relative path curl exe", script: `'.\curl.exe' https://example.com`, network: true},
{name: "Windows relative path curl exe", script: `'tools\curl.exe' https://example.com`, network: true},
{name: "Windows drive relative path curl exe", script: `'C:curl.exe' https://example.com`, network: true},
{name: "Windows npm cmd", script: "npm.cmd install", network: true},
{name: "wget piped to shell", script: "wget -qO- https://x.test | sh", network: true},
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
+1 -4
View File
@@ -153,10 +153,7 @@ func unsafeCommandPrefix(prefix []string) bool {
return true
}
}
if unsafeCommandPrefixLauncher(prefix[0]) {
return true
}
return false
return unsafeCommandPrefixLauncher(prefix[0])
}
func unsafeCommandPrefixPart(part string) bool {
+1 -1
View File
@@ -249,7 +249,7 @@ func (engine *Engine) shellSandboxActive(policy Policy) bool {
return false
}
backend := engine.backend
if !(backend.Available && backend.Executable != "" && backend.CommandWrapping && backend.NativeIsolation) {
if !backend.Available || backend.Executable == "" || !backend.CommandWrapping || !backend.NativeIsolation {
return false
}
// On Windows the command is only actually wrapped once `zero sandbox setup`
+19
View File
@@ -74,6 +74,25 @@ func TestEngineBashAllowGrantDoesNotBypassNetworkPrompt(t *testing.T) {
}
}
func TestEngineClassifiesPowerShellCurlCommandAsNetwork(t *testing.T) {
engine := NewEngine(EngineOptions{
WorkspaceRoot: t.TempDir(),
Policy: DefaultPolicy(),
})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{
"command": `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`,
},
})
if decision.Action != ActionPrompt || decision.Reason != ReasonNetworkBlocked || !HasRiskCategory(decision.Risk, "network") {
t.Fatalf("PowerShell curl command decision = %#v, want network prompt", decision)
}
}
func TestUnsandboxedExecutionAllowedPreservesDeniedReads(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
+155 -7
View File
@@ -16,7 +16,11 @@ import (
"github.com/Gitlawb/zero/internal/redaction"
)
const grantSchemaVersion = 3
const (
grantSchemaVersion = 3
grantLockTimeout = 5 * time.Second
grantLockRetry = 10 * time.Millisecond
)
type Grant struct {
ToolName string `json:"toolName"`
@@ -139,7 +143,40 @@ func (store *GrantStore) ConsumeMigrationNotice() (string, error) {
}
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
// Look for a pending notice WITHOUT taking the interprocess lock. Acquiring
// that lock creates the grants directory and opens <grants>.lockfile, so doing
// it unconditionally would make this call require a WRITABLE grants directory
// even when it has nothing to write. Every startup and every `zero exec` calls
// this, so a user with no grants file whose configured grants path (or its
// parent) sits on a read-only mount would fail outright with "failed to
// migrate sandbox grants", where reading alone reports an empty state and
// writes nothing.
state, needsMigration, err := store.readStateFile(false)
if err != nil {
return "", err
}
if !needsMigration && (state.Migration == nil || !state.Migration.NoticePending) {
return "", nil
}
// Past here a write is genuinely required — either a schema/policy migration,
// which only a lock holder may perform, or clearing NoticePending — so the
// lock is warranted and a read-only grants path legitimately fails (as it did
// before this lock existed, via readState's own migration path).
//
// Clearing the pending flag is a read-modify-write like any other mutator, so
// it takes the same interprocess lock: two frontends starting at once must not
// both read NoticePending, both print the notice, and clobber each other's
// write of the rest of the state.
unlock, err := store.lockStateFile()
if err != nil {
return "", err
}
defer unlock()
// Re-read under the lock. The unlocked peek above is only a snapshot: another
// process may have consumed the notice or migrated the file while this one
// waited, and when needsMigration was set that snapshot's contents are
// meaningless by readStateFile's contract.
state, err = store.readStateLocked()
if err != nil {
return "", err
}
@@ -161,7 +198,13 @@ func (store *GrantStore) Grant(input GrantInput) (Grant, error) {
}
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
unlock, err := store.lockStateFile()
if err != nil {
return Grant{}, err
}
defer unlock()
state, err := store.readStateLocked()
if err != nil {
return Grant{}, err
}
@@ -193,7 +236,13 @@ func (store *GrantStore) GrantCommandPrefix(input CommandPrefixInput) (CommandPr
}
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
unlock, err := store.lockStateFile()
if err != nil {
return CommandPrefixGrant{}, err
}
defer unlock()
state, err := store.readStateLocked()
if err != nil {
return CommandPrefixGrant{}, err
}
@@ -337,7 +386,13 @@ func (store *GrantStore) Revoke(toolName string) (int, error) {
}
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
unlock, err := store.lockStateFile()
if err != nil {
return 0, err
}
defer unlock()
state, err := store.readStateLocked()
if err != nil {
return 0, err
}
@@ -372,7 +427,13 @@ func (store *GrantStore) RevokePath(toolName string, scopePath string) (int, err
}
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
unlock, err := store.lockStateFile()
if err != nil {
return 0, err
}
defer unlock()
state, err := store.readStateLocked()
if err != nil {
return 0, err
}
@@ -407,7 +468,13 @@ func (store *GrantStore) RevokePath(toolName string, scopePath string) (int, err
func (store *GrantStore) Clear() (int, error) {
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
unlock, err := store.lockStateFile()
if err != nil {
return 0, err
}
defer unlock()
state, err := store.readStateLocked()
if err != nil {
return 0, err
}
@@ -537,7 +604,52 @@ func reconcileScope(scope string, kind ScopeKind) (string, ScopeKind) {
return scope, kind
}
// readState reads the grant file for a caller that does NOT hold the
// interprocess lock (the lookup/list paths). A schema or policy migration has to
// write — a backup plus the migrated state — so it is performed under the lock,
// and the read is retaken there because another process may have migrated the
// file while this one waited.
func (store *GrantStore) readState() (grantFile, error) {
state, needsMigration, err := store.readStateFile(false)
if err != nil || !needsMigration {
return state, err
}
unlock, err := store.lockStateFile()
if err != nil {
return grantFile{}, err
}
defer unlock()
return store.readStateLocked()
}
// readStateLocked reads the grant file for a caller that already holds the
// interprocess lock, so a migration writes through directly instead of
// re-acquiring it (which would deadlock against the caller's own lock).
func (store *GrantStore) readStateLocked() (grantFile, error) {
state, _, err := store.readStateFile(true)
return state, err
}
// readStateFile decodes the grant file. needsMigration reports that the state on
// disk must be rewritten (legacy schema or a changed policy version) and that the
// caller has to retake this read while holding the interprocess lock; the
// returned state is meaningless in that case.
func (store *GrantStore) readStateFile(locked bool) (grantFile, bool, error) {
state, err := store.decodeState(locked)
if err != nil {
if errors.Is(err, errGrantMigrationNeedsLock) {
return grantFile{}, true, nil
}
return grantFile{}, false, err
}
return state, false, nil
}
// errGrantMigrationNeedsLock is the internal signal from decodeState that the
// file needs a migration write, which only a lock holder may perform.
var errGrantMigrationNeedsLock = errors.New("sandbox grants file needs a migration write")
func (store *GrantStore) decodeState(locked bool) (grantFile, error) {
data, err := os.ReadFile(store.filePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
@@ -559,12 +671,18 @@ func (store *GrantStore) readState() (grantFile, error) {
return grantFile{}, store.invalidGrantFile(err)
}
if head.SchemaVersion == 1 || head.SchemaVersion == 2 {
if !locked {
return grantFile{}, errGrantMigrationNeedsLock
}
return store.migrateLegacyState(data, head.SchemaVersion, head.Grants, head.CommandPrefixes)
}
if head.SchemaVersion != grantSchemaVersion {
return grantFile{}, fmt.Errorf("invalid sandbox grants file at %s: unsupported schemaVersion", store.filePath)
}
if head.PolicyVersion != execution.PolicyVersion {
if !locked {
return grantFile{}, errGrantMigrationNeedsLock
}
return store.migrateChangedPolicy(data, head.PolicyVersion, head.Grants, head.CommandPrefixes)
}
buckets := map[string][]Grant{}
@@ -800,6 +918,36 @@ func (store *GrantStore) writeState(state grantFile) error {
return nil
}
func (store *GrantStore) lockStateFile() (func(), error) {
lockPath := store.filePath + ".lockfile"
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
return nil, err
}
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
deadline := time.Now().Add(grantLockTimeout)
for {
locked, err := tryLockGrantFile(file)
if err != nil {
_ = file.Close()
return nil, err
}
if locked {
return func() {
_ = unlockGrantFile(file)
_ = file.Close()
}, nil
}
if time.Now().After(deadline) {
_ = file.Close()
return nil, fmt.Errorf("timed out waiting for sandbox grants lock at %s", lockPath)
}
time.Sleep(grantLockRetry)
}
}
func normalizeStoredGrant(name string, grant Grant) (Grant, error) {
if strings.TrimSpace(grant.ToolName) == "" {
grant.ToolName = name
+24
View File
@@ -0,0 +1,24 @@
//go:build !windows
package sandbox
import (
"errors"
"os"
"syscall"
)
func tryLockGrantFile(file *os.File) (bool, error) {
err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err == nil {
return true, nil
}
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
return false, nil
}
return false, err
}
func unlockGrantFile(file *os.File) error {
return syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
}
+60
View File
@@ -0,0 +1,60 @@
//go:build windows
package sandbox
import (
"errors"
"os"
"syscall"
"unsafe"
)
const (
grantLockfileFailImmediately = 0x00000001
grantLockfileExclusiveLock = 0x00000002
grantErrorLockViolation = syscall.Errno(33)
grantErrorSharingViolation = syscall.Errno(32)
)
var (
grantKernel32 = syscall.NewLazyDLL("kernel32.dll")
grantProcLockFileEx = grantKernel32.NewProc("LockFileEx")
grantProcUnlockFileEx = grantKernel32.NewProc("UnlockFileEx")
)
func tryLockGrantFile(file *os.File) (bool, error) {
var overlapped syscall.Overlapped
result, _, err := grantProcLockFileEx.Call(
file.Fd(),
uintptr(grantLockfileExclusiveLock|grantLockfileFailImmediately),
0,
1,
0,
uintptr(unsafe.Pointer(&overlapped)),
)
if result != 0 {
return true, nil
}
if errors.Is(err, grantErrorLockViolation) || errors.Is(err, grantErrorSharingViolation) {
return false, nil
}
if err == syscall.Errno(0) {
return false, nil
}
return false, err
}
func unlockGrantFile(file *os.File) error {
var overlapped syscall.Overlapped
result, _, err := grantProcUnlockFileEx.Call(
file.Fd(),
0,
1,
0,
uintptr(unsafe.Pointer(&overlapped)),
)
if result != 0 || err == syscall.Errno(0) {
return nil
}
return err
}
+243
View File
@@ -1,10 +1,13 @@
package sandbox
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
@@ -70,6 +73,246 @@ func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
}
}
func TestGrantStoreSerializesWritesAcrossStores(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
storeA, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewGrantStore A returned error: %v", err)
}
storeB, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewGrantStore B returned error: %v", err)
}
unlock, err := storeA.lockStateFile()
if err != nil {
t.Fatalf("lockStateFile returned error: %v", err)
}
done := make(chan error, 1)
go func() {
_, err := storeB.Grant(GrantInput{ToolName: "write_file", Decision: GrantAllow})
done <- err
}()
select {
case err := <-done:
unlock()
t.Fatalf("Grant completed while another store held the file lock: %v", err)
case <-time.After(100 * time.Millisecond):
// Expected: storeB is waiting for storeA to release the lock.
}
unlock()
select {
case err := <-done:
if err != nil {
t.Fatalf("Grant returned error after file lock release: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Grant did not complete after file lock release")
}
}
// TestGrantStoreSerializesMigrationNoticeAcrossStores covers the other
// read-modify-write on the state file: clearing the pending flag. Two frontends
// starting at once must not both read it as pending and clobber each other.
func TestGrantStoreSerializesMigrationNoticeAcrossStores(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
if err := writeText(path, `{"schemaVersion":1,"grants":{"write_file":{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}}}`); err != nil {
t.Fatalf("write v1 grants: %v", err)
}
storeA, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewGrantStore A returned error: %v", err)
}
storeB, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewGrantStore B returned error: %v", err)
}
// Migrate first, so the notice is pending and the lock contention below is
// about consuming it rather than about the migration write.
if _, err := storeA.List(); err != nil {
t.Fatalf("List returned error: %v", err)
}
unlock, err := storeA.lockStateFile()
if err != nil {
t.Fatalf("lockStateFile returned error: %v", err)
}
done := make(chan error, 1)
go func() {
notice, err := storeB.ConsumeMigrationNotice()
if err == nil && notice == "" {
err = errors.New("expected the pending migration notice")
}
done <- err
}()
select {
case err := <-done:
unlock()
t.Fatalf("ConsumeMigrationNotice completed while another store held the file lock: %v", err)
case <-time.After(100 * time.Millisecond):
// Expected: storeB is waiting for storeA to release the lock.
}
unlock()
select {
case err := <-done:
if err != nil {
t.Fatalf("ConsumeMigrationNotice after lock release: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("ConsumeMigrationNotice did not complete after file lock release")
}
// The flag write landed, so the notice is not reported twice.
if again, err := storeA.ConsumeMigrationNotice(); err != nil || again != "" {
t.Fatalf("second ConsumeMigrationNotice = %q err=%v, want empty", again, err)
}
}
// TestConsumeMigrationNoticeWritesNothingWithoutAPendingNotice is the regression
// test for jatmn's #755 finding: ConsumeMigrationNotice took the interprocess
// lock unconditionally, and acquiring that lock MkdirAlls the grants directory
// and creates <grants>.lockfile. Every startup and every `zero exec` calls this,
// so it turned "read the grants state" into "require a writable grants
// directory" — a user with no grants file whose grants path sat on a read-only
// mount failed outright with "failed to migrate sandbox grants".
//
// Asserting that nothing is created is the portable form of that requirement: it
// holds identically on every OS and as any user, unlike a read-only-directory
// test (see the sibling test, which cannot run everywhere).
func TestConsumeMigrationNoticeWritesNothingWithoutAPendingNotice(t *testing.T) {
dir := filepath.Join(t.TempDir(), "config")
path := filepath.Join(dir, "sandbox-grants.json")
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewGrantStore returned error: %v", err)
}
notice, err := store.ConsumeMigrationNotice()
if err != nil {
t.Fatalf("ConsumeMigrationNotice with no grants file: %v", err)
}
if notice != "" {
t.Fatalf("notice = %q, want empty when nothing was migrated", notice)
}
// Nothing at all may be created: not the lock file, not the grants file, not
// even the directory that would hold them.
if _, err := os.Lstat(dir); !os.IsNotExist(err) {
entries, _ := os.ReadDir(dir)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
t.Fatalf("the grants directory was created (contents: %v); reading a state with no pending notice must not require a writable directory", names)
}
}
// TestConsumeMigrationNoticeToleratesAReadOnlyGrantsDirectory exercises the
// actual failure from the same finding, rather than only its portable proxy
// above: with the grants directory read-only and no grants file, the pre-fix
// code failed at MkdirAll/OpenFile for the lock file.
//
// It cannot run everywhere — Windows ignores the mode bits used here, and root
// bypasses directory permissions entirely — so it skips instead of pretending to
// cover those platforms.
func TestConsumeMigrationNoticeToleratesAReadOnlyGrantsDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix directory mode bits do not deny writes on Windows")
}
if os.Geteuid() == 0 {
t.Skip("root bypasses directory permissions, so a read-only directory cannot be simulated")
}
dir := t.TempDir()
path := filepath.Join(dir, "sandbox-grants.json")
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewGrantStore returned error: %v", err)
}
if err := os.Chmod(dir, 0o500); err != nil {
t.Skipf("cannot make the grants directory read-only: %v", err)
}
// Restore write permission so t.TempDir's cleanup can remove the directory.
t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
// Confirm the directory really is read-only here; otherwise this test would
// pass without exercising anything.
if probe, err := os.OpenFile(filepath.Join(dir, ".probe"), os.O_CREATE|os.O_RDWR, 0o600); err == nil {
_ = probe.Close()
t.Skip("this filesystem still allows writes to a 0500 directory")
}
notice, err := store.ConsumeMigrationNotice()
if err != nil {
t.Fatalf("ConsumeMigrationNotice on a read-only grants directory: %v", err)
}
if notice != "" {
t.Fatalf("notice = %q, want empty when nothing was migrated", notice)
}
}
// TestGrantStoreMigrationTakesTheFileLock covers the migrations that write
// through readState: a legacy file must not be rewritten (nor its backup written)
// while another process holds the lock, or two processes could both migrate and
// one would clobber the other's result.
func TestGrantStoreMigrationTakesTheFileLock(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":1,"grants":{"write_file":{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}}}`
if err := writeText(path, original); err != nil {
t.Fatalf("write v1 grants: %v", err)
}
storeA, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewGrantStore A returned error: %v", err)
}
storeB, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewGrantStore B returned error: %v", err)
}
unlock, err := storeA.lockStateFile()
if err != nil {
t.Fatalf("lockStateFile returned error: %v", err)
}
done := make(chan error, 1)
go func() {
_, err := storeB.List()
done <- err
}()
select {
case err := <-done:
unlock()
t.Fatalf("the migration ran while another store held the file lock: %v", err)
case <-time.After(100 * time.Millisecond):
if raw, err := os.ReadFile(path); err != nil {
unlock()
t.Fatalf("ReadFile grants: %v", err)
} else if string(raw) != original {
unlock()
t.Fatalf("the grant file was rewritten while the lock was held:\n%s", raw)
}
}
unlock()
select {
case err := <-done:
if err != nil {
t.Fatalf("List after lock release: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("the migration did not complete after file lock release")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile migrated grants: %v", err)
}
if !strings.Contains(string(raw), `"schemaVersion": 3`) {
t.Fatalf("grant file was not migrated after the lock was released:\n%s", raw)
}
}
func TestGrantStoreMigratesExactV1GrantAndReportsOnce(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
+1 -1
View File
@@ -38,7 +38,7 @@ func ApplyLandlockFilesystemProfile(profile PermissionProfile, cwd string) error
}
writeRoots := landlockWritableRoots(profile.FileSystem, cwd)
if len(writeRoots) == 0 {
return errors.New("Landlock requires at least one writable root")
return errors.New("Landlock requires at least one writable root") //nolint:staticcheck // Preserve the proper name and established user-facing text.
}
return installLandlockFilesystemRules(writeRoots)
}
+1 -1
View File
@@ -4,7 +4,7 @@ package sandbox
import "errors"
var ErrLandlockUnsupported = errors.New("Landlock is only supported on Linux")
var ErrLandlockUnsupported = errors.New("Landlock is only supported on Linux") //nolint:staticcheck // Preserve the exported sentinel's established text.
func ApplyLandlockFilesystemProfile(profile PermissionProfile, cwd string) error {
return ErrLandlockUnsupported
+92 -2
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
@@ -378,7 +379,31 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
home := t.TempDir()
awsDir := filepath.Join(home, ".aws")
gcloudDir := filepath.Join(home, ".config", "gcloud")
if err := mkdirAll(awsDir, gcloudDir); err != nil {
npmrc := filepath.Join(home, ".npmrc")
ghHosts := filepath.Join(home, ".config", "gh", "hosts.yml")
netrc := filepath.Join(home, ".netrc")
dockerConfig := filepath.Join(home, ".docker", "config.json")
kubeConfig := filepath.Join(home, ".kube", "config")
zeroConfig := filepath.Join(home, ".config", "zero")
if err := mkdirAll(
awsDir,
gcloudDir,
filepath.Dir(ghHosts),
filepath.Dir(dockerConfig),
filepath.Dir(kubeConfig),
filepath.Dir(zeroConfig),
); err != nil {
t.Fatal(err)
}
for _, path := range []string{npmrc, ghHosts, netrc, dockerConfig, kubeConfig, zeroConfig} {
if path == zeroConfig {
continue
}
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
}
if err := os.MkdirAll(zeroConfig, 0o755); err != nil {
t.Fatal(err)
}
keyFile := filepath.Join(home, "sa-key.json")
@@ -387,7 +412,17 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
}
paths := credentialDenyReadPathsIn(home, keyFile, nil)
for _, want := range normalizeProfilePaths([]string{awsDir, gcloudDir, keyFile}) {
for _, want := range normalizeProfilePaths([]string{
awsDir,
gcloudDir,
keyFile,
npmrc,
ghHosts,
netrc,
dockerConfig,
kubeConfig,
zeroConfig,
}) {
if !stringSliceContains(paths, want) {
t.Errorf("credential deny paths = %#v, want %q included", paths, want)
}
@@ -418,3 +453,58 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
t.Errorf("credential deny paths without home = %#v, want key file included", homeless)
}
}
func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T) {
root := t.TempDir()
configHome := filepath.Join(root, "xdg")
npmrc := filepath.Join(root, "npm", "userconfig")
ghConfigDir := filepath.Join(root, "gh")
cloudSDKConfig := filepath.Join(root, "gcloud")
netrc := filepath.Join(root, "netrc")
dockerConfigDir := filepath.Join(root, "docker")
kubeConfigA := filepath.Join(root, "kube", "a")
kubeConfigB := filepath.Join(root, "kube", "b")
zeroConfig := filepath.Join(configHome, "zero")
filePaths := []string{
npmrc,
filepath.Join(ghConfigDir, "hosts.yml"),
netrc,
filepath.Join(dockerConfigDir, "config.json"),
kubeConfigA,
kubeConfigB,
zeroConfig,
}
if err := os.MkdirAll(cloudSDKConfig, 0o755); err != nil {
t.Fatal(err)
}
for _, path := range filePaths {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if path == zeroConfig {
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
continue
}
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
}
got := credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
ConfigHome: configHome,
CloudSDKConfig: cloudSDKConfig,
NPMUserConfig: npmrc,
GHConfigDir: ghConfigDir,
Netrc: netrc,
DockerConfigDir: dockerConfigDir,
KubeConfig: strings.Join([]string{kubeConfigA, kubeConfigB}, string(filepath.ListSeparator)),
}, nil)
wantPaths := append(filePaths, cloudSDKConfig)
for _, want := range normalizeProfilePaths(wantPaths) {
if !stringSliceContains(got, want) {
t.Errorf("credential deny paths = %#v, want override %q included", got, want)
}
}
}
+91 -12
View File
@@ -145,9 +145,10 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop
}
// credentialDenyReadPaths returns default deny-read entries for well-known
// credential stores (~/.aws, ~/.config/gcloud, ~/.azure, and the file
// GOOGLE_APPLICATION_CREDENTIALS points to) so sandboxed commands cannot read
// cloud secrets under the read-all workspace posture. Two deliberate limits:
// credential stores so sandboxed commands cannot read secrets under the
// read-all workspace posture. This includes tool configuration files that can
// carry credentials and are now discoverable through the preserved caller
// environment. Two deliberate limits:
//
// - Windows is skipped: a non-empty profile DenyRead switches the Windows
// runner onto the capability-SID/ACL deny path and away from the
@@ -166,23 +167,100 @@ func credentialDenyReadPaths(policy Policy) []string {
// A failed home lookup only drops the home-based candidates; the
// GOOGLE_APPLICATION_CREDENTIALS target must be protected regardless.
home, _ := os.UserHomeDir()
return credentialDenyReadPathsIn(home, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), policy.AllowRead)
return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
Home: home,
ConfigHome: os.Getenv("XDG_CONFIG_HOME"),
CloudSDKConfig: os.Getenv("CLOUDSDK_CONFIG"),
GoogleCredentials: os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"),
NPMUserConfig: firstNonEmpty(os.Getenv("NPM_CONFIG_USERCONFIG"), os.Getenv("npm_config_userconfig")),
GHConfigDir: os.Getenv("GH_CONFIG_DIR"),
Netrc: os.Getenv("NETRC"),
DockerConfigDir: os.Getenv("DOCKER_CONFIG"),
KubeConfig: os.Getenv("KUBECONFIG"),
}, policy.AllowRead)
}
// credentialDenyReadPathsIn is the pure core of credentialDenyReadPaths,
// separated so tests can exercise it against a synthetic home directory.
func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead []string) []string {
return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
Home: home,
GoogleCredentials: googleCredentials,
}, allowRead)
}
type credentialPathEnvironment struct {
Home string
ConfigHome string
CloudSDKConfig string
GoogleCredentials string
NPMUserConfig string
GHConfigDir string
Netrc string
DockerConfigDir string
KubeConfig string
}
func credentialDenyReadPathsForEnvironment(env credentialPathEnvironment, allowRead []string) []string {
var candidates []string
if home = strings.TrimSpace(home); home != "" {
home := strings.TrimSpace(env.Home)
configHome := strings.TrimSpace(env.ConfigHome)
if configHome == "" && home != "" {
configHome = filepath.Join(home, ".config")
}
if home != "" {
candidates = append(candidates,
filepath.Join(home, ".aws"),
filepath.Join(home, ".config", "gcloud"),
filepath.Join(home, ".azure"),
)
}
if target := strings.TrimSpace(googleCredentials); target != "" {
npmUserConfig := strings.TrimSpace(env.NPMUserConfig)
if npmUserConfig == "" && home != "" {
npmUserConfig = filepath.Join(home, ".npmrc")
}
netrc := strings.TrimSpace(env.Netrc)
if netrc == "" && home != "" {
netrc = filepath.Join(home, ".netrc")
}
dockerConfigDir := strings.TrimSpace(env.DockerConfigDir)
if dockerConfigDir == "" && home != "" {
dockerConfigDir = filepath.Join(home, ".docker")
}
candidates = append(candidates, npmUserConfig, netrc)
if dockerConfigDir != "" {
candidates = append(candidates, filepath.Join(dockerConfigDir, "config.json"))
}
if configHome != "" {
candidates = append(candidates,
filepath.Join(configHome, "zero"),
)
}
cloudSDKConfig := strings.TrimSpace(env.CloudSDKConfig)
if cloudSDKConfig == "" && configHome != "" {
cloudSDKConfig = filepath.Join(configHome, "gcloud")
}
if cloudSDKConfig != "" {
candidates = append(candidates, cloudSDKConfig)
}
ghConfigDir := strings.TrimSpace(env.GHConfigDir)
if ghConfigDir == "" && configHome != "" {
ghConfigDir = filepath.Join(configHome, "gh")
}
if ghConfigDir != "" {
candidates = append(candidates, filepath.Join(ghConfigDir, "hosts.yml"))
}
if target := strings.TrimSpace(env.GoogleCredentials); target != "" {
candidates = append(candidates, target)
}
kubeConfig := strings.TrimSpace(env.KubeConfig)
if kubeConfig == "" && home != "" {
kubeConfig = filepath.Join(home, ".kube", "config")
}
for _, path := range filepath.SplitList(kubeConfig) {
if path = strings.TrimSpace(path); path != "" {
candidates = append(candidates, path)
}
}
allowRoots := normalizeProfilePaths(allowRead)
out := make([]string, 0, len(candidates))
for _, path := range normalizeProfilePaths(candidates) {
@@ -206,11 +284,12 @@ func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead
// 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.
// of failing with "unable to access ~/.gitconfig". On macOS, where reads are
// allow-listed, granting only these files avoids exposing the surrounding
// configuration directory. Linux uses a read-all profile with explicit
// credential deny rules. The paths are granted at the macOS seatbelt read rule
// rather than the cross-platform PermissionProfile so 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) == "" {
@@ -266,6 +266,74 @@ func TestWindowsRestrictedTokenNestedPipeCapture(t *testing.T) {
}
}
// TestWindowsRestrictedTokenPowerShell exercises the default Windows shell
// shape end to end under the write-restricted token. It covers PowerShell
// initialization, a native pipeline (which creates IPC objects), UTF-8 output,
// and workspace write enforcement.
func TestWindowsRestrictedTokenPowerShell(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
}
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
powerShell := realSmokePowerShell(t)
root := t.TempDir()
outside := t.TempDir()
sandboxHome := filepath.Join(root, ".zero-sandbox")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{root},
WriteRoots: []WritableRoot{{Root: root}},
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
config := WindowsSandboxCommandArgsOptions{
SandboxHome: sandboxHome,
CommandCWD: root,
WorkspaceRoots: []string{root},
PermissionProfile: profile,
SandboxLevel: WindowsSandboxLevelUnelevated,
}
insideMarker := filepath.Join(root, "powershell-ok.txt")
script := "$ErrorActionPreference='Stop'; " +
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " +
"$values = 1,2,3 | ForEach-Object { $_ * 2 }; " +
"[IO.File]::WriteAllText(" + powershellSingleQuote(insideMarker) + ", (($values -join ',') + ' ✓'))"
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
powerShell, "-NoLogo", "-NoProfile", "-Command", script,
}, 0)
if bytes, err := os.ReadFile(insideMarker); err != nil || strings.TrimSpace(string(bytes)) != "2,4,6 ✓" {
t.Fatalf("PowerShell sandbox marker = %q, %v; want pipeline output", bytes, err)
}
outsideMarker := filepath.Join(outside, "powershell-denied.txt")
deniedScript := "$ErrorActionPreference='Stop'; " +
"[IO.File]::WriteAllText(" + powershellSingleQuote(outsideMarker) + ", 'leaked')"
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
powerShell, "-NoLogo", "-NoProfile", "-Command", deniedScript,
}, 1)
if _, err := os.Stat(outsideMarker); err == nil {
t.Fatal("sandboxed PowerShell wrote outside every granted root")
} else if !os.IsNotExist(err) {
t.Fatalf("stat outside PowerShell marker: %v", err)
}
}
func realSmokePowerShell(t *testing.T) string {
t.Helper()
for _, candidate := range []string{"pwsh.exe", "pwsh", "powershell.exe", "powershell"} {
if path, err := exec.LookPath(candidate); err == nil {
return path
}
}
t.Skip("PowerShell is unavailable")
return ""
}
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
t.Helper()
if path := os.Getenv(envKey); path != "" {
+8 -22
View File
@@ -27,13 +27,10 @@ var fallbackSandboxRuntimes = struct {
}{roots: make(map[string]string)}
type SandboxRuntime struct {
Root string `json:"root,omitempty"`
Home string `json:"home,omitempty"`
Cache string `json:"cache,omitempty"`
Config string `json:"config,omitempty"`
Data string `json:"data,omitempty"`
State string `json:"state,omitempty"`
Temp string `json:"temp,omitempty"`
Root string `json:"root,omitempty"`
Cache string `json:"cache,omitempty"`
Data string `json:"data,omitempty"`
Temp string `json:"temp,omitempty"`
}
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
@@ -75,21 +72,15 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
}
}()
runtimeState := SandboxRuntime{
Root: root,
Home: filepath.Join(root, "home"),
Cache: filepath.Join(root, "cache"),
Config: filepath.Join(root, "config"),
Data: filepath.Join(root, "data"),
State: filepath.Join(root, "state"),
Temp: filepath.Join(root, "tmp"),
Root: root,
Cache: filepath.Join(root, "cache"),
Data: filepath.Join(root, "data"),
Temp: filepath.Join(root, "tmp"),
}
directories := []string{
runtimeState.Root,
runtimeState.Home,
runtimeState.Cache,
runtimeState.Config,
runtimeState.Data,
runtimeState.State,
runtimeState.Temp,
filepath.Join(runtimeState.Cache, "npm"),
filepath.Join(runtimeState.Cache, "yarn"),
@@ -207,16 +198,11 @@ func sandboxRuntimeEnvironment(env []string, runtimeState *SandboxRuntime) []str
return env
}
overrides := []string{
"HOME=" + runtimeState.Home,
"XDG_CACHE_HOME=" + runtimeState.Cache,
"XDG_CONFIG_HOME=" + runtimeState.Config,
"XDG_DATA_HOME=" + runtimeState.Data,
"XDG_STATE_HOME=" + runtimeState.State,
"TMPDIR=" + runtimeState.Temp,
"TMP=" + runtimeState.Temp,
"TEMP=" + runtimeState.Temp,
"npm_config_cache=" + filepath.Join(runtimeState.Cache, "npm"),
"NPM_CONFIG_USERCONFIG=" + filepath.Join(runtimeState.Config, "npmrc"),
"YARN_CACHE_FOLDER=" + filepath.Join(runtimeState.Cache, "yarn"),
"COREPACK_HOME=" + filepath.Join(runtimeState.Cache, "corepack"),
"PIP_CACHE_DIR=" + filepath.Join(runtimeState.Cache, "pip"),
+143 -22
View File
@@ -2,7 +2,9 @@ package sandbox
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -22,12 +24,18 @@ func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) {
if pathWithinRoot(workspace, runtimeState.Root) {
t.Fatalf("runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
}
for _, path := range []string{runtimeState.Home, runtimeState.Cache, runtimeState.Config, runtimeState.Data, runtimeState.State, runtimeState.Temp} {
for _, path := range []string{runtimeState.Cache, runtimeState.Data, runtimeState.Temp, filepath.Join(runtimeState.Data, "cargo")} {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
t.Fatalf("managed runtime directory %q was not prepared: %v", path, err)
}
}
for _, name := range []string{"home", "config", "state"} {
path := filepath.Join(runtimeState.Root, name)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("obsolete synthetic runtime directory %q exists: %v", path, err)
}
}
}
func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
@@ -116,34 +124,63 @@ func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) {
}
}
func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) {
func TestSandboxRuntimeEnvironmentPreservesUserConfiguration(t *testing.T) {
root := filepath.Join(t.TempDir(), "runtime")
runtimeState := SandboxRuntime{
Root: root,
Home: filepath.Join(root, "home"),
Cache: filepath.Join(root, "cache"),
Config: filepath.Join(root, "config"),
Data: filepath.Join(root, "data"),
State: filepath.Join(root, "state"),
Temp: filepath.Join(root, "tmp"),
Root: root,
Cache: filepath.Join(root, "cache"),
Data: filepath.Join(root, "data"),
Temp: filepath.Join(root, "tmp"),
}
env := sandboxRuntimeEnvironment([]string{
"HOME=/workspace",
"HOME=/home/user",
"XDG_CACHE_HOME=/host/cache",
"XDG_CONFIG_HOME=/home/user/.config",
"XDG_DATA_HOME=/home/user/.local/share",
"XDG_STATE_HOME=/home/user/.local/state",
"NPM_CONFIG_USERCONFIG=/home/user/.npmrc",
"PATH=/usr/bin",
}, &runtimeState)
for key, want := range map[string]string{
"HOME": runtimeState.Home,
"XDG_CACHE_HOME": runtimeState.Cache,
"XDG_CONFIG_HOME": runtimeState.Config,
"XDG_DATA_HOME": runtimeState.Data,
"XDG_STATE_HOME": runtimeState.State,
"TMPDIR": runtimeState.Temp,
"npm_config_cache": filepath.Join(runtimeState.Cache, "npm"),
"NPM_CONFIG_USERCONFIG": filepath.Join(runtimeState.Config, "npmrc"),
"YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"),
"COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"),
"HOME": "/home/user",
"XDG_CONFIG_HOME": "/home/user/.config",
"XDG_DATA_HOME": "/home/user/.local/share",
"XDG_STATE_HOME": "/home/user/.local/state",
"NPM_CONFIG_USERCONFIG": "/home/user/.npmrc",
} {
if got := envListValue(env, key, ""); got != want {
t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env)
}
}
}
func TestSandboxRuntimeEnvironmentUsesManagedWritableState(t *testing.T) {
root := filepath.Join(t.TempDir(), "runtime")
runtimeState := SandboxRuntime{
Root: root,
Cache: filepath.Join(root, "cache"),
Data: filepath.Join(root, "data"),
Temp: filepath.Join(root, "tmp"),
}
env := sandboxRuntimeEnvironment([]string{
"XDG_CACHE_HOME=/host/cache",
"TMPDIR=/host/tmp",
"PATH=/usr/bin",
}, &runtimeState)
for key, want := range map[string]string{
"XDG_CACHE_HOME": runtimeState.Cache,
"TMPDIR": runtimeState.Temp,
"TMP": runtimeState.Temp,
"TEMP": runtimeState.Temp,
"npm_config_cache": filepath.Join(runtimeState.Cache, "npm"),
"YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"),
"COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"),
"PIP_CACHE_DIR": filepath.Join(runtimeState.Cache, "pip"),
"GOCACHE": filepath.Join(runtimeState.Cache, "go-build"),
"GOMODCACHE": filepath.Join(runtimeState.Data, "go-mod"),
"CARGO_HOME": filepath.Join(runtimeState.Data, "cargo"),
} {
if got := envListValue(env, key, ""); got != want {
t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env)
@@ -154,9 +191,93 @@ func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) {
}
}
func TestSandboxRuntimeEnvironmentPreservesGitGlobalIgnore(t *testing.T) {
git, err := exec.LookPath("git")
if err != nil {
t.Skip("git is unavailable")
}
root := t.TempDir()
home := filepath.Join(root, "home")
configHome := filepath.Join(home, ".config")
repository := filepath.Join(root, "repository")
runtimeRoot := filepath.Join(root, "runtime")
for _, directory := range []string{
filepath.Join(configHome, "git"),
repository,
filepath.Join(runtimeRoot, "cache"),
filepath.Join(runtimeRoot, "data"),
filepath.Join(runtimeRoot, "tmp"),
} {
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(configHome, "git", "ignore"), []byte("ignored.txt\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repository, "ignored.txt"), []byte("ignored\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repository, "visible.txt"), []byte("visible\n"), 0o600); err != nil {
t.Fatal(err)
}
runtimeState := SandboxRuntime{
Root: runtimeRoot,
Cache: filepath.Join(runtimeRoot, "cache"),
Data: filepath.Join(runtimeRoot, "data"),
Temp: filepath.Join(runtimeRoot, "tmp"),
}
inherited := append(os.Environ(),
"GIT_CONFIG_GLOBAL="+filepath.Join(root, "missing-global-config"),
"GIT_DIR="+filepath.Join(root, "wrong-repository"),
"GIT_CONFIG_COUNT=1",
"GIT_CONFIG_KEY_0=core.excludesFile",
"GIT_CONFIG_VALUE_0="+filepath.Join(root, "missing-ignore"),
)
env := sandboxRuntimeEnvironment(upsertEnvList(withoutGitEnvironmentOverrides(inherited),
"HOME="+home,
"XDG_CONFIG_HOME="+configHome,
"GIT_CONFIG_NOSYSTEM=1",
"GIT_TERMINAL_PROMPT=0",
), &runtimeState)
runGit := func(args ...string) string {
t.Helper()
command := exec.Command(git, args...)
command.Dir = repository
command.Env = env
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
}
return string(output)
}
runGit("init", "--quiet")
status := runGit("status", "--short", "--untracked-files=all")
if strings.Contains(status, "ignored.txt") {
t.Fatalf("global ignore was not honored:\n%s", status)
}
if !strings.Contains(status, "visible.txt") {
t.Fatalf("non-ignored file is missing from status:\n%s", status)
}
}
func withoutGitEnvironmentOverrides(env []string) []string {
out := make([]string, 0, len(env))
for _, value := range env {
key, _, ok := strings.Cut(value, "=")
if ok && strings.HasPrefix(strings.ToUpper(key), "GIT_") {
continue
}
out = append(out, value)
}
return out
}
func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
t.Setenv("HOME", filepath.Join(t.TempDir(), "home"))
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
@@ -189,8 +310,8 @@ func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
} else if !inUse {
t.Fatal("command plan runtime must be marked in use")
}
if got := envListValue(plan.Env, "HOME", ""); got != plan.PermissionProfile.Runtime.Home {
t.Fatalf("HOME = %q, want managed home %q", got, plan.PermissionProfile.Runtime.Home)
if got := envListValue(plan.Env, "HOME", ""); got != os.Getenv("HOME") {
t.Fatalf("HOME = %q, want caller home %q", got, os.Getenv("HOME"))
}
foundWriteRoot := false
for _, root := range plan.PermissionProfile.FileSystem.WriteRoots {
+59 -14
View File
@@ -430,25 +430,70 @@ func shellDashCPayload(program string, fields []string) string {
// name: it strips shell quoting/escaping characters (", ', `, \) wherever they
// appear in the token (including embedded ones like `vi\m` or `v"i"m`), strips
// leading command-substitution markers, removes any directory prefix (so
// /usr/bin/vim and C:\tools\vim.exe match "vim"), and lowercases. This closes
// path/quote/substitution evasions of the detector.
// /usr/bin/vim and C:\tools\vim.exe match "vim"), removes Windows executable
// suffixes, and lowercases. This closes path/quote/substitution evasions of the
// detector and keeps curl.exe/npm.cmd equivalent to curl/npm for risk analysis.
func normalizeProgramToken(field string) string {
token := strings.TrimSpace(field)
token = strings.TrimLeft(token, "$(")
token = strings.TrimRight(token, ")")
// Strip shell quoting/escaping characters (", ', `, \) wherever they appear
// in the token — surrounding, embedded, or as a mid-word escape — so
// "vim", v"i"m, 'v'im, and vi\m all collapse to the program name. This is
// done BEFORE the directory-prefix trim so an escape can't masquerade as a
// path separator (e.g. vi\m must become vim, not m).
token = stripChars(token, "\"'`\\")
// Strip a directory prefix so /usr/bin/vim reduces to the basename. (A
// Windows-style backslash path separator is already removed above, so only
// the POSIX separator remains to split on.)
if i := strings.LastIndex(token, "/"); i >= 0 {
token = token[i+1:]
// PowerShell commonly invokes drive-letter, UNC, and relative paths. Extract
// their basename while backslashes still carry path-separator meaning;
// removing them first would turn C:\tools\curl.exe into c:toolscurl.exe and
// bypass the canonical curl risk entry.
pathToken := stripChars(token, "\"'`")
if windowsPathBasename, ok := windowsExecutablePathBasename(pathToken); ok {
token = windowsPathBasename
} else {
// Strip shell quoting/escaping characters (", ', `, \) wherever they
// appear in the token — surrounding, embedded, or as a mid-word escape
// — so "vim", v"i"m, 'v'im, and vi\m all collapse to the program name.
token = stripChars(token, "\"'`\\")
// Strip a directory prefix so /usr/bin/vim reduces to the basename.
if i := strings.LastIndex(token, "/"); i >= 0 {
token = token[i+1:]
}
}
return strings.ToLower(token)
token = strings.ToLower(token)
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
if strings.HasSuffix(token, suffix) {
return strings.TrimSuffix(token, suffix)
}
}
return token
}
func windowsExecutablePathBasename(token string) (string, bool) {
drivePath := len(token) >= 3 &&
((token[0] >= 'a' && token[0] <= 'z') || (token[0] >= 'A' && token[0] <= 'Z')) &&
token[1] == ':'
uncPath := strings.HasPrefix(token, `\\`)
explicitRelativePath := strings.HasPrefix(token, `.\`) || strings.HasPrefix(token, `..\`)
backslashRelativePath := strings.ContainsRune(token, '\\') && hasWindowsExecutableSuffix(token)
if !drivePath && !uncPath && !explicitRelativePath && !backslashRelativePath {
return "", false
}
index := strings.LastIndexAny(token, `\/`)
if index >= 0 {
if index+1 >= len(token) {
return "", false
}
return token[index+1:], true
}
if drivePath && len(token) > 2 {
return token[2:], true
}
return "", false
}
func hasWindowsExecutableSuffix(token string) bool {
token = strings.ToLower(token)
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
if strings.HasSuffix(token, suffix) {
return true
}
}
return false
}
// stripChars returns s with every rune in cutset removed.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
var ErrWindowsNetworkEnforcementUnavailable = errors.New("Windows sandbox network enforcement is not available")
var ErrWindowsNetworkEnforcementUnavailable = errors.New("Windows sandbox network enforcement is not available") //nolint:staticcheck // Preserve the exported sentinel's established text.
const (
windowsWFPProviderKey = "2e31d31c-3948-4753-9117-e5d1a6496f41"
+422
View File
@@ -0,0 +1,422 @@
package sessions
import (
"fmt"
"strings"
"unicode/utf8"
)
const (
GoalObjectiveMaxLength = 4_000
GoalMaxConsecutiveContinuations = 20
goalContinuationLimitStatusReason = "automatic continuation limit reached"
)
func validateGoalObjective(objective string) (string, error) {
objective = strings.TrimSpace(objective)
if objective == "" {
return "", fmt.Errorf("goal objective is required")
}
if utf8.RuneCountInString(objective) > GoalObjectiveMaxLength {
return "", fmt.Errorf("goal objective cannot exceed %d characters", GoalObjectiveMaxLength)
}
return objective, nil
}
// CreateGoal persists a new active goal for a session. It refuses to replace an
// existing goal so callers must make replacement an explicit user decision.
func (store *Store) CreateGoal(sessionID, objective string, tokenBudget int) (Metadata, Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
var err error
objective, err = validateGoalObjective(objective)
if err != nil {
return Metadata{}, Event{}, err
}
if tokenBudget < 0 {
return Metadata{}, Event{}, fmt.Errorf("goal token budget cannot be negative")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
if session.Goal != nil {
return Metadata{}, Event{}, fmt.Errorf("session already has a goal")
}
now := store.timestamp()
session.Goal = &Goal{
Objective: objective,
Status: GoalStatusActive,
TokenBudget: tokenBudget,
ContinuationLimit: GoalMaxConsecutiveContinuations,
CreatedAt: now,
UpdatedAt: now,
}
if err := store.writeMetadata(session); err != nil {
return Metadata{}, Event{}, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalCreated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, Event{}, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
return loaded, event, nil
}
// UpdateGoal changes a goal's lifecycle state. Terminal goals remain available
// for status/history until the user explicitly clears them.
func (store *Store) UpdateGoal(sessionID string, status GoalStatus, reason string) (Metadata, Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
if !validGoalStatus(status) {
return Metadata{}, Event{}, fmt.Errorf("invalid goal status %q", status)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
if session.Goal == nil {
return Metadata{}, Event{}, fmt.Errorf("session has no goal")
}
session.Goal.Status = status
session.Goal.StatusReason = strings.TrimSpace(reason)
if status == GoalStatusActive {
session.Goal.ContinuationCount = 0
if session.Goal.ContinuationLimit <= 0 {
session.Goal.ContinuationLimit = GoalMaxConsecutiveContinuations
}
}
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, Event{}, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, Event{}, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
return loaded, event, nil
}
// EditGoal replaces the objective and optional budget without resetting usage or
// lifecycle timestamps. User-facing command handling owns confirmation.
func (store *Store) EditGoal(sessionID, objective string, tokenBudget int) (Metadata, Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
var err error
objective, err = validateGoalObjective(objective)
if err != nil {
return Metadata{}, Event{}, err
}
if tokenBudget < 0 {
return Metadata{}, Event{}, fmt.Errorf("goal token budget cannot be negative")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
if session.Goal == nil {
return Metadata{}, Event{}, fmt.Errorf("session has no goal")
}
session.Goal.Objective = objective
session.Goal.TokenBudget = tokenBudget
session.Goal.Status = GoalStatusActive
session.Goal.StatusReason = ""
session.Goal.ContinuationCount = 0
if session.Goal.ContinuationLimit <= 0 {
session.Goal.ContinuationLimit = GoalMaxConsecutiveContinuations
}
if tokenBudget > 0 && session.Goal.TokensUsed >= tokenBudget {
session.Goal.Status = GoalStatusBudgetLimited
session.Goal.StatusReason = "token budget reached"
}
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, Event{}, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, Event{}, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
return loaded, event, nil
}
// ResetGoalContinuations starts a fresh autonomous-run allowance after explicit
// user input. It makes the safety bound consecutive rather than lifetime-wide.
func (store *Store) ResetGoalContinuations(sessionID string) (Metadata, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, err
}
if session.Goal == nil || session.Goal.Status != GoalStatusActive {
return session, nil
}
changed := false
if session.Goal.ContinuationCount != 0 {
session.Goal.ContinuationCount = 0
changed = true
}
if session.Goal.ContinuationLimit <= 0 {
session.Goal.ContinuationLimit = GoalMaxConsecutiveContinuations
changed = true
}
if !changed {
return session, nil
}
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, err
}
return session, nil
}
// ReserveGoalContinuation atomically reserves one automatic continuation. Once
// the persisted consecutive-run limit is exhausted it pauses the goal before
// another provider request can start, even when the provider reports no usage.
func (store *Store) ReserveGoalContinuation(sessionID string) (Metadata, *Event, bool, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, nil, false, fmt.Errorf("invalid zero session id %q", sessionID)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, nil, false, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, false, err
}
if session.Goal == nil || session.Goal.Status != GoalStatusActive {
return session, nil, false, nil
}
limit := session.Goal.ContinuationLimit
if limit <= 0 {
limit = GoalMaxConsecutiveContinuations
session.Goal.ContinuationLimit = limit
}
if session.Goal.ContinuationCount < limit {
session.Goal.ContinuationCount++
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, nil, false, err
}
return session, nil, true, nil
}
session.Goal.Status = GoalStatusPaused
session.Goal.StatusReason = goalContinuationLimitStatusReason
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, nil, false, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, nil, false, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, false, err
}
return loaded, &event, false, nil
}
// AddGoalUsage accounts tokens consumed while a goal is active. Reaching the
// optional budget pauses the goal before another autonomous turn can start.
func (store *Store) AddGoalUsage(sessionID string, tokens int) (Metadata, *Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, nil, fmt.Errorf("invalid zero session id %q", sessionID)
}
if tokens < 0 {
return Metadata{}, nil, fmt.Errorf("goal token usage cannot be negative")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, nil, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, err
}
if session.Goal == nil || tokens == 0 {
return session, nil, nil
}
session.Goal.TokensUsed += tokens
session.Goal.UpdatedAt = store.timestamp()
budgetReached := false
if session.Goal.Status == GoalStatusActive &&
session.Goal.TokenBudget > 0 &&
session.Goal.TokensUsed >= session.Goal.TokenBudget {
session.Goal.Status = GoalStatusBudgetLimited
session.Goal.StatusReason = "token budget reached"
budgetReached = true
}
if err := store.writeMetadata(session); err != nil {
return Metadata{}, nil, err
}
if !budgetReached {
return session, nil, nil
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, nil, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, err
}
return loaded, &event, nil
}
// PauseGoalIfActive pauses a goal only while it is still active. It is used by
// cancellation paths where an in-flight agent may have completed or blocked the
// goal immediately before the cancellation reached the runtime.
func (store *Store) PauseGoalIfActive(sessionID, reason string) (Metadata, *Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, nil, fmt.Errorf("invalid zero session id %q", sessionID)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, nil, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, err
}
if session.Goal == nil || session.Goal.Status != GoalStatusActive {
return session, nil, nil
}
session.Goal.Status = GoalStatusPaused
session.Goal.StatusReason = strings.TrimSpace(reason)
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, nil, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, nil, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, err
}
return loaded, &event, nil
}
// ClearGoal removes the current goal while preserving an audit event.
func (store *Store) ClearGoal(sessionID string) (Metadata, Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
if session.Goal == nil {
return Metadata{}, Event{}, fmt.Errorf("session has no goal")
}
cleared := cloneGoal(session.Goal)
session.Goal = nil
if err := store.writeMetadata(session); err != nil {
return Metadata{}, Event{}, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalCleared,
Payload: cleared,
})
if err != nil {
return Metadata{}, Event{}, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
return loaded, event, nil
}
func validGoalStatus(status GoalStatus) bool {
switch status {
case GoalStatusActive, GoalStatusPaused, GoalStatusBlocked, GoalStatusBudgetLimited, GoalStatusUsageLimited, GoalStatusComplete:
return true
default:
return false
}
}
func cloneGoal(goal *Goal) *Goal {
if goal == nil {
return nil
}
copy := *goal
return &copy
}
+382
View File
@@ -0,0 +1,382 @@
package sessions
import (
"os"
"strings"
"testing"
"time"
)
func TestGoalLifecyclePersistsInSessionMetadata(t *testing.T) {
now := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC)
store := NewStore(StoreOptions{
RootDir: t.TempDir(),
Now: func() time.Time {
now = now.Add(time.Second)
return now
},
})
session, err := store.Create(CreateInput{SessionID: "goal_session"})
if err != nil {
t.Fatal(err)
}
created, event, err := store.CreateGoal(session.SessionID, "Ship the release", 1_000)
if err != nil {
t.Fatal(err)
}
if event.Type != EventGoalCreated {
t.Fatalf("create event = %q, want %q", event.Type, EventGoalCreated)
}
if created.Goal == nil || created.Goal.Objective != "Ship the release" ||
created.Goal.Status != GoalStatusActive || created.Goal.TokenBudget != 1_000 {
t.Fatalf("created goal = %#v", created.Goal)
}
accounted, _, err := store.AddGoalUsage(session.SessionID, 250)
if err != nil {
t.Fatal(err)
}
if accounted.Goal.TokensUsed != 250 || accounted.Goal.Status != GoalStatusActive {
t.Fatalf("accounted goal = %#v", accounted.Goal)
}
paused, event, err := store.UpdateGoal(session.SessionID, GoalStatusPaused, "user interrupted")
if err != nil {
t.Fatal(err)
}
if event.Type != EventGoalUpdated || paused.Goal.Status != GoalStatusPaused ||
paused.Goal.StatusReason != "user interrupted" {
t.Fatalf("paused goal/event = %#v / %#v", paused.Goal, event)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if loaded == nil || loaded.Goal == nil || loaded.Goal.Status != GoalStatusPaused {
t.Fatalf("reloaded session = %#v", loaded)
}
cleared, event, err := store.ClearGoal(session.SessionID)
if err != nil {
t.Fatal(err)
}
if event.Type != EventGoalCleared || cleared.Goal != nil {
t.Fatalf("cleared goal/event = %#v / %#v", cleared.Goal, event)
}
}
func TestGoalBudgetPausesAtLimit(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "budget_session"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Stay bounded", 100); err != nil {
t.Fatal(err)
}
updated, event, err := store.AddGoalUsage(session.SessionID, 100)
if err != nil {
t.Fatal(err)
}
if updated.Goal.Status != GoalStatusBudgetLimited || updated.Goal.StatusReason != "token budget reached" {
t.Fatalf("budgeted goal = %#v", updated.Goal)
}
if event == nil || event.Type != EventGoalUpdated {
t.Fatalf("budget transition event = %#v", event)
}
}
func TestGoalContinuationLimitStopsWithoutProviderUsage(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "continuation_limit"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Stay bounded without usage events", 0); err != nil {
t.Fatal(err)
}
for continuation := 1; continuation <= GoalMaxConsecutiveContinuations; continuation++ {
updated, event, reserved, err := store.ReserveGoalContinuation(session.SessionID)
if err != nil {
t.Fatal(err)
}
if !reserved || event != nil {
t.Fatalf("continuation %d: reserved=%v event=%#v", continuation, reserved, event)
}
if updated.Goal.ContinuationCount != continuation {
t.Fatalf("continuation count = %d, want %d", updated.Goal.ContinuationCount, continuation)
}
if updated.Goal.TokensUsed != 0 {
t.Fatalf("provider-independent guard unexpectedly recorded tokens: %#v", updated.Goal)
}
}
stopped, event, reserved, err := store.ReserveGoalContinuation(session.SessionID)
if err != nil {
t.Fatal(err)
}
if reserved || event == nil || stopped.Goal.Status != GoalStatusPaused ||
stopped.Goal.StatusReason != goalContinuationLimitStatusReason {
t.Fatalf("unbounded continuation was not stopped: goal=%#v event=%#v reserved=%v", stopped.Goal, event, reserved)
}
reloaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if reloaded.Goal.ContinuationCount != GoalMaxConsecutiveContinuations ||
reloaded.Goal.Status != GoalStatusPaused {
t.Fatalf("continuation guard did not persist: %#v", reloaded.Goal)
}
resumed, _, err := store.UpdateGoal(session.SessionID, GoalStatusActive, "")
if err != nil {
t.Fatal(err)
}
if resumed.Goal.ContinuationCount != 0 {
t.Fatalf("explicit resume did not reset consecutive continuations: %#v", resumed.Goal)
}
}
func TestResetGoalContinuationsPersistsOnlyActiveChanges(t *testing.T) {
newStore := func(t *testing.T) (*Store, Metadata) {
t.Helper()
now := time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC)
store := NewStore(StoreOptions{
RootDir: t.TempDir(),
Now: func() time.Time {
now = now.Add(time.Second)
return now
},
})
session, err := store.Create(CreateInput{SessionID: "reset_goal"})
if err != nil {
t.Fatal(err)
}
return store, session
}
pinMetadataTime := func(t *testing.T, store *Store, sessionID string) time.Time {
t.Helper()
path := store.metadataPath(sessionID)
sentinel := time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC)
if err := os.Chtimes(path, sentinel, sentinel); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
return info.ModTime()
}
assertMetadataTime := func(t *testing.T, store *Store, sessionID string, want time.Time) {
t.Helper()
info, err := os.Stat(store.metadataPath(sessionID))
if err != nil {
t.Fatal(err)
}
if !info.ModTime().Equal(want) {
t.Fatalf("metadata was rewritten: modtime=%s want=%s", info.ModTime(), want)
}
}
t.Run("no goal is a no-op", func(t *testing.T) {
store, session := newStore(t)
modTime := pinMetadataTime(t, store, session.SessionID)
updated, err := store.ResetGoalContinuations(session.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.Goal != nil {
t.Fatalf("no-goal reset created a goal: %#v", updated.Goal)
}
assertMetadataTime(t, store, session.SessionID, modTime)
})
t.Run("inactive goal is a no-op", func(t *testing.T) {
store, session := newStore(t)
if _, _, err := store.CreateGoal(session.SessionID, "Pause safely", 0); err != nil {
t.Fatal(err)
}
if _, _, reserved, err := store.ReserveGoalContinuation(session.SessionID); err != nil || !reserved {
t.Fatalf("reserve continuation: reserved=%v err=%v", reserved, err)
}
paused, _, err := store.UpdateGoal(session.SessionID, GoalStatusPaused, "user paused")
if err != nil {
t.Fatal(err)
}
modTime := pinMetadataTime(t, store, session.SessionID)
updated, err := store.ResetGoalContinuations(session.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.Goal.ContinuationCount != 1 || updated.Goal.UpdatedAt != paused.Goal.UpdatedAt {
t.Fatalf("inactive goal changed during reset: before=%#v after=%#v", paused.Goal, updated.Goal)
}
assertMetadataTime(t, store, session.SessionID, modTime)
})
t.Run("already reset active goal is a no-op", func(t *testing.T) {
store, session := newStore(t)
created, _, err := store.CreateGoal(session.SessionID, "Stay reset", 0)
if err != nil {
t.Fatal(err)
}
modTime := pinMetadataTime(t, store, session.SessionID)
updated, err := store.ResetGoalContinuations(session.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.Goal.ContinuationCount != 0 ||
updated.Goal.ContinuationLimit != GoalMaxConsecutiveContinuations ||
updated.Goal.UpdatedAt != created.Goal.UpdatedAt {
t.Fatalf("already-reset goal changed: before=%#v after=%#v", created.Goal, updated.Goal)
}
assertMetadataTime(t, store, session.SessionID, modTime)
})
t.Run("active goal reset persists", func(t *testing.T) {
store, session := newStore(t)
if _, _, err := store.CreateGoal(session.SessionID, "Reset progress", 0); err != nil {
t.Fatal(err)
}
reserved, _, ok, err := store.ReserveGoalContinuation(session.SessionID)
if err != nil || !ok {
t.Fatalf("reserve continuation: reserved=%v err=%v", ok, err)
}
updated, err := store.ResetGoalContinuations(session.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.Goal.ContinuationCount != 0 ||
updated.Goal.ContinuationLimit != GoalMaxConsecutiveContinuations ||
updated.Goal.UpdatedAt == reserved.Goal.UpdatedAt {
t.Fatalf("active reset did not persist: before=%#v after=%#v", reserved.Goal, updated.Goal)
}
reloaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if reloaded.Goal == nil || *reloaded.Goal != *updated.Goal {
t.Fatalf("active reset was not durable: updated=%#v reloaded=%#v", updated.Goal, reloaded.Goal)
}
})
}
func TestGoalObjectiveLengthIsBounded(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "objective_limit"})
if err != nil {
t.Fatal(err)
}
tooLong := strings.Repeat("g", GoalObjectiveMaxLength+1)
if _, _, err := store.CreateGoal(session.SessionID, tooLong, 0); err == nil ||
!strings.Contains(err.Error(), "cannot exceed") {
t.Fatalf("oversized objective error = %v", err)
}
}
func TestEditGoalUpdatesStateAndRejectsInvalidInputWithoutMutation(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "edit_goal"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Original objective", 1_000); err != nil {
t.Fatal(err)
}
if _, _, err := store.AddGoalUsage(session.SessionID, 250); err != nil {
t.Fatal(err)
}
edited, event, err := store.EditGoal(session.SessionID, "Updated objective", 500)
if err != nil {
t.Fatal(err)
}
if event.Type != EventGoalUpdated {
t.Fatalf("edit event = %q, want %q", event.Type, EventGoalUpdated)
}
if edited.Goal == nil ||
edited.Goal.Objective != "Updated objective" ||
edited.Goal.TokenBudget != 500 ||
edited.Goal.TokensUsed != 250 ||
edited.Goal.Status != GoalStatusActive ||
edited.Goal.StatusReason != "" {
t.Fatalf("edited goal = %#v", edited.Goal)
}
limited, _, err := store.EditGoal(session.SessionID, "Stay within budget", 200)
if err != nil {
t.Fatal(err)
}
if limited.Goal == nil ||
limited.Goal.Status != GoalStatusBudgetLimited ||
limited.Goal.StatusReason != "token budget reached" ||
limited.Goal.TokensUsed != 250 {
t.Fatalf("budget-limited goal = %#v", limited.Goal)
}
before := *limited.Goal
eventsBefore, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatal(err)
}
if _, _, err := store.EditGoal(session.SessionID, " ", 200); err == nil {
t.Fatal("EditGoal should reject an empty objective")
}
if _, _, err := store.EditGoal(session.SessionID, "Invalid budget", -1); err == nil {
t.Fatal("EditGoal should reject a negative token budget")
}
after, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if after == nil || after.Goal == nil || *after.Goal != before {
t.Fatalf("invalid edit mutated goal: before=%#v after=%#v", before, after)
}
eventsAfter, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatal(err)
}
if len(eventsAfter) != len(eventsBefore) {
t.Fatalf("invalid edit appended events: before=%d after=%d", len(eventsBefore), len(eventsAfter))
}
}
func TestCreateGoalRefusesImplicitReplacement(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "replace_session"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "First", 0); err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Second", 0); err == nil {
t.Fatal("CreateGoal should require an explicit clear before replacement")
}
}
func TestPauseGoalIfActiveDoesNotOverwriteTerminalState(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "terminal_session"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Finish", 0); err != nil {
t.Fatal(err)
}
if _, _, err := store.UpdateGoal(session.SessionID, GoalStatusComplete, ""); err != nil {
t.Fatal(err)
}
updated, event, err := store.PauseGoalIfActive(session.SessionID, "cancelled")
if err != nil {
t.Fatal(err)
}
if event != nil || updated.Goal.Status != GoalStatusComplete {
t.Fatalf("terminal goal changed during cancellation: goal=%#v event=%#v", updated.Goal, event)
}
}
+29
View File
@@ -45,6 +45,9 @@ const (
EventSpecDraft EventType = "spec_draft"
EventSpecApproved EventType = "spec_approved"
EventSpecRejected EventType = "spec_rejected"
EventGoalCreated EventType = "goal_created"
EventGoalUpdated EventType = "goal_updated"
EventGoalCleared EventType = "goal_cleared"
)
type SessionKind string
@@ -65,6 +68,29 @@ const (
SpecStatusRejected SpecStatus = "rejected"
)
type GoalStatus string
const (
GoalStatusActive GoalStatus = "active"
GoalStatusPaused GoalStatus = "paused"
GoalStatusBlocked GoalStatus = "blocked"
GoalStatusBudgetLimited GoalStatus = "budget_limited"
GoalStatusUsageLimited GoalStatus = "usage_limited"
GoalStatusComplete GoalStatus = "complete"
)
type Goal struct {
Objective string `json:"objective"`
Status GoalStatus `json:"status"`
StatusReason string `json:"statusReason,omitempty"`
TokenBudget int `json:"tokenBudget,omitempty"`
TokensUsed int `json:"tokensUsed,omitempty"`
ContinuationCount int `json:"continuationCount,omitempty"`
ContinuationLimit int `json:"continuationLimit,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type Metadata struct {
SessionID string `json:"sessionId"`
SessionKind SessionKind `json:"sessionKind,omitempty"`
@@ -91,6 +117,7 @@ type Metadata struct {
SpecRejectReason string `json:"specRejectReason,omitempty"`
SpecSourceSessionID string `json:"specSourceSessionId,omitempty"`
SpecImplSessionID string `json:"specImplSessionId,omitempty"`
Goal *Goal `json:"goal,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
EventCount int `json:"eventCount"`
@@ -123,6 +150,7 @@ type CreateInput struct {
SpecRejectReason string
SpecSourceSessionID string
SpecImplSessionID string
Goal *Goal
}
type ForkInput struct {
@@ -279,6 +307,7 @@ func (store *Store) Create(input CreateInput) (Metadata, error) {
SpecRejectReason: strings.TrimSpace(input.SpecRejectReason),
SpecSourceSessionID: strings.TrimSpace(input.SpecSourceSessionID),
SpecImplSessionID: strings.TrimSpace(input.SpecImplSessionID),
Goal: cloneGoal(input.Goal),
CreatedAt: timestamp,
UpdatedAt: timestamp,
EventCount: 0,
+1
View File
@@ -17,6 +17,7 @@ func Info(dir string, name string) (SkillInfo, bool) {
info.Hash = entry.Hash
}
}
info.HashDrift = skillHashDrift(skill, info.Hash)
return info, true
}
+21 -3
View File
@@ -76,9 +76,10 @@ type LockEntry struct {
// SkillInfo bundles a discovered skill with its recorded source and hash, for
// `skill info`.
type SkillInfo struct {
Skill Skill `json:"skill"`
Source string `json:"source,omitempty"`
Hash string `json:"hash,omitempty"`
Skill Skill `json:"skill"`
Source string `json:"source,omitempty"`
Hash string `json:"hash,omitempty"`
HashDrift bool `json:"hashDrift"`
}
// Install fetches the skill at options.Source and copies its SKILL.md into
@@ -275,6 +276,7 @@ func InfoFromRoots(primaryDir string, roots []string, name string) (SkillInfo, b
info.Hash = entry.Hash
}
}
info.HashDrift = skillHashDrift(skill, info.Hash)
return info, true
}
@@ -481,3 +483,19 @@ func hashContent(data []byte) string {
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:])
}
// skillHashDrift reports whether the on-disk SKILL.md no longer matches the
// lockfile hash recorded at install time. Missing lock hashes never count as
// drift (agents-only / unlocked skills). A locked skill whose SKILL.md cannot
// be read is treated as drifted — not as a clean match.
func skillHashDrift(skill Skill, lockHash string) bool {
lockHash = strings.TrimSpace(lockHash)
if lockHash == "" {
return false
}
data, err := os.ReadFile(skill.Path)
if err != nil {
return true
}
return hashContent(data) != lockHash
}
+24
View File
@@ -407,4 +407,28 @@ func TestInfoReturnsFrontmatterSourceAndHash(t *testing.T) {
if info.Hash != installed.Hash {
t.Fatalf("Info hash = %q, want %q", info.Hash, installed.Hash)
}
if info.HashDrift {
t.Fatal("expected no hash drift immediately after install")
}
if err := os.WriteFile(info.Skill.Path, []byte("---\nname: demo\ndescription: described\n---\nedited body\n"), 0o644); err != nil {
t.Fatal(err)
}
info, ok = Info(destDir, "demo")
if !ok {
t.Fatal("Info(demo) not found after edit")
}
if !info.HashDrift {
t.Fatal("expected hash drift after SKILL.md edit")
}
}
func TestSkillHashDriftUnreadableLockedPath(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing", "SKILL.md")
if !skillHashDrift(Skill{Path: missing}, "sha256:deadbeef") {
t.Fatal("expected drift when locked SKILL.md cannot be read")
}
if skillHashDrift(Skill{Path: missing}, "") {
t.Fatal("missing lock hash must not count as drift")
}
}
+1 -1
View File
@@ -827,7 +827,7 @@ func runChildProcess(ctx context.Context, binaryPath string, args []string, prog
// ExitCode() is -1 when the child was terminated by a signal rather
// than exiting. ProcessState.String() is the portable description
// (e.g. "signal: killed") — capture it so the failure isn't opaque.
signalDesc = exitErr.ProcessState.String()
signalDesc = exitErr.String()
}
} else {
return ChildRunResult{Events: events, Stderr: stderr.String(), ExitCode: -1, Started: started}, fmt.Errorf("run specialist child: %w", err)
+9 -27
View File
@@ -106,7 +106,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil && !msysGuardBypassed(issue, commandEngine) {
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtime.GOOS)); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
@@ -293,23 +293,19 @@ func shellIssueBlockResult(issue shellIssue) Result {
}
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
// commandText. On Windows, when the command is not wrapped by the sandbox
// engine (plan.Wrapped == false), it also overrides the child's raw command
// line so commandText reaches cmd.exe unescaped; see
// zeroSandbox.WindowsShellCommandLine for why that matters. The wrapped case
// gets the same treatment inside the sandboxed runner process itself
// (internal/sandbox/windows_process_windows.go), since that command line is
// built there, not here.
// commandText. PowerShell is preferred on Windows, with cmd.exe retained as
// the fallback. Only cmd.exe needs the raw command-line override.
func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) {
hostShell := detectShellRuntime(runtime.GOOS)
spec := zeroSandbox.CommandSpec{
Name: shellExecutable(),
Args: shellArguments(commandText),
Name: hostShell.Executable,
Args: hostShell.arguments(commandText),
Dir: absoluteCwd,
}
if engine != nil {
command, plan, err := engine.CommandContext(ctx, spec)
if err == nil {
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
}
return command, plan, err
}
@@ -333,7 +329,7 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
}
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
command.Dir = spec.Dir
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
return command, plan, nil
}
@@ -385,20 +381,6 @@ func interactiveBlockResult(detection zeroSandbox.InteractiveCommandResult) Resu
}
}
func shellExecutable() string {
if runtime.GOOS == "windows" {
return "cmd.exe"
}
return "/bin/sh"
}
func shellArguments(command string) []string {
if runtime.GOOS == "windows" {
return zeroSandbox.WindowsShellArgs(command)
}
return []string{"-c", command}
}
func commandExitCode(err error) int {
if err == nil {
return 0
@@ -621,7 +603,7 @@ func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int,
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
output := formatBashOutput(stdout, stderr, exitCode)
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
if issue := detectShellOutputIssueForRuntime(stdout+"\n"+stderr, detectShellRuntime(runtime.GOOS)); issue != nil {
meta["shell_issue"] = issue.Kind
output = appendShellIssueHint(output, *issue)
}
+3 -3
View File
@@ -36,6 +36,6 @@ func hardenProcessLifetime(command *exec.Cmd) {
}
}
// applyWindowsShellCommandLine is a no-op outside Windows; there is no
// cmd.exe command-line-parsing quirk to work around here.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {}
// applyWindowsShellCommandLine is a no-op outside Windows.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool, cmdFallback bool) {
}
+6 -9
View File
@@ -31,15 +31,12 @@ func hardenProcessLifetime(command *exec.Cmd) {
}
}
// applyWindowsShellCommandLine overrides command's raw child command line so
// commandText reaches cmd.exe unescaped instead of auto-quoted the way
// exec.Cmd would normally encode a single Args element. Skipped when wrapped
// is true: the sandbox engine then routes execution through a separate
// zero-windows-command-runner process, which builds its own child command
// line from scratch (internal/sandbox/windows_process_windows.go) rather than
// inheriting whatever this outer exec.Cmd is configured with.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {
if wrapped {
// applyWindowsShellCommandLine overrides the cmd.exe fallback's raw child
// command line so commandText reaches cmd.exe unescaped. PowerShell consumes
// ordinary argv quoting and must not take this path. Wrapped commands are
// handled after unwrapping by the Windows sandbox runner.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool, cmdFallback bool) {
if wrapped || !cmdFallback {
return
}
command.SysProcAttr = &syscall.SysProcAttr{CmdLine: zeroSandbox.WindowsShellCommandLine(commandText)}
+39 -13
View File
@@ -124,11 +124,15 @@ func TestBashToolDescribesHostShellSyntax(t *testing.T) {
}
if runtime.GOOS == "windows" {
if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe and cwd guidance in bash description, got %q", description)
}
if !strings.Contains(description, "double quotes") || !strings.Contains(description, `--jq ".a | b"`) {
t.Fatalf("expected the double-quote metacharacter rule in bash description, got %q", description)
shell := detectShellRuntime(runtime.GOOS)
if shell.Kind == shellKindPowerShell {
for _, want := range []string{"powershell", "cwd", "get-childitem", "select-string", "$env:name"} {
if !strings.Contains(description, want) {
t.Fatalf("expected Windows PowerShell guidance %q in bash description, got %q", want, description)
}
}
} else if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe fallback guidance in bash description, got %q", description)
}
return
}
@@ -455,13 +459,14 @@ func TestBashToolReturnsNonzeroExitAsError(t *testing.T) {
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: 7"} {
wantExitCode := strconv.Itoa(helperFailureExitCode())
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: " + wantExitCode} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
}
}
if result.Meta["exit_code"] != "7" {
t.Fatalf("expected exit_code metadata 7, got %q", result.Meta["exit_code"])
if result.Meta["exit_code"] != wantExitCode {
t.Fatalf("expected exit_code metadata %s, got %q", wantExitCode, result.Meta["exit_code"])
}
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
@@ -578,10 +583,16 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
}
registry := NewRegistry()
registry.Register(NewScopedBashTool(root, nil))
msysCommand := "cat somefile.txt"
if detectShellRuntime(runtime.GOOS).Kind == shellKindPowerShell {
// cat is a native Get-Content alias in PowerShell. Use a name that
// still resolves to an incompatible Git-for-Windows MSYS executable.
msysCommand = "grep pattern somefile.txt"
}
t.Run("default sandboxing still blocks an MSYS-prone command", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": "cat somefile.txt",
"command": msysCommand,
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
@@ -594,7 +605,7 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
t.Run("approved require_escalated bypasses the MSYS guard", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": "cat somefile.txt",
"command": msysCommand,
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
@@ -614,7 +625,14 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
t.Run("approved require_escalated still blocks an unrelated syntax issue", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": `cd /d/tmp/zero-pr-158 && dir`,
// No `&&` here on purpose. The POSIX-style path is what this case is
// about, and on Windows PowerShell 5.1 a chained command trips the
// separate windows_powershell_version preflight first, which shadows
// the block being asserted. That only shows up on 5.1: the runners
// have PowerShell 7, where && is valid and the version check does not
// fire, so the substitution kept this green in CI while failing on the
// shell a stock Windows box actually gets.
"command": `cd /d/tmp/zero-pr-158`,
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
@@ -648,8 +666,9 @@ func TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure(t *testing.T)
"command": command,
})
if result.Status != StatusError || result.Meta["exit_code"] != "7" {
t.Fatalf("expected the helper's real exit 7 failure, got %s: %#v", result.Status, result)
wantExitCode := strconv.Itoa(helperFailureExitCode())
if result.Status != StatusError || result.Meta["exit_code"] != wantExitCode {
t.Fatalf("expected the helper failure exit %s, got %s: %#v", wantExitCode, result.Status, result)
}
if result.Meta["shell_issue"] == "windows_msys_sandbox" {
t.Fatalf("expected the MSYS marker in the command's own argument text to be ignored, got %#v", result)
@@ -860,6 +879,9 @@ func TestBashToolRunsCommandLineForLoopSyntax(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("cmd.exe command-line vs batch-file parsing is Windows-specific")
}
if detectShellRuntime(runtime.GOOS).Kind != shellKindCmd {
t.Skip("cmd.exe fallback is not selected")
}
root := t.TempDir()
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
@@ -881,6 +903,10 @@ func helperCommand(name string) string {
return executable + " --zero-bash-helper " + name
}
func helperFailureExitCode() int {
return 7
}
func shellQuote(value string) string {
if runtime.GOOS == "windows" {
return value
+2 -2
View File
@@ -173,7 +173,7 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil && !msysGuardBypassed(issue, commandEngine) {
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtimeGOOS())); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
@@ -459,7 +459,7 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssue(output, runtimeGOOS()); issue != nil {
if issue := detectShellOutputIssueForRuntime(output, detectShellRuntime(runtimeGOOS())); issue != nil {
meta["shell_issue"] = issue.Kind
body = appendShellIssueHint(body, *issue)
}
+138 -32
View File
@@ -18,7 +18,7 @@ import (
)
func TestIndependentExecCommandConstructorsShareDefaultManager(t *testing.T) {
root := t.TempDir()
root := execTestRoot(t)
execTool := NewScopedExecCommandTool(root, nil, nil)
writeTool := NewWriteStdinTool(nil)
@@ -76,17 +76,21 @@ func TestExecCommandToolDescribesHostShellSyntax(t *testing.T) {
description := strings.ToLower(strings.Join(descriptionParts, " "))
if runtime.GOOS == "windows" {
if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe and cwd guidance in exec_command description, got %q", description)
}
if !strings.Contains(description, "double quotes") || !strings.Contains(description, `--jq ".a | b"`) {
t.Fatalf("expected the double-quote metacharacter rule in exec_command description, got %q", description)
shell := detectShellRuntime(runtime.GOOS)
if shell.Kind == shellKindPowerShell {
for _, want := range []string{"powershell", "cwd", "get-childitem", "select-string", "$env:name"} {
if !strings.Contains(description, want) {
t.Fatalf("expected Windows PowerShell guidance %q in exec_command description, got %q", want, description)
}
}
} else if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe fallback guidance in exec_command description, got %q", description)
}
}
}
func TestExecCommandReturnsSessionAndWriteStdinPollsCompletion(t *testing.T) {
root := t.TempDir()
root := execTestRoot(t)
manager := newExecSessionManager()
execTool := NewScopedExecCommandTool(root, nil, manager)
writeTool := NewWriteStdinTool(manager)
@@ -134,7 +138,7 @@ func TestExecCommandReturnsSessionAndWriteStdinPollsCompletion(t *testing.T) {
}
func TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval(t *testing.T) {
root := t.TempDir()
root := execTestRoot(t)
manager := newExecSessionManager()
registry := NewRegistry()
registry.Register(NewScopedExecCommandTool(root, nil, manager))
@@ -183,7 +187,7 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
if runtime.GOOS != "windows" {
t.Skip("windows-only MSYS sandbox guard")
}
root := t.TempDir()
root := execTestRoot(t)
manager := newExecSessionManager()
registry := NewRegistry()
registry.Register(NewScopedExecCommandTool(root, nil, manager))
@@ -192,9 +196,15 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
})
msysCommand := "cat somefile.txt"
if detectShellRuntime(runtime.GOOS).Kind == shellKindPowerShell {
// cat is a native Get-Content alias in PowerShell, so use an executable
// name that still exercises the MSYS guard.
msysCommand = "grep pattern somefile.txt"
}
result := registry.RunWithOptions(context.Background(), ExecCommandToolName, map[string]any{
"cmd": "cat somefile.txt",
"cmd": msysCommand,
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
@@ -204,8 +214,8 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
// Assert on the preflight block sentinel (exit_code "-1", set only by
// shellIssueBlockResult) rather than shell_issue: once the guard is
// bypassed, "cat somefile.txt" actually runs, and its real,
// PATH-dependent output could otherwise trip the unrelated
// bypassed, the command actually runs, and its real, PATH-dependent output
// could otherwise trip the unrelated
// post-execution detectShellOutputIssue heuristic and make this
// assertion flaky for reasons unrelated to the guard under test.
if result.Meta["exit_code"] == "-1" {
@@ -214,7 +224,7 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
}
func TestExecCommandReturnsExitCodeWhenCommandCompletesDuringInitialYield(t *testing.T) {
root := t.TempDir()
root := execTestRoot(t)
manager := newExecSessionManager()
execTool := NewScopedExecCommandTool(root, nil, manager)
@@ -253,7 +263,7 @@ func TestExecCommandReportsWorkspaceChanges(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell syntax")
}
root := t.TempDir()
root := execTestRoot(t)
result := NewScopedExecCommandTool(root, nil, newExecSessionManager()).Run(context.Background(), map[string]any{
"cmd": "mkdir -p src node_modules/pkg && printf 'export {}' > src/main.ts && printf generated > node_modules/pkg/index.js",
"yield_time_ms": 30000,
@@ -284,8 +294,9 @@ func TestExecCommandApplicationFailureHasTypedOutcome(t *testing.T) {
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
}
if result.ExecutionOutcome.Exit == nil || result.ExecutionOutcome.Exit.Code != 7 {
t.Fatalf("execution exit = %#v, want code 7", result.ExecutionOutcome.Exit)
wantExitCode := helperFailureExitCode()
if result.ExecutionOutcome.Exit == nil || result.ExecutionOutcome.Exit.Code != wantExitCode {
t.Fatalf("execution exit = %#v, want code %d", result.ExecutionOutcome.Exit, wantExitCode)
}
}
@@ -316,6 +327,26 @@ func TestExecCommandUsesStructuredAdapterDenial(t *testing.T) {
}
}
func TestExecCommandDoesNotInferNativeSandboxDenialFromOutput(t *testing.T) {
result := execToolResult(execToolResultInput{
commandText: "touch \"$HOME/probe\"",
output: "touch: cannot touch '/home/user/probe': Read-only file system",
exited: true,
exitCode: 1,
enforcement: execution.Enforcement{Backend: string(sandbox.BackendLinuxBwrap), Level: string(sandbox.EnforcementNative)},
})
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
}
if result.ExecutionOutcome.Denial != nil {
t.Fatalf("command output created a typed denial: %#v", result.ExecutionOutcome.Denial)
}
if result.Meta[SandboxLikelyDeniedMeta] == "true" {
t.Fatalf("command output created sandbox-denial metadata: %#v", result.Meta)
}
}
func executionRequestHasCapability(request execution.Request, kind execution.CapabilityKind, scope string) bool {
for _, capability := range request.Capabilities {
if capability.Kind == kind && (scope == "" || capability.Scope == scope) {
@@ -326,7 +357,7 @@ func executionRequestHasCapability(request execution.Request, kind execution.Cap
}
func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) {
root := t.TempDir()
root := execTestRoot(t)
manager := newExecSessionManager()
execTool := NewScopedExecCommandTool(root, nil, manager)
writeTool := NewWriteStdinTool(manager)
@@ -380,7 +411,7 @@ func parseListeningAddress(output string) string {
}
func TestExecCommandReapsFinishedUnpolledSession(t *testing.T) {
root := t.TempDir()
root := execTestRoot(t)
manager := execution.NewProcessManager(execution.ProcessManagerOptions{CompletedRetention: 10 * time.Millisecond})
execTool := NewScopedExecCommandTool(root, nil, manager)
@@ -506,7 +537,7 @@ func TestWriteStdinInterruptTerminatesSession(t *testing.T) {
}
func TestWriteStdinRejectsInputForNonTTYSession(t *testing.T) {
root := t.TempDir()
root := execTestRoot(t)
manager := newExecSessionManager()
execTool := NewScopedExecCommandTool(root, nil, manager)
writeTool := NewWriteStdinTool(manager)
@@ -537,9 +568,43 @@ func TestWriteStdinRejectsInputForNonTTYSession(t *testing.T) {
manager.Stop(sessionID)
}
// execTestRoot is t.TempDir with a cleanup that tolerates Windows still holding
// the directory.
//
// A terminated process does not release its handles the instant the session
// reports it exited, and Windows refuses to remove a directory with an open
// handle where POSIX does not. t.TempDir's own cleanup removes once and fails
// the test on a sharing violation, which turns ordinary teardown timing into a
// test failure with nothing actually wrong underneath it.
func execTestRoot(t *testing.T) string {
t.Helper()
root, err := os.MkdirTemp("", "zero-exec-test-")
if err != nil {
t.Fatalf("create test root: %v", err)
}
t.Cleanup(func() {
deadline := time.Now().Add(10 * time.Second)
for {
if err := os.RemoveAll(root); err == nil {
return
}
if time.Now().After(deadline) {
// Deliberately not a failure. The directory is under the OS temp
// root and the process holding it has already been terminated, so
// the worst case is a stale directory, not a broken test.
t.Logf("test root %s still held after the cleanup deadline; leaving it", root)
return
}
time.Sleep(50 * time.Millisecond)
}
})
return root
}
func TestWriteStdinStopIntentTerminatesNonTTYSession(t *testing.T) {
for _, chars := range []string{`\u0003`, "exit\n"} {
root := t.TempDir()
root := execTestRoot(t)
manager := newExecSessionManager()
execTool := NewScopedExecCommandTool(root, nil, manager)
writeTool := NewWriteStdinTool(manager)
@@ -556,16 +621,46 @@ func TestWriteStdinStopIntentTerminatesNonTTYSession(t *testing.T) {
t.Fatalf("session_id is not numeric: %v", err)
}
result := writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"chars": chars,
"yield_time_ms": 1000,
})
if result.Status != StatusOK {
t.Fatalf("stop input %q status = %s: %s", chars, result.Status, result.Output)
}
if result.Meta["session_id"] != "" {
t.Fatalf("stop input %q should not leave session running, meta=%#v output=%q", chars, result.Meta, result.Output)
// Re-send the stop until the session reports it has exited, rather than
// waiting a fixed slice and asserting on whatever is true by then.
//
// A single 1s yield used to be enough because the session ran under
// cmd.exe. Under PowerShell the tree is slower to stand up and tear down,
// and the FIRST stop in a fresh process pays the interpreter's cold start,
// so it routinely returns with termination already under way but not
// finished. The old assertion read that as "the stop did not work" and
// failed, intermittently, on whichever of the two inputs happened to run
// first. CI never saw it because the runners have PowerShell 7 while a
// stock Windows box only has 5.1.
//
// Repeating the same input rather than polling with empty chars is
// deliberate: Terminate is a no-op against an already-dead tree, so it is
// safe to repeat, and it keeps the interrupt flag that an empty-chars poll
// would drop, which is what the interrupted assertion below needs.
var result Result
// Every poll's output is kept, not just the last one. Each Continue
// returns only what the session produced since the previous collection,
// so a marker printed just before the poll that observes the exit lands
// in the earlier result and would be invisible to a check against the
// final one alone.
var collected strings.Builder
deadline := time.Now().Add(30 * time.Second)
for {
result = writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"chars": chars,
"yield_time_ms": 500,
})
collected.WriteString(result.Output)
if result.Status != StatusOK {
t.Fatalf("stop input %q status = %s: %s", chars, result.Status, result.Output)
}
if result.Meta["session_id"] == "" {
break
}
if time.Now().After(deadline) {
t.Fatalf("stop input %q left the session running past the deadline, meta=%#v output=%q", chars, result.Meta, result.Output)
}
}
if result.Meta["exit_code"] == "" {
t.Fatalf("stop input %q should report exit_code, meta=%#v output=%q", chars, result.Meta, result.Output)
@@ -573,6 +668,17 @@ func TestWriteStdinStopIntentTerminatesNonTTYSession(t *testing.T) {
if result.Meta["interrupted"] != "true" {
t.Fatalf("stop input %q should report interrupted metadata, meta=%#v output=%q", chars, result.Meta, result.Output)
}
// The session has to have been killed rather than allowed to finish.
// Without this the retry loop makes the test unfalsifiable: the helper
// sleeps 5s, well inside the deadline, so a terminate that did nothing at
// all would let the sleep end on its own and every assertion above would
// still hold. The interrupted flag does not close the hole either, since
// it echoes the request rather than the outcome. The helper prints this
// line only on the way out of a completed sleep, so its absence is what
// separates the two, and it says nothing about how long the loop took.
if strings.Contains(collected.String(), "long sleep finished") {
t.Fatalf("stop input %q let the session run to completion instead of terminating it, output=%q", chars, collected.String())
}
}
}
@@ -612,7 +718,7 @@ func TestExecCommandTTYSessionAcceptsInputOnLinux(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("pty transport is currently implemented for linux")
}
root := t.TempDir()
root := execTestRoot(t)
manager := newExecSessionManager()
execTool := NewScopedExecCommandTool(root, nil, manager)
writeTool := NewWriteStdinTool(manager)
@@ -650,7 +756,7 @@ func TestExecCommandTTYSessionAcceptsInputOnLinux(t *testing.T) {
}
func TestExecSessionSnapshotsAndStopAll(t *testing.T) {
root := t.TempDir()
root := execTestRoot(t)
manager := newExecSessionManager()
execTool := NewScopedExecCommandTool(root, nil, manager).(execCommandTool)
+187
View File
@@ -0,0 +1,187 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Gitlawb/zero/internal/sessions"
)
type goalTool struct {
baseTool
store *sessions.Store
sessionID string
action string
}
// NewGoalTools returns goal lifecycle tools bound to one captured session. The
// binding prevents a late tool call from mutating a different session after a
// resume or session switch.
func NewGoalTools(store *sessions.Store, sessionID string) []Tool {
return []Tool{
newGetGoalTool(store, sessionID),
newCreateGoalTool(store, sessionID),
newUpdateGoalTool(store, sessionID),
}
}
func newGetGoalTool(store *sessions.Store, sessionID string) Tool {
return &goalTool{
baseTool: baseTool{
name: "get_goal",
description: "Get the current persistent goal and its status, token budget, and usage for this session.",
parameters: Schema{
Type: "object",
AdditionalProperties: false,
},
safety: readOnlySafety("Reads goal state for the current session."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true},
},
store: store,
sessionID: sessionID,
action: "get",
}
}
func newCreateGoalTool(store *sessions.Store, sessionID string) Tool {
minimum := 0
maximum := 1_000_000_000
maxObjectiveLength := sessions.GoalObjectiveMaxLength
return &goalTool{
baseTool: baseTool{
name: "create_goal",
description: "Create one persistent goal for this session when the user explicitly asks to pursue an ongoing objective. " +
"Do not infer a goal from an ordinary one-turn task. This fails while any goal is still stored.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"objective": {
Type: "string",
Description: "The concrete objective to keep pursuing.",
MaxLength: &maxObjectiveLength,
},
"token_budget": {
Type: "integer",
Description: "Optional maximum total tokens. Zero means no goal-specific limit.",
Minimum: &minimum,
Maximum: &maximum,
},
},
Required: []string{"objective"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
Reason: "Creates persistent goal state and enables automatic follow-up runs for the current session.",
AdvertiseInAuto: true,
},
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
store: store,
sessionID: sessionID,
action: "create",
}
}
func newUpdateGoalTool(store *sessions.Store, sessionID string) Tool {
return &goalTool{
baseTool: baseTool{
name: "update_goal",
description: "Finish the current goal or mark it blocked. Use complete only when the objective is achieved and no required work remains. " +
"Use blocked only when progress cannot continue without user input or an external state change.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"status": {
Type: "string",
Description: "The terminal state to apply.",
Enum: []string{string(sessions.GoalStatusComplete), string(sessions.GoalStatusBlocked)},
},
"reason": {
Type: "string",
Description: "Why the goal is blocked. Optional for completed goals.",
},
},
Required: []string{"status"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectNone,
Permission: PermissionAllow,
Reason: "Updates persistent goal state for the current session.",
},
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
store: store,
sessionID: sessionID,
action: "update",
}
}
func (tool *goalTool) Run(_ context.Context, args map[string]any) Result {
if tool.store == nil || !sessions.ValidSessionID(tool.sessionID) {
return errorResult("Error: Goal state is unavailable for this run.")
}
switch tool.action {
case "get":
session, err := tool.store.Get(tool.sessionID)
if err != nil {
return errorResult("Error: Read goal: " + err.Error())
}
if session == nil || session.Goal == nil {
return okResult("No goal is set for this session.")
}
return goalResult(session.Goal)
case "create":
objective, err := stringArg(args, "objective", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for create_goal: " + err.Error())
}
tokenBudget, err := intArg(args, "token_budget", 0, 0, 1_000_000_000)
if err != nil {
return errorResult("Error: Invalid arguments for create_goal: " + err.Error())
}
session, _, err := tool.store.CreateGoal(tool.sessionID, objective, tokenBudget)
if err != nil {
return errorResult("Error: Create goal: " + err.Error())
}
return goalResult(session.Goal)
case "update":
statusText, err := stringArg(args, "status", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for update_goal: " + err.Error())
}
status := sessions.GoalStatus(strings.ToLower(statusText))
if status != sessions.GoalStatusComplete && status != sessions.GoalStatusBlocked {
return errorResult(`Error: Invalid arguments for update_goal: status must be "complete" or "blocked"`)
}
reason, err := stringArgWithEmpty(args, "reason", "", false, true)
if err != nil {
return errorResult("Error: Invalid arguments for update_goal: " + err.Error())
}
if status == sessions.GoalStatusBlocked && strings.TrimSpace(reason) == "" {
return errorResult("Error: Invalid arguments for update_goal: blocked goals require a reason")
}
session, _, err := tool.store.UpdateGoal(tool.sessionID, status, reason)
if err != nil {
return errorResult("Error: Update goal: " + err.Error())
}
return goalResult(session.Goal)
default:
return errorResult("Error: Unknown goal action.")
}
}
func goalResult(goal *sessions.Goal) Result {
if goal == nil {
return okResult("No goal is set for this session.")
}
data, err := json.MarshalIndent(goal, "", " ")
if err != nil {
return errorResult(fmt.Sprintf("Error: Encode goal: %v", err))
}
return okResult(string(data))
}
+76
View File
@@ -0,0 +1,76 @@
package tools
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sessions"
)
func TestGoalToolsAreBoundToTheirSession(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
first, err := store.Create(sessions.CreateInput{SessionID: "first"})
if err != nil {
t.Fatal(err)
}
second, err := store.Create(sessions.CreateInput{SessionID: "second"})
if err != nil {
t.Fatal(err)
}
goalTools := NewGoalTools(store, first.SessionID)
result := goalTools[1].Run(context.Background(), map[string]any{"objective": "Finish first"})
if result.Status != StatusOK {
t.Fatalf("create result = %#v", result)
}
loadedFirst, _ := store.Get(first.SessionID)
loadedSecond, _ := store.Get(second.SessionID)
if loadedFirst.Goal == nil || loadedFirst.Goal.Objective != "Finish first" {
t.Fatalf("first goal = %#v", loadedFirst.Goal)
}
if loadedSecond.Goal != nil {
t.Fatalf("second goal unexpectedly changed = %#v", loadedSecond.Goal)
}
}
func TestCreateGoalToolDeclaresTokenBudgetMaximum(t *testing.T) {
create := NewGoalTools(nil, "goal")[1]
maximum := create.Parameters().Properties["token_budget"].Maximum
if maximum == nil || *maximum != 1_000_000_000 {
t.Fatalf("token_budget maximum = %v, want 1000000000", maximum)
}
maxLength := create.Parameters().Properties["objective"].MaxLength
if maxLength == nil || *maxLength != sessions.GoalObjectiveMaxLength {
t.Fatalf("objective maxLength = %v, want %d", maxLength, sessions.GoalObjectiveMaxLength)
}
safety := create.Safety()
if safety.SideEffect != SideEffectWrite || safety.Permission != PermissionPrompt || !safety.AdvertiseInAuto {
t.Fatalf("create_goal safety = %#v, want prompted persistent write advertised in auto mode", safety)
}
}
func TestUpdateGoalToolRestrictsAgentTransitions(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(sessions.CreateInput{SessionID: "goal"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Finish it", 0); err != nil {
t.Fatal(err)
}
update := NewGoalTools(store, session.SessionID)[2]
result := update.Run(context.Background(), map[string]any{"status": "paused"})
if result.Status != StatusError || !strings.Contains(result.Output, "complete") {
t.Fatalf("paused result = %#v", result)
}
result = update.Run(context.Background(), map[string]any{"status": "blocked"})
if result.Status != StatusError || !strings.Contains(result.Output, "require a reason") {
t.Fatalf("reasonless blocked result = %#v", result)
}
result = update.Run(context.Background(), map[string]any{"status": "complete"})
if result.Status != StatusOK {
t.Fatalf("complete result = %#v", result)
}
}
+301 -14
View File
@@ -1,15 +1,33 @@
package tools
import (
"context"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"unicode"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
type shellKind string
const (
shellKindPOSIX shellKind = "posix"
shellKindPowerShell shellKind = "powershell"
shellKindCmd shellKind = "cmd"
)
type shellRuntime struct {
GOOS string
Executable string
Syntax string
Kind shellKind
}
type shellIssue struct {
@@ -20,7 +38,18 @@ type shellIssue struct {
const windowsMsysSandboxKind = "windows_msys_sandbox"
const windowsMsysSandboxSuggestion = "MSYS/Cygwin coreutils and shells (bash, sh) from Git for Windows cannot run under Zero's write-restricted Windows sandbox, and the WSL bash launcher cannot reach the WSL service from it either. This also hits native commands that spawn Git Bash internally: git hooks and git/gh credential helpers can fail this way even though git and gh themselves run fine. Prefer Zero native tools (grep, read_file with offset/limit, list_directory, glob), cmd.exe findstr, or PowerShell Select-Object -First/-Last. If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
const windowsMsysSandboxSuggestion = "MSYS/Cygwin executables and shells (bash, sh) from Git for Windows cannot run under Zero's write-restricted Windows sandbox, and the WSL bash launcher cannot reach the WSL service from it either. This also hits native commands that spawn Git Bash internally: git hooks and git/gh credential helpers can fail this way even though git and gh themselves run fine. Prefer native PowerShell cmdlets or Zero tools (grep, read_file with offset/limit, list_directory, glob). If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
const windowsPowerShellUTF8Prefix = "try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}\n"
const windowsPowerShellFailurePrefix = "$ErrorActionPreference = 'Stop'\n"
const windowsPowerShellExitSuffix = "\nif ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }\n"
var (
hostShellOnce sync.Once
hostShell shellRuntime
)
// windowsMsysProneNames is the single source of truth for POSIX coreutil and
// shell names that commonly resolve to a Git-for-Windows MSYS/Cygwin binary
@@ -54,24 +83,139 @@ var (
)
func detectShellRuntime(goos string) shellRuntime {
if goos == "windows" {
return shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe"}
if goos != runtime.GOOS {
return detectShellRuntimeWithLookup(goos, exec.LookPath, os.Getenv)
}
return shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh"}
hostShellOnce.Do(func() {
hostShell = detectShellRuntimeWithProbe(goos, exec.LookPath, os.Getenv, powerShellExecutableUsable)
})
return hostShell
}
func detectShellRuntimeWithLookup(goos string, lookPath func(string) (string, error), getenv func(string) string) shellRuntime {
return detectShellRuntimeWithProbe(goos, lookPath, getenv, func(string) bool { return true })
}
func detectShellRuntimeWithProbe(goos string, lookPath func(string) (string, error), getenv func(string) string, usable func(string) bool) shellRuntime {
if goos == "windows" {
for _, candidate := range windowsPowerShellCandidates(getenv) {
if path, err := lookPath(candidate); err == nil && strings.TrimSpace(path) != "" && usable(path) {
return shellRuntime{
GOOS: goos,
Executable: path,
Syntax: "PowerShell",
Kind: shellKindPowerShell,
}
}
}
return shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe", Kind: shellKindCmd}
}
return shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh", Kind: shellKindPOSIX}
}
func powerShellExecutableUsable(path string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
command := exec.CommandContext(ctx, path, "-NoLogo", "-NoProfile", "-Command", "exit 0")
return command.Run() == nil
}
func windowsPowerShellCandidates(getenv func(string) string) []string {
candidates := []string{"pwsh.exe", "pwsh"}
if programFiles := strings.TrimSpace(getenv("ProgramFiles")); programFiles != "" {
candidates = append(candidates, filepath.Join(programFiles, "PowerShell", "7", "pwsh.exe"))
}
candidates = append(candidates, "powershell.exe", "powershell")
if systemRoot := strings.TrimSpace(getenv("SystemRoot")); systemRoot != "" {
candidates = append(candidates, filepath.Join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
}
return candidates
}
func (shell shellRuntime) arguments(command string) []string {
switch shell.Kind {
case shellKindPowerShell:
script := windowsPowerShellUTF8Prefix + windowsPowerShellFailurePrefix + command + windowsPowerShellExitSuffix
return []string{"-NoLogo", "-NoProfile", "-Command", script}
case shellKindCmd:
return zeroSandbox.WindowsShellArgs(command)
default:
return []string{"-c", command}
}
}
// HostShellCommand returns the shell executable and argv used for a command on
// this host. Keeping this in one place makes agent tools and the user-typed TUI
// shell escape use the same Windows shell and fallback behavior.
func HostShellCommand(command string) (string, []string) {
shell := detectShellRuntime(runtime.GOOS)
return shell.Executable, shell.arguments(command)
}
// NewHostShellCommandContext constructs a direct host-shell command. The
// cmd.exe fallback needs a Windows-specific raw command line, while PowerShell
// and POSIX shells use normal argv handling.
func NewHostShellCommandContext(ctx context.Context, commandText string) *exec.Cmd {
shell := detectShellRuntime(runtime.GOOS)
command := exec.CommandContext(ctx, shell.Executable, shell.arguments(commandText)...)
applyWindowsShellCommandLine(command, commandText, false, shell.Kind == shellKindCmd)
return command
}
func shellGuidanceForGOOS(goos string) string {
runtime := detectShellRuntime(goos)
if goos == "windows" {
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories. To include | & > < or other metacharacters in an argument value, wrap the value in double quotes (e.g. --jq \".a | b\"); single quotes do not protect metacharacters in cmd.exe. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
return shellGuidanceForRuntime(detectShellRuntime(goos))
}
func shellGuidanceForRuntime(shell shellRuntime) string {
if shell.GOOS == "windows" && shell.Kind == shellKindPowerShell {
guidance := "Uses PowerShell syntax on Windows; prefer cwd/workdir over Set-Location when changing directories. Examples: Get-ChildItem -Force; Get-ChildItem -Recurse -Filter *.go; Get-ChildItem -Recurse | Select-String -Pattern 'TODO'; Get-Process | Where-Object { $_.ProcessName -like '*node*' }; $env:NAME='value'; @'\nprint('hello')\n'@ | python -. Do not invoke Git-for-Windows MSYS/Cygwin executables (bash, sh, grep.exe, sed.exe, head.exe, and similar) inside the restricted sandbox; prefer PowerShell cmdlets or native Zero tools."
return guidance + legacyPowerShellChainGuidance(shell)
}
guidance := "Uses " + runtime.Syntax + " syntax."
if goos == "darwin" {
if shell.GOOS == "windows" {
return "Uses " + shell.Syntax + " syntax on Windows because PowerShell was unavailable; prefer cwd/workdir over cd when changing directories. To include | & > < or other metacharacters in an argument value, wrap the value in double quotes (e.g. --jq \".a | b\"); single quotes do not protect metacharacters in cmd.exe. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
}
guidance := "Uses " + shell.Syntax + " syntax."
if shell.GOOS == "darwin" {
guidance += " To find or stop a process, use `lsof -i :PORT` (or `lsof -nP -iTCP -sTCP:LISTEN`) for the PID then `kill <pid>`; `ps` and `pgrep` do not work under the sandbox."
}
return guidance
}
// HostShellEnvironmentGuidance returns the concise, model-facing shell rule
// for the current host.
func HostShellEnvironmentGuidance() string {
return hostShellEnvironmentGuidanceForRuntime(detectShellRuntime(runtime.GOOS))
}
func hostShellEnvironmentGuidanceForRuntime(shell shellRuntime) string {
if shell.GOOS == "windows" && shell.Kind == shellKindPowerShell {
guidance := "Shell syntax: PowerShell for exec_command/bash tools. Use PowerShell cmdlets and pipelines (Get-ChildItem, Get-Content, Select-String, Select-Object), use $env:NAME='value' for environment variables, and prefer the workdir/cwd argument over Set-Location. Do not invoke Git-for-Windows MSYS binaries (bash, sh, grep.exe, sed.exe, head.exe, and similar) inside the restricted sandbox; use native PowerShell cmdlets or Zero tools instead, or sandbox_permissions require_escalated only when host-level execution is truly required."
return guidance + legacyPowerShellChainGuidance(shell)
}
if shell.GOOS == "windows" {
return "Shell syntax: Windows cmd.exe syntax for exec_command/bash tools because PowerShell is unavailable. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes. Do not invoke Git-for-Windows MSYS binaries inside the restricted sandbox; use native Zero tools instead. Prefer the workdir/cwd argument over cd."
}
return "Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories."
}
func legacyPowerShellChainGuidance(shell shellRuntime) string {
if !shell.isWindowsPowerShell() {
return ""
}
return " This host uses Windows PowerShell 5.1, which does not support && or ||. Use separate statements when execution is unconditional, or if ($?) { ... } and if (-not $?) { ... } for conditional chaining."
}
func (shell shellRuntime) isWindowsPowerShell() bool {
if shell.Kind != shellKindPowerShell {
return false
}
executable := strings.TrimSpace(shell.Executable)
if index := strings.LastIndexAny(executable, `\/`); index >= 0 {
executable = executable[index+1:]
}
return strings.EqualFold(executable, "powershell") || strings.EqualFold(executable, "powershell.exe")
}
// MsysProneCommandName reports whether a bare command name commonly resolves to
// a Git-for-Windows MSYS binary that fails under the Windows restricted sandbox.
func MsysProneCommandName(name string) bool {
@@ -172,7 +316,15 @@ func msysProneCommandWord(word string) bool {
}
func detectShellCommandIssue(command string, goos string) *shellIssue {
if goos != "windows" {
shell := shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh", Kind: shellKindPOSIX}
if goos == "windows" {
shell = shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe", Kind: shellKindCmd}
}
return detectShellCommandIssueForRuntime(command, shell)
}
func detectShellCommandIssueForRuntime(command string, shell shellRuntime) *shellIssue {
if shell.GOOS != "windows" {
return nil
}
trimmed := strings.TrimSpace(command)
@@ -184,7 +336,24 @@ func detectShellCommandIssue(command string, goos string) *shellIssue {
// cat), so an escaped metachar can't stand in as a fake segment boundary
// either.
unquoted := stripCmdCaretEscapes(stripDoubleQuotedSpans(trimmed))
if shell.Kind == shellKindPowerShell {
unquoted = stripPowerShellQuotedSpans(trimmed)
if shell.isWindowsPowerShell() && (strings.Contains(unquoted, "&&") || strings.Contains(unquoted, "||")) {
return &shellIssue{
Kind: "windows_powershell_version",
Message: "Command uses && or ||, but this host runs Windows PowerShell 5.1, which does not support those operators.",
Suggestion: "Use separate statements when execution is unconditional, or if ($?) { ... } and if (-not $?) { ... } for conditional chaining.",
}
}
}
if windowsBashStyleCDPattern.MatchString(unquoted) {
if shell.Kind == shellKindPowerShell {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command looks like POSIX/Bash syntax, but Zero runs PowerShell commands on this Windows host.",
Suggestion: "Use the cwd/workdir argument instead of cd and use native PowerShell syntax or Zero tools such as list_directory, read_file, grep, and glob.",
}
}
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command looks like POSIX/Bash syntax, but Zero runs bash tool commands through Windows cmd.exe on this host.",
@@ -198,18 +367,128 @@ func detectShellCommandIssue(command string, goos string) *shellIssue {
// Being segment/word anchored rather than a whole-string regex or scan,
// neither check matches text that only appears inside a quoted argument
// (e.g. a commit message or PR comment body discussing head.exe).
for _, segment := range windowsCommandSegments(trimmed) {
segments := windowsCommandSegments(trimmed)
if shell.Kind == shellKindPowerShell {
segments = powerShellCommandSegments(trimmed)
}
for _, segment := range segments {
word := firstCommandWord(segment)
if windowsMsysBinaryPathPattern.MatchString(word) {
return windowsMsysSandboxIssue("Command invokes an MSYS/Cygwin binary path that cannot run under Zero's Windows sandbox.")
}
if msysProneCommandWord(word) {
if shell.Kind == shellKindPowerShell && powerShellAliasWord(word) {
continue
}
return windowsMsysSandboxIssue("Command uses a POSIX coreutil (head/tail/grep/cat/...) that commonly resolves to Git-for-Windows MSYS binaries incompatible with the Windows sandbox.")
}
}
return nil
}
// powerShellCommandSegments splits the subset of PowerShell syntax needed for
// command-position checks. It respects single/double quoted strings and the
// backtick escape, then splits on pipeline/statement operators. Complex script
// interpretation remains PowerShell's job; this scanner only determines
// whether a known-incompatible MSYS executable is actually invoked.
func powerShellCommandSegments(command string) []string {
var segments []string
var current strings.Builder
var quote rune
escaped := false
for _, c := range command {
if escaped {
current.WriteRune(c)
escaped = false
continue
}
if c == '`' {
current.WriteRune(c)
escaped = true
continue
}
if quote != 0 {
current.WriteRune(c)
if c == quote {
quote = 0
}
continue
}
if c == '\'' || c == '"' {
quote = c
current.WriteRune(c)
continue
}
if c == ';' || c == '|' || c == '&' {
if segment := strings.TrimSpace(current.String()); segment != "" {
segments = append(segments, segment)
}
current.Reset()
continue
}
current.WriteRune(c)
}
if segment := strings.TrimSpace(current.String()); segment != "" {
segments = append(segments, segment)
}
return segments
}
func powerShellAliasWord(word string) bool {
trimmed := strings.Trim(word, `"'`)
if strings.ContainsAny(trimmed, `\/`) || strings.HasSuffix(strings.ToLower(trimmed), ".exe") {
return false
}
switch strings.ToLower(trimmed) {
case "cat", "ls":
return true
default:
return false
}
}
func stripPowerShellQuotedSpans(command string) string {
var b strings.Builder
b.Grow(len(command))
var quote rune
escaped := false
runes := []rune(command)
for index := 0; index < len(runes); index++ {
c := runes[index]
if escaped {
b.WriteRune(' ')
escaped = false
continue
}
if c == '`' {
b.WriteRune(' ')
escaped = true
continue
}
if quote != 0 {
b.WriteRune(' ')
if c == quote {
// PowerShell escapes a single quote inside a single-quoted
// string by doubling it.
if quote == '\'' && index+1 < len(runes) && runes[index+1] == '\'' {
index++
b.WriteRune(' ')
continue
}
quote = 0
}
continue
}
if c == '\'' || c == '"' {
quote = c
b.WriteRune(' ')
continue
}
b.WriteRune(c)
}
return b.String()
}
// stripDoubleQuotedSpans replaces the contents of every double-quoted span
// (quotes included) with spaces, preserving the string's length and the
// position of unquoted text. cmd.exe treats a double-quoted span as a single
@@ -256,7 +535,15 @@ func stripCmdCaretEscapes(command string) string {
// quoted-text false positives the preflight command-position check exists to
// avoid, just after execution instead of before it.
func detectShellOutputIssue(output string, goos string) *shellIssue {
if goos != "windows" {
shell := shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh", Kind: shellKindPOSIX}
if goos == "windows" {
shell = shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe", Kind: shellKindCmd}
}
return detectShellOutputIssueForRuntime(output, shell)
}
func detectShellOutputIssueForRuntime(output string, shell shellRuntime) *shellIssue {
if shell.GOOS != "windows" {
return nil
}
lower := strings.ToLower(output)
@@ -266,8 +553,8 @@ func detectShellOutputIssue(output string, goos string) *shellIssue {
if wslServiceDeniedInOutput(lower) {
return windowsMsysSandboxIssue("WSL bash could not connect to the WSL service under Zero's Windows sandbox (Bash/Service/CreateInstance/E_ACCESSDENIED).")
}
if strings.Contains(lower, "the syntax of the command is incorrect") ||
strings.Contains(lower, "is not recognized as an internal or external command") {
if shell.Kind == shellKindCmd && (strings.Contains(lower, "the syntax of the command is incorrect") ||
strings.Contains(lower, "is not recognized as an internal or external command")) {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Windows cmd.exe rejected the command syntax.",
+208
View File
@@ -1,10 +1,218 @@
package tools
import (
"errors"
"os/exec"
"runtime"
"strings"
"testing"
)
func TestDetectShellRuntimePrefersPowerShellSevenOnWindows(t *testing.T) {
lookPath := func(name string) (string, error) {
if name == "pwsh.exe" {
return `C:\Program Files\PowerShell\7\pwsh.exe`, nil
}
return "", errors.New("not found")
}
shell := detectShellRuntimeWithLookup("windows", lookPath, func(string) string { return "" })
if shell.Kind != shellKindPowerShell || !strings.HasSuffix(strings.ToLower(shell.Executable), `\pwsh.exe`) {
t.Fatalf("shell = %#v, want PowerShell 7", shell)
}
}
func TestDetectShellRuntimeFallsBackThroughWindowsPowerShellToCmd(t *testing.T) {
t.Run("windows powershell", func(t *testing.T) {
lookPath := func(name string) (string, error) {
if name == "powershell.exe" {
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, nil
}
return "", errors.New("not found")
}
shell := detectShellRuntimeWithLookup("windows", lookPath, func(string) string { return "" })
if shell.Kind != shellKindPowerShell || !strings.HasSuffix(strings.ToLower(shell.Executable), `\powershell.exe`) {
t.Fatalf("shell = %#v, want Windows PowerShell", shell)
}
})
t.Run("cmd", func(t *testing.T) {
shell := detectShellRuntimeWithLookup(
"windows",
func(string) (string, error) { return "", errors.New("not found") },
func(string) string { return "" },
)
if shell.Kind != shellKindCmd || shell.Executable != "cmd.exe" {
t.Fatalf("shell = %#v, want cmd.exe fallback", shell)
}
})
}
func TestDetectShellRuntimeSkipsUnusablePowerShell(t *testing.T) {
lookPath := func(name string) (string, error) {
switch name {
case "pwsh.exe":
return `C:\broken\pwsh.exe`, nil
case "powershell.exe":
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, nil
default:
return "", errors.New("not found")
}
}
shell := detectShellRuntimeWithProbe(
"windows",
lookPath,
func(string) string { return "" },
func(path string) bool { return !strings.Contains(path, `\broken\`) },
)
if shell.Kind != shellKindPowerShell || strings.Contains(shell.Executable, `\broken\`) {
t.Fatalf("shell = %#v, want usable Windows PowerShell fallback", shell)
}
}
func TestPowerShellArgumentsDisableProfilesAndRequestUTF8(t *testing.T) {
shell := shellRuntime{GOOS: "windows", Executable: "pwsh.exe", Syntax: "PowerShell", Kind: shellKindPowerShell}
args := shell.arguments("Write-Output 'hello'")
joined := strings.Join(args, "\n")
for _, want := range []string{
"-NoLogo",
"-NoProfile",
"-Command",
windowsPowerShellUTF8Prefix,
"$ErrorActionPreference = 'Stop'",
"Write-Output 'hello'",
"exit $LASTEXITCODE",
} {
if !strings.Contains(joined, want) {
t.Fatalf("PowerShell args missing %q: %#v", want, args)
}
}
script := args[len(args)-1]
preferenceIndex := strings.Index(script, "$ErrorActionPreference")
commandIndex := strings.Index(script, "Write-Output 'hello'")
exitIndex := strings.Index(script, "exit $LASTEXITCODE")
if preferenceIndex < 0 || commandIndex <= preferenceIndex || exitIndex <= commandIndex {
t.Fatalf("PowerShell failure handling is not ordered around the command: %q", script)
}
}
func TestWindowsPowerShellArgumentsPropagateFailures(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows PowerShell integration test")
}
executable, err := exec.LookPath("powershell.exe")
if err != nil {
t.Skipf("Windows PowerShell unavailable: %v", err)
}
shell := shellRuntime{
GOOS: "windows",
Executable: executable,
Syntax: "PowerShell",
Kind: shellKindPowerShell,
}
t.Run("native exit code after cmdlet", func(t *testing.T) {
command := exec.Command(executable, shell.arguments("cmd /c exit 5; Write-Output done")...)
output, err := command.CombinedOutput()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 5 {
t.Fatalf("exit = %v, output = %q; want exact native exit code 5", err, output)
}
if !strings.Contains(string(output), "done") {
t.Fatalf("output = %q, want command output before propagated failure", output)
}
})
t.Run("cmdlet error stops script", func(t *testing.T) {
missing := strings.ReplaceAll(t.TempDir()+`\missing`, `'`, `''`)
commandText := "Get-Item -LiteralPath '" + missing + "'; Write-Output after"
command := exec.Command(executable, shell.arguments(commandText)...)
output, err := command.CombinedOutput()
if err == nil {
t.Fatalf("cmdlet failure reported success: %q", output)
}
if strings.Contains(string(output), "after") {
t.Fatalf("script continued after cmdlet failure: %q", output)
}
})
}
func TestWindowsPowerShellGuidanceAvoidsUnsupportedChainOperators(t *testing.T) {
shell := shellRuntime{
GOOS: "windows",
Executable: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`,
Syntax: "PowerShell",
Kind: shellKindPowerShell,
}
for _, guidance := range []string{
shellGuidanceForRuntime(shell),
hostShellEnvironmentGuidanceForRuntime(shell),
} {
for _, want := range []string{"Windows PowerShell 5.1", "&&", "||", "if ($?)"} {
if !strings.Contains(guidance, want) {
t.Fatalf("legacy PowerShell guidance missing %q: %q", want, guidance)
}
}
}
}
func TestWindowsPowerShellCommandIssueFlagsUnsupportedChainOperators(t *testing.T) {
legacy := shellRuntime{
GOOS: "windows",
Executable: "powershell.exe",
Syntax: "PowerShell",
Kind: shellKindPowerShell,
}
for _, command := range []string{
"Write-Output one && Write-Output two",
"Write-Output one || Write-Output two",
} {
issue := detectShellCommandIssueForRuntime(command, legacy)
if issue == nil || issue.Kind != "windows_powershell_version" {
t.Fatalf("legacy PowerShell command %q issue = %#v, want version issue", command, issue)
}
}
for _, command := range []string{
`Write-Output "one && two"`,
"Write-Output 'one || two'",
"Write-Output one `&`& Write-Output two",
} {
if issue := detectShellCommandIssueForRuntime(command, legacy); issue != nil {
t.Fatalf("literal legacy PowerShell operators in %q produced issue %#v", command, issue)
}
}
modern := shellRuntime{GOOS: "windows", Executable: "pwsh.exe", Syntax: "PowerShell", Kind: shellKindPowerShell}
if issue := detectShellCommandIssueForRuntime("Write-Output one && Write-Output two", modern); issue != nil {
t.Fatalf("PowerShell 7 chain operator produced issue %#v", issue)
}
}
func TestPowerShellCommandIssueAllowsAliasesAndBlocksMsysExecutables(t *testing.T) {
shell := shellRuntime{GOOS: "windows", Executable: "pwsh.exe", Syntax: "PowerShell", Kind: shellKindPowerShell}
for _, command := range []string{
`ls -Force`,
`cat README.md`,
`Get-ChildItem | Select-Object -First 10`,
`Write-Output 'grep README.md; head file.txt'`,
`Write-Output 'cd /tmp'`,
} {
if issue := detectShellCommandIssueForRuntime(command, shell); issue != nil {
t.Fatalf("PowerShell-native command %q was blocked: %#v", command, issue)
}
}
for _, command := range []string{
`grep README.md`,
`Get-Content README.md | head -10`,
`cat.exe README.md`,
`bash -lc "make test"`,
`Write-Output ok; sed -n '1,5p' README.md`,
} {
if issue := detectShellCommandIssueForRuntime(command, shell); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("MSYS-prone command %q was not blocked: %#v", command, issue)
}
}
}
func TestDetectShellCommandIssueFlagsMsysBinaryPaths(t *testing.T) {
for _, command := range []string{
`for /F %i in ('whoami') do echo %i | "C:\Program Files\Git\usr\bin\head.exe" -1`,
+1
View File
@@ -83,6 +83,7 @@ type PropertySchema struct {
Minimum *int `json:"minimum,omitempty"`
Maximum *int `json:"maximum,omitempty"`
MinLength *int `json:"minLength,omitempty"`
MaxLength *int `json:"maxLength,omitempty"`
MinItems *int `json:"minItems,omitempty"`
// Properties/Required describe nested object fields (for Type "object" or an
// object-typed Items).
+6 -2
View File
@@ -83,6 +83,7 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
})
}
parent.btw = btwState{}
parent.goalContinuationsSuspended = true
// A scrollback print that was already scheduled may acknowledge after the
// side surface is active. The hidden model does not receive that unscoped
// acknowledgement, so clear its print latch and rebuild on return.
@@ -175,6 +176,7 @@ func (m model) leaveBTW() (model, tea.Cmd) {
}
m, _ = m.clearLoopsForSessionSwitch()
parent := *m.btw.parent
parent.goalContinuationsSuspended = false
parent.btwRunIDSeq = maxInt(parent.btwRunIDSeq, m.runID)
parent.btw = btwState{}
// A hidden parent completion may have scheduled an unscoped git-sweep result
@@ -196,13 +198,15 @@ func (m model) leaveBTW() (model, tea.Cmd) {
text: "Returned from the isolated BTW conversation. Its messages were not added to this session.",
})
parent.resetFlushFrontier("· returned from btw ·")
return parent, batchCommands(sweepCmd, spinnerCmd)
var goalCmd tea.Cmd
parent, goalCmd = parent.launchGoalContinuationIfReady()
return parent, batchCommands(sweepCmd, spinnerCmd, goalCmd)
}
func btwCommandUnavailable(command parsedCommand) bool {
arg := strings.ToLower(strings.TrimSpace(command.text))
switch command.kind {
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop,
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop, commandGoal,
commandRewind, commandCompact, commandSTTModel, commandMCP:
return true
case commandModel:
+45
View File
@@ -135,6 +135,51 @@ func TestBTWCanOpenWhileParentRunContinues(t *testing.T) {
}
}
func TestBTWHiddenParentDoesNotLaunchGoalContinuation(t *testing.T) {
m := newBTWTestModel(t)
goalSession, _, err := m.sessionStore.CreateGoal(m.activeSession.SessionID, "Stay visible to the user", 0)
if err != nil {
t.Fatal(err)
}
m.activeSession = goalSession
m.provider = &fakeProvider{}
m.pending = true
m.runID = 7
m.activeRunID = 7
side, _ := m.handleBTWCommand("")
routed, _, ok := side.routeBTWParentMessage(agentResponseMsg{
runID: 7,
goalAware: true,
rows: []transcriptRow{{kind: rowAssistant, text: "main turn finished", final: true}},
})
if !ok || routed.btw.parent == nil {
t.Fatal("parent completion was not routed while BTW was active")
}
parent := routed.btw.parent
if parent.pending {
t.Fatal("hidden parent launched an automatic goal continuation")
}
if parent.activeSession.Goal == nil || parent.activeSession.Goal.ContinuationCount != 0 {
t.Fatalf("hidden parent consumed a continuation: %#v", parent.activeSession.Goal)
}
returned, cmd := routed.leaveBTW()
if cmd == nil || !returned.pending {
t.Fatalf("returning from BTW did not resume the deferred goal: pending=%v cmd=%v", returned.pending, cmd)
}
if returned.goalContinuationsSuspended {
t.Fatal("goal continuations remained suspended after leaving BTW")
}
if returned.activeSession.Goal == nil || returned.activeSession.Goal.ContinuationCount != 1 {
t.Fatalf("returning from BTW reserved an unexpected continuation: %#v", returned.activeSession.Goal)
}
again, duplicateCmd := returned.launchGoalContinuationIfReady()
if duplicateCmd != nil || again.activeSession.Goal.ContinuationCount != 1 {
t.Fatalf("returning from BTW launched more than one continuation: goal=%#v cmd=%v", again.activeSession.Goal, duplicateCmd)
}
}
func TestBTWInlineQuestionStartsSideRun(t *testing.T) {
m := newBTWTestModel(t)
m.provider = &fakeProvider{}
+6 -12
View File
@@ -2,12 +2,11 @@ package tui
import (
"context"
"os/exec"
"runtime"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
// bashEscapeTimeout bounds a "!cmd" shell escape so a hung command can't freeze
@@ -30,8 +29,7 @@ func runBashEscape(cwd, command string) tea.Cmd {
ctx, cancel := context.WithTimeout(context.Background(), bashEscapeTimeout)
defer cancel()
name, shellArgs := escapeShell(command)
cmd := exec.CommandContext(ctx, name, shellArgs...)
cmd := tools.NewHostShellCommandContext(ctx, command)
if strings.TrimSpace(cwd) != "" {
cmd.Dir = cwd
}
@@ -51,15 +49,11 @@ func runBashEscape(cwd, command string) tea.Cmd {
}
}
// escapeShell returns the platform shell and arguments for a "!cmd" escape,
// matching the agent bash tool: cmd.exe on Windows, /bin/sh elsewhere. Hardcoding
// "bash" broke the escape on stock Windows (no bash on PATH) and anywhere bash is
// not installed at a predictable path.
// escapeShell returns the platform shell and arguments for tests and display.
// Execution itself uses tools.NewHostShellCommandContext so the cmd.exe
// fallback receives its required raw Windows command line.
func escapeShell(command string) (string, []string) {
if runtime.GOOS == "windows" {
return "cmd.exe", []string{"/d", "/s", "/c", command}
}
return "/bin/sh", []string{"-c", command}
return tools.HostShellCommand(command)
}
func appendNote(text, note string) string {
+1 -1
View File
@@ -548,7 +548,7 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
// and a stored OAuth login (e.g. ChatGPT) is a credential too — the profile stays
// keyless on purpose so newProvider attaches the bearer resolver + login key.
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" &&
!(hasDescriptor && descriptor.Local) && !oauthLoginAvailable(target) {
(!hasDescriptor || !descriptor.Local) && !oauthLoginAvailable(target) {
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", false, nil
}
next, err := m.newProvider(target)
+8
View File
@@ -50,6 +50,7 @@ const (
commandBTW
commandSkills
commandLoop
commandGoal
commandVoice
commandSTTModel
commandUnknown
@@ -338,6 +339,13 @@ var commandDefinitions = []commandDefinition{
description: "Repeat a prompt or command on an interval (e.g. /loop 5m /babysit-prs), or self-paced when no interval is given.",
kind: commandLoop,
},
{
name: "/goal",
usage: "/goal [--tokens N] <objective> | status | pause | resume | edit | clear",
group: commandGroupSession,
description: "Create and pursue one persistent objective for this session.",
kind: commandGoal,
},
{
name: "/help",
usage: "/help",
-21
View File
@@ -8,7 +8,6 @@ import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/dictation"
)
@@ -87,10 +86,6 @@ func (m model) scrollableTranscriptLayoutView(header string, body transcriptBody
return m.renderScrollableTranscriptWindow(frame, bodyWindow, window, width, overlay)
}
func (m model) scrollableTranscriptView(header string, body string, footer string, width int, overlay string) string {
return m.scrollableTranscriptLayoutView(header, transcriptBodyLayout{lines: viewLines(body)}, footer, width, overlay)
}
func (m model) overlayMouseTop(overlayHeight int, width int) int {
return m.overlayMouseRect(overlayHeight, width).y
}
@@ -230,17 +225,6 @@ func (d *streamingDecoder) tailLines() []string {
return out
}
// ensureAgeTickReschedule is a small helper used after a fade-state change
// to start the tick if it's not already running. The age-tick case
// short-circuits when fadeActive is false, so calling this on a no-op
// transition (e.g. a 0-byte delta) is safe.
func (m model) ensureAgeTickReschedule() tea.Cmd {
if !m.fadeActive {
return nil
}
return streamingFadeTick()
}
// newSTTDownloadPicker builds the model-download chooser, seeded with the
// curated shortlist. The full model list from the release is fetched
// asynchronously and merged in (see fetchSTTModelsCmd / handleSTTModelsFetched).
@@ -288,11 +272,6 @@ func transcriptViewportStartForFrame(body string, frame transcriptFrameLayout, s
return window.start, window.height, frame.bodyRect.y
}
func transcriptViewportStartForLayout(layout transcriptBodyLayout, frame transcriptFrameLayout, scrollOffset int) (int, int, int) {
window := transcriptViewportForLayout(layout, frame, scrollOffset).window()
return window.start, window.height, frame.bodyRect.y
}
func transcriptViewportForBody(body string, frame transcriptFrameLayout, offset int) transcriptViewport {
return newTranscriptViewport(len(viewLines(body)), frame.bodyRect.height, offset)
}
+5 -4
View File
@@ -1,6 +1,7 @@
package tui
import (
"context"
"os"
"os/exec"
"path/filepath"
@@ -192,7 +193,7 @@ func TestGitSweepCmdAgainstRealRepo(t *testing.T) {
run("commit", "-q", "-m", "seed")
write("pre-existing.txt", "dirt\n") // dirty BEFORE the TUI "opens"
baseline := gitSweepCmd(nil, dir, true)().(gitSweepMsg)
baseline := gitSweepCmd(context.TODO(), dir, true)().(gitSweepMsg)
if !baseline.ok || len(baseline.files) != 1 || baseline.files[0].path != "pre-existing.txt" {
t.Fatalf("baseline should see only the pre-existing dirt: %+v", baseline)
}
@@ -201,7 +202,7 @@ func TestGitSweepCmdAgainstRealRepo(t *testing.T) {
write("scaffolded.txt", "hello\n")
write("tracked.txt", "one\ntwo\nthree\nfour\n")
sweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg)
sweep := gitSweepCmd(context.TODO(), dir, false)().(gitSweepMsg)
if !sweep.ok {
t.Fatal("sweep failed")
}
@@ -224,7 +225,7 @@ func TestGitSweepCmdAgainstRealRepo(t *testing.T) {
run("commit", "-q", "-m", "second")
run("mv", "tracked.txt", "renamed.txt")
write("renamed.txt", "one\ntwo\nthree\nfour\nfive\n")
renameSweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg)
renameSweep := gitSweepCmd(context.TODO(), dir, false)().(gitSweepMsg)
if !renameSweep.ok {
t.Fatal("rename sweep failed")
}
@@ -237,7 +238,7 @@ func TestGitSweepCmdAgainstRealRepo(t *testing.T) {
}
// Not-a-repo → ok=false (the sweep latches off).
if msg := gitSweepCmd(nil, t.TempDir(), false)().(gitSweepMsg); msg.ok {
if msg := gitSweepCmd(context.TODO(), t.TempDir(), false)().(gitSweepMsg); msg.ok {
t.Fatal("a non-repo should report ok=false")
}
}
+2 -2
View File
@@ -46,7 +46,7 @@ func (m model) settledRow(row transcriptRow, rc rowContext) bool {
if row.id != "" && rc.resolved[rcKey(row.runID, row.id)] {
return true
}
return !(m.pending && row.runID != 0 && row.runID == m.activeRunID)
return !m.pending || row.runID == 0 || row.runID != m.activeRunID
case rowPermission:
event := row.permission
if event == nil || event.ToolCallID == "" || event.Action != agent.PermissionActionPrompt {
@@ -57,7 +57,7 @@ func (m model) settledRow(row transcriptRow, rc rowContext) bool {
}
// An undecided prompt renders live (with the modal card) until its
// decision lands or its run ends.
return !(m.pending && row.runID != 0 && row.runID == m.activeRunID)
return !m.pending || row.runID == 0 || row.runID != m.activeRunID
default:
// User/system/error/assistant/ask-user rows are appended in final form;
// welcome rows render nothing and settle trivially.
+358
View File
@@ -0,0 +1,358 @@
package tui
import (
"context"
"fmt"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const goalContinuationPrompt = "Continue pursuing the active goal. Review the existing session context, make concrete progress, and use update_goal when the objective is complete or genuinely blocked."
func (m model) handleGoalCommand(args string) (model, tea.Cmd) {
action, rest := splitGoalCommand(args)
if rest != "" && (action == "status" || action == "pause" || action == "resume" || action == "clear") {
return m.appendGoalError("Usage: /goal " + action), nil
}
switch action {
case "status":
return m.appendGoalStatus(), nil
case "pause":
if m.activeSession.Goal == nil {
return m.appendGoalError("No goal is set for this session."), nil
}
if m.pending {
m.cancelRun()
return m, nil
}
return m.setGoalStatus(sessions.GoalStatusPaused, "paused by user"), nil
case "resume":
if m.pending {
return m.appendGoalError("A run is already in progress."), nil
}
if m.activeSession.Goal == nil {
return m.appendGoalError("No goal is set for this session."), nil
}
if m.activeSession.Goal.Status == sessions.GoalStatusBudgetLimited &&
m.activeSession.Goal.TokenBudget > 0 &&
m.activeSession.Goal.TokensUsed >= m.activeSession.Goal.TokenBudget {
return m.appendGoalError("The token budget is exhausted. Increase it with /goal edit --tokens N <objective>."), nil
}
m = m.setGoalStatus(sessions.GoalStatusActive, "")
return m.launchGoalContinuationIfReady()
case "clear":
if m.activeSession.Goal == nil {
return m.appendGoalError("No goal is set for this session."), nil
}
if m.pending {
m.cancelRun()
}
updated, event, err := m.sessionStore.ClearGoal(m.activeSession.SessionID)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: "Goal cleared."})
return m, nil
case "edit":
objective, budget, err := parseGoalObjective(rest)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
if m.activeSession.Goal == nil {
return m.appendGoalError("No goal is set for this session."), nil
}
if m.pending {
return m.appendGoalError("Pause the current run before editing its goal."), nil
}
if !goalBudgetSpecified(rest) {
budget = m.activeSession.Goal.TokenBudget
}
updated, event, err := m.sessionStore.EditGoal(m.activeSession.SessionID, objective, budget)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
message := "Goal updated and resumed: " + objective
if updated.Goal.Status == sessions.GoalStatusBudgetLimited {
message = "Goal updated, but its token budget is still exhausted. Increase the budget or use --tokens 0 for no goal-specific limit."
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: message})
return m.launchGoalContinuationIfReady()
case "create":
if m.pending {
return m.appendGoalError("A run is already in progress."), nil
}
objective, budget, err := parseGoalObjective(rest)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
if m.activeSession.Goal != nil {
return m.appendGoalError("This session already has a goal. Use /goal edit <objective> or /goal clear first."), nil
}
m, err = m.ensureActiveSession(objective)
if err != nil {
return m.appendGoalError("session create error: " + err.Error()), nil
}
updated, event, err := m.sessionStore.CreateGoal(m.activeSession.SessionID, objective, budget)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
return m.launchPrompt(objective)
default:
return m.appendGoalError("Unknown action. Use /goal, /goal pause, /goal resume, /goal edit <objective>, or /goal clear."), nil
}
}
func splitGoalCommand(args string) (string, string) {
trimmed := strings.TrimSpace(args)
if trimmed == "" || strings.EqualFold(trimmed, "status") {
return "status", ""
}
first, rest, _ := strings.Cut(trimmed, " ")
switch strings.ToLower(first) {
case "status", "pause", "resume", "clear":
return strings.ToLower(first), strings.TrimSpace(rest)
case "edit":
return "edit", strings.TrimSpace(rest)
default:
return "create", trimmed
}
}
func parseGoalObjective(input string) (string, int, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", 0, fmt.Errorf("goal objective is required")
}
budget := 0
if strings.HasPrefix(input, "--tokens") {
fields := strings.Fields(input)
if len(fields) < 3 || fields[0] != "--tokens" {
return "", 0, fmt.Errorf("usage: /goal [--tokens N] <objective>")
}
value, err := strconv.Atoi(fields[1])
if err != nil || value < 0 {
return "", 0, fmt.Errorf("goal token budget must be a non-negative integer")
}
budget = value
input = strings.TrimSpace(strings.Join(fields[2:], " "))
}
if input == "" {
return "", 0, fmt.Errorf("goal objective is required")
}
return input, budget, nil
}
func goalBudgetSpecified(input string) bool {
return strings.HasPrefix(strings.TrimSpace(input), "--tokens")
}
func (m model) appendGoalStatus() model {
goal := m.activeSession.Goal
if goal == nil {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
text: "Goal\nstatus: none\nStart one with /goal <objective>.",
})
return m
}
budget := "unlimited"
if goal.TokenBudget > 0 {
budget = fmt.Sprintf("%d / %d tokens", goal.TokensUsed, goal.TokenBudget)
} else if goal.TokensUsed > 0 {
budget = fmt.Sprintf("%d tokens used", goal.TokensUsed)
}
lines := []string{
"Goal",
"status: " + string(goal.Status),
"objective: " + goal.Objective,
"budget: " + budget,
fmt.Sprintf(
"automatic continuations: %d / %d",
goal.ContinuationCount,
goalContinuationLimit(goal),
),
}
if goal.StatusReason != "" {
lines = append(lines, "reason: "+goal.StatusReason)
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: strings.Join(lines, "\n")})
return m
}
func goalContinuationLimit(goal *sessions.Goal) int {
if goal != nil && goal.ContinuationLimit > 0 {
return goal.ContinuationLimit
}
return sessions.GoalMaxConsecutiveContinuations
}
func (m model) goalFooterSummary() string {
if m.activeSession.Goal == nil {
return ""
}
return "goal " + string(m.activeSession.Goal.Status)
}
func (m model) appendGoalError(message string) model {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowError, text: "Goal: " + message})
return m
}
func (m model) setGoalStatus(status sessions.GoalStatus, reason string) model {
if m.sessionStore == nil || m.activeSession.SessionID == "" || m.activeSession.Goal == nil {
return m.appendGoalError("Goal state is unavailable.")
}
updated, event, err := m.sessionStore.UpdateGoal(m.activeSession.SessionID, status, reason)
if err != nil {
return m.appendGoalError(err.Error())
}
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
text: "Goal " + string(status) + ".",
})
return m
}
func (m model) goalRegistry() *tools.Registry {
registry := cloneToolRegistry(m.registry)
if m.activeSession.SessionID == "" {
return registry
}
for _, tool := range tools.NewGoalTools(m.sessionStore, m.activeSession.SessionID) {
registry.Register(tool)
}
return registry
}
func (m model) goalSystemPrompt(base string) string {
goal := m.activeSession.Goal
if goal == nil {
return base
}
instruction := fmt.Sprintf(
"Persistent goal for this session:\nObjective: %s\nStatus: %s\n"+
"When the status is active, keep pursuing this objective across turns. "+
"Call update_goal with complete only after the objective is genuinely achieved, "+
"or blocked with a concrete reason when progress requires user input or an external change.",
goal.Objective,
goal.Status,
)
if strings.TrimSpace(base) == "" {
return instruction
}
return base + "\n\n" + instruction
}
func (m model) launchGoalContinuationIfReady() (model, tea.Cmd) {
goal := m.activeSession.Goal
if goal == nil || goal.Status != sessions.GoalStatusActive || m.pending ||
m.compactInFlight || m.exiting || m.provider == nil ||
m.goalContinuationsSuspended {
return m, nil
}
updated, event, reserved, err := m.sessionStore.ReserveGoalContinuation(m.activeSession.SessionID)
if err != nil {
return m.appendGoalError("reserve automatic continuation: " + err.Error()), nil
}
m.activeSession = updated
if event != nil {
m.sessionEvents = append(m.sessionEvents, *event)
}
if !reserved {
if event != nil && updated.Goal != nil && updated.Goal.Status == sessions.GoalStatusPaused {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
text: fmt.Sprintf(
"Goal paused after %d automatic continuations. Review progress, then use /goal resume to continue.",
goalContinuationLimit(updated.Goal),
),
})
}
return m, nil
}
goal = updated.Goal
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
text: "Continuing goal: " + goal.Objective,
})
prompt := m.sessionPrompt(goalContinuationPrompt)
m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{
"role": "goal",
"content": goalContinuationPrompt,
})
if err != nil {
return m.appendGoalError("session record error: " + err.Error()), nil
}
runCtx, cancel := context.WithCancel(m.ctx)
m = m.beginRun(cancel)
return m, tea.Batch(m.runAgent(m.activeRunID, runCtx, prompt, nil), m.spinner.Tick)
}
func (m model) reconcileGoalAfterRun(usageEvents []zeroruntime.Usage, runErr error) model {
if m.sessionStore == nil || m.activeSession.SessionID == "" {
return m
}
loaded, err := m.sessionStore.Get(m.activeSession.SessionID)
if err != nil || loaded == nil {
if err != nil {
return m.appendGoalError("reload after run: " + err.Error())
}
return m
}
if loaded.Goal == nil {
return m
}
tokens := 0
for _, event := range usageEvents {
tokens += event.TotalTokens()
}
if tokens > 0 {
updated, _, addErr := m.sessionStore.AddGoalUsage(loaded.SessionID, tokens)
if addErr != nil {
return m.appendGoalError("account usage: " + addErr.Error())
}
loaded = &updated
}
if runErr != nil && loaded.Goal.Status == sessions.GoalStatusActive {
status := sessions.GoalStatusPaused
reason := "run stopped after an error"
if errhint.Classify(runErr) == errhint.RateLimit {
status = sessions.GoalStatusUsageLimited
reason = "provider usage limit reached"
}
updated, _, updateErr := m.sessionStore.UpdateGoal(
loaded.SessionID,
status,
reason,
)
if updateErr != nil {
return m.appendGoalError("pause after error: " + updateErr.Error())
}
loaded = &updated
message := "Goal paused because the run stopped with an error. Use /goal resume to continue."
if status == sessions.GoalStatusUsageLimited {
message = "Goal stopped at the provider usage limit. Use /goal resume when capacity is available."
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: message})
}
m.activeSession = *loaded
if events, readErr := m.sessionStore.ReadEvents(loaded.SessionID); readErr == nil {
m.sessionEvents = events
}
return m
}
+362
View File
@@ -0,0 +1,362 @@
package tui
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestGoalCommandCreatesPersistentGoalAndStartsRun(t *testing.T) {
store := testSessionStore(t)
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
next, cmd := m.handleGoalCommand("--tokens 500 Ship the release")
if cmd == nil || !next.pending {
t.Fatal("creating a goal should start its first run")
}
if next.activeSession.Goal == nil ||
next.activeSession.Goal.Objective != "Ship the release" ||
next.activeSession.Goal.TokenBudget != 500 ||
next.activeSession.Goal.Status != sessions.GoalStatusActive {
t.Fatalf("active goal = %#v", next.activeSession.Goal)
}
loaded, err := store.Get(next.activeSession.SessionID)
if err != nil {
t.Fatal(err)
}
if loaded == nil || loaded.Goal == nil || loaded.Goal.Objective != "Ship the release" {
t.Fatalf("persisted session = %#v", loaded)
}
}
func TestGoalCommandDoesNotCreateGoalDuringActiveRun(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_pending"})
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
m.pending = true
next, cmd := m.handleGoalCommand("Do not replace the active run")
if cmd != nil {
t.Fatal("goal creation during an active run returned a command")
}
if next.activeSession.Goal != nil {
t.Fatalf("goal was created during an active run: %#v", next.activeSession.Goal)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if loaded.Goal != nil {
t.Fatalf("goal was persisted during an active run: %#v", loaded.Goal)
}
if !transcriptContains(next.transcript, "A run is already in progress.") {
t.Fatalf("missing active-run explanation: %#v", next.transcript)
}
}
func TestGoalRunRegistryContainsSessionBoundTools(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_tools"})
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
registry := m.goalRegistry()
for _, name := range []string{"get_goal", "create_goal", "update_goal"} {
if _, ok := registry.Get(name); !ok {
t.Fatalf("goal registry missing %q", name)
}
}
if _, ok := m.registry.Get("get_goal"); ok {
t.Fatal("goal tools should not mutate the shared base registry")
}
}
func TestLoopRunExcludesGoalToolsAndInstructions(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_loop"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Keep this out of loops", 0)
if err != nil {
t.Fatal(err)
}
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "Loop iteration complete."},
{Type: zeroruntime.StreamEventDone},
}}}
m := newModel(context.Background(), Options{
Provider: provider,
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
m.activeLoopID = "loop-1"
_ = execCmd(m.runAgentWithOptions(1, context.Background(), "run loop", nil, tuiAgentRunOptions{}))
if len(provider.requests) != 1 {
t.Fatalf("provider requests = %d, want 1", len(provider.requests))
}
request := provider.requests[0]
for _, definition := range request.Tools {
switch definition.Name {
case "get_goal", "create_goal", "update_goal":
t.Fatalf("loop request exposed goal tool %q", definition.Name)
}
}
for _, message := range request.Messages {
if strings.Contains(message.Content, "Persistent goal for this session:") {
t.Fatalf("loop request included goal instructions: %q", message.Content)
}
}
}
func TestLoopRunDoesNotConsumeGoalBudgetOrLaunchContinuation(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_loop_usage"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Keep loop usage separate", 10)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
m.pending = true
m.activeRunID = 1
m.activeLoopID = "loop-1"
updated, _ := m.Update(agentResponseMsg{
runID: 1,
goalAware: false,
usageEvents: []zeroruntime.Usage{{InputTokens: 8, OutputTokens: 4}},
rows: []transcriptRow{{kind: rowAssistant, text: "loop result", final: true}},
})
next := updated.(model)
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if loaded.Goal.TokensUsed != 0 || loaded.Goal.ContinuationCount != 0 {
t.Fatalf("loop run mutated goal accounting: %#v", loaded.Goal)
}
if next.pending {
t.Fatal("loop completion launched a goal continuation")
}
}
func TestAgentCanCompleteGoalWithoutAnotherContinuation(t *testing.T) {
store := testSessionStore(t)
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "goal_done", ToolName: "update_goal"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "goal_done", ArgumentsFragment: `{"status":"complete"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "goal_done"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "The goal is complete."},
{Type: zeroruntime.StreamEventDone},
},
}}
m := newModel(context.Background(), Options{
Provider: provider,
Registry: tools.NewRegistry(),
SessionStore: store,
})
running, cmd := m.handleGoalCommand("Finish the task")
response := execCmd(cmd)
if response == nil {
t.Fatal("goal run did not return an agent response")
}
updated, nextCmd := running.Update(response)
settled := updated.(model)
if settled.activeSession.Goal == nil || settled.activeSession.Goal.Status != sessions.GoalStatusComplete {
t.Fatalf("completed goal = %#v", settled.activeSession.Goal)
}
if settled.pending {
t.Fatal("completed goal started another continuation")
}
// Background title/recap/sweep commands may still be returned; none should
// have changed the settled goal back to active.
_ = nextCmd
}
func TestGoalBudgetStopsAutomaticContinuation(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_budget"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Stay bounded", 20)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
m = m.reconcileGoalAfterRun([]zeroruntime.Usage{{InputTokens: 12, OutputTokens: 8}}, nil)
if m.activeSession.Goal.Status != sessions.GoalStatusBudgetLimited {
t.Fatalf("budgeted goal status = %q", m.activeSession.Goal.Status)
}
next, cmd := m.launchGoalContinuationIfReady()
if cmd != nil || next.pending {
t.Fatal("a budget-paused goal must not launch another run")
}
}
func TestActiveGoalLaunchesContinuation(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_continue"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Keep going", 0)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
next, cmd := m.launchGoalContinuationIfReady()
if cmd == nil || !next.pending {
t.Fatal("active goal should launch a continuation while idle")
}
if !transcriptContains(next.transcript, "Continuing goal: Keep going") {
t.Fatalf("continuation was not surfaced: %#v", next.transcript)
}
}
func TestGoalContinuationChainStopsAtPersistedLimit(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_hard_stop"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Never run forever", 0)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
for continuation := 1; continuation <= sessions.GoalMaxConsecutiveContinuations; continuation++ {
next, cmd := m.launchGoalContinuationIfReady()
if cmd == nil || !next.pending {
t.Fatalf("continuation %d did not launch", continuation)
}
if next.runCancel != nil {
next.runCancel()
}
next.runCancel = nil
next.pending = false
next.activeRunID = 0
m = next
}
stopped, cmd := m.launchGoalContinuationIfReady()
if cmd != nil || stopped.pending {
t.Fatal("goal exceeded its automatic continuation limit")
}
if stopped.activeSession.Goal.Status != sessions.GoalStatusPaused ||
!transcriptContains(stopped.transcript, "Goal paused after") {
t.Fatalf("hard stop was not surfaced: goal=%#v transcript=%#v", stopped.activeSession.Goal, stopped.transcript)
}
}
func TestGoalActionsRejectTrailingArguments(t *testing.T) {
for _, action := range []string{"status extra", "pause extra", "resume extra", "clear extra"} {
m := newModel(context.Background(), Options{})
next, cmd := m.handleGoalCommand(action)
if cmd != nil || !transcriptContains(next.transcript, "Usage: /goal") {
t.Fatalf("%q silently accepted trailing arguments: %#v", action, next.transcript)
}
}
}
func TestCancelRunPausesActiveGoal(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_cancel"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Keep going", 0)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{SessionStore: store})
m.activeSession = session
m.pending = true
m.activeRunID = 1
m.cancelRun()
if m.activeSession.Goal.Status != sessions.GoalStatusPaused {
t.Fatalf("cancelled goal status = %q", m.activeSession.Goal.Status)
}
if !transcriptContains(m.transcript, "Goal paused") {
t.Fatalf("cancel did not explain goal pause: %#v", m.transcript)
}
}
func TestParseGoalObjective(t *testing.T) {
objective, budget, err := parseGoalObjective("--tokens 1200 finish the migration")
if err != nil {
t.Fatal(err)
}
if objective != "finish the migration" || budget != 1200 {
t.Fatalf("parsed objective/budget = %q/%d", objective, budget)
}
if _, _, err := parseGoalObjective("--tokens nope task"); err == nil ||
!strings.Contains(err.Error(), "non-negative integer") {
t.Fatalf("invalid budget error = %v", err)
}
}
func TestGoalStatusIsVisibleInNarrowFooter(t *testing.T) {
m := newModel(context.Background(), Options{})
m.activeSession.Goal = &sessions.Goal{Objective: "Ship", Status: sessions.GoalStatusActive}
status := plainRender(t, m.statusLine(51))
if !strings.Contains(status, "goal active") {
t.Fatalf("narrow status omitted active goal: %q", status)
}
}
+1 -1
View File
@@ -290,7 +290,7 @@ func writeTestPDF(t *testing.T, dir, name, text string) string {
xrefStart := buf.Len()
buf.WriteString("xref\n0 " + strconv.Itoa(len(offsets)+1) + "\n0000000000 65535 f \n")
for _, off := range offsets {
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\nstartxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
+2 -2
View File
@@ -594,8 +594,8 @@ func (m model) loopListText() string {
when = "due"
}
}
b.WriteString(fmt.Sprintf(" %s · %s · iter %d · %s · %s\n",
l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt)))
fmt.Fprintf(&b, " %s · %s · iter %d · %s · %s\n",
l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt))
}
b.WriteString("Stop with /loop stop <id> or /loop stop all.")
return b.String()
+60 -11
View File
@@ -245,10 +245,13 @@ type model struct {
loopCounter int
loopTicking bool
loopLeavePrompt commandKind
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
// goalContinuationsSuspended keeps a hidden parent from launching autonomous
// work while the user is in an isolated BTW conversation.
goalContinuationsSuspended bool
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
// to the session they were recording into AT CANCEL TIME. Each cancelled
// agent goroutine keeps running to completion and returns its accumulated
@@ -585,6 +588,7 @@ type agentResponseMsg struct {
sessionEvents []pendingSessionEvent
specReview *pendingSpecReviewPrompt
err error
goalAware bool
// Turn metadata for settled rows that do not otherwise carry it.
turnTools int
turnElapsed time.Duration
@@ -2415,6 +2419,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streamingText = nil
m.streamingReasoning = ""
m.streamingReasoningExpanded = false
if msg.goalAware {
m = m.reconcileGoalAfterRun(msg.usageEvents, msg.err)
}
// Roll the completed run's wall-time into the session's rolling average so
// /context can surface typical turn latency, not just token counts.
if msg.turnElapsed > 0 {
@@ -2469,8 +2476,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// other loops remain.
m, loopTickCmd = m.ensureLoopTick()
}
hadQueuedMessage := strings.TrimSpace(m.queuedMessage) != ""
next, queuedCmd := m.launchQueuedMessageIfReady()
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
var goalCmd tea.Cmd
if msg.goalAware && !hadQueuedMessage && msg.specReview == nil {
next, goalCmd = next.launchGoalContinuationIfReady()
}
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd, goalCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapGeneratedMsg:
@@ -4346,6 +4358,8 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
return m.handleBTWCommand(command.text)
case commandLoop:
return m.handleLoopCommand(command.text)
case commandGoal:
return m.handleGoalCommand(command.text)
case commandExit:
// Closing the session stops its foreground loops mid-task; warn once so a
// token-spending loop isn't ended by reflex.
@@ -4750,6 +4764,14 @@ func (m model) launchPrompt(prompt string) (model, tea.Cmd) {
text: "session create error: " + err.Error(),
})
} else {
if m.activeLoopID == "" && sessions.IsResumableKind(m.activeSession.SessionKind) &&
m.activeSession.Goal != nil && m.activeSession.Goal.Status == sessions.GoalStatusActive {
if updated, resetErr := m.sessionStore.ResetGoalContinuations(m.activeSession.SessionID); resetErr != nil {
m = m.appendGoalError("reset automatic continuation count: " + resetErr.Error())
} else {
m.activeSession = updated
}
}
agentPrompt := m.sessionPrompt(prompt)
m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{
"role": "user",
@@ -4893,6 +4915,8 @@ func (m *model) rememberInput(value string) {
}
func (m *model) cancelRun() {
goalWasActive := m.pending && m.activeSession.Goal != nil &&
m.activeSession.Goal.Status == sessions.GoalStatusActive
if m.runCancel != nil {
m.runCancel()
}
@@ -4943,6 +4967,18 @@ func (m *model) cancelRun() {
*m = next
}
}
if goalWasActive && m.sessionStore != nil && m.activeSession.SessionID != "" {
updated, event, err := m.sessionStore.PauseGoalIfActive(m.activeSession.SessionID, "run cancelled by user")
if err != nil {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowError, text: "Goal: pause after cancellation: " + err.Error()})
} else {
m.activeSession = updated
if event != nil {
m.sessionEvents = append(m.sessionEvents, *event)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: "Goal paused. Use /goal resume to continue."})
}
}
}
m.pending = false
m.runCancel = nil
m.activeRunID = 0
@@ -4992,9 +5028,19 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
usageModelID := m.modelName
var specReview *pendingSpecReviewPrompt
options := m.agentOptions
options.Registry = m.registry
options.Registry = cloneToolRegistry(m.registry)
goalAwareRun := !runOptions.specDraft && m.activeLoopID == "" &&
sessions.IsResumableKind(m.activeSession.SessionKind)
if goalAwareRun {
options.Registry = m.goalRegistry()
}
if runOptions.registry != nil {
options.Registry = runOptions.registry
options.Registry = cloneToolRegistry(runOptions.registry)
if goalAwareRun && m.activeSession.SessionID != "" {
for _, tool := range tools.NewGoalTools(m.sessionStore, m.activeSession.SessionID) {
options.Registry.Register(tool)
}
}
}
options.PermissionMode = m.permissionMode
if runOptions.permissionMode != "" {
@@ -5003,6 +5049,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
if runOptions.systemPrompt != "" {
options.SystemPrompt = runOptions.systemPrompt
}
if goalAwareRun {
options.SystemPrompt = m.goalSystemPrompt(options.SystemPrompt)
}
options.SessionID = m.activeSession.SessionID
options.ProviderName = m.providerName
options.Model = m.modelName
@@ -5411,7 +5460,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
if runOptions.specDraft {
if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" {
@@ -5421,10 +5470,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
flushReasoning(m.now())
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
flushReasoning(m.now())
elapsed := m.now().Sub(started)
@@ -5449,7 +5498,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
"content": result.FinalAnswer,
},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
}
}
+1 -1
View File
@@ -1396,7 +1396,7 @@ func TestBeginRunPreparesRunCompletionWarningEachTurn(t *testing.T) {
})
m = m.beginRun(nil)
m = m.beginRun(nil)
_ = m.beginRun(nil)
if prepares != 2 {
t.Fatalf("PrepareRunCompletionWarning called %d times, want once per run", prepares)
+20 -14
View File
@@ -218,20 +218,22 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return m, nil
case keyIs(msg, tea.KeyUp) || keyCtrl(msg, 'p'):
if m.setup.stage == setupStageMethod {
switch m.setup.stage {
case setupStageMethod:
m.moveSetupMethod(-1)
} else if m.setup.stage == setupStageProvider {
case setupStageProvider:
m.moveSetupProvider(-1)
} else if m.setup.stage == setupStageModel {
case setupStageModel:
m.moveSetupModel(-1)
}
return m, nil
case keyIs(msg, tea.KeyDown) || keyCtrl(msg, 'n'):
if m.setup.stage == setupStageMethod {
switch m.setup.stage {
case setupStageMethod:
m.moveSetupMethod(1)
} else if m.setup.stage == setupStageProvider {
case setupStageProvider:
m.moveSetupProvider(1)
} else if m.setup.stage == setupStageModel {
case setupStageModel:
m.moveSetupModel(1)
}
return m, nil
@@ -279,15 +281,17 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "q":
return m, tea.Quit
case "k":
if m.setup.stage == setupStageProvider {
switch m.setup.stage {
case setupStageProvider:
m.moveSetupProvider(-1)
} else if m.setup.stage == setupStageModel {
case setupStageModel:
m.moveSetupModel(-1)
}
case "j":
if m.setup.stage == setupStageProvider {
switch m.setup.stage {
case setupStageProvider:
m.moveSetupProvider(1)
} else if m.setup.stage == setupStageModel {
case setupStageModel:
m.moveSetupModel(1)
}
}
@@ -360,16 +364,18 @@ func (m model) handleSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
switch {
case mouseWheelUp(msg):
m.clearMouseSelection()
if m.setup.stage == setupStageProvider {
switch m.setup.stage {
case setupStageProvider:
m.moveSetupProvider(-1)
} else if m.setup.stage == setupStageModel {
case setupStageModel:
m.moveSetupModel(-1)
}
case mouseWheelDown(msg):
m.clearMouseSelection()
if m.setup.stage == setupStageProvider {
switch m.setup.stage {
case setupStageProvider:
m.moveSetupProvider(1)
} else if m.setup.stage == setupStageModel {
case setupStageModel:
m.moveSetupModel(1)
}
}
+1 -1
View File
@@ -687,7 +687,7 @@ func TestProviderWizardPersistsPastedKeyToUserConfig(t *testing.T) {
updated, _ = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
updated, _ = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
_ = updated.(model)
if captured.APIKey != secret {
t.Fatalf("captured APIKey = %q, want pasted secret", captured.APIKey)
+1 -1
View File
@@ -1473,7 +1473,7 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card
// scrollback clean. Skipped for: the uncapped detailed view (opts.bodyCap==0),
// diff tools whose body must stay reviewable, and short output.
collapsedFooter := ""
if opts.bodyCap > 0 && !toolCardAlwaysExpands(name) && !(!failed && (isExploreTool(name) || isLocalControlTool(name))) {
if opts.bodyCap > 0 && !toolCardAlwaysExpands(name) && (failed || (!isExploreTool(name) && !isLocalControlTool(name))) {
collapsedFooter = collapsedToolFooter(row.detail)
}
if collapsedFooter != "" && !row.expanded {
+15 -7
View File
@@ -317,18 +317,26 @@ func (m model) formatResumeSummary(session sessions.Metadata, eventCount int) st
if recorded := strings.TrimSpace(session.Provider); recorded != "" && !strings.EqualFold(recorded, m.providerName) {
providerLine += " (recorded: " + recorded + ")"
}
lines := []string{
"id: " + session.SessionID,
"title: " + displayValue(session.Title, "untitled"),
modelLine,
providerLine,
fmt.Sprintf("events: %d", eventCount),
}
if session.Goal != nil {
goalLine := "goal: " + string(session.Goal.Status) + " — " + session.Goal.Objective
if session.Goal.Status == sessions.GoalStatusActive {
goalLine += " (run /goal resume to continue)"
}
lines = append(lines, goalLine)
}
return renderCommandOutput(commandOutput{
Title: "Resumed Zero session",
Status: commandStatusOK,
Sections: []commandSection{{
Title: "Session",
Lines: []string{
"id: " + session.SessionID,
"title: " + displayValue(session.Title, "untitled"),
modelLine,
providerLine,
fmt.Sprintf("events: %d", eventCount),
},
Lines: lines,
}},
})
}
+29
View File
@@ -1056,6 +1056,35 @@ func TestResumedPromptIncludesSessionContext(t *testing.T) {
}
}
func TestResumeActiveGoalDoesNotStartAutomaticRun(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "resume_active_goal"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Wait for explicit resume", 0); err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.input.SetValue("/resume " + session.SessionID)
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil || next.pending {
t.Fatal("resuming a session unexpectedly started a billed goal run")
}
if next.activeSession.Goal == nil || next.activeSession.Goal.Status != sessions.GoalStatusActive {
t.Fatalf("active goal was not restored: %#v", next.activeSession.Goal)
}
if !transcriptContains(next.transcript, "run /goal resume to continue") {
t.Fatalf("resume did not explain how to continue the goal: %#v", next.transcript)
}
}
func TestResumeCommandReportsMissingSession(t *testing.T) {
m := newModel(context.Background(), Options{SessionStore: testSessionStore(t)})
m.input.SetValue("/resume missing_session")
+3 -3
View File
@@ -393,10 +393,10 @@ func toolCallSummary(event streamjson.Event) string {
case "write_stdin":
sessionID := toolCallIntArg(args, "session_id")
chars, _ := args["chars"].(string)
switch {
case chars == "":
switch chars {
case "":
return fmt.Sprintf("poll session %d", sessionID)
case chars == "\x03":
case "\x03":
return fmt.Sprintf("interrupt session %d", sessionID)
default:
return fmt.Sprintf("send input to session %d", sessionID)
-5
View File
@@ -220,8 +220,3 @@ func (t tuiTheme) onPanel(style lipgloss.Style) lipgloss.Style {
func (t tuiTheme) onSel(style lipgloss.Style) lipgloss.Style {
return style.Background(t.bgSel)
}
// onPerm paints on the permission-card tint.
func (t tuiTheme) onPerm(style lipgloss.Style) lipgloss.Style {
return style.Background(t.bgPerm)
}
+1 -1
View File
@@ -1360,7 +1360,7 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
}
return m, nil, false
}
if line.permOption && !(m.pendingPermission != nil && m.pendingPermission.typing) {
if line.permOption && (m.pendingPermission == nil || !m.pendingPermission.typing) {
// A left-click on a permission-popup option resolves it directly. The
// typing guard is defence-in-depth: renderFocusedPermissionPrompt already
// returns nil offsets in feedback mode, so no option row is registered as
+6 -6
View File
@@ -42,19 +42,19 @@ func TestBashEscapeGatedByPermissionMode(t *testing.T) {
}
}
// The "!" escape must use the platform shell (cmd.exe on Windows, /bin/sh
// elsewhere), not a hardcoded "bash" that is absent on stock Windows.
// The "!" escape must use the same detected host shell as the agent tools.
func TestEscapeShellWrapsCommandForPlatform(t *testing.T) {
name, args := escapeShell("echo hi")
if name == "" {
t.Fatal("escapeShell returned an empty executable")
}
if len(args) == 0 || args[len(args)-1] != "echo hi" {
t.Fatalf("escapeShell args = %v, want the command as the final arg", args)
if len(args) == 0 || !strings.Contains(args[len(args)-1], "echo hi") {
t.Fatalf("escapeShell args = %v, want the command in the final arg", args)
}
if runtime.GOOS == "windows" {
if name != "cmd.exe" || args[0] != "/d" {
t.Fatalf("windows shell = %q %v, want cmd.exe /d /s /c", name, args)
lowerName := strings.ToLower(filepath.Base(name))
if lowerName != "pwsh.exe" && lowerName != "powershell.exe" && lowerName != "cmd.exe" {
t.Fatalf("windows shell = %q %v, want PowerShell or cmd.exe fallback", name, args)
}
} else if name != "/bin/sh" || args[0] != "-c" {
t.Fatalf("unix shell = %q %v, want /bin/sh -c", name, args)
+6
View File
@@ -211,6 +211,9 @@ func (m model) statusLine(width int) string {
if dictation := m.dictationStatusChip(); dictation != "" {
return fitStyledLine(prefix+btwChip+dictation, width)
}
if goalSummary := m.goalFooterSummary(); goalSummary != "" {
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render("◎ ") + zeroTheme.muted.Render(goalSummary)
}
return fitStyledLine(left, width)
}
@@ -240,6 +243,9 @@ func (m model) statusLine(width int) string {
// Active loops surface a persistent "↻ N loops · next 3:05pm" segment so a
// running loop is always visible (hidden during an exit/cancel confirm above).
if !m.exitConfirmActive && !m.cancelConfirmActive {
if goalSummary := m.goalFooterSummary(); goalSummary != "" {
left += separator + zeroTheme.accent.Render("◎ ") + zeroTheme.muted.Render(goalSummary)
}
if loopSummary := m.loopFooterSummary(); loopSummary != "" {
left += separator + zeroTheme.accent.Render("↻ ") + zeroTheme.muted.Render(loopSummary)
}
+3 -2
View File
@@ -162,10 +162,11 @@ func applyStandaloneUpdate(ctx context.Context, result Result, executablePath st
binaryName := "zero"
optionalBinaries := linuxOptionalBinaries
if runtime.GOOS == "windows" {
switch runtime.GOOS {
case "windows":
binaryName = "zero.exe"
optionalBinaries = windowsOptionalBinaries
} else if runtime.GOOS == "darwin" {
case "darwin":
optionalBinaries = nil
}
+3 -2
View File
@@ -151,10 +151,11 @@ func TestApplyStandaloneUpdateReplacesBinary(t *testing.T) {
func TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails(t *testing.T) {
binaryName := "zero"
optionalName := "zero-seccomp"
if runtime.GOOS == "windows" {
switch runtime.GOOS {
case "windows":
binaryName = "zero.exe"
optionalName = "zero-windows-command-runner.exe"
} else if runtime.GOOS == "darwin" {
case "darwin":
t.Skip("macOS ships no optional helper binaries to refresh")
}
+1 -1
View File
@@ -344,7 +344,7 @@ func releasePlatform(goos string) (string, error) {
// self-updating release asset. Point users at `npm update` rather
// than a source rebuild, since that's the documented Termux
// install/upgrade path and doesn't require a Go toolchain.
return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g @gitlawb/zero` to get the latest.", goos)
return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g @gitlawb/zero` to get the latest.", goos) //nolint:staticcheck // Preserve established user-facing error text.
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ func validCommandName(name string) bool {
return false
}
for _, r := range name {
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' {
return false
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ case "$(go env GOOS)" in
ZERO_SANDBOX_REAL_SMOKE=1 \
ZERO_WINDOWS_COMMAND_RUNNER_EXE="$tmpdir/windows-zero-windows-command-runner.exe" \
ZERO_WINDOWS_SANDBOX_SETUP_EXE="$tmpdir/windows-zero-windows-sandbox-setup.exe" \
go test ./internal/sandbox -run TestWindowsRestrictedTokenRealSandboxSmoke -count=1
go test ./internal/sandbox -run 'TestWindowsRestrictedTokenRealSandboxSmoke|TestWindowsRestrictedTokenPowerShell' -count=1
;;
*)
echo "No real sandbox smoke is defined for this host platform."