mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -10:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6d43c62d9 | ||
|
|
673fce11bd | ||
|
|
f00e83a5ed | ||
|
|
1f92b25e8c | ||
|
|
a4279662b9 | ||
|
|
2cf6fd2334 | ||
|
|
995b9086c7 | ||
|
|
4582727f95 | ||
|
|
b903825139 | ||
|
|
eb608741b0 | ||
|
|
2d62387e08 | ||
|
|
a1b9bbb07c | ||
|
|
4cb1f10952 | ||
|
|
c0fedade8d | ||
|
|
7d66942564 | ||
|
|
29fc29a41d | ||
|
|
7c36805505 | ||
|
|
922a5bab5f |
No files matched your search
@@ -1,16 +1,320 @@
|
||||
---
|
||||
description: Use Go-native project commands; Node is only for the npm wrapper.
|
||||
globs: "*.go, *.js, package.json, .github/workflows/*.yml, docs/*.md"
|
||||
description: Extending Zero — how to write AGENTS.md files, custom specialists, skills, hooks, MCP servers, and plugins for the open-source CLI coding agent.
|
||||
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Default to Go commands in this repository.
|
||||
# Extending Zero
|
||||
|
||||
- Use `go run ./cmd/zero` to launch the local CLI.
|
||||
- Use `go test ./...` for the repository test suite.
|
||||
- Use `go run ./cmd/zero-release build` to build the release-facing binary.
|
||||
- Use `go run ./cmd/zero-release smoke` to smoke-test the built binary.
|
||||
- Use `go run ./cmd/zero-release package` and `go run ./cmd/zero-release verify` for release artifact checks.
|
||||
- Use `go run ./cmd/zero-perf-bench` for performance smoke work.
|
||||
- Do not add Bun or TypeScript build/test dependencies for repository validation.
|
||||
- Use Node only when directly exercising the npm wrapper at `bin/zero.js`.
|
||||
Zero is an open-source terminal coding agent in the same family as Claude Code, Codex, and Droid. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
|
||||
|
||||
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
|
||||
|
||||
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
|
||||
2. Add new specialist sub-agents.
|
||||
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
|
||||
|
||||
## 1. Drop a project `AGENTS.md`
|
||||
|
||||
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
|
||||
|
||||
Accepted file names, in priority order at each level:
|
||||
|
||||
| Path | Notes |
|
||||
| --- | --- |
|
||||
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
|
||||
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
|
||||
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
|
||||
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
|
||||
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
|
||||
|
||||
```markdown
|
||||
# Project conventions for <your project>
|
||||
|
||||
- Build with `make`, not `go build` directly.
|
||||
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
|
||||
- Run `make lint` before opening a PR.
|
||||
- Never edit files under `third_party/` — those are vendored.
|
||||
```
|
||||
|
||||
Tips:
|
||||
|
||||
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
|
||||
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
|
||||
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
|
||||
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
|
||||
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
|
||||
|
||||
## 2. Custom specialists
|
||||
|
||||
Specialists are Zero's sub-agents. Three scopes, in priority order:
|
||||
|
||||
| Scope | Path | Shared? |
|
||||
| --- | --- | --- |
|
||||
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
|
||||
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
|
||||
| Project | `./.zero/specialists/*.md` | yes — the repo team |
|
||||
|
||||
Project overrides user overrides built-in when names collide.
|
||||
|
||||
A specialist is a markdown manifest with frontmatter and a system prompt:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Reviews API changes for breaking-change risk and missing tests.
|
||||
tools: read-only,plan
|
||||
---
|
||||
|
||||
You review API changes. For every changed hunk in `internal/api/` or any file
|
||||
that ends in `_api.go`:
|
||||
|
||||
1. Confirm the public signature is backward-compatible, or note the breaking
|
||||
change explicitly with the migration path.
|
||||
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
|
||||
the new behaviour is exercised.
|
||||
3. Flag any new exported symbol without a doc comment.
|
||||
|
||||
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
|
||||
```
|
||||
|
||||
CLI management (the prompt is passed inline via `--prompt`):
|
||||
|
||||
```bash
|
||||
zero specialist list
|
||||
zero specialist show api-reviewer
|
||||
zero specialist create api-reviewer \
|
||||
--project \
|
||||
--description "Reviews API changes" \
|
||||
--tools read-only,plan \
|
||||
--prompt "$(cat api-reviewer.md)"
|
||||
zero specialist edit api-reviewer --project
|
||||
zero specialist delete api-reviewer --project
|
||||
zero specialist path # prints the resolved specialists directory
|
||||
```
|
||||
|
||||
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](docs/SPECIALISTS.md).
|
||||
|
||||
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
|
||||
|
||||
## 3. Skills
|
||||
|
||||
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Skills are **user-level only** in this version — there's no project-scoped skill directory yet, so anything you want shared with the team goes in `AGENTS.md` (section 1) or as a hook (section 4).
|
||||
|
||||
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
|
||||
|
||||
```
|
||||
~/.local/share/zero/skills/
|
||||
run-benchmarks/
|
||||
SKILL.md
|
||||
write-changelog/
|
||||
SKILL.md
|
||||
```
|
||||
|
||||
`SKILL.md` format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Run the project's benchmark suite and summarize the deltas.
|
||||
---
|
||||
|
||||
# Run benchmarks
|
||||
|
||||
1. `make bench` — captures the wall-clock and RSS before and after.
|
||||
2. `benchstat before.txt after.txt` — diffs the two.
|
||||
3. Report any regression > 5% with the function name and the previous value.
|
||||
```
|
||||
|
||||
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. If two skills declare the same name, the one in the lexicographically-first directory wins — duplicates are dropped silently. Plugin-declared skills (section 6) are not yet merged into the loader, so a `skills:` entry inside a plugin's `plugin.json` is not visible to the `skill` tool today.
|
||||
|
||||
The `skill` core tool lets the agent load any discovered skill by name.
|
||||
|
||||
## 4. Hooks
|
||||
|
||||
Hooks fire shell commands on lifecycle events. Configure them in JSON:
|
||||
|
||||
- User: `~/.config/zero/hooks.json`
|
||||
- Project: `./.zero/hooks.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"id": "block-rm-rf",
|
||||
"event": "beforeTool",
|
||||
"matcher": "bash",
|
||||
"command": "/usr/local/bin/zero-hook-block-rmrf.sh",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "log-session",
|
||||
"event": "sessionStart",
|
||||
"command": "/usr/local/bin/zero-hook-log.sh",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# /usr/local/bin/zero-hook-block-rmrf.sh
|
||||
set -euo pipefail
|
||||
payload="$(cat)"
|
||||
if printf '%s' "$payload" | grep -q '"input":"[^"]*rm[[:space:]]+-rf'; then
|
||||
echo "refusing rm -rf" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
Events the agent emits (in dispatch order):
|
||||
|
||||
| Event | Fires when | Matcher allowed? |
|
||||
| --- | --- | --- |
|
||||
| `beforeTool` | A tool is about to run | yes (tool name) |
|
||||
| `afterTool` | A tool just returned | yes (tool name) |
|
||||
| `sessionStart` | A session begins | no |
|
||||
| `sessionEnd` | A session ends | no |
|
||||
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
|
||||
| `specialistStop` | A sub-agent ends | yes (specialist name) |
|
||||
|
||||
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
|
||||
|
||||
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
|
||||
|
||||
## 5. MCP — Model Context Protocol
|
||||
|
||||
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
|
||||
|
||||
### As a client — configure MCP servers in `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": ["--port", "7777"]
|
||||
},
|
||||
"github": {
|
||||
"type": "http",
|
||||
"url": "https://api.example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer YOUR_TOKEN_HERE" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Manage via CLI:
|
||||
|
||||
```bash
|
||||
zero mcp add docs --type stdio -- docs-mcp --port 7777
|
||||
zero mcp add github --type http --url https://api.example.com/mcp \
|
||||
--header "Authorization=Bearer YOUR_TOKEN_HERE"
|
||||
zero mcp list
|
||||
zero mcp check docs
|
||||
zero mcp remove github
|
||||
zero mcp oauth login github
|
||||
```
|
||||
|
||||
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
|
||||
|
||||
- A wrapper script that sources the secret and execs the real command.
|
||||
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
|
||||
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
|
||||
|
||||
### As a server — expose Zero's tools to another agent
|
||||
|
||||
```bash
|
||||
zero serve --mcp
|
||||
```
|
||||
|
||||
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
|
||||
|
||||
## 6. Plugins
|
||||
|
||||
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
|
||||
|
||||
- User: `~/.config/zero/plugins/<id>/`
|
||||
- Project: `./.zero/plugins/<id>/`
|
||||
|
||||
Each plugin has a `plugin.json` manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "github-pr-review",
|
||||
"name": "GitHub PR Review",
|
||||
"description": "Adds review tools for GitHub PRs.",
|
||||
"version": "1.0.0",
|
||||
"tools": [
|
||||
{ "name": "list_prs", "command": "./tools/list_prs.sh" }
|
||||
],
|
||||
"hooks": [
|
||||
{ "name": "pre-merge-check", "event": "beforeTool", "command": "./hooks/pre-merge.sh" }
|
||||
],
|
||||
"skills": [
|
||||
{ "path": "./skills/review-checklist/SKILL.md" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Install and manage:
|
||||
|
||||
```bash
|
||||
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
||||
zero plugins list
|
||||
zero plugins remove github-pr-review # alias: rm
|
||||
```
|
||||
|
||||
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
|
||||
|
||||
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
|
||||
|
||||
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above. Skills declared inside a plugin's `plugin.json` are not yet merged into the `skill` tool's discovery (see section 3).
|
||||
|
||||
## 7. Configuration locations
|
||||
|
||||
Three layers, applied in order (later layers override earlier ones):
|
||||
|
||||
| Layer | Path | Notes |
|
||||
| --- | --- | --- |
|
||||
| Built-in defaults | compiled in | Lowest priority. |
|
||||
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
|
||||
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
|
||||
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
|
||||
| Environment | `ZERO_*` | Provider commands, secrets, skills dir override. |
|
||||
|
||||
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
|
||||
|
||||
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
|
||||
|
||||
## 8. End-to-end example
|
||||
|
||||
A team that wants every contributor's Zero to behave the same way commits:
|
||||
|
||||
- `AGENTS.md` — project conventions, build commands, do-not-edit lists.
|
||||
- `.zero/config.json` — provider catalog, default model, allowed tools.
|
||||
- `.zero/specialists/api-reviewer.md` — the team's PR-review specialist.
|
||||
- `.zero/hooks.json` — block `rm -rf` and `git push --force` on `beforeTool`.
|
||||
- `.zero/plugins/internal-tooling/` — a plugin that adds the team's internal CLI tools to the agent's toolset.
|
||||
|
||||
Each contributor adds only:
|
||||
|
||||
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
|
||||
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
|
||||
|
||||
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
|
||||
|
||||
## 9. Reference
|
||||
|
||||
- [README](README.md) — install, quickstart, command reference.
|
||||
- [docs/SPECIALISTS.md](docs/SPECIALISTS.md) — full specialist manifest spec.
|
||||
- [docs/PRD.md](docs/PRD.md) — product requirements, full feature surface.
|
||||
- [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
|
||||
- [docs/INSTALL.md](docs/INSTALL.md) — install from source or release.
|
||||
@@ -179,6 +179,40 @@ The model registry tracks each model's capabilities, context window, and cost
|
||||
|
||||
Every mutating tool routes through the permission policy **before** any side effect.
|
||||
|
||||
### Web search & scraping (free, no API key)
|
||||
|
||||
`web_fetch` is built in and always available: it pulls a single URL into clean
|
||||
markdown locally, with no third party. For **search**, JS-rendered scraping,
|
||||
whole-site crawls, PDF→markdown, and structured extraction, Zero ships the
|
||||
[Firecrawl](https://firecrawl.dev) **keyless** MCP server **enabled by default**, so
|
||||
web search and scraping work out of the box with **no setup and no API key** (1,000
|
||||
free credits/month). It is a normal MCP server, so you stay in control:
|
||||
|
||||
```bash
|
||||
zero mcp tools list # see the firecrawl_* tools it exposes
|
||||
zero mcp disable firecrawl # turn it off — web_fetch stays as the local floor
|
||||
```
|
||||
|
||||
- **Privacy / disclosure:** keyless requests route through `firecrawl.dev`. If you
|
||||
prefer nothing leaves your machine, `zero mcp disable firecrawl` and rely on
|
||||
`web_fetch`, or self-host below.
|
||||
- **Self-host (unlimited, private):** Firecrawl is open source (AGPL-3.0). Run your
|
||||
own instance and override the default URL — Zero only *calls* it over the network,
|
||||
so the AGPL never reaches into Zero's own code:
|
||||
```jsonc
|
||||
// config.json
|
||||
{ "mcp": { "servers": { "firecrawl": { "type": "http", "url": "http://localhost:3002/v2/mcp" } } } }
|
||||
```
|
||||
- **Bring your own key (higher limits):** add an auth header over the default:
|
||||
```bash
|
||||
zero mcp add firecrawl --type http --url https://mcp.firecrawl.dev/v2/mcp \
|
||||
--header "Authorization: Bearer fc-your-key"
|
||||
```
|
||||
|
||||
An MCP server that can't be reached at startup (e.g. Firecrawl on an offline
|
||||
machine) is **skipped with a warning, not fatal** — Zero still launches and the
|
||||
rest of its tools, including `web_fetch`, keep working.
|
||||
|
||||
### Extra write directories (`--add-dir`)
|
||||
|
||||
Zero confines writes to the workspace by default. To let the agent write somewhere
|
||||
@@ -204,14 +238,14 @@ still resolve against the workspace only, and network and destructive-shell poli
|
||||
are unchanged. A write denied outside all roots returns an error that suggests
|
||||
`/add-dir`.
|
||||
|
||||
Two extra hardening flags are available in the `sandbox` config block, both **off
|
||||
by default** and safe to leave unset:
|
||||
Two extra hardening/diagnostic flags are available in the `sandbox` config block,
|
||||
both **off by default** and safe to leave unset:
|
||||
|
||||
- `sandbox.blockUnixSockets` (Linux) — on the bubblewrap backend, installs a
|
||||
seccomp filter that denies `AF_UNIX` socket creation in the sandboxed command,
|
||||
closing the Unix-socket channel that filesystem/network isolation leaves open.
|
||||
Degrades gracefully (runs without the filter) when the `zero-seccomp` helper is
|
||||
not installed alongside the binary.
|
||||
- `sandbox.blockUnixSockets` (Linux) — asks the Linux sandbox helper to install a
|
||||
best-effort seccomp filter that denies `AF_UNIX` socket creation in the
|
||||
sandboxed command. Linux packages also continue to ship `zero-seccomp` as a
|
||||
compatibility wrapper for existing direct uses; new sandbox plans do not depend
|
||||
on that external wrapper.
|
||||
- `sandbox.monitorDenials` (macOS) — tails the unified log for this run's seatbelt
|
||||
denials and appends them to a command's stderr as a `<sandbox_violations>` block
|
||||
so blocked operations are visible. A no-op on OS versions that do not deliver
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(sandbox.RunLinuxSandboxHelper(os.Args[1:], os.Stderr))
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
//go:build linux
|
||||
|
||||
// Command zero-seccomp is a tiny exec wrapper for the Linux sandbox: it installs
|
||||
// the Unix-socket-blocking seccomp filter on itself and then execs the real
|
||||
// command, so a bubblewrap-sandboxed process cannot create AF_UNIX sockets (a gap
|
||||
// bubblewrap's filesystem/network isolation leaves open). Wire it by prefixing the
|
||||
// sandboxed command with this binary, e.g. `bwrap ... -- zero-seccomp <command>`.
|
||||
//
|
||||
// It degrades gracefully: if the filter cannot be installed (e.g. the kernel lacks
|
||||
// seccomp), it warns and runs the command anyway, since bubblewrap still provides
|
||||
// the primary isolation.
|
||||
// Command zero-seccomp is retained as a compatibility wrapper for existing Linux
|
||||
// installs and scripts. The main sandbox path now applies the same optional
|
||||
// Unix-socket filter inside zero-linux-sandbox when sandbox.blockUnixSockets is
|
||||
// enabled.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "zero-seccomp is only supported on Linux")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(sandbox.RunWindowsSandboxCommandRunner(os.Args[1:], os.Stderr))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(sandbox.RunWindowsSandboxSetup(os.Args[1:], os.Stderr))
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
)
|
||||
|
||||
func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
if toolName != "bash" {
|
||||
return nil
|
||||
}
|
||||
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
tokens, ok := safeShellCommandTokens(command)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if requested, ok := requestedPrefixRule(args); ok {
|
||||
if safeRequestedPrefix(requested, tokens) {
|
||||
return requested
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
|
||||
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
|
||||
if toolName != "bash" || options.Sandbox == nil {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
tokens, ok := safeShellCommandTokens(command)
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
|
||||
return grant, true, false
|
||||
}
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
|
||||
return grant, true, true
|
||||
}
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
|
||||
func firstStringArg(args map[string]any, names ...string) (string, bool) {
|
||||
for _, name := range names {
|
||||
if raw, ok := args[name].(string); ok {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value != "" {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func requestedPrefixRule(args map[string]any) ([]string, bool) {
|
||||
raw, ok := args["prefix_rule"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case []string:
|
||||
return cleanPrefixRule(value), true
|
||||
case []any:
|
||||
out := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
part, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, true
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return cleanPrefixRule(out), true
|
||||
default:
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
|
||||
func cleanPrefixRule(prefix []string) []string {
|
||||
cleaned := make([]string, 0, len(prefix))
|
||||
for _, part := range prefix {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil
|
||||
}
|
||||
cleaned = append(cleaned, part)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func safeRequestedPrefix(prefix []string, command []string) bool {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) || len(prefix) > len(command) {
|
||||
return false
|
||||
}
|
||||
for index := range prefix {
|
||||
if prefix[index] != command[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeShellCommandTokens(command string) ([]string, bool) {
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" {
|
||||
return nil, false
|
||||
}
|
||||
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
|
||||
if err != nil || len(file.Stmts) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
stmt := file.Stmts[0]
|
||||
if stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || stmt.Semicolon.IsValid() || len(stmt.Redirs) > 0 {
|
||||
return nil, false
|
||||
}
|
||||
call, ok := stmt.Cmd.(*syntax.CallExpr)
|
||||
if !ok || len(call.Assigns) > 0 || len(call.Args) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
tokens := make([]string, 0, len(call.Args))
|
||||
for _, word := range call.Args {
|
||||
if len(word.Parts) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
lit, ok := word.Parts[0].(*syntax.Lit)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
tokens = append(tokens, lit.Value)
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) {
|
||||
return nil, false
|
||||
}
|
||||
return tokens, true
|
||||
}
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "go test ./..."})
|
||||
want := []string{"go", "test", "./..."}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short",
|
||||
"prefix_rule": []any{"git", "status"},
|
||||
})
|
||||
want := []string{"git", "status"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short",
|
||||
"prefix_rule": []any{"git"},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("broad requested prefix should be rejected, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
|
||||
cases := []string{
|
||||
"echo hi && echo bye",
|
||||
"cat < in > out",
|
||||
"FOO=bar go test",
|
||||
"echo $(whoami)",
|
||||
"cat *.go",
|
||||
"bash -lc go test",
|
||||
}
|
||||
for _, command := range cases {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
if got := proposedCommandPrefix("bash", map[string]any{"command": command}); got != nil {
|
||||
t.Fatalf("unsafe command got prefix %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
|
||||
cases := []string{
|
||||
"find . -type f",
|
||||
"xargs rm -rf /tmp/x",
|
||||
"timeout 5 go test ./...",
|
||||
"nice go test ./...",
|
||||
"nohup go test ./...",
|
||||
"watch ls",
|
||||
"ssh host ls",
|
||||
"make test",
|
||||
"npm run dev",
|
||||
"command git status",
|
||||
"eval echo hi",
|
||||
"exec echo hi",
|
||||
"python script.py",
|
||||
"node script.js",
|
||||
"./script.sh --flag",
|
||||
"/tmp/script.sh --flag",
|
||||
}
|
||||
for _, command := range cases {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
if got := proposedCommandPrefix("bash", map[string]any{"command": command}); got != nil {
|
||||
t.Fatalf("unsafe launcher got prefix %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsRequestedUnsafeLauncherPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "find . -type f",
|
||||
"prefix_rule": []any{"find", "."},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("unsafe requested launcher prefix should be rejected, got %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -77,17 +77,38 @@ type CompactionResult struct {
|
||||
// ~1k tokens is a representative mid-range cost across providers.
|
||||
const imageTokenEstimate = 1000
|
||||
|
||||
// estimateTokens is a cheap, dependency-free token estimate (~4 chars/token)
|
||||
// ApproxTextTokens estimates the token count of text WITHOUT a real tokenizer.
|
||||
// The BPE tokenizers zero targets fold a run's leading space into the following
|
||||
// token rather than emitting whitespace as its own token, so naive len/4
|
||||
// overcounts real text by ~15-20% (measured: a 24.8k-char prompt counted 5.2k
|
||||
// real tokens where len/4 said 6.2k). Counting NON-whitespace bytes / 4 tracks
|
||||
// the provider's actual count closely (validated against live usage) while
|
||||
// staying allocation- and dependency-free. zero still receives the exact count
|
||||
// back as usage on every request; this estimate is only for the pre-request
|
||||
// context budget preview and the compaction threshold.
|
||||
func ApproxTextTokens(value string) int {
|
||||
nonSpace := 0
|
||||
for i := 0; i < len(value); i++ {
|
||||
switch value[i] {
|
||||
case ' ', '\t', '\n', '\r', '\f', '\v':
|
||||
default:
|
||||
nonSpace++
|
||||
}
|
||||
}
|
||||
return nonSpace / 4
|
||||
}
|
||||
|
||||
// estimateTokens is a cheap, dependency-free token estimate (see ApproxTextTokens)
|
||||
// across message content plus tool call names/arguments and a flat per-image
|
||||
// cost. It deliberately uses no real tokenizer; it only needs to be monotonic
|
||||
// and roughly proportional so the loop can decide when to compact.
|
||||
func estimateTokens(messages []zeroruntime.Message) int {
|
||||
total := 0
|
||||
for _, message := range messages {
|
||||
total += len(message.Content) / 4
|
||||
total += ApproxTextTokens(message.Content)
|
||||
for _, call := range message.ToolCalls {
|
||||
total += len(call.Name) / 4
|
||||
total += len(call.Arguments) / 4
|
||||
total += ApproxTextTokens(call.Name)
|
||||
total += ApproxTextTokens(call.Arguments)
|
||||
total += 4 // small per-call overhead
|
||||
}
|
||||
total += len(message.Images) * imageTokenEstimate
|
||||
@@ -104,11 +125,11 @@ func estimateTokens(messages []zeroruntime.Message) int {
|
||||
func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
total := 0
|
||||
for _, tool := range tools {
|
||||
total += len(tool.Name) / 4
|
||||
total += len(tool.Description) / 4
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += len(encoded) / 4
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
}
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
|
||||
@@ -48,16 +48,16 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
|
||||
}
|
||||
|
||||
// estimateToolTokens approximates the token footprint of advertised tool
|
||||
// definitions (name + description + JSON schema), matching estimateTokens'
|
||||
// ~4-chars/token heuristic so all categories use the same scale.
|
||||
// definitions (name + description + JSON schema), using the same ApproxTextTokens
|
||||
// heuristic as estimateTokens so all categories share one scale.
|
||||
func estimateToolTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
total := 0
|
||||
for _, tool := range tools {
|
||||
total += len(tool.Name) / 4
|
||||
total += len(tool.Description) / 4
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += len(encoded) / 4
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
}
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
|
||||
// object with several typed, described properties. Real MCP tools are this size
|
||||
// or larger, which is why shipping their full schemas every turn is expensive.
|
||||
type richMCPTool struct{ name string }
|
||||
|
||||
func (t richMCPTool) Name() string { return t.name }
|
||||
func (t richMCPTool) Description() string {
|
||||
return "Search the web and return ranked, structured results with titles, URLs, snippets, and optional cleaned full-page content. Supports natural-language and keyword queries with domain and date filtering."
|
||||
}
|
||||
func (t richMCPTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
AdditionalProperties: false,
|
||||
Required: []string{"query"},
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"query": {Type: "string", Description: "The search query to run against the web index. Supports natural-language and keyword queries."},
|
||||
"num_results": {Type: "integer", Description: "Maximum number of results to return (1-100). Defaults to 10."},
|
||||
"include_domains": {Type: "array", Description: "Only return results from these domains.", Items: &tools.PropertySchema{Type: "string"}},
|
||||
"exclude_domains": {Type: "array", Description: "Never return results from these domains.", Items: &tools.PropertySchema{Type: "string"}},
|
||||
"start_published_date": {Type: "string", Description: "ISO-8601 date; only results published on or after this date are returned."},
|
||||
"end_published_date": {Type: "string", Description: "ISO-8601 date; only results published on or before this date are returned."},
|
||||
"category": {Type: "string", Description: "Restrict results to a content category.", Enum: []string{"news", "research paper", "company", "tweet", "pdf"}},
|
||||
"include_text": {Type: "boolean", Description: "Include cleaned full-page text for each result (slower, larger payload)."},
|
||||
},
|
||||
}
|
||||
}
|
||||
func (t richMCPTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t richMCPTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
func (t richMCPTool) Deferred() bool { return true }
|
||||
|
||||
// TestDeferralTokenSavingsMeasurement quantifies the win from the lowered
|
||||
// deferThreshold default: with a typical small MCP server (6 tools), threshold 10
|
||||
// (the OLD default) kept deferral OFF so all six full schemas shipped every turn,
|
||||
// while threshold 3 (the NEW default) collapses them to compact reminder lines.
|
||||
func TestDeferralTokenSavingsMeasurement(t *testing.T) {
|
||||
const mcpToolCount = 6
|
||||
registry := tools.NewRegistry()
|
||||
for i := 0; i < mcpToolCount; i++ {
|
||||
registry.Register(richMCPTool{name: fmt.Sprintf("mcp__websearch__search_v%d", i)})
|
||||
}
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
|
||||
measure := func(threshold int) (int, int) {
|
||||
defs, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: threshold}, map[string]bool{})
|
||||
encoded, err := json.Marshal(defs)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tool defs: %v", err)
|
||||
}
|
||||
bytes := len(encoded) + len(reminder)
|
||||
return bytes, bytes / 4 // ~tokens (approx 4 chars/token)
|
||||
}
|
||||
|
||||
eagerBytes, eagerTok := measure(10) // 6 < 10 -> inactive -> all schemas eager
|
||||
deferBytes, deferTok := measure(3) // 6 >= 3 -> active -> compact reminder
|
||||
savedPct := 100 * float64(eagerBytes-deferBytes) / float64(eagerBytes)
|
||||
|
||||
t.Logf("MCP tools in set: %d", mcpToolCount)
|
||||
t.Logf("threshold 10 (OLD, eager schemas): %d bytes (~%d tokens) per turn", eagerBytes, eagerTok)
|
||||
t.Logf("threshold 3 (NEW, deferred lines): %d bytes (~%d tokens) per turn", deferBytes, deferTok)
|
||||
t.Logf("SAVED: ~%d tokens per turn (%.0f%% smaller tool payload)", eagerTok-deferTok, savedPct)
|
||||
|
||||
if deferBytes >= eagerBytes {
|
||||
t.Fatalf("deferral should shrink the tool payload: eager=%d defer=%d", eagerBytes, deferBytes)
|
||||
}
|
||||
if savedPct < 50 {
|
||||
t.Fatalf("expected a large reduction, got only %.0f%%", savedPct)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
|
||||
type argsPermTool struct{ perm tools.Permission }
|
||||
|
||||
func (argsPermTool) Name() string { return "args" }
|
||||
func (argsPermTool) Description() string { return "" }
|
||||
func (argsPermTool) Parameters() tools.Schema { return tools.Schema{} }
|
||||
func (argsPermTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} }
|
||||
func (argsPermTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
|
||||
func (t argsPermTool) PermissionForArgs(map[string]any) tools.Permission { return t.perm }
|
||||
|
||||
type plainTool struct{ perm tools.Permission }
|
||||
|
||||
func (plainTool) Name() string { return "plain" }
|
||||
func (plainTool) Description() string { return "" }
|
||||
func (plainTool) Parameters() tools.Schema { return tools.Schema{} }
|
||||
func (t plainTool) Safety() tools.Safety { return tools.Safety{Permission: t.perm} }
|
||||
func (plainTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
|
||||
|
||||
func TestEffectivePermission(t *testing.T) {
|
||||
// A tool implementing ArgsPermissioner refines its permission per call.
|
||||
if got := effectivePermission(argsPermTool{perm: tools.PermissionAllow}, nil); got != tools.PermissionAllow {
|
||||
t.Fatalf("args-aware tool: got %q, want allow", got)
|
||||
}
|
||||
if got := effectivePermission(argsPermTool{perm: tools.PermissionPrompt}, nil); got != tools.PermissionPrompt {
|
||||
t.Fatalf("args-aware tool: got %q, want prompt", got)
|
||||
}
|
||||
// A tool WITHOUT the interface falls back to its static Safety().Permission —
|
||||
// the args-aware path must never change a normal tool's behavior.
|
||||
if got := effectivePermission(plainTool{perm: tools.PermissionAllow}, nil); got != tools.PermissionAllow {
|
||||
t.Fatalf("plain tool: got %q, want its static allow", got)
|
||||
}
|
||||
if got := effectivePermission(plainTool{perm: tools.PermissionPrompt}, nil); got != tools.PermissionPrompt {
|
||||
t.Fatalf("plain tool: got %q, want its static prompt", got)
|
||||
}
|
||||
}
|
||||
+510
-25
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
@@ -23,6 +24,35 @@ const (
|
||||
toolResultControlSpecReview = "spec_review_required"
|
||||
)
|
||||
|
||||
var errPermissionApprovalCanceled = errors.New("permission approval cancelled")
|
||||
|
||||
type permissionRunState struct {
|
||||
mu sync.Mutex
|
||||
cleanups []func()
|
||||
}
|
||||
|
||||
func (state *permissionRunState) add(cleanup func()) {
|
||||
if state == nil || cleanup == nil {
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
state.cleanups = append(state.cleanups, cleanup)
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
func (state *permissionRunState) cleanup() {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
cleanups := state.cleanups
|
||||
state.cleanups = nil
|
||||
state.mu.Unlock()
|
||||
for i := len(cleanups) - 1; i >= 0; i-- {
|
||||
cleanups[i]()
|
||||
}
|
||||
}
|
||||
|
||||
// abortedToolResultNotice is the placeholder tool result recorded for a tool
|
||||
// call that was advertised by the assistant turn but never executed because the
|
||||
// repeated-failure guard halted the run first. It keeps every tool_use paired
|
||||
@@ -64,6 +94,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
if permissionMode == "" {
|
||||
permissionMode = PermissionModeAuto
|
||||
}
|
||||
runPermissions := &permissionRunState{}
|
||||
options.runPermissions = runPermissions
|
||||
defer runPermissions.cleanup()
|
||||
|
||||
messages := zeroruntime.SeedMessagesWithImages(buildSystemPrompt(options), prompt, options.Images)
|
||||
|
||||
@@ -545,36 +578,93 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
if call.Name == "ask_user" {
|
||||
return executeAskUser(ctx, registry, call, args, permissionMode, options)
|
||||
}
|
||||
if call.Name == tools.RequestPermissionsToolName {
|
||||
return executeRequestPermissions(ctx, call, args, permissionMode, options)
|
||||
}
|
||||
|
||||
permissionGranted := permissionMode == PermissionModeUnsafe
|
||||
if toolFound && tool.Safety().Permission == tools.PermissionAllow {
|
||||
if toolFound && effectivePermission(tool, args) == tools.PermissionAllow {
|
||||
permissionGranted = true
|
||||
}
|
||||
|
||||
decisionReason := ""
|
||||
var decisionAction PermissionDecisionAction
|
||||
var decisionCommandPrefix []string
|
||||
if toolFound && !permissionGranted {
|
||||
if grant, ok, session := matchCommandPrefix(call.Name, args, options); ok {
|
||||
permissionGranted = true
|
||||
if session {
|
||||
decisionAction = PermissionDecisionAllowPrefix
|
||||
decisionReason = "session command prefix approval matched"
|
||||
} else {
|
||||
decisionAction = PermissionDecisionAlwaysAllowPrefix
|
||||
decisionReason = "persistent command prefix approval matched"
|
||||
}
|
||||
decisionCommandPrefix = grant.Prefix
|
||||
}
|
||||
}
|
||||
|
||||
var preflightDecision *sandbox.Decision
|
||||
if toolFound && options.Sandbox != nil {
|
||||
decision := options.Sandbox.Evaluate(ctx, sandboxRequest(call.Name, tool, args, permissionGranted, permissionMode, options))
|
||||
preflightDecision = &decision
|
||||
}
|
||||
|
||||
decisionReason := ""
|
||||
if toolFound && options.OnPermissionRequest != nil && shouldRequestPermission(tool, permissionGranted, preflightDecision) {
|
||||
requestEvent, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, preflightDecision)
|
||||
if !ok {
|
||||
requestEvent = fallbackPermissionEvent(call, tool, args, permissionMode, options)
|
||||
}
|
||||
request := permissionRequestFromEvent(requestEvent, args)
|
||||
request := permissionRequestFromEvent(requestEvent, args, options)
|
||||
decision, err := requestPermission(ctx, request, options)
|
||||
if err != nil {
|
||||
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
|
||||
}
|
||||
decision.Action = normalizePermissionDecisionAction(decision.Action)
|
||||
decisionReason = strings.TrimSpace(decision.Reason)
|
||||
decisionAction = decision.Action
|
||||
switch decision.Action {
|
||||
case PermissionDecisionAllow:
|
||||
permissionGranted = true
|
||||
requestEvent.DecisionAction = decision.Action
|
||||
case PermissionDecisionAllowForSession:
|
||||
permissionGranted = true
|
||||
requestEvent.DecisionAction = decision.Action
|
||||
if options.Sandbox != nil {
|
||||
// The current call stays allowed if recording the session grant
|
||||
// fails; the user is simply prompted again for a later match.
|
||||
if grant, err := persistSessionPermissionGrant(call.Name, args, decisionReason, options); err == nil {
|
||||
requestEvent.GrantMatched = true
|
||||
requestEvent.Grant = &grant
|
||||
}
|
||||
}
|
||||
case PermissionDecisionAllowPrefix:
|
||||
if len(request.CommandPrefix) == 0 {
|
||||
emitDeniedPermission(options, call, requestEvent, decisionReason)
|
||||
return deniedPermissionResult(call, decisionReason, requestEvent), nil
|
||||
}
|
||||
permissionGranted = true
|
||||
requestEvent.DecisionAction = decision.Action
|
||||
decisionCommandPrefix = append([]string(nil), request.CommandPrefix...)
|
||||
if options.Sandbox != nil && len(decisionCommandPrefix) > 0 {
|
||||
options.Sandbox.GrantCommandPrefixForSession(call.Name, decisionCommandPrefix)
|
||||
}
|
||||
case PermissionDecisionAlwaysAllowPrefix:
|
||||
if len(request.CommandPrefix) == 0 {
|
||||
emitDeniedPermission(options, call, requestEvent, decisionReason)
|
||||
return deniedPermissionResult(call, decisionReason, requestEvent), nil
|
||||
}
|
||||
permissionGranted = true
|
||||
requestEvent.DecisionAction = decision.Action
|
||||
decisionCommandPrefix = append([]string(nil), request.CommandPrefix...)
|
||||
if options.Sandbox != nil && len(decisionCommandPrefix) > 0 {
|
||||
if grant, err := persistCommandPrefixGrant(call.Name, decisionCommandPrefix, decisionReason, options); err == nil {
|
||||
decisionCommandPrefix = append([]string(nil), grant.Prefix...)
|
||||
}
|
||||
}
|
||||
case PermissionDecisionAlwaysAllow:
|
||||
permissionGranted = true
|
||||
requestEvent.DecisionAction = decision.Action
|
||||
// "Always allow" also persists a grant so FUTURE calls skip the prompt.
|
||||
// With no sandbox engine there is nowhere to persist it, and persistence
|
||||
// can also fail — neither must deny what the user explicitly allowed, so
|
||||
@@ -584,8 +674,14 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
// (buildPermissionEvent reads decision.GrantMatched/Grant), so the
|
||||
// persisted grant does not need to be recorded on requestEvent here.
|
||||
if options.Sandbox != nil {
|
||||
_, _ = persistPermissionGrant(call.Name, args, decisionReason, options)
|
||||
if grant, err := persistPermissionGrant(call.Name, args, decisionReason, options); err == nil {
|
||||
requestEvent.GrantMatched = true
|
||||
requestEvent.Grant = &grant
|
||||
}
|
||||
}
|
||||
case PermissionDecisionCancel:
|
||||
emitCanceledPermission(options, call, requestEvent, decisionReason)
|
||||
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
||||
default:
|
||||
emitDeniedPermission(options, call, requestEvent, decisionReason)
|
||||
return deniedPermissionResult(call, decisionReason, requestEvent), nil
|
||||
@@ -624,6 +720,12 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
if toolFound && options.OnPermission != nil {
|
||||
if event, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, sandboxDecision); ok {
|
||||
event.DecisionReason = decisionReason
|
||||
if decisionAction != "" {
|
||||
event.DecisionAction = decisionAction
|
||||
}
|
||||
if len(decisionCommandPrefix) > 0 {
|
||||
event.CommandPrefix = append([]string(nil), decisionCommandPrefix...)
|
||||
}
|
||||
options.OnPermission(event)
|
||||
}
|
||||
}
|
||||
@@ -912,6 +1014,17 @@ func sandboxRequest(toolName string, tool tools.Tool, args map[string]any, permi
|
||||
}
|
||||
}
|
||||
|
||||
// effectivePermission returns the permission for THIS specific call. A tool that
|
||||
// implements tools.ArgsPermissioner may relax its static permission for arguments
|
||||
// it can prove are safe (the Task tool auto-allows a read-only sub-agent); every
|
||||
// other tool falls back to its static Safety().Permission unchanged.
|
||||
func effectivePermission(tool tools.Tool, args map[string]any) tools.Permission {
|
||||
if p, ok := tool.(tools.ArgsPermissioner); ok {
|
||||
return p.PermissionForArgs(args)
|
||||
}
|
||||
return tool.Safety().Permission
|
||||
}
|
||||
|
||||
func shouldRequestPermission(tool tools.Tool, permissionGranted bool, decision *sandbox.Decision) bool {
|
||||
if permissionGranted || tool.Safety().Permission != tools.PermissionPrompt {
|
||||
return false
|
||||
@@ -931,13 +1044,251 @@ func requestPermission(ctx context.Context, request PermissionRequest, options O
|
||||
|
||||
func normalizePermissionDecisionAction(action PermissionDecisionAction) PermissionDecisionAction {
|
||||
switch action {
|
||||
case PermissionDecisionAllow, PermissionDecisionAlwaysAllow:
|
||||
case PermissionDecisionAllow, PermissionDecisionAllowStrict, PermissionDecisionAllowForSession, PermissionDecisionAllowPrefix, PermissionDecisionAlwaysAllowPrefix, PermissionDecisionAlwaysAllow, PermissionDecisionCancel:
|
||||
return action
|
||||
default:
|
||||
return PermissionDecisionDeny
|
||||
}
|
||||
}
|
||||
|
||||
type requestPermissionsArgs struct {
|
||||
EnvironmentID string `json:"environment_id"`
|
||||
Reason string `json:"reason"`
|
||||
Permissions sandbox.RequestPermissionProfile `json:"permissions"`
|
||||
}
|
||||
|
||||
func executeRequestPermissions(ctx context.Context, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) (ToolResult, error) {
|
||||
parsed, err := parseRequestPermissionsArgs(args)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Invalid arguments for request_permissions: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
normalized, err := sandbox.NormalizeRequestPermissionProfile(parsed.Permissions, options.Cwd)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Invalid permissions for request_permissions: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
if normalized.Empty() {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: request_permissions requires at least one permission.",
|
||||
}, nil
|
||||
}
|
||||
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Invalid permissions for request_permissions: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
request := requestPermissionsPrompt(call, parsed, grantProfile, permissionMode, options)
|
||||
if options.OnPermissionRequest == nil {
|
||||
response := sandbox.RequestPermissionsResponse{
|
||||
Permissions: sandbox.RequestPermissionProfile{},
|
||||
Scope: sandbox.PermissionGrantScopeTurn,
|
||||
}
|
||||
return requestPermissionsResult(call, response, false), nil
|
||||
}
|
||||
|
||||
decision, err := requestPermission(ctx, request, options)
|
||||
if err != nil {
|
||||
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
|
||||
}
|
||||
decision.Action = normalizePermissionDecisionAction(decision.Action)
|
||||
decisionReason := strings.TrimSpace(decision.Reason)
|
||||
if decisionReason == "" {
|
||||
decisionReason = request.Reason
|
||||
}
|
||||
|
||||
response := sandbox.RequestPermissionsResponse{
|
||||
Permissions: sandbox.RequestPermissionProfile{},
|
||||
Scope: sandbox.PermissionGrantScopeTurn,
|
||||
}
|
||||
permissionGranted := false
|
||||
switch decision.Action {
|
||||
case PermissionDecisionAllow:
|
||||
response.Permissions = grantProfile
|
||||
response.Scope = sandbox.PermissionGrantScopeTurn
|
||||
permissionGranted = true
|
||||
case PermissionDecisionAllowStrict:
|
||||
response.Permissions = grantProfile
|
||||
response.Scope = sandbox.PermissionGrantScopeTurn
|
||||
response.StrictAutoReview = true
|
||||
permissionGranted = true
|
||||
case PermissionDecisionAllowForSession:
|
||||
response.Permissions = grantProfile
|
||||
response.Scope = sandbox.PermissionGrantScopeSession
|
||||
permissionGranted = true
|
||||
case PermissionDecisionCancel:
|
||||
decision.Action = PermissionDecisionDeny
|
||||
decisionReason = firstNonEmptyString(decisionReason, "continued without granting permissions")
|
||||
default:
|
||||
decision.Action = PermissionDecisionDeny
|
||||
decisionReason = firstNonEmptyString(decisionReason, "continued without granting permissions")
|
||||
}
|
||||
|
||||
if permissionGranted {
|
||||
if options.Sandbox == nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: request_permissions cannot grant permissions because the sandbox engine is not configured.",
|
||||
}, nil
|
||||
}
|
||||
cleanup, err := options.Sandbox.GrantRequestPermissions(response.Permissions, response.Scope)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Failed to grant requested permissions: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
if response.Scope == sandbox.PermissionGrantScopeTurn && options.runPermissions != nil {
|
||||
options.runPermissions.add(cleanup)
|
||||
}
|
||||
}
|
||||
|
||||
emitRequestPermissionsDecision(options, call, request, decision.Action, decisionReason, permissionGranted)
|
||||
return requestPermissionsResult(call, response, false), nil
|
||||
}
|
||||
|
||||
func parseRequestPermissionsArgs(args map[string]any) (requestPermissionsArgs, error) {
|
||||
data, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return requestPermissionsArgs{}, err
|
||||
}
|
||||
var parsed requestPermissionsArgs
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return requestPermissionsArgs{}, err
|
||||
}
|
||||
if parsed.EnvironmentID == "" {
|
||||
if raw, ok := args["environmentId"].(string); ok {
|
||||
parsed.EnvironmentID = raw
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func requestPermissionsPrompt(call ToolCall, parsed requestPermissionsArgs, profile sandbox.RequestPermissionProfile, permissionMode PermissionMode, options Options) PermissionRequest {
|
||||
autonomy := options.Autonomy
|
||||
if normalized, err := sandbox.NormalizeAutonomy(sandbox.Autonomy(autonomy)); err == nil {
|
||||
autonomy = string(normalized)
|
||||
}
|
||||
reason := strings.TrimSpace(parsed.Reason)
|
||||
if reason == "" {
|
||||
reason = "The agent is requesting additional permissions."
|
||||
}
|
||||
return PermissionRequest{
|
||||
ToolCallID: call.ID,
|
||||
ToolName: tools.RequestPermissionsToolName,
|
||||
Action: PermissionActionPrompt,
|
||||
Permission: string(tools.PermissionPrompt),
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: autonomy,
|
||||
SideEffect: "permissions",
|
||||
Reason: reason,
|
||||
Scope: requestPermissionsScope(profile),
|
||||
Args: map[string]any{
|
||||
"environment_id": parsed.EnvironmentID,
|
||||
"reason": parsed.Reason,
|
||||
"permissions": profile,
|
||||
},
|
||||
AvailableDecisions: []PermissionDecisionAction{
|
||||
PermissionDecisionAllow,
|
||||
PermissionDecisionAllowStrict,
|
||||
PermissionDecisionAllowForSession,
|
||||
PermissionDecisionDeny,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func requestPermissionsScope(profile sandbox.RequestPermissionProfile) string {
|
||||
parts := []string{}
|
||||
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled {
|
||||
parts = append(parts, "network all outbound access")
|
||||
}
|
||||
if profile.FileSystem != nil {
|
||||
for _, path := range profile.FileSystem.Read {
|
||||
parts = append(parts, "read "+path)
|
||||
}
|
||||
for _, path := range profile.FileSystem.Write {
|
||||
parts = append(parts, "write "+path)
|
||||
}
|
||||
for _, path := range profile.FileSystem.DenyRead {
|
||||
parts = append(parts, "deny read "+path)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func emitRequestPermissionsDecision(options Options, call ToolCall, request PermissionRequest, action PermissionDecisionAction, reason string, granted bool) {
|
||||
if options.OnPermission == nil {
|
||||
return
|
||||
}
|
||||
eventAction := PermissionActionDeny
|
||||
if granted {
|
||||
eventAction = PermissionActionAllow
|
||||
}
|
||||
options.OnPermission(PermissionEvent{
|
||||
ToolCallID: call.ID,
|
||||
ToolName: call.Name,
|
||||
Action: eventAction,
|
||||
DecisionAction: action,
|
||||
Permission: request.Permission,
|
||||
PermissionGranted: granted,
|
||||
PermissionMode: request.PermissionMode,
|
||||
Autonomy: request.Autonomy,
|
||||
SideEffect: request.SideEffect,
|
||||
Reason: reason,
|
||||
Scope: request.Scope,
|
||||
DecisionReason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
func requestPermissionsResult(call ToolCall, response sandbox.RequestPermissionsResponse, redacted bool) ToolResult {
|
||||
data, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Failed to serialize request_permissions response: " + err.Error(),
|
||||
}
|
||||
}
|
||||
output, didRedact := scrubInterceptedOutput(string(data))
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusOK,
|
||||
Output: output,
|
||||
Redacted: redacted || didRedact,
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func persistPermissionGrant(toolName string, args map[string]any, reason string, options Options) (sandbox.Grant, error) {
|
||||
if options.Sandbox == nil {
|
||||
return sandbox.Grant{}, errors.New("sandbox engine is not configured")
|
||||
@@ -963,6 +1314,39 @@ func persistPermissionGrant(toolName string, args map[string]any, reason string,
|
||||
})
|
||||
}
|
||||
|
||||
func persistSessionPermissionGrant(toolName string, args map[string]any, reason string, options Options) (sandbox.Grant, error) {
|
||||
if options.Sandbox == nil {
|
||||
return sandbox.Grant{}, errors.New("sandbox engine is not configured")
|
||||
}
|
||||
maxAutonomy := sandbox.Autonomy(options.Autonomy)
|
||||
if maxAutonomy == "" {
|
||||
maxAutonomy = sandbox.AutonomyMedium
|
||||
}
|
||||
if normalized, err := sandbox.NormalizeAutonomy(maxAutonomy); err == nil {
|
||||
maxAutonomy = normalized
|
||||
}
|
||||
scope, kind := sandbox.DeriveScope(toolName, args)
|
||||
return options.Sandbox.GrantForSession(sandbox.GrantInput{
|
||||
ToolName: toolName,
|
||||
Decision: sandbox.GrantAllow,
|
||||
MaxAutonomy: maxAutonomy,
|
||||
Reason: reason,
|
||||
Scope: scope,
|
||||
ScopeKind: kind,
|
||||
})
|
||||
}
|
||||
|
||||
func persistCommandPrefixGrant(toolName string, prefix []string, reason string, options Options) (sandbox.CommandPrefixGrant, error) {
|
||||
if options.Sandbox == nil {
|
||||
return sandbox.CommandPrefixGrant{}, errors.New("sandbox engine is not configured")
|
||||
}
|
||||
return options.Sandbox.GrantCommandPrefix(sandbox.CommandPrefixInput{
|
||||
ToolName: toolName,
|
||||
Prefix: prefix,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
func emitDeniedPermission(options Options, call ToolCall, requestEvent PermissionEvent, reason string) {
|
||||
if options.OnPermission == nil {
|
||||
return
|
||||
@@ -975,11 +1359,31 @@ func emitDeniedPermission(options Options, call ToolCall, requestEvent Permissio
|
||||
event.ToolCallID = call.ID
|
||||
event.ToolName = call.Name
|
||||
event.Action = PermissionActionDeny
|
||||
event.DecisionAction = PermissionDecisionDeny
|
||||
event.PermissionGranted = false
|
||||
event.DecisionReason = reason
|
||||
options.OnPermission(event)
|
||||
}
|
||||
|
||||
func emitCanceledPermission(options Options, call ToolCall, requestEvent PermissionEvent, reason string) {
|
||||
if options.OnPermission == nil {
|
||||
return
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = "cancelled in TUI"
|
||||
}
|
||||
event := requestEvent
|
||||
event.ToolCallID = call.ID
|
||||
event.ToolName = call.Name
|
||||
event.Action = PermissionActionCancel
|
||||
event.DecisionAction = PermissionDecisionCancel
|
||||
event.PermissionGranted = false
|
||||
event.DecisionReason = reason
|
||||
event.Reason = reason
|
||||
options.OnPermission(event)
|
||||
}
|
||||
|
||||
func deniedPermissionResult(call ToolCall, reason string, requestEvent PermissionEvent) ToolResult {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
@@ -990,6 +1394,7 @@ func deniedPermissionResult(call ToolCall, reason string, requestEvent Permissio
|
||||
}
|
||||
event := requestEvent
|
||||
event.Action = PermissionActionDeny
|
||||
event.DecisionAction = PermissionDecisionDeny
|
||||
event.PermissionGranted = false
|
||||
event.DecisionReason = reason
|
||||
if event.Risk.Level == "" {
|
||||
@@ -1016,12 +1421,38 @@ func deniedPermissionResult(call ToolCall, reason string, requestEvent Permissio
|
||||
}
|
||||
}
|
||||
|
||||
func canceledPermissionResult(call ToolCall, reason string, requestEvent PermissionEvent) ToolResult {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = "cancelled in TUI"
|
||||
}
|
||||
event := requestEvent
|
||||
event.Action = PermissionActionCancel
|
||||
event.DecisionAction = PermissionDecisionCancel
|
||||
event.PermissionGranted = false
|
||||
event.DecisionReason = reason
|
||||
event.Reason = reason
|
||||
if requestEvent.ToolName == "" {
|
||||
event.ToolName = call.Name
|
||||
}
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Permission approval cancelled for " + call.Name + ": " + reason,
|
||||
DenialReason: DenialApprovalCanceled,
|
||||
Meta: map[string]string{
|
||||
"permission_action": string(event.Action),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// permissionScope returns a concise, human-readable description of what a tool
|
||||
// call will actually touch — the file path, directory, or working dir lifted
|
||||
// from its arguments — so the permission card and the persisted decision can
|
||||
// show the user exactly what "allow" covers. It is empty when the tool exposes
|
||||
// no path-like argument (the grant is then plainly tool-wide). The first
|
||||
// matching key wins, ordered most-specific (a concrete file) first.
|
||||
// call will actually touch -- a file path, directory, working dir, or network
|
||||
// host lifted from its arguments -- so the permission card and persisted
|
||||
// decision can show the user exactly what "allow" covers. It is empty when the
|
||||
// tool exposes no scoped argument (the grant is then plainly tool-wide). The
|
||||
// first matching key wins, ordered most-specific (a concrete file) first.
|
||||
func permissionScope(toolName string, args map[string]any) string {
|
||||
// sandbox.DeriveScope is the single source of truth for which arguments carry
|
||||
// a scope, shared with grant persistence and matching so the card display can
|
||||
@@ -1098,6 +1529,7 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
ToolCallID: call.ID,
|
||||
ToolName: call.Name,
|
||||
Action: action,
|
||||
DecisionAction: grantDecisionAction(grant),
|
||||
Permission: string(safety.Permission),
|
||||
PermissionGranted: permissionGranted,
|
||||
PermissionMode: permissionMode,
|
||||
@@ -1109,30 +1541,83 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
Violation: violation,
|
||||
GrantMatched: grantMatched,
|
||||
Grant: grant,
|
||||
CommandPrefix: proposedCommandPrefix(call.Name, args),
|
||||
}, true
|
||||
}
|
||||
|
||||
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
|
||||
if grant == nil {
|
||||
return ""
|
||||
}
|
||||
if grant.Session {
|
||||
return PermissionDecisionAllowForSession
|
||||
}
|
||||
if grant.Decision == sandbox.GrantAllow {
|
||||
return PermissionDecisionAlwaysAllow
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fallbackPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionMode PermissionMode, options Options) PermissionEvent {
|
||||
event, _ := buildPermissionEvent(call, tool, args, false, permissionMode, options, nil)
|
||||
return event
|
||||
}
|
||||
|
||||
func permissionRequestFromEvent(event PermissionEvent, args map[string]any) PermissionRequest {
|
||||
func permissionRequestFromEvent(event PermissionEvent, args map[string]any, options Options) PermissionRequest {
|
||||
return PermissionRequest{
|
||||
ToolCallID: event.ToolCallID,
|
||||
ToolName: event.ToolName,
|
||||
Action: event.Action,
|
||||
Permission: event.Permission,
|
||||
PermissionMode: event.PermissionMode,
|
||||
Autonomy: event.Autonomy,
|
||||
SideEffect: event.SideEffect,
|
||||
Reason: event.Reason,
|
||||
Scope: event.Scope,
|
||||
Risk: event.Risk,
|
||||
Args: cloneArgs(args),
|
||||
Violation: event.Violation,
|
||||
GrantMatched: event.GrantMatched,
|
||||
Grant: event.Grant,
|
||||
ToolCallID: event.ToolCallID,
|
||||
ToolName: event.ToolName,
|
||||
Action: event.Action,
|
||||
Permission: event.Permission,
|
||||
PermissionMode: event.PermissionMode,
|
||||
Autonomy: event.Autonomy,
|
||||
SideEffect: event.SideEffect,
|
||||
Reason: event.Reason,
|
||||
Scope: event.Scope,
|
||||
Risk: event.Risk,
|
||||
Args: cloneArgs(args),
|
||||
Violation: event.Violation,
|
||||
GrantMatched: event.GrantMatched,
|
||||
Grant: event.Grant,
|
||||
CommandPrefix: append([]string(nil), event.CommandPrefix...),
|
||||
AvailableDecisions: availablePermissionDecisions(event, options),
|
||||
}
|
||||
}
|
||||
|
||||
func availablePermissionDecisions(event PermissionEvent, options Options) []PermissionDecisionAction {
|
||||
if event.Action != PermissionActionPrompt {
|
||||
return nil
|
||||
}
|
||||
decisions := []PermissionDecisionAction{PermissionDecisionAllow}
|
||||
if options.Sandbox != nil {
|
||||
decisions = append(decisions, PermissionDecisionAllowForSession)
|
||||
if event.ToolName == "bash" && len(event.CommandPrefix) > 0 {
|
||||
decisions = append(decisions, PermissionDecisionAllowPrefix)
|
||||
if options.Sandbox.CanPersistGrants() {
|
||||
decisions = append(decisions, PermissionDecisionAlwaysAllowPrefix)
|
||||
}
|
||||
}
|
||||
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) {
|
||||
decisions = append(decisions, PermissionDecisionAlwaysAllow)
|
||||
}
|
||||
}
|
||||
switch event.ToolName {
|
||||
case "bash":
|
||||
decisions = append(decisions, PermissionDecisionDeny, PermissionDecisionCancel)
|
||||
case "apply_patch":
|
||||
decisions = append(decisions, PermissionDecisionCancel)
|
||||
default:
|
||||
decisions = append(decisions, PermissionDecisionDeny)
|
||||
}
|
||||
return decisions
|
||||
}
|
||||
|
||||
func permissionSupportsPersistentDecision(toolName string) bool {
|
||||
switch toolName {
|
||||
case "bash", "apply_patch":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+550
-1
@@ -54,6 +54,7 @@ func TestIsRetriableToolError(t *testing.T) {
|
||||
// Structured denial categories are authoritative regardless of message text.
|
||||
{"denial: filtered", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialFiltered}, false},
|
||||
{"denial: permission", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialPermissionDenied}, false},
|
||||
{"denial: approval canceled", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialApprovalCanceled}, false},
|
||||
{"denial: sandbox", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialSandboxViolation}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -315,6 +316,37 @@ func TestRunAdvertisesWebFetchInAutoMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
|
||||
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreNetworkTools() {
|
||||
if tool.Name() == "web_search" {
|
||||
registry.Register(tool)
|
||||
}
|
||||
}
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), "what tools exist?", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAuto,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected one request, got %d", len(provider.requests))
|
||||
}
|
||||
if len(provider.requests[0].Tools) != 1 || provider.requests[0].Tools[0].Name != "web_search" {
|
||||
t.Fatalf("expected web_search to be advertised in auto mode, got %#v", provider.requests[0].Tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
@@ -761,6 +793,55 @@ func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
result, err := Run(context.Background(), "write notes", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
}),
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
t.Fatal("workspace write should not request permission")
|
||||
return PermissionDecision{}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
permissionEvents = append(permissionEvents, event)
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "write done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "notes.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "hello" {
|
||||
t.Fatalf("expected workspace write content, got %q", content)
|
||||
}
|
||||
if len(permissionEvents) != 1 {
|
||||
t.Fatalf("expected one auto permission event, got %#v", permissionEvents)
|
||||
}
|
||||
event := permissionEvents[0]
|
||||
if event.Action != PermissionActionAllow || event.PermissionGranted || event.GrantMatched {
|
||||
t.Fatalf("expected sandbox policy allow without user grant, got %#v", event)
|
||||
}
|
||||
if event.Reason != "workspace write permitted by sandbox policy" {
|
||||
t.Fatalf("expected workspace-write reason, got %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
@@ -809,6 +890,98 @@ func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write should not continue")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
result, err := Run(context.Background(), "write notes", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionCancel, Reason: "redirect needed"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
permissionEvents = append(permissionEvents, event)
|
||||
},
|
||||
})
|
||||
|
||||
if !errors.Is(err, errPermissionApprovalCanceled) {
|
||||
t.Fatalf("expected permission approval cancel error, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "" {
|
||||
t.Fatalf("canceled run must not continue to final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("canceled permission prompt should stop after first turn, got %d provider requests", len(provider.requests))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "notes.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected canceled write to leave file missing, stat error: %v", err)
|
||||
}
|
||||
if len(permissionEvents) != 1 {
|
||||
t.Fatalf("expected one cancel permission event, got %#v", permissionEvents)
|
||||
}
|
||||
event := permissionEvents[0]
|
||||
if event.Action != PermissionActionCancel || event.DecisionAction != PermissionDecisionCancel || event.PermissionGranted {
|
||||
t.Fatalf("expected final cancel event, got %#v", event)
|
||||
}
|
||||
if event.DecisionReason != "redirect needed" {
|
||||
t.Fatalf("expected cancel reason in final event, got %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailablePermissionDecisionsSplitDenyAndCancel(t *testing.T) {
|
||||
options := Options{Sandbox: sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})}
|
||||
cases := []struct {
|
||||
name string
|
||||
tool string
|
||||
want []PermissionDecisionAction
|
||||
}{
|
||||
{
|
||||
name: "bash keeps recoverable deny and explicit cancel",
|
||||
tool: "bash",
|
||||
want: []PermissionDecisionAction{
|
||||
PermissionDecisionAllow,
|
||||
PermissionDecisionAllowForSession,
|
||||
PermissionDecisionDeny,
|
||||
PermissionDecisionCancel,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "apply patch uses cancel without recoverable deny",
|
||||
tool: "apply_patch",
|
||||
want: []PermissionDecisionAction{
|
||||
PermissionDecisionAllow,
|
||||
PermissionDecisionAllowForSession,
|
||||
PermissionDecisionCancel,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file write keeps recoverable deny",
|
||||
tool: "write_file",
|
||||
want: []PermissionDecisionAction{
|
||||
PermissionDecisionAllow,
|
||||
PermissionDecisionAllowForSession,
|
||||
PermissionDecisionDeny,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := availablePermissionDecisions(PermissionEvent{
|
||||
ToolName: tc.tool,
|
||||
Action: PermissionActionPrompt,
|
||||
}, options)
|
||||
if !equalPermissionDecisions(got, tc.want) {
|
||||
t.Fatalf("decisions = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
@@ -819,6 +992,8 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var permissionEvents []PermissionEvent
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.EnforceWorkspace = false
|
||||
|
||||
result, err := Run(context.Background(), "write notes", provider, Options{
|
||||
Registry: registry,
|
||||
@@ -826,7 +1001,7 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
@@ -879,6 +1054,359 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "write_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt","content":"first","overwrite":true}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "write_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"path":"notes.txt","content":"second","overwrite":true}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.EnforceWorkspace = false
|
||||
|
||||
result, err := Run(context.Background(), "write notes twice", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllowForSession, Reason: "trust this file for the session"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
permissionEvents = append(permissionEvents, event)
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "notes.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "second" {
|
||||
t.Fatalf("expected second write content, got %q", content)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected one permission request, got %#v", requests)
|
||||
}
|
||||
if len(permissionEvents) != 2 {
|
||||
t.Fatalf("expected two permission events, got %#v", permissionEvents)
|
||||
}
|
||||
if permissionEvents[0].DecisionAction != PermissionDecisionAllowForSession || permissionEvents[0].Grant == nil || !permissionEvents[0].Grant.Session {
|
||||
t.Fatalf("expected first event to carry session grant, got %#v", permissionEvents[0])
|
||||
}
|
||||
if !permissionEvents[1].GrantMatched || permissionEvents[1].Grant == nil || !permissionEvents[1].Grant.Session {
|
||||
t.Fatalf("expected second event to be session-grant matched, got %#v", permissionEvents[1])
|
||||
}
|
||||
lookup, err := store.Lookup("write_file", filepath.Join(root, "notes.txt"), sandbox.AutonomyMedium)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lookup.Matched {
|
||||
t.Fatalf("session approval must not persist a grant, got %#v", lookup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo prefix-ok"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"echo prefix-ok again"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
result, err := Run(context.Background(), "run twice", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
if !containsPermissionDecision(request.AvailableDecisions, PermissionDecisionAllowPrefix) {
|
||||
t.Fatalf("bash prompt missing prefix approval decision: %#v", request.AvailableDecisions)
|
||||
}
|
||||
if !equalStringSlices(request.CommandPrefix, []string{"echo", "prefix-ok"}) {
|
||||
t.Fatalf("request command prefix = %#v", request.CommandPrefix)
|
||||
}
|
||||
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "trust this command prefix"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
permissionEvents = append(permissionEvents, event)
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected only the first bash call to prompt, got %#v", requests)
|
||||
}
|
||||
if len(permissionEvents) != 2 {
|
||||
t.Fatalf("expected two bash permission events, got %#v", permissionEvents)
|
||||
}
|
||||
for index, event := range permissionEvents {
|
||||
if event.DecisionAction != PermissionDecisionAllowPrefix || !event.PermissionGranted {
|
||||
t.Fatalf("event %d = %#v, want prefix allow", index, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.Network = sandbox.NetworkAllow
|
||||
|
||||
firstProvider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo persist-ok"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "first done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var firstRequests []PermissionRequest
|
||||
var firstEvents []PermissionEvent
|
||||
if _, err := Run(context.Background(), "run once", firstProvider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
firstRequests = append(firstRequests, request)
|
||||
if !containsPermissionDecision(request.AvailableDecisions, PermissionDecisionAlwaysAllowPrefix) {
|
||||
t.Fatalf("bash prompt missing persistent prefix approval decision: %#v", request.AvailableDecisions)
|
||||
}
|
||||
return PermissionDecision{Action: PermissionDecisionAlwaysAllowPrefix, Reason: "trust this command prefix"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
firstEvents = append(firstEvents, event)
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(firstRequests) != 1 {
|
||||
t.Fatalf("expected first run to prompt once, got %#v", firstRequests)
|
||||
}
|
||||
if len(firstEvents) != 1 || firstEvents[0].DecisionAction != PermissionDecisionAlwaysAllowPrefix {
|
||||
t.Fatalf("expected persistent prefix event, got %#v", firstEvents)
|
||||
}
|
||||
prefixes, err := store.ListCommandPrefixes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(prefixes) != 1 || !equalStringSlices(prefixes[0].Prefix, []string{"echo", "persist-ok"}) {
|
||||
t.Fatalf("persisted prefixes = %#v, want echo persist-ok", prefixes)
|
||||
}
|
||||
|
||||
secondProvider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"echo persist-ok again"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "second done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var secondEvents []PermissionEvent
|
||||
if _, err := Run(context.Background(), "run again", secondProvider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
t.Fatalf("persistent command prefix should skip prompt, got %#v", request)
|
||||
return PermissionDecision{}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
secondEvents = append(secondEvents, event)
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(secondEvents) != 1 || secondEvents[0].DecisionAction != PermissionDecisionAlwaysAllowPrefix || !secondEvents[0].PermissionGranted {
|
||||
t.Fatalf("expected persistent prefix match event, got %#v", secondEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersistentCommandPrefixDoesNotBypassNetworkDeny(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.GrantCommandPrefix(sandbox.CommandPrefixInput{ToolName: "bash", Prefix: []string{"curl"}}); err != nil {
|
||||
t.Fatalf("seed command prefix: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"curl https://example.com"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var events []PermissionEvent
|
||||
if _, err := Run(context.Background(), "curl", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Store: store,
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
t.Fatalf("matched persistent command prefix should not prompt before sandbox denial, got %#v", request)
|
||||
return PermissionDecision{}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
events = append(events, event)
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].Action != PermissionActionDeny || events[0].Violation == nil || events[0].Violation.Code != sandbox.ViolationNetwork {
|
||||
t.Fatalf("expected network sandbox denial despite prefix match, got %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo hi && echo bye","prefix_rule":["echo"]}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), "run unsafe", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
if len(request.CommandPrefix) != 0 {
|
||||
t.Fatalf("unsafe command should not have a command prefix: %#v", request.CommandPrefix)
|
||||
}
|
||||
if containsPermissionDecision(request.AvailableDecisions, PermissionDecisionAllowPrefix) {
|
||||
t.Fatalf("unsafe command should not offer prefix approval: %#v", request.AvailableDecisions)
|
||||
}
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "deny unsafe"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
||||
// Choosing "always allow" with NO sandbox engine configured must still allow
|
||||
// THIS call (there is just nowhere to persist a grant for future calls). The
|
||||
@@ -917,6 +1445,15 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
|
||||
for _, decision := range decisions {
|
||||
if decision == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cancelMidStreamProvider cancels the run while the provider stream is open and
|
||||
// never sends a terminal event, so CollectStream returns via ctx.Done().
|
||||
type cancelMidStreamProvider struct{ cancel context.CancelFunc }
|
||||
@@ -1188,6 +1725,18 @@ func quoteJSONString(value string) string {
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func equalPermissionDecisions(a, b []PermissionDecisionAction) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestRunAppendsConfirmationPolicyToSystemPrompt(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestPermissionScope(t *testing.T) {
|
||||
@@ -21,6 +24,7 @@ func TestPermissionScope(t *testing.T) {
|
||||
{name: "non-string path ignored", tool: "write_file", args: map[string]any{"path": 42}, want: ""},
|
||||
{name: "path wins over cwd", tool: "x", args: map[string]any{"cwd": "a", "path": "b"}, want: "b"},
|
||||
{name: "whitespace path is no scope", tool: "write_file", args: map[string]any{"path": " "}, want: ""},
|
||||
{name: "web fetch host", tool: "web_fetch", args: map[string]any{"url": "https://Example.COM:443/a"}, want: "example.com"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -41,3 +45,32 @@ func TestPermissionScopeTruncatesLongPaths(t *testing.T) {
|
||||
t.Fatalf("truncated scope should end with an ellipsis: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistPermissionGrantScopesWebFetchToHost(t *testing.T) {
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Store: store,
|
||||
})
|
||||
|
||||
grant, err := persistPermissionGrant("web_fetch", map[string]any{"url": "https://Example.COM:443/docs"}, "trust this host", Options{
|
||||
Sandbox: engine,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if grant.ScopeKind != sandbox.ScopeHost || grant.Scope != "example.com" {
|
||||
t.Fatalf("grant = %#v, want host-scoped example.com", grant)
|
||||
}
|
||||
if lookup, err := store.Lookup("web_fetch", "example.com", sandbox.AutonomyMedium); err != nil || !lookup.Matched {
|
||||
t.Fatalf("expected exact host lookup to match: lookup=%#v err=%v", lookup, err)
|
||||
}
|
||||
if lookup, err := store.Lookup("web_fetch", "api.example.com", sandbox.AutonomyMedium); err != nil || lookup.Matched {
|
||||
t.Fatalf("expected subdomain lookup to re-prompt: lookup=%#v err=%v", lookup, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
target := filepath.Join(outside, "vegetables.txt")
|
||||
scope, err := sandbox.NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Scope: scope,
|
||||
})
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewRequestPermissionsTool())
|
||||
registry.Register(tools.NewScopedWriteFileTool(workspace, scope))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "grant-1", ToolName: tools.RequestPermissionsToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "grant-1", ArgumentsFragment: `{"reason":"Need to write outside the workspace.","permissions":{"file_system":{"write":[` + quoteJSONString(target) + `]}}}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "grant-1"},
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "write-1", ToolName: "write_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "write-1", ArgumentsFragment: `{"path":` + quoteJSONString(target) + `,"content":"carrot\n","overwrite":true}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "write-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var results []ToolResult
|
||||
normalizedProfile, err := sandbox.NormalizeRequestPermissionProfile(sandbox.RequestPermissionProfile{
|
||||
FileSystem: &sandbox.FileSystemPermissions{Write: []string{target}},
|
||||
}, workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedGrantProfile, err := sandbox.RequestPermissionGrantProfile(normalizedProfile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedWriteRoot := expectedGrantProfile.FileSystem.Write[0]
|
||||
|
||||
result, err := Run(context.Background(), "write the file", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAuto,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Cwd: workspace,
|
||||
Sandbox: engine,
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "ok"}, nil
|
||||
},
|
||||
OnToolResult: func(result ToolResult) {
|
||||
results = append(results, result)
|
||||
},
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
||||
}
|
||||
if len(requests) != 1 || requests[0].ToolName != tools.RequestPermissionsToolName {
|
||||
t.Fatalf("permission requests = %#v, want one request_permissions prompt", requests)
|
||||
}
|
||||
if !strings.Contains(requests[0].Scope, "write "+expectedWriteRoot) || strings.Contains(requests[0].Scope, target) {
|
||||
t.Fatalf("permission request scope = %q, want widened directory root %q and not file %q", requests[0].Scope, expectedWriteRoot, target)
|
||||
}
|
||||
if len(results) != 2 || results[0].Status != tools.StatusOK || results[1].Status != tools.StatusOK {
|
||||
t.Fatalf("tool results = %#v, want request_permissions and write_file ok", results)
|
||||
}
|
||||
var grantResponse sandbox.RequestPermissionsResponse
|
||||
if err := json.Unmarshal([]byte(results[0].Output), &grantResponse); err != nil {
|
||||
t.Fatalf("unmarshal grant response %q: %v", results[0].Output, err)
|
||||
}
|
||||
if grantResponse.Permissions.FileSystem == nil ||
|
||||
len(grantResponse.Permissions.FileSystem.Write) != 1 ||
|
||||
grantResponse.Permissions.FileSystem.Write[0] != expectedWriteRoot {
|
||||
t.Fatalf("grant response permissions = %#v, want write root %q", grantResponse.Permissions, expectedWriteRoot)
|
||||
}
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "carrot\n" {
|
||||
t.Fatalf("target content = %q", content)
|
||||
}
|
||||
|
||||
decision := engine.Evaluate(context.Background(), sandbox.Request{
|
||||
WorkspaceRoot: workspace,
|
||||
ToolName: "write_file",
|
||||
SideEffect: sandbox.SideEffectWrite,
|
||||
Permission: sandbox.PermissionPrompt,
|
||||
PermissionMode: sandbox.PermissionModeAuto,
|
||||
Autonomy: sandbox.AutonomyMedium,
|
||||
Args: map[string]any{"path": target},
|
||||
})
|
||||
if decision.Action != sandbox.ActionDeny {
|
||||
t.Fatalf("turn grant should be cleaned up after Run, got decision %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPermissionsDenyReturnsEmptyGrantAndContinues(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewRequestPermissionsTool())
|
||||
provider := requestPermissionsOnlyProvider(`{"reason":"Need network.","permissions":{"network":{"enabled":true}}}`, "done")
|
||||
var results []ToolResult
|
||||
|
||||
result, err := Run(context.Background(), "request network", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAuto,
|
||||
Cwd: workspace,
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: workspace, Policy: sandbox.DefaultPolicy()}),
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "no"}, nil
|
||||
},
|
||||
OnToolResult: func(result ToolResult) { results = append(results, result) },
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
||||
}
|
||||
if len(results) != 1 || results[0].Status != tools.StatusOK {
|
||||
t.Fatalf("request_permissions denial should be an ok tool result, got %#v", results)
|
||||
}
|
||||
var response sandbox.RequestPermissionsResponse
|
||||
if err := json.Unmarshal([]byte(results[0].Output), &response); err != nil {
|
||||
t.Fatalf("unmarshal response %q: %v", results[0].Output, err)
|
||||
}
|
||||
if !response.Permissions.Empty() || response.Scope != sandbox.PermissionGrantScopeTurn {
|
||||
t.Fatalf("deny response = %#v, want empty turn grant", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPermissionsStrictReviewResponse(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewRequestPermissionsTool())
|
||||
provider := requestPermissionsOnlyProvider(`{"reason":"Need read access.","permissions":{"file_system":{"read":[`+quoteJSONString(workspace)+`]}}}`, "done")
|
||||
var output string
|
||||
|
||||
_, err := Run(context.Background(), "request read", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAuto,
|
||||
Cwd: workspace,
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: workspace, Policy: sandbox.DefaultPolicy()}),
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionAllowStrict, Reason: "review commands"}, nil
|
||||
},
|
||||
OnToolResult: func(result ToolResult) { output = result.Output },
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response sandbox.RequestPermissionsResponse
|
||||
if err := json.Unmarshal([]byte(output), &response); err != nil {
|
||||
t.Fatalf("unmarshal response %q: %v", output, err)
|
||||
}
|
||||
if !response.StrictAutoReview || response.Scope != sandbox.PermissionGrantScopeTurn {
|
||||
t.Fatalf("strict response = %#v, want strict turn grant", response)
|
||||
}
|
||||
if response.Permissions.FileSystem == nil || len(response.Permissions.FileSystem.Read) != 1 {
|
||||
t.Fatalf("strict response permissions = %#v, want read permission", response.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func requestPermissionsOnlyProvider(arguments string, finalAnswer string) *mockProvider {
|
||||
if !strings.HasPrefix(arguments, "{") {
|
||||
arguments = "{}"
|
||||
}
|
||||
return &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "grant-1", ToolName: tools.RequestPermissionsToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "grant-1", ArgumentsFragment: arguments},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "grant-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: finalAnswer},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpecialistDelegationContext(t *testing.T) {
|
||||
if got := specialistDelegationContext(Options{}); got != "" {
|
||||
t.Fatalf("no specialists should yield an empty section, got %q", got)
|
||||
}
|
||||
got := specialistDelegationContext(Options{Specialists: []SpecialistInfo{
|
||||
{Name: "explorer", WhenToUse: "Read-only codebase exploration."},
|
||||
{Name: " ", WhenToUse: "nameless, should be skipped"},
|
||||
{Name: "worker"},
|
||||
}})
|
||||
if !strings.Contains(got, "<specialists>") || !strings.Contains(got, "</specialists>") {
|
||||
t.Fatalf("missing specialists block: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- explorer: Read-only codebase exploration.") {
|
||||
t.Fatalf("missing explorer line: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- worker\n") {
|
||||
t.Fatalf("worker (no description) line missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "nameless") {
|
||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
|
||||
with := buildSystemPrompt(Options{Specialists: []SpecialistInfo{
|
||||
{Name: "explorer", WhenToUse: "Explore."},
|
||||
}})
|
||||
if !strings.Contains(with, "<specialists>") || !strings.Contains(with, "Task tool") {
|
||||
t.Fatalf("expected delegation guidance in system prompt: %q", with)
|
||||
}
|
||||
// Default (no specialists) must reproduce the prior prompt: no delegation block.
|
||||
without := buildSystemPrompt(Options{})
|
||||
if strings.Contains(without, "<specialists>") {
|
||||
t.Fatalf("delegation block must not appear without specialists")
|
||||
}
|
||||
}
|
||||
+266
-14
@@ -31,13 +31,20 @@ var confirmationPolicy string
|
||||
const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with the current workspace and use tools when needed."
|
||||
|
||||
// projectContextFiles are workspace docs injected into the system prompt so the
|
||||
// agent honors project-specific conventions (mirrors AGENTS.md / CLAUDE.md). The
|
||||
// first one found wins.
|
||||
// agent honors project-specific conventions (mirrors AGENTS.md / CLAUDE.md).
|
||||
// The first match at each directory level wins; the loader walks the chain
|
||||
// from the git root down to the cwd and injects the matches in
|
||||
// general-to-specific order.
|
||||
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
|
||||
|
||||
// maxProjectContextBytes caps how much of a project doc is injected so a large
|
||||
// guidelines file can't blow the context budget.
|
||||
const maxProjectContextBytes = 8 << 10 // 8 KiB
|
||||
// maxProjectContextBytes caps how much of a single project doc is injected so
|
||||
// a large guidelines file can't blow the context budget.
|
||||
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
|
||||
|
||||
// maxProjectContextTotalBytes caps the total bytes across all matched
|
||||
// project guideline files. With path-walking, multiple files can match (root
|
||||
// + sub-tree), so a per-file cap alone is not enough.
|
||||
const maxProjectContextTotalBytes = 32 << 10 // 32 KiB total
|
||||
|
||||
// maxRepoMapContextBytes keeps the repository map useful but compact enough to
|
||||
// remain stable across normal agent turns.
|
||||
@@ -67,18 +74,76 @@ func buildSystemPrompt(options Options) string {
|
||||
if session := sessionRuntimeContext(options); session != "" {
|
||||
sections = append(sections, session)
|
||||
}
|
||||
if prefixes := approvedCommandPrefixContext(options); prefixes != "" {
|
||||
sections = append(sections, prefixes)
|
||||
}
|
||||
if seed := workspaceSeedContext(options.Cwd); seed != "" {
|
||||
sections = append(sections, seed)
|
||||
}
|
||||
if ws := workspaceContext(options.Cwd); ws != "" {
|
||||
sections = append(sections, ws)
|
||||
}
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
|
||||
sections = append(sections, policy)
|
||||
}
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
func approvedCommandPrefixContext(options Options) string {
|
||||
if options.Sandbox == nil {
|
||||
return ""
|
||||
}
|
||||
prefixes := options.Sandbox.ApprovedCommandPrefixes()
|
||||
if len(prefixes) == 0 {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(prefixes))
|
||||
for _, grant := range prefixes {
|
||||
if len(grant.Prefix) == 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "- "+grant.ToolName+": "+strings.Join(grant.Prefix, " "))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
|
||||
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
|
||||
// tool outputs out of the main context. It renders only when specialists are
|
||||
// known (which is only where the Task tool is actually registered), so a run with
|
||||
// no delegatable specialists produces the previous prompt unchanged.
|
||||
func specialistDelegationContext(options Options) string {
|
||||
if len(options.Specialists) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<specialists>\n")
|
||||
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
|
||||
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
|
||||
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
|
||||
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
|
||||
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
|
||||
for _, info := range options.Specialists {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if desc := strings.TrimSpace(info.WhenToUse); desc != "" {
|
||||
b.WriteString("- " + name + ": " + desc + "\n")
|
||||
} else {
|
||||
b.WriteString("- " + name + "\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("</specialists>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sessionRuntimeContext(options Options) string {
|
||||
provider := strings.TrimSpace(options.ProviderName)
|
||||
model := strings.TrimSpace(options.Model)
|
||||
@@ -121,8 +186,36 @@ func workspaceContext(cwd string) string {
|
||||
}
|
||||
b.WriteString("</environment>")
|
||||
|
||||
for _, name := range projectContextFiles {
|
||||
data, err := os.ReadFile(filepath.Join(cwd, name))
|
||||
b.WriteString(projectGuidelines(cwd, findProjectGitRoot(cwd)))
|
||||
if repoMap := repoMapContext(cwd); repoMap != "" {
|
||||
b.WriteString("\n\n## Repo map\n\n" + repoMap)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// projectGuidelines walks the directory chain from the git root to cwd
|
||||
// (inclusive), finds the first matching project context file at each level
|
||||
// (case-insensitive basename match), reads it, and returns the joined
|
||||
// content in general-to-specific order — the most general file (at the git
|
||||
// root) appears first, the most specific (at cwd) last. Each file is capped
|
||||
// at maxProjectContextBytes; the total across all files is capped at
|
||||
// maxProjectContextTotalBytes. Returns "" when no file matches.
|
||||
func projectGuidelines(cwd, gitRoot string) string {
|
||||
dirs := projectGuidelineDirs(cwd, gitRoot)
|
||||
if len(dirs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
totalUsed := 0
|
||||
for _, dir := range dirs {
|
||||
if totalUsed >= maxProjectContextTotalBytes {
|
||||
break
|
||||
}
|
||||
match := findProjectContextFile(dir)
|
||||
if match == "" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(match)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -130,22 +223,181 @@ func workspaceContext(cwd string) string {
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
if len(content) > maxProjectContextBytes {
|
||||
cut := maxProjectContextBytes
|
||||
// Per-file cap is the lesser of the configured per-file limit and
|
||||
// whatever remains of the total budget, so a single file can never
|
||||
// blow past either bound.
|
||||
limit := maxProjectContextBytes
|
||||
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
if len(content) > limit {
|
||||
cut := limit
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
content = content[:cut] + "\n… (truncated)"
|
||||
}
|
||||
b.WriteString("\n\n## Project guidelines (" + name + ")\n\n" + content)
|
||||
break // first match wins
|
||||
}
|
||||
if repoMap := repoMapContext(cwd); repoMap != "" {
|
||||
b.WriteString("\n\n## Repo map\n\n" + repoMap)
|
||||
label := projectGuidelineLabel(match, gitRoot)
|
||||
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
|
||||
totalUsed += len(content)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
|
||||
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
|
||||
// from cwd, the chain collapses to [cwd].
|
||||
func projectGuidelineDirs(cwd, gitRoot string) []string {
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" {
|
||||
return nil
|
||||
}
|
||||
gitRoot = strings.TrimSpace(gitRoot)
|
||||
if gitRoot == "" {
|
||||
return []string{cwd}
|
||||
}
|
||||
rel, err := filepath.Rel(gitRoot, cwd)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return []string{cwd}
|
||||
}
|
||||
if rel == "." {
|
||||
return []string{gitRoot}
|
||||
}
|
||||
dirs := []string{gitRoot}
|
||||
cur := gitRoot
|
||||
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
|
||||
if seg == "" || seg == "." {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, seg)
|
||||
dirs = append(dirs, cur)
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// projectGuidelineLabel renders the path of match as a short label relative
|
||||
// to gitRoot. When gitRoot is empty, the basename is returned.
|
||||
func projectGuidelineLabel(match, gitRoot string) string {
|
||||
if gitRoot == "" {
|
||||
return filepath.Base(match)
|
||||
}
|
||||
rel, err := filepath.Rel(gitRoot, match)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return filepath.Base(match)
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// findProjectContextFile returns the first matching project guideline file
|
||||
// in dir. Lookup is case-insensitive on the basename and on the actual
|
||||
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
|
||||
// still resolves to its true filename on case-sensitive filesystems — the
|
||||
// returned path is what should appear in the project guidelines label.
|
||||
// Returns "" when nothing matches.
|
||||
func findProjectContextFile(dir string) string {
|
||||
for _, name := range projectContextFiles {
|
||||
baseLower := strings.ToLower(filepath.Base(name))
|
||||
// Walk to the file's parent through dir with case-insensitive segment
|
||||
// matching, then find a regular file with the same lowercased
|
||||
// basename. This works on both case-sensitive and case-insensitive
|
||||
// filesystems and always returns the on-disk filename.
|
||||
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(parent)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
|
||||
return filepath.Join(parent, e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveDirCaseInsensitive walks from anchor to target, matching each
|
||||
// segment case-insensitively. Returns the resolved absolute path and true on
|
||||
// success, or "" and false if any segment cannot be found.
|
||||
func resolveDirCaseInsensitive(target, anchor string) (string, bool) {
|
||||
if target == "" || target == "." {
|
||||
return anchor, true
|
||||
}
|
||||
rel, err := filepath.Rel(anchor, target)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if rel == "." {
|
||||
return anchor, true
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
cur := anchor
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(cur)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && strings.EqualFold(e.Name(), p) {
|
||||
cur = filepath.Join(cur, e.Name())
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return cur, true
|
||||
}
|
||||
|
||||
// findProjectGitRoot returns the nearest ancestor of cwd that contains a
|
||||
// .git entry (file or directory). Returns "" when no git root is found, so
|
||||
// the caller can fall back to cwd-only lookup.
|
||||
func findProjectGitRoot(cwd string) string {
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" {
|
||||
return ""
|
||||
}
|
||||
cur := cwd
|
||||
for {
|
||||
if hasGitMetadata(cur) {
|
||||
return cur
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return ""
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
func hasGitMetadata(dir string) bool {
|
||||
gitPath := filepath.Join(dir, ".git")
|
||||
info, err := os.Stat(gitPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if info.IsDir() {
|
||||
_, err := os.Stat(filepath.Join(gitPath, "HEAD"))
|
||||
return err == nil
|
||||
}
|
||||
data, err := os.ReadFile(gitPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(strings.TrimSpace(string(data)), "gitdir: ")
|
||||
}
|
||||
|
||||
func workspaceSeedContext(cwd string) string {
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
@@ -71,6 +73,22 @@ func TestBuildSystemPromptOmitsWorkspaceSeedWithoutCwd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesApprovedCommandPrefixes(t *testing.T) {
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore returned error: %v", err)
|
||||
}
|
||||
if _, err := store.GrantCommandPrefix(sandbox.CommandPrefixInput{ToolName: "bash", Prefix: []string{"git", "status"}}); err != nil {
|
||||
t.Fatalf("GrantCommandPrefix returned error: %v", err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Sandbox: sandbox.NewEngine(sandbox.EngineOptions{Store: store})})
|
||||
for _, want := range []string{"Approved Command Prefixes", "bash: git status"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("system prompt missing %q:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeSystemPromptTestFile(t *testing.T, root, rel, contents string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||
@@ -95,3 +113,179 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
|
||||
// loader must still resolve it when the cwd lookup uses lowercase.
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
if !strings.Contains(prompt, "## Project guidelines (AGENTS.MD)") {
|
||||
t.Fatalf("expected case-insensitive AGENTS.MD resolution, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "make lint") {
|
||||
t.Fatalf("expected AGENTS.MD content injected, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptProjectGuidelinesPathWalkingMonorepo(t *testing.T) {
|
||||
// Simulate a monorepo: root + sub-tree each have their own AGENTS.md.
|
||||
// The user launches Zero from the sub-tree, so both files should be
|
||||
// injected in general-to-specific order (root first, cwd last).
|
||||
root := t.TempDir()
|
||||
leaf := filepath.Join(root, "services", "api")
|
||||
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("Repo-wide: prefer Go."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(leaf, "AGENTS.md"), []byte("API: follow REST conventions."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: leaf})
|
||||
normalized := filepath.ToSlash(prompt)
|
||||
rootLabel := "Project guidelines (AGENTS.md)"
|
||||
leafLabel := "Project guidelines (services/api/AGENTS.md)"
|
||||
rootBlock := systemPromptTestBlock(t, normalized, rootLabel, leafLabel)
|
||||
leafBlock := systemPromptTestBlock(t, normalized, leafLabel, "## Repo map")
|
||||
if !strings.Contains(rootBlock, "Repo-wide: prefer Go.") {
|
||||
t.Fatalf("expected root AGENTS.md in general-to-specific slot, got:\n%s", rootBlock)
|
||||
}
|
||||
if !strings.Contains(leafBlock, "API: follow REST conventions.") {
|
||||
t.Fatalf("expected leaf AGENTS.md in specific slot, got:\n%s", leafBlock)
|
||||
}
|
||||
// Root must appear before leaf in the prompt.
|
||||
rootIdx := strings.Index(normalized, "## "+rootLabel)
|
||||
leafIdx := strings.Index(normalized, "## "+leafLabel)
|
||||
if rootIdx < 0 || leafIdx < 0 || rootIdx > leafIdx {
|
||||
t.Fatalf("expected root (general) before leaf (specific) in prompt, got root=%d leaf=%d", rootIdx, leafIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptProjectGuidelinesZeroFallback(t *testing.T) {
|
||||
// ZERO.md is the second-priority name at each level; the loader picks it
|
||||
// when no AGENTS.md is present.
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "ZERO.md"), []byte("Brand-specific rule."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
if !strings.Contains(prompt, "## Project guidelines (ZERO.md)") {
|
||||
t.Fatalf("expected ZERO.md fallback to be injected, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Brand-specific rule.") {
|
||||
t.Fatalf("expected ZERO.md content, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptProjectGuidelinesProjectLocalFallback(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
dot := filepath.Join(cwd, ".zero")
|
||||
if err := os.MkdirAll(dot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dot, "AGENTS.md"), []byte("Personal: use dark theme."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
// Without a git root, the label collapses to the basename; the test
|
||||
// confirms the .zero/AGENTS.md file's content is the one injected, not
|
||||
// any other file.
|
||||
if !strings.Contains(prompt, "Personal: use dark theme.") {
|
||||
t.Fatalf("expected .zero/AGENTS.md content, got:\n%s", prompt)
|
||||
}
|
||||
// The project guidelines block must be present (regardless of label).
|
||||
if !strings.Contains(prompt, "## Project guidelines (") {
|
||||
t.Fatalf("expected a project guidelines block, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
|
||||
// Root file fits in the per-file cap; leaf file is bigger than what's
|
||||
// left of the total budget, so it must be truncated. The truncation
|
||||
// marker must appear in the prompt and the full untruncated payload
|
||||
// must not.
|
||||
root := t.TempDir()
|
||||
leaf := filepath.Join(root, "sub")
|
||||
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootContent := strings.Repeat("r", maxProjectContextBytes) // exactly per-file cap
|
||||
leafContent := strings.Repeat("L", maxProjectContextTotalBytes+1) // over the total cap
|
||||
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte(rootContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(leaf, "AGENTS.md"), []byte(leafContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: leaf})
|
||||
if !strings.Contains(prompt, "truncated") {
|
||||
t.Fatalf("expected truncation marker on second file, prompt length=%d", len(prompt))
|
||||
}
|
||||
// Sanity: the leaf file's full payload must NOT be present untruncated.
|
||||
if strings.Contains(prompt, strings.Repeat("L", maxProjectContextTotalBytes-1)) {
|
||||
t.Fatalf("leaf file appears untruncated; total cap is not enforced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
|
||||
root := filepath.Join("r")
|
||||
leaf := filepath.Join(root, "a", "b")
|
||||
got := projectGuidelineDirs(leaf, root)
|
||||
want := []string{root, filepath.Join(root, "a"), leaf}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("projectGuidelineDirs = %v, want %v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("projectGuidelineDirs[%d] = %q, want %q (full %v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectGuidelineDirsCollapsesToCwdWithoutGitRoot(t *testing.T) {
|
||||
got := projectGuidelineDirs(filepath.Join("some", "path"), "")
|
||||
if len(got) != 1 || got[0] != filepath.Join("some", "path") {
|
||||
t.Fatalf("projectGuidelineDirs = %v, want [some/path]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindProjectContextFileCaseInsensitiveBasename(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := findProjectContextFile(cwd)
|
||||
if filepath.Base(got) != "AGENTS.MD" {
|
||||
t.Fatalf("findProjectContextFile = %q, want basename AGENTS.MD", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindProjectGitRootIgnoresEmptyGitDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf := filepath.Join(root, "child")
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := findProjectGitRoot(leaf); got != "" {
|
||||
t.Fatalf("findProjectGitRoot = %q, want empty for invalid .git directory", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApproxTextTokens(t *testing.T) {
|
||||
if got := ApproxTextTokens(""); got != 0 {
|
||||
t.Fatalf("empty = %d, want 0", got)
|
||||
}
|
||||
// Whitespace is folded out (BPE merges it into the adjacent token):
|
||||
// "aaaa bbbb" has 8 non-space bytes -> 2 tokens, where naive len/4 says 3.
|
||||
if got := ApproxTextTokens("aaaa bbbb"); got != 2 {
|
||||
t.Fatalf("ApproxTextTokens = %d, want 2 (8 non-space / 4)", got)
|
||||
}
|
||||
// It must never exceed naive len/4 — it only removes whitespace — and on
|
||||
// whitespace-bearing text it should be strictly smaller.
|
||||
cases := []string{"hello world", "func main() {\n\treturn\n}", "a\tb\nc d e f"}
|
||||
for _, s := range cases {
|
||||
approx, naive := ApproxTextTokens(s), len(s)/4
|
||||
if approx > naive {
|
||||
t.Fatalf("ApproxTextTokens(%q)=%d exceeded len/4=%d", s, approx, naive)
|
||||
}
|
||||
}
|
||||
// Pure non-whitespace matches len/4 exactly (nothing to fold).
|
||||
if got := ApproxTextTokens("abcdefgh"); got != 2 {
|
||||
t.Fatalf("ApproxTextTokens(no spaces) = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
+61
-32
@@ -35,12 +35,18 @@ const (
|
||||
PermissionActionAllow PermissionAction = "allow"
|
||||
PermissionActionPrompt PermissionAction = "prompt"
|
||||
PermissionActionDeny PermissionAction = "deny"
|
||||
PermissionActionCancel PermissionAction = "cancel"
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionDecisionAllow PermissionDecisionAction = "allow"
|
||||
PermissionDecisionDeny PermissionDecisionAction = "deny"
|
||||
PermissionDecisionAlwaysAllow PermissionDecisionAction = "always_allow"
|
||||
PermissionDecisionAllow PermissionDecisionAction = "allow"
|
||||
PermissionDecisionAllowStrict PermissionDecisionAction = "allow_with_strict_auto_review"
|
||||
PermissionDecisionAllowForSession PermissionDecisionAction = "allow_for_session"
|
||||
PermissionDecisionAllowPrefix PermissionDecisionAction = "allow_prefix_for_session"
|
||||
PermissionDecisionAlwaysAllowPrefix PermissionDecisionAction = "always_allow_prefix"
|
||||
PermissionDecisionDeny PermissionDecisionAction = "deny"
|
||||
PermissionDecisionAlwaysAllow PermissionDecisionAction = "always_allow"
|
||||
PermissionDecisionCancel PermissionDecisionAction = "cancel"
|
||||
)
|
||||
|
||||
type ToolResult struct {
|
||||
@@ -73,25 +79,28 @@ const (
|
||||
DenialNone DenialCategory = ""
|
||||
DenialFiltered DenialCategory = "filtered" // tool not enabled for this run
|
||||
DenialPermissionDenied DenialCategory = "permission_denied" // approval declined
|
||||
DenialApprovalCanceled DenialCategory = "approval_canceled" // approval canceled and run aborted
|
||||
DenialSandboxViolation DenialCategory = "sandbox_violation" // blocked by the sandbox
|
||||
DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook
|
||||
)
|
||||
|
||||
type PermissionRequest struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
Action PermissionAction `json:"action"`
|
||||
Permission string `json:"permission"`
|
||||
PermissionMode PermissionMode `json:"permissionMode"`
|
||||
Autonomy string `json:"autonomy,omitempty"`
|
||||
SideEffect string `json:"sideEffect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
Violation *sandbox.Violation `json:"violation,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
Action PermissionAction `json:"action"`
|
||||
Permission string `json:"permission"`
|
||||
PermissionMode PermissionMode `json:"permissionMode"`
|
||||
Autonomy string `json:"autonomy,omitempty"`
|
||||
SideEffect string `json:"sideEffect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
Violation *sandbox.Violation `json:"violation,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
|
||||
}
|
||||
|
||||
type PermissionDecision struct {
|
||||
@@ -100,21 +109,23 @@ type PermissionDecision struct {
|
||||
}
|
||||
|
||||
type PermissionEvent struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
Action PermissionAction `json:"action"`
|
||||
Permission string `json:"permission"`
|
||||
PermissionGranted bool `json:"permissionGranted,omitempty"`
|
||||
PermissionMode PermissionMode `json:"permissionMode"`
|
||||
Autonomy string `json:"autonomy,omitempty"`
|
||||
SideEffect string `json:"sideEffect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
DecisionReason string `json:"decisionReason,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Violation *sandbox.Violation `json:"violation,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
Action PermissionAction `json:"action"`
|
||||
DecisionAction PermissionDecisionAction `json:"decisionAction,omitempty"`
|
||||
Permission string `json:"permission"`
|
||||
PermissionGranted bool `json:"permissionGranted,omitempty"`
|
||||
PermissionMode PermissionMode `json:"permissionMode"`
|
||||
Autonomy string `json:"autonomy,omitempty"`
|
||||
SideEffect string `json:"sideEffect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
DecisionReason string `json:"decisionReason,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Violation *sandbox.Violation `json:"violation,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||
}
|
||||
|
||||
// AskUserQuestion is one clarifying question the agent wants answered.
|
||||
@@ -136,6 +147,15 @@ type AskUserResponse struct {
|
||||
Answers []string `json:"answers"`
|
||||
}
|
||||
|
||||
// SpecialistInfo is a one-line summary of a delegatable sub-agent (its name and
|
||||
// when-to-use description) surfaced to the orchestrator's system prompt so it can
|
||||
// route work to the right specialist. It is plain data so the agent package needs
|
||||
// no dependency on internal/specialist.
|
||||
type SpecialistInfo struct {
|
||||
Name string
|
||||
WhenToUse string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
MaxTurns int
|
||||
// DeferThreshold activates deferred MCP-tool loading: when the number of
|
||||
@@ -144,6 +164,13 @@ type Options struct {
|
||||
// 0 (or below the eligible count) keeps every tool eager — byte-identical to
|
||||
// the pre-deferral behavior.
|
||||
DeferThreshold int
|
||||
// Specialists lists the sub-agents the orchestrator may delegate to via the
|
||||
// Task tool; when non-empty the system prompt gains a delegation section that
|
||||
// names them and nudges the model to offload read-heavy work (search,
|
||||
// exploration) so verbose tool output stays out of the main context. It is
|
||||
// populated only where the Task tool is actually registered, so an empty slice
|
||||
// (the default) reproduces the previous prompt byte-for-byte.
|
||||
Specialists []SpecialistInfo
|
||||
// Specialist/sub-agent metadata is carried through exec now and consumed by
|
||||
// the specialist runtime in later slices.
|
||||
SessionID string
|
||||
@@ -208,6 +235,8 @@ type Options struct {
|
||||
// ceiling and the autonomy gate. nil disables it entirely (the loop is
|
||||
// byte-identical to before). One instance per run — it holds attempt state.
|
||||
SelfCorrect *SelfCorrector
|
||||
|
||||
runPermissions *permissionRunState
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
|
||||
+127
-36
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,7 +19,9 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/selfverify"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
@@ -37,39 +40,42 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
type appDeps struct {
|
||||
getwd func() (string, error)
|
||||
stdin io.Reader
|
||||
userConfigPath func() (string, error)
|
||||
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
now func() time.Time
|
||||
getwd func() (string, error)
|
||||
stdin io.Reader
|
||||
userConfigPath func() (string, error)
|
||||
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type mcpToolRuntime interface {
|
||||
Close() error
|
||||
Skipped() []mcp.SkippedServer
|
||||
}
|
||||
|
||||
type noopMCPRuntime struct{}
|
||||
@@ -78,6 +84,10 @@ func (noopMCPRuntime) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (noopMCPRuntime) Skipped() []mcp.SkippedServer {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run executes the minimal Go CLI surface. It returns an exit code so tests can
|
||||
// exercise command behavior without terminating the test process.
|
||||
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
@@ -111,6 +121,7 @@ func defaultAppDeps() appDeps {
|
||||
})
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return sessions.NewStore(sessions.StoreOptions{})
|
||||
},
|
||||
@@ -133,6 +144,12 @@ func defaultAppDeps() appDeps {
|
||||
return sandbox.NewGrantStore(sandbox.StoreOptions{})
|
||||
},
|
||||
selectSandboxBackend: sandbox.SelectBackend,
|
||||
runSandboxSetupHelper: func(path string, args []string, stdout io.Writer, stderr io.Writer) error {
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
return cmd.Run()
|
||||
},
|
||||
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return mcp.RegisterTools(ctx, registry, cfg, options)
|
||||
},
|
||||
@@ -337,6 +354,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.probeProviderHealth == nil {
|
||||
deps.probeProviderHealth = defaults.probeProviderHealth
|
||||
}
|
||||
if deps.detectLocalRuntimes == nil {
|
||||
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
|
||||
}
|
||||
if deps.newSessionStore == nil {
|
||||
deps.newSessionStore = defaults.newSessionStore
|
||||
}
|
||||
@@ -367,6 +387,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.selectSandboxBackend == nil {
|
||||
deps.selectSandboxBackend = defaults.selectSandboxBackend
|
||||
}
|
||||
if deps.runSandboxSetupHelper == nil {
|
||||
deps.runSandboxSetupHelper = defaults.runSandboxSetupHelper
|
||||
}
|
||||
if deps.registerMCPTools == nil {
|
||||
deps.registerMCPTools = defaults.registerMCPTools
|
||||
}
|
||||
@@ -464,7 +487,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
}
|
||||
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot)
|
||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
||||
}
|
||||
@@ -495,6 +518,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
// A server that could not be reached or validated is skipped, not fatal (one
|
||||
// bad MCP server must not abort startup) — surface each so a missing tool set is
|
||||
// explained rather than silently absent.
|
||||
for _, skipped := range mcpRuntime.Skipped() {
|
||||
fmt.Fprintf(stderr, "warning: MCP server %s unavailable, skipped: %s\n", skipped.Name, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
|
||||
}
|
||||
// Make local plugins live: register their declared tools into the registry and
|
||||
// collect their hooks + skill roots for the dispatcher and skill tool below.
|
||||
// Done after specialist + MCP registration so plugin tools are part of the
|
||||
@@ -525,11 +554,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
||||
}
|
||||
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: deps.selectSandboxBackend(sandbox.BackendOptions{}),
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
})
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
@@ -570,6 +600,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
ExitCode: exitCode,
|
||||
}
|
||||
},
|
||||
SandboxSetupCommand: tuiSandboxSetupCommand(sandboxBackend, deps),
|
||||
AgentOptions: agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
Registry: registry,
|
||||
@@ -579,6 +610,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
Specialists: specialistRuntime.specialists,
|
||||
},
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
@@ -594,6 +626,21 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
})
|
||||
}
|
||||
|
||||
func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.Context) tui.SandboxSetupCommandResult {
|
||||
if backend.Platform != "windows" || backend.Name != sandbox.BackendWindowsRestrictedToken || !backend.Available || backend.Executable == "" {
|
||||
return nil
|
||||
}
|
||||
return func(context.Context) tui.SandboxSetupCommandResult {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runSandboxSetup(nil, &stdout, &stderr, deps)
|
||||
return tui.SandboxSetupCommandResult{
|
||||
Output: strings.TrimSpace(stdout.String()),
|
||||
Error: strings.TrimSpace(stderr.String()),
|
||||
ExitCode: exitCode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return nil, nil
|
||||
@@ -619,9 +666,22 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
|
||||
type agentToolRuntime struct {
|
||||
specialist *specialist.Runtime
|
||||
swarm *swarm.Swarm
|
||||
// specialists summarizes the registered specialists (name + description) for
|
||||
// the orchestrator's system-prompt delegation section. Populated alongside the
|
||||
// tools, so it is present exactly when the Task tool is.
|
||||
specialists []agent.SpecialistInfo
|
||||
}
|
||||
|
||||
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*agentToolRuntime, error) {
|
||||
// specialistInfos returns the runtime's specialist summaries, nil-safe so the
|
||||
// exec path can call it whether or not specialist tools were registered.
|
||||
func (r *agentToolRuntime) specialistInfos() []agent.SpecialistInfo {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.specialists
|
||||
}
|
||||
|
||||
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) {
|
||||
paths, err := specialist.DefaultPaths(workspaceRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -634,16 +694,41 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*a
|
||||
// The swarm reuses the same specialist executor to launch each member, so
|
||||
// every member runs under the orchestrator's sandbox + policy. Mailbox state
|
||||
// lives under the workspace so its files fall within the sandbox write rules.
|
||||
// MaxTeamSize (0 => the swarm's default of 8) caps concurrent members per team.
|
||||
sw, err := swarm.New(swarm.Options{
|
||||
BaseDir: filepath.Join(workspaceRoot, ".zero", "swarm"),
|
||||
Launcher: swarm.NewSpecialistLauncher(executor),
|
||||
BaseDir: filepath.Join(workspaceRoot, ".zero", "swarm"),
|
||||
Launcher: swarm.NewSpecialistLauncher(executor),
|
||||
MaxTeamSize: maxTeamSize,
|
||||
})
|
||||
if err != nil {
|
||||
runtime.Close()
|
||||
return nil, err
|
||||
}
|
||||
swarm.RegisterTools(registry, sw)
|
||||
return &agentToolRuntime{specialist: runtime, swarm: sw}, nil
|
||||
return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil
|
||||
}
|
||||
|
||||
// specialistSummaries loads the available specialists (built-ins + user/project
|
||||
// profiles) and returns their name + description for the orchestrator's
|
||||
// delegation prompt. A load error yields no summaries (the prompt simply omits
|
||||
// the delegation section) rather than failing the run.
|
||||
func specialistSummaries(paths specialist.Paths) []agent.SpecialistInfo {
|
||||
result, err := specialist.Load(specialist.LoadOptions{Paths: paths})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
summaries := make([]agent.SpecialistInfo, 0, len(result.Specialists))
|
||||
for _, manifest := range result.Specialists {
|
||||
name := strings.TrimSpace(manifest.Metadata.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, agent.SpecialistInfo{
|
||||
Name: name,
|
||||
WhenToUse: strings.TrimSpace(manifest.Metadata.Description),
|
||||
})
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
|
||||
func shouldRegisterExecSpecialistTools(options execOptions) bool {
|
||||
@@ -653,7 +738,13 @@ func shouldRegisterExecSpecialistTools(options execOptions) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(options.tag), specialist.SessionTagSpecialist) {
|
||||
return false
|
||||
}
|
||||
return options.skipPermissionsUnsafe || strings.EqualFold(strings.TrimSpace(options.autonomy), "high")
|
||||
// Register at medium/high (and unsafe). The Task tool's permission gate
|
||||
// auto-approves only read-only specialist spawns and prompts for write-capable
|
||||
// ones, so a non-trivial exec can auto-delegate exploration safely (a headless
|
||||
// write spawn still gets denied at the prompt). Default "low" stays clean — no
|
||||
// specialist tooling or swarm runtime for trivial/CI one-shots.
|
||||
autonomy := strings.ToLower(strings.TrimSpace(options.autonomy))
|
||||
return options.skipPermissionsUnsafe || autonomy == "high" || autonomy == "medium"
|
||||
}
|
||||
|
||||
func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
|
||||
|
||||
@@ -112,6 +112,9 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchedOptions = options
|
||||
return 7
|
||||
@@ -446,6 +449,9 @@ func TestRunNoArgsLaunchesTUIWithResolvedProviderMetadata(t *testing.T) {
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchedOptions = options
|
||||
return 0
|
||||
@@ -845,6 +851,9 @@ func TestRunSetupNoArgsForcesSetupTUI(t *testing.T) {
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &cliFakeProvider{}, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchedOptions = options
|
||||
return 0
|
||||
|
||||
@@ -78,6 +78,9 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if command == "setup" {
|
||||
return runProvidersSetup(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "detect" {
|
||||
return runProvidersDetect(args, stdout, stderr, deps)
|
||||
}
|
||||
if command != "list" && command != "current" && command != "catalog" {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
|
||||
}
|
||||
@@ -451,8 +454,10 @@ func writeProvidersHelp(w io.Writer) error {
|
||||
zero providers check [name] [flags]
|
||||
zero providers use <name> [flags]
|
||||
zero providers setup <catalog-id> [flags]
|
||||
zero providers detect [flags]
|
||||
|
||||
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
|
||||
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
|
||||
|
||||
Flags:
|
||||
--json Print JSON summary
|
||||
|
||||
+24
-5
@@ -164,8 +164,16 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
var specialistRuntime *agentToolRuntime
|
||||
if shouldRegisterExecSpecialistTools(options) {
|
||||
// Specialist tools register before the full config resolve below (so
|
||||
// --list-tools stays offline). swarm.maxTeamSize is not affected by
|
||||
// overrides, so an empty-overrides resolve yields the same value; a resolve
|
||||
// error falls back to the swarm's built-in default (0 => 8).
|
||||
maxTeamSize := 0
|
||||
if swarmCfg, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil {
|
||||
maxTeamSize = swarmCfg.Swarm.MaxTeamSize
|
||||
}
|
||||
var err error
|
||||
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot)
|
||||
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot, maxTeamSize)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "specialist_error", err.Error())
|
||||
}
|
||||
@@ -478,6 +486,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
DeferThreshold: effectiveDeferThreshold,
|
||||
Specialists: specialistRuntime.specialistInfos(),
|
||||
SessionID: preparedSession.Session.SessionID,
|
||||
CallingSessionID: options.callingSessionID,
|
||||
CallingToolUseID: options.callingToolUseID,
|
||||
@@ -715,12 +724,22 @@ func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfi
|
||||
policy = applyConfiguredAutonomyCeiling(policy, cfg.MaxAutonomy)
|
||||
if network := strings.TrimSpace(cfg.Network); network != "" {
|
||||
switch sandbox.NetworkMode(network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny, sandbox.NetworkScoped:
|
||||
policy.Network = sandbox.NetworkMode(network)
|
||||
}
|
||||
}
|
||||
// Opt-in hardening flags: only ever turn a feature ON from config, never off,
|
||||
// so a programmatic default can't be silently disabled by an omitted key.
|
||||
// Scoped egress allow/deny lists from (global) config; the engine consults
|
||||
// these only under NetworkScoped and falls closed to deny on an empty
|
||||
// allowlist or a backend that can't enforce scoping.
|
||||
if len(cfg.NetworkAllowedDomains) > 0 {
|
||||
policy.AllowedDomains = append([]string(nil), cfg.NetworkAllowedDomains...)
|
||||
}
|
||||
if len(cfg.NetworkDeniedDomains) > 0 {
|
||||
policy.DeniedDomains = append([]string(nil), cfg.NetworkDeniedDomains...)
|
||||
}
|
||||
// Opt-in hardening/diagnostic flags: only ever turn a feature ON from
|
||||
// config, never off, so a programmatic default can't be silently disabled by
|
||||
// an omitted key.
|
||||
if cfg.BlockUnixSockets {
|
||||
policy.BlockUnixSockets = true
|
||||
}
|
||||
@@ -1072,7 +1091,7 @@ func sessionPermissionEventType(event agent.PermissionEvent) sessions.EventType
|
||||
if event.Action == agent.PermissionActionPrompt {
|
||||
return sessions.EventPermissionRequest
|
||||
}
|
||||
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny {
|
||||
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny || event.Action == agent.PermissionActionCancel {
|
||||
return sessions.EventPermissionDecision
|
||||
}
|
||||
return sessions.EventPermission
|
||||
|
||||
@@ -357,7 +357,7 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
toolCallID: "call_write",
|
||||
toolName: "write_file",
|
||||
arguments: `{"path":"notes.txt","content":"hello"}`,
|
||||
answer: "write denied",
|
||||
answer: "write allowed",
|
||||
}, nil
|
||||
},
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
||||
@@ -374,16 +374,22 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
eventTypes := jsonEventTypes(events)
|
||||
if !slices.Contains(eventTypes, "permission_request") {
|
||||
t.Fatalf("expected permission_request event in %v; output %q", eventTypes, stdout.String())
|
||||
if slices.Contains(eventTypes, "permission_request") {
|
||||
t.Fatalf("workspace write should not request permission in %v; output %q", eventTypes, stdout.String())
|
||||
}
|
||||
permissionEvent := findJSONEvent(t, events, "permission_request")
|
||||
if permissionEvent["id"] != "call_write" || permissionEvent["name"] != "write_file" || permissionEvent["action"] != "prompt" {
|
||||
permissionEvent := findJSONEvent(t, events, "permission_decision")
|
||||
if permissionEvent["id"] != "call_write" || permissionEvent["name"] != "write_file" || permissionEvent["action"] != "allow" {
|
||||
t.Fatalf("unexpected permission event: %#v", permissionEvent)
|
||||
}
|
||||
if permissionEvent["permission"] != "prompt" || permissionEvent["permissionMode"] != "auto" || permissionEvent["sideEffect"] != "write" {
|
||||
t.Fatalf("unexpected permission metadata: %#v", permissionEvent)
|
||||
}
|
||||
if permissionEvent["permissionGranted"] == true {
|
||||
t.Fatalf("workspace policy allow should not be recorded as user-granted permission: %#v", permissionEvent)
|
||||
}
|
||||
if permissionEvent["reason"] != "workspace write permitted by sandbox policy" {
|
||||
t.Fatalf("unexpected workspace permission reason: %#v", permissionEvent)
|
||||
}
|
||||
risk, ok := permissionEvent["risk"].(map[string]any)
|
||||
if !ok || risk["level"] == "" {
|
||||
t.Fatalf("expected permission risk payload, got %#v", permissionEvent)
|
||||
@@ -400,12 +406,12 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
permissionRecord := findSessionEvent(t, recorded, sessions.EventPermissionRequest)
|
||||
permissionRecord := findSessionEvent(t, recorded, sessions.EventPermissionDecision)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(permissionRecord.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode permission payload: %v", err)
|
||||
}
|
||||
if payload["toolCallId"] != "call_write" || payload["name"] != "write_file" || payload["action"] != "prompt" {
|
||||
if payload["toolCallId"] != "call_write" || payload["name"] != "write_file" || payload["action"] != "allow" {
|
||||
t.Fatalf("unexpected recorded permission payload: %#v", payload)
|
||||
}
|
||||
|
||||
@@ -715,6 +721,10 @@ func (fn closeFunc) Close() error {
|
||||
return fn()
|
||||
}
|
||||
|
||||
func (fn closeFunc) Skipped() []mcp.SkippedServer {
|
||||
return nil
|
||||
}
|
||||
|
||||
type toolCallingExecProvider struct {
|
||||
toolCallID string
|
||||
toolName string
|
||||
|
||||
@@ -242,7 +242,7 @@ func streamJSONPermissionEventType(event agent.PermissionEvent) streamjson.Event
|
||||
if event.Action == agent.PermissionActionPrompt {
|
||||
return streamjson.EventPermissionRequest
|
||||
}
|
||||
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny {
|
||||
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny || event.Action == agent.PermissionActionCancel {
|
||||
return streamjson.EventPermissionDecision
|
||||
}
|
||||
return streamjson.EventPermission
|
||||
|
||||
@@ -697,11 +697,20 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if !legacyFound {
|
||||
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
|
||||
}
|
||||
raw = legacyRaw
|
||||
found = true
|
||||
}
|
||||
var server map[string]json.RawMessage
|
||||
if len(raw) > 0 && string(raw) != "null" {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// `zero mcp disable firecrawl` must work even though firecrawl is a built-in
|
||||
// default that is not written to the user's config file until overridden.
|
||||
func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
// A config with no firecrawl entry — the default lives in code, not the file.
|
||||
writeMCPCommandRawConfig(t, configPath, `{"activeProvider":"fast"}`)
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runWithDeps([]string{"mcp", "disable", "firecrawl", "--json"}, &out, &errBuf, appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
})
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
|
||||
}
|
||||
var payload struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Changed bool `json:"changed"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode disable JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if payload.ServerName != "firecrawl" || !payload.Disabled || !payload.Changed {
|
||||
t.Fatalf("disable payload = %#v, want firecrawl disabled+changed", payload)
|
||||
}
|
||||
|
||||
// End-to-end: resolving that config now turns the default off.
|
||||
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if !cfg.Servers["firecrawl"].Disabled {
|
||||
t.Fatal("expected `mcp disable firecrawl` to turn the seeded default off in the resolved config")
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,10 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
}
|
||||
|
||||
var provider config.ProviderProfile
|
||||
var sandboxConfig config.SandboxConfig
|
||||
if resolved, resolveErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); resolveErr == nil {
|
||||
provider = resolved.Provider
|
||||
sandboxConfig = resolved.Sandbox
|
||||
}
|
||||
var health *providerhealth.Result
|
||||
if options.connectivity && config.HasProviderProfile(provider) {
|
||||
@@ -67,6 +69,8 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
UserConfig: userConfig,
|
||||
ProjectConfig: projectConfig,
|
||||
Provider: provider,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Sandbox: sandboxConfig,
|
||||
Connectivity: options.connectivity,
|
||||
ProviderHealth: health,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
)
|
||||
|
||||
type providerDetectOptions struct {
|
||||
json bool
|
||||
}
|
||||
|
||||
// providerDetectAction is the JSON/text shape of a single suggested next step.
|
||||
type providerDetectAction struct {
|
||||
Label string `json:"label"`
|
||||
Command string `json:"command"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type providerDetectRuntime struct {
|
||||
CatalogID string `json:"catalogID"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
Action providerDetectAction `json:"action"`
|
||||
}
|
||||
|
||||
type providerDetectProvider struct {
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
Actions []providerDetectAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type providerDetectReport struct {
|
||||
DetectedRuntimes []providerDetectRuntime `json:"detectedRuntimes"`
|
||||
Providers []providerDetectProvider `json:"providers"`
|
||||
}
|
||||
|
||||
// runProvidersDetect probes the machine for running local, OpenAI-compatible
|
||||
// model runtimes (Ollama, LM Studio) and prints a no-key adopt command for each
|
||||
// one it finds, followed by the next-step actions for every already-configured
|
||||
// provider. It is the onboarding-advice surface — "what can I do right now?" —
|
||||
// and never errors on a machine with nothing running locally (it just reports an
|
||||
// empty detected list).
|
||||
func runProvidersDetect(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseProviderDetectArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeProvidersHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode
|
||||
}
|
||||
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
detected := deps.detectLocalRuntimes(ctx, provideronboarding.LocalDetectOptions{})
|
||||
|
||||
report := buildProviderDetectReport(resolved, detected)
|
||||
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, report); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, formatProviderDetectReport(report)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parseProviderDetectArgs(args []string) (providerDetectOptions, bool, error) {
|
||||
options := providerDetectOptions{}
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func buildProviderDetectReport(resolved config.ResolvedConfig, detected []provideronboarding.DetectedLocalRuntime) providerDetectReport {
|
||||
report := providerDetectReport{
|
||||
DetectedRuntimes: make([]providerDetectRuntime, 0, len(detected)),
|
||||
Providers: make([]providerDetectProvider, 0, len(resolved.Providers)),
|
||||
}
|
||||
for _, runtime := range detected {
|
||||
report.DetectedRuntimes = append(report.DetectedRuntimes, providerDetectRuntime{
|
||||
CatalogID: runtime.CatalogID,
|
||||
Name: runtime.Name,
|
||||
BaseURL: runtime.BaseURL,
|
||||
Models: runtime.Models,
|
||||
Action: providerDetectActionFrom(runtime.SetupAction()),
|
||||
})
|
||||
}
|
||||
for _, profile := range resolved.Providers {
|
||||
active := strings.TrimSpace(profile.Name) != "" && profile.Name == resolved.ActiveProvider
|
||||
state := provideronboarding.ProviderState{Profile: profile, Active: active}
|
||||
actions := state.Actions()
|
||||
entry := providerDetectProvider{
|
||||
Name: profile.Name,
|
||||
Active: active,
|
||||
Actions: make([]providerDetectAction, 0, len(actions)),
|
||||
}
|
||||
for _, action := range actions {
|
||||
entry.Actions = append(entry.Actions, providerDetectActionFrom(action))
|
||||
}
|
||||
report.Providers = append(report.Providers, entry)
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func providerDetectActionFrom(action provideronboarding.Action) providerDetectAction {
|
||||
return providerDetectAction{Label: action.Label, Command: action.Command, Detail: action.Detail}
|
||||
}
|
||||
|
||||
func formatProviderDetectReport(report providerDetectReport) string {
|
||||
lines := []string{"Detected local runtimes:"}
|
||||
if len(report.DetectedRuntimes) == 0 {
|
||||
lines = append(lines, " (none running on default ports)")
|
||||
}
|
||||
for _, runtime := range report.DetectedRuntimes {
|
||||
name := strings.TrimSpace(runtime.Name)
|
||||
if name == "" {
|
||||
name = runtime.CatalogID
|
||||
}
|
||||
lines = append(lines, " "+name+" — "+runtime.BaseURL)
|
||||
if len(runtime.Models) > 0 {
|
||||
lines = append(lines, " models: "+strings.Join(runtime.Models, ", "))
|
||||
}
|
||||
if command := strings.TrimSpace(runtime.Action.Command); command != "" {
|
||||
lines = append(lines, " "+runtime.Action.Label+": "+command)
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, "", "Configured providers:")
|
||||
if len(report.Providers) == 0 {
|
||||
lines = append(lines, " (none configured — run `zero setup`)")
|
||||
}
|
||||
for _, provider := range report.Providers {
|
||||
name := strings.TrimSpace(provider.Name)
|
||||
if name == "" {
|
||||
name = "(unnamed)"
|
||||
}
|
||||
marker := ""
|
||||
if provider.Active {
|
||||
marker = " (active)"
|
||||
}
|
||||
lines = append(lines, " "+name+marker)
|
||||
if len(provider.Actions) == 0 {
|
||||
lines = append(lines, " (ready)")
|
||||
}
|
||||
for _, action := range provider.Actions {
|
||||
line := " " + action.Label
|
||||
if command := strings.TrimSpace(action.Command); command != "" {
|
||||
line += ": " + command
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
)
|
||||
|
||||
func TestRunProvidersDetectSurfacesLocalRuntimeAndProviderActions(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
deps.detectLocalRuntimes = func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime {
|
||||
return []provideronboarding.DetectedLocalRuntime{{
|
||||
LocalRuntime: provideronboarding.LocalRuntime{CatalogID: "ollama", Name: "Ollama Local", BaseURL: "http://localhost:11434/v1"},
|
||||
Reachable: true,
|
||||
Models: []string{"llama3.1"},
|
||||
}}
|
||||
}
|
||||
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
// Configured-but-inactive provider with no credential: should yield the full
|
||||
// "Use provider" / "Check provider" / "Set API key" action set.
|
||||
profile := config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
Model: "gpt-4o",
|
||||
APIKeyEnv: "OPENAI_API_KEY",
|
||||
}
|
||||
return config.ResolvedConfig{ActiveProvider: "other", Providers: []config.ProviderProfile{profile}}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "detect"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Detected local runtimes:",
|
||||
"Ollama Local — http://localhost:11434/v1",
|
||||
"models: llama3.1",
|
||||
"zero providers add ollama", // SetupAction's no-key adopt command
|
||||
"Configured providers:",
|
||||
"openai",
|
||||
"Use provider",
|
||||
"Check provider",
|
||||
"Set API key",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("detect output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersDetectJSONNoRuntimesActiveProvider(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
deps.detectLocalRuntimes = func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime {
|
||||
return nil // nothing running locally
|
||||
}
|
||||
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
// Active provider with an inline key: only "Check provider" remains.
|
||||
profile := config.ProviderProfile{
|
||||
Name: "local",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
APIKey: "sk-secret",
|
||||
Model: "custom",
|
||||
}
|
||||
return config.ResolvedConfig{ActiveProvider: "local", Provider: profile, Providers: []config.ProviderProfile{profile}}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "detect", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "sk-secret") {
|
||||
t.Fatalf("detect JSON leaked the API key: %s", stdout.String())
|
||||
}
|
||||
var payload struct {
|
||||
DetectedRuntimes []struct {
|
||||
CatalogID string `json:"catalogID"`
|
||||
} `json:"detectedRuntimes"`
|
||||
Providers []struct {
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
Actions []struct {
|
||||
Label string `json:"label"`
|
||||
} `json:"actions"`
|
||||
} `json:"providers"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("detect JSON did not decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(payload.DetectedRuntimes) != 0 {
|
||||
t.Fatalf("expected no detected runtimes, got %#v", payload.DetectedRuntimes)
|
||||
}
|
||||
if len(payload.Providers) != 1 || payload.Providers[0].Name != "local" || !payload.Providers[0].Active {
|
||||
t.Fatalf("expected one active provider 'local', got %#v", payload.Providers)
|
||||
}
|
||||
if len(payload.Providers[0].Actions) != 1 || payload.Providers[0].Actions[0].Label != "Check provider" {
|
||||
t.Fatalf("expected only a Check action for an active keyed provider, got %#v", payload.Providers[0].Actions)
|
||||
}
|
||||
}
|
||||
+137
-3
@@ -31,6 +31,10 @@ func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return exitSuccess
|
||||
case "policy":
|
||||
return runSandboxPolicy(args[1:], stdout, stderr, deps)
|
||||
case "setup":
|
||||
return runSandboxSetup(args[1:], stdout, stderr, deps)
|
||||
case "check":
|
||||
return runSandboxCheck(args[1:], stdout, stderr, deps)
|
||||
case "grants":
|
||||
return runSandboxGrants(args[1:], stdout, stderr, deps)
|
||||
default:
|
||||
@@ -112,6 +116,102 @@ func runSandboxPolicy(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseSandboxCommandOptions(args, sandboxCommandFlags{})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeSandboxSetupHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
|
||||
backend := deps.selectSandboxBackend(zeroSandbox.BackendOptions{})
|
||||
if backend.Platform != "windows" {
|
||||
message := "No native sandbox setup is required for " + displayPlatform(backend.Platform)
|
||||
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
|
||||
Platform: displayPlatform(backend.Platform),
|
||||
Backend: backend.Name,
|
||||
Ran: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
if backend.Name != zeroSandbox.BackendWindowsRestrictedToken || !backend.Available || backend.Executable == "" {
|
||||
message := "Windows sandbox setup helper is not available"
|
||||
if strings.TrimSpace(backend.Message) != "" {
|
||||
message += ": " + backend.Message
|
||||
}
|
||||
return writeAppError(stderr, message, exitProvider)
|
||||
}
|
||||
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
profile := zeroSandbox.PermissionProfileFromPolicy(workspaceRoot, policy, scope)
|
||||
setupPath := zeroSandbox.WindowsSandboxSetupPathForRunner(backend.Executable)
|
||||
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if err := deps.runSandboxSetupHelper(setupPath, setupArgs, stdout, stderr); err != nil {
|
||||
return writeAppError(stderr, "Windows sandbox setup failed: "+err.Error(), exitProvider)
|
||||
}
|
||||
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
|
||||
Platform: "windows",
|
||||
Backend: backend.Name,
|
||||
Helper: setupPath,
|
||||
Ran: true,
|
||||
Message: "Windows sandbox setup complete",
|
||||
})
|
||||
}
|
||||
|
||||
type sandboxSetupResult struct {
|
||||
Platform string `json:"platform"`
|
||||
Backend zeroSandbox.BackendName `json:"backend"`
|
||||
Helper string `json:"helper,omitempty"`
|
||||
Ran bool `json:"ran"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeSandboxSetupResult(stdout io.Writer, asJSON bool, result sandboxSetupResult) int {
|
||||
if asJSON {
|
||||
if err := writePrettyJSON(stdout, result); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, result.Message); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if result.Helper != "" {
|
||||
if _, err := fmt.Fprintln(stdout, "helper: "+result.Helper); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func displayPlatform(platform string) string {
|
||||
if strings.TrimSpace(platform) == "" {
|
||||
return "this platform"
|
||||
}
|
||||
return platform
|
||||
}
|
||||
|
||||
// sandboxGuards is the resolved set of pre-execution safety guards the engine
|
||||
// applies for the effective policy. There is no layered/merged config today, so
|
||||
// the "effective" view is the fully-resolved DefaultPolicy plus the platform
|
||||
@@ -192,13 +292,20 @@ func formatEffectiveSandboxPolicy(workspaceRoot string, policy zeroSandbox.Polic
|
||||
"allow_policy_only_runner: "+fmt.Sprintf("%t", policy.AllowPolicyOnlyRunner),
|
||||
"max_autonomy: "+string(policy.MaxAutonomy),
|
||||
"backend: "+string(backend.Name),
|
||||
"target_backend: "+string(plan.TargetBackend),
|
||||
"support_level: "+string(plan.SupportLevel),
|
||||
"command_wrapped: "+fmt.Sprintf("%t", plan.CommandWrapped),
|
||||
"enforcement_level: "+string(plan.EnforcementLevel),
|
||||
"requires_platform_sandbox: "+fmt.Sprintf("%t", plan.RequiresPlatformSandbox),
|
||||
"interactive_command_guard: "+enabledLabel(guards.InteractiveCommand),
|
||||
"destructive_shell_guard: "+enabledLabel(guards.DestructiveShell),
|
||||
"network_guard: "+enabledLabel(guards.Network),
|
||||
"workspace_guard: "+enabledLabel(guards.Workspace),
|
||||
"grants: "+grantsPath,
|
||||
)
|
||||
if plan.DowngradeReason != "" {
|
||||
lines = append(lines, "downgrade_reason: "+plan.DowngradeReason)
|
||||
}
|
||||
if backend.Platform != "" {
|
||||
lines = append(lines, "backend_platform: "+backend.Platform)
|
||||
}
|
||||
@@ -247,15 +354,20 @@ func runSandboxGrants(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
prefixes, err := store.ListCommandPrefixes()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, struct {
|
||||
Grants []zeroSandbox.Grant `json:"grants"`
|
||||
}{Grants: grants}); err != nil {
|
||||
Grants []zeroSandbox.Grant `json:"grants"`
|
||||
CommandPrefixes []zeroSandbox.CommandPrefixGrant `json:"commandPrefixes,omitempty"`
|
||||
}{Grants: grants, CommandPrefixes: prefixes}); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, zeroSandbox.FormatGrantList(grants)); err != nil {
|
||||
if _, err := fmt.Fprintln(stdout, zeroSandbox.FormatGrantListWithCommandPrefixes(grants, prefixes)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
@@ -499,7 +611,14 @@ func formatSandboxPolicy(workspaceRoot string, policy zeroSandbox.Policy, backen
|
||||
"network: " + string(policy.Network),
|
||||
"max_autonomy: " + string(policy.MaxAutonomy),
|
||||
"backend: " + string(backend.Name),
|
||||
"target_backend: " + string(plan.TargetBackend),
|
||||
"support_level: " + string(plan.SupportLevel),
|
||||
"command_wrapped: " + fmt.Sprintf("%t", plan.CommandWrapped),
|
||||
"enforcement_level: " + string(plan.EnforcementLevel),
|
||||
"requires_platform_sandbox: " + fmt.Sprintf("%t", plan.RequiresPlatformSandbox),
|
||||
}
|
||||
if plan.DowngradeReason != "" {
|
||||
lines = append(lines, "downgrade_reason: "+plan.DowngradeReason)
|
||||
}
|
||||
if backend.Platform != "" {
|
||||
lines = append(lines, "backend_platform: "+backend.Platform)
|
||||
@@ -526,6 +645,8 @@ func writeSandboxHelp(w io.Writer) error {
|
||||
|
||||
Commands:
|
||||
policy Inspect active sandbox policy and platform backend
|
||||
setup Run native platform sandbox setup
|
||||
check Evaluate the sandbox decision for a hypothetical tool action
|
||||
grants Manage persistent sandbox grants
|
||||
|
||||
`)
|
||||
@@ -544,6 +665,19 @@ Flags:
|
||||
return err
|
||||
}
|
||||
|
||||
func writeSandboxSetupHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero sandbox setup [flags]
|
||||
|
||||
Runs native platform sandbox setup when the selected backend requires it.
|
||||
|
||||
Flags:
|
||||
--json Print JSON output
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeSandboxGrantsHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero sandbox grants <command>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/zerocommands"
|
||||
)
|
||||
|
||||
type sandboxCheckOptions struct {
|
||||
tool string
|
||||
sideEffect string
|
||||
path string
|
||||
autonomy string
|
||||
reason string
|
||||
json bool
|
||||
}
|
||||
|
||||
// sandboxCheckReport is the combined snapshot `zero sandbox check` emits: the
|
||||
// active plan (policy + backend + restrictions), the decision the engine would
|
||||
// make for the described tool action, and any persistent grant that matches the
|
||||
// tool. It is the production consumer of the zerocommands sandbox-snapshot
|
||||
// contract, giving operators and CI a stable, redacted JSON view of "what would
|
||||
// the sandbox do for this action?".
|
||||
type sandboxCheckReport struct {
|
||||
Tool string `json:"tool"`
|
||||
Plan zerocommands.SandboxPlanSnapshot `json:"plan"`
|
||||
Decision zerocommands.SandboxDecisionSnapshot `json:"decision"`
|
||||
Grant zerocommands.SandboxGrantMatchSnapshot `json:"grant"`
|
||||
}
|
||||
|
||||
func runSandboxCheck(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseSandboxCheckArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeSandboxCheckHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if strings.TrimSpace(options.tool) == "" {
|
||||
return writeExecUsageError(stderr, "tool name is required: zero sandbox check <tool> [flags]")
|
||||
}
|
||||
|
||||
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
store, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
|
||||
backend := deps.selectSandboxBackend(zeroSandbox.BackendOptions{})
|
||||
plan := backend.BuildPlan(workspaceRoot, policy)
|
||||
|
||||
sideEffect, err := parseSandboxCheckSideEffect(options.sideEffect)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
autonomy, err := parseSandboxCheckAutonomy(options.autonomy)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
requestArgs := map[string]any{}
|
||||
scopeAbs := workspaceRoot
|
||||
if path := strings.TrimSpace(options.path); path != "" {
|
||||
requestArgs["path"] = path
|
||||
if filepath.IsAbs(path) {
|
||||
scopeAbs = filepath.Clean(path)
|
||||
} else {
|
||||
scopeAbs = filepath.Join(workspaceRoot, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the engine with the SAME write scope the real entrypoints use
|
||||
// (workspace root + configured additional write roots) so the check's
|
||||
// decision matches actual enforcement and a stale extra-root config surfaces
|
||||
// here too, instead of silently falling back to a workspace-only scope.
|
||||
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
Backend: backend,
|
||||
Scope: scope,
|
||||
})
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
decision := engine.Evaluate(ctx, zeroSandbox.Request{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
ToolName: options.tool,
|
||||
SideEffect: sideEffect,
|
||||
PermissionMode: zeroSandbox.PermissionModeAsk,
|
||||
Autonomy: autonomy,
|
||||
Args: requestArgs,
|
||||
Reason: strings.TrimSpace(options.reason),
|
||||
})
|
||||
|
||||
lookup, err := store.Lookup(options.tool, scopeAbs, autonomy)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
report := sandboxCheckReport{
|
||||
Tool: strings.TrimSpace(options.tool),
|
||||
Plan: zerocommands.SandboxPlanSnapshotFromPlan(plan),
|
||||
Decision: zerocommands.SandboxDecisionSnapshotFromDecision(decision),
|
||||
Grant: zerocommands.SandboxGrantMatchSnapshotFromLookup(options.tool, lookup),
|
||||
}
|
||||
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, report); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, formatSandboxCheckReport(report)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parseSandboxCheckArgs(args []string) (sandboxCheckOptions, bool, error) {
|
||||
options := sandboxCheckOptions{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--side-effect" || arg == "--path" || arg == "--autonomy" || arg == "--reason":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
assignSandboxCheckFlag(&options, arg, value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--side-effect="):
|
||||
value, err := requiredInlineFlagValue(arg, "--side-effect")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.sideEffect = value
|
||||
case strings.HasPrefix(arg, "--path="):
|
||||
value, err := requiredInlineFlagValue(arg, "--path")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.path = value
|
||||
case strings.HasPrefix(arg, "--autonomy="):
|
||||
value, err := requiredInlineFlagValue(arg, "--autonomy")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.autonomy = value
|
||||
case strings.HasPrefix(arg, "--reason="):
|
||||
value, err := requiredInlineFlagValue(arg, "--reason")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.reason = value
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||
default:
|
||||
if options.tool != "" {
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
options.tool = arg
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func assignSandboxCheckFlag(options *sandboxCheckOptions, flag string, value string) {
|
||||
switch flag {
|
||||
case "--side-effect":
|
||||
options.sideEffect = value
|
||||
case "--path":
|
||||
options.path = value
|
||||
case "--autonomy":
|
||||
options.autonomy = value
|
||||
case "--reason":
|
||||
options.reason = value
|
||||
}
|
||||
}
|
||||
|
||||
// parseSandboxCheckSideEffect validates --side-effect against the closed set the
|
||||
// help advertises (empty defaults to read), so a typo returns a usage error
|
||||
// instead of being passed to the engine as an unknown side effect.
|
||||
func parseSandboxCheckSideEffect(value string) (zeroSandbox.SideEffect, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "read":
|
||||
return zeroSandbox.SideEffectRead, nil
|
||||
case "write":
|
||||
return zeroSandbox.SideEffectWrite, nil
|
||||
case "shell":
|
||||
return zeroSandbox.SideEffectShell, nil
|
||||
case "network":
|
||||
return zeroSandbox.SideEffectNetwork, nil
|
||||
case "none":
|
||||
return zeroSandbox.SideEffectNone, nil
|
||||
default:
|
||||
return "", execUsageError{fmt.Sprintf("invalid --side-effect %q: expected read, write, shell, network, or none", value)}
|
||||
}
|
||||
}
|
||||
|
||||
// parseSandboxCheckAutonomy validates --autonomy against the closed set the help
|
||||
// advertises (empty defaults to high), so a typo returns a usage error instead
|
||||
// of the engine silently clamping an unknown tier.
|
||||
func parseSandboxCheckAutonomy(value string) (zeroSandbox.Autonomy, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "":
|
||||
return zeroSandbox.AutonomyHigh, nil
|
||||
case string(zeroSandbox.AutonomyLow):
|
||||
return zeroSandbox.AutonomyLow, nil
|
||||
case string(zeroSandbox.AutonomyMedium):
|
||||
return zeroSandbox.AutonomyMedium, nil
|
||||
case string(zeroSandbox.AutonomyHigh):
|
||||
return zeroSandbox.AutonomyHigh, nil
|
||||
default:
|
||||
return "", execUsageError{fmt.Sprintf("invalid --autonomy %q: expected low, medium, or high", value)}
|
||||
}
|
||||
}
|
||||
|
||||
func formatSandboxCheckReport(report sandboxCheckReport) string {
|
||||
lines := []string{
|
||||
"Sandbox check: " + report.Tool,
|
||||
"decision: " + report.Decision.Action,
|
||||
}
|
||||
if reason := strings.TrimSpace(report.Decision.Reason); reason != "" {
|
||||
lines = append(lines, "reason: "+reason)
|
||||
}
|
||||
risk := report.Decision.Risk
|
||||
riskLine := "risk: " + risk.Level
|
||||
if len(risk.Categories) > 0 {
|
||||
riskLine += " [" + strings.Join(risk.Categories, ", ") + "]"
|
||||
}
|
||||
lines = append(lines, riskLine)
|
||||
if violation := report.Decision.Violation; violation != nil {
|
||||
line := "violation: [" + violation.Code + "] " + violation.Reason
|
||||
if violation.Path != "" {
|
||||
line += " (path: " + violation.Path + ")"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if report.Grant.Matched && report.Grant.Grant != nil {
|
||||
lines = append(lines, "grant: matched "+report.Grant.Grant.Decision+" (maxAutonomy "+report.Grant.Grant.MaxAutonomy+")")
|
||||
} else {
|
||||
lines = append(lines, "grant: no grant matched this action")
|
||||
}
|
||||
lines = append(lines,
|
||||
"policy: mode="+report.Plan.Policy.EffectiveMode+" network="+report.Plan.Policy.Network+fmt.Sprintf(" enforceWorkspace=%t", report.Plan.Policy.EnforceWorkspace),
|
||||
"backend: "+report.Plan.Backend.Name+fmt.Sprintf(" (available=%t)", report.Plan.Backend.Available),
|
||||
)
|
||||
if len(report.Plan.Restrictions) > 0 {
|
||||
lines = append(lines, "restrictions:")
|
||||
for _, restriction := range report.Plan.Restrictions {
|
||||
lines = append(lines, " - "+restriction)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func writeSandboxCheckHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero sandbox check <tool> [flags]
|
||||
|
||||
Evaluates the sandbox decision for a hypothetical tool action against the
|
||||
resolved policy, and prints the plan, the decision (allow/prompt/deny with risk
|
||||
and any violation), and any persistent grant that matches the tool. Secrets in
|
||||
grant reasons are redacted.
|
||||
|
||||
Flags:
|
||||
--side-effect <kind> read | write | shell | network | none (default read)
|
||||
--path <path> Target path for the action (read/write checks)
|
||||
--autonomy <level> low | medium | high (default high)
|
||||
--reason <text> Reason recorded with the request
|
||||
--json Print the machine-readable snapshot
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func sandboxCheckDeps(t *testing.T) (appDeps, string) {
|
||||
t.Helper()
|
||||
store := newSandboxTestStore(t)
|
||||
root := t.TempDir()
|
||||
return appDeps{
|
||||
getwd: func() (string, error) { return root, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendPolicyOnly, Platform: "windows", Fallback: true}
|
||||
},
|
||||
}, root
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
// An absolute path outside the workspace, portable across OSes: a Unix literal
|
||||
// like /etc/passwd is not absolute on Windows (it would be joined into the
|
||||
// workspace and allowed), so build one under a separate temp dir instead.
|
||||
outside := filepath.Join(t.TempDir(), "outside.txt")
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check", "write_file", "--side-effect", "write", "--path", outside, "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Tool string `json:"tool"`
|
||||
Plan struct {
|
||||
Policy struct {
|
||||
EffectiveMode string `json:"effectiveMode"`
|
||||
} `json:"policy"`
|
||||
Backend struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"backend"`
|
||||
} `json:"plan"`
|
||||
Decision struct {
|
||||
Action string `json:"action"`
|
||||
Risk struct {
|
||||
Level string `json:"level"`
|
||||
} `json:"risk"`
|
||||
Violation *struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"violation"`
|
||||
} `json:"decision"`
|
||||
Grant struct {
|
||||
ToolName string `json:"toolName"`
|
||||
Matched bool `json:"matched"`
|
||||
} `json:"grant"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode check JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Tool != "write_file" {
|
||||
t.Fatalf("tool = %q", payload.Tool)
|
||||
}
|
||||
if payload.Plan.Policy.EffectiveMode != string(sandbox.ModeEnforce) {
|
||||
t.Fatalf("effectiveMode = %q, want enforce", payload.Plan.Policy.EffectiveMode)
|
||||
}
|
||||
if payload.Plan.Backend.Name != string(sandbox.BackendPolicyOnly) {
|
||||
t.Fatalf("backend = %q", payload.Plan.Backend.Name)
|
||||
}
|
||||
if payload.Decision.Action != string(sandbox.ActionDeny) {
|
||||
t.Fatalf("expected deny for out-of-workspace write, got %q\n%s", payload.Decision.Action, stdout.String())
|
||||
}
|
||||
if payload.Decision.Violation == nil {
|
||||
t.Fatalf("expected a violation for the out-of-workspace write")
|
||||
}
|
||||
if payload.Grant.ToolName != "write_file" || payload.Grant.Matched {
|
||||
t.Fatalf("expected an unmatched grant for write_file, got %#v", payload.Grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckTextRendersDecisionAndPlan(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check", "read_file", "--side-effect", "read", "--path", "notes.txt"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Sandbox check: read_file",
|
||||
"decision: ",
|
||||
"risk: ",
|
||||
"policy: mode=enforce",
|
||||
"backend: policy-only",
|
||||
"grant: no grant matched this action",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("check text missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckRequiresTool(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check"}, &stdout, &stderr, deps)
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("expected a usage error when no tool is given, got success: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "tool name is required") {
|
||||
t.Fatalf("expected 'tool name is required', got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckRejectsInvalidFlags(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
for _, args := range [][]string{
|
||||
{"sandbox", "check", "read_file", "--side-effect", "wrtie"},
|
||||
{"sandbox", "check", "read_file", "--autonomy", "supreme"},
|
||||
} {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps(args, &stdout, &stderr, deps); code == exitSuccess {
|
||||
t.Fatalf("args %v: expected a usage error, got success: %s", args, stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "invalid --") {
|
||||
t.Fatalf("args %v: expected an 'invalid --…' usage error, got %q", args, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckMatchedGrantRedactsReason(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
root := t.TempDir()
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return root, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendPolicyOnly}
|
||||
},
|
||||
}
|
||||
// Seed a tool-wide allow grant whose reason embeds a secret; the matched-grant
|
||||
// snapshot must redact it.
|
||||
if _, err := store.Grant(sandbox.GrantInput{
|
||||
ToolName: "read_file",
|
||||
Decision: sandbox.GrantAllow,
|
||||
MaxAutonomy: sandbox.AutonomyHigh,
|
||||
Reason: "approved with sk-test-secret1234567890",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed grant: %v", err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check", "read_file", "--autonomy", "high", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "sk-test-secret1234567890") {
|
||||
t.Fatalf("grant reason leaked a secret into the snapshot:\n%s", stdout.String())
|
||||
}
|
||||
var payload struct {
|
||||
Grant struct {
|
||||
Matched bool `json:"matched"`
|
||||
Grant *struct {
|
||||
Reason string `json:"reason"`
|
||||
} `json:"grant"`
|
||||
} `json:"grant"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode check JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !payload.Grant.Matched || payload.Grant.Grant == nil {
|
||||
t.Fatalf("expected a matched grant, got %#v", payload.Grant)
|
||||
}
|
||||
if !strings.Contains(payload.Grant.Grant.Reason, "REDACTED") {
|
||||
t.Fatalf("expected the reason to be redacted, got %q", payload.Grant.Grant.Reason)
|
||||
}
|
||||
}
|
||||
+232
-17
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -103,6 +105,41 @@ func TestRunSandboxGrantsAllowListDenyRevokeAndClear(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxGrantsListIncludesCommandPrefixes(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
if _, err := store.GrantCommandPrefix(sandbox.CommandPrefixInput{ToolName: "bash", Prefix: []string{"git", "status"}, Reason: "status checks"}); err != nil {
|
||||
t.Fatalf("GrantCommandPrefix: %v", err)
|
||||
}
|
||||
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "grants", "list", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("list json exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
CommandPrefixes []sandbox.CommandPrefixGrant `json:"commandPrefixes"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode list JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(payload.CommandPrefixes) != 1 || !reflect.DeepEqual(payload.CommandPrefixes[0].Prefix, []string{"git", "status"}) {
|
||||
t.Fatalf("unexpected commandPrefixes payload: %#v", payload.CommandPrefixes)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"sandbox", "grants", "list"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("list text exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
for _, want := range []string{"bash", "`git status`", "command-prefix", "status checks"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("list text = %q, missing %q", stdout.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxGrantsCreateAndRevokeByPath(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
|
||||
@@ -218,7 +255,7 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
Name: sandbox.BackendPolicyOnly,
|
||||
Platform: "windows",
|
||||
Fallback: true,
|
||||
Message: "policy-only fallback: Windows native sandbox adapter is not implemented",
|
||||
Message: "policy-only fallback: Windows sandbox command runner is not available",
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -242,10 +279,14 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
Policy sandbox.Policy `json:"policy"`
|
||||
Backend sandbox.Backend `json:"backend"`
|
||||
Plan struct {
|
||||
SupportLevel string `json:"supportLevel"`
|
||||
Capabilities []sandbox.BackendCapability `json:"capabilities"`
|
||||
Restrictions []string `json:"restrictions"`
|
||||
Warnings []string `json:"warnings"`
|
||||
TargetBackend sandbox.BackendName `json:"targetBackend"`
|
||||
CommandWrapped bool `json:"commandWrapped"`
|
||||
EnforcementLevel sandbox.EnforcementLevel `json:"enforcementLevel"`
|
||||
DowngradeReason string `json:"downgradeReason"`
|
||||
SupportLevel string `json:"supportLevel"`
|
||||
Capabilities []sandbox.BackendCapability `json:"capabilities"`
|
||||
Restrictions []string `json:"restrictions"`
|
||||
Warnings []string `json:"warnings"`
|
||||
} `json:"plan"`
|
||||
Grants string `json:"grantsPath"`
|
||||
}
|
||||
@@ -261,13 +302,16 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
if payload.Plan.SupportLevel != string(sandbox.BackendSupportPolicyOnly) {
|
||||
t.Fatalf("support level = %q, want policy-only", payload.Plan.SupportLevel)
|
||||
}
|
||||
if payload.Plan.TargetBackend != sandbox.BackendWindowsRestrictedToken || payload.Plan.CommandWrapped || payload.Plan.EnforcementLevel != sandbox.EnforcementDegraded || payload.Plan.DowngradeReason == "" {
|
||||
t.Fatalf("unexpected manager baseline fields: %#v", payload.Plan)
|
||||
}
|
||||
if sandboxPolicyCapabilityStatus(payload.Plan.Capabilities, "native_process_isolation") != sandbox.CapabilityUnavailable {
|
||||
t.Fatalf("expected native isolation unavailable, got %#v", payload.Plan.Capabilities)
|
||||
}
|
||||
if !sandboxPolicyRestrictionContains(payload.Plan.Restrictions, "native process isolation unavailable on windows") {
|
||||
t.Fatalf("expected JSON plan to document Windows fallback, got %#v", payload.Plan.Restrictions)
|
||||
}
|
||||
if !sandboxPolicyRestrictionContains(payload.Plan.Warnings, "Windows native sandbox adapter is not implemented") {
|
||||
if !sandboxPolicyRestrictionContains(payload.Plan.Warnings, "Windows sandbox command runner is not available") {
|
||||
t.Fatalf("expected JSON warnings to document Windows fallback, got %#v", payload.Plan.Warnings)
|
||||
}
|
||||
} else {
|
||||
@@ -275,12 +319,16 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"Zero sandbox policy",
|
||||
"backend: policy-only",
|
||||
"target_backend: windows-restricted-token",
|
||||
"support_level: policy-only",
|
||||
"command_wrapped: false",
|
||||
"enforcement_level: degraded",
|
||||
"downgrade_reason: policy-only fallback: Windows sandbox command runner is not available",
|
||||
"backend_fallback: true",
|
||||
"backend_command_wrapping: false",
|
||||
"backend_native_isolation: false",
|
||||
"backend_platform: windows",
|
||||
"Windows native sandbox adapter is not implemented",
|
||||
"Windows sandbox command runner is not available",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("expected policy text to contain %q, got %q", want, output)
|
||||
@@ -291,6 +339,173 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxSetupRunsWindowsHelper(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
runnerDir := t.TempDir()
|
||||
runnerPath := filepath.Join(runnerDir, sandbox.WindowsSandboxCommandRunnerName)
|
||||
var gotPath string
|
||||
var gotArgs []string
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return workspace, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
Sandbox: config.SandboxConfig{Network: "allow"},
|
||||
}, nil
|
||||
},
|
||||
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{
|
||||
Name: sandbox.BackendWindowsRestrictedToken,
|
||||
Available: true,
|
||||
Platform: "windows",
|
||||
Executable: runnerPath,
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
}
|
||||
},
|
||||
runSandboxSetupHelper: func(path string, args []string, stdout io.Writer, stderr io.Writer) error {
|
||||
gotPath = path
|
||||
gotArgs = append([]string{}, args...)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "setup", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("setup exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
wantPath := filepath.Join(runnerDir, sandbox.WindowsSandboxSetupName)
|
||||
if gotPath != wantPath {
|
||||
t.Fatalf("setup helper path = %q, want %q", gotPath, wantPath)
|
||||
}
|
||||
config, err := sandbox.ParseWindowsSandboxSetupArgs(gotArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
if config.CommandCWD != workspace || len(config.WorkspaceRoots) != 1 || config.WorkspaceRoots[0] != workspace {
|
||||
t.Fatalf("setup args config = %#v, want workspace cwd/root", config)
|
||||
}
|
||||
if config.PermissionProfile.Network.Mode != sandbox.NetworkAllow {
|
||||
t.Fatalf("setup network profile = %#v, want allow", config.PermissionProfile.Network)
|
||||
}
|
||||
var payload sandboxSetupResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode setup JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !payload.Ran || payload.Helper != wantPath || payload.Backend != sandbox.BackendWindowsRestrictedToken {
|
||||
t.Fatalf("setup payload = %#v, want ran Windows helper", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxSetupNoopsForNonWindowsBackend(t *testing.T) {
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendLinuxBwrap, Platform: "linux", Available: true}
|
||||
},
|
||||
runSandboxSetupHelper: func(string, []string, io.Writer, io.Writer) error {
|
||||
t.Fatal("setup helper should not run for non-Windows backend")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "setup"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("setup exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No native sandbox setup is required for linux") {
|
||||
t.Fatalf("setup output = %q, want non-Windows no-op message", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
|
||||
if got := tuiSandboxSetupCommand(sandbox.Backend{Name: sandbox.BackendLinuxBwrap, Platform: "linux", Available: true}, appDeps{}); got != nil {
|
||||
t.Fatal("linux backend should not enable the TUI sandbox setup command")
|
||||
}
|
||||
if got := tuiSandboxSetupCommand(sandbox.Backend{Name: sandbox.BackendPolicyOnly, Platform: "windows", Available: false}, appDeps{}); got != nil {
|
||||
t.Fatal("policy-only Windows backend should not enable the TUI sandbox setup command")
|
||||
}
|
||||
got := tuiSandboxSetupCommand(sandbox.Backend{
|
||||
Name: sandbox.BackendWindowsRestrictedToken,
|
||||
Platform: "windows",
|
||||
Available: true,
|
||||
Executable: filepath.Join(t.TempDir(), sandbox.WindowsSandboxCommandRunnerName),
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
}, appDeps{})
|
||||
if got == nil {
|
||||
t.Fatal("native Windows backend should enable the TUI sandbox setup command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
workspace := t.TempDir()
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return workspace, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{
|
||||
Name: sandbox.BackendPolicyOnly,
|
||||
Platform: "windows",
|
||||
Fallback: true,
|
||||
Message: "policy-only fallback: Windows sandbox command runner is not available",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "policy", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("policy exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
|
||||
got := stdout.String()
|
||||
got = replacePathToken(got, workspace, "$WORKSPACE")
|
||||
got = replacePathToken(got, store.FilePath(), "$GRANTS")
|
||||
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_policy_only.golden.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
}
|
||||
if !jsonValuesEqual(t, wantBytes, []byte(got)) {
|
||||
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), got)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
|
||||
t.Helper()
|
||||
var wantValue any
|
||||
if err := json.Unmarshal(wantBytes, &wantValue); err != nil {
|
||||
t.Fatalf("decode wanted JSON: %v", err)
|
||||
}
|
||||
var gotValue any
|
||||
if err := json.Unmarshal(gotBytes, &gotValue); err != nil {
|
||||
t.Fatalf("decode got JSON: %v\n%s", err, string(gotBytes))
|
||||
}
|
||||
return reflect.DeepEqual(wantValue, gotValue)
|
||||
}
|
||||
|
||||
func replacePathToken(value string, path string, token string) string {
|
||||
replace := func(value string, path string) string {
|
||||
value = strings.ReplaceAll(value, path, token)
|
||||
if encoded, err := json.Marshal(path); err == nil && len(encoded) >= 2 {
|
||||
value = strings.ReplaceAll(value, string(encoded[1:len(encoded)-1]), token)
|
||||
}
|
||||
return value
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(path); err == nil && resolved != path {
|
||||
value = replace(value, resolved)
|
||||
}
|
||||
return replace(value, path)
|
||||
}
|
||||
|
||||
func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
deps := appDeps{
|
||||
@@ -641,24 +856,24 @@ func TestApplyConfiguredAutonomyCeiling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConfiguredSandboxPolicyHardeningFlags(t *testing.T) {
|
||||
func TestApplyConfiguredSandboxPolicyDiagnosticsFlags(t *testing.T) {
|
||||
base := sandbox.DefaultPolicy()
|
||||
if base.BlockUnixSockets || base.MonitorDenials {
|
||||
t.Fatalf("precondition: hardening flags must default off, got block=%v monitor=%v", base.BlockUnixSockets, base.MonitorDenials)
|
||||
t.Fatalf("precondition: diagnostic flags must default off")
|
||||
}
|
||||
|
||||
// Omitted keys leave the (off) defaults untouched.
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.BlockUnixSockets || got.MonitorDenials {
|
||||
t.Fatalf("empty config must not enable hardening flags, got block=%v monitor=%v", got.BlockUnixSockets, got.MonitorDenials)
|
||||
t.Fatalf("empty config must not enable diagnostic flags: %#v", got)
|
||||
}
|
||||
|
||||
// Each flag opts in independently.
|
||||
got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{BlockUnixSockets: true, MonitorDenials: true})
|
||||
if !got.BlockUnixSockets {
|
||||
t.Fatal("BlockUnixSockets config not applied to policy")
|
||||
}
|
||||
if !got.MonitorDenials {
|
||||
t.Fatal("MonitorDenials config not applied to policy")
|
||||
// The flags opt in.
|
||||
got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{
|
||||
BlockUnixSockets: true,
|
||||
MonitorDenials: true,
|
||||
})
|
||||
if !got.BlockUnixSockets || !got.MonitorDenials {
|
||||
t.Fatalf("diagnostic config not applied to policy: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// The central config->policy translation must carry scoped mode + the egress
|
||||
// allow/deny lists into the engine policy (this path feeds exec, the TUI app,
|
||||
// and the sandbox CLI commands).
|
||||
func TestApplyConfiguredSandboxPolicyWiresScopedNetwork(t *testing.T) {
|
||||
cfg := config.SandboxConfig{
|
||||
Network: "scoped",
|
||||
NetworkAllowedDomains: []string{"github.com", "pypi.org"},
|
||||
NetworkDeniedDomains: []string{"evil.example"},
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), cfg)
|
||||
if policy.Network != sandbox.NetworkScoped {
|
||||
t.Fatalf("Network = %q, want scoped", policy.Network)
|
||||
}
|
||||
if len(policy.AllowedDomains) != 2 || policy.AllowedDomains[0] != "github.com" {
|
||||
t.Fatalf("AllowedDomains = %#v", policy.AllowedDomains)
|
||||
}
|
||||
if len(policy.DeniedDomains) != 1 || policy.DeniedDomains[0] != "evil.example" {
|
||||
t.Fatalf("DeniedDomains = %#v", policy.DeniedDomains)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
)
|
||||
|
||||
// TestSpecialistSummariesIncludesBuiltins confirms the orchestrator delegation
|
||||
// prompt is fed the built-in specialists (the wiring behind auto-delegation).
|
||||
func TestSpecialistSummariesIncludesBuiltins(t *testing.T) {
|
||||
paths, err := specialist.DefaultPaths(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultPaths: %v", err)
|
||||
}
|
||||
descByName := map[string]string{}
|
||||
for _, info := range specialistSummaries(paths) {
|
||||
descByName[info.Name] = info.WhenToUse
|
||||
}
|
||||
for _, name := range []string{"worker", "explorer", "code-review"} {
|
||||
if descByName[name] == "" {
|
||||
t.Fatalf("specialistSummaries missing built-in %q (or empty description); got %v", name, descByName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"policy": {
|
||||
"mode": "enforce",
|
||||
"network": "deny",
|
||||
"enforceWorkspace": true,
|
||||
"denyDestructiveShell": true,
|
||||
"allowPolicyOnlyRunner": true,
|
||||
"maxAutonomy": "high"
|
||||
},
|
||||
"backend": {
|
||||
"name": "policy-only",
|
||||
"available": false,
|
||||
"platform": "windows",
|
||||
"fallback": true,
|
||||
"commandWrapping": false,
|
||||
"nativeIsolation": false,
|
||||
"message": "policy-only fallback: Windows sandbox command runner is not available"
|
||||
},
|
||||
"plan": {
|
||||
"backend": {
|
||||
"name": "policy-only",
|
||||
"available": false,
|
||||
"platform": "windows",
|
||||
"fallback": true,
|
||||
"commandWrapping": false,
|
||||
"nativeIsolation": false,
|
||||
"message": "policy-only fallback: Windows sandbox command runner is not available"
|
||||
},
|
||||
"targetBackend": "windows-restricted-token",
|
||||
"workspaceRoot": "$WORKSPACE",
|
||||
"policy": {
|
||||
"mode": "enforce",
|
||||
"network": "deny",
|
||||
"enforceWorkspace": true,
|
||||
"denyDestructiveShell": true,
|
||||
"allowPolicyOnlyRunner": true,
|
||||
"maxAutonomy": "high"
|
||||
},
|
||||
"permissionProfile": {
|
||||
"fileSystem": {
|
||||
"kind": "restricted",
|
||||
"readRoots": [
|
||||
"$WORKSPACE"
|
||||
],
|
||||
"writeRoots": [
|
||||
{
|
||||
"root": "$WORKSPACE",
|
||||
"protectedMetadataNames": [
|
||||
".git",
|
||||
".zero",
|
||||
".agents"
|
||||
]
|
||||
}
|
||||
],
|
||||
"includePlatformRoots": true,
|
||||
"allowTemp": true
|
||||
},
|
||||
"network": {
|
||||
"mode": "deny"
|
||||
}
|
||||
},
|
||||
"commandWrapped": false,
|
||||
"enforcementLevel": "degraded",
|
||||
"downgradeReason": "policy-only fallback: Windows sandbox command runner is not available",
|
||||
"supportLevel": "policy-only",
|
||||
"requiresPlatformSandbox": true,
|
||||
"capabilities": [
|
||||
{
|
||||
"key": "policy_evaluation",
|
||||
"status": "preflight",
|
||||
"detail": "tool requests are evaluated against sandbox policy before execution"
|
||||
},
|
||||
{
|
||||
"key": "workspace_write_guard",
|
||||
"status": "preflight",
|
||||
"detail": "filesystem writes are checked against the workspace root before execution"
|
||||
},
|
||||
{
|
||||
"key": "network_guard",
|
||||
"status": "preflight",
|
||||
"detail": "network-capable tool requests are denied before execution"
|
||||
},
|
||||
{
|
||||
"key": "destructive_shell_guard",
|
||||
"status": "preflight",
|
||||
"detail": "destructive shell patterns are denied before execution"
|
||||
},
|
||||
{
|
||||
"key": "native_process_isolation",
|
||||
"status": "unavailable",
|
||||
"detail": "Windows sandbox command runner is not available"
|
||||
},
|
||||
{
|
||||
"key": "command_wrapping",
|
||||
"status": "unavailable",
|
||||
"detail": "shell commands are not wrapped by a native platform sandbox"
|
||||
}
|
||||
],
|
||||
"restrictions": [
|
||||
"filesystem writes must stay inside workspace",
|
||||
"network access denied unless a future adapter grants it explicitly",
|
||||
"destructive shell patterns denied before execution",
|
||||
"native process isolation unavailable on windows; policy engine still evaluates tool requests before execution",
|
||||
"shell commands are not wrapped by a native platform sandbox"
|
||||
],
|
||||
"warnings": [
|
||||
"Windows sandbox command runner is not available; using policy-only preflight checks",
|
||||
"shell commands are not wrapped by a native platform sandbox"
|
||||
]
|
||||
},
|
||||
"grantsPath": "$GRANTS"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so web
|
||||
// search and scraping work out of the box with no setup and no API key. They are
|
||||
// seeded before user/project config is merged (see ResolveMCP), so a user can
|
||||
// override any field — for example point firecrawl at a self-hosted instance, or
|
||||
// add an API-key header to lift the free-tier limit — or disable it entirely with
|
||||
// `zero mcp disable <name>` (which writes `"disabled": true`).
|
||||
//
|
||||
// Keyless Firecrawl routes requests through firecrawl.dev (1,000 free credits per
|
||||
// month, no account). Self-host Firecrawl (AGPL-3.0) for unlimited and private
|
||||
// use. Zero only calls it over the network, so Firecrawl's license never reaches
|
||||
// into Zero's own code.
|
||||
func DefaultMCPServers() map[string]MCPServerConfig {
|
||||
return map[string]MCPServerConfig{
|
||||
"firecrawl": {
|
||||
Type: "http",
|
||||
URL: "https://mcp.firecrawl.dev/v2/mcp",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// IsDefaultMCPServer reports whether name is one of Zero's built-in default MCP
|
||||
// servers. The config commands use it so a default can be disabled/enabled even
|
||||
// though it is not written to the user's config file until overridden.
|
||||
func IsDefaultMCPServer(name string) bool {
|
||||
_, ok := DefaultMCPServers()[strings.TrimSpace(name)]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsDefaultMCPServer(t *testing.T) {
|
||||
if !IsDefaultMCPServer("firecrawl") {
|
||||
t.Fatal("firecrawl should be a built-in default")
|
||||
}
|
||||
if IsDefaultMCPServer(" firecrawl ") == false {
|
||||
t.Fatal("IsDefaultMCPServer should trim whitespace")
|
||||
}
|
||||
if IsDefaultMCPServer("not-a-default") {
|
||||
t.Fatal("unknown server should not be a default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPSeedsEnabledFirecrawlDefault(t *testing.T) {
|
||||
cfg, err := ResolveMCP(ResolveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl, ok := cfg.Servers["firecrawl"]
|
||||
if !ok {
|
||||
t.Fatal("expected the firecrawl default to be seeded with no user config")
|
||||
}
|
||||
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
|
||||
t.Fatalf("unexpected firecrawl default: %#v", firecrawl)
|
||||
}
|
||||
if firecrawl.Disabled {
|
||||
t.Fatal("the firecrawl default must be enabled out of the box")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPUserCanDisableDefault(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if !cfg.Servers["firecrawl"].Disabled {
|
||||
t.Fatal("a user must be able to disable the default by writing over it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
// Point firecrawl at a self-hosted instance; the default's Type must survive.
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"url":"http://localhost:3002/mcp"}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl := cfg.Servers["firecrawl"]
|
||||
if firecrawl.URL != "http://localhost:3002/mcp" {
|
||||
t.Fatalf("user override of the default URL did not apply: %#v", firecrawl)
|
||||
}
|
||||
if firecrawl.Type != "http" {
|
||||
t.Fatalf("override should keep the default's other fields (type), got %#v", firecrawl)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,16 @@ import (
|
||||
|
||||
const defaultMaxTurns = 30
|
||||
|
||||
const defaultDeferThreshold = 10
|
||||
// defaultDeferThreshold is the number of deferred-eligible (MCP) tools at which
|
||||
// Zero collapses their full JSON schemas into compact `tool_search` reminder
|
||||
// lines instead of advertising every schema on every turn. MCP tool schemas run
|
||||
// 300-600 tokens each, so eagerly shipping even a small server's toolset wastes
|
||||
// thousands of input tokens per message. Kept low so a typical single-server set
|
||||
// (Exa-style, a handful of tools) defers and stays cheap; the only cost is one
|
||||
// `tool_search` round-trip before a deferred tool's first use, after which it is
|
||||
// loaded for the rest of the run. Override per-config with tools.deferThreshold
|
||||
// (set 0 to always advertise every schema, e.g. for a model without tool_search).
|
||||
const defaultDeferThreshold = 3
|
||||
|
||||
func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
cfg := FileConfig{
|
||||
@@ -58,6 +67,10 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid tools.deferThreshold %d: must be >= 0", cfg.Tools.DeferThreshold)
|
||||
}
|
||||
|
||||
if cfg.Swarm.MaxTeamSize < 0 {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid swarm.maxTeamSize %d: must be >= 0 (0 uses the default)", cfg.Swarm.MaxTeamSize)
|
||||
}
|
||||
|
||||
if maxAutonomy := strings.TrimSpace(cfg.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
// Fail loud on an invalid ceiling. An unvalidated typo (e.g. "moderate")
|
||||
// would otherwise survive Resolve untouched, reach the sandbox bridge,
|
||||
@@ -70,8 +83,14 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
if network := strings.TrimSpace(cfg.Sandbox.Network); network != "" {
|
||||
switch sandbox.NetworkMode(network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
case sandbox.NetworkScoped:
|
||||
// Scoped with no allowlist would collapse to deny in the engine; reject
|
||||
// it loudly so the operator isn't surprised by a silent downgrade.
|
||||
if !hasNonEmptyDomain(cfg.Sandbox.NetworkAllowedDomains) {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: scoped requires a non-empty sandbox.networkAllowedDomains", network)
|
||||
}
|
||||
default:
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: expected allow or deny", network)
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: expected allow, deny, or scoped", network)
|
||||
}
|
||||
}
|
||||
if mode := strings.TrimSpace(cfg.Notify.Mode); mode != "" {
|
||||
@@ -103,12 +122,16 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
|
||||
cfg := FileConfig{}
|
||||
// Seed Zero's built-in default MCP servers (e.g. keyless Firecrawl for free,
|
||||
// no-setup web search/scrape) BEFORE merging user/project config, so the user
|
||||
// can override any field or disable a default by writing over it.
|
||||
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
|
||||
|
||||
for _, path := range []string{options.UserConfigPath, options.ProjectConfigPath} {
|
||||
if path == "" {
|
||||
@@ -154,7 +177,18 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
// Scoped egress allow/deny lists are a GRANT, layered here (global user
|
||||
// config) like AdditionalWriteRoots and likewise NOT taken from project
|
||||
// config (see mergeProjectConfig).
|
||||
dst.Sandbox.NetworkAllowedDomains = unionStrings(dst.Sandbox.NetworkAllowedDomains, src.Sandbox.NetworkAllowedDomains)
|
||||
dst.Sandbox.NetworkDeniedDomains = unionStrings(dst.Sandbox.NetworkDeniedDomains, src.Sandbox.NetworkDeniedDomains)
|
||||
dst.Sandbox.AdditionalWriteRoots = unionStrings(dst.Sandbox.AdditionalWriteRoots, src.Sandbox.AdditionalWriteRoots)
|
||||
if src.Sandbox.BlockUnixSockets {
|
||||
dst.Sandbox.BlockUnixSockets = true
|
||||
}
|
||||
if src.Sandbox.MonitorDenials {
|
||||
dst.Sandbox.MonitorDenials = true
|
||||
}
|
||||
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
|
||||
dst.Notify.Mode = mode
|
||||
}
|
||||
@@ -165,6 +199,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
|
||||
dst.Tools.deferThresholdSet = true
|
||||
}
|
||||
if src.Swarm.MaxTeamSize != 0 {
|
||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||
}
|
||||
if src.Preferences.FavoriteModels != nil {
|
||||
dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels)
|
||||
}
|
||||
@@ -185,16 +222,23 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
mergeProvider(dst, provider)
|
||||
}
|
||||
mergeMCPConfig(&dst.MCP, src.MCP)
|
||||
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
|
||||
// config: a cloned repo's .zero/config.json must not be able to grant
|
||||
// itself write access outside the workspace. Global config and CLI flags
|
||||
// are the only config sources for write roots.
|
||||
// Sandbox.AdditionalWriteRoots and Sandbox.NetworkAllowedDomains/
|
||||
// NetworkDeniedDomains are intentionally NOT merged from project config: a
|
||||
// cloned repo's .zero/config.json must not be able to grant itself write
|
||||
// access outside the workspace, nor widen its own network egress allowlist.
|
||||
// Global config (and CLI flags) are the only sources for those grants.
|
||||
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
dst.Sandbox.MaxAutonomy = maxAutonomy
|
||||
}
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
if src.Sandbox.BlockUnixSockets {
|
||||
dst.Sandbox.BlockUnixSockets = true
|
||||
}
|
||||
if src.Sandbox.MonitorDenials {
|
||||
dst.Sandbox.MonitorDenials = true
|
||||
}
|
||||
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
|
||||
dst.Notify.Mode = mode
|
||||
}
|
||||
@@ -205,6 +249,9 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
|
||||
dst.Tools.deferThresholdSet = true
|
||||
}
|
||||
if src.Swarm.MaxTeamSize != 0 {
|
||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -509,6 +556,12 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
if maxAutonomy := strings.TrimSpace(overrides.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
cfg.Sandbox.MaxAutonomy = maxAutonomy
|
||||
}
|
||||
if overrides.Sandbox.BlockUnixSockets {
|
||||
cfg.Sandbox.BlockUnixSockets = true
|
||||
}
|
||||
if overrides.Sandbox.MonitorDenials {
|
||||
cfg.Sandbox.MonitorDenials = true
|
||||
}
|
||||
if mode := strings.TrimSpace(overrides.Notify.Mode); mode != "" {
|
||||
cfg.Notify.Mode = mode
|
||||
}
|
||||
@@ -590,6 +643,18 @@ func hasProviderFields(profile ProviderProfile) bool {
|
||||
// base, preserving order. Used for additive config keys like
|
||||
// sandbox.additionalWriteRoots where a later layer must not erase earlier
|
||||
// grants.
|
||||
// hasNonEmptyDomain reports whether domains contains at least one entry that is
|
||||
// non-empty after trimming — used to reject a scoped network policy that has no
|
||||
// usable allowlist.
|
||||
func hasNonEmptyDomain(domains []string) bool {
|
||||
for _, domain := range domains {
|
||||
if strings.TrimSpace(domain) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unionStrings(base []string, extra []string) []string {
|
||||
seen := make(map[string]struct{}, len(base))
|
||||
for _, value := range base {
|
||||
|
||||
@@ -1269,6 +1269,26 @@ func TestResolveSandboxMaxAutonomyFromFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxBlockUnixSocketsFromFile(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"sandbox": {"blockUnixSockets": true},
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"provider": "openai",
|
||||
"api_key": "sk-test",
|
||||
"model": "gpt-test"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if !resolved.Sandbox.BlockUnixSockets {
|
||||
t.Fatal("resolved.Sandbox.BlockUnixSockets = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxMaxAutonomyPrecedence(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"sandbox": {"maxAutonomy": "low"},
|
||||
@@ -1417,6 +1437,15 @@ func TestResolveDefaultsDeferThresholdWhenUnset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The default must stay low so a typical single MCP server's toolset defers
|
||||
// instead of shipping every full schema each turn (the token-economy fix). This
|
||||
// guards against a regression silently bumping it back up.
|
||||
func TestDefaultDeferThresholdStaysLowForTokenEconomy(t *testing.T) {
|
||||
if defaultDeferThreshold <= 0 || defaultDeferThreshold > 4 {
|
||||
t.Fatalf("defaultDeferThreshold = %d, want a small positive value (<=4) so small MCP sets defer", defaultDeferThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeferThresholdFromFile(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "p",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAcceptsScopedNetworkWithAllowlist(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"sandbox": {"network": "scoped", "networkAllowedDomains": ["github.com", "pypi.org"], "networkDeniedDomains": ["evil.example"]}
|
||||
}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Network != "scoped" {
|
||||
t.Fatalf("Network = %q, want scoped", resolved.Sandbox.Network)
|
||||
}
|
||||
if len(resolved.Sandbox.NetworkAllowedDomains) != 2 || resolved.Sandbox.NetworkAllowedDomains[0] != "github.com" {
|
||||
t.Fatalf("NetworkAllowedDomains = %#v", resolved.Sandbox.NetworkAllowedDomains)
|
||||
}
|
||||
if len(resolved.Sandbox.NetworkDeniedDomains) != 1 {
|
||||
t.Fatalf("NetworkDeniedDomains = %#v", resolved.Sandbox.NetworkDeniedDomains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsScopedNetworkWithoutAllowlist(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"sandbox": {"network": "scoped"}}`)
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "scoped requires a non-empty sandbox.networkAllowedDomains") {
|
||||
t.Fatalf("expected scoped-without-allowlist error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsUnknownNetworkMode(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"sandbox": {"network": "bogus"}}`)
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "expected allow, deny, or scoped") {
|
||||
t.Fatalf("expected unknown-mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Security: a project config (a cloned repo) must NOT be able to add to the
|
||||
// scoped egress allowlist — only the global user config can.
|
||||
func TestResolveProjectCannotWidenNetworkAllowlist(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"sandbox": {"network": "scoped", "networkAllowedDomains": ["github.com"]}}`)
|
||||
projectPath := writeConfig(t, `{"sandbox": {"networkAllowedDomains": ["exfil.example"]}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
for _, d := range resolved.Sandbox.NetworkAllowedDomains {
|
||||
if d == "exfil.example" {
|
||||
t.Fatalf("project config widened the egress allowlist: %#v", resolved.Sandbox.NetworkAllowedDomains)
|
||||
}
|
||||
}
|
||||
if len(resolved.Sandbox.NetworkAllowedDomains) != 1 || resolved.Sandbox.NetworkAllowedDomains[0] != "github.com" {
|
||||
t.Fatalf("allowlist = %#v, want [github.com] only", resolved.Sandbox.NetworkAllowedDomains)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveSwarmMaxTeamSize(t *testing.T) {
|
||||
provider := `"activeProvider":"p","providers":[{"name":"p","provider":"openai","api_key":"sk","model_id":"m"}]`
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
wantSize int
|
||||
wantError bool
|
||||
}{
|
||||
{"parsed from config", `{` + provider + `,"swarm":{"maxTeamSize":3}}`, 3, false},
|
||||
{"unset stays 0 so the swarm applies its own default", `{` + provider + `}`, 0, false},
|
||||
{"negative is rejected", `{` + provider + `,"swarm":{"maxTeamSize":-1}}`, 0, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := writeConfig(t, tc.json)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if tc.wantError {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an invalid maxTeamSize")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Swarm.MaxTeamSize != tc.wantSize {
|
||||
t.Fatalf("MaxTeamSize = %d, want %d", resolved.Swarm.MaxTeamSize, tc.wantSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,22 @@ func HasProviderProfile(profile ProviderProfile) bool {
|
||||
type SandboxConfig struct {
|
||||
MaxAutonomy string `json:"maxAutonomy,omitempty"`
|
||||
// Network controls whether shell commands classified as network-touching
|
||||
// (curl, git push, package installs, …) are allowed: "allow" or "deny".
|
||||
// Empty keeps the built-in default (deny). Without this knob the engine's
|
||||
// hard-coded NetworkDeny was unreachable from any config surface.
|
||||
// (curl, git push, package installs, …) are allowed: "allow", "deny", or
|
||||
// "scoped". Empty keeps the built-in default (deny). "scoped" permits egress
|
||||
// only to NetworkAllowedDomains (minus NetworkDeniedDomains) and REQUIRES a
|
||||
// non-empty NetworkAllowedDomains; it falls closed to "deny" on a backend that
|
||||
// cannot enforce scoped egress.
|
||||
Network string `json:"network,omitempty"`
|
||||
// NetworkAllowedDomains is the egress allowlist consulted only when Network is
|
||||
// "scoped": the sandboxed process may reach only these domains (minus
|
||||
// NetworkDeniedDomains) and nothing else. Ignored for "allow"/"deny". Like
|
||||
// AdditionalWriteRoots this is a GRANT, so it is honored from the GLOBAL user
|
||||
// config only — never project config — so a cloned repo cannot widen its own
|
||||
// network egress.
|
||||
NetworkAllowedDomains []string `json:"networkAllowedDomains,omitempty"`
|
||||
// NetworkDeniedDomains subtracts from NetworkAllowedDomains under "scoped".
|
||||
// Global-user-config only, for the same reason as NetworkAllowedDomains.
|
||||
NetworkDeniedDomains []string `json:"networkDeniedDomains,omitempty"`
|
||||
// AdditionalWriteRoots lists directories outside the workspace the sandbox
|
||||
// allows writes in. Each entry must be an existing directory; entries are
|
||||
// normalized (~-expanded, absolutized, symlink-resolved) at startup and an
|
||||
@@ -70,12 +82,9 @@ type SandboxConfig struct {
|
||||
// flags only — deliberately not project config, so a cloned repo cannot
|
||||
// grant itself write access. Session-only grants use /add-dir instead.
|
||||
AdditionalWriteRoots []string `json:"additionalWriteRoots,omitempty"`
|
||||
// BlockUnixSockets, when true, installs a seccomp filter on the Linux
|
||||
// (bubblewrap) backend that denies AF_UNIX socket creation in the sandboxed
|
||||
// command, closing the Unix-socket channel bubblewrap's filesystem/network
|
||||
// isolation leaves open. Off by default; degrades gracefully (runs without the
|
||||
// filter) when the zero-seccomp helper is not installed. Ignored on other
|
||||
// backends.
|
||||
// BlockUnixSockets, when true, asks the Linux sandbox helper to install a
|
||||
// best-effort seccomp filter that denies AF_UNIX socket creation inside the
|
||||
// sandboxed command. Off by default; ignored on non-Linux backends.
|
||||
BlockUnixSockets bool `json:"blockUnixSockets,omitempty"`
|
||||
// MonitorDenials, when true on macOS, tails the unified log for this run's
|
||||
// sandbox denials and appends them to a command's stderr so blocked operations
|
||||
@@ -98,6 +107,14 @@ type PreferencesConfig struct {
|
||||
FavoriteModels []string `json:"favoriteModels,omitempty"`
|
||||
}
|
||||
|
||||
// SwarmConfig tunes the multi-agent swarm. MaxTeamSize caps how many members run
|
||||
// concurrently per team; 0 uses the built-in default (8). Spawns past the cap
|
||||
// queue and launch as slots free, so lowering it bounds parallelism (and provider
|
||||
// load / rate-limit pressure) without dropping work.
|
||||
type SwarmConfig struct {
|
||||
MaxTeamSize int `json:"maxTeamSize,omitempty"`
|
||||
}
|
||||
|
||||
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
|
||||
// threshold (including to 0, which disables deferral). Use this for programmatic
|
||||
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
|
||||
@@ -131,6 +148,7 @@ type FileConfig struct {
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
}
|
||||
|
||||
@@ -162,6 +180,7 @@ type ResolvedConfig struct {
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
Swarm SwarmConfig
|
||||
Preferences PreferencesConfig
|
||||
}
|
||||
|
||||
@@ -206,6 +225,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
Sandbox SandboxConfig `json:"sandbox"`
|
||||
Notify NotifyConfig `json:"notify"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Swarm SwarmConfig `json:"swarm"`
|
||||
Preferences PreferencesConfig `json:"preferences"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
@@ -230,6 +250,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.Sandbox = raw.Sandbox
|
||||
cfg.Notify = raw.Notify
|
||||
cfg.Tools = raw.Tools
|
||||
cfg.Swarm = raw.Swarm
|
||||
cfg.Preferences = raw.Preferences
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
|
||||
@@ -261,7 +261,10 @@ func estimateTextTokens(value string) int {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
tokens := len(value) / 4
|
||||
// Share the agent's heuristic (non-whitespace bytes / 4) so the context
|
||||
// preview matches the scale the compaction loop uses and tracks the real
|
||||
// tokenizer far better than naive len/4. Non-empty text reports at least 1.
|
||||
tokens := agent.ApproxTextTokens(value)
|
||||
if tokens == 0 {
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ type Options struct {
|
||||
UserConfig string
|
||||
ProjectConfig string
|
||||
Provider config.ProviderProfile
|
||||
WorkspaceRoot string
|
||||
Sandbox config.SandboxConfig
|
||||
Connectivity bool
|
||||
ProviderHealth *providerhealth.Result
|
||||
// GOOS overrides the platform used to resolve the sandbox backend. Empty
|
||||
@@ -69,7 +71,7 @@ func Run(options Options) Report {
|
||||
modelCheck := providerModelCheck(options.Provider)
|
||||
checks = append(checks, modelCheck)
|
||||
checks = append(checks, connectivityCheck(options.Provider, options.Connectivity, modelCheck.Status, options.ProviderHealth))
|
||||
checks = append(checks, sandboxBackendCheck(options.GOOS, options.LookupExecutable))
|
||||
checks = append(checks, sandboxBackendCheck(options.GOOS, options.LookupExecutable, options.WorkspaceRoot, options.Sandbox))
|
||||
checks = append(checks, lspServersCheck(options.LookupExecutable))
|
||||
|
||||
report := Report{
|
||||
|
||||
+101
-18
@@ -4,17 +4,18 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// sandboxBackendCheck reports whether a native sandbox backend (bubblewrap on
|
||||
// Linux, sandbox-exec on macOS) is available. A missing backend is a WARN, not a
|
||||
// FAIL: ZERO still evaluates its policy engine before every tool call, it just
|
||||
// loses native process isolation. The remedy line is the fix — the platform's
|
||||
// install/enable command — not a restatement of the diagnosis.
|
||||
func sandboxBackendCheck(goos string, lookup func(string) (string, error)) Check {
|
||||
// sandboxBackendCheck reports whether the selected platform has a native
|
||||
// sandbox backend ready. A missing backend is a WARN, not a FAIL: Zero still
|
||||
// evaluates policy before tool calls, but shell execution is degraded to
|
||||
// preflight checks instead of native process isolation.
|
||||
func sandboxBackendCheck(goos string, lookup func(string) (string, error), workspaceRoot string, sandboxConfig config.SandboxConfig) Check {
|
||||
if goos == "" {
|
||||
goos = runtime.GOOS
|
||||
}
|
||||
@@ -23,34 +24,116 @@ func sandboxBackendCheck(goos string, lookup func(string) (string, error)) Check
|
||||
}
|
||||
backend := sandbox.SelectBackend(sandbox.BackendOptions{GOOS: goos, LookupExecutable: lookup})
|
||||
if backend.Available {
|
||||
if setupCheck := windowsSandboxSetupCheck(goos, backend, workspaceRoot, sandboxConfig); setupCheck != nil {
|
||||
return *setupCheck
|
||||
}
|
||||
return check("sandbox.backend", "Sandbox backend", StatusPass, fmt.Sprintf("Native sandbox backend %s is available.", backend.Name), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"supportLevel": string(backend.SupportLevel()),
|
||||
})
|
||||
}
|
||||
remedy := sandboxRemedy(goos)
|
||||
return check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("No native sandbox backend on %s; ZERO falls back to policy-only preflight checks.", goos), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"remedy": remedy,
|
||||
remedy := sandboxRemedy(goos, backend)
|
||||
return check("sandbox.backend", "Sandbox backend", StatusWarn, sandboxBackendWarning(goos, backend), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"supportLevel": string(backend.SupportLevel()),
|
||||
"downgradeReason": backend.DowngradeReason(sandbox.DefaultPolicy()),
|
||||
"remedy": remedy,
|
||||
})
|
||||
}
|
||||
|
||||
// sandboxRemedy returns the platform-specific, actionable command to obtain a
|
||||
// native sandbox backend.
|
||||
func sandboxRemedy(goos string) string {
|
||||
func sandboxBackendWarning(goos string, backend sandbox.Backend) string {
|
||||
if backend.Message != "" {
|
||||
return fmt.Sprintf("Native sandbox backend unavailable on %s: %s.", goos, backend.Message)
|
||||
}
|
||||
return fmt.Sprintf("Native sandbox backend unavailable on %s; shell commands use degraded policy-only preflight checks.", goos)
|
||||
}
|
||||
|
||||
// sandboxRemedy returns the platform-specific, actionable step to obtain a
|
||||
// native sandbox backend or complete required setup.
|
||||
func sandboxRemedy(goos string, backend sandbox.Backend) string {
|
||||
switch goos {
|
||||
case "linux":
|
||||
return "install bubblewrap (e.g. `apt-get install bubblewrap` or `dnf install bubblewrap`) so `bwrap` is on PATH"
|
||||
return "install the Linux sandbox helper or bubblewrap so native command wrapping is available"
|
||||
case "darwin":
|
||||
return "sandbox-exec ships with macOS; ensure /usr/bin is on PATH so `sandbox-exec` resolves"
|
||||
case "windows":
|
||||
return "native sandboxing is not yet available on Windows; run inside WSL2 or a Linux container for native isolation"
|
||||
if backend.Message != "" {
|
||||
return "install the Windows sandbox command runner and setup helper together, then run `zero sandbox setup`"
|
||||
}
|
||||
return "run `zero sandbox setup` to prepare the Windows native sandbox"
|
||||
default:
|
||||
return "no native sandbox adapter exists for " + goos + "; run inside Linux (bubblewrap) or macOS (sandbox-exec) for native isolation"
|
||||
}
|
||||
}
|
||||
|
||||
func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoot string, sandboxConfig config.SandboxConfig) *Check {
|
||||
if goos != "windows" || backend.Name != sandbox.BackendWindowsRestrictedToken {
|
||||
return nil
|
||||
}
|
||||
workspaceRoot = strings.TrimSpace(workspaceRoot)
|
||||
if workspaceRoot == "" {
|
||||
return nil
|
||||
}
|
||||
scope, err := sandbox.NewScope(workspaceRoot, sandboxConfig.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
result := check("sandbox.backend", "Sandbox backend", StatusWarn, "Windows sandbox setup could not be checked because configured write roots are invalid: "+err.Error(), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"supportLevel": string(backend.SupportLevel()),
|
||||
"setupStatus": "invalid-config",
|
||||
"remedy": "fix sandbox.additionalWriteRoots, then run `zero sandbox setup`",
|
||||
})
|
||||
return &result
|
||||
}
|
||||
sandboxHome, err := sandbox.ResolveWindowsSandboxHome(nil)
|
||||
if err != nil {
|
||||
result := check("sandbox.backend", "Sandbox backend", StatusWarn, "Windows sandbox setup could not be checked: "+err.Error(), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"supportLevel": string(backend.SupportLevel()),
|
||||
"setupStatus": "unknown",
|
||||
"remedy": "run `zero sandbox setup` after fixing the sandbox home path",
|
||||
})
|
||||
return &result
|
||||
}
|
||||
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
|
||||
setupConfig := sandbox.WindowsSandboxSetupConfig{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
}
|
||||
if err := sandbox.ValidateWindowsSandboxSetupMarker(setupConfig); err != nil {
|
||||
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but Windows sandbox setup is missing or out of date: %v.", backend.Name, err), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"supportLevel": string(backend.SupportLevel()),
|
||||
"setupStatus": "missing-or-out-of-date",
|
||||
"remedy": "run `zero sandbox setup` to prepare the Windows native sandbox",
|
||||
})
|
||||
return &result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func doctorSandboxPolicy(cfg config.SandboxConfig) sandbox.Policy {
|
||||
policy := sandbox.DefaultPolicy()
|
||||
switch sandbox.NetworkMode(cfg.Network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny, sandbox.NetworkScoped:
|
||||
policy.Network = sandbox.NetworkMode(cfg.Network)
|
||||
}
|
||||
if len(cfg.NetworkAllowedDomains) > 0 {
|
||||
policy.AllowedDomains = append([]string(nil), cfg.NetworkAllowedDomains...)
|
||||
}
|
||||
if len(cfg.NetworkDeniedDomains) > 0 {
|
||||
policy.DeniedDomains = append([]string(nil), cfg.NetworkDeniedDomains...)
|
||||
}
|
||||
policy.MonitorDenials = cfg.MonitorDenials
|
||||
return policy
|
||||
}
|
||||
|
||||
// lspServersCheck reports which language servers ZERO would use are present on
|
||||
// PATH. Missing servers are not a failure — ZERO degrades to text-only edits for
|
||||
// those languages — so the worst status is WARN, and each missing server gets an
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// validProvider is a fully-formed provider profile so the report's overall OK
|
||||
@@ -39,15 +40,15 @@ func TestSandboxCheckPassesWhenBackendPresent(t *testing.T) {
|
||||
Now: fixedDoctorClock("2026-06-12T10:00:00Z"),
|
||||
Runtime: "go",
|
||||
GOOS: "linux",
|
||||
LookupExecutable: stubLookup("bwrap"),
|
||||
LookupExecutable: stubLookup(sandbox.LinuxSandboxHelperName, "bwrap"),
|
||||
})
|
||||
|
||||
check := report.Check("sandbox.backend")
|
||||
if check == nil || check.Status != StatusPass {
|
||||
t.Fatalf("expected sandbox.backend pass, got %#v", report.Checks)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(check.Message), "bubblewrap") {
|
||||
t.Fatalf("expected bubblewrap named in message, got %q", check.Message)
|
||||
if !strings.Contains(check.Message, string(sandbox.BackendLinuxBwrap)) {
|
||||
t.Fatalf("expected Linux sandbox backend named in message, got %q", check.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +102,74 @@ func TestSandboxCheckRemedyIsPlatformSpecific(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxCheckReportsWindowsNativeSetupStates(t *testing.T) {
|
||||
native := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-12T10:08:00Z"),
|
||||
Runtime: "go",
|
||||
GOOS: "windows",
|
||||
LookupExecutable: stubLookup(
|
||||
sandbox.WindowsSandboxCommandRunnerName,
|
||||
sandbox.WindowsSandboxSetupName,
|
||||
),
|
||||
}).Check("sandbox.backend")
|
||||
if native == nil || native.Status != StatusPass {
|
||||
t.Fatalf("expected windows native sandbox pass when helpers are present, got %#v", native)
|
||||
}
|
||||
if !strings.Contains(native.Message, string(sandbox.BackendWindowsRestrictedToken)) {
|
||||
t.Fatalf("expected native Windows backend in message, got %q", native.Message)
|
||||
}
|
||||
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", t.TempDir())
|
||||
needsSetup := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-12T10:08:30Z"),
|
||||
Runtime: "go",
|
||||
GOOS: "windows",
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
LookupExecutable: stubLookup(
|
||||
sandbox.WindowsSandboxCommandRunnerName,
|
||||
sandbox.WindowsSandboxSetupName,
|
||||
),
|
||||
}).Check("sandbox.backend")
|
||||
if needsSetup == nil || needsSetup.Status != StatusWarn {
|
||||
t.Fatalf("expected setup-missing warning when marker is absent, got %#v", needsSetup)
|
||||
}
|
||||
setupStatus, _ := needsSetup.Details["setupStatus"].(string)
|
||||
setupRemedy, _ := needsSetup.Details["remedy"].(string)
|
||||
if setupStatus != "missing-or-out-of-date" || !strings.Contains(setupRemedy, "zero sandbox setup") {
|
||||
t.Fatalf("setup warning details = %#v, want missing/out-of-date with setup remedy", needsSetup.Details)
|
||||
}
|
||||
|
||||
setupMissing := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-12T10:09:00Z"),
|
||||
Runtime: "go",
|
||||
GOOS: "windows",
|
||||
LookupExecutable: stubLookup(sandbox.WindowsSandboxCommandRunnerName),
|
||||
}).Check("sandbox.backend")
|
||||
if setupMissing == nil || setupMissing.Status != StatusWarn {
|
||||
t.Fatalf("expected setup-missing warning, got %#v", setupMissing)
|
||||
}
|
||||
remedy, _ := setupMissing.Details["remedy"].(string)
|
||||
if !strings.Contains(setupMissing.Message, "setup helper") || !strings.Contains(remedy, "zero sandbox setup") {
|
||||
t.Fatalf("setup-missing check message=%q remedy=%q details=%#v", setupMissing.Message, remedy, setupMissing.Details)
|
||||
}
|
||||
if strings.Contains(setupMissing.Message, "not yet available") || strings.Contains(remedy, "WSL2") {
|
||||
t.Fatalf("windows remedy should not claim native sandboxing is unavailable: message=%q remedy=%q", setupMissing.Message, remedy)
|
||||
}
|
||||
|
||||
runnerMissing := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-12T10:10:00Z"),
|
||||
Runtime: "go",
|
||||
GOOS: "windows",
|
||||
LookupExecutable: stubLookup(),
|
||||
}).Check("sandbox.backend")
|
||||
if runnerMissing == nil || runnerMissing.Status != StatusWarn {
|
||||
t.Fatalf("expected runner-missing warning, got %#v", runnerMissing)
|
||||
}
|
||||
if !strings.Contains(runnerMissing.Message, "command runner") {
|
||||
t.Fatalf("runner-missing message = %q, want command runner guidance", runnerMissing.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLSPCheckWarnsForMissingServersWithRemedy(t *testing.T) {
|
||||
report := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-12T10:10:00Z"),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestDoctorSandboxPolicyWiresScopedNetwork(t *testing.T) {
|
||||
cfg := config.SandboxConfig{Network: "scoped", NetworkAllowedDomains: []string{"github.com"}}
|
||||
policy := doctorSandboxPolicy(cfg)
|
||||
if policy.Network != sandbox.NetworkScoped {
|
||||
t.Fatalf("Network = %q, want scoped", policy.Network)
|
||||
}
|
||||
if len(policy.AllowedDomains) != 1 || policy.AllowedDomains[0] != "github.com" {
|
||||
t.Fatalf("AllowedDomains = %#v", policy.AllowedDomains)
|
||||
}
|
||||
}
|
||||
+166
-34
@@ -6,23 +6,58 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// defaultConnectTimeout bounds how long startup waits for ONE MCP server to
|
||||
// connect and list its tools. A server that exceeds it is abandoned and skipped
|
||||
// so a slow or unreachable server (e.g. a hosted endpoint blocked by the local
|
||||
// network) cannot delay the first model response. Servers connect concurrently,
|
||||
// so total startup cost is the slowest reachable server, not the sum.
|
||||
const defaultConnectTimeout = 8 * time.Second
|
||||
|
||||
type RegisterOptions struct {
|
||||
PermissionStore *PermissionStore
|
||||
Autonomy PermissionAutonomy
|
||||
ClientFactory func(context.Context, Server) (ToolClient, error)
|
||||
// ConnectTimeout bounds the per-server connect+list at startup. Zero uses
|
||||
// defaultConnectTimeout.
|
||||
ConnectTimeout time.Duration
|
||||
}
|
||||
|
||||
// SkippedServer records an MCP server that was not registered because it could
|
||||
// not be reached or its tools could not be validated. Registration is
|
||||
// best-effort per server: one unreachable server is skipped (and reported here)
|
||||
// rather than aborting startup or disabling the others.
|
||||
type SkippedServer struct {
|
||||
Name string
|
||||
Err error
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
clients []ToolClient
|
||||
// cancels releases the per-server connect contexts of the clients we KEPT.
|
||||
// A stdio server's subprocess is tied to its context, so the context must
|
||||
// stay live for the session and be cancelled only at Close (after the client
|
||||
// is closed). Same length/order as clients is not required.
|
||||
cancels []context.CancelFunc
|
||||
skipped []SkippedServer
|
||||
once sync.Once
|
||||
err error
|
||||
}
|
||||
|
||||
// Skipped returns the servers that were skipped during registration (unreachable
|
||||
// or invalid), so the caller can warn the user without failing the launch.
|
||||
func (runtime *Runtime) Skipped() []SkippedServer {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
return runtime.skipped
|
||||
}
|
||||
|
||||
var unsafeToolNameChars = regexp.MustCompile(`[^A-Za-z0-9_]+`)
|
||||
|
||||
func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options RegisterOptions) (*Runtime, error) {
|
||||
@@ -45,54 +80,145 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
|
||||
}
|
||||
}
|
||||
|
||||
// Registration is atomic per call: validate and stage every server's tools
|
||||
// first, then commit to the caller's registry only once they all succeed. If a
|
||||
// LATER server fails mid-registration, nothing was committed, so the registry
|
||||
// never ends up holding dead tools that point at a (now-closed) client for an
|
||||
// earlier server.
|
||||
timeout := options.ConnectTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultConnectTimeout
|
||||
}
|
||||
|
||||
// Connect every server CONCURRENTLY: connect + list-tools is network/process
|
||||
// I/O, so connecting serially makes startup wait for the SUM of all servers —
|
||||
// one slow or unreachable server would block every other server AND the first
|
||||
// model response. Each server gets its own cancelable context bounded by the
|
||||
// startup timeout; a server that does not connect + list in time is abandoned
|
||||
// (its context cancelled to tear down the half-open connection/subprocess) and
|
||||
// recorded as skipped. The concurrent phase does ONLY I/O and touches no shared
|
||||
// state; all validation, conflict detection, and registration happen in the
|
||||
// deterministic serial phase below, so the result is identical regardless of
|
||||
// completion order.
|
||||
type connectResult struct {
|
||||
client ToolClient
|
||||
remote []RemoteTool
|
||||
cancel context.CancelFunc
|
||||
err error
|
||||
}
|
||||
results := make([]connectResult, len(servers))
|
||||
var wg sync.WaitGroup
|
||||
for index := range servers {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
server := servers[index]
|
||||
serverCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan connectResult, 1)
|
||||
go func() {
|
||||
client, remote, err := connectAndList(serverCtx, factory, server)
|
||||
done <- connectResult{client: client, remote: remote, err: err}
|
||||
}()
|
||||
select {
|
||||
case res := <-done:
|
||||
if res.err != nil {
|
||||
cancel() // failed: nothing to keep
|
||||
} else {
|
||||
res.cancel = cancel // keep the context alive; released at Close
|
||||
}
|
||||
results[index] = res
|
||||
case <-time.After(timeout):
|
||||
cancel() // abandon the slow connect: tears down the conn/subprocess
|
||||
// Reap the goroutine + any partial client in the background so a
|
||||
// slow server never blocks startup.
|
||||
go func() {
|
||||
if res := <-done; res.client != nil {
|
||||
_ = res.client.Close()
|
||||
}
|
||||
}()
|
||||
results[index] = connectResult{err: fmt.Errorf("connect timed out after %s", timeout)}
|
||||
}
|
||||
}(index)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Serial, deterministic commit in server order. Building tools reads the
|
||||
// permission store and the registry, so it stays single-goroutine. A server is
|
||||
// best-effort: one that failed to connect, timed out, returned a nameless tool,
|
||||
// or conflicts with an already-committed tool is SKIPPED (recorded, not fatal),
|
||||
// and still contributes its tools all-or-none. The conflict check spans the
|
||||
// registry plus every tool committed by an earlier server.
|
||||
staged := make([]registryTool, 0)
|
||||
stagedNames := make(map[string]struct{})
|
||||
for _, server := range servers {
|
||||
client, err := factory(ctx, server)
|
||||
if err != nil {
|
||||
_ = runtime.Close()
|
||||
return nil, err
|
||||
for index, server := range servers {
|
||||
res := results[index]
|
||||
if res.err != nil {
|
||||
runtime.skipped = append(runtime.skipped, SkippedServer{Name: server.Name, Err: res.err})
|
||||
continue
|
||||
}
|
||||
runtime.clients = append(runtime.clients, client)
|
||||
|
||||
remoteTools, err := client.ListTools(ctx)
|
||||
if err != nil {
|
||||
_ = runtime.Close()
|
||||
return nil, fmt.Errorf("list MCP tools for %s: %w", server.Name, err)
|
||||
serverTools, validateErr := buildServerTools(registry, server, res.remote, res.client, options, stagedNames)
|
||||
if validateErr != nil {
|
||||
if res.cancel != nil {
|
||||
res.cancel()
|
||||
}
|
||||
_ = res.client.Close()
|
||||
runtime.skipped = append(runtime.skipped, SkippedServer{Name: server.Name, Err: validateErr})
|
||||
continue
|
||||
}
|
||||
for _, remote := range remoteTools {
|
||||
if strings.TrimSpace(remote.Name) == "" {
|
||||
_ = runtime.Close()
|
||||
return nil, fmt.Errorf("MCP server %s returned a tool without a name", server.Name)
|
||||
}
|
||||
tool := newRegistryTool(server, remote, client, options)
|
||||
// Conflict detection spans both the existing registry and tools staged so
|
||||
// far in this call (two MCP tools whose names collapse to the same
|
||||
// sanitized name conflict even though neither is in the registry yet).
|
||||
if existing, ok := registry.Get(tool.Name()); ok {
|
||||
_ = runtime.Close()
|
||||
return nil, fmt.Errorf("MCP tool %s from %s conflicts with existing tool %s", remote.Name, server.Name, existing.Name())
|
||||
}
|
||||
if _, ok := stagedNames[tool.Name()]; ok {
|
||||
_ = runtime.Close()
|
||||
return nil, fmt.Errorf("MCP tool %s from %s conflicts with another MCP tool named %s", remote.Name, server.Name, tool.Name())
|
||||
}
|
||||
runtime.clients = append(runtime.clients, res.client)
|
||||
if res.cancel != nil {
|
||||
runtime.cancels = append(runtime.cancels, res.cancel)
|
||||
}
|
||||
for _, tool := range serverTools {
|
||||
stagedNames[tool.Name()] = struct{}{}
|
||||
staged = append(staged, tool)
|
||||
}
|
||||
}
|
||||
// Every server succeeded — commit the staged tools to the registry.
|
||||
for _, tool := range staged {
|
||||
registry.Register(tool)
|
||||
}
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
// connectAndList connects to one server and lists its tools. It does ONLY I/O
|
||||
// (no registry, permission-store, or other shared state), so it is safe to run
|
||||
// concurrently for every server. On a list error it closes the client.
|
||||
func connectAndList(ctx context.Context, factory func(context.Context, Server) (ToolClient, error), server Server) (ToolClient, []RemoteTool, error) {
|
||||
client, err := factory(ctx, server)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
remoteTools, err := client.ListTools(ctx)
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
return nil, nil, fmt.Errorf("list MCP tools for %s: %w", server.Name, err)
|
||||
}
|
||||
return client, remoteTools, nil
|
||||
}
|
||||
|
||||
// buildServerTools validates a server's remote tools against the registry and the
|
||||
// names already committed by earlier servers, returning the server's tools only
|
||||
// when every one is named and conflict-free. It runs in the serial commit phase
|
||||
// (single goroutine), so its registry and permission-store reads are race-free.
|
||||
// On error the caller closes the client (it owns the result), so this never does.
|
||||
func buildServerTools(registry *tools.Registry, server Server, remoteTools []RemoteTool, client ToolClient, options RegisterOptions, stagedNames map[string]struct{}) ([]registryTool, error) {
|
||||
serverTools := make([]registryTool, 0, len(remoteTools))
|
||||
localNames := make(map[string]struct{})
|
||||
for _, remote := range remoteTools {
|
||||
if strings.TrimSpace(remote.Name) == "" {
|
||||
return nil, fmt.Errorf("MCP server %s returned a tool without a name", server.Name)
|
||||
}
|
||||
tool := newRegistryTool(server, remote, client, options)
|
||||
if existing, ok := registry.Get(tool.Name()); ok {
|
||||
return nil, fmt.Errorf("MCP tool %s from %s conflicts with existing tool %s", remote.Name, server.Name, existing.Name())
|
||||
}
|
||||
if _, ok := stagedNames[tool.Name()]; ok {
|
||||
return nil, fmt.Errorf("MCP tool %s from %s conflicts with another MCP tool named %s", remote.Name, server.Name, tool.Name())
|
||||
}
|
||||
if _, ok := localNames[tool.Name()]; ok {
|
||||
return nil, fmt.Errorf("MCP tool %s from %s conflicts with another tool from the same server", remote.Name, server.Name)
|
||||
}
|
||||
localNames[tool.Name()] = struct{}{}
|
||||
serverTools = append(serverTools, tool)
|
||||
}
|
||||
return serverTools, nil
|
||||
}
|
||||
|
||||
func (runtime *Runtime) Close() error {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
@@ -103,6 +229,12 @@ func (runtime *Runtime) Close() error {
|
||||
runtime.err = err
|
||||
}
|
||||
}
|
||||
// Release the kept servers' connect contexts AFTER closing the clients: a
|
||||
// stdio subprocess is already terminated by Close, so cancelling is then a
|
||||
// no-op; it frees the context (and any tied subprocess) either way.
|
||||
for _, cancel := range runtime.cancels {
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
return runtime.err
|
||||
}
|
||||
|
||||
@@ -118,14 +118,15 @@ func TestRegisterToolsMarksPersistentlyApprovedToolsAllow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterToolsRollsBackEarlierServerToolsWhenLaterServerFails(t *testing.T) {
|
||||
func TestRegisterToolsSkipsUnreachableServerAndKeepsOthers(t *testing.T) {
|
||||
// NormalizeConfig sorts server names, so "alpha" registers before "zebra".
|
||||
// "zebra" fails mid-registration; registration must be atomic, so "alpha"'s
|
||||
// tools must NOT be left dangling in the caller's registry.
|
||||
// "zebra" is unreachable; it must be SKIPPED (recorded in Skipped()), not fatal,
|
||||
// and "alpha"'s tools must still register — one bad server can't disable the rest
|
||||
// or abort startup. A server still contributes its tools all-or-none.
|
||||
registry := tools.NewRegistry()
|
||||
alphaClient := &fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "Lookup documentation"}}}
|
||||
|
||||
_, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"alpha": {Type: "stdio", Command: "alpha-mcp"},
|
||||
"zebra": {Type: "stdio", Command: "zebra-mcp"},
|
||||
}}, RegisterOptions{
|
||||
@@ -136,21 +137,29 @@ func TestRegisterToolsRollsBackEarlierServerToolsWhenLaterServerFails(t *testing
|
||||
return alphaClient, nil
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected RegisterTools to fail when a later server fails")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools returned error, want a skip: %v", err)
|
||||
}
|
||||
if _, ok := registry.Get("mcp_alpha_lookup"); ok {
|
||||
t.Fatal("expected earlier server's tools to be rolled back when a later server fails")
|
||||
defer runtime.Close()
|
||||
if _, ok := registry.Get("mcp_alpha_lookup"); !ok {
|
||||
t.Fatal("expected the reachable server's tools to register when another is skipped")
|
||||
}
|
||||
skipped := runtime.Skipped()
|
||||
if len(skipped) != 1 || skipped[0].Name != "zebra" {
|
||||
t.Fatalf("Skipped() = %#v, want exactly one entry for zebra", skipped)
|
||||
}
|
||||
if skipped[0].Err == nil {
|
||||
t.Fatal("expected the skipped server to record why it was skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterToolsPreservesPriorRegistryStateOnFailure(t *testing.T) {
|
||||
// A tool that predates the MCP registration must survive a failed RegisterTools
|
||||
// call: rollback removes only what this call added.
|
||||
func TestRegisterToolsKeepsPriorStateAndReachableServersWhenOneIsSkipped(t *testing.T) {
|
||||
// A tool that predates registration must survive, the reachable server's tools
|
||||
// must register, and the unreachable server is skipped (recorded), not fatal.
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(&fakePreexistingTool{name: "preexisting"})
|
||||
|
||||
_, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"alpha": {Type: "stdio", Command: "alpha-mcp"},
|
||||
"zebra": {Type: "stdio", Command: "zebra-mcp"},
|
||||
}}, RegisterOptions{
|
||||
@@ -161,14 +170,53 @@ func TestRegisterToolsPreservesPriorRegistryStateOnFailure(t *testing.T) {
|
||||
return &fakeToolClient{listed: []RemoteTool{{Name: "lookup"}}}, nil
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected RegisterTools to fail when a later server fails")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools returned error, want a skip: %v", err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
if _, ok := registry.Get("preexisting"); !ok {
|
||||
t.Fatal("expected pre-existing tool to survive a failed MCP registration")
|
||||
t.Fatal("expected the pre-existing tool to survive registration")
|
||||
}
|
||||
if _, ok := registry.Get("mcp_alpha_lookup"); ok {
|
||||
t.Fatal("expected the failed call to add no MCP tools")
|
||||
if _, ok := registry.Get("mcp_alpha_lookup"); !ok {
|
||||
t.Fatal("expected the reachable server's tools to register")
|
||||
}
|
||||
if skipped := runtime.Skipped(); len(skipped) != 1 || skipped[0].Name != "zebra" {
|
||||
t.Fatalf("Skipped() = %#v, want exactly one entry for zebra", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterToolsSkipsServerThatExceedsConnectTimeout(t *testing.T) {
|
||||
// A slow server must not block startup: it is abandoned after ConnectTimeout
|
||||
// and skipped, while a fast server registers normally. This is the latency fix
|
||||
// — one unreachable server can't hold up the first response.
|
||||
registry := tools.NewRegistry()
|
||||
fastClient := &fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "fast"}}}
|
||||
|
||||
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"fast": {Type: "stdio", Command: "fast-mcp"},
|
||||
"slow": {Type: "stdio", Command: "slow-mcp"},
|
||||
}}, RegisterOptions{
|
||||
ConnectTimeout: 50 * time.Millisecond,
|
||||
ClientFactory: func(ctx context.Context, server Server) (ToolClient, error) {
|
||||
if server.Name == "slow" {
|
||||
// Block past the timeout, honoring ctx cancellation so the abandoned
|
||||
// connect tears down cleanly instead of leaking.
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return fastClient, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools error: %v", err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
if _, ok := registry.Get("mcp_fast_lookup"); !ok {
|
||||
t.Fatal("expected the fast server's tools to register despite the slow one timing out")
|
||||
}
|
||||
skipped := runtime.Skipped()
|
||||
if len(skipped) != 1 || skipped[0].Name != "slow" {
|
||||
t.Fatalf("Skipped() = %#v, want exactly one entry for slow", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,81 @@
|
||||
package modelregistry
|
||||
|
||||
// SupportsVision reports whether the model identified by modelID is known to the
|
||||
// registry and advertises the vision capability. It returns false when the model
|
||||
// cannot be resolved (e.g. a custom or openai-compatible id absent from the
|
||||
// catalog): an unknown model is treated as "cannot confirm", so callers drop
|
||||
// images rather than send them to a model that may reject them.
|
||||
import "strings"
|
||||
|
||||
// SupportsVision reports whether the model identified by modelID accepts image
|
||||
// input. A model in the curated catalog is authoritative (its declared
|
||||
// capability wins). For a model the catalog does NOT know — a custom /
|
||||
// openai-compatible / ollama id that will never be in the catalog — it falls
|
||||
// back to recognizing well-known multimodal families by name, so a real vision
|
||||
// model still accepts images. A name outside those families stays refused (the
|
||||
// previous "cannot confirm → drop" behavior), so text-only models are unaffected.
|
||||
//
|
||||
// modelID is resolved through the registry's normal alias/pattern matching, so
|
||||
// any spelling that Get accepts works here too. This helper is the single
|
||||
// capability check shared by the headless (exec) and interactive (TUI) input
|
||||
// surfaces; the warn-and-drop behavior lives at those call sites.
|
||||
func SupportsVision(registry Registry, modelID string) bool {
|
||||
return registry.SupportsCapability(modelID, ModelCapabilityVision)
|
||||
if registry.SupportsCapability(modelID, ModelCapabilityVision) {
|
||||
return true
|
||||
}
|
||||
// The catalog knows this model and it lacks vision: trust that — never let the
|
||||
// name heuristic override an authoritative "no".
|
||||
if _, known := registry.Get(modelID); known {
|
||||
return false
|
||||
}
|
||||
return visionCapableByName(modelID)
|
||||
}
|
||||
|
||||
// visionCapableByName reports whether modelID names a known multimodal family.
|
||||
// It is conservative: it matches the established vision families and leaves
|
||||
// everything else (including text-only models like gpt-oss, kimi, and coder
|
||||
// models) refused, so a false "supported" is unlikely. Used only as a fallback
|
||||
// for models absent from the curated catalog.
|
||||
func visionCapableByName(modelID string) bool {
|
||||
id := strings.ToLower(strings.TrimSpace(modelID))
|
||||
if slash := strings.LastIndexByte(id, '/'); slash >= 0 {
|
||||
id = id[slash+1:] // drop a "provider/" prefix
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(id, "gemini"):
|
||||
return true // every Gemini model is multimodal
|
||||
case strings.Contains(id, "claude-3"), strings.Contains(id, "claude-4"),
|
||||
strings.Contains(id, "claude-opus"), strings.Contains(id, "claude-sonnet"),
|
||||
strings.Contains(id, "claude-haiku"):
|
||||
return true // Claude 3+ are multimodal
|
||||
case strings.Contains(id, "gpt-4o"), strings.Contains(id, "gpt-4.1"),
|
||||
strings.Contains(id, "gpt-4-turbo"), strings.Contains(id, "gpt-4-vision"),
|
||||
strings.Contains(id, "gpt-5"):
|
||||
return true // GPT-4o / 4.1 / 4-turbo / 5 are multimodal (gpt-oss is NOT matched)
|
||||
case id == "o1" || id == "o3" || strings.HasPrefix(id, "o1-") ||
|
||||
strings.HasPrefix(id, "o3-") || strings.HasPrefix(id, "o4-"):
|
||||
return true // OpenAI o-series accept images
|
||||
case strings.Contains(id, "grok-4"),
|
||||
strings.Contains(id, "grok") && mentionsVision(id):
|
||||
return true // Grok 4 (and grok vision variants) are multimodal
|
||||
case strings.Contains(id, "llava"), strings.Contains(id, "pixtral"),
|
||||
strings.Contains(id, "internvl"), strings.Contains(id, "minicpm-v"),
|
||||
strings.Contains(id, "moondream"), strings.Contains(id, "bakllava"),
|
||||
strings.Contains(id, "-vl"), strings.Contains(id, "vl-"),
|
||||
mentionsVision(id):
|
||||
return true // common open multimodal families + *-vl / *-vision
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// mentionsVision reports whether id advertises vision via the word "vision",
|
||||
// excluding negated forms (vision-less / no-vision): a model named for LACKING
|
||||
// vision must not be treated as multimodal.
|
||||
func mentionsVision(id string) bool {
|
||||
if !strings.Contains(id, "vision") {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(id, "vision-less"), strings.Contains(id, "visionless"),
|
||||
strings.Contains(id, "no-vision"), strings.Contains(id, "non-vision"),
|
||||
strings.Contains(id, "novision"), strings.Contains(id, "nonvision"):
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package modelregistry
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVisionCapableByName(t *testing.T) {
|
||||
vision := []string{
|
||||
"gemini-3-flash-preview", "gemini-2.5-flash", "ollama-cloud/gemini-3-flash-preview",
|
||||
"grok-4.3", "grok-4-fast", "grok-2-vision",
|
||||
"gpt-4o", "gpt-4.1-mini", "gpt-4-turbo", "gpt-5", "o3-mini", "o1",
|
||||
"claude-sonnet-4.5", "claude-3-haiku",
|
||||
"llava:13b", "qwen2.5-vl-7b", "llama3.2-vision", "pixtral-12b", "moondream",
|
||||
}
|
||||
textOnly := []string{
|
||||
"gpt-oss:120b", "kimi-for-coding", "deepseek-coder", "qwen2.5-coder",
|
||||
"codestral", "llama3.1-8b", "grok-text-only", "mistral-large",
|
||||
// Negated "vision" names must NOT match the bare-"vision" fallback.
|
||||
"my-custom-vision-less-model", "no-vision-model", "grok-vision-less",
|
||||
}
|
||||
for _, m := range vision {
|
||||
if !visionCapableByName(m) {
|
||||
t.Errorf("expected %q to be recognized as vision-capable", m)
|
||||
}
|
||||
}
|
||||
for _, m := range textOnly {
|
||||
if visionCapableByName(m) {
|
||||
t.Errorf("expected %q to NOT be vision-capable", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportsVisionFallbackForUnknownModels(t *testing.T) {
|
||||
reg, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Catalog model: authoritative vision flag.
|
||||
if !SupportsVision(reg, "gpt-4o") {
|
||||
t.Error("gpt-4o should be vision (catalog)")
|
||||
}
|
||||
// Unknown-to-catalog but real vision models now pass via the name fallback.
|
||||
for _, m := range []string{"gemini-3-flash-preview", "grok-4.3"} {
|
||||
if !SupportsVision(reg, m) {
|
||||
t.Errorf("%q should be vision via name fallback", m)
|
||||
}
|
||||
}
|
||||
// Unknown text-only models stay refused.
|
||||
for _, m := range []string{"gpt-oss:120b", "kimi-for-coding"} {
|
||||
if SupportsVision(reg, m) {
|
||||
t.Errorf("%q should NOT be vision", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
-33
@@ -12,8 +12,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// secretBytes is the AES-256 key length kept in the per-user secret file.
|
||||
const secretBytes = 32
|
||||
const (
|
||||
// secretBytes is the AES-256 key length kept in the per-user secret file.
|
||||
secretBytes = 32
|
||||
|
||||
secretRetryAttempts = 500
|
||||
secretRetryDelay = 2 * time.Millisecond
|
||||
)
|
||||
|
||||
// aesGCMCrypter encrypts the token file at rest with AES-256-GCM under a
|
||||
// per-user random secret persisted (0600) beside the token file. The on-disk
|
||||
@@ -78,9 +83,9 @@ func (c *aesGCMCrypter) open(blob []byte) ([]byte, error) {
|
||||
|
||||
// loadOrCreateSecret reads the 32-byte secret at path. When create is set and
|
||||
// the file is absent, it generates a random secret and creates the file
|
||||
// exclusively (0600). A wrong-sized existing secret fails closed (corruption).
|
||||
// atomically (0600). A wrong-sized existing secret fails closed (corruption).
|
||||
func loadOrCreateSecret(path string, create bool) ([]byte, error) {
|
||||
if data, err := readSecretFile(path); err == nil {
|
||||
if data, err := readSecretFileRetry(path); err == nil {
|
||||
return data, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
@@ -88,48 +93,85 @@ func loadOrCreateSecret(path string, create bool) ([]byte, error) {
|
||||
if !create {
|
||||
return nil, fmt.Errorf("oauth: token secret %s is missing; cannot decrypt the token file", path)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return createSecretFile(path)
|
||||
}
|
||||
|
||||
func createSecretFile(path string) ([]byte, error) {
|
||||
lockPath := path + ".lock"
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
|
||||
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
_ = lock.Close()
|
||||
defer os.Remove(lockPath)
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
return data, nil
|
||||
} else if !errors.Is(rerr, os.ErrNotExist) {
|
||||
return nil, rerr
|
||||
}
|
||||
return writeNewSecretFile(path)
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
|
||||
}
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
return data, nil
|
||||
} else {
|
||||
lastErr = rerr
|
||||
}
|
||||
time.Sleep(secretRetryDelay)
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret lock %s", lockPath)
|
||||
}
|
||||
|
||||
func writeNewSecretFile(path string) ([]byte, error) {
|
||||
secret := make([]byte, secretBytes)
|
||||
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
|
||||
return nil, fmt.Errorf("oauth: generate token secret: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Create exclusively: an os.Rename publish would clobber on POSIX, so two
|
||||
// concurrent first-run processes could each generate a secret and the loser
|
||||
// would silently orphan the tokens it encrypts. O_EXCL lets exactly one
|
||||
// process win; everyone else adopts the winner's on-disk secret.
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
// The winner may not have finished writing yet, so a read here can see a
|
||||
// short/absent file. Retry briefly so concurrent first-run invocations
|
||||
// converge on the winner's secret instead of failing transiently.
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
secret, rerr := readSecretFile(path)
|
||||
if rerr == nil {
|
||||
return secret, nil
|
||||
}
|
||||
lastErr = rerr
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: create token secret: %w", err)
|
||||
return nil, fmt.Errorf("oauth: create token secret temp file: %w", err)
|
||||
}
|
||||
if _, werr := f.Write(secret); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path)
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return nil, fmt.Errorf("oauth: chmod token secret temp file: %w", err)
|
||||
}
|
||||
if _, werr := tmp.Write(secret); werr != nil {
|
||||
_ = tmp.Close()
|
||||
return nil, fmt.Errorf("oauth: write token secret: %w", werr)
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = os.Remove(path)
|
||||
if cerr := tmp.Close(); cerr != nil {
|
||||
return nil, fmt.Errorf("oauth: write token secret: %w", cerr)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return nil, fmt.Errorf("oauth: publish token secret: %w", err)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func readSecretFileRetry(path string) ([]byte, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
|
||||
data, err := readSecretFile(path)
|
||||
if !isTransientSecretAccessError(err) {
|
||||
return data, err
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(secretRetryDelay)
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
|
||||
}
|
||||
|
||||
// readSecretFile reads and validates the secret at path, returning a wrapped
|
||||
// os.ErrNotExist when it is absent so callers can branch on creation.
|
||||
func readSecretFile(path string) ([]byte, error) {
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestLoadOrCreateSecretConcurrentConverges(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
// Exactly one creator wins; every racer must converge on the same on-disk
|
||||
// secret rather than orphaning its own (the O_EXCL + bounded-retry path).
|
||||
// secret rather than reading a half-published file or orphaning its own.
|
||||
for i := 0; i < n; i++ {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("goroutine %d: %v", i, errs[i])
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package oauth
|
||||
|
||||
func isTransientSecretAccessError(error) bool {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build windows
|
||||
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func isTransientSecretAccessError(err error) bool {
|
||||
return errors.Is(err, windows.ERROR_SHARING_VIOLATION) ||
|
||||
errors.Is(err, windows.ERROR_LOCK_VIOLATION)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package providermodelcatalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
func TestLooksLikeCodingModelID(t *testing.T) {
|
||||
coding := []string{"gpt-4o", "claude-sonnet-4.5", "o1", "o3-mini", "o4-mini", "qwen2.5-coder", "deepseek-chat", "kimi-k2", "codestral-latest", "grok-code"}
|
||||
for _, id := range coding {
|
||||
if !LooksLikeCodingModelID(id) {
|
||||
t.Errorf("LooksLikeCodingModelID(%q) = false, want true", id)
|
||||
}
|
||||
}
|
||||
nonCoding := []string{"", " ", "whisper-1", "dall-e-3", "tts-1", "text-embedding-3-large", "omni-moderation-latest", "sora", "random-widget-7"}
|
||||
for _, id := range nonCoding {
|
||||
if LooksLikeCodingModelID(id) {
|
||||
t.Errorf("LooksLikeCodingModelID(%q) = true, want false", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelMatchesProvider(t *testing.T) {
|
||||
cases := []struct {
|
||||
transport providercatalog.Transport
|
||||
provider modelregistry.ProviderKind
|
||||
want bool
|
||||
}{
|
||||
{providercatalog.TransportOpenAI, modelregistry.ProviderOpenAI, true},
|
||||
{providercatalog.TransportOpenAI, modelregistry.ProviderAnthropic, false},
|
||||
{providercatalog.TransportAnthropic, modelregistry.ProviderAnthropic, true},
|
||||
{providercatalog.TransportAnthropicCompatible, modelregistry.ProviderAnthropic, true},
|
||||
{providercatalog.TransportGoogle, modelregistry.ProviderGoogle, true},
|
||||
{providercatalog.TransportGoogle, modelregistry.ProviderOpenAI, false},
|
||||
{providercatalog.TransportOpenAICompatible, modelregistry.ProviderOpenAI, false}, // default branch: no match
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := modelMatchesProvider(modelregistry.ModelEntry{Provider: tc.provider}, providercatalog.Descriptor{Transport: tc.transport})
|
||||
if got != tc.want {
|
||||
t.Errorf("modelMatchesProvider(provider=%q, transport=%q) = %v, want %v", tc.provider, tc.transport, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultedOpenGatewayURL(t *testing.T) {
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{}, " https://x/models.json "); got != "https://x/models.json" {
|
||||
t.Fatalf("explicit override = %q, want trimmed override", got)
|
||||
}
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://gw.example.com/v1"}, ""); got != "https://gw.example.com/zero/models.json" {
|
||||
t.Fatalf("derived = %q", got)
|
||||
}
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://gateway.gitlawb.com/zero/models.json" {
|
||||
t.Fatalf("fallback = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelSortLabelAndSortModels(t *testing.T) {
|
||||
if got := modelSortLabel(Model{Description: " GPT-4o "}); got != "gpt-4o" {
|
||||
t.Fatalf("label from description = %q", got)
|
||||
}
|
||||
if got := modelSortLabel(Model{ID: "Llama-3"}); got != "llama-3" {
|
||||
t.Fatalf("label from id = %q", got)
|
||||
}
|
||||
models := []Model{{ID: "b", Description: "Zeta"}, {ID: "a", Description: "Alpha"}}
|
||||
sortModels(models)
|
||||
if models[0].Description != "Alpha" {
|
||||
t.Fatalf("sortModels did not order by label: %#v", models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchJSON(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Accept") != "application/json" {
|
||||
t.Errorf("Accept header = %q, want application/json", r.Header.Get("Accept"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
body, err := fetchJSON(context.Background(), srv.URL, srv.Client())
|
||||
if err != nil || string(body) != `{"ok":true}` {
|
||||
t.Fatalf("fetchJSON = %q, %v", string(body), err)
|
||||
}
|
||||
if _, err := fetchJSON(context.Background(), " ", srv.Client()); err == nil {
|
||||
t.Fatal("expected an error for an empty endpoint")
|
||||
}
|
||||
|
||||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusInternalServerError)
|
||||
}))
|
||||
defer bad.Close()
|
||||
if _, err := fetchJSON(context.Background(), bad.URL, bad.Client()); err == nil {
|
||||
t.Fatal("expected an error for a 500 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchModelsDevAndOpenGatewayOverHTTP(t *testing.T) {
|
||||
modelsDev := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"openai":{"models":{"gpt-4o":{"id":"gpt-4o"}}}}`))
|
||||
}))
|
||||
defer modelsDev.Close()
|
||||
models, err := FetchModelsDev(context.Background(), "openai", FetchOptions{HTTPClient: modelsDev.Client(), ModelsDevURL: modelsDev.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchModelsDev error: %v", err)
|
||||
}
|
||||
if !containsModelID(models, "gpt-4o") {
|
||||
t.Fatalf("FetchModelsDev models = %#v, want gpt-4o", models)
|
||||
}
|
||||
|
||||
gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"models":[{"id":"claude-coder"}]}`))
|
||||
}))
|
||||
defer gateway.Close()
|
||||
models, err = FetchOpenGateway(context.Background(), gateway.URL, FetchOptions{HTTPClient: gateway.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchOpenGateway error: %v", err)
|
||||
}
|
||||
if !containsModelID(models, "claude-coder") {
|
||||
t.Fatalf("FetchOpenGateway models = %#v, want claude-coder", models)
|
||||
}
|
||||
|
||||
// FetchRemote routes the opengateway provider to FetchOpenGateway via the override URL.
|
||||
routed, err := FetchRemote(context.Background(), providercatalog.Descriptor{ID: "gitlawb-opengateway"}, FetchOptions{HTTPClient: gateway.Client(), OpenGatewayURL: gateway.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRemote error: %v", err)
|
||||
}
|
||||
if !containsModelID(routed, "claude-coder") {
|
||||
t.Fatalf("FetchRemote models = %#v, want claude-coder", routed)
|
||||
}
|
||||
}
|
||||
|
||||
func containsModelID(models []Model, id string) bool {
|
||||
for _, model := range models {
|
||||
if model.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -163,6 +163,22 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
}
|
||||
}, 0)
|
||||
if err != nil {
|
||||
// A caller-driven cancel/deadline (parent ctx done) surfaces here as a
|
||||
// transport error whose string can contain "context deadline exceeded" plus
|
||||
// the request host — which UpstreamUnreachable would mislabel as an upstream
|
||||
// outage. Check the parent context first and surface its error verbatim, so
|
||||
// only genuine connect failures (ctx still live) get humanized.
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + ctxErr.Error())})
|
||||
return
|
||||
}
|
||||
// A direct connection that never completes (e.g. a hosted endpoint blocked
|
||||
// by the local network) surfaces as a transport error; humanize it the same
|
||||
// way as a proxy's gateway error so the user sees a clear connectivity cause.
|
||||
if humanized, ok := providerio.UpstreamUnreachable(err.Error()); ok {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact(humanized)})
|
||||
return
|
||||
}
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
}
|
||||
@@ -304,6 +320,13 @@ func (provider *Provider) emitHTTPError(ctx context.Context, response *http.Resp
|
||||
if message == "" {
|
||||
message = response.Status
|
||||
}
|
||||
// A local proxy (e.g. an Ollama daemon serving a "-cloud" model) answers on
|
||||
// localhost but returns a gateway error when it cannot reach its own backend.
|
||||
// Surface that as a clear connectivity message instead of the raw proxied body.
|
||||
if humanized, ok := providerio.UpstreamUnreachable(message); ok {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact(humanized)})
|
||||
return
|
||||
}
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.classifiedError(response.StatusCode, message),
|
||||
|
||||
@@ -375,6 +375,33 @@ func TestStreamCompletionClassifiesHTTPErrorsAndRedactsToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionHumanizesUpstreamUnreachableGatewayError(t *testing.T) {
|
||||
// A local Ollama daemon serving a "-cloud" model answers on localhost but
|
||||
// returns HTTP 502 with an opaque proxied transport error when it cannot reach
|
||||
// its cloud backend. The adapter must surface a clear connectivity message
|
||||
// naming the host, not the raw proxied JSON body.
|
||||
provider := newTestProviderWithKey(t, "sk-secret", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"Post \"https://ollama.com:443/v1/chat/completions?ts=1\": net/http: TLS handshake timeout"}`, http.StatusBadGateway)
|
||||
})
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned setup error: %v", err)
|
||||
}
|
||||
events := readAll(stream)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want one error", events)
|
||||
}
|
||||
got := events[0].Error
|
||||
if !strings.HasPrefix(got, "upstream unreachable: ") {
|
||||
t.Fatalf("error = %q, want upstream-unreachable prefix", got)
|
||||
}
|
||||
for _, want := range []string{"ollama.com:443", "TLS handshake timeout"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("error = %q, want it to contain %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
|
||||
provider := newTestProviderWithKey(t, "sk-secret", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"error":{"message":"stream failed sk-secret","type":"server_error"}}`)
|
||||
@@ -432,6 +459,36 @@ func TestStreamCompletionEmitsErrorWhenContextCancels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A parent-context deadline surfaces as a transport error whose string contains
|
||||
// "context deadline exceeded" plus the request host. That must be reported as a
|
||||
// caller timeout, NOT humanized into an "upstream unreachable" outage (the host
|
||||
// is reachable; the caller's clock ran out).
|
||||
func TestStreamCompletionContextDeadlineNotHumanizedAsUpstream(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
<-release // hold the request open until the parent deadline fires
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
stream, err := provider.StreamCompletion(ctx, zeroruntime.CompletionRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned setup error: %v", err)
|
||||
}
|
||||
events := readAll(stream)
|
||||
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
|
||||
t.Fatalf("events = %#v, want one error", events)
|
||||
}
|
||||
got := events[0].Error
|
||||
if strings.Contains(got, "upstream unreachable") {
|
||||
t.Fatalf("parent-context deadline mislabeled as an upstream outage: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "context deadline exceeded") {
|
||||
t.Fatalf("error = %q, want the context deadline surfaced verbatim", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionFlushesBufferedContentWhenContextCancels(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
provider := newTestProviderWithThinkTags(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -248,6 +248,76 @@ func Redact(message string, secrets ...string) string {
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
// upstreamFailureMarkers are transport-level failures that mean a request never
|
||||
// reached the model: the server the client connected to could not establish a
|
||||
// connection to its upstream. They distinguish a connectivity problem (outside
|
||||
// the agent's control) from the model rejecting the request.
|
||||
var upstreamFailureMarkers = []string{
|
||||
"TLS handshake timeout",
|
||||
"context deadline exceeded",
|
||||
"connection refused",
|
||||
"no such host",
|
||||
"network is unreachable",
|
||||
"i/o timeout",
|
||||
}
|
||||
|
||||
// UpstreamUnreachable detects a provider error that is really a connectivity
|
||||
// failure to an upstream host rather than a model/request error, and rewrites it
|
||||
// into a clear, actionable message. The common case is a local Ollama daemon
|
||||
// serving a "-cloud" model: it answers on localhost but returns HTTP 502 because
|
||||
// it cannot reach its own cloud backend, surfacing an opaque proxied string like
|
||||
// `Post "https://ollama.com:443/...": net/http: TLS handshake timeout`. It
|
||||
// matches only when both a transport failure marker and a concrete host are
|
||||
// present, so the agent's own request-deadline cancellations are left untouched.
|
||||
// Non-matching messages are returned unchanged with false.
|
||||
func UpstreamUnreachable(message string) (string, bool) {
|
||||
reason := ""
|
||||
for _, marker := range upstreamFailureMarkers {
|
||||
if strings.Contains(message, marker) {
|
||||
reason = marker
|
||||
break
|
||||
}
|
||||
}
|
||||
host := upstreamHost(message)
|
||||
if reason == "" || host == "" {
|
||||
return message, false
|
||||
}
|
||||
|
||||
return "upstream unreachable: the model server could not connect to " + host +
|
||||
" (" + reason + "). The request never reached the model — this is a network failure " +
|
||||
"between the model server and its upstream, not a model error. Verify the host is " +
|
||||
"reachable from the machine running the model server (DNS/proxy/VPN/firewall); a local " +
|
||||
"daemon proxying a cloud model — e.g. an Ollama daemon serving a \"-cloud\" model — must " +
|
||||
"itself be able to reach the internet.", true
|
||||
}
|
||||
|
||||
// upstreamHost extracts the unreachable host from a Go transport error. It
|
||||
// handles the two shapes these errors take: a quoted request URL
|
||||
// (`... "https://host:port/path": ...`) and a raw dial target
|
||||
// (`dial tcp host:port: ...`). The URL form is preferred when both are present.
|
||||
// It returns "" when neither yields a host.
|
||||
func upstreamHost(message string) string {
|
||||
if index := strings.Index(message, "\"http"); index >= 0 {
|
||||
rest := message[index+1:]
|
||||
if end := strings.IndexByte(rest, '"'); end >= 0 {
|
||||
if parsed, err := url.Parse(rest[:end]); err == nil && parsed.Host != "" {
|
||||
return parsed.Host
|
||||
}
|
||||
}
|
||||
}
|
||||
const dialPrefix = "dial tcp "
|
||||
if index := strings.Index(message, dialPrefix); index >= 0 {
|
||||
rest := message[index+len(dialPrefix):]
|
||||
if end := strings.Index(rest, ": "); end >= 0 {
|
||||
rest = rest[:end]
|
||||
}
|
||||
if host := strings.TrimSpace(rest); host != "" && !strings.Contains(host, " ") {
|
||||
return host
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PositiveOrDefault validates optional max token settings.
|
||||
func PositiveOrDefault(value int, fallback int, label string) (int, error) {
|
||||
if value == 0 {
|
||||
|
||||
@@ -119,3 +119,85 @@ func TestScanSSEDataWithContextDeliversThenEOF(t *testing.T) {
|
||||
t.Fatalf("got %#v, want one accumulated payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
// UpstreamUnreachable rewrites a transport/gateway connectivity failure into a
|
||||
// clear message naming the host, and leaves genuine model errors (and bare
|
||||
// markers with no host) untouched.
|
||||
func TestUpstreamUnreachable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
message string
|
||||
wantMatch bool
|
||||
wantHost string
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "ollama daemon 502 cloud proxy",
|
||||
message: `Post "https://ollama.com:443/v1/chat/completions?ts=1781690613": net/http: TLS handshake timeout`,
|
||||
wantMatch: true,
|
||||
wantHost: "ollama.com:443",
|
||||
wantReason: "TLS handshake timeout",
|
||||
},
|
||||
{
|
||||
name: "direct connection tls timeout",
|
||||
message: `Post "https://ollama.com/v1/chat/completions": net/http: TLS handshake timeout`,
|
||||
wantMatch: true,
|
||||
wantHost: "ollama.com",
|
||||
wantReason: "TLS handshake timeout",
|
||||
},
|
||||
{
|
||||
name: "url form preferred over dial target",
|
||||
message: `Get "https://api.example.com/v1/models": dial tcp 203.0.113.7:443: i/o timeout`,
|
||||
wantMatch: true,
|
||||
wantHost: "api.example.com",
|
||||
wantReason: "i/o timeout",
|
||||
},
|
||||
{
|
||||
name: "dns lookup failure keeps url host",
|
||||
message: `Post "https://ollama.com/api/chat": dial tcp: lookup ollama.com on 8.8.8.8:53: no such host`,
|
||||
wantMatch: true,
|
||||
wantHost: "ollama.com",
|
||||
wantReason: "no such host",
|
||||
},
|
||||
{
|
||||
name: "local daemon not running",
|
||||
message: `dial tcp 127.0.0.1:11434: connect: connection refused`,
|
||||
wantMatch: true,
|
||||
wantHost: "127.0.0.1:11434",
|
||||
wantReason: "connection refused",
|
||||
},
|
||||
{
|
||||
name: "genuine model error untouched",
|
||||
message: `{"error":{"message":"model not found"}}`,
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "marker without host untouched",
|
||||
message: `context deadline exceeded`,
|
||||
wantMatch: false,
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got, ok := UpstreamUnreachable(testCase.message)
|
||||
if ok != testCase.wantMatch {
|
||||
t.Fatalf("match = %v, want %v (got %q)", ok, testCase.wantMatch, got)
|
||||
}
|
||||
if !testCase.wantMatch {
|
||||
if got != testCase.message {
|
||||
t.Fatalf("non-match must return input unchanged, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(got, "upstream unreachable: ") {
|
||||
t.Errorf("missing prefix in %q", got)
|
||||
}
|
||||
if !strings.Contains(got, testCase.wantHost) {
|
||||
t.Errorf("missing host %q in %q", testCase.wantHost, got)
|
||||
}
|
||||
if !strings.Contains(got, testCase.wantReason) {
|
||||
t.Errorf("missing reason %q in %q", testCase.wantReason, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package redaction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsSensitiveKey_CompoundAndTokenCounts(t *testing.T) {
|
||||
o := Options{}
|
||||
sensitive := []string{
|
||||
"db_password", "smtp_password", "user_password",
|
||||
"session_secret", "webhook_secret", "stripe_secret_key", "client-secret",
|
||||
"auth_token", "csrf_token", "vault_token", "my_token", "xsrf-token",
|
||||
"ssh_private_key", "rsa_private_key",
|
||||
"backup_api_key", "my_apikey", "service_apikey",
|
||||
"db_passphrase", "aws_credential", "gcp_credentials",
|
||||
}
|
||||
for _, k := range sensitive {
|
||||
if !IsSensitiveKey(k, o) {
|
||||
t.Errorf("expected %q to be sensitive", k)
|
||||
}
|
||||
}
|
||||
// CRITICAL: token-count and ordinary "*_key" fields must NOT be redacted —
|
||||
// they are everywhere in an LLM agent and redacting them would break it.
|
||||
notSensitive := []string{
|
||||
"max_tokens", "prompt_tokens", "completion_tokens", "total_tokens",
|
||||
"input_tokens", "output_tokens", "reasoning_tokens", "tokens",
|
||||
"token_count", "token_usage", "tokens_used",
|
||||
"primary_key", "public_key", "cache_key", "foreign_key", "sort_key",
|
||||
"idempotency_key", "key", "key_name", "api_version", "username", "message",
|
||||
}
|
||||
for _, k := range notSensitive {
|
||||
if IsSensitiveKey(k, o) {
|
||||
t.Errorf("expected %q to NOT be sensitive (false positive)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactString_CompoundKeyForms(t *testing.T) {
|
||||
o := Options{}
|
||||
cases := []struct{ in, secret string }{
|
||||
{"db_password=hunter2supersecret", "hunter2supersecret"},
|
||||
{`{"stripe_secret_key": "rk_live_plaintextvalue"}`, "rk_live_plaintextvalue"},
|
||||
{"GET /x?session_secret=plainsecretvalue HTTP/1.1", "plainsecretvalue"},
|
||||
{"auth_token='opaque-assigned-token-value'", "opaque-assigned-token-value"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
out := RedactString(c.in, o)
|
||||
if strings.Contains(out, c.secret) {
|
||||
t.Errorf("RedactString(%q) leaked %q: got %q", c.in, c.secret, out)
|
||||
}
|
||||
if !strings.Contains(out, RedactedSecret) {
|
||||
t.Errorf("RedactString(%q) should contain %q: got %q", c.in, RedactedSecret, out)
|
||||
}
|
||||
}
|
||||
// Token-count lines must pass through untouched.
|
||||
for _, c := range []string{"max_tokens: 4096", "prompt_tokens=128", "token_count: 50"} {
|
||||
if out := RedactString(c, o); out != c {
|
||||
t.Errorf("RedactString(%q) should be unchanged, got %q", c, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactString_AuthHeaderSchemes(t *testing.T) {
|
||||
o := Options{}
|
||||
// Opaque values (no token-format prefix) so only the header/colon logic can
|
||||
// redact them — isolates the auth-scheme fix from the text-pattern fallback.
|
||||
cases := []struct{ in, secret string }{
|
||||
{"Authorization: Bearer opaquebearervalue1234567", "opaquebearervalue1234567"},
|
||||
{"Authorization: token opaquetokenvalue1234567", "opaquetokenvalue1234567"},
|
||||
{"Authorization: ApiKey opaqueapikeyvalue1234567", "opaqueapikeyvalue1234567"},
|
||||
{"Proxy-Authorization: Digest opaquedigestvalue1234", "opaquedigestvalue1234"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
out := RedactString(c.in, o)
|
||||
if strings.Contains(out, c.secret) {
|
||||
t.Errorf("RedactString(%q) leaked %q: got %q", c.in, c.secret, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parameterized schemes spread the secret across comma-separated params, so the
|
||||
// WHOLE value after the scheme must be redacted — not just the first token.
|
||||
func TestRedactString_MultiPartAuthSchemes(t *testing.T) {
|
||||
o := Options{}
|
||||
cases := []struct {
|
||||
in string
|
||||
leaked []string // every one of these must be gone
|
||||
keepsch string // scheme name that should remain visible
|
||||
}{
|
||||
{
|
||||
in: `Authorization: Digest username="alice", realm="api", nonce="abc123", uri="/x", response="deadbeefcafef00ddeadbeefcafef00d", qop=auth`,
|
||||
leaked: []string{"deadbeefcafef00ddeadbeefcafef00d", `nonce="abc123"`, `username="alice"`},
|
||||
keepsch: "Digest",
|
||||
},
|
||||
{
|
||||
in: `Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260618/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024`,
|
||||
leaked: []string{"fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024", "SignedHeaders=host;x-amz-date", "AKIAIOSFODNN7EXAMPLE"},
|
||||
keepsch: "AWS4-HMAC-SHA256",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
out := RedactString(c.in, o)
|
||||
for _, secret := range c.leaked {
|
||||
if strings.Contains(out, secret) {
|
||||
t.Errorf("multi-part header leaked %q\n in: %s\n out: %s", secret, c.in, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, c.keepsch) {
|
||||
t.Errorf("scheme %q should remain visible, got: %s", c.keepsch, out)
|
||||
}
|
||||
if !strings.Contains(out, RedactedSecret) {
|
||||
t.Errorf("expected a redaction marker, got: %s", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against re-introducing an over-broad "key: value" pattern: a
|
||||
// "file.go:line: message" line (universal in test/compiler output, where the
|
||||
// filename may even contain a secret-looking segment) must keep its structure so
|
||||
// downstream parsers still work. Only an actual secret VALUE on the line is
|
||||
// redacted (by the token-format patterns), never the file:line prefix.
|
||||
func TestRedactString_PreservesFileLineColons(t *testing.T) {
|
||||
o := Options{}
|
||||
in := " secret_test.go:12: token sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
out := RedactString(in, o)
|
||||
if !strings.Contains(out, "secret_test.go:12:") {
|
||||
t.Errorf("file:line prefix mangled: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "sk-proj-abcdefghijklmnopqrstuvwxyz") {
|
||||
t.Errorf("secret leaked: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, RedactedSecret) {
|
||||
t.Errorf("expected the token to be redacted: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactValue_CompoundKeys(t *testing.T) {
|
||||
o := Options{}
|
||||
in := map[string]any{
|
||||
"db_password": "plainpw",
|
||||
"max_tokens": 4096,
|
||||
"nested": map[string]any{"session_secret": "innersecretvalue"},
|
||||
}
|
||||
out, ok := RedactValue(in, o).(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map result, got %T", RedactValue(in, o))
|
||||
}
|
||||
if out["db_password"] != RedactedSecret {
|
||||
t.Errorf("db_password not redacted: %v", out["db_password"])
|
||||
}
|
||||
if out["max_tokens"] != int64(4096) {
|
||||
t.Errorf("max_tokens should be preserved as 4096, got %v (%T)", out["max_tokens"], out["max_tokens"])
|
||||
}
|
||||
inner := out["nested"].(map[string]any)
|
||||
if inner["session_secret"] != RedactedSecret {
|
||||
t.Errorf("nested session_secret not redacted: %v", inner["session_secret"])
|
||||
}
|
||||
}
|
||||
@@ -83,9 +83,14 @@ var (
|
||||
privateKeyPattern = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`)
|
||||
jsonStringPattern = regexp.MustCompile(`("([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*)"([^"\\]*(?:\\.[^"\\]*)*)"`)
|
||||
assignPattern = regexp.MustCompile(`\b([A-Za-z_][A-Za-z0-9_.-]*)(\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s&]+))`)
|
||||
headerPattern = regexp.MustCompile(`(?i)\b(authorization|proxy-authorization)\s*:\s*(bearer|basic)\s+([^\s,;]+)`)
|
||||
secretHeader = regexp.MustCompile(`(?i)\b(x-api-key|api-key|cookie|set-cookie)\s*:\s*([^\r\n]+)`)
|
||||
queryPattern = regexp.MustCompile(`([?&])([^=&#\s]+)=([^&#\s]+)`)
|
||||
// Redact the ENTIRE credential after the scheme (to end of line), not just the
|
||||
// first token: parameterized schemes (Digest, OAuth, AWS4-HMAC-SHA256) spread
|
||||
// the secret across comma-separated params (…, response=…, Signature=…), so a
|
||||
// single-token capture would leave the actual secret visible. The scheme name
|
||||
// is kept; only the value is replaced.
|
||||
headerPattern = regexp.MustCompile(`(?i)\b(authorization|proxy-authorization)\s*:\s*(bearer|basic|token|apikey|api-key|digest|negotiate|oauth|aws4-hmac-sha256)\s+([^\r\n]+)`)
|
||||
secretHeader = regexp.MustCompile(`(?i)\b(x-api-key|api-key|cookie|set-cookie)\s*:\s*([^\r\n]+)`)
|
||||
queryPattern = regexp.MustCompile(`([?&])([^=&#\s]+)=([^&#\s]+)`)
|
||||
)
|
||||
|
||||
func IsSensitiveKey(key string, options Options) bool {
|
||||
@@ -101,6 +106,49 @@ func IsSensitiveKey(key string, options Options) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return keyLooksSensitive(normalized)
|
||||
}
|
||||
|
||||
// secretKeySegments are "_"-delimited segments that mark the whole key sensitive
|
||||
// even when the full name isn't in the exact list, so compound names like
|
||||
// db_password, session_secret, and stripe_secret_key are caught. "token" is
|
||||
// handled separately (suffix-only, in keyLooksSensitive) so the agent's many
|
||||
// token-COUNT fields (max_tokens, prompt_tokens, token_count) are never redacted.
|
||||
var secretKeySegments = map[string]struct{}{
|
||||
"password": {},
|
||||
"passwd": {},
|
||||
"passphrase": {},
|
||||
"secret": {},
|
||||
"credential": {},
|
||||
"credentials": {},
|
||||
"apikey": {},
|
||||
}
|
||||
|
||||
// keyLooksSensitive applies conservative structural heuristics to a normalized
|
||||
// key (already lower-cased and "_"-delimited by normalizeKey). It is deliberately
|
||||
// narrow: a bare "token"/"key" segment is NOT enough, so token-count and ordinary
|
||||
// "*_key" fields (max_tokens, primary_key, public_key) stay un-redacted.
|
||||
func keyLooksSensitive(normalized string) bool {
|
||||
segments := strings.Split(normalized, "_")
|
||||
for i, seg := range segments {
|
||||
if _, ok := secretKeySegments[seg]; ok {
|
||||
return true
|
||||
}
|
||||
// "<x>_token" (singular, trailing) is a credential — auth_token, csrf_token,
|
||||
// vault_token. NOT "tokens" (plural count) and NOT "token_<x>" (token_count,
|
||||
// token_usage), where token is pluralized or not the trailing segment.
|
||||
if seg == "token" && i > 0 && i == len(segments)-1 {
|
||||
return true
|
||||
}
|
||||
// "api_key" / "private_key" as adjacent segments. A bare "key" stays
|
||||
// non-sensitive (primary_key, public_key, cache_key, foreign_key, …).
|
||||
if seg == "key" && i > 0 {
|
||||
switch segments[i-1] {
|
||||
case "api", "private":
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+107
-4
@@ -202,6 +202,19 @@ func Package(ctx context.Context, options PackageOptions) (PackageResult, error)
|
||||
archivePath := filepath.Join(releaseDir, archiveName)
|
||||
artifactPath := filepath.Join(rootDir, ZeroArtifactName(goos))
|
||||
stagedBinaryPath := filepath.Join(stagingDir, ZeroArtifactName(goos))
|
||||
helperArtifacts := map[string]string{}
|
||||
if goos == "linux" {
|
||||
helperPath := filepath.Join(rootDir, LinuxSandboxHelperArtifactName(goos))
|
||||
helperArtifacts[LinuxSandboxHelperArtifactName(goos)] = helperPath
|
||||
seccompPath := filepath.Join(rootDir, LinuxSeccompHelperArtifactName(goos))
|
||||
helperArtifacts[LinuxSeccompHelperArtifactName(goos)] = seccompPath
|
||||
}
|
||||
if goos == "windows" {
|
||||
runnerPath := filepath.Join(rootDir, WindowsSandboxCommandRunnerArtifactName(goos))
|
||||
helperArtifacts[WindowsSandboxCommandRunnerArtifactName(goos)] = runnerPath
|
||||
setupPath := filepath.Join(rootDir, WindowsSandboxSetupArtifactName(goos))
|
||||
helperArtifacts[WindowsSandboxSetupArtifactName(goos)] = setupPath
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(releaseDir); err != nil {
|
||||
return PackageResult{}, fmt.Errorf("clean release dir: %w", err)
|
||||
@@ -219,10 +232,15 @@ func Package(ctx context.Context, options PackageOptions) (PackageResult, error)
|
||||
if err := buildZero(ctx, rootDir, artifactPath, version, goos, goarch); err != nil {
|
||||
return PackageResult{}, err
|
||||
}
|
||||
for name, path := range helperArtifacts {
|
||||
if err := buildReleaseHelper(ctx, rootDir, path, goos, goarch, name); err != nil {
|
||||
return PackageResult{}, fmt.Errorf("build %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
if err := smokeVersion(ctx, artifactPath, version); err != nil {
|
||||
return PackageResult{}, err
|
||||
}
|
||||
if err := copyPackageFiles(rootDir, stagingDir, artifactPath, stagedBinaryPath, goos, version); err != nil {
|
||||
if err := copyPackageFiles(rootDir, stagingDir, artifactPath, stagedBinaryPath, goos, version, helperArtifacts); err != nil {
|
||||
return PackageResult{}, err
|
||||
}
|
||||
if err := createArchive(stagingDir, archivePath, goos); err != nil {
|
||||
@@ -250,6 +268,34 @@ func ZeroArtifactName(goos string) string {
|
||||
return "zero"
|
||||
}
|
||||
|
||||
func LinuxSandboxHelperArtifactName(goos string) string {
|
||||
if goos == "windows" {
|
||||
return "zero-linux-sandbox.exe"
|
||||
}
|
||||
return "zero-linux-sandbox"
|
||||
}
|
||||
|
||||
func LinuxSeccompHelperArtifactName(goos string) string {
|
||||
if goos == "windows" {
|
||||
return "zero-seccomp.exe"
|
||||
}
|
||||
return "zero-seccomp"
|
||||
}
|
||||
|
||||
func WindowsSandboxCommandRunnerArtifactName(goos string) string {
|
||||
if goos == "windows" {
|
||||
return "zero-windows-command-runner.exe"
|
||||
}
|
||||
return "zero-windows-command-runner"
|
||||
}
|
||||
|
||||
func WindowsSandboxSetupArtifactName(goos string) string {
|
||||
if goos == "windows" {
|
||||
return "zero-windows-sandbox-setup.exe"
|
||||
}
|
||||
return "zero-windows-sandbox-setup"
|
||||
}
|
||||
|
||||
func DefaultBuildOutput(rootDir string, goos string) string {
|
||||
return filepath.Join(rootDir, ZeroArtifactName(goos))
|
||||
}
|
||||
@@ -550,7 +596,59 @@ func PackageVersion(rootDir string) (string, error) {
|
||||
}
|
||||
|
||||
func buildZero(ctx context.Context, rootDir string, output string, version string, goos string, goarch string) error {
|
||||
command := exec.CommandContext(ctx, "go", "build", "-trimpath", "-ldflags", BuildLdflags(version), "-o", output, "./cmd/zero")
|
||||
return buildGoPackage(ctx, rootDir, output, version, goos, goarch, "./cmd/zero")
|
||||
}
|
||||
|
||||
func buildLinuxSandboxHelper(ctx context.Context, rootDir string, output string, goos string, goarch string) error {
|
||||
if goos != "linux" {
|
||||
return nil
|
||||
}
|
||||
return buildGoPackage(ctx, rootDir, output, "", goos, goarch, "./cmd/zero-linux-sandbox")
|
||||
}
|
||||
|
||||
func buildLinuxSeccompHelper(ctx context.Context, rootDir string, output string, goos string, goarch string) error {
|
||||
if goos != "linux" {
|
||||
return nil
|
||||
}
|
||||
return buildGoPackage(ctx, rootDir, output, "", goos, goarch, "./cmd/zero-seccomp")
|
||||
}
|
||||
|
||||
func buildWindowsSandboxCommandRunner(ctx context.Context, rootDir string, output string, goos string, goarch string) error {
|
||||
if goos != "windows" {
|
||||
return nil
|
||||
}
|
||||
return buildGoPackage(ctx, rootDir, output, "", goos, goarch, "./cmd/zero-windows-command-runner")
|
||||
}
|
||||
|
||||
func buildWindowsSandboxSetup(ctx context.Context, rootDir string, output string, goos string, goarch string) error {
|
||||
if goos != "windows" {
|
||||
return nil
|
||||
}
|
||||
return buildGoPackage(ctx, rootDir, output, "", goos, goarch, "./cmd/zero-windows-sandbox-setup")
|
||||
}
|
||||
|
||||
func buildReleaseHelper(ctx context.Context, rootDir string, output string, goos string, goarch string, name string) error {
|
||||
switch name {
|
||||
case LinuxSandboxHelperArtifactName(goos):
|
||||
return buildLinuxSandboxHelper(ctx, rootDir, output, goos, goarch)
|
||||
case LinuxSeccompHelperArtifactName(goos):
|
||||
return buildLinuxSeccompHelper(ctx, rootDir, output, goos, goarch)
|
||||
case WindowsSandboxCommandRunnerArtifactName(goos):
|
||||
return buildWindowsSandboxCommandRunner(ctx, rootDir, output, goos, goarch)
|
||||
case WindowsSandboxSetupArtifactName(goos):
|
||||
return buildWindowsSandboxSetup(ctx, rootDir, output, goos, goarch)
|
||||
default:
|
||||
return fmt.Errorf("unknown release helper %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func buildGoPackage(ctx context.Context, rootDir string, output string, version string, goos string, goarch string, pkg string) error {
|
||||
args := []string{"build", "-trimpath"}
|
||||
if version != "" {
|
||||
args = append(args, "-ldflags", BuildLdflags(version))
|
||||
}
|
||||
args = append(args, "-o", output, pkg)
|
||||
command := exec.CommandContext(ctx, "go", args...)
|
||||
command.Dir = rootDir
|
||||
command.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS="+goos, "GOARCH="+goarch)
|
||||
outputBytes, err := command.CombinedOutput()
|
||||
@@ -559,7 +657,7 @@ func buildZero(ctx context.Context, rootDir string, output string, version strin
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return fmt.Errorf("build release binary: %s", message)
|
||||
return fmt.Errorf("build release binary %s: %s", filepath.Base(output), message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -581,7 +679,7 @@ func smokeVersion(ctx context.Context, binaryPath string, version string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyPackageFiles(rootDir string, stagingDir string, artifactPath string, stagedBinaryPath string, goos string, version string) error {
|
||||
func copyPackageFiles(rootDir string, stagingDir string, artifactPath string, stagedBinaryPath string, goos string, version string, helperArtifacts map[string]string) error {
|
||||
if err := copyFile(artifactPath, stagedBinaryPath, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -598,6 +696,11 @@ func copyPackageFiles(rootDir string, stagingDir string, artifactPath string, st
|
||||
if err := copyFile(filepath.Join(rootDir, "bin", "zero.js"), filepath.Join(stagingDir, "bin", "zero.js"), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, source := range helperArtifacts {
|
||||
if err := copyFile(source, filepath.Join(stagingDir, name), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stagingDir, "VERSION"), []byte(version+"\n"), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -207,6 +207,12 @@ func TestBuildHelpersMatchScriptContracts(t *testing.T) {
|
||||
if got := DefaultBuildOutput(root, "linux"); got != filepath.Join(root, "zero") {
|
||||
t.Fatalf("DefaultBuildOutput(linux) = %q", got)
|
||||
}
|
||||
if got := WindowsSandboxCommandRunnerArtifactName("windows"); got != "zero-windows-command-runner.exe" {
|
||||
t.Fatalf("WindowsSandboxCommandRunnerArtifactName(windows) = %q", got)
|
||||
}
|
||||
if got := WindowsSandboxSetupArtifactName("windows"); got != "zero-windows-sandbox-setup.exe" {
|
||||
t.Fatalf("WindowsSandboxSetupArtifactName(windows) = %q", got)
|
||||
}
|
||||
if got := BuildLdflags(version); !strings.Contains(got, "-X github.com/Gitlawb/zero/internal/cli.version=0.1.0") {
|
||||
t.Fatalf("BuildLdflags = %q", got)
|
||||
}
|
||||
@@ -351,6 +357,66 @@ func TestCreateArchivesWithRootPackageFiles(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCopyPackageFilesStagesLinuxSandboxHelper(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
staging := t.TempDir()
|
||||
artifact := filepath.Join(root, "zero")
|
||||
helper := filepath.Join(root, "zero-linux-sandbox")
|
||||
seccomp := filepath.Join(root, "zero-seccomp")
|
||||
for path, content := range map[string]string{
|
||||
artifact: "zero",
|
||||
helper: "helper",
|
||||
seccomp: "seccomp",
|
||||
filepath.Join(root, "README.md"): "readme",
|
||||
filepath.Join(root, "package.json"): `{"version":"0.1.0"}`,
|
||||
filepath.Join(root, "bin", "zero.js"): "wrapper",
|
||||
} {
|
||||
mustWriteFile(t, path, content)
|
||||
}
|
||||
if err := copyPackageFiles(root, staging, artifact, filepath.Join(staging, "zero"), "linux", "0.1.0", map[string]string{
|
||||
"zero-linux-sandbox": helper,
|
||||
"zero-seccomp": seccomp,
|
||||
}); err != nil {
|
||||
t.Fatalf("copyPackageFiles: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(staging, "zero-linux-sandbox")); err != nil {
|
||||
t.Fatalf("staged helper missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(staging, "zero-seccomp")); err != nil {
|
||||
t.Fatalf("staged seccomp compatibility helper missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyPackageFilesStagesWindowsSandboxHelpers(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
staging := t.TempDir()
|
||||
artifact := filepath.Join(root, "zero.exe")
|
||||
runner := filepath.Join(root, "zero-windows-command-runner.exe")
|
||||
setup := filepath.Join(root, "zero-windows-sandbox-setup.exe")
|
||||
for path, content := range map[string]string{
|
||||
artifact: "zero",
|
||||
runner: "runner",
|
||||
setup: "setup",
|
||||
filepath.Join(root, "README.md"): "readme",
|
||||
filepath.Join(root, "package.json"): `{"version":"0.1.0"}`,
|
||||
filepath.Join(root, "bin", "zero.js"): "wrapper",
|
||||
} {
|
||||
mustWriteFile(t, path, content)
|
||||
}
|
||||
if err := copyPackageFiles(root, staging, artifact, filepath.Join(staging, "zero.exe"), "windows", "0.1.0", map[string]string{
|
||||
"zero-windows-command-runner.exe": runner,
|
||||
"zero-windows-sandbox-setup.exe": setup,
|
||||
}); err != nil {
|
||||
t.Fatalf("copyPackageFiles: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(staging, "zero-windows-command-runner.exe")); err != nil {
|
||||
t.Fatalf("staged runner missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(staging, "zero-windows-sandbox-setup.exe")); err != nil {
|
||||
t.Fatalf("staged setup helper missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func packageStagingFixture(t *testing.T, binaryName string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
+139
-55
@@ -1,8 +1,7 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type BackendOptions struct {
|
||||
@@ -47,13 +46,20 @@ func (backend Backend) EnforcesScopedEgress() bool {
|
||||
}
|
||||
|
||||
type BackendPlan struct {
|
||||
Backend Backend `json:"backend"`
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
Policy Policy `json:"policy"`
|
||||
SupportLevel BackendSupportLevel `json:"supportLevel"`
|
||||
Capabilities []BackendCapability `json:"capabilities"`
|
||||
Restrictions []string `json:"restrictions"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Backend Backend `json:"backend"`
|
||||
TargetBackend BackendName `json:"targetBackend"`
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
Policy Policy `json:"policy"`
|
||||
PermissionProfile PermissionProfile `json:"permissionProfile"`
|
||||
CommandWrapped bool `json:"commandWrapped"`
|
||||
SandboxEnvMarkers []string `json:"sandboxEnvMarkers,omitempty"`
|
||||
EnforcementLevel EnforcementLevel `json:"enforcementLevel"`
|
||||
DowngradeReason string `json:"downgradeReason,omitempty"`
|
||||
SupportLevel BackendSupportLevel `json:"supportLevel"`
|
||||
RequiresPlatformSandbox bool `json:"requiresPlatformSandbox"`
|
||||
Capabilities []BackendCapability `json:"capabilities"`
|
||||
Restrictions []string `json:"restrictions"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type BackendCapability struct {
|
||||
@@ -63,35 +69,42 @@ type BackendCapability struct {
|
||||
}
|
||||
|
||||
func SelectBackend(options BackendOptions) Backend {
|
||||
goos := options.GOOS
|
||||
if goos == "" {
|
||||
goos = runtime.GOOS
|
||||
}
|
||||
lookup := options.LookupExecutable
|
||||
if lookup == nil {
|
||||
lookup = exec.LookPath
|
||||
}
|
||||
return NewSandboxManager(SandboxManagerOptions{
|
||||
GOOS: options.GOOS,
|
||||
LookupExecutable: options.LookupExecutable,
|
||||
}).Backend()
|
||||
}
|
||||
|
||||
func TargetBackendForPlatform(goos string, wsl bool) BackendName {
|
||||
switch goos {
|
||||
case "linux":
|
||||
if path, err := lookup("bwrap"); err == nil && path != "" {
|
||||
return nativeBackend(goos, BackendBubblewrap, path, "bubblewrap sandbox available")
|
||||
}
|
||||
// Under WSL (no bubblewrap), do NOT silently degrade to a plain policy-only
|
||||
// runner: select the WSL backend, which still routes egress through the
|
||||
// filtering proxy and is gated on AllowPolicyOnlyRunner in the runner.
|
||||
if info := detectWSL(); info.IsWSL {
|
||||
return wslBackend(goos, info)
|
||||
}
|
||||
return policyOnlyBackend(goos, "policy-only fallback: bubblewrap is not installed")
|
||||
case "darwin":
|
||||
if path, err := lookup("sandbox-exec"); err == nil && path != "" {
|
||||
return nativeBackend(goos, BackendSandboxExec, path, "sandbox-exec backend available")
|
||||
return BackendMacOSSeatbelt
|
||||
case "linux":
|
||||
if wsl {
|
||||
return BackendPolicyOnly
|
||||
}
|
||||
return policyOnlyBackend(goos, "policy-only fallback: sandbox-exec is not available")
|
||||
return BackendLinuxBwrap
|
||||
case "windows":
|
||||
return policyOnlyBackend(goos, "policy-only fallback: Windows native sandbox adapter is not implemented")
|
||||
return BackendWindowsRestrictedToken
|
||||
default:
|
||||
return policyOnlyBackend(goos, "policy-only fallback: no platform sandbox adapter is available for "+goos)
|
||||
return BackendPolicyOnly
|
||||
}
|
||||
}
|
||||
|
||||
func (backend Backend) TargetBackend() BackendName {
|
||||
if backend.Platform == "windows" {
|
||||
if backend.Name == BackendWindowsElevated || backend.Name == BackendWindowsRestrictedToken {
|
||||
return backend.Name
|
||||
}
|
||||
return BackendWindowsRestrictedToken
|
||||
}
|
||||
switch backend.Name {
|
||||
case BackendWSL:
|
||||
return BackendPolicyOnly
|
||||
case BackendNone, BackendMacOSSeatbelt, BackendLinuxBwrap, BackendLinuxLandlock, BackendWindowsRestrictedToken, BackendWindowsElevated, BackendPolicyOnly:
|
||||
return backend.Name
|
||||
default:
|
||||
return TargetBackendForPlatform(backend.Platform, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,11 +116,9 @@ func nativeBackend(goos string, name BackendName, executable string, message str
|
||||
Fallback: false,
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
// Only sandbox-exec can enforce scoped egress today; bubblewrap's isolated
|
||||
// network namespace has no bridge to the host filtering proxy.
|
||||
ScopedEgress: name == BackendSandboxExec,
|
||||
Executable: executable,
|
||||
Message: message,
|
||||
ScopedEgress: name == BackendMacOSSeatbelt,
|
||||
Executable: executable,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +155,24 @@ func policyOnlyBackend(goos string, message string) Backend {
|
||||
}
|
||||
|
||||
func (backend Backend) BuildPlan(workspaceRoot string, policy Policy) BackendPlan {
|
||||
effectivePolicy := policy
|
||||
if effectivePolicy.Mode == "" {
|
||||
effectivePolicy = DefaultPolicy()
|
||||
}
|
||||
profile := PermissionProfileFromPolicy(workspaceRoot, effectivePolicy, nil)
|
||||
execRequest, _ := NewSandboxManager(SandboxManagerOptions{
|
||||
GOOS: backend.Platform,
|
||||
Backend: backend,
|
||||
}).BuildExecutionRequest(SandboxManagerRequest{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: effectivePolicy,
|
||||
Profile: profile,
|
||||
Preference: SandboxPreferenceAuto,
|
||||
})
|
||||
return execRequest.BackendPlan(effectivePolicy)
|
||||
}
|
||||
|
||||
func (backend Backend) restrictions(policy Policy) []string {
|
||||
effectivePolicy := policy
|
||||
if effectivePolicy.Mode == "" {
|
||||
effectivePolicy = DefaultPolicy()
|
||||
@@ -153,7 +182,11 @@ func (backend Backend) BuildPlan(workspaceRoot string, policy Policy) BackendPla
|
||||
restrictions = append(restrictions, "filesystem writes must stay inside workspace")
|
||||
}
|
||||
if effectivePolicy.Network == NetworkDeny {
|
||||
restrictions = append(restrictions, "network access denied unless a future adapter grants it explicitly")
|
||||
if backend.Name == BackendWindowsRestrictedToken && backend.NativeIsolation {
|
||||
restrictions = append(restrictions, "Windows WFP filters block outbound network for sandbox identities")
|
||||
} else {
|
||||
restrictions = append(restrictions, "network access denied unless a future adapter grants it explicitly")
|
||||
}
|
||||
}
|
||||
if effectivePolicy.DenyDestructiveShell {
|
||||
restrictions = append(restrictions, "destructive shell patterns denied before execution")
|
||||
@@ -168,15 +201,7 @@ func (backend Backend) BuildPlan(workspaceRoot string, policy Policy) BackendPla
|
||||
} else if backend.Available {
|
||||
restrictions = append(restrictions, "shell commands are wrapped through "+string(backend.Name)+" when launched by the sandbox engine")
|
||||
}
|
||||
return BackendPlan{
|
||||
Backend: backend,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: effectivePolicy,
|
||||
SupportLevel: backend.SupportLevel(),
|
||||
Capabilities: backend.Capabilities(effectivePolicy),
|
||||
Restrictions: restrictions,
|
||||
Warnings: backend.Warnings(),
|
||||
}
|
||||
return restrictions
|
||||
}
|
||||
|
||||
func (backend Backend) SupportLevel() BackendSupportLevel {
|
||||
@@ -186,6 +211,60 @@ func (backend Backend) SupportLevel() BackendSupportLevel {
|
||||
return BackendSupportPolicyOnly
|
||||
}
|
||||
|
||||
func (backend Backend) EnforcementLevel(policy Policy) EnforcementLevel {
|
||||
if policy.Mode == "" {
|
||||
policy = DefaultPolicy()
|
||||
}
|
||||
if policy.Mode == ModeDisabled {
|
||||
return EnforcementDisabled
|
||||
}
|
||||
if backend.SupportLevel() == BackendSupportNative {
|
||||
return EnforcementNative
|
||||
}
|
||||
return EnforcementDegraded
|
||||
}
|
||||
|
||||
func (backend Backend) DowngradeReason(policy Policy) string {
|
||||
if policy.Mode == "" {
|
||||
policy = DefaultPolicy()
|
||||
}
|
||||
if policy.Mode == ModeDisabled {
|
||||
return "sandbox policy disabled"
|
||||
}
|
||||
if backend.SupportLevel() == BackendSupportNative {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(backend.Message) != "" {
|
||||
return backend.Message
|
||||
}
|
||||
platform := backend.Platform
|
||||
if platform == "" {
|
||||
platform = "this platform"
|
||||
}
|
||||
return "native sandbox unavailable on " + platform
|
||||
}
|
||||
|
||||
func (backend Backend) SandboxEnvMarkers(policy Policy) []string {
|
||||
if policy.Mode == "" {
|
||||
policy = DefaultPolicy()
|
||||
}
|
||||
if policy.Mode == ModeDisabled {
|
||||
return nil
|
||||
}
|
||||
if !(backend.CommandWrapping && backend.Available) && backend.Name != BackendWSL {
|
||||
return nil
|
||||
}
|
||||
name := backend.Name
|
||||
if name == "" {
|
||||
name = BackendPolicyOnly
|
||||
}
|
||||
return []string{
|
||||
EnvSandboxed + "=1",
|
||||
EnvSandboxBackend + "=" + string(name),
|
||||
"ZERO_SANDBOX_NETWORK=" + string(policy.Network),
|
||||
}
|
||||
}
|
||||
|
||||
func (backend Backend) Warnings() []string {
|
||||
if backend.SupportLevel() == BackendSupportNative {
|
||||
return nil
|
||||
@@ -199,7 +278,7 @@ func (backend Backend) Warnings() []string {
|
||||
"shell commands are not wrapped by a native platform sandbox",
|
||||
}
|
||||
if backend.Platform == "windows" {
|
||||
warnings[0] = "Windows native sandbox adapter is not implemented; using policy-only preflight checks"
|
||||
warnings[0] = "Windows sandbox command runner is not available; using policy-only preflight checks"
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
@@ -208,6 +287,15 @@ func (backend Backend) Capabilities(policy Policy) []BackendCapability {
|
||||
if policy.Mode == "" {
|
||||
policy = DefaultPolicy()
|
||||
}
|
||||
networkGuard := BackendCapability{
|
||||
Key: "network_guard",
|
||||
Status: policyCapabilityStatus(policy.Mode, policy.Network == NetworkDeny),
|
||||
Detail: "network-capable tool requests are denied before execution",
|
||||
}
|
||||
if policy.Mode != ModeDisabled && policy.Network == NetworkDeny && backend.Name == BackendWindowsRestrictedToken && backend.NativeIsolation {
|
||||
networkGuard.Status = CapabilityNative
|
||||
networkGuard.Detail = "Windows WFP filters block outbound network for sandbox identities"
|
||||
}
|
||||
capabilities := []BackendCapability{
|
||||
{
|
||||
Key: "policy_evaluation",
|
||||
@@ -219,11 +307,7 @@ func (backend Backend) Capabilities(policy Policy) []BackendCapability {
|
||||
Status: policyCapabilityStatus(policy.Mode, policy.EnforceWorkspace),
|
||||
Detail: "filesystem writes are checked against the workspace root before execution",
|
||||
},
|
||||
{
|
||||
Key: "network_guard",
|
||||
Status: policyCapabilityStatus(policy.Mode, policy.Network == NetworkDeny),
|
||||
Detail: "network-capable tool requests are denied before execution",
|
||||
},
|
||||
networkGuard,
|
||||
{
|
||||
Key: "destructive_shell_guard",
|
||||
Status: policyCapabilityStatus(policy.Mode, policy.DenyDestructiveShell),
|
||||
@@ -239,7 +323,7 @@ func (backend Backend) Capabilities(policy Policy) []BackendCapability {
|
||||
nativeIsolation.Status = CapabilityNative
|
||||
nativeIsolation.Detail = "tool subprocesses can run inside " + string(backend.Name)
|
||||
} else if backend.Platform == "windows" {
|
||||
nativeIsolation.Detail = "Windows native sandbox adapter is not implemented yet"
|
||||
nativeIsolation.Detail = "Windows sandbox command runner is not available"
|
||||
}
|
||||
commandWrapping := BackendCapability{
|
||||
Key: "command_wrapping",
|
||||
|
||||
@@ -8,18 +8,21 @@ import (
|
||||
)
|
||||
|
||||
func TestSelectBackendChoosesPlatformAdapterWithFallback(t *testing.T) {
|
||||
t.Run("linux bubblewrap available", func(t *testing.T) {
|
||||
t.Run("linux helper available", func(t *testing.T) {
|
||||
backend := SelectBackend(BackendOptions{
|
||||
GOOS: "linux",
|
||||
LookupExecutable: func(name string) (string, error) {
|
||||
if name == LinuxSandboxHelperName {
|
||||
return "/usr/bin/zero-linux-sandbox", nil
|
||||
}
|
||||
if name == "bwrap" {
|
||||
return "/usr/bin/bwrap", nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
})
|
||||
if backend.Name != BackendBubblewrap || !backend.Available || backend.Executable != "/usr/bin/bwrap" {
|
||||
t.Fatalf("linux backend = %#v, want available bubblewrap", backend)
|
||||
if backend.Name != BackendLinuxBwrap || !backend.Available || backend.Executable != "/usr/bin/zero-linux-sandbox" {
|
||||
t.Fatalf("linux backend = %#v, want available Linux helper", backend)
|
||||
}
|
||||
if backend.Platform != "linux" || backend.Fallback || !backend.CommandWrapping || !backend.NativeIsolation {
|
||||
t.Fatalf("linux backend capabilities = %#v, want native wrapping", backend)
|
||||
@@ -33,6 +36,24 @@ func TestSelectBackendChoosesPlatformAdapterWithFallback(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("linux helper missing falls back explicitly", func(t *testing.T) {
|
||||
backend := SelectBackend(BackendOptions{
|
||||
GOOS: "linux",
|
||||
LookupExecutable: func(name string) (string, error) {
|
||||
if name == "bwrap" {
|
||||
return "/usr/bin/bwrap", nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
})
|
||||
if backend.Name != BackendPolicyOnly || backend.Available {
|
||||
t.Fatalf("linux backend = %#v, want policy-only fallback without Linux helper", backend)
|
||||
}
|
||||
if !strings.Contains(backend.Message, "Linux sandbox helper is not available") {
|
||||
t.Fatalf("linux fallback message = %q, want missing helper", backend.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("darwin sandbox exec available", func(t *testing.T) {
|
||||
backend := SelectBackend(BackendOptions{
|
||||
GOOS: "darwin",
|
||||
@@ -43,14 +64,63 @@ func TestSelectBackendChoosesPlatformAdapterWithFallback(t *testing.T) {
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
})
|
||||
if backend.Name != BackendSandboxExec || !backend.Available || backend.Executable != "/usr/bin/sandbox-exec" {
|
||||
t.Fatalf("darwin backend = %#v, want available sandbox-exec", backend)
|
||||
if backend.Name != BackendMacOSSeatbelt || !backend.Available || backend.Executable != "/usr/bin/sandbox-exec" {
|
||||
t.Fatalf("darwin backend = %#v, want available macOS Seatbelt backend", backend)
|
||||
}
|
||||
if backend.Platform != "darwin" || backend.Fallback || !backend.CommandWrapping || !backend.NativeIsolation {
|
||||
t.Fatalf("darwin backend capabilities = %#v, want native wrapping", backend)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("windows command runner available", func(t *testing.T) {
|
||||
backend := SelectBackend(BackendOptions{
|
||||
GOOS: "windows",
|
||||
LookupExecutable: func(name string) (string, error) {
|
||||
if name == WindowsSandboxCommandRunnerName {
|
||||
return `C:\zero\zero-windows-command-runner.exe`, nil
|
||||
}
|
||||
if name == WindowsSandboxSetupName {
|
||||
return `C:\zero\zero-windows-sandbox-setup.exe`, nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
})
|
||||
if backend.Name != BackendWindowsRestrictedToken || !backend.Available || backend.Executable != `C:\zero\zero-windows-command-runner.exe` {
|
||||
t.Fatalf("windows backend = %#v, want available restricted-token runner", backend)
|
||||
}
|
||||
if backend.Platform != "windows" || backend.Fallback || !backend.CommandWrapping || !backend.NativeIsolation {
|
||||
t.Fatalf("windows backend capabilities = %#v, want native wrapping", backend)
|
||||
}
|
||||
plan := backend.BuildPlan(t.TempDir(), DefaultPolicy())
|
||||
if plan.SupportLevel != BackendSupportNative || len(plan.Warnings) != 0 {
|
||||
t.Fatalf("windows plan = %#v, want native support without warnings", plan)
|
||||
}
|
||||
if capabilityStatus(plan.Capabilities, "native_process_isolation") != CapabilityNative {
|
||||
t.Fatalf("windows native isolation capability = %#v, want native", plan.Capabilities)
|
||||
}
|
||||
if capabilityStatus(plan.Capabilities, "network_guard") != CapabilityNative {
|
||||
t.Fatalf("windows network guard capability = %#v, want native WFP enforcement", plan.Capabilities)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("windows setup helper missing", func(t *testing.T) {
|
||||
backend := SelectBackend(BackendOptions{
|
||||
GOOS: "windows",
|
||||
LookupExecutable: func(name string) (string, error) {
|
||||
if name == WindowsSandboxCommandRunnerName {
|
||||
return `C:\zero\zero-windows-command-runner.exe`, nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
})
|
||||
if backend.Name != BackendPolicyOnly || backend.Available || backend.Platform != "windows" {
|
||||
t.Fatalf("windows backend = %#v, want policy-only windows fallback", backend)
|
||||
}
|
||||
if !strings.Contains(backend.Message, "Windows sandbox setup helper is not available") {
|
||||
t.Fatalf("expected Windows setup helper fallback message, got %q", backend.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("windows falls back explicitly", func(t *testing.T) {
|
||||
backend := SelectBackend(BackendOptions{
|
||||
GOOS: "windows",
|
||||
@@ -62,7 +132,7 @@ func TestSelectBackendChoosesPlatformAdapterWithFallback(t *testing.T) {
|
||||
if !backend.Fallback || backend.CommandWrapping || backend.NativeIsolation {
|
||||
t.Fatalf("windows backend capabilities = %#v, want no native wrapping", backend)
|
||||
}
|
||||
if !strings.Contains(backend.Message, "Windows native sandbox adapter is not implemented") {
|
||||
if !strings.Contains(backend.Message, "Windows sandbox command runner is not available") {
|
||||
t.Fatalf("expected Windows fallback message, got %q", backend.Message)
|
||||
}
|
||||
plan := backend.BuildPlan(t.TempDir(), DefaultPolicy())
|
||||
@@ -72,8 +142,8 @@ func TestSelectBackendChoosesPlatformAdapterWithFallback(t *testing.T) {
|
||||
if capabilityStatus(plan.Capabilities, "native_process_isolation") != CapabilityUnavailable {
|
||||
t.Fatalf("windows native isolation capability = %#v, want unavailable", plan.Capabilities)
|
||||
}
|
||||
if !restrictionContains(plan.Warnings, "Windows native sandbox adapter is not implemented") {
|
||||
t.Fatalf("windows warnings = %#v, want adapter warning", plan.Warnings)
|
||||
if !restrictionContains(plan.Warnings, "Windows sandbox command runner is not available") {
|
||||
t.Fatalf("windows warnings = %#v, want command runner warning", plan.Warnings)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -94,6 +164,45 @@ func TestSelectBackendChoosesPlatformAdapterWithFallback(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLegacySandboxEntrypointsAreExplicitAndExist(t *testing.T) {
|
||||
t.Run("linux still requires bubblewrap behind helper", func(t *testing.T) {
|
||||
lookups := map[string]string{
|
||||
LinuxSandboxHelperName: "/usr/bin/" + LinuxSandboxHelperName,
|
||||
"bwrap": "/usr/bin/bwrap",
|
||||
}
|
||||
backend := SelectBackend(BackendOptions{
|
||||
GOOS: "linux",
|
||||
LookupExecutable: func(name string) (string, error) {
|
||||
path, ok := lookups[name]
|
||||
if !ok {
|
||||
return "", errors.New("missing")
|
||||
}
|
||||
return path, nil
|
||||
},
|
||||
})
|
||||
|
||||
if backend.Name != BackendLinuxBwrap || backend.Executable != lookups[LinuxSandboxHelperName] {
|
||||
t.Fatalf("linux backend = %#v, want Linux helper backed by bwrap", backend)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("macos still uses sandbox-exec behind seatbelt backend", func(t *testing.T) {
|
||||
backend := SelectBackend(BackendOptions{
|
||||
GOOS: "darwin",
|
||||
LookupExecutable: func(name string) (string, error) {
|
||||
if name == "sandbox-exec" {
|
||||
return "/usr/bin/sandbox-exec", nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
})
|
||||
|
||||
if backend.Name != BackendMacOSSeatbelt || backend.Executable != "/usr/bin/sandbox-exec" {
|
||||
t.Fatalf("darwin backend = %#v, want Seatbelt backend through sandbox-exec", backend)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackendBuildPlanDocumentsBestEffortIsolation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
policy := DefaultPolicy()
|
||||
|
||||
@@ -93,7 +93,7 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
|
||||
if networkPrograms[prog] {
|
||||
result.Network = true
|
||||
}
|
||||
if destructivePrograms[prog] || (prog == "rm" && hasRecursiveForce(rest)) {
|
||||
if destructivePrograms[prog] || (prog == "rm" && hasRecursiveForce(rest)) || (prog == "find" && hasFindDelete(rest)) {
|
||||
result.Destructive = true
|
||||
}
|
||||
return true
|
||||
@@ -238,3 +238,12 @@ func hasRecursiveForce(args []*syntax.Word) bool {
|
||||
}
|
||||
return recursive && force
|
||||
}
|
||||
|
||||
func hasFindDelete(args []*syntax.Word) bool {
|
||||
for _, arg := range args {
|
||||
if wordText(arg) == "-delete" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func TestAnalyzeCommand(t *testing.T) {
|
||||
{name: "rm inside quoted arg", script: `git commit -m "rm -rf /"`, destructive: false},
|
||||
{name: "rm end-of-options literal filename", script: "rm -- -rf", destructive: false},
|
||||
{name: "dd", script: "dd if=/dev/zero of=/dev/disk2", destructive: true},
|
||||
{name: "find delete", script: "find . -type f -delete", destructive: true},
|
||||
|
||||
// Wrappers are unwrapped to the real payload, not classified on the launcher.
|
||||
{name: "sudo wraps rm -rf", script: "sudo rm -rf /tmp/x", destructive: true},
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTargetBackendForPlatformBaseline(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
wsl bool
|
||||
want BackendName
|
||||
}{
|
||||
{name: "linux", goos: "linux", want: BackendLinuxBwrap},
|
||||
{name: "linux wsl", goos: "linux", wsl: true, want: BackendPolicyOnly},
|
||||
{name: "macos", goos: "darwin", want: BackendMacOSSeatbelt},
|
||||
{name: "windows", goos: "windows", want: BackendWindowsRestrictedToken},
|
||||
{name: "unsupported", goos: "plan9", want: BackendPolicyOnly},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := TargetBackendForPlatform(test.goos, test.wsl); got != test.want {
|
||||
t.Fatalf("TargetBackendForPlatform(%q, %t) = %q, want %q", test.goos, test.wsl, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPlanCarriesPhase0ManagerFields(t *testing.T) {
|
||||
linux := SelectBackend(BackendOptions{
|
||||
GOOS: "linux",
|
||||
LookupExecutable: func(name string) (string, error) {
|
||||
if name == LinuxSandboxHelperName {
|
||||
return "/usr/bin/zero-linux-sandbox", nil
|
||||
}
|
||||
if name == "bwrap" {
|
||||
return "/usr/bin/bwrap", nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
}).BuildPlan("/workspace", DefaultPolicy())
|
||||
|
||||
if linux.TargetBackend != BackendLinuxBwrap || linux.EnforcementLevel != EnforcementNative || !linux.CommandWrapped {
|
||||
t.Fatalf("linux plan metadata = %#v, want linux-bwrap native wrapped", linux)
|
||||
}
|
||||
for _, marker := range []string{EnvSandboxed + "=1", EnvSandboxBackend + "=" + string(BackendLinuxBwrap), "ZERO_SANDBOX_NETWORK=deny"} {
|
||||
if !stringSliceContains(linux.SandboxEnvMarkers, marker) {
|
||||
t.Fatalf("linux plan markers = %#v, missing %q", linux.SandboxEnvMarkers, marker)
|
||||
}
|
||||
}
|
||||
|
||||
windows := SelectBackend(BackendOptions{
|
||||
GOOS: "windows",
|
||||
LookupExecutable: func(string) (string, error) { return "", errors.New("missing") },
|
||||
}).BuildPlan("/workspace", DefaultPolicy())
|
||||
|
||||
if windows.TargetBackend != BackendWindowsRestrictedToken {
|
||||
t.Fatalf("windows target backend = %q, want %q", windows.TargetBackend, BackendWindowsRestrictedToken)
|
||||
}
|
||||
if windows.EnforcementLevel != EnforcementDegraded || windows.CommandWrapped || windows.DowngradeReason == "" {
|
||||
t.Fatalf("windows plan metadata = %#v, want degraded unwrapped plan with downgrade reason", windows)
|
||||
}
|
||||
if len(windows.SandboxEnvMarkers) != 0 {
|
||||
t.Fatalf("windows policy-only plan must not claim sandbox env markers: %#v", windows.SandboxEnvMarkers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPlanCarriesSandboxMetadata(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{
|
||||
Name: BackendLinuxBwrap,
|
||||
Available: true,
|
||||
Platform: "linux",
|
||||
Executable: "/usr/bin/zero-linux-sandbox",
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
},
|
||||
})
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: root})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
|
||||
if plan.TargetBackend != BackendLinuxBwrap || !plan.Wrapped || plan.EnforcementLevel != EnforcementNative || plan.DowngradeReason != "" {
|
||||
t.Fatalf("wrapped command metadata = %#v, want native linux-bwrap", plan)
|
||||
}
|
||||
if !stringSliceContains(plan.SandboxEnvMarkers, EnvSandboxBackend+"="+string(BackendLinuxBwrap)) {
|
||||
t.Fatalf("wrapped command markers = %#v, missing backend marker", plan.SandboxEnvMarkers)
|
||||
}
|
||||
|
||||
policyOnly := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{Name: BackendPolicyOnly, Platform: "linux", Fallback: true, Message: "policy-only fallback"},
|
||||
})
|
||||
direct, err := policyOnly.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan policy-only: %v", err)
|
||||
}
|
||||
if direct.TargetBackend != BackendPolicyOnly || direct.Wrapped || direct.EnforcementLevel != EnforcementDegraded || !strings.Contains(direct.DowngradeReason, "policy-only") {
|
||||
t.Fatalf("policy-only command metadata = %#v, want degraded direct command", direct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyOnlyDisabledFailClosedForTargetPlatforms(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
policy := DefaultPolicy()
|
||||
policy.AllowPolicyOnlyRunner = false
|
||||
tests := []struct {
|
||||
name string
|
||||
backend Backend
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "linux",
|
||||
backend: Backend{Name: BackendPolicyOnly, Platform: "linux", Fallback: true},
|
||||
wantErr: errPolicyOnlyRunnerDisabled,
|
||||
},
|
||||
{
|
||||
name: "macos",
|
||||
backend: Backend{Name: BackendPolicyOnly, Platform: "darwin", Fallback: true},
|
||||
wantErr: errPolicyOnlyRunnerDisabled,
|
||||
},
|
||||
{
|
||||
name: "windows",
|
||||
backend: Backend{Name: BackendPolicyOnly, Platform: "windows", Fallback: true},
|
||||
wantErr: errPolicyOnlyRunnerDisabled,
|
||||
},
|
||||
{
|
||||
name: "wsl",
|
||||
backend: Backend{Name: BackendWSL, Platform: "linux", Fallback: true, ProxyEgress: true},
|
||||
wantErr: errWSLPolicyOnlyDisabled,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy, Backend: test.backend})
|
||||
_, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root})
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("BuildCommandPlan error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -29,9 +29,9 @@ func bashRequest() Request {
|
||||
}
|
||||
|
||||
var nativeWrappingBackend = Backend{
|
||||
Name: BackendBubblewrap,
|
||||
Name: BackendLinuxBwrap,
|
||||
Available: true,
|
||||
Executable: "/usr/bin/bwrap",
|
||||
Executable: "/usr/bin/zero-linux-sandbox",
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
}
|
||||
@@ -72,9 +72,11 @@ func TestAutoAllowBashOffStillPrompts(t *testing.T) {
|
||||
// must not error.
|
||||
func TestAutoAllowBashDoesNotAffectNonShell(t *testing.T) {
|
||||
engine := autoAllowBashEngine(t, true, nativeWrappingBackend)
|
||||
// A non-shell prompt tool must still prompt; auto-allow is shell-only.
|
||||
// A non-shell prompt tool must still prompt; auto-allow is shell-only. Use a
|
||||
// generic write tool name so the workspace file-tool auto-allow does not
|
||||
// apply.
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "write_file",
|
||||
ToolName: "custom_writer",
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type commandPrefixGrantSet struct {
|
||||
mu sync.Mutex
|
||||
grants []CommandPrefixGrant
|
||||
}
|
||||
|
||||
type CommandPrefixGrant struct {
|
||||
ToolName string `json:"toolName"`
|
||||
Prefix []string `json:"prefix"`
|
||||
ApprovedAt string `json:"approvedAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Session bool `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
type CommandPrefixInput struct {
|
||||
ToolName string
|
||||
Prefix []string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func newCommandPrefixGrantSet() *commandPrefixGrantSet {
|
||||
return &commandPrefixGrantSet{}
|
||||
}
|
||||
|
||||
func (set *commandPrefixGrantSet) add(grant CommandPrefixGrant) {
|
||||
if set == nil || grant.ToolName == "" || len(grant.Prefix) == 0 {
|
||||
return
|
||||
}
|
||||
set.mu.Lock()
|
||||
defer set.mu.Unlock()
|
||||
for _, existing := range set.grants {
|
||||
if existing.ToolName == grant.ToolName && sameStringSlice(existing.Prefix, grant.Prefix) {
|
||||
return
|
||||
}
|
||||
}
|
||||
grant.Prefix = append([]string(nil), grant.Prefix...)
|
||||
set.grants = append(set.grants, grant)
|
||||
}
|
||||
|
||||
func (set *commandPrefixGrantSet) match(toolName string, command []string) (CommandPrefixGrant, bool) {
|
||||
if set == nil || toolName == "" || len(command) == 0 {
|
||||
return CommandPrefixGrant{}, false
|
||||
}
|
||||
set.mu.Lock()
|
||||
defer set.mu.Unlock()
|
||||
for _, grant := range set.grants {
|
||||
if grant.ToolName == toolName && hasStringPrefix(command, grant.Prefix) {
|
||||
grant.Prefix = append([]string(nil), grant.Prefix...)
|
||||
return grant, true
|
||||
}
|
||||
}
|
||||
return CommandPrefixGrant{}, false
|
||||
}
|
||||
|
||||
func hasStringPrefix(values []string, prefix []string) bool {
|
||||
if len(prefix) == 0 || len(prefix) > len(values) {
|
||||
return false
|
||||
}
|
||||
for index := range prefix {
|
||||
if values[index] != prefix[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func NormalizeCommandPrefix(prefix []string) ([]string, bool) {
|
||||
cleaned := make([]string, 0, len(prefix))
|
||||
for _, part := range prefix {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil, false
|
||||
}
|
||||
cleaned = append(cleaned, part)
|
||||
}
|
||||
if unsafeCommandPrefix(cleaned) {
|
||||
return nil, false
|
||||
}
|
||||
return cleaned, true
|
||||
}
|
||||
|
||||
func ValidCommandPrefix(prefix []string) bool {
|
||||
_, ok := NormalizeCommandPrefix(prefix)
|
||||
return ok
|
||||
}
|
||||
|
||||
var bannedCommandPrefixSuggestions = [][]string{
|
||||
{"python3"},
|
||||
{"python3", "-"},
|
||||
{"python3", "-c"},
|
||||
{"python"},
|
||||
{"python", "-"},
|
||||
{"python", "-c"},
|
||||
{"py"},
|
||||
{"py", "-3"},
|
||||
{"pythonw"},
|
||||
{"pyw"},
|
||||
{"pypy"},
|
||||
{"pypy3"},
|
||||
{"git"},
|
||||
{"bash"},
|
||||
{"bash", "-lc"},
|
||||
{"sh"},
|
||||
{"sh", "-c"},
|
||||
{"sh", "-lc"},
|
||||
{"zsh"},
|
||||
{"zsh", "-lc"},
|
||||
{"/bin/zsh"},
|
||||
{"/bin/zsh", "-lc"},
|
||||
{"/bin/bash"},
|
||||
{"/bin/bash", "-lc"},
|
||||
{"pwsh"},
|
||||
{"pwsh", "-Command"},
|
||||
{"pwsh", "-c"},
|
||||
{"powershell"},
|
||||
{"powershell", "-Command"},
|
||||
{"powershell", "-c"},
|
||||
{"powershell.exe"},
|
||||
{"powershell.exe", "-Command"},
|
||||
{"powershell.exe", "-c"},
|
||||
{"env"},
|
||||
{"sudo"},
|
||||
{"node"},
|
||||
{"node", "-e"},
|
||||
{"perl"},
|
||||
{"perl", "-e"},
|
||||
{"ruby"},
|
||||
{"ruby", "-e"},
|
||||
{"php"},
|
||||
{"php", "-r"},
|
||||
{"lua"},
|
||||
{"lua", "-e"},
|
||||
{"osascript"},
|
||||
}
|
||||
|
||||
func unsafeCommandPrefix(prefix []string) bool {
|
||||
if len(prefix) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, part := range prefix {
|
||||
if unsafeCommandPrefixPart(part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, banned := range bannedCommandPrefixSuggestions {
|
||||
if sameStringSlice(prefix, banned) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if unsafeCommandPrefixLauncher(prefix[0]) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unsafeCommandPrefixPart(part string) bool {
|
||||
part = strings.TrimSpace(part)
|
||||
return part == "" ||
|
||||
strings.ContainsAny(part, "\x00\r\n*?[]{}") ||
|
||||
strings.Contains(part, "$(") ||
|
||||
strings.Contains(part, "`")
|
||||
}
|
||||
|
||||
func unsafeCommandPrefixLauncher(program string) bool {
|
||||
program = strings.ToLower(strings.TrimSpace(program))
|
||||
if strings.ContainsAny(program, `/\`) {
|
||||
return true
|
||||
}
|
||||
switch program {
|
||||
case "bash", "sh", "zsh", "/bin/bash", "/bin/zsh",
|
||||
"pwsh", "powershell", "powershell.exe",
|
||||
"env", "sudo", "doas", "su", "run0", "osascript",
|
||||
"command", "eval", "exec", "time",
|
||||
"find", "xargs", "timeout", "nice", "nohup", "watch", "setsid", "stdbuf", "ionice",
|
||||
"ssh", "make", "npm", "npx",
|
||||
"python", "python3", "py", "pythonw", "pyw", "pypy", "pypy3",
|
||||
"node", "perl", "ruby", "php", "lua", "deno", "bun":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sameStringSlice(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+238
-25
@@ -3,7 +3,9 @@ package sandbox
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// reasonAboveCeiling is the Decision.Reason emitted when a grant-allow or unsafe
|
||||
@@ -19,11 +21,15 @@ type EngineOptions struct {
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
workspaceRoot string
|
||||
policy Policy
|
||||
store *GrantStore
|
||||
backend Backend
|
||||
scope *Scope
|
||||
workspaceRoot string
|
||||
policy Policy
|
||||
store *GrantStore
|
||||
backend Backend
|
||||
scope *Scope
|
||||
sessionGrants *memoryGrantSet
|
||||
sessionProfiles *permissionProfileGrantSet
|
||||
turnProfiles *permissionProfileGrantSet
|
||||
commandPrefixes *commandPrefixGrantSet
|
||||
}
|
||||
|
||||
func NewEngine(options EngineOptions) *Engine {
|
||||
@@ -47,11 +53,15 @@ func NewEngine(options EngineOptions) *Engine {
|
||||
scope = &Scope{workspaceRoot: normalizeWorkspaceRootBestEffort(workspaceRoot)}
|
||||
}
|
||||
return &Engine{
|
||||
workspaceRoot: workspaceRoot,
|
||||
policy: policy,
|
||||
store: options.Store,
|
||||
backend: options.Backend,
|
||||
scope: scope,
|
||||
workspaceRoot: workspaceRoot,
|
||||
policy: policy,
|
||||
store: options.Store,
|
||||
backend: options.Backend,
|
||||
scope: scope,
|
||||
sessionGrants: newMemoryGrantSet(),
|
||||
sessionProfiles: newPermissionProfileGrantSet(),
|
||||
turnProfiles: newPermissionProfileGrantSet(),
|
||||
commandPrefixes: newCommandPrefixGrantSet(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +75,61 @@ func (engine *Engine) Scope() *Scope {
|
||||
return engine.scope
|
||||
}
|
||||
|
||||
func (engine *Engine) CanPersistGrants() bool {
|
||||
return engine != nil && engine.store != nil
|
||||
}
|
||||
|
||||
func (engine *Engine) GrantCommandPrefixForSession(toolName string, prefix []string) {
|
||||
if engine == nil || len(prefix) == 0 {
|
||||
return
|
||||
}
|
||||
prefix, ok := NormalizeCommandPrefix(prefix)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
engine.commandPrefixes.add(CommandPrefixGrant{
|
||||
ToolName: toolName,
|
||||
Prefix: prefix,
|
||||
Session: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (engine *Engine) GrantCommandPrefix(input CommandPrefixInput) (CommandPrefixGrant, error) {
|
||||
if engine == nil || engine.store == nil {
|
||||
return CommandPrefixGrant{}, errors.New("sandbox grant store is not configured")
|
||||
}
|
||||
return engine.store.GrantCommandPrefix(input)
|
||||
}
|
||||
|
||||
func (engine *Engine) LookupCommandPrefix(toolName string, command []string) (CommandPrefixGrant, bool) {
|
||||
if engine == nil || engine.store == nil || len(command) == 0 {
|
||||
return CommandPrefixGrant{}, false
|
||||
}
|
||||
grant, matched, err := engine.store.LookupCommandPrefix(toolName, command)
|
||||
if err != nil {
|
||||
return CommandPrefixGrant{}, false
|
||||
}
|
||||
return grant, matched
|
||||
}
|
||||
|
||||
func (engine *Engine) ApprovedCommandPrefixes() []CommandPrefixGrant {
|
||||
if engine == nil || engine.store == nil {
|
||||
return nil
|
||||
}
|
||||
grants, err := engine.store.ListCommandPrefixes()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return grants
|
||||
}
|
||||
|
||||
func (engine *Engine) LookupCommandPrefixForSession(toolName string, command []string) (CommandPrefixGrant, bool) {
|
||||
if engine == nil || len(command) == 0 {
|
||||
return CommandPrefixGrant{}, false
|
||||
}
|
||||
return engine.commandPrefixes.match(toolName, command)
|
||||
}
|
||||
|
||||
// ReadExclusions returns the resolved DenyRead/AllowRead exclusion matcher for
|
||||
// this engine's policy, resolving each policy entry ONCE. The search tools build
|
||||
// it a single time per run and reuse it across the whole walk so the predicates
|
||||
@@ -73,13 +138,17 @@ func (engine *Engine) Scope() *Scope {
|
||||
func (engine *Engine) ReadExclusions() *ReadExclusions {
|
||||
// A disabled policy enforces nothing, so it must not filter search results
|
||||
// either (Evaluate already allows every request under ModeDisabled).
|
||||
if engine == nil || engine.policy.Mode == ModeDisabled {
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
policy := engine.effectivePolicy(engine.policy)
|
||||
if policy.Mode == ModeDisabled {
|
||||
return nil
|
||||
}
|
||||
return &ReadExclusions{
|
||||
workspaceRoot: engine.workspaceRoot,
|
||||
denyRoots: resolvePolicyPaths(engine.policy.DenyRead),
|
||||
allowRoots: resolvePolicyPaths(engine.policy.AllowRead),
|
||||
denyRoots: resolvePolicyPaths(policy.DenyRead),
|
||||
allowRoots: resolvePolicyPaths(policy.AllowRead),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,10 +157,14 @@ func (engine *Engine) ReadExclusions() *ReadExclusions {
|
||||
// DenyRead is unset or the engine has no scope.
|
||||
func (engine *Engine) ReadExclusionGlobs() []string {
|
||||
// A disabled policy filters nothing (parity with ReadExclusions / Evaluate).
|
||||
if engine == nil || engine.policy.Mode == ModeDisabled {
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
return ReadExclusionGlobs(engine.policy, engine.scope)
|
||||
policy := engine.effectivePolicy(engine.policy)
|
||||
if policy.Mode == ModeDisabled {
|
||||
return nil
|
||||
}
|
||||
return ReadExclusionGlobs(policy, engine.scope)
|
||||
}
|
||||
|
||||
// effectiveNetworkMode is the single source of truth for the engine's active
|
||||
@@ -130,7 +203,7 @@ func (engine *Engine) NetworkHostAllowed(host string) (bool, NetworkMode) {
|
||||
if engine == nil {
|
||||
return true, NetworkAllow
|
||||
}
|
||||
policy := engine.policy
|
||||
policy := engine.effectivePolicy(engine.policy)
|
||||
if policy.Mode == ModeDisabled {
|
||||
return true, NetworkAllow
|
||||
}
|
||||
@@ -236,7 +309,7 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
|
||||
if engine == nil {
|
||||
return Decision{Action: ActionAllow, Risk: Classify(request), Reason: "sandbox disabled"}
|
||||
}
|
||||
policy := engine.policy
|
||||
policy := engine.effectivePolicy(engine.policy)
|
||||
if policy.Mode == "" {
|
||||
policy = DefaultPolicy()
|
||||
}
|
||||
@@ -282,6 +355,9 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
|
||||
// workspace boundary itself needs a root, so it is gated on having one. Mode is
|
||||
// already known to be enforcing here (ModeDisabled returned above).
|
||||
enforceWorkspace := policy.EnforceWorkspace && request.WorkspaceRoot != ""
|
||||
if violation := applyPatchPathViolation(request); violation != nil {
|
||||
return deny(request, risk, violation.Code, violation.Path, violation.Reason, false)
|
||||
}
|
||||
for _, requested := range requestPaths(request) {
|
||||
if violation := validatePathWithPolicy(scope, policy, request.SideEffect, enforceWorkspace, request.WorkspaceRoot, requested); violation != nil {
|
||||
return deny(request, risk, violation.Code, violation.Path, violation.Reason, false)
|
||||
@@ -300,10 +376,10 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
|
||||
if policy.DenyDestructiveShell && HasRiskCategory(risk, "destructive") {
|
||||
return deny(request, risk, ViolationDestructiveCommand, "", "destructive shell command is blocked by sandbox policy", false)
|
||||
}
|
||||
reqRaw, reqKind := DeriveScope(request.ToolName, request.Args)
|
||||
reqScope := resolveScopeForKind(reqRaw, reqKind, request.WorkspaceRoot)
|
||||
if engine.store != nil {
|
||||
reqRaw, _ := DeriveScope(request.ToolName, request.Args)
|
||||
reqScopeAbs := resolveScopeAbs(reqRaw, request.WorkspaceRoot)
|
||||
match, err := engine.store.Lookup(request.ToolName, reqScopeAbs, request.Autonomy)
|
||||
match, err := engine.store.Lookup(request.ToolName, reqScope, request.Autonomy)
|
||||
if err == nil && match.Matched {
|
||||
grant := match.Grant
|
||||
if grant.Decision == GrantDeny {
|
||||
@@ -330,9 +406,34 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
|
||||
}
|
||||
}
|
||||
}
|
||||
if match := engine.lookupSessionGrant(request.ToolName, reqScope, request.Autonomy); match.Matched {
|
||||
grant := match.Grant
|
||||
if !autonomyAllowed(rawAutonomy, policy.MaxAutonomy) {
|
||||
return Decision{
|
||||
Action: ActionPrompt,
|
||||
Reason: reasonAboveCeiling,
|
||||
Risk: risk,
|
||||
GrantMatched: true,
|
||||
Grant: &grant,
|
||||
}
|
||||
}
|
||||
return Decision{
|
||||
Action: ActionAllow,
|
||||
Reason: "session sandbox allow grant matched",
|
||||
Risk: risk,
|
||||
GrantMatched: true,
|
||||
Grant: &grant,
|
||||
}
|
||||
}
|
||||
if request.Permission == PermissionAllow {
|
||||
return Decision{Action: ActionAllow, Risk: risk, Reason: permissionReason(request)}
|
||||
}
|
||||
if workspaceWriteAutoAllowed(policy, request, scope) {
|
||||
if !autonomyAllowed(rawAutonomy, policy.MaxAutonomy) {
|
||||
return Decision{Action: ActionPrompt, Risk: risk, Reason: reasonAboveCeiling}
|
||||
}
|
||||
return Decision{Action: ActionAllow, Risk: risk, Reason: "workspace write permitted by sandbox policy", AutoAllowed: true}
|
||||
}
|
||||
// Auto-allow a sandboxed shell command when the operator opted in: the active
|
||||
// native sandbox is the safety boundary, so the bash prompt is skipped. This
|
||||
// only applies to shell commands AND only when a wrapping sandbox is actually
|
||||
@@ -358,19 +459,131 @@ func (engine *Engine) Grant(input GrantInput) (Grant, error) {
|
||||
if engine == nil || engine.store == nil {
|
||||
return Grant{}, errors.New("sandbox grant store is not configured")
|
||||
}
|
||||
kind, err := normalizeScopeKind(input.ScopeKind)
|
||||
input, err := engine.normalizeGrantInput(input)
|
||||
if err != nil {
|
||||
return Grant{}, err
|
||||
}
|
||||
return engine.store.Grant(input)
|
||||
}
|
||||
|
||||
func (engine *Engine) GrantForSession(input GrantInput) (Grant, error) {
|
||||
if engine == nil {
|
||||
return Grant{}, errors.New("sandbox engine is not configured")
|
||||
}
|
||||
input, err := engine.normalizeGrantInput(input)
|
||||
if err != nil {
|
||||
return Grant{}, err
|
||||
}
|
||||
grant, err := createGrant(input, time.Now)
|
||||
if err != nil {
|
||||
return Grant{}, err
|
||||
}
|
||||
grant.Session = true
|
||||
engine.sessionGrants.add(grant)
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
func (engine *Engine) normalizeGrantInput(input GrantInput) (GrantInput, error) {
|
||||
kind, err := normalizeScopeKind(input.ScopeKind)
|
||||
if err != nil {
|
||||
return GrantInput{}, err
|
||||
}
|
||||
scope, kind := reconcileScope(strings.TrimSpace(input.Scope), kind)
|
||||
if kind != ScopeToolWide {
|
||||
// Anchor a relative scope to this workspace so the grant cannot match a
|
||||
// same-named path in another project.
|
||||
scope = resolveScopeAbs(scope, engine.workspaceRoot)
|
||||
// Anchor relative path scopes to this workspace so the grant cannot match
|
||||
// a same-named path in another project. Host scopes remain network hosts.
|
||||
scope = resolveScopeForKind(scope, kind, engine.workspaceRoot)
|
||||
}
|
||||
input.Scope = scope
|
||||
input.ScopeKind = kind
|
||||
return engine.store.Grant(input)
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (engine *Engine) lookupSessionGrant(toolName string, reqScope string, requestedAutonomy Autonomy) GrantLookup {
|
||||
if engine == nil {
|
||||
return GrantLookup{}
|
||||
}
|
||||
requested, err := NormalizeAutonomy(requestedAutonomy)
|
||||
if err != nil {
|
||||
return GrantLookup{}
|
||||
}
|
||||
return engine.sessionGrants.lookup(toolName, reqScope, requested)
|
||||
}
|
||||
|
||||
func workspaceWriteAutoAllowed(policy Policy, request Request, scope *Scope) bool {
|
||||
if !policy.EnforceWorkspace || request.WorkspaceRoot == "" || request.SideEffect != SideEffectWrite {
|
||||
return false
|
||||
}
|
||||
paths := requestPaths(request)
|
||||
if len(paths) == 0 || requestPathsTouchProtectedMetadata(scope, request.WorkspaceRoot, paths) {
|
||||
return false
|
||||
}
|
||||
switch request.ToolName {
|
||||
case "write_file", "edit_file", "apply_patch":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func requestPathsTouchProtectedMetadata(scope *Scope, workspaceRoot string, paths []string) bool {
|
||||
if len(paths) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, path := range paths {
|
||||
if pathTouchesProtectedMetadata(scope, workspaceRoot, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pathTouchesProtectedMetadata(scope *Scope, workspaceRoot string, path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
return relativePathTouchesProtectedMetadata(path)
|
||||
}
|
||||
roots := []string{}
|
||||
if scope != nil {
|
||||
roots = scope.Roots()
|
||||
} else if workspaceRoot != "" {
|
||||
roots = []string{workspaceRoot}
|
||||
}
|
||||
for _, root := range roots {
|
||||
root = normalizeWorkspaceRootBestEffort(root)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
normalized := NormalizePrefixForRoot(filepath.Clean(path), root)
|
||||
relative, err := filepath.Rel(root, normalized)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
||||
continue
|
||||
}
|
||||
if relativePathTouchesProtectedMetadata(relative) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func relativePathTouchesProtectedMetadata(path string) bool {
|
||||
cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(path)))
|
||||
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || filepath.IsAbs(cleaned) {
|
||||
return false
|
||||
}
|
||||
first := cleaned
|
||||
if index := strings.Index(first, string(filepath.Separator)); index >= 0 {
|
||||
first = first[:index]
|
||||
}
|
||||
for _, protected := range protectedMetadataNames {
|
||||
if first == protected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func deny(request Request, risk Risk, code ViolationCode, path string, reason string, recoverable bool) Decision {
|
||||
|
||||
+202
-13
@@ -11,8 +11,8 @@ import (
|
||||
|
||||
// TestEvaluateExemptsNetworkToolsFromDenyByDefault reproduces the web_search
|
||||
// hard-deny: under NetworkDeny, the engine-level network gate must NOT deny a
|
||||
// first-party network TOOL (SideEffect=network) by default, so it follows the
|
||||
// normal permission flow instead of being blocked outright. EnforceToolNetwork
|
||||
// first-party network TOOL (SideEffect=network) by default, so it follows its
|
||||
// normal tool permission metadata instead of being blocked outright. EnforceToolNetwork
|
||||
// restores the strict deny, and a network SHELL command stays denied either way.
|
||||
func TestEvaluateExemptsNetworkToolsFromDenyByDefault(t *testing.T) {
|
||||
base := Policy{Mode: ModeEnforce, Network: NetworkDeny, MaxAutonomy: AutonomyHigh}
|
||||
@@ -20,15 +20,15 @@ func TestEvaluateExemptsNetworkToolsFromDenyByDefault(t *testing.T) {
|
||||
// Default (EnforceToolNetwork off): web_search is NOT network-denied.
|
||||
engine := NewEngine(EngineOptions{Policy: base})
|
||||
d := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt,
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow,
|
||||
})
|
||||
if d.Action == ActionDeny {
|
||||
t.Fatalf("web_search must not be network-denied by default, got deny: %s", d.ErrorString())
|
||||
if d.Action != ActionAllow {
|
||||
t.Fatalf("web_search must be allowed by default, got %q: %s", d.Action, d.ErrorString())
|
||||
}
|
||||
|
||||
// Granted permission → it actually runs (ActionAllow), not blocked.
|
||||
// Granted permission also runs, not blocked.
|
||||
allowed := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow, PermissionGranted: true,
|
||||
})
|
||||
if allowed.Action != ActionAllow {
|
||||
t.Fatalf("granted web_search must be allowed under deny by default, got %q (%s)", allowed.Action, allowed.ErrorString())
|
||||
@@ -39,7 +39,7 @@ func TestEvaluateExemptsNetworkToolsFromDenyByDefault(t *testing.T) {
|
||||
strict.EnforceToolNetwork = true
|
||||
strictEngine := NewEngine(EngineOptions{Policy: strict})
|
||||
ds := strictEngine.Evaluate(context.Background(), Request{
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow, PermissionGranted: true,
|
||||
})
|
||||
if ds.Action != ActionDeny || ds.Violation == nil || ds.Violation.Code != ViolationNetwork {
|
||||
t.Fatalf("with EnforceToolNetwork, web_search must be network-denied, got %q (%+v)", ds.Action, ds.Violation)
|
||||
@@ -67,7 +67,7 @@ func TestEngineEvaluatesReadPromptAndPersistentDecisions(t *testing.T) {
|
||||
}
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: DefaultPolicy(),
|
||||
Policy: promptWorkspaceWritePolicy(),
|
||||
Store: store,
|
||||
})
|
||||
|
||||
@@ -145,7 +145,7 @@ func TestEngineGrantScopesToFileAndDirectory(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore returned error: %v", err)
|
||||
}
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Store: store})
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: promptWorkspaceWritePolicy(), Store: store})
|
||||
|
||||
writeReq := func(path string) Request {
|
||||
return Request{
|
||||
@@ -185,6 +185,189 @@ func TestEngineGrantScopesToFileAndDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
}{
|
||||
{name: "write_file", args: map[string]any{"path": "notes.txt"}},
|
||||
{name: "edit_file", args: map[string]any{"path": "notes.txt"}},
|
||||
{name: "apply_patch", args: map[string]any{"patch": "diff --git a/notes.txt b/notes.txt\n"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: tc.name,
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: AutonomyMedium,
|
||||
Args: tc.args,
|
||||
})
|
||||
if decision.Action != ActionAllow || !decision.AutoAllowed || decision.GrantMatched {
|
||||
t.Fatalf("workspace mutation decision = %#v, want auto allow without grant", decision)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
outside := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "write_file",
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: AutonomyMedium,
|
||||
Args: map[string]any{"path": filepath.Join(t.TempDir(), "escape.txt")},
|
||||
})
|
||||
if outside.Action != ActionDeny || outside.Violation == nil || outside.Violation.Code != ViolationOutsideWorkspace {
|
||||
t.Fatalf("outside workspace mutation decision = %#v, want deny", outside)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineDoesNotAutoAllowProtectedMetadataWrites(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
}{
|
||||
{name: "write_file git hook", args: map[string]any{"path": ".git/hooks/pre-commit"}},
|
||||
{name: "edit_file zero config", args: map[string]any{"path": ".zero/config.json"}},
|
||||
{name: "apply_patch agents metadata", args: map[string]any{"patch": "--- /dev/null\n+++ b/.agents/config.json\n@@ -0,0 +1 @@\n+{}\n"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: strings.Fields(tc.name)[0],
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: AutonomyMedium,
|
||||
Args: tc.args,
|
||||
})
|
||||
if decision.Action != ActionPrompt || decision.AutoAllowed {
|
||||
t.Fatalf("protected metadata decision = %#v, want prompt without auto-allow", decision)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineDeniesApplyPatchEscapesFromPatchBody(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
|
||||
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "apply_patch",
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: AutonomyMedium,
|
||||
Args: map[string]any{
|
||||
"patch": "--- a/notes.txt\n+++ b/../escape.txt\n@@ -0,0 +1 @@\n+escape\n",
|
||||
},
|
||||
})
|
||||
|
||||
if decision.Action != ActionDeny || decision.Violation == nil || decision.Violation.Code != ViolationOutsideWorkspace {
|
||||
t.Fatalf("escaping apply_patch decision = %#v, want outside-workspace deny", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineSessionGrantDoesNotMatchSiblingFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: promptWorkspaceWritePolicy()})
|
||||
|
||||
if _, err := engine.GrantForSession(GrantInput{
|
||||
ToolName: "write_file",
|
||||
Decision: GrantAllow,
|
||||
MaxAutonomy: AutonomyMedium,
|
||||
Scope: "src/a.txt",
|
||||
ScopeKind: ScopeFile,
|
||||
}); err != nil {
|
||||
t.Fatalf("GrantForSession file: %v", err)
|
||||
}
|
||||
|
||||
request := func(path string) Request {
|
||||
return Request{
|
||||
ToolName: "write_file",
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: AutonomyMedium,
|
||||
Args: map[string]any{"path": path},
|
||||
}
|
||||
}
|
||||
|
||||
if decision := engine.Evaluate(context.Background(), request("src/a.txt")); decision.Action != ActionAllow || !decision.GrantMatched || decision.Grant == nil || !decision.Grant.Session {
|
||||
t.Fatalf("same file session grant decision = %#v, want matched session allow", decision)
|
||||
}
|
||||
if decision := engine.Evaluate(context.Background(), request("src/b.txt")); decision.Action != ActionPrompt || decision.GrantMatched {
|
||||
t.Fatalf("sibling file session grant decision = %#v, want prompt without grant match", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineDeniesAutoAllowedSymlinkEscape(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
|
||||
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "apply_patch",
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: AutonomyMedium,
|
||||
Args: map[string]any{
|
||||
"patch": "--- /dev/null\n+++ b/linked/escape.txt\n@@ -0,0 +1 @@\n+escape\n",
|
||||
},
|
||||
})
|
||||
|
||||
if decision.Action != ActionDeny || decision.Violation == nil || decision.Violation.Code != ViolationSymlinkTraversal {
|
||||
t.Fatalf("symlink apply_patch decision = %#v, want symlink traversal deny", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineGrantScopesWebFetchToHost(t *testing.T) {
|
||||
store, err := NewGrantStore(StoreOptions{
|
||||
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
|
||||
Now: fixedSandboxTime("2026-06-05T14:00:00Z"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore returned error: %v", err)
|
||||
}
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy(), Store: store})
|
||||
if _, err := engine.Grant(GrantInput{
|
||||
ToolName: "web_fetch",
|
||||
Decision: GrantAllow,
|
||||
MaxAutonomy: AutonomyMedium,
|
||||
Scope: "Example.COM:443",
|
||||
ScopeKind: ScopeHost,
|
||||
}); err != nil {
|
||||
t.Fatalf("engine.Grant host: %v", err)
|
||||
}
|
||||
|
||||
request := func(rawURL string) Request {
|
||||
return Request{
|
||||
ToolName: "web_fetch",
|
||||
SideEffect: SideEffectNetwork,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: AutonomyMedium,
|
||||
Args: map[string]any{"url": rawURL},
|
||||
}
|
||||
}
|
||||
|
||||
if d := engine.Evaluate(context.Background(), request("https://example.com/docs")); d.Action != ActionAllow || !d.GrantMatched {
|
||||
t.Fatalf("same host should auto-allow, got %#v", d)
|
||||
}
|
||||
if d := engine.Evaluate(context.Background(), request("https://api.example.com/docs")); d.Action != ActionPrompt || d.GrantMatched {
|
||||
t.Fatalf("different host should re-prompt, got %#v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "escape.txt")
|
||||
@@ -680,6 +863,12 @@ func fixedSandboxTime(value string) func() time.Time {
|
||||
return func() time.Time { return parsed }
|
||||
}
|
||||
|
||||
func promptWorkspaceWritePolicy() Policy {
|
||||
policy := DefaultPolicy()
|
||||
policy.EnforceWorkspace = false
|
||||
return policy
|
||||
}
|
||||
|
||||
func TestEvaluateAllowsWritesInsideExtraScopeRoot(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
@@ -765,8 +954,8 @@ func TestNewEngineDerivesWorkspaceRootFromScope(t *testing.T) {
|
||||
func TestEngineNetworkHostAllowed(t *testing.T) {
|
||||
// scoped enforcement requires a backend that can actually route scoped egress
|
||||
// (only sandbox-exec); otherwise scoped fails closed (collapses to deny).
|
||||
egressBackend := Backend{Name: BackendSandboxExec, Available: true, Executable: "/usr/bin/sandbox-exec", ScopedEgress: true}
|
||||
noEgressBackend := Backend{Name: BackendBubblewrap, Available: true, Executable: "/usr/bin/bwrap"}
|
||||
egressBackend := Backend{Name: BackendMacOSSeatbelt, Available: true, Executable: "/usr/bin/sandbox-exec", ScopedEgress: true}
|
||||
noEgressBackend := Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox"}
|
||||
cases := []struct {
|
||||
name string
|
||||
policy Policy
|
||||
@@ -810,7 +999,7 @@ func TestEngineNetworkHostAllowed(t *testing.T) {
|
||||
// the network policy, so even a deny / scoped-unlisted host is allowed (the
|
||||
// sandboxed-shell egress decision is separate and unaffected).
|
||||
func TestEngineNetworkHostAllowedToolNetworkDefaultExempt(t *testing.T) {
|
||||
egressBackend := Backend{Name: BackendSandboxExec, Available: true, Executable: "/usr/bin/sandbox-exec", ScopedEgress: true}
|
||||
egressBackend := Backend{Name: BackendMacOSSeatbelt, Available: true, Executable: "/usr/bin/sandbox-exec", ScopedEgress: true}
|
||||
policies := []Policy{
|
||||
{Mode: ModeEnforce, Network: NetworkDeny},
|
||||
{Mode: ModeEnforce, Network: NetworkScoped, AllowedDomains: []string{"allowed.test"}},
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ScopeKind classifies what a grant's scope covers. An empty kind is a tool-wide
|
||||
// grant (it authorizes the whole tool, the pre-scoping behavior); a file scope
|
||||
// matches one exact path; a dir scope matches the directory and any descendant.
|
||||
// ScopeKind classifies what a grant's scope covers. An empty kind is a
|
||||
// tool-wide grant (it authorizes the whole tool, the pre-scoping behavior); a
|
||||
// file scope matches one exact path; a dir scope matches the directory and any
|
||||
// descendant; a host scope matches one normalized network host.
|
||||
type ScopeKind string
|
||||
|
||||
const (
|
||||
ScopeToolWide ScopeKind = ""
|
||||
ScopeFile ScopeKind = "file"
|
||||
ScopeDir ScopeKind = "dir"
|
||||
ScopeHost ScopeKind = "host"
|
||||
)
|
||||
|
||||
// scopeArgKeys lists the path-like argument keys in priority order (most specific
|
||||
@@ -31,11 +35,16 @@ var scopeArgKeys = []struct {
|
||||
{"cwd", ScopeDir},
|
||||
}
|
||||
|
||||
// DeriveScope inspects a tool call's arguments and returns the raw (un-resolved)
|
||||
// scope string and its kind. It returns ("", ScopeToolWide) when no path-like
|
||||
// argument is present, the value is not a string, or the value points at the
|
||||
// workspace root (".") — in those cases the grant is plainly tool-wide.
|
||||
// DeriveScope inspects a tool call's arguments and returns the raw
|
||||
// (un-resolved) scope string and its kind. It returns ("", ScopeToolWide) when
|
||||
// no scoped argument is present, the value is not a string, or the value points
|
||||
// at the workspace root (".") -- in those cases the grant is plainly tool-wide.
|
||||
func DeriveScope(toolName string, args map[string]any) (string, ScopeKind) {
|
||||
if toolName == "web_fetch" {
|
||||
if host, ok := deriveHostScope(args["url"]); ok {
|
||||
return host, ScopeHost
|
||||
}
|
||||
}
|
||||
for _, candidate := range scopeArgKeys {
|
||||
value, ok := args[candidate.key].(string)
|
||||
if !ok {
|
||||
@@ -55,6 +64,19 @@ func DeriveScope(toolName string, args map[string]any) (string, ScopeKind) {
|
||||
return "", ScopeToolWide
|
||||
}
|
||||
|
||||
func deriveHostScope(value any) (string, bool) {
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "", false
|
||||
}
|
||||
host := normalizeHostScope(parsed.Hostname())
|
||||
return host, host != ""
|
||||
}
|
||||
|
||||
// resolveScopeAbs converts a raw scope to an absolute, cleaned path. A relative
|
||||
// scope is anchored to workspaceRoot; when workspaceRoot is empty it falls back
|
||||
// to filepath.Abs (process working directory). An empty scope resolves to "".
|
||||
@@ -75,35 +97,65 @@ func resolveScopeAbs(raw string, workspaceRoot string) string {
|
||||
return filepath.Clean(trimmed)
|
||||
}
|
||||
|
||||
func resolveScopeForKind(raw string, kind ScopeKind, workspaceRoot string) string {
|
||||
scope, kind := reconcileScope(strings.TrimSpace(raw), kind)
|
||||
switch kind {
|
||||
case ScopeFile, ScopeDir:
|
||||
return resolveScopeAbs(scope, workspaceRoot)
|
||||
case ScopeHost:
|
||||
return normalizeHostScope(scope)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHostScope(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if parsed, err := url.Parse(trimmed); err == nil && parsed.Host != "" {
|
||||
trimmed = parsed.Hostname()
|
||||
} else if host, _, err := net.SplitHostPort(trimmed); err == nil {
|
||||
trimmed = host
|
||||
}
|
||||
trimmed = strings.Trim(trimmed, "[]")
|
||||
trimmed = strings.TrimSuffix(trimmed, ".")
|
||||
return strings.ToLower(strings.TrimSpace(trimmed))
|
||||
}
|
||||
|
||||
// grantCovers reports whether a stored grant covers a request whose absolute
|
||||
// scope is reqAbs. A tool-wide grant covers everything (including a tool-wide
|
||||
// scope is reqScope. A tool-wide grant covers everything (including a tool-wide
|
||||
// request); a file grant matches its exact path; a dir grant matches the
|
||||
// directory itself or any descendant. A narrower grant never covers a tool-wide
|
||||
// request (reqAbs == ""), so such a request re-prompts (fail-safe).
|
||||
func grantCovers(grant Grant, reqAbs string) bool {
|
||||
// directory itself or any descendant; a host grant matches its exact normalized
|
||||
// host. A narrower grant never covers a tool-wide request (reqScope == ""), so
|
||||
// such a request re-prompts (fail-safe).
|
||||
func grantCovers(grant Grant, reqScope string) bool {
|
||||
switch grant.ScopeKind {
|
||||
case ScopeToolWide:
|
||||
return true
|
||||
case ScopeFile:
|
||||
return reqAbs != "" && reqAbs == grant.Scope
|
||||
return reqScope != "" && reqScope == grant.Scope
|
||||
case ScopeDir:
|
||||
if reqAbs == "" || grant.Scope == "" {
|
||||
if reqScope == "" || grant.Scope == "" {
|
||||
return false
|
||||
}
|
||||
if reqAbs == grant.Scope {
|
||||
if reqScope == grant.Scope {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(reqAbs, grant.Scope+string(filepath.Separator))
|
||||
return strings.HasPrefix(reqScope, grant.Scope+string(filepath.Separator))
|
||||
case ScopeHost:
|
||||
return reqScope != "" && normalizeHostScope(reqScope) == normalizeHostScope(grant.Scope)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// scopeSpecificity ranks scope kinds so the most precise covering allow wins when
|
||||
// several grants match the same request (file > dir > tool-wide).
|
||||
// several grants match the same request.
|
||||
func scopeSpecificity(kind ScopeKind) int {
|
||||
switch kind {
|
||||
case ScopeFile:
|
||||
case ScopeFile, ScopeHost:
|
||||
return 2
|
||||
case ScopeDir:
|
||||
return 1
|
||||
|
||||
@@ -165,6 +165,8 @@ func TestDeriveScope(t *testing.T) {
|
||||
{name: "path wins over cwd", tool: "x", args: map[string]any{"cwd": "a", "path": "b"}, wantRaw: "b", wantKind: ScopeFile},
|
||||
{name: "non-string path ignored", tool: "write_file", args: map[string]any{"path": 42}, wantRaw: "", wantKind: ScopeToolWide},
|
||||
{name: "whitespace path is tool-wide", tool: "write_file", args: map[string]any{"path": " "}, wantRaw: "", wantKind: ScopeToolWide},
|
||||
{name: "web fetch url host", tool: "web_fetch", args: map[string]any{"url": "https://Example.COM:443/path?q=1"}, wantRaw: "example.com", wantKind: ScopeHost},
|
||||
{name: "web fetch invalid url is tool-wide", tool: "web_fetch", args: map[string]any{"url": "://bad"}, wantRaw: "", wantKind: ScopeToolWide},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -224,6 +226,7 @@ func TestGrantCovers(t *testing.T) {
|
||||
toolWide := Grant{ScopeKind: ScopeToolWide}
|
||||
fileGrant := Grant{Scope: file, ScopeKind: ScopeFile}
|
||||
dirGrant := Grant{Scope: dir, ScopeKind: ScopeDir}
|
||||
hostGrant := Grant{Scope: "example.com", ScopeKind: ScopeHost}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -241,6 +244,9 @@ func TestGrantCovers(t *testing.T) {
|
||||
{"dir does not cover a sibling dir prefix", dirGrant, siblingDir, false},
|
||||
{"dir does not cover its parent", dirGrant, parent, false},
|
||||
{"dir does not cover an empty request", dirGrant, "", false},
|
||||
{"host covers exact host case-insensitively", hostGrant, "EXAMPLE.com", true},
|
||||
{"host does not cover subdomain", hostGrant, "api.example.com", false},
|
||||
{"host does not cover empty request", hostGrant, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
+193
-23
@@ -19,12 +19,13 @@ const grantSchemaVersion = 2
|
||||
|
||||
type Grant struct {
|
||||
ToolName string `json:"toolName"`
|
||||
Scope string `json:"scope,omitempty"` // absolute, cleaned path; "" = tool-wide
|
||||
ScopeKind ScopeKind `json:"scopeKind,omitempty"` // file | dir | "" (tool-wide)
|
||||
Scope string `json:"scope,omitempty"` // absolute path, host, or "" for tool-wide
|
||||
ScopeKind ScopeKind `json:"scopeKind,omitempty"` // file | dir | host | "" (tool-wide)
|
||||
Decision GrantDecision `json:"decision"`
|
||||
MaxAutonomy Autonomy `json:"maxAutonomy"`
|
||||
ApprovedAt string `json:"approvedAt"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Session bool `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
type StoreOptions struct {
|
||||
@@ -38,9 +39,9 @@ type GrantInput struct {
|
||||
Decision GrantDecision
|
||||
MaxAutonomy Autonomy
|
||||
Reason string
|
||||
// Scope is the raw (possibly relative) path the grant covers; ScopeKind
|
||||
// classifies it. engine.Grant resolves Scope to an absolute path before
|
||||
// persisting. Both empty means a tool-wide grant.
|
||||
// Scope is the raw path or host the grant covers; ScopeKind classifies it.
|
||||
// engine.Grant resolves path scopes to absolute paths and normalizes host
|
||||
// scopes before persisting. Both empty means a tool-wide grant.
|
||||
Scope string
|
||||
ScopeKind ScopeKind
|
||||
}
|
||||
@@ -51,8 +52,9 @@ type GrantLookup struct {
|
||||
}
|
||||
|
||||
type grantFile struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Grants map[string][]Grant `json:"grants"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Grants map[string][]Grant `json:"grants"`
|
||||
CommandPrefixes map[string][]CommandPrefixGrant `json:"commandPrefixes,omitempty"`
|
||||
}
|
||||
|
||||
type GrantStore struct {
|
||||
@@ -150,12 +152,42 @@ func (store *GrantStore) Grant(input GrantInput) (Grant, error) {
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
// Lookup returns the grant that governs a tool call whose absolute scope is
|
||||
// reqScopeAbs (empty for a tool-wide request, e.g. a shell command with no cwd).
|
||||
func (store *GrantStore) GrantCommandPrefix(input CommandPrefixInput) (CommandPrefixGrant, error) {
|
||||
grant, err := createCommandPrefixGrant(input, store.now)
|
||||
if err != nil {
|
||||
return CommandPrefixGrant{}, err
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
if err != nil {
|
||||
return CommandPrefixGrant{}, err
|
||||
}
|
||||
bucket := state.CommandPrefixes[grant.ToolName]
|
||||
replaced := false
|
||||
for i := range bucket {
|
||||
if sameStringSlice(bucket[i].Prefix, grant.Prefix) {
|
||||
bucket[i] = grant
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
bucket = append(bucket, grant)
|
||||
}
|
||||
state.CommandPrefixes[grant.ToolName] = bucket
|
||||
if err := store.writeState(state); err != nil {
|
||||
return CommandPrefixGrant{}, err
|
||||
}
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
// Lookup returns the grant that governs a tool call whose normalized scope is
|
||||
// reqScope (empty for a tool-wide request, e.g. a shell command with no cwd).
|
||||
// Among the tool's grants that cover the request, a covering deny wins outright
|
||||
// (regardless of autonomy); otherwise the most-specific covering allow whose
|
||||
// recorded MaxAutonomy admits the requested autonomy is returned.
|
||||
func (store *GrantStore) Lookup(toolName string, reqScopeAbs string, requestedAutonomy Autonomy) (GrantLookup, error) {
|
||||
func (store *GrantStore) Lookup(toolName string, reqScope string, requestedAutonomy Autonomy) (GrantLookup, error) {
|
||||
if err := ValidateToolName(toolName); err != nil {
|
||||
return GrantLookup{}, err
|
||||
}
|
||||
@@ -170,15 +202,42 @@ func (store *GrantStore) Lookup(toolName string, reqScopeAbs string, requestedAu
|
||||
return GrantLookup{}, err
|
||||
}
|
||||
bucket := state.Grants[strings.TrimSpace(toolName)]
|
||||
return lookupGrantBucket(bucket, reqScope, requested), nil
|
||||
}
|
||||
|
||||
func (store *GrantStore) LookupCommandPrefix(toolName string, command []string) (CommandPrefixGrant, bool, error) {
|
||||
if err := ValidateToolName(toolName); err != nil {
|
||||
return CommandPrefixGrant{}, false, err
|
||||
}
|
||||
if _, ok := NormalizeCommandPrefix(command); !ok {
|
||||
return CommandPrefixGrant{}, false, nil
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
if err != nil {
|
||||
return CommandPrefixGrant{}, false, err
|
||||
}
|
||||
bucket := state.CommandPrefixes[strings.TrimSpace(toolName)]
|
||||
for _, grant := range bucket {
|
||||
if hasStringPrefix(command, grant.Prefix) {
|
||||
grant.Prefix = append([]string(nil), grant.Prefix...)
|
||||
return grant, true, nil
|
||||
}
|
||||
}
|
||||
return CommandPrefixGrant{}, false, nil
|
||||
}
|
||||
|
||||
func lookupGrantBucket(bucket []Grant, reqScope string, requested Autonomy) GrantLookup {
|
||||
var bestAllow *Grant
|
||||
for i := range bucket {
|
||||
grant := bucket[i]
|
||||
if !grantCovers(grant, reqScopeAbs) {
|
||||
if !grantCovers(grant, reqScope) {
|
||||
continue
|
||||
}
|
||||
if grant.Decision == GrantDeny {
|
||||
covering := grant
|
||||
return GrantLookup{Matched: true, Grant: covering}, nil
|
||||
return GrantLookup{Matched: true, Grant: covering}
|
||||
}
|
||||
if !autonomyAllowed(requested, grant.MaxAutonomy) {
|
||||
continue
|
||||
@@ -189,9 +248,9 @@ func (store *GrantStore) Lookup(toolName string, reqScopeAbs string, requestedAu
|
||||
}
|
||||
}
|
||||
if bestAllow != nil {
|
||||
return GrantLookup{Matched: true, Grant: *bestAllow}, nil
|
||||
return GrantLookup{Matched: true, Grant: *bestAllow}
|
||||
}
|
||||
return GrantLookup{}, nil
|
||||
return GrantLookup{}
|
||||
}
|
||||
|
||||
func (store *GrantStore) List() ([]Grant, error) {
|
||||
@@ -220,6 +279,32 @@ func (store *GrantStore) List() ([]Grant, error) {
|
||||
return grants, nil
|
||||
}
|
||||
|
||||
func (store *GrantStore) ListCommandPrefixes() ([]CommandPrefixGrant, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(state.CommandPrefixes))
|
||||
for name := range state.CommandPrefixes {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
grants := make([]CommandPrefixGrant, 0, len(names))
|
||||
for _, name := range names {
|
||||
bucket := append([]CommandPrefixGrant(nil), state.CommandPrefixes[name]...)
|
||||
sort.Slice(bucket, func(i, j int) bool {
|
||||
return strings.Join(bucket[i].Prefix, "\x00") < strings.Join(bucket[j].Prefix, "\x00")
|
||||
})
|
||||
for i := range bucket {
|
||||
bucket[i].Prefix = append([]string(nil), bucket[i].Prefix...)
|
||||
}
|
||||
grants = append(grants, bucket...)
|
||||
}
|
||||
return grants, nil
|
||||
}
|
||||
|
||||
func (store *GrantStore) Revoke(toolName string) (int, error) {
|
||||
if err := ValidateToolName(toolName); err != nil {
|
||||
return 0, err
|
||||
@@ -232,11 +317,13 @@ func (store *GrantStore) Revoke(toolName string) (int, error) {
|
||||
}
|
||||
key := strings.TrimSpace(toolName)
|
||||
bucket, ok := state.Grants[key]
|
||||
if !ok || len(bucket) == 0 {
|
||||
prefixes := state.CommandPrefixes[key]
|
||||
if (!ok || len(bucket) == 0) && len(prefixes) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
count := len(bucket)
|
||||
count := len(bucket) + len(prefixes)
|
||||
delete(state.Grants, key)
|
||||
delete(state.CommandPrefixes, key)
|
||||
if err := store.writeState(state); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -302,6 +389,9 @@ func (store *GrantStore) Clear() (int, error) {
|
||||
for _, bucket := range state.Grants {
|
||||
count += len(bucket)
|
||||
}
|
||||
for _, bucket := range state.CommandPrefixes {
|
||||
count += len(bucket)
|
||||
}
|
||||
if count == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -312,7 +402,11 @@ func (store *GrantStore) Clear() (int, error) {
|
||||
}
|
||||
|
||||
func FormatGrantList(grants []Grant) string {
|
||||
if len(grants) == 0 {
|
||||
return FormatGrantListWithCommandPrefixes(grants, nil)
|
||||
}
|
||||
|
||||
func FormatGrantListWithCommandPrefixes(grants []Grant, prefixes []CommandPrefixGrant) string {
|
||||
if len(grants) == 0 && len(prefixes) == 0 {
|
||||
return "No persistent sandbox grants."
|
||||
}
|
||||
lines := []string{"Sandbox Grants:"}
|
||||
@@ -327,6 +421,13 @@ func FormatGrantList(grants []Grant) string {
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
for _, grant := range prefixes {
|
||||
line := fmt.Sprintf(" %s (`%s`) [command-prefix] approved %s", grant.ToolName, strings.Join(grant.Prefix, " "), grant.ApprovedAt)
|
||||
if grant.Reason != "" {
|
||||
line += " - " + redaction.RedactString(grant.Reason, redaction.Options{})
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -368,6 +469,23 @@ func createGrant(input GrantInput, now func() time.Time) (Grant, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createCommandPrefixGrant(input CommandPrefixInput, now func() time.Time) (CommandPrefixGrant, error) {
|
||||
toolName := strings.TrimSpace(input.ToolName)
|
||||
if err := ValidateToolName(toolName); err != nil {
|
||||
return CommandPrefixGrant{}, err
|
||||
}
|
||||
prefix, ok := NormalizeCommandPrefix(input.Prefix)
|
||||
if !ok {
|
||||
return CommandPrefixGrant{}, fmt.Errorf("invalid command prefix")
|
||||
}
|
||||
return CommandPrefixGrant{
|
||||
ToolName: toolName,
|
||||
Prefix: prefix,
|
||||
ApprovedAt: now().UTC().Format(time.RFC3339),
|
||||
Reason: redaction.RedactString(strings.TrimSpace(input.Reason), redaction.Options{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normalizeScopeKind validates and lower-cases a scope kind. An empty kind is the
|
||||
// tool-wide grant.
|
||||
func normalizeScopeKind(kind ScopeKind) (ScopeKind, error) {
|
||||
@@ -378,8 +496,10 @@ func normalizeScopeKind(kind ScopeKind) (ScopeKind, error) {
|
||||
return ScopeFile, nil
|
||||
case ScopeDir:
|
||||
return ScopeDir, nil
|
||||
case ScopeHost:
|
||||
return ScopeHost, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid sandbox scope kind %q. Expected file, dir, or empty", kind)
|
||||
return "", fmt.Errorf("invalid sandbox scope kind %q. Expected file, dir, host, or empty", kind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +510,13 @@ func reconcileScope(scope string, kind ScopeKind) (string, ScopeKind) {
|
||||
if kind == ScopeToolWide || scope == "" {
|
||||
return "", ScopeToolWide
|
||||
}
|
||||
if kind == ScopeHost {
|
||||
host := normalizeHostScope(scope)
|
||||
if host == "" {
|
||||
return "", ScopeToolWide
|
||||
}
|
||||
return host, ScopeHost
|
||||
}
|
||||
return scope, kind
|
||||
}
|
||||
|
||||
@@ -405,13 +532,15 @@ func (store *GrantStore) readState() (grantFile, error) {
|
||||
// directly to a single Grant — can be decoded and migrated separately from the
|
||||
// current (v2) map[tool][]Grant shape.
|
||||
var head struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Grants json.RawMessage `json:"grants"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Grants json.RawMessage `json:"grants"`
|
||||
CommandPrefixes json.RawMessage `json:"commandPrefixes"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &head); err != nil {
|
||||
return grantFile{}, store.invalidGrantFile(err)
|
||||
}
|
||||
buckets := map[string][]Grant{}
|
||||
commandPrefixBuckets := map[string][]CommandPrefixGrant{}
|
||||
switch head.SchemaVersion {
|
||||
case 1:
|
||||
var legacy map[string]Grant
|
||||
@@ -432,6 +561,11 @@ func (store *GrantStore) readState() (grantFile, error) {
|
||||
return grantFile{}, store.invalidGrantFile(err)
|
||||
}
|
||||
}
|
||||
if len(head.CommandPrefixes) > 0 {
|
||||
if err := json.Unmarshal(head.CommandPrefixes, &commandPrefixBuckets); err != nil {
|
||||
return grantFile{}, store.invalidGrantFile(err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return grantFile{}, fmt.Errorf("invalid sandbox grants file at %s: unsupported schemaVersion", store.filePath)
|
||||
}
|
||||
@@ -451,7 +585,21 @@ func (store *GrantStore) readState() (grantFile, error) {
|
||||
normalized[key] = append(normalized[key], ng)
|
||||
}
|
||||
}
|
||||
return grantFile{SchemaVersion: grantSchemaVersion, Grants: normalized}, nil
|
||||
normalizedPrefixes := map[string][]CommandPrefixGrant{}
|
||||
for name, bucket := range commandPrefixBuckets {
|
||||
key := strings.TrimSpace(name)
|
||||
if err := ValidateToolName(key); err != nil {
|
||||
return grantFile{}, store.invalidGrantFile(err)
|
||||
}
|
||||
for _, grant := range bucket {
|
||||
ng, err := normalizeStoredCommandPrefixGrant(key, grant)
|
||||
if err != nil {
|
||||
return grantFile{}, store.invalidGrantFile(err)
|
||||
}
|
||||
normalizedPrefixes[key] = append(normalizedPrefixes[key], ng)
|
||||
}
|
||||
}
|
||||
return grantFile{SchemaVersion: grantSchemaVersion, Grants: normalized, CommandPrefixes: normalizedPrefixes}, nil
|
||||
}
|
||||
|
||||
func (store *GrantStore) invalidGrantFile(err error) error {
|
||||
@@ -508,10 +656,32 @@ func normalizeStoredGrant(name string, grant Grant) (Grant, error) {
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
func normalizeStoredCommandPrefixGrant(name string, grant CommandPrefixGrant) (CommandPrefixGrant, error) {
|
||||
if strings.TrimSpace(grant.ToolName) == "" {
|
||||
grant.ToolName = name
|
||||
}
|
||||
if strings.TrimSpace(grant.ToolName) != name {
|
||||
return CommandPrefixGrant{}, fmt.Errorf("command prefix key %q does not match toolName %q", name, grant.ToolName)
|
||||
}
|
||||
if strings.TrimSpace(grant.ApprovedAt) == "" {
|
||||
return CommandPrefixGrant{}, fmt.Errorf("command prefix grant %s approvedAt is required", name)
|
||||
}
|
||||
prefix, ok := NormalizeCommandPrefix(grant.Prefix)
|
||||
if !ok {
|
||||
return CommandPrefixGrant{}, fmt.Errorf("invalid command prefix for %s", name)
|
||||
}
|
||||
grant.ToolName = name
|
||||
grant.Prefix = prefix
|
||||
grant.ApprovedAt = strings.TrimSpace(grant.ApprovedAt)
|
||||
grant.Reason = redaction.RedactString(strings.TrimSpace(grant.Reason), redaction.Options{})
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
func emptyGrantState() grantFile {
|
||||
return grantFile{
|
||||
SchemaVersion: grantSchemaVersion,
|
||||
Grants: map[string][]Grant{},
|
||||
SchemaVersion: grantSchemaVersion,
|
||||
Grants: map[string][]Grant{},
|
||||
CommandPrefixes: map[string][]CommandPrefixGrant{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,88 @@ func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantStorePersistsCommandPrefixes(t *testing.T) {
|
||||
store, err := NewGrantStore(StoreOptions{
|
||||
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
|
||||
Now: fixedSandboxTime("2026-06-05T14:30:00Z"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore returned error: %v", err)
|
||||
}
|
||||
grant, err := store.GrantCommandPrefix(CommandPrefixInput{
|
||||
ToolName: "bash",
|
||||
Prefix: []string{"git", "status"},
|
||||
Reason: "status checks",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GrantCommandPrefix returned error: %v", err)
|
||||
}
|
||||
if grant.ApprovedAt != "2026-06-05T14:30:00Z" {
|
||||
t.Fatalf("approvedAt = %q, want fixed timestamp", grant.ApprovedAt)
|
||||
}
|
||||
|
||||
// Re-granting the same prefix updates instead of duplicating.
|
||||
if _, err := store.GrantCommandPrefix(CommandPrefixInput{ToolName: "bash", Prefix: []string{"git", "status"}, Reason: "updated"}); err != nil {
|
||||
t.Fatalf("GrantCommandPrefix update returned error: %v", err)
|
||||
}
|
||||
reopened, err := NewGrantStore(StoreOptions{FilePath: store.FilePath()})
|
||||
if err != nil {
|
||||
t.Fatalf("reopen grant store: %v", err)
|
||||
}
|
||||
prefixes, err := reopened.ListCommandPrefixes()
|
||||
if err != nil {
|
||||
t.Fatalf("ListCommandPrefixes returned error: %v", err)
|
||||
}
|
||||
if len(prefixes) != 1 || prefixes[0].ToolName != "bash" || !sameStringSlice(prefixes[0].Prefix, []string{"git", "status"}) || prefixes[0].Reason != "updated" {
|
||||
t.Fatalf("unexpected command prefixes: %#v", prefixes)
|
||||
}
|
||||
match, matched, err := reopened.LookupCommandPrefix("bash", []string{"git", "status", "--short"})
|
||||
if err != nil {
|
||||
t.Fatalf("LookupCommandPrefix returned error: %v", err)
|
||||
}
|
||||
if !matched || !sameStringSlice(match.Prefix, []string{"git", "status"}) {
|
||||
t.Fatalf("lookup = (%#v,%t), want git status match", match, matched)
|
||||
}
|
||||
if _, matched, err := reopened.LookupCommandPrefix("bash", []string{"git", "diff"}); err != nil || matched {
|
||||
t.Fatalf("git diff lookup = matched %t err %v, want no match", matched, err)
|
||||
}
|
||||
text := FormatGrantListWithCommandPrefixes(nil, prefixes)
|
||||
for _, want := range []string{"Sandbox Grants:", "bash", "`git status`", "command-prefix", "updated"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("formatted grants = %q, missing %q", text, want)
|
||||
}
|
||||
}
|
||||
|
||||
revoked, err := reopened.Revoke("bash")
|
||||
if err != nil {
|
||||
t.Fatalf("Revoke returned error: %v", err)
|
||||
}
|
||||
if revoked != 1 {
|
||||
t.Fatalf("revoked = %d, want 1", revoked)
|
||||
}
|
||||
if prefixes, err := reopened.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
|
||||
t.Fatalf("prefixes after revoke = %#v err %v, want none", prefixes, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantStoreRejectsUnsafeCommandPrefixes(t *testing.T) {
|
||||
store, err := NewGrantStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore returned error: %v", err)
|
||||
}
|
||||
for _, prefix := range [][]string{
|
||||
{"find"},
|
||||
{"xargs"},
|
||||
{"python", "script.py"},
|
||||
{"./script.sh"},
|
||||
{"git"},
|
||||
} {
|
||||
if _, err := store.GrantCommandPrefix(CommandPrefixInput{ToolName: "bash", Prefix: prefix}); err == nil {
|
||||
t.Fatalf("GrantCommandPrefix(%#v) succeeded, want validation error", prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantStoreRejectsUnsafeInputsAndMalformedFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := NewGrantStore(StoreOptions{FilePath: filepath.Join(root, "sandbox-grants.json")})
|
||||
@@ -102,6 +184,21 @@ func TestGrantStoreRejectsUnsafeInputsAndMalformedFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantStoreRejectsUnsafeStoredCommandPrefix(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "sandbox-grants.json")
|
||||
if err := writeText(path, `{"schemaVersion":2,"grants":{},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["find"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`); err != nil {
|
||||
t.Fatalf("write malformed command prefix: %v", err)
|
||||
}
|
||||
store, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore returned error: %v", err)
|
||||
}
|
||||
if _, err := store.ListCommandPrefixes(); err == nil || !strings.Contains(err.Error(), "invalid command prefix") {
|
||||
t.Fatalf("expected invalid command prefix error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGrantPathUsesOverrideAndConfigHome(t *testing.T) {
|
||||
override := filepath.Join(t.TempDir(), "custom.json")
|
||||
path, err := ResolveGrantPath(map[string]string{"ZERO_SANDBOX_GRANTS_PATH": override})
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//go:build linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ApplyLandlockFilesystemProfile applies the helper's fallback Linux filesystem
|
||||
// sandbox to the current process. The caller must exec the final command after
|
||||
// this returns.
|
||||
func ApplyLandlockFilesystemProfile(profile PermissionProfile, cwd string, allowNetworkForProxy bool, proxyRouteSpec string) error {
|
||||
if err := validateLandlockProfile(profile, allowNetworkForProxy, proxyRouteSpec); err != nil {
|
||||
return err
|
||||
}
|
||||
needsFilesystemRules := profile.FileSystem.Kind != FileSystemUnrestricted
|
||||
needsNetworkDeny := profile.Network.Mode == NetworkDeny
|
||||
if !needsFilesystemRules && !needsNetworkDeny {
|
||||
return nil
|
||||
}
|
||||
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
|
||||
return fmt.Errorf("set no_new_privs: %w", err)
|
||||
}
|
||||
if needsNetworkDeny {
|
||||
if err := ApplyLinuxNetworkDeny(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !needsFilesystemRules {
|
||||
return nil
|
||||
}
|
||||
writeRoots := landlockWritableRoots(profile.FileSystem, cwd)
|
||||
if len(writeRoots) == 0 {
|
||||
return errors.New("Landlock requires at least one writable root")
|
||||
}
|
||||
return installLandlockFilesystemRules(writeRoots)
|
||||
}
|
||||
|
||||
func validateLandlockProfile(profile PermissionProfile, allowNetworkForProxy bool, proxyRouteSpec string) error {
|
||||
if allowNetworkForProxy || strings.TrimSpace(proxyRouteSpec) != "" {
|
||||
return errors.New("proxy-routed networking requires the bubblewrap helper mode")
|
||||
}
|
||||
if profile.Network.ProxyRequired || profile.Network.Mode == NetworkScoped {
|
||||
return errors.New("scoped networking requires a backend with proxy routing support")
|
||||
}
|
||||
fs := profile.FileSystem
|
||||
if fs.Kind != FileSystemRestricted {
|
||||
return nil
|
||||
}
|
||||
if len(fs.DenyRead) > 0 {
|
||||
return errors.New("deny-read paths require the bubblewrap helper mode")
|
||||
}
|
||||
if len(fs.DenyWrite) > 0 {
|
||||
return errors.New("deny-write paths require the bubblewrap helper mode")
|
||||
}
|
||||
for _, root := range fs.WriteRoots {
|
||||
if len(root.ReadOnlySubpaths) > 0 || len(root.ProtectedMetadataNames) > 0 {
|
||||
return errors.New("read-only write-root subpaths require the bubblewrap helper mode")
|
||||
}
|
||||
}
|
||||
if !landlockHasFullReadAccess(fs) {
|
||||
return errors.New("restricted read roots are not supported by Landlock helper mode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func landlockHasFullReadAccess(fs FileSystemPolicy) bool {
|
||||
if fs.Kind == FileSystemUnrestricted {
|
||||
return true
|
||||
}
|
||||
for _, root := range fs.ReadRoots {
|
||||
if filepath.Clean(root) == string(filepath.Separator) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func landlockWritableRoots(fs FileSystemPolicy, cwd string) []string {
|
||||
roots := make([]string, 0, len(fs.WriteRoots)+1)
|
||||
add := func(root string) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return
|
||||
}
|
||||
if abs, err := filepath.Abs(root); err == nil {
|
||||
root = abs
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = resolved
|
||||
}
|
||||
if pathExists(root) {
|
||||
roots = append(roots, filepath.Clean(root))
|
||||
}
|
||||
}
|
||||
add(cwd)
|
||||
for _, root := range fs.WriteRoots {
|
||||
add(root.Root)
|
||||
}
|
||||
return dedupeStrings(roots)
|
||||
}
|
||||
|
||||
func installLandlockFilesystemRules(writeRoots []string) error {
|
||||
abi, err := queryLandlockABI()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
handledAccess := landlockHandledFilesystemAccess(abi)
|
||||
if handledAccess == 0 {
|
||||
return errors.New("kernel did not report a usable Landlock filesystem ABI")
|
||||
}
|
||||
rulesetAttr := unix.LandlockRulesetAttr{Access_fs: handledAccess}
|
||||
rulesetFD, err := landlockCreateRuleset(&rulesetAttr, unsafe.Sizeof(rulesetAttr), 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create ruleset: %w", err)
|
||||
}
|
||||
defer unix.Close(rulesetFD)
|
||||
|
||||
readAccess := landlockReadFilesystemAccess(abi)
|
||||
if err := landlockAddPathRule(rulesetFD, string(filepath.Separator), readAccess); err != nil {
|
||||
return err
|
||||
}
|
||||
if pathExists("/dev/null") {
|
||||
if err := landlockAddPathRule(rulesetFD, "/dev/null", landlockFileReadWriteAccess(abi)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, root := range writeRoots {
|
||||
if err := landlockAddPathRule(rulesetFD, root, handledAccess); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := landlockRestrictSelf(rulesetFD); err != nil {
|
||||
return fmt.Errorf("restrict self: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryLandlockABI() (int, error) {
|
||||
abi, err := landlockCreateRuleset(nil, 0, unix.LANDLOCK_CREATE_RULESET_VERSION)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("query ABI: %w", err)
|
||||
}
|
||||
if abi <= 0 {
|
||||
return 0, fmt.Errorf("query ABI returned %d", abi)
|
||||
}
|
||||
return abi, nil
|
||||
}
|
||||
|
||||
func landlockCreateRuleset(attr *unix.LandlockRulesetAttr, size uintptr, flags int) (int, error) {
|
||||
var attrPtr uintptr
|
||||
if attr != nil {
|
||||
attrPtr = uintptr(unsafe.Pointer(attr))
|
||||
}
|
||||
fd, _, errno := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, attrPtr, size, uintptr(flags))
|
||||
if errno != 0 {
|
||||
return -1, errno
|
||||
}
|
||||
return int(fd), nil
|
||||
}
|
||||
|
||||
func landlockAddPathRule(rulesetFD int, path string, allowedAccess uint64) error {
|
||||
pathFD, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("open rule path %s: %w", path, err)
|
||||
}
|
||||
defer unix.Close(pathFD)
|
||||
rule := unix.LandlockPathBeneathAttr{
|
||||
Allowed_access: allowedAccess,
|
||||
Parent_fd: int32(pathFD),
|
||||
}
|
||||
_, _, errno := unix.Syscall6(
|
||||
unix.SYS_LANDLOCK_ADD_RULE,
|
||||
uintptr(rulesetFD),
|
||||
uintptr(unix.LANDLOCK_RULE_PATH_BENEATH),
|
||||
uintptr(unsafe.Pointer(&rule)),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if errno != 0 {
|
||||
return fmt.Errorf("add path rule %s: %w", path, errno)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func landlockRestrictSelf(rulesetFD int) error {
|
||||
_, _, errno := unix.Syscall(unix.SYS_LANDLOCK_RESTRICT_SELF, uintptr(rulesetFD), 0, 0)
|
||||
if errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func landlockHandledFilesystemAccess(abi int) uint64 {
|
||||
access := uint64(unix.LANDLOCK_ACCESS_FS_EXECUTE |
|
||||
unix.LANDLOCK_ACCESS_FS_WRITE_FILE |
|
||||
unix.LANDLOCK_ACCESS_FS_READ_FILE |
|
||||
unix.LANDLOCK_ACCESS_FS_READ_DIR |
|
||||
unix.LANDLOCK_ACCESS_FS_REMOVE_DIR |
|
||||
unix.LANDLOCK_ACCESS_FS_REMOVE_FILE |
|
||||
unix.LANDLOCK_ACCESS_FS_MAKE_CHAR |
|
||||
unix.LANDLOCK_ACCESS_FS_MAKE_DIR |
|
||||
unix.LANDLOCK_ACCESS_FS_MAKE_REG |
|
||||
unix.LANDLOCK_ACCESS_FS_MAKE_SOCK |
|
||||
unix.LANDLOCK_ACCESS_FS_MAKE_FIFO |
|
||||
unix.LANDLOCK_ACCESS_FS_MAKE_BLOCK |
|
||||
unix.LANDLOCK_ACCESS_FS_MAKE_SYM)
|
||||
if abi >= 2 {
|
||||
access |= unix.LANDLOCK_ACCESS_FS_REFER
|
||||
}
|
||||
if abi >= 3 {
|
||||
access |= unix.LANDLOCK_ACCESS_FS_TRUNCATE
|
||||
}
|
||||
if abi >= 5 {
|
||||
access |= unix.LANDLOCK_ACCESS_FS_IOCTL_DEV
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
func landlockReadFilesystemAccess(abi int) uint64 {
|
||||
access := uint64(unix.LANDLOCK_ACCESS_FS_EXECUTE |
|
||||
unix.LANDLOCK_ACCESS_FS_READ_FILE |
|
||||
unix.LANDLOCK_ACCESS_FS_READ_DIR)
|
||||
if abi >= 5 {
|
||||
access |= unix.LANDLOCK_ACCESS_FS_IOCTL_DEV
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
func landlockFileReadWriteAccess(abi int) uint64 {
|
||||
access := uint64(unix.LANDLOCK_ACCESS_FS_READ_FILE |
|
||||
unix.LANDLOCK_ACCESS_FS_WRITE_FILE)
|
||||
if abi >= 3 {
|
||||
access |= unix.LANDLOCK_ACCESS_FS_TRUNCATE
|
||||
}
|
||||
if abi >= 5 {
|
||||
access |= unix.LANDLOCK_ACCESS_FS_IOCTL_DEV
|
||||
}
|
||||
return access
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//go:build linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestValidateLandlockProfileRejectsRestrictedReadRoots(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{root},
|
||||
WriteRoots: []WritableRoot{{Root: root}},
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkAllow},
|
||||
}
|
||||
err := validateLandlockProfile(profile, false, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "restricted read roots") {
|
||||
t.Fatalf("validateLandlockProfile error = %v, want restricted read roots rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLandlockProfileRejectsScopedAndProxyNetworking(t *testing.T) {
|
||||
profile := landlockTestProfile(t.TempDir(), NetworkPolicy{Mode: NetworkScoped, ProxyRequired: true})
|
||||
if err := validateLandlockProfile(profile, false, ""); err == nil || !strings.Contains(err.Error(), "scoped networking") {
|
||||
t.Fatalf("validateLandlockProfile scoped error = %v", err)
|
||||
}
|
||||
profile.Network = NetworkPolicy{Mode: NetworkAllow}
|
||||
if err := validateLandlockProfile(profile, true, ""); err == nil || !strings.Contains(err.Error(), "proxy-routed networking") {
|
||||
t.Fatalf("validateLandlockProfile proxy error = %v", err)
|
||||
}
|
||||
if err := validateLandlockProfile(profile, false, "proxy=127.0.0.1:1"); err == nil || !strings.Contains(err.Error(), "proxy-routed networking") {
|
||||
t.Fatalf("validateLandlockProfile proxy spec error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLandlockProfileAllowsNetworkDeny(t *testing.T) {
|
||||
profile := landlockTestProfile(t.TempDir(), NetworkPolicy{Mode: NetworkDeny})
|
||||
if err := validateLandlockProfile(profile, false, ""); err != nil {
|
||||
t.Fatalf("validateLandlockProfile network deny: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLandlockProfileRejectsUnsupportedFilesystemCarveouts(t *testing.T) {
|
||||
profile := landlockTestProfile(t.TempDir(), NetworkPolicy{Mode: NetworkAllow})
|
||||
profile.FileSystem.DenyRead = []string{t.TempDir()}
|
||||
if err := validateLandlockProfile(profile, false, ""); err == nil || !strings.Contains(err.Error(), "deny-read") {
|
||||
t.Fatalf("validateLandlockProfile deny-read error = %v", err)
|
||||
}
|
||||
profile = landlockTestProfile(t.TempDir(), NetworkPolicy{Mode: NetworkAllow})
|
||||
profile.FileSystem.DenyWrite = []string{t.TempDir()}
|
||||
if err := validateLandlockProfile(profile, false, ""); err == nil || !strings.Contains(err.Error(), "deny-write") {
|
||||
t.Fatalf("validateLandlockProfile deny-write error = %v", err)
|
||||
}
|
||||
profile = landlockTestProfile(t.TempDir(), NetworkPolicy{Mode: NetworkAllow})
|
||||
profile.FileSystem.WriteRoots[0].ProtectedMetadataNames = []string{".git"}
|
||||
if err := validateLandlockProfile(profile, false, ""); err == nil || !strings.Contains(err.Error(), "read-only write-root subpaths") {
|
||||
t.Fatalf("validateLandlockProfile metadata error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLandlockAccessMasksFollowABI(t *testing.T) {
|
||||
abi1 := landlockHandledFilesystemAccess(1)
|
||||
for _, unexpected := range []uint64{
|
||||
unix.LANDLOCK_ACCESS_FS_REFER,
|
||||
unix.LANDLOCK_ACCESS_FS_TRUNCATE,
|
||||
unix.LANDLOCK_ACCESS_FS_IOCTL_DEV,
|
||||
} {
|
||||
if abi1&unexpected != 0 {
|
||||
t.Fatalf("ABI 1 mask unexpectedly includes %#x", unexpected)
|
||||
}
|
||||
}
|
||||
abi5 := landlockHandledFilesystemAccess(5)
|
||||
for _, expected := range []uint64{
|
||||
unix.LANDLOCK_ACCESS_FS_EXECUTE,
|
||||
unix.LANDLOCK_ACCESS_FS_WRITE_FILE,
|
||||
unix.LANDLOCK_ACCESS_FS_READ_FILE,
|
||||
unix.LANDLOCK_ACCESS_FS_READ_DIR,
|
||||
unix.LANDLOCK_ACCESS_FS_REFER,
|
||||
unix.LANDLOCK_ACCESS_FS_TRUNCATE,
|
||||
unix.LANDLOCK_ACCESS_FS_IOCTL_DEV,
|
||||
} {
|
||||
if abi5&expected == 0 {
|
||||
t.Fatalf("ABI 5 mask missing %#x", expected)
|
||||
}
|
||||
}
|
||||
read := landlockReadFilesystemAccess(5)
|
||||
if read&unix.LANDLOCK_ACCESS_FS_WRITE_FILE != 0 {
|
||||
t.Fatalf("read mask includes write access: %#x", read)
|
||||
}
|
||||
file := landlockFileReadWriteAccess(5)
|
||||
for _, unexpected := range []uint64{
|
||||
unix.LANDLOCK_ACCESS_FS_READ_DIR,
|
||||
unix.LANDLOCK_ACCESS_FS_MAKE_DIR,
|
||||
unix.LANDLOCK_ACCESS_FS_REMOVE_DIR,
|
||||
unix.LANDLOCK_ACCESS_FS_MAKE_REG,
|
||||
} {
|
||||
if file&unexpected != 0 {
|
||||
t.Fatalf("file mask unexpectedly includes %#x", unexpected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLandlockWritableRootsIncludesCWD(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
profile := landlockTestProfile(extra, NetworkPolicy{Mode: NetworkAllow})
|
||||
roots := landlockWritableRoots(profile.FileSystem, cwd)
|
||||
for _, want := range []string{filepath.Clean(cwd), filepath.Clean(extra)} {
|
||||
if !stringSliceContains(roots, want) {
|
||||
t.Fatalf("writable roots = %#v, missing %s", roots, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func landlockTestProfile(root string, network NetworkPolicy) PermissionProfile {
|
||||
return PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{string(filepath.Separator)},
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: root,
|
||||
}},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: network,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrLandlockUnsupported = errors.New("Landlock is only supported on Linux")
|
||||
|
||||
func ApplyLandlockFilesystemProfile(profile PermissionProfile, cwd string, allowNetworkForProxy bool, proxyRouteSpec string) error {
|
||||
return ErrLandlockUnsupported
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const LinuxSandboxHelperName = "zero-linux-sandbox"
|
||||
|
||||
const linuxSandboxBackendEnv = BackendLinuxBwrap
|
||||
|
||||
type LinuxSandboxCommandArgsOptions struct {
|
||||
SandboxPolicyCWD string
|
||||
CommandCWD string
|
||||
PermissionProfile PermissionProfile
|
||||
UseLandlock bool
|
||||
ApplySeccompThenExec bool
|
||||
BlockUnixSockets bool
|
||||
AllowNetworkForProxy bool
|
||||
ProxyRouteSpec string
|
||||
NoProc bool
|
||||
Command []string
|
||||
}
|
||||
|
||||
type LinuxSandboxHelperConfig struct {
|
||||
SandboxPolicyCWD string
|
||||
CommandCWD string
|
||||
PermissionProfile PermissionProfile
|
||||
UseLandlock bool
|
||||
ApplySeccompThenExec bool
|
||||
BlockUnixSockets bool
|
||||
AllowNetworkForProxy bool
|
||||
ProxyRouteSpec string
|
||||
NoProc bool
|
||||
Command []string
|
||||
}
|
||||
|
||||
type LinuxSandboxHelperCommand struct {
|
||||
Name string
|
||||
ArgsPrefix []string
|
||||
Dir string
|
||||
}
|
||||
|
||||
type LinuxSandboxBwrapOptions struct {
|
||||
Config LinuxSandboxHelperConfig
|
||||
HelperPath string
|
||||
}
|
||||
|
||||
var linuxSandboxHelperCommand = findLinuxSandboxHelperCommand
|
||||
|
||||
func BuildLinuxSandboxCommandArgs(options LinuxSandboxCommandArgsOptions) ([]string, error) {
|
||||
sandboxPolicyCWD := strings.TrimSpace(options.SandboxPolicyCWD)
|
||||
if sandboxPolicyCWD == "" {
|
||||
return nil, errors.New("linux sandbox helper requires sandbox policy cwd")
|
||||
}
|
||||
commandCWD := strings.TrimSpace(options.CommandCWD)
|
||||
if commandCWD == "" {
|
||||
commandCWD = sandboxPolicyCWD
|
||||
}
|
||||
if len(options.Command) == 0 {
|
||||
return nil, errors.New("linux sandbox helper requires command")
|
||||
}
|
||||
profileJSON, err := json.Marshal(options.PermissionProfile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal linux sandbox permission profile: %w", err)
|
||||
}
|
||||
args := []string{
|
||||
"--sandbox-policy-cwd", sandboxPolicyCWD,
|
||||
"--command-cwd", commandCWD,
|
||||
"--permission-profile", string(profileJSON),
|
||||
}
|
||||
if options.UseLandlock {
|
||||
args = append(args, "--use-landlock")
|
||||
}
|
||||
if options.ApplySeccompThenExec {
|
||||
args = append(args, "--apply-seccomp-then-exec")
|
||||
}
|
||||
if options.BlockUnixSockets {
|
||||
args = append(args, "--block-unix-sockets")
|
||||
}
|
||||
if options.AllowNetworkForProxy {
|
||||
args = append(args, "--allow-network-for-proxy")
|
||||
}
|
||||
if route := strings.TrimSpace(options.ProxyRouteSpec); route != "" {
|
||||
args = append(args, "--proxy-route-spec", route)
|
||||
}
|
||||
if options.NoProc {
|
||||
args = append(args, "--no-proc")
|
||||
}
|
||||
args = append(args, "--")
|
||||
args = append(args, options.Command...)
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error) {
|
||||
var config LinuxSandboxHelperConfig
|
||||
var profileJSON string
|
||||
flags := flag.NewFlagSet(LinuxSandboxHelperName, flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
flags.StringVar(&config.SandboxPolicyCWD, "sandbox-policy-cwd", "", "sandbox policy cwd")
|
||||
flags.StringVar(&config.CommandCWD, "command-cwd", "", "command cwd")
|
||||
flags.StringVar(&profileJSON, "permission-profile", "", "permission profile JSON")
|
||||
flags.BoolVar(&config.UseLandlock, "use-landlock", false, "use Landlock backend")
|
||||
flags.BoolVar(&config.ApplySeccompThenExec, "apply-seccomp-then-exec", false, "apply seccomp before exec")
|
||||
flags.BoolVar(&config.BlockUnixSockets, "block-unix-sockets", false, "block AF_UNIX sockets before exec")
|
||||
flags.BoolVar(&config.AllowNetworkForProxy, "allow-network-for-proxy", false, "allow proxy-routed network")
|
||||
flags.StringVar(&config.ProxyRouteSpec, "proxy-route-spec", "", "proxy route spec")
|
||||
flags.BoolVar(&config.NoProc, "no-proc", false, "skip proc mount")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return LinuxSandboxHelperConfig{}, err
|
||||
}
|
||||
config.SandboxPolicyCWD = strings.TrimSpace(config.SandboxPolicyCWD)
|
||||
if config.SandboxPolicyCWD == "" {
|
||||
return LinuxSandboxHelperConfig{}, errors.New("missing --sandbox-policy-cwd")
|
||||
}
|
||||
config.CommandCWD = strings.TrimSpace(config.CommandCWD)
|
||||
if config.CommandCWD == "" {
|
||||
config.CommandCWD = config.SandboxPolicyCWD
|
||||
}
|
||||
profileJSON = strings.TrimSpace(profileJSON)
|
||||
if profileJSON == "" {
|
||||
return LinuxSandboxHelperConfig{}, errors.New("missing --permission-profile")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(profileJSON), &config.PermissionProfile); err != nil {
|
||||
return LinuxSandboxHelperConfig{}, fmt.Errorf("invalid --permission-profile: %w", err)
|
||||
}
|
||||
config.Command = flags.Args()
|
||||
if len(config.Command) == 0 {
|
||||
return LinuxSandboxHelperConfig{}, errors.New("missing command after --")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, error) {
|
||||
config := options.Config
|
||||
if config.ApplySeccompThenExec {
|
||||
return nil, errors.New("inner seccomp stage cannot be wrapped by bubblewrap again")
|
||||
}
|
||||
if config.UseLandlock {
|
||||
return nil, errors.New("linux landlock helper mode is not implemented yet")
|
||||
}
|
||||
helperPath := strings.TrimSpace(options.HelperPath)
|
||||
if helperPath == "" {
|
||||
return nil, errors.New("linux sandbox helper path is required")
|
||||
}
|
||||
commandCWD := strings.TrimSpace(config.CommandCWD)
|
||||
if commandCWD == "" {
|
||||
commandCWD = config.SandboxPolicyCWD
|
||||
}
|
||||
innerArgs, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
|
||||
SandboxPolicyCWD: config.SandboxPolicyCWD,
|
||||
CommandCWD: commandCWD,
|
||||
PermissionProfile: config.PermissionProfile,
|
||||
ApplySeccompThenExec: true,
|
||||
BlockUnixSockets: config.BlockUnixSockets,
|
||||
AllowNetworkForProxy: config.AllowNetworkForProxy,
|
||||
ProxyRouteSpec: config.ProxyRouteSpec,
|
||||
NoProc: config.NoProc,
|
||||
Command: config.Command,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := []string{
|
||||
"--new-session",
|
||||
"--die-with-parent",
|
||||
"--unshare-user",
|
||||
"--unshare-pid",
|
||||
"--unshare-ipc",
|
||||
"--unshare-uts",
|
||||
}
|
||||
args = append(args, linuxBwrapFilesystemArgs(config.PermissionProfile)...)
|
||||
if pathExists(helperPath) {
|
||||
args = append(args, "--ro-bind", helperPath, helperPath)
|
||||
}
|
||||
if shouldUnshareLinuxNetwork(config.PermissionProfile.Network, config.AllowNetworkForProxy) {
|
||||
args = append(args, "--unshare-net")
|
||||
}
|
||||
if !config.NoProc {
|
||||
args = append(args, "--proc", "/proc")
|
||||
}
|
||||
args = append(args, "--chdir", commandCWD)
|
||||
args = append(args, "--clearenv")
|
||||
for _, env := range linuxHelperSandboxEnvironment(config.PermissionProfile, commandCWD) {
|
||||
key, value, ok := strings.Cut(env, "=")
|
||||
if ok {
|
||||
args = append(args, "--setenv", key, value)
|
||||
}
|
||||
}
|
||||
args = append(args, "--", helperPath)
|
||||
args = append(args, innerArgs...)
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
|
||||
fs := profile.FileSystem
|
||||
if fs.Kind == FileSystemUnrestricted {
|
||||
args := []string{"--ro-bind", "/", "/", "--dev", "/dev"}
|
||||
if fs.AllowTemp {
|
||||
args = append(args, "--tmpfs", "/tmp")
|
||||
}
|
||||
for _, root := range fs.WriteRoots {
|
||||
if pathExists(root.Root) {
|
||||
args = append(args, "--bind", root.Root, root.Root)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
args := []string{"--tmpfs", "/", "--dev", "/dev"}
|
||||
if fs.IncludePlatformRoots {
|
||||
for _, root := range linuxPlatformReadRoots() {
|
||||
args = append(args, "--ro-bind", root, root)
|
||||
}
|
||||
}
|
||||
for _, root := range fs.ReadRoots {
|
||||
if pathExists(root) {
|
||||
args = append(args, "--ro-bind", root, root)
|
||||
}
|
||||
}
|
||||
if fs.AllowTemp {
|
||||
args = append(args, "--tmpfs", "/tmp")
|
||||
}
|
||||
for _, root := range fs.WriteRoots {
|
||||
if !pathExists(root.Root) {
|
||||
continue
|
||||
}
|
||||
args = append(args, "--bind", root.Root, root.Root)
|
||||
for _, subpath := range root.ReadOnlySubpaths {
|
||||
args = appendReadOnlyLinuxPathArgs(args, subpath)
|
||||
}
|
||||
for _, name := range root.ProtectedMetadataNames {
|
||||
args = appendReadOnlyLinuxPathArgs(args, filepath.Join(root.Root, name))
|
||||
}
|
||||
}
|
||||
for _, path := range fs.DenyWrite {
|
||||
args = appendReadOnlyLinuxPathArgs(args, path)
|
||||
}
|
||||
for _, path := range fs.DenyRead {
|
||||
args = appendUnreadableLinuxPathArgs(args, path)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func linuxPlatformReadRoots() []string {
|
||||
candidates := []string{"/bin", "/sbin", "/usr", "/etc", "/lib", "/lib64", "/nix/store", "/run/current-system/sw"}
|
||||
roots := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if pathExists(candidate) {
|
||||
roots = append(roots, candidate)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func appendReadOnlyLinuxPathArgs(args []string, path string) []string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return args
|
||||
}
|
||||
if pathExists(path) {
|
||||
return append(args, "--ro-bind", path, path)
|
||||
}
|
||||
return append(args, "--perms", "555", "--tmpfs", path, "--remount-ro", path)
|
||||
}
|
||||
|
||||
func appendUnreadableLinuxPathArgs(args []string, path string) []string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return args
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
return append(args, "--ro-bind", "/dev/null", path)
|
||||
}
|
||||
return append(args, "--perms", "000", "--tmpfs", path, "--remount-ro", path)
|
||||
}
|
||||
|
||||
func shouldUnshareLinuxNetwork(policy NetworkPolicy, allowNetworkForProxy bool) bool {
|
||||
if allowNetworkForProxy {
|
||||
return true
|
||||
}
|
||||
return policy.Mode == NetworkDeny || policy.Mode == NetworkScoped || policy.ProxyRequired
|
||||
}
|
||||
|
||||
func linuxHelperSandboxEnvironment(profile PermissionProfile, home string) []string {
|
||||
env := []string{
|
||||
"HOME=" + home,
|
||||
"PATH=" + firstEnv("PATH", defaultPath()),
|
||||
"TERM=" + firstEnv("TERM", "dumb"),
|
||||
EnvSandboxBackend + "=" + string(linuxSandboxBackendEnv),
|
||||
"ZERO_SANDBOX_NETWORK=" + string(profile.Network.Mode),
|
||||
EnvSandboxed + "=1",
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func pathExists(path string) bool {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func findLinuxSandboxHelperCommand() (LinuxSandboxHelperCommand, error) {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
candidate := filepath.Join(filepath.Dir(exe), LinuxSandboxHelperName)
|
||||
if executableRegularFile(candidate) {
|
||||
return LinuxSandboxHelperCommand{Name: candidate}, nil
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath(LinuxSandboxHelperName); err == nil && path != "" {
|
||||
return LinuxSandboxHelperCommand{Name: path}, nil
|
||||
}
|
||||
if root := linuxSandboxRepoRoot(); root != "" {
|
||||
mainPath := filepath.Join(root, "cmd", LinuxSandboxHelperName, "main.go")
|
||||
if _, err := os.Stat(mainPath); err == nil {
|
||||
if goPath, lookErr := exec.LookPath("go"); lookErr == nil && goPath != "" {
|
||||
return LinuxSandboxHelperCommand{
|
||||
Name: goPath,
|
||||
ArgsPrefix: []string{"run", "./cmd/" + LinuxSandboxHelperName},
|
||||
Dir: root,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return LinuxSandboxHelperCommand{}, errors.New("zero-linux-sandbox helper is not available")
|
||||
}
|
||||
|
||||
func executableRegularFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0
|
||||
}
|
||||
|
||||
func linuxSandboxRepoRoot() string {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil {
|
||||
return ""
|
||||
}
|
||||
return root
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//go:build linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
|
||||
config, err := ParseLinuxSandboxHelperArgs(args)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": "+err.Error())
|
||||
return 2
|
||||
}
|
||||
if config.ApplySeccompThenExec {
|
||||
return runLinuxSandboxInnerStage(config, stderr)
|
||||
}
|
||||
if config.UseLandlock {
|
||||
return runLinuxSandboxLandlockStage(config, stderr)
|
||||
}
|
||||
helperPath, err := os.Executable()
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": resolve helper path: "+err.Error())
|
||||
return 125
|
||||
}
|
||||
bwrapArgs, err := BuildLinuxSandboxBwrapArgs(LinuxSandboxBwrapOptions{
|
||||
Config: config,
|
||||
HelperPath: helperPath,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": "+err.Error())
|
||||
return 125
|
||||
}
|
||||
bwrapPath, err := findBubblewrapExecutable()
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": bubblewrap is not available: "+err.Error())
|
||||
return 125
|
||||
}
|
||||
if err := syscall.Exec(bwrapPath, append([]string{"bwrap"}, bwrapArgs...), os.Environ()); err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": exec bubblewrap: "+err.Error())
|
||||
return 126
|
||||
}
|
||||
return 126
|
||||
}
|
||||
|
||||
func findBubblewrapExecutable() (string, error) {
|
||||
if path, err := exec.LookPath("bwrap"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
for _, candidate := range []string{"/usr/bin/bwrap", "/bin/bwrap"} {
|
||||
if executableRegularFile(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", exec.ErrNotFound
|
||||
}
|
||||
|
||||
func runLinuxSandboxInnerStage(config LinuxSandboxHelperConfig, stderr io.Writer) int {
|
||||
if config.UseLandlock {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": inner seccomp stage is incompatible with Landlock mode")
|
||||
return 2
|
||||
}
|
||||
if config.AllowNetworkForProxy && config.ProxyRouteSpec == "" {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": proxy networking requires --proxy-route-spec")
|
||||
return 125
|
||||
}
|
||||
if config.BlockUnixSockets {
|
||||
if err := ApplyUnixSocketBlock(); err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": warning: "+err.Error()+"; running without the Unix-socket filter")
|
||||
}
|
||||
}
|
||||
binary, err := exec.LookPath(config.Command[0])
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": "+err.Error())
|
||||
return 127
|
||||
}
|
||||
if err := syscall.Exec(binary, config.Command, os.Environ()); err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": exec command: "+err.Error())
|
||||
return 126
|
||||
}
|
||||
return 126
|
||||
}
|
||||
|
||||
func runLinuxSandboxLandlockStage(config LinuxSandboxHelperConfig, stderr io.Writer) int {
|
||||
if err := ApplyLandlockFilesystemProfile(config.PermissionProfile, config.CommandCWD, config.AllowNetworkForProxy, config.ProxyRouteSpec); err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": apply Landlock: "+err.Error())
|
||||
return 125
|
||||
}
|
||||
binary, err := exec.LookPath(config.Command[0])
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": "+err.Error())
|
||||
return 127
|
||||
}
|
||||
if err := syscall.Exec(binary, config.Command, linuxHelperSandboxEnvironment(config.PermissionProfile, config.CommandCWD)); err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": exec command: "+err.Error())
|
||||
return 126
|
||||
}
|
||||
return 126
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
|
||||
if _, err := ParseLinuxSandboxHelperArgs(args); err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": "+err.Error())
|
||||
return 2
|
||||
}
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": Linux sandbox helper is only available on Linux")
|
||||
return 125
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildLinuxSandboxCommandArgsSerializesPermissionProfile(t *testing.T) {
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{"/workspace"},
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: "/workspace",
|
||||
ProtectedMetadataNames: []string{".git", ".zero"},
|
||||
}},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
}
|
||||
args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
|
||||
SandboxPolicyCWD: "/workspace",
|
||||
CommandCWD: "/workspace/app",
|
||||
PermissionProfile: profile,
|
||||
UseLandlock: true,
|
||||
BlockUnixSockets: true,
|
||||
AllowNetworkForProxy: true,
|
||||
Command: []string{"/bin/sh", "-c", "pwd"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLinuxSandboxCommandArgs: %v", err)
|
||||
}
|
||||
|
||||
wantPrefix := []string{"--sandbox-policy-cwd", "/workspace", "--command-cwd", "/workspace/app", "--permission-profile"}
|
||||
if len(args) < len(wantPrefix)+1 || !reflect.DeepEqual(args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("args prefix = %#v, want %#v", args, wantPrefix)
|
||||
}
|
||||
var gotProfile PermissionProfile
|
||||
if err := json.Unmarshal([]byte(args[len(wantPrefix)]), &gotProfile); err != nil {
|
||||
t.Fatalf("permission profile JSON: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotProfile, profile) {
|
||||
t.Fatalf("permission profile = %#v, want %#v", gotProfile, profile)
|
||||
}
|
||||
separator := indexString(args, "--")
|
||||
if separator < 0 {
|
||||
t.Fatalf("args missing command separator: %#v", args)
|
||||
}
|
||||
if !reflect.DeepEqual(args[separator+1:], []string{"/bin/sh", "-c", "pwd"}) {
|
||||
t.Fatalf("command args = %#v", args[separator+1:])
|
||||
}
|
||||
if !stringSliceContains(args, "--use-landlock") || !stringSliceContains(args, "--block-unix-sockets") || !stringSliceContains(args, "--allow-network-for-proxy") {
|
||||
t.Fatalf("args missing helper feature flags: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLinuxSandboxHelperArgs(t *testing.T) {
|
||||
profile := DefaultPermissionProfile("/workspace")
|
||||
args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
|
||||
SandboxPolicyCWD: "/workspace",
|
||||
PermissionProfile: profile,
|
||||
ApplySeccompThenExec: true,
|
||||
BlockUnixSockets: true,
|
||||
ProxyRouteSpec: "proxy=127.0.0.1:9999",
|
||||
NoProc: true,
|
||||
Command: []string{"true"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLinuxSandboxCommandArgs: %v", err)
|
||||
}
|
||||
config, err := ParseLinuxSandboxHelperArgs(args)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLinuxSandboxHelperArgs: %v", err)
|
||||
}
|
||||
if config.SandboxPolicyCWD != "/workspace" || config.CommandCWD != "/workspace" {
|
||||
t.Fatalf("cwd config = %#v", config)
|
||||
}
|
||||
if !config.ApplySeccompThenExec || !config.BlockUnixSockets || !config.NoProc || config.ProxyRouteSpec != "proxy=127.0.0.1:9999" {
|
||||
t.Fatalf("feature config = %#v", config)
|
||||
}
|
||||
if !reflect.DeepEqual(config.PermissionProfile, profile) || !reflect.DeepEqual(config.Command, []string{"true"}) {
|
||||
t.Fatalf("parsed config = %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinuxSandboxBwrapArgsWrapsInnerSeccompStage(t *testing.T) {
|
||||
helperPath := filepath.Join(t.TempDir(), LinuxSandboxHelperName)
|
||||
if err := os.WriteFile(helperPath, []byte("helper"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile helper: %v", err)
|
||||
}
|
||||
args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
|
||||
SandboxPolicyCWD: "/workspace",
|
||||
PermissionProfile: DefaultPermissionProfile("/workspace"),
|
||||
BlockUnixSockets: true,
|
||||
Command: []string{"true"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLinuxSandboxCommandArgs: %v", err)
|
||||
}
|
||||
config, err := ParseLinuxSandboxHelperArgs(args)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLinuxSandboxHelperArgs: %v", err)
|
||||
}
|
||||
bwrapArgs, err := BuildLinuxSandboxBwrapArgs(LinuxSandboxBwrapOptions{
|
||||
Config: config,
|
||||
HelperPath: helperPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLinuxSandboxBwrapArgs: %v", err)
|
||||
}
|
||||
for _, want := range [][]string{
|
||||
{"--new-session"},
|
||||
{"--die-with-parent"},
|
||||
{"--unshare-user"},
|
||||
{"--unshare-pid"},
|
||||
{"--unshare-net"},
|
||||
{"--chdir", "/workspace"},
|
||||
{"--setenv", EnvSandboxBackend, string(BackendLinuxBwrap)},
|
||||
{"--ro-bind", helperPath, helperPath},
|
||||
{"--", helperPath},
|
||||
{"--apply-seccomp-then-exec"},
|
||||
{"--block-unix-sockets"},
|
||||
{"--", "true"},
|
||||
} {
|
||||
assertArgsContainSequence(t, bwrapArgs, want...)
|
||||
}
|
||||
}
|
||||
|
||||
func indexString(values []string, want string) int {
|
||||
for index, value := range values {
|
||||
if value == want {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
type SandboxPreference string
|
||||
|
||||
const (
|
||||
SandboxPreferenceAuto SandboxPreference = "auto"
|
||||
SandboxPreferenceRequire SandboxPreference = "require"
|
||||
SandboxPreferenceForbid SandboxPreference = "forbid"
|
||||
)
|
||||
|
||||
type SandboxManagerOptions struct {
|
||||
GOOS string
|
||||
LookupExecutable func(string) (string, error)
|
||||
Backend Backend
|
||||
}
|
||||
|
||||
type SandboxManager struct {
|
||||
goos string
|
||||
backend Backend
|
||||
}
|
||||
|
||||
type SandboxManagerRequest struct {
|
||||
WorkspaceRoot string
|
||||
Command CommandSpec
|
||||
Policy Policy
|
||||
Scope *Scope
|
||||
Profile PermissionProfile
|
||||
Preference SandboxPreference
|
||||
ValidateExecution bool
|
||||
}
|
||||
|
||||
type SandboxExecutionRequest struct {
|
||||
Command CommandSpec `json:"command"`
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
PermissionProfile PermissionProfile `json:"permissionProfile"`
|
||||
Backend Backend `json:"backend"`
|
||||
TargetBackend BackendName `json:"targetBackend"`
|
||||
CommandWrapped bool `json:"commandWrapped"`
|
||||
SandboxEnvMarkers []string `json:"sandboxEnvMarkers,omitempty"`
|
||||
EnforcementLevel EnforcementLevel `json:"enforcementLevel"`
|
||||
DowngradeReason string `json:"downgradeReason,omitempty"`
|
||||
SupportLevel BackendSupportLevel `json:"supportLevel"`
|
||||
RequiresPlatformSandbox bool `json:"requiresPlatformSandbox"`
|
||||
}
|
||||
|
||||
func NewSandboxManager(options SandboxManagerOptions) SandboxManager {
|
||||
goos := options.GOOS
|
||||
if goos == "" {
|
||||
goos = runtime.GOOS
|
||||
}
|
||||
backend := options.Backend
|
||||
if backend.Name == "" {
|
||||
backend = selectPlatformBackend(goos, options.LookupExecutable)
|
||||
}
|
||||
if backend.Platform == "" {
|
||||
backend.Platform = goos
|
||||
}
|
||||
backend = inferBackendCapabilities(backend)
|
||||
return SandboxManager{goos: goos, backend: backend}
|
||||
}
|
||||
|
||||
func (manager SandboxManager) Backend() Backend {
|
||||
return manager.backend
|
||||
}
|
||||
|
||||
func selectPlatformBackend(goos string, lookup func(string) (string, error)) Backend {
|
||||
if lookup == nil {
|
||||
lookup = exec.LookPath
|
||||
}
|
||||
switch goos {
|
||||
case "linux":
|
||||
if helper, err := lookup(LinuxSandboxHelperName); err == nil && helper != "" {
|
||||
if _, bwrapErr := lookup("bwrap"); bwrapErr != nil {
|
||||
return policyOnlyBackend(goos, "policy-only fallback: bubblewrap is not installed")
|
||||
}
|
||||
return nativeBackend(goos, BackendLinuxBwrap, helper, "Linux sandbox helper available")
|
||||
}
|
||||
if info := detectWSL(); info.IsWSL {
|
||||
return wslBackend(goos, info)
|
||||
}
|
||||
return policyOnlyBackend(goos, "policy-only fallback: Linux sandbox helper is not available")
|
||||
case "darwin":
|
||||
if path, err := lookup("sandbox-exec"); err == nil && path != "" {
|
||||
return nativeBackend(goos, BackendMacOSSeatbelt, path, "macOS Seatbelt backend available")
|
||||
}
|
||||
return policyOnlyBackend(goos, "policy-only fallback: sandbox-exec is not available")
|
||||
case "windows":
|
||||
runner := findWindowsSandboxCommandRunner(lookup)
|
||||
setup := findWindowsSandboxSetupHelper(lookup)
|
||||
if runner != "" && setup != "" {
|
||||
return nativeBackend(goos, BackendWindowsRestrictedToken, runner, "Windows sandbox command runner and setup helper available")
|
||||
}
|
||||
if runner != "" {
|
||||
return policyOnlyBackend(goos, "policy-only fallback: Windows sandbox setup helper is not available")
|
||||
}
|
||||
return policyOnlyBackend(goos, "policy-only fallback: Windows sandbox command runner is not available")
|
||||
default:
|
||||
return policyOnlyBackend(goos, "policy-only fallback: no platform sandbox adapter is available for "+goos)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerRequest) (SandboxExecutionRequest, error) {
|
||||
policy := request.Policy
|
||||
if policy.Mode == "" {
|
||||
policy = DefaultPolicy()
|
||||
}
|
||||
preference := request.Preference
|
||||
if preference == "" {
|
||||
preference = SandboxPreferenceAuto
|
||||
}
|
||||
profile := request.Profile
|
||||
if permissionProfileUnset(profile) {
|
||||
profile = PermissionProfileFromPolicy(request.WorkspaceRoot, policy, request.Scope)
|
||||
}
|
||||
requiresPlatformSandbox := profile.RequiresPlatformSandbox() && preference != SandboxPreferenceForbid
|
||||
backend := manager.backend
|
||||
enforcementLevel := backend.EnforcementLevel(policy)
|
||||
if preference == SandboxPreferenceForbid || policy.Mode == ModeDisabled || !requiresPlatformSandbox {
|
||||
enforcementLevel = EnforcementDisabled
|
||||
}
|
||||
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
|
||||
return SandboxExecutionRequest{}, errPolicyOnlyRunnerDisabled
|
||||
}
|
||||
if request.ValidateExecution && requiresPlatformSandbox && backend.SupportLevel() != BackendSupportNative && !policy.AllowPolicyOnlyRunner {
|
||||
if backend.Name == BackendWSL {
|
||||
return SandboxExecutionRequest{}, errWSLPolicyOnlyDisabled
|
||||
}
|
||||
return SandboxExecutionRequest{}, errPolicyOnlyRunnerDisabled
|
||||
}
|
||||
targetBackend := manager.targetBackend(preference, policy, requiresPlatformSandbox)
|
||||
downgradeReason := ""
|
||||
if requiresPlatformSandbox && enforcementLevel == EnforcementDegraded {
|
||||
downgradeReason = backend.DowngradeReason(policy)
|
||||
}
|
||||
wrapped := backend.CommandWrapping && backend.Available && enforcementLevel == EnforcementNative
|
||||
markers := backend.SandboxEnvMarkers(policy)
|
||||
if !wrapped && backend.Name != BackendWSL {
|
||||
markers = nil
|
||||
}
|
||||
return SandboxExecutionRequest{
|
||||
Command: request.Command,
|
||||
WorkspaceRoot: request.WorkspaceRoot,
|
||||
PermissionProfile: profile,
|
||||
Backend: backend,
|
||||
TargetBackend: targetBackend,
|
||||
CommandWrapped: wrapped,
|
||||
SandboxEnvMarkers: markers,
|
||||
EnforcementLevel: enforcementLevel,
|
||||
DowngradeReason: downgradeReason,
|
||||
SupportLevel: backend.SupportLevel(),
|
||||
RequiresPlatformSandbox: requiresPlatformSandbox,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (manager SandboxManager) BuildCommandPlan(request SandboxManagerRequest) (CommandPlan, error) {
|
||||
execRequest, err := manager.BuildExecutionRequest(request)
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
policy := request.Policy
|
||||
if policy.Mode == "" {
|
||||
policy = DefaultPolicy()
|
||||
}
|
||||
return buildPlatformCommandPlan(execRequest, policy)
|
||||
}
|
||||
|
||||
func (manager SandboxManager) targetBackend(preference SandboxPreference, policy Policy, requiresPlatformSandbox bool) BackendName {
|
||||
if preference == SandboxPreferenceForbid || policy.Mode == ModeDisabled || !requiresPlatformSandbox {
|
||||
return BackendNone
|
||||
}
|
||||
if manager.backend.Name == BackendWSL {
|
||||
return BackendPolicyOnly
|
||||
}
|
||||
return manager.backend.TargetBackend()
|
||||
}
|
||||
|
||||
func (request SandboxExecutionRequest) BackendPlan(policy Policy) BackendPlan {
|
||||
return BackendPlan{
|
||||
Backend: request.Backend,
|
||||
TargetBackend: request.TargetBackend,
|
||||
WorkspaceRoot: request.WorkspaceRoot,
|
||||
Policy: policy,
|
||||
PermissionProfile: request.PermissionProfile,
|
||||
CommandWrapped: request.CommandWrapped,
|
||||
SandboxEnvMarkers: request.SandboxEnvMarkers,
|
||||
EnforcementLevel: request.EnforcementLevel,
|
||||
DowngradeReason: request.DowngradeReason,
|
||||
SupportLevel: request.SupportLevel,
|
||||
RequiresPlatformSandbox: request.RequiresPlatformSandbox,
|
||||
Capabilities: request.Backend.Capabilities(policy),
|
||||
Restrictions: request.Backend.restrictions(policy),
|
||||
Warnings: request.Backend.Warnings(),
|
||||
}
|
||||
}
|
||||
|
||||
func permissionProfileUnset(profile PermissionProfile) bool {
|
||||
return profile.FileSystem.Kind == "" && profile.Network.Mode == ""
|
||||
}
|
||||
|
||||
func inferBackendCapabilities(backend Backend) Backend {
|
||||
if backend.Available && backend.Executable != "" {
|
||||
switch backend.Name {
|
||||
case BackendLinuxBwrap, BackendMacOSSeatbelt, BackendWindowsRestrictedToken:
|
||||
backend.CommandWrapping = true
|
||||
backend.NativeIsolation = true
|
||||
}
|
||||
}
|
||||
if backend.Name == BackendMacOSSeatbelt && backend.Available && backend.Executable != "" {
|
||||
backend.ScopedEgress = true
|
||||
}
|
||||
return backend
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
denyRead := filepath.Join(workspace, "private")
|
||||
denyWrite := filepath.Join(workspace, "readonly")
|
||||
if err := mkdirAll(denyRead, denyWrite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope, err := NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
policy := DefaultPolicy()
|
||||
policy.Network = NetworkScoped
|
||||
policy.AllowedDomains = []string{"example.com", "api.example.com"}
|
||||
policy.DeniedDomains = []string{"blocked.example.com"}
|
||||
policy.DenyRead = []string{denyRead}
|
||||
policy.DenyWrite = []string{denyWrite}
|
||||
|
||||
profile := PermissionProfileFromPolicy(workspace, policy, scope)
|
||||
if profile.FileSystem.Kind != FileSystemRestricted {
|
||||
t.Fatalf("filesystem kind = %q, want restricted", profile.FileSystem.Kind)
|
||||
}
|
||||
if len(profile.FileSystem.WriteRoots) != 2 {
|
||||
t.Fatalf("write roots = %#v, want workspace + extra", profile.FileSystem.WriteRoots)
|
||||
}
|
||||
if profile.FileSystem.WriteRoots[0].Root != scope.Roots()[0] || profile.FileSystem.WriteRoots[1].Root != scope.Roots()[1] {
|
||||
t.Fatalf("write roots = %#v, want scope roots %#v", profile.FileSystem.WriteRoots, scope.Roots())
|
||||
}
|
||||
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".git") || !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".zero") {
|
||||
t.Fatalf("protected metadata names = %#v, want workspace metadata protected", profile.FileSystem.WriteRoots[0].ProtectedMetadataNames)
|
||||
}
|
||||
if len(profile.FileSystem.DenyRead) != 1 || len(profile.FileSystem.DenyWrite) != 1 {
|
||||
t.Fatalf("deny paths = %#v / %#v, want one each", profile.FileSystem.DenyRead, profile.FileSystem.DenyWrite)
|
||||
}
|
||||
if profile.Network.Mode != NetworkScoped || !profile.Network.ProxyRequired {
|
||||
t.Fatalf("network profile = %#v, want scoped proxy-required", profile.Network)
|
||||
}
|
||||
if !profile.RequiresPlatformSandbox() {
|
||||
t.Fatal("workspace-write scoped profile must require a platform sandbox")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionProfileFromDisabledPolicyDoesNotRequirePlatformSandbox(t *testing.T) {
|
||||
policy := DefaultPolicy()
|
||||
policy.Mode = ModeDisabled
|
||||
profile := PermissionProfileFromPolicy(t.TempDir(), policy, nil)
|
||||
if profile.FileSystem.Kind != FileSystemUnrestricted || profile.Network.Mode != NetworkAllow {
|
||||
t.Fatalf("disabled profile = %#v, want unrestricted filesystem and allow network", profile)
|
||||
}
|
||||
if profile.RequiresPlatformSandbox() {
|
||||
t.Fatalf("disabled profile must not require platform sandbox: %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxManagerBuildsExecutionRequestFromProfile(t *testing.T) {
|
||||
backend := Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox", Platform: "linux"}
|
||||
policy := DefaultPolicy()
|
||||
profile := PermissionProfileFromPolicy("/workspace", policy, nil)
|
||||
request, err := NewSandboxManager(SandboxManagerOptions{GOOS: "linux", Backend: backend}).BuildExecutionRequest(SandboxManagerRequest{
|
||||
WorkspaceRoot: "/workspace",
|
||||
Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: "/workspace"},
|
||||
Policy: policy,
|
||||
Profile: profile,
|
||||
Preference: SandboxPreferenceAuto,
|
||||
ValidateExecution: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildExecutionRequest: %v", err)
|
||||
}
|
||||
if request.TargetBackend != BackendLinuxBwrap || !request.CommandWrapped || request.EnforcementLevel != EnforcementNative {
|
||||
t.Fatalf("execution request = %#v, want native linux-bwrap wrapping", request)
|
||||
}
|
||||
if request.PermissionProfile.FileSystem.Kind != FileSystemRestricted || !request.RequiresPlatformSandbox {
|
||||
t.Fatalf("execution request profile = %#v, requires=%t", request.PermissionProfile, request.RequiresPlatformSandbox)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxManagerBuildsCommandPlanThroughLinuxHelper(t *testing.T) {
|
||||
backend := Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox", Platform: "linux"}
|
||||
policy := DefaultPolicy()
|
||||
policy.BlockUnixSockets = true
|
||||
manager := NewSandboxManager(SandboxManagerOptions{GOOS: "linux", Backend: backend})
|
||||
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
|
||||
WorkspaceRoot: "/workspace",
|
||||
Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: "/workspace/nested"},
|
||||
Policy: policy,
|
||||
Profile: PermissionProfileFromPolicy("/workspace", policy, nil),
|
||||
Preference: SandboxPreferenceAuto,
|
||||
ValidateExecution: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
if !plan.Wrapped || plan.Name != "/usr/bin/zero-linux-sandbox" || plan.TargetBackend != BackendLinuxBwrap {
|
||||
t.Fatalf("command plan = %#v, want native linux helper wrapper", plan)
|
||||
}
|
||||
if plan.EnforcementLevel != EnforcementNative {
|
||||
t.Fatalf("command metadata = %#v, want helper backend with native enforcement", plan)
|
||||
}
|
||||
assertArgsContainSequence(t, plan.Args, "--sandbox-policy-cwd", "/workspace")
|
||||
assertArgsContainSequence(t, plan.Args, "--command-cwd", "/workspace/nested")
|
||||
assertArgsContainSequence(t, plan.Args, "--block-unix-sockets")
|
||||
assertArgsContainSequence(t, plan.Args, "--", "/bin/sh", "-c", "pwd")
|
||||
}
|
||||
|
||||
func TestSandboxManagerBuildsCommandPlanThroughWindowsRunner(t *testing.T) {
|
||||
backend := Backend{Name: BackendWindowsRestrictedToken, Available: true, Executable: `C:\zero\zero-windows-command-runner.exe`, Platform: "windows"}
|
||||
policy := DefaultPolicy()
|
||||
manager := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend})
|
||||
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
|
||||
WorkspaceRoot: `C:\workspace`,
|
||||
Command: CommandSpec{Name: "cmd.exe", Args: []string{"/d", "/s", "/c", "dir"}, Dir: `C:\workspace\src`, Env: []string{"PATH=C:\\Tools", "TERM=xterm"}},
|
||||
Policy: policy,
|
||||
Profile: PermissionProfileFromPolicy(`C:\workspace`, policy, nil),
|
||||
Preference: SandboxPreferenceAuto,
|
||||
ValidateExecution: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
if !plan.Wrapped || plan.Name != `C:\zero\zero-windows-command-runner.exe` || plan.TargetBackend != BackendWindowsRestrictedToken {
|
||||
t.Fatalf("command plan = %#v, want native windows command runner wrapper", plan)
|
||||
}
|
||||
if plan.EnforcementLevel != EnforcementNative {
|
||||
t.Fatalf("command metadata = %#v, want native restricted-token backend", plan)
|
||||
}
|
||||
assertArgsContainSequence(t, plan.Args, "--command-cwd", `C:\workspace\src`)
|
||||
assertArgsContainSequence(t, plan.Args, "--sandbox-home")
|
||||
assertArgsContainSequence(t, plan.Args, "--windows-sandbox-level", string(WindowsSandboxLevelRestrictedToken))
|
||||
assertArgsContainSequence(t, plan.Args, "--workspace-root", `C:\workspace`)
|
||||
assertArgsContainSequence(t, plan.Args, "--", "cmd.exe", "/d", "/s", "/c", "dir")
|
||||
|
||||
config, err := ParseWindowsSandboxCommandArgs(plan.Args)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWindowsSandboxCommandArgs: %v", err)
|
||||
}
|
||||
if config.SandboxHome == "" || config.CommandCWD != `C:\workspace\src` || len(config.WorkspaceRoots) != 1 || config.WorkspaceRoots[0] != `C:\workspace` {
|
||||
t.Fatalf("parsed roots = %#v cwd=%q, want workspace root and command cwd", config.WorkspaceRoots, config.CommandCWD)
|
||||
}
|
||||
if config.PermissionProfile.FileSystem.Kind != FileSystemRestricted || config.PermissionProfile.Network.Mode != NetworkDeny {
|
||||
t.Fatalf("parsed permission profile = %#v, want restricted deny profile", config.PermissionProfile)
|
||||
}
|
||||
if config.Env[EnvSandboxed] != "1" || config.Env[EnvSandboxBackend] != string(BackendWindowsRestrictedToken) || config.Env["COMSPEC"] == "" {
|
||||
t.Fatalf("parsed env = %#v, want sandbox markers and COMSPEC", config.Env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxManagerBuildsDegradedPolicyOnlyCommandPlan(t *testing.T) {
|
||||
policy := DefaultPolicy()
|
||||
backend := Backend{Name: BackendPolicyOnly, Platform: "windows", Fallback: true, Message: "policy-only fallback"}
|
||||
manager := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend})
|
||||
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
|
||||
WorkspaceRoot: `C:\workspace`,
|
||||
Command: CommandSpec{Name: "cmd.exe", Args: []string{"/c", "dir"}, Dir: `C:\workspace`},
|
||||
Policy: policy,
|
||||
Profile: PermissionProfileFromPolicy(`C:\workspace`, policy, nil),
|
||||
Preference: SandboxPreferenceAuto,
|
||||
ValidateExecution: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
if plan.Wrapped || plan.Name != "cmd.exe" || plan.TargetBackend != BackendWindowsRestrictedToken {
|
||||
t.Fatalf("policy-only plan = %#v, want direct command targeting windows backend", plan)
|
||||
}
|
||||
if plan.EnforcementLevel != EnforcementDegraded || plan.DowngradeReason == "" {
|
||||
t.Fatalf("policy-only metadata = %#v, want degraded fallback", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxManagerSelectsPlatformBackend(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
lookupName string
|
||||
lookupPath string
|
||||
setupPath string
|
||||
want BackendName
|
||||
wantTarget BackendName
|
||||
}{
|
||||
{name: "linux", goos: "linux", lookupName: LinuxSandboxHelperName, lookupPath: "/usr/bin/zero-linux-sandbox", want: BackendLinuxBwrap, wantTarget: BackendLinuxBwrap},
|
||||
{name: "macos", goos: "darwin", lookupName: "sandbox-exec", lookupPath: "/usr/bin/sandbox-exec", want: BackendMacOSSeatbelt, wantTarget: BackendMacOSSeatbelt},
|
||||
{name: "windows", goos: "windows", lookupName: WindowsSandboxCommandRunnerName, lookupPath: `C:\zero\zero-windows-command-runner.exe`, setupPath: `C:\zero\zero-windows-sandbox-setup.exe`, want: BackendWindowsRestrictedToken, wantTarget: BackendWindowsRestrictedToken},
|
||||
{name: "unsupported", goos: "plan9", want: BackendPolicyOnly, wantTarget: BackendPolicyOnly},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
manager := NewSandboxManager(SandboxManagerOptions{
|
||||
GOOS: test.goos,
|
||||
LookupExecutable: func(name string) (string, error) {
|
||||
if name == test.lookupName && test.lookupPath != "" {
|
||||
return test.lookupPath, nil
|
||||
}
|
||||
if test.goos == "linux" && name == "bwrap" {
|
||||
return "/usr/bin/bwrap", nil
|
||||
}
|
||||
if name == WindowsSandboxSetupName && test.setupPath != "" {
|
||||
return test.setupPath, nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
})
|
||||
backend := manager.Backend()
|
||||
if backend.Name != test.want {
|
||||
t.Fatalf("backend = %#v, want %q", backend, test.want)
|
||||
}
|
||||
if backend.TargetBackend() != test.wantTarget {
|
||||
t.Fatalf("target backend = %q, want %q for %#v", backend.TargetBackend(), test.wantTarget, backend)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBackendDelegatesToSandboxManagerSelection(t *testing.T) {
|
||||
backend := SelectBackend(BackendOptions{
|
||||
GOOS: "linux",
|
||||
LookupExecutable: func(name string) (string, error) {
|
||||
if name == LinuxSandboxHelperName {
|
||||
return "/usr/bin/zero-linux-sandbox", nil
|
||||
}
|
||||
if name == "bwrap" {
|
||||
return "/usr/bin/bwrap", nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
})
|
||||
managerBackend := NewSandboxManager(SandboxManagerOptions{
|
||||
GOOS: "linux",
|
||||
LookupExecutable: func(name string) (string, error) {
|
||||
if name == LinuxSandboxHelperName {
|
||||
return "/usr/bin/zero-linux-sandbox", nil
|
||||
}
|
||||
if name == "bwrap" {
|
||||
return "/usr/bin/bwrap", nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
}).Backend()
|
||||
if backend != managerBackend {
|
||||
t.Fatalf("SelectBackend = %#v, manager backend = %#v", backend, managerBackend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxManagerFailsClosedWhenNativeRequiredAndPolicyOnlyDisabled(t *testing.T) {
|
||||
policy := DefaultPolicy()
|
||||
policy.AllowPolicyOnlyRunner = false
|
||||
profile := PermissionProfileFromPolicy("/workspace", policy, nil)
|
||||
_, err := NewSandboxManager(SandboxManagerOptions{
|
||||
GOOS: "windows",
|
||||
Backend: Backend{Name: BackendPolicyOnly, Platform: "windows", Fallback: true},
|
||||
}).BuildExecutionRequest(SandboxManagerRequest{
|
||||
WorkspaceRoot: "/workspace",
|
||||
Command: CommandSpec{Name: "cmd.exe", Dir: "/workspace"},
|
||||
Policy: policy,
|
||||
Profile: profile,
|
||||
Preference: SandboxPreferenceAuto,
|
||||
ValidateExecution: true,
|
||||
})
|
||||
if !errors.Is(err, errPolicyOnlyRunnerDisabled) {
|
||||
t.Fatalf("BuildExecutionRequest error = %v, want policy-only disabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mkdirAll(paths ...string) error {
|
||||
for _, path := range paths {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -271,7 +271,7 @@ func validatePathWithPolicy(scope *Scope, policy Policy, sideEffect SideEffect,
|
||||
}
|
||||
}
|
||||
if enforceWorkspace {
|
||||
return scope.validate(path)
|
||||
return scope.validateRead(path)
|
||||
}
|
||||
return nil
|
||||
case SideEffectWrite, SideEffectOutOfWorkspace:
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FileSystemPolicyKind string
|
||||
|
||||
const (
|
||||
FileSystemRestricted FileSystemPolicyKind = "restricted"
|
||||
FileSystemUnrestricted FileSystemPolicyKind = "unrestricted"
|
||||
FileSystemExternal FileSystemPolicyKind = "external"
|
||||
)
|
||||
|
||||
type PermissionProfile struct {
|
||||
FileSystem FileSystemPolicy `json:"fileSystem"`
|
||||
Network NetworkPolicy `json:"network"`
|
||||
}
|
||||
|
||||
type FileSystemPolicy struct {
|
||||
Kind FileSystemPolicyKind `json:"kind"`
|
||||
ReadRoots []string `json:"readRoots,omitempty"`
|
||||
WriteRoots []WritableRoot `json:"writeRoots,omitempty"`
|
||||
DenyRead []string `json:"denyRead,omitempty"`
|
||||
DenyWrite []string `json:"denyWrite,omitempty"`
|
||||
IncludePlatformRoots bool `json:"includePlatformRoots,omitempty"`
|
||||
AllowTemp bool `json:"allowTemp,omitempty"`
|
||||
}
|
||||
|
||||
type WritableRoot struct {
|
||||
Root string `json:"root"`
|
||||
ReadOnlySubpaths []string `json:"readOnlySubpaths,omitempty"`
|
||||
ProtectedMetadataNames []string `json:"protectedMetadataNames,omitempty"`
|
||||
}
|
||||
|
||||
type NetworkPolicy struct {
|
||||
Mode NetworkMode `json:"mode"`
|
||||
AllowedDomains []string `json:"allowedDomains,omitempty"`
|
||||
DeniedDomains []string `json:"deniedDomains,omitempty"`
|
||||
ProxyRequired bool `json:"proxyRequired,omitempty"`
|
||||
}
|
||||
|
||||
var protectedMetadataNames = []string{".git", ".zero", ".agents"}
|
||||
|
||||
func DefaultPermissionProfile(workspaceRoot string) PermissionProfile {
|
||||
return PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil)
|
||||
}
|
||||
|
||||
func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile {
|
||||
if policy.Mode == "" {
|
||||
policy = DefaultPolicy()
|
||||
}
|
||||
if policy.Mode == ModeDisabled {
|
||||
return PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{Kind: FileSystemUnrestricted, IncludePlatformRoots: true, AllowTemp: true},
|
||||
Network: NetworkPolicy{Mode: NetworkAllow},
|
||||
}
|
||||
}
|
||||
|
||||
roots := permissionProfileRoots(workspaceRoot, scope)
|
||||
if extra := normalizeProfileDirs(policy.AllowWrite); len(extra) > 0 {
|
||||
roots = dedupeStrings(append(roots, extra...))
|
||||
}
|
||||
readRoots := permissionProfileReadRoots(workspaceRoot, policy, scope, roots)
|
||||
writeRoots := make([]WritableRoot, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
writeRoots = append(writeRoots, WritableRoot{
|
||||
Root: root,
|
||||
ProtectedMetadataNames: append([]string{}, protectedMetadataNames...),
|
||||
})
|
||||
}
|
||||
return PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: readRoots,
|
||||
WriteRoots: writeRoots,
|
||||
DenyRead: normalizeProfilePaths(policy.DenyRead),
|
||||
DenyWrite: normalizeProfilePaths(policy.DenyWrite),
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{
|
||||
Mode: effectiveNetwork(policy),
|
||||
AllowedDomains: normalizeDomains(policy.AllowedDomains),
|
||||
DeniedDomains: normalizeDomains(policy.DeniedDomains),
|
||||
ProxyRequired: effectiveNetwork(policy) == NetworkScoped,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (profile PermissionProfile) RequiresPlatformSandbox() bool {
|
||||
if profile.FileSystem.Kind == FileSystemRestricted {
|
||||
return true
|
||||
}
|
||||
if profile.Network.ProxyRequired {
|
||||
return true
|
||||
}
|
||||
return profile.Network.Mode == NetworkDeny || profile.Network.Mode == NetworkScoped
|
||||
}
|
||||
|
||||
func permissionProfileRoots(workspaceRoot string, scope *Scope) []string {
|
||||
if scope != nil {
|
||||
return scope.Roots()
|
||||
}
|
||||
if root := normalizeProfilePath(workspaceRoot); root != "" {
|
||||
return []string{root}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scope, writeRoots []string) []string {
|
||||
readRoots := append([]string{}, writeRoots...)
|
||||
if scope != nil {
|
||||
readRoots = dedupeStrings(append(readRoots, scope.ReadRoots()...))
|
||||
} else if root := normalizeProfilePath(workspaceRoot); root != "" {
|
||||
readRoots = dedupeStrings(append(readRoots, root))
|
||||
}
|
||||
if extra := normalizeProfileDirs(policy.AllowRead); len(extra) > 0 {
|
||||
readRoots = dedupeStrings(append(readRoots, extra...))
|
||||
}
|
||||
return readRoots
|
||||
}
|
||||
|
||||
func normalizeProfileDirs(entries []string) []string {
|
||||
paths := normalizeProfilePaths(entries)
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() && filepath.Dir(path) != path {
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeProfilePaths(entries []string) []string {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
path := normalizeProfilePath(entry)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[path]; ok {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
out = append(out, path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeProfilePath(entry string) string {
|
||||
trimmed := strings.TrimSpace(entry)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if trimmed == "~" || strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, "~"+string(filepath.Separator)) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
trimmed = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(trimmed[1:], "/"), string(filepath.Separator)))
|
||||
}
|
||||
absolute, err := filepath.Abs(trimmed)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
|
||||
return resolved
|
||||
}
|
||||
return filepath.Clean(absolute)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ func TestIsAlreadySandboxed(t *testing.T) {
|
||||
t.Fatalf("IsAlreadySandboxed must be false when only %s=1 is set without %s", EnvSandboxed, EnvSandboxBackend)
|
||||
}
|
||||
// The backend marker alone (no ZERO_SANDBOXED=1) must not count either.
|
||||
t.Setenv(EnvSandboxBackend, string(BackendBubblewrap))
|
||||
t.Setenv(EnvSandboxBackend, string(BackendLinuxBwrap))
|
||||
t.Setenv(EnvSandboxed, "")
|
||||
if IsAlreadySandboxed() {
|
||||
t.Fatalf("IsAlreadySandboxed must be false when only %s is set", EnvSandboxBackend)
|
||||
@@ -28,11 +28,11 @@ func TestIsAlreadySandboxed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSandboxEnvironmentMarksSandboxed(t *testing.T) {
|
||||
env := sandboxEnvironment(DefaultPolicy(), BackendBubblewrap, "/home/agent")
|
||||
env := sandboxEnvironment(DefaultPolicy(), BackendLinuxBwrap, "/home/agent")
|
||||
// Both correlated markers must be set: IsAlreadySandboxed requires BOTH, so a
|
||||
// regression that drops either one would silently break re-entrancy detection.
|
||||
wantSandboxed := EnvSandboxed + "=1"
|
||||
wantBackend := EnvSandboxBackend + "=" + string(BackendBubblewrap)
|
||||
wantBackend := EnvSandboxBackend + "=" + string(BackendLinuxBwrap)
|
||||
var gotSandboxed, gotBackend bool
|
||||
for _, entry := range env {
|
||||
switch entry {
|
||||
@@ -55,14 +55,14 @@ func TestBuildCommandPlanWrapsWhenNotAlreadySandboxed(t *testing.T) {
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{Name: BackendBubblewrap, Available: true, Executable: "/usr/bin/bwrap"},
|
||||
Backend: Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox"},
|
||||
})
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
if !plan.Wrapped || plan.Name != "/usr/bin/bwrap" {
|
||||
t.Fatalf("expected a wrapped bubblewrap plan, got wrapped=%v name=%q", plan.Wrapped, plan.Name)
|
||||
if !plan.Wrapped || plan.Name != "/usr/bin/zero-linux-sandbox" {
|
||||
t.Fatalf("expected a wrapped Linux helper plan, got wrapped=%v name=%q", plan.Wrapped, plan.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +70,12 @@ func TestBuildCommandPlanReEntrancyGuardReturnsPassThrough(t *testing.T) {
|
||||
// Simulate running inside an already-wrapped process: BOTH correlated markers
|
||||
// that sandboxEnvironment sets together must be present for the guard to fire.
|
||||
t.Setenv(EnvSandboxed, "1")
|
||||
t.Setenv(EnvSandboxBackend, string(BackendBubblewrap))
|
||||
t.Setenv(EnvSandboxBackend, string(BackendLinuxBwrap))
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{Name: BackendBubblewrap, Available: true, Executable: "/usr/bin/bwrap"},
|
||||
Backend: Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox"},
|
||||
})
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type PermissionGrantScope string
|
||||
|
||||
const (
|
||||
PermissionGrantScopeTurn PermissionGrantScope = "turn"
|
||||
PermissionGrantScopeSession PermissionGrantScope = "session"
|
||||
)
|
||||
|
||||
type RequestPermissionProfile struct {
|
||||
Network *NetworkPermissions `json:"network,omitempty"`
|
||||
FileSystem *FileSystemPermissions `json:"file_system,omitempty"`
|
||||
}
|
||||
|
||||
type RequestPermissionsResponse struct {
|
||||
Permissions RequestPermissionProfile `json:"permissions"`
|
||||
Scope PermissionGrantScope `json:"scope"`
|
||||
StrictAutoReview bool `json:"strict_auto_review,omitempty"`
|
||||
}
|
||||
|
||||
type NetworkPermissions struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
type FileSystemPermissions struct {
|
||||
Read []string `json:"read,omitempty"`
|
||||
Write []string `json:"write,omitempty"`
|
||||
DenyRead []string `json:"deny_read,omitempty"`
|
||||
GlobScanMaxDepth *int `json:"glob_scan_max_depth,omitempty"`
|
||||
Entries []FileSystemSandboxEntry `json:"entries,omitempty"`
|
||||
}
|
||||
|
||||
type FileSystemSandboxEntry struct {
|
||||
Path FileSystemPath `json:"path"`
|
||||
Access FileSystemAccessMode `json:"access"`
|
||||
}
|
||||
|
||||
type FileSystemAccessMode string
|
||||
|
||||
const (
|
||||
FileSystemAccessRead FileSystemAccessMode = "read"
|
||||
FileSystemAccessWrite FileSystemAccessMode = "write"
|
||||
FileSystemAccessDeny FileSystemAccessMode = "deny"
|
||||
)
|
||||
|
||||
type FileSystemPath struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
}
|
||||
|
||||
func (path *FileSystemPath) UnmarshalJSON(data []byte) error {
|
||||
var rawString string
|
||||
if err := json.Unmarshal(data, &rawString); err == nil {
|
||||
path.Kind = "path"
|
||||
path.Type = "path"
|
||||
path.Path = rawString
|
||||
return nil
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = json.Unmarshal(raw["kind"], &path.Kind)
|
||||
_ = json.Unmarshal(raw["type"], &path.Type)
|
||||
_ = json.Unmarshal(raw["path"], &path.Path)
|
||||
_ = json.Unmarshal(raw["pattern"], &path.Pattern)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (profile RequestPermissionProfile) Empty() bool {
|
||||
networkEmpty := profile.Network == nil || profile.Network.Enabled == nil || !*profile.Network.Enabled
|
||||
fs := profile.FileSystem
|
||||
fileSystemEmpty := fs == nil ||
|
||||
(len(fs.Read) == 0 && len(fs.Write) == 0 && len(fs.DenyRead) == 0 && len(fs.Entries) == 0)
|
||||
return networkEmpty && fileSystemEmpty
|
||||
}
|
||||
|
||||
func NormalizeRequestPermissionProfile(profile RequestPermissionProfile, basePath string) (RequestPermissionProfile, error) {
|
||||
var normalized RequestPermissionProfile
|
||||
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled {
|
||||
enabled := true
|
||||
normalized.Network = &NetworkPermissions{Enabled: &enabled}
|
||||
}
|
||||
if profile.FileSystem != nil {
|
||||
fs, err := normalizeFileSystemPermissions(*profile.FileSystem, basePath)
|
||||
if err != nil {
|
||||
return RequestPermissionProfile{}, err
|
||||
}
|
||||
if len(fs.Read) > 0 || len(fs.Write) > 0 || len(fs.DenyRead) > 0 {
|
||||
normalized.FileSystem = &fs
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// RequestPermissionGrantProfile returns the effective grant profile the engine
|
||||
// can enforce. File-like read/write requests become their normalized parent
|
||||
// directory roots, matching GrantRequestPermissions and native sandbox profiles.
|
||||
func RequestPermissionGrantProfile(profile RequestPermissionProfile) (RequestPermissionProfile, error) {
|
||||
var grant RequestPermissionProfile
|
||||
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled {
|
||||
enabled := true
|
||||
grant.Network = &NetworkPermissions{Enabled: &enabled}
|
||||
}
|
||||
if profile.FileSystem != nil {
|
||||
read, err := grantPermissionRoots(profile.FileSystem.Read)
|
||||
if err != nil {
|
||||
return RequestPermissionProfile{}, err
|
||||
}
|
||||
write, err := grantPermissionRoots(profile.FileSystem.Write)
|
||||
if err != nil {
|
||||
return RequestPermissionProfile{}, err
|
||||
}
|
||||
fs := FileSystemPermissions{
|
||||
Read: read,
|
||||
Write: write,
|
||||
DenyRead: append([]string{}, profile.FileSystem.DenyRead...),
|
||||
}
|
||||
if len(fs.Read) > 0 || len(fs.Write) > 0 || len(fs.DenyRead) > 0 {
|
||||
grant.FileSystem = &fs
|
||||
}
|
||||
}
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
func grantPermissionRoots(paths []string) ([]string, error) {
|
||||
roots := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
continue
|
||||
}
|
||||
root, err := normalizeScopeRoot(permissionRoot(path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roots = append(roots, root)
|
||||
}
|
||||
return dedupeStrings(roots), nil
|
||||
}
|
||||
|
||||
func normalizeFileSystemPermissions(fs FileSystemPermissions, basePath string) (FileSystemPermissions, error) {
|
||||
var out FileSystemPermissions
|
||||
var err error
|
||||
if out.Read, err = normalizePermissionPaths(fs.Read, basePath); err != nil {
|
||||
return FileSystemPermissions{}, err
|
||||
}
|
||||
if out.Write, err = normalizePermissionPaths(fs.Write, basePath); err != nil {
|
||||
return FileSystemPermissions{}, err
|
||||
}
|
||||
if out.DenyRead, err = normalizePermissionPaths(fs.DenyRead, basePath); err != nil {
|
||||
return FileSystemPermissions{}, err
|
||||
}
|
||||
for _, entry := range fs.Entries {
|
||||
path, ok := entry.Path.pathString()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
normalized, err := normalizePermissionPath(path, basePath)
|
||||
if err != nil {
|
||||
return FileSystemPermissions{}, err
|
||||
}
|
||||
switch normalizeFileSystemAccess(entry.Access) {
|
||||
case FileSystemAccessRead:
|
||||
out.Read = append(out.Read, normalized)
|
||||
case FileSystemAccessWrite:
|
||||
out.Write = append(out.Write, normalized)
|
||||
case FileSystemAccessDeny:
|
||||
out.DenyRead = append(out.DenyRead, normalized)
|
||||
}
|
||||
}
|
||||
out.Read = dedupeStrings(out.Read)
|
||||
out.Write = dedupeStrings(out.Write)
|
||||
out.DenyRead = dedupeStrings(out.DenyRead)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (path FileSystemPath) pathString() (string, bool) {
|
||||
kind := strings.TrimSpace(firstNonEmpty(path.Type, path.Kind))
|
||||
if kind == "" || kind == "path" {
|
||||
value := strings.TrimSpace(path.Path)
|
||||
return value, value != ""
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func normalizeFileSystemAccess(access FileSystemAccessMode) FileSystemAccessMode {
|
||||
switch strings.ToLower(strings.TrimSpace(string(access))) {
|
||||
case "read":
|
||||
return FileSystemAccessRead
|
||||
case "write":
|
||||
return FileSystemAccessWrite
|
||||
case "deny", "none":
|
||||
return FileSystemAccessDeny
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionPaths(paths []string, basePath string) ([]string, error) {
|
||||
out := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
normalized, err := normalizePermissionPath(path, basePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalized != "" {
|
||||
out = append(out, normalized)
|
||||
}
|
||||
}
|
||||
return dedupeStrings(out), nil
|
||||
}
|
||||
|
||||
func normalizePermissionPath(path string, basePath string) (string, error) {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !filepath.IsAbs(trimmed) {
|
||||
base := strings.TrimSpace(basePath)
|
||||
if base == "" {
|
||||
var err error
|
||||
base, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
trimmed = filepath.Join(base, trimmed)
|
||||
}
|
||||
absolute, err := filepath.Abs(trimmed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
|
||||
return filepath.Clean(resolved), nil
|
||||
}
|
||||
return filepath.Clean(absolute), nil
|
||||
}
|
||||
|
||||
func (engine *Engine) GrantRequestPermissions(profile RequestPermissionProfile, scope PermissionGrantScope) (func(), error) {
|
||||
if engine == nil {
|
||||
return nil, errors.New("sandbox engine is not configured")
|
||||
}
|
||||
if scope == "" {
|
||||
scope = PermissionGrantScopeTurn
|
||||
}
|
||||
var cleanups []func()
|
||||
cleanup := func() {
|
||||
for i := len(cleanups) - 1; i >= 0; i-- {
|
||||
cleanups[i]()
|
||||
}
|
||||
}
|
||||
if engine.scope != nil && profile.FileSystem != nil {
|
||||
for _, path := range profile.FileSystem.Read {
|
||||
root := permissionRoot(path)
|
||||
var undo func()
|
||||
var err error
|
||||
if scope == PermissionGrantScopeSession {
|
||||
_, err = engine.scope.AddRead(root)
|
||||
} else {
|
||||
_, undo, err = engine.scope.AddTemporaryRead(root)
|
||||
if undo != nil {
|
||||
cleanups = append(cleanups, undo)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("grant read permission %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
for _, path := range profile.FileSystem.Write {
|
||||
root := permissionRoot(path)
|
||||
var undo func()
|
||||
var err error
|
||||
if scope == PermissionGrantScopeSession {
|
||||
_, err = engine.scope.Add(root)
|
||||
} else {
|
||||
_, undo, err = engine.scope.AddTemporaryWrite(root)
|
||||
if undo != nil {
|
||||
cleanups = append(cleanups, undo)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("grant write permission %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch scope {
|
||||
case PermissionGrantScopeSession:
|
||||
engine.sessionProfiles.add(profile)
|
||||
return func() {}, nil
|
||||
case PermissionGrantScopeTurn:
|
||||
remove := engine.turnProfiles.add(profile)
|
||||
cleanups = append(cleanups, remove)
|
||||
return cleanup, nil
|
||||
default:
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("unsupported permission grant scope %q", scope)
|
||||
}
|
||||
}
|
||||
|
||||
func permissionRoot(path string) string {
|
||||
clean := filepath.Clean(path)
|
||||
if info, err := os.Stat(clean); err == nil && info.IsDir() {
|
||||
return clean
|
||||
}
|
||||
return filepath.Dir(clean)
|
||||
}
|
||||
|
||||
func (engine *Engine) effectivePolicy(policy Policy) Policy {
|
||||
if engine == nil {
|
||||
return policy
|
||||
}
|
||||
for _, profile := range engine.sessionProfiles.list() {
|
||||
policy = applyRequestPermissionProfile(policy, profile)
|
||||
}
|
||||
for _, profile := range engine.turnProfiles.list() {
|
||||
policy = applyRequestPermissionProfile(policy, profile)
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func applyRequestPermissionProfile(policy Policy, profile RequestPermissionProfile) Policy {
|
||||
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled {
|
||||
policy.Network = NetworkAllow
|
||||
policy.AllowedDomains = nil
|
||||
policy.DeniedDomains = nil
|
||||
}
|
||||
if profile.FileSystem != nil {
|
||||
policy.AllowRead = dedupeStrings(append(policy.AllowRead, profile.FileSystem.Read...))
|
||||
policy.AllowWrite = dedupeStrings(append(policy.AllowWrite, profile.FileSystem.Write...))
|
||||
policy.DenyRead = dedupeStrings(append(policy.DenyRead, profile.FileSystem.DenyRead...))
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
type memoryGrantSet struct {
|
||||
mu sync.Mutex
|
||||
grants map[string][]Grant
|
||||
}
|
||||
|
||||
func newMemoryGrantSet() *memoryGrantSet {
|
||||
return &memoryGrantSet{grants: map[string][]Grant{}}
|
||||
}
|
||||
|
||||
func (set *memoryGrantSet) add(grant Grant) {
|
||||
if set == nil {
|
||||
return
|
||||
}
|
||||
set.mu.Lock()
|
||||
defer set.mu.Unlock()
|
||||
bucket := set.grants[grant.ToolName]
|
||||
for index := range bucket {
|
||||
if bucket[index].Scope == grant.Scope && bucket[index].ScopeKind == grant.ScopeKind {
|
||||
bucket[index] = grant
|
||||
set.grants[grant.ToolName] = bucket
|
||||
return
|
||||
}
|
||||
}
|
||||
set.grants[grant.ToolName] = append(bucket, grant)
|
||||
}
|
||||
|
||||
func (set *memoryGrantSet) lookup(toolName string, reqScope string, requested Autonomy) GrantLookup {
|
||||
if set == nil {
|
||||
return GrantLookup{}
|
||||
}
|
||||
set.mu.Lock()
|
||||
defer set.mu.Unlock()
|
||||
return lookupGrantBucket(set.grants[strings.TrimSpace(toolName)], reqScope, requested)
|
||||
}
|
||||
|
||||
type permissionProfileGrantSet struct {
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
profiles map[int]RequestPermissionProfile
|
||||
}
|
||||
|
||||
func newPermissionProfileGrantSet() *permissionProfileGrantSet {
|
||||
return &permissionProfileGrantSet{profiles: map[int]RequestPermissionProfile{}}
|
||||
}
|
||||
|
||||
func (set *permissionProfileGrantSet) add(profile RequestPermissionProfile) func() {
|
||||
if set == nil {
|
||||
return func() {}
|
||||
}
|
||||
set.mu.Lock()
|
||||
set.nextID++
|
||||
id := set.nextID
|
||||
set.profiles[id] = profile
|
||||
set.mu.Unlock()
|
||||
return func() {
|
||||
set.mu.Lock()
|
||||
delete(set.profiles, id)
|
||||
set.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (set *permissionProfileGrantSet) list() []RequestPermissionProfile {
|
||||
if set == nil {
|
||||
return nil
|
||||
}
|
||||
set.mu.Lock()
|
||||
defer set.mu.Unlock()
|
||||
out := make([]RequestPermissionProfile, 0, len(set.profiles))
|
||||
for _, profile := range set.profiles {
|
||||
out = append(out, profile)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGrantRequestPermissionsReadDoesNotGrantWrite(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
target := filepath.Join(outside, "notes.txt")
|
||||
scope, err := NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: DefaultPolicy(),
|
||||
Scope: scope,
|
||||
})
|
||||
|
||||
cleanup, err := engine.GrantRequestPermissions(RequestPermissionProfile{
|
||||
FileSystem: &FileSystemPermissions{Read: []string{outside}},
|
||||
}, PermissionGrantScopeTurn)
|
||||
if err != nil {
|
||||
t.Fatalf("GrantRequestPermissions: %v", err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
read := engine.Evaluate(context.Background(), Request{
|
||||
WorkspaceRoot: workspace,
|
||||
ToolName: "read_file",
|
||||
SideEffect: SideEffectRead,
|
||||
Permission: PermissionAllow,
|
||||
Args: map[string]any{"path": target},
|
||||
})
|
||||
if read.Action != ActionAllow {
|
||||
t.Fatalf("read grant should allow reads, got %#v", read)
|
||||
}
|
||||
write := engine.Evaluate(context.Background(), Request{
|
||||
WorkspaceRoot: workspace,
|
||||
ToolName: "write_file",
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionAllow,
|
||||
Args: map[string]any{"path": target},
|
||||
})
|
||||
if write.Action != ActionDeny {
|
||||
t.Fatalf("read grant must not allow writes, got %#v", write)
|
||||
}
|
||||
|
||||
cleanup()
|
||||
readAfterCleanup := engine.Evaluate(context.Background(), Request{
|
||||
WorkspaceRoot: workspace,
|
||||
ToolName: "read_file",
|
||||
SideEffect: SideEffectRead,
|
||||
Permission: PermissionAllow,
|
||||
Args: map[string]any{"path": target},
|
||||
})
|
||||
if readAfterCleanup.Action != ActionDeny {
|
||||
t.Fatalf("turn read grant should be cleaned up, got %#v", readAfterCleanup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
policy := DefaultPolicy()
|
||||
policy.EnforceToolNetwork = true
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: policy})
|
||||
request := Request{
|
||||
WorkspaceRoot: workspace,
|
||||
ToolName: "web_fetch",
|
||||
SideEffect: SideEffectNetwork,
|
||||
Permission: PermissionAllow,
|
||||
Args: map[string]any{"url": "https://example.com"},
|
||||
}
|
||||
if before := engine.Evaluate(context.Background(), request); before.Action != ActionDeny {
|
||||
t.Fatalf("default policy should deny shell network, got %#v", before)
|
||||
}
|
||||
enabled := true
|
||||
cleanup, err := engine.GrantRequestPermissions(RequestPermissionProfile{
|
||||
Network: &NetworkPermissions{Enabled: &enabled},
|
||||
}, PermissionGrantScopeTurn)
|
||||
if err != nil {
|
||||
t.Fatalf("GrantRequestPermissions: %v", err)
|
||||
}
|
||||
if after := engine.Evaluate(context.Background(), request); after.Action != ActionAllow {
|
||||
t.Fatalf("network turn grant should allow shell network, got %#v", after)
|
||||
}
|
||||
cleanup()
|
||||
if afterCleanup := engine.Evaluate(context.Background(), request); afterCleanup.Action != ActionDeny {
|
||||
t.Fatalf("network turn grant should be cleaned up, got %#v", afterCleanup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantRequestPermissionsSessionPersists(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
target := filepath.Join(outside, "notes.txt")
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: DefaultPolicy()})
|
||||
|
||||
cleanup, err := engine.GrantRequestPermissions(RequestPermissionProfile{
|
||||
FileSystem: &FileSystemPermissions{Write: []string{outside}},
|
||||
}, PermissionGrantScopeSession)
|
||||
if err != nil {
|
||||
t.Fatalf("GrantRequestPermissions: %v", err)
|
||||
}
|
||||
cleanup()
|
||||
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
WorkspaceRoot: workspace,
|
||||
ToolName: "write_file",
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionAllow,
|
||||
Args: map[string]any{"path": target},
|
||||
})
|
||||
if decision.Action != ActionAllow {
|
||||
t.Fatalf("session grant should persist after cleanup no-op, got %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionProfileIncludesReadOnlyGrantAsReadRootOnly(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
scope, err := NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
expectedReadRoot, err := scope.AddRead(outside)
|
||||
if err != nil {
|
||||
t.Fatalf("AddRead: %v", err)
|
||||
}
|
||||
|
||||
profile := PermissionProfileFromPolicy(workspace, DefaultPolicy(), scope)
|
||||
if !containsString(profile.FileSystem.ReadRoots, expectedReadRoot) {
|
||||
t.Fatalf("read roots = %#v, want %s", profile.FileSystem.ReadRoots, expectedReadRoot)
|
||||
}
|
||||
for _, root := range profile.FileSystem.WriteRoots {
|
||||
if root.Root == expectedReadRoot {
|
||||
t.Fatalf("read-only grant must not become writable, write roots = %#v", profile.FileSystem.WriteRoots)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPermissionGrantProfileWidensFilesToGrantRoots(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
readDir := filepath.Join(workspace, "read")
|
||||
writeDir := filepath.Join(workspace, "write")
|
||||
if err := os.MkdirAll(readDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(writeDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
readFile := filepath.Join(readDir, "secret.txt")
|
||||
writeFile := filepath.Join(writeDir, "output.txt")
|
||||
if err := os.WriteFile(readFile, []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
grant, err := RequestPermissionGrantProfile(RequestPermissionProfile{
|
||||
FileSystem: &FileSystemPermissions{
|
||||
Read: []string{readFile},
|
||||
Write: []string{writeFile},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if grant.FileSystem == nil {
|
||||
t.Fatal("expected filesystem grant")
|
||||
}
|
||||
expectedReadRoot, err := normalizeScopeRoot(readDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedWriteRoot, err := normalizeScopeRoot(writeDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(grant.FileSystem.Read) != 1 || grant.FileSystem.Read[0] != expectedReadRoot {
|
||||
t.Fatalf("read grant roots = %#v, want %#v", grant.FileSystem.Read, []string{expectedReadRoot})
|
||||
}
|
||||
if len(grant.FileSystem.Write) != 1 || grant.FileSystem.Write[0] != expectedWriteRoot {
|
||||
t.Fatalf("write grant roots = %#v, want %#v", grant.FileSystem.Write, []string{expectedWriteRoot})
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -232,5 +233,127 @@ func requestPaths(request Request) []string {
|
||||
paths = append(paths, value)
|
||||
}
|
||||
}
|
||||
if request.ToolName == "apply_patch" {
|
||||
paths = append(paths, applyPatchRequestPaths(request.Args)...)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func applyPatchRequestPaths(args map[string]any) []string {
|
||||
patch := firstArgString(args, "patch", "diff")
|
||||
if patch == "" {
|
||||
return nil
|
||||
}
|
||||
cwd := firstArgString(args, "cwd")
|
||||
var paths []string
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if cwd != "" && filepath.Clean(cwd) != "." && !filepath.IsAbs(path) {
|
||||
path = filepath.Join(cwd, path)
|
||||
}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func applyPatchPathViolation(request Request) *pathViolation {
|
||||
if request.ToolName != "apply_patch" {
|
||||
return nil
|
||||
}
|
||||
patch := firstArgString(request.Args, "patch", "diff")
|
||||
if patch == "" {
|
||||
return nil
|
||||
}
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") {
|
||||
return &pathViolation{
|
||||
Code: ViolationOutsideWorkspace,
|
||||
Path: path,
|
||||
Reason: fmt.Sprintf("patch path %q must stay inside the workspace", path),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func patchHeaderPaths(patch string) []string {
|
||||
var paths []string
|
||||
oldRemaining, newRemaining := 0, 0
|
||||
inHunk := false
|
||||
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
|
||||
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "-"):
|
||||
oldRemaining--
|
||||
case strings.HasPrefix(line, "+"):
|
||||
newRemaining--
|
||||
case strings.HasPrefix(line, "\\"):
|
||||
default:
|
||||
oldRemaining--
|
||||
newRemaining--
|
||||
}
|
||||
continue
|
||||
}
|
||||
inHunk = false
|
||||
switch {
|
||||
case strings.HasPrefix(line, "diff --git "):
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 4 {
|
||||
paths = append(paths, stripPatchPrefix(fields[2]), stripPatchPrefix(fields[3]))
|
||||
}
|
||||
case strings.HasPrefix(line, "@@"):
|
||||
oldRemaining, newRemaining = parsePatchHunkCounts(line)
|
||||
inHunk = oldRemaining > 0 || newRemaining > 0
|
||||
case strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "):
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
paths = append(paths, stripPatchPrefix(fields[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func parsePatchHunkCounts(line string) (int, int) {
|
||||
_, rest, ok := strings.Cut(line, "@@")
|
||||
if !ok {
|
||||
return 0, 0
|
||||
}
|
||||
rangeSection := rest
|
||||
if before, _, ok := strings.Cut(rest, "@@"); ok {
|
||||
rangeSection = before
|
||||
}
|
||||
old, next := 0, 0
|
||||
for _, field := range strings.Fields(rangeSection) {
|
||||
switch {
|
||||
case strings.HasPrefix(field, "-"):
|
||||
old = patchHunkCount(field[1:])
|
||||
case strings.HasPrefix(field, "+"):
|
||||
next = patchHunkCount(field[1:])
|
||||
}
|
||||
}
|
||||
return old, next
|
||||
}
|
||||
|
||||
func patchHunkCount(spec string) int {
|
||||
if _, count, ok := strings.Cut(spec, ","); ok {
|
||||
if n, err := strconv.Atoi(count); err == nil {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func stripPatchPrefix(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
|
||||
path = path[2:]
|
||||
}
|
||||
return filepath.ToSlash(path)
|
||||
}
|
||||
@@ -253,6 +253,13 @@ func TestClassifyASTCatchesDestructiveProgramsRegexMisses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyFlagsFindDeleteAsDestructive(t *testing.T) {
|
||||
risk := classifyCommand("find . -type f -delete")
|
||||
if risk.Level != RiskCritical || !HasRiskCategory(risk, "destructive") {
|
||||
t.Fatalf("Classify(find -delete) = level %s, categories %v; want critical destructive", risk.Level, risk.Categories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyASTCatchesNetworkProgramsRegexMisses(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
"telnet example.com 23",
|
||||
|
||||
+470
-200
@@ -13,8 +13,6 @@ import (
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
const bubblewrapWorkspace = "/workspace"
|
||||
|
||||
var errPolicyOnlyRunnerDisabled = errors.New("policy-only sandbox runner is disabled")
|
||||
|
||||
// errWSLPolicyOnlyDisabled is returned when running under WSL without bubblewrap
|
||||
@@ -32,15 +30,21 @@ type CommandSpec struct {
|
||||
}
|
||||
|
||||
type CommandPlan struct {
|
||||
Backend Backend `json:"backend"`
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
Policy Policy `json:"policy"`
|
||||
Wrapped bool `json:"wrapped"`
|
||||
Name string `json:"name"`
|
||||
Args []string `json:"args"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
SandboxDir string `json:"sandboxDir,omitempty"`
|
||||
Backend Backend `json:"backend"`
|
||||
TargetBackend BackendName `json:"targetBackend"`
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
Policy Policy `json:"policy"`
|
||||
PermissionProfile PermissionProfile `json:"permissionProfile"`
|
||||
Wrapped bool `json:"wrapped"`
|
||||
SandboxEnvMarkers []string `json:"sandboxEnvMarkers,omitempty"`
|
||||
EnforcementLevel EnforcementLevel `json:"enforcementLevel"`
|
||||
DowngradeReason string `json:"downgradeReason,omitempty"`
|
||||
RequiresPlatformSandbox bool `json:"requiresPlatformSandbox"`
|
||||
Name string `json:"name"`
|
||||
Args []string `json:"args"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
SandboxDir string `json:"sandboxDir,omitempty"`
|
||||
// MonitorTag, when non-empty, is the unique marker embedded in the
|
||||
// sandbox-exec profile's denial messages; a caller passes it to
|
||||
// StartDenialMonitor to capture what the sandbox blocked. Empty unless
|
||||
@@ -162,7 +166,8 @@ func (engine *Engine) writeRoots(workspaceRoot string) []string {
|
||||
// sandboxed shell command may write where the policy grants writes. DenyWrite
|
||||
// is enforced at the policy gate, and on sandbox-exec additionally as an
|
||||
// explicit deny rule (see sandboxExecProfile).
|
||||
if extra := resolveWriteRootPaths(engine.policy.AllowWrite); len(extra) > 0 {
|
||||
policy := engine.effectivePolicy(engine.policy)
|
||||
if extra := resolveWriteRootPaths(policy.AllowWrite); len(extra) > 0 {
|
||||
roots = dedupeStrings(append(roots, extra...))
|
||||
}
|
||||
return roots
|
||||
@@ -172,11 +177,11 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
|
||||
if engine == nil {
|
||||
return directCommandPlan(spec, Backend{Name: BackendPolicyOnly, Message: "sandbox disabled"}, Policy{}, ""), nil
|
||||
}
|
||||
policy := engine.policy
|
||||
policy := engine.effectivePolicy(engine.policy)
|
||||
if policy.Mode == "" {
|
||||
policy = DefaultPolicy()
|
||||
}
|
||||
workspaceRoot, commandDir, relativeDir, err := engine.resolveCommandDir(spec.Dir, policy)
|
||||
workspaceRoot, commandDir, err := engine.resolveCommandDir(spec.Dir, policy)
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
@@ -190,34 +195,60 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
|
||||
if backend.Name == "" {
|
||||
backend = Backend{Name: BackendPolicyOnly, Message: "policy-only fallback: sandbox backend was not selected"}
|
||||
}
|
||||
preference := SandboxPreferenceAuto
|
||||
// Re-entrancy guard: a command spawned by a process we already wrapped (both
|
||||
// ZERO_SANDBOXED=1 and ZERO_SANDBOX_BACKEND set in its env — see
|
||||
// IsAlreadySandboxed) must not be wrapped again — nested bwrap / sandbox-exec
|
||||
// IsAlreadySandboxed) must not be wrapped again — nested platform wrappers
|
||||
// fails and a second egress proxy would be redundant. Return a pass-through
|
||||
// plan. Mirrors the already-sandboxed guard used by comparable executor
|
||||
// sandboxes.
|
||||
// plan.
|
||||
if IsAlreadySandboxed() {
|
||||
return directCommandPlan(spec, backend, policy, workspaceRoot), nil
|
||||
preference = SandboxPreferenceForbid
|
||||
}
|
||||
if policy.Mode == ModeDisabled {
|
||||
return directCommandPlan(spec, backend, policy, workspaceRoot), nil
|
||||
preference = SandboxPreferenceForbid
|
||||
}
|
||||
profile := PermissionProfileFromPolicy(workspaceRoot, policy, engine.scope)
|
||||
manager := NewSandboxManager(SandboxManagerOptions{
|
||||
GOOS: backend.Platform,
|
||||
Backend: backend,
|
||||
})
|
||||
return manager.BuildCommandPlan(SandboxManagerRequest{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Command: spec,
|
||||
Policy: policy,
|
||||
Scope: engine.scope,
|
||||
Profile: profile,
|
||||
Preference: preference,
|
||||
ValidateExecution: true,
|
||||
})
|
||||
}
|
||||
|
||||
func buildPlatformCommandPlan(execRequest SandboxExecutionRequest, policy Policy) (CommandPlan, error) {
|
||||
if policy.Mode == "" {
|
||||
policy = DefaultPolicy()
|
||||
}
|
||||
spec := execRequest.Command
|
||||
backend := execRequest.Backend
|
||||
workspaceRoot := execRequest.WorkspaceRoot
|
||||
if execRequest.EnforcementLevel == EnforcementDisabled || execRequest.TargetBackend == BackendNone || !execRequest.RequiresPlatformSandbox {
|
||||
return withSandboxExecutionMetadata(directCommandPlan(spec, backend, policy, workspaceRoot), execRequest), nil
|
||||
}
|
||||
switch backend.Name {
|
||||
case BackendBubblewrap:
|
||||
case BackendLinuxBwrap:
|
||||
if backend.Available && backend.Executable != "" {
|
||||
// Bubblewrap isolates the network namespace (--unshare-net) with no
|
||||
// bridge to the host loopback proxy, so it cannot enforce scoped egress;
|
||||
// a scoped policy collapses to deny (no proxy is started). Evaluate's
|
||||
// network gate denies network-risk tools for this backend to match.
|
||||
return bubblewrapCommandPlan(spec, workspaceRoot, relativeDir, engine.writeRoots(workspaceRoot), policy, backend), nil
|
||||
return linuxSandboxHelperCommandPlan(execRequest, policy)
|
||||
}
|
||||
case BackendSandboxExec:
|
||||
case BackendMacOSSeatbelt:
|
||||
if backend.Available && backend.Executable != "" {
|
||||
egress, err := startScopedEgress(policy, backend)
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
return sandboxExecCommandPlan(spec, workspaceRoot, engine.writeRoots(workspaceRoot), policy, backend, egress), nil
|
||||
return withSandboxExecutionMetadata(seatbeltCommandPlan(execRequest, policy, backend, egress), execRequest), nil
|
||||
}
|
||||
case BackendWindowsRestrictedToken:
|
||||
if backend.Available && backend.Executable != "" {
|
||||
return windowsRestrictedTokenCommandPlan(execRequest, policy)
|
||||
}
|
||||
case BackendWSL:
|
||||
// WSL fallback: no native isolation available. Fail closed unless the policy
|
||||
@@ -230,12 +261,67 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
return wslCommandPlan(spec, workspaceRoot, policy, backend, egress), nil
|
||||
return withSandboxExecutionMetadata(wslCommandPlan(spec, workspaceRoot, policy, backend, egress), execRequest), nil
|
||||
}
|
||||
if !policy.AllowPolicyOnlyRunner {
|
||||
return CommandPlan{}, errPolicyOnlyRunnerDisabled
|
||||
}
|
||||
return directCommandPlan(spec, backend, policy, workspaceRoot), nil
|
||||
return withSandboxExecutionMetadata(directCommandPlan(spec, backend, policy, workspaceRoot), execRequest), nil
|
||||
}
|
||||
|
||||
func linuxSandboxHelperCommandPlan(execRequest SandboxExecutionRequest, policy Policy) (CommandPlan, error) {
|
||||
spec := execRequest.Command
|
||||
helper := LinuxSandboxHelperCommand{}
|
||||
if execRequest.Backend.Name == BackendLinuxBwrap && execRequest.Backend.Executable != "" {
|
||||
helper.Name = execRequest.Backend.Executable
|
||||
} else {
|
||||
resolved, err := linuxSandboxHelperCommand()
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
helper = resolved
|
||||
}
|
||||
command := append([]string{spec.Name}, spec.Args...)
|
||||
args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
|
||||
SandboxPolicyCWD: execRequest.WorkspaceRoot,
|
||||
CommandCWD: spec.Dir,
|
||||
PermissionProfile: execRequest.PermissionProfile,
|
||||
BlockUnixSockets: policy.BlockUnixSockets,
|
||||
Command: command,
|
||||
})
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
planDir := spec.Dir
|
||||
if helper.Dir != "" {
|
||||
planDir = helper.Dir
|
||||
}
|
||||
plan := CommandPlan{
|
||||
Backend: execRequest.Backend,
|
||||
TargetBackend: execRequest.TargetBackend,
|
||||
WorkspaceRoot: execRequest.WorkspaceRoot,
|
||||
Policy: policy,
|
||||
Wrapped: true,
|
||||
SandboxEnvMarkers: execRequest.SandboxEnvMarkers,
|
||||
EnforcementLevel: execRequest.EnforcementLevel,
|
||||
Name: helper.Name,
|
||||
Args: append(append([]string{}, helper.ArgsPrefix...), args...),
|
||||
Dir: planDir,
|
||||
Env: cloneStrings(spec.Env),
|
||||
SandboxDir: spec.Dir,
|
||||
}
|
||||
return withSandboxExecutionMetadata(plan, execRequest), nil
|
||||
}
|
||||
|
||||
func withSandboxExecutionMetadata(plan CommandPlan, request SandboxExecutionRequest) CommandPlan {
|
||||
plan.Backend = request.Backend
|
||||
plan.TargetBackend = request.TargetBackend
|
||||
plan.PermissionProfile = request.PermissionProfile
|
||||
plan.SandboxEnvMarkers = request.SandboxEnvMarkers
|
||||
plan.EnforcementLevel = request.EnforcementLevel
|
||||
plan.DowngradeReason = request.DowngradeReason
|
||||
plan.RequiresPlatformSandbox = request.RequiresPlatformSandbox
|
||||
return plan
|
||||
}
|
||||
|
||||
// wslCommandPlan builds the WSL policy-only fallback plan: the command runs
|
||||
@@ -267,15 +353,19 @@ func wslCommandPlan(spec CommandSpec, workspaceRoot string, policy Policy, backe
|
||||
"with proxy egress (least privilege). The engine still enforces the full policy."
|
||||
}
|
||||
plan := CommandPlan{
|
||||
Backend: backend,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Wrapped: false,
|
||||
Name: spec.Name,
|
||||
Args: cloneStrings(spec.Args),
|
||||
Dir: spec.Dir,
|
||||
Env: env,
|
||||
Notes: []string{note},
|
||||
Backend: backend,
|
||||
TargetBackend: backend.TargetBackend(),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Wrapped: false,
|
||||
SandboxEnvMarkers: backend.SandboxEnvMarkers(policy),
|
||||
EnforcementLevel: backend.EnforcementLevel(policy),
|
||||
DowngradeReason: note,
|
||||
Name: spec.Name,
|
||||
Args: cloneStrings(spec.Args),
|
||||
Dir: spec.Dir,
|
||||
Env: env,
|
||||
Notes: []string{note},
|
||||
}
|
||||
if egress != nil {
|
||||
plan.cleanup = egress.cleanup
|
||||
@@ -358,27 +448,31 @@ func startScopedEgress(policy Policy, backend Backend) (*scopedEgress, error) {
|
||||
|
||||
func directCommandPlan(spec CommandSpec, backend Backend, policy Policy, workspaceRoot string) CommandPlan {
|
||||
return CommandPlan{
|
||||
Backend: backend,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Wrapped: false,
|
||||
Name: spec.Name,
|
||||
Args: cloneStrings(spec.Args),
|
||||
Dir: spec.Dir,
|
||||
Env: cloneStrings(spec.Env),
|
||||
Backend: backend,
|
||||
TargetBackend: backend.TargetBackend(),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Wrapped: false,
|
||||
SandboxEnvMarkers: backend.SandboxEnvMarkers(policy),
|
||||
EnforcementLevel: backend.EnforcementLevel(policy),
|
||||
DowngradeReason: backend.DowngradeReason(policy),
|
||||
Name: spec.Name,
|
||||
Args: cloneStrings(spec.Args),
|
||||
Dir: spec.Dir,
|
||||
Env: cloneStrings(spec.Env),
|
||||
}
|
||||
}
|
||||
|
||||
func (engine *Engine) resolveCommandDir(dir string, policy Policy) (string, string, string, error) {
|
||||
func (engine *Engine) resolveCommandDir(dir string, policy Policy) (string, string, error) {
|
||||
workspaceRoot := strings.TrimSpace(engine.workspaceRoot)
|
||||
if workspaceRoot == "" {
|
||||
return "", "", "", errors.New("sandbox workspace root is required")
|
||||
return "", "", errors.New("sandbox workspace root is required")
|
||||
}
|
||||
workspaceRoot = filepath.Clean(workspaceRoot)
|
||||
if !filepath.IsAbs(workspaceRoot) {
|
||||
absolute, err := filepath.Abs(workspaceRoot)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("resolve sandbox workspace: %w", err)
|
||||
return "", "", fmt.Errorf("resolve sandbox workspace: %w", err)
|
||||
}
|
||||
workspaceRoot = absolute
|
||||
}
|
||||
@@ -398,7 +492,7 @@ func (engine *Engine) resolveCommandDir(dir string, policy Policy) (string, stri
|
||||
}
|
||||
if policy.EnforceWorkspace {
|
||||
if violation := engine.scopeFor(engine.workspaceRoot).validate(commandDir); violation != nil {
|
||||
return "", "", "", Violation{
|
||||
return "", "", Violation{
|
||||
Code: violation.Code,
|
||||
ToolName: "sandbox_command",
|
||||
Action: ActionDeny,
|
||||
@@ -412,119 +506,14 @@ func (engine *Engine) resolveCommandDir(dir string, policy Policy) (string, stri
|
||||
}
|
||||
}
|
||||
}
|
||||
relativeDir, err := filepath.Rel(workspaceRoot, commandDir)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("resolve sandbox command directory: %w", err)
|
||||
}
|
||||
if relativeDir == "." {
|
||||
relativeDir = ""
|
||||
}
|
||||
return workspaceRoot, commandDir, relativeDir, nil
|
||||
return workspaceRoot, commandDir, nil
|
||||
}
|
||||
|
||||
func bubblewrapCommandPlan(spec CommandSpec, workspaceRoot string, relativeDir string, writeRoots []string, policy Policy, backend Backend) CommandPlan {
|
||||
sandboxDir := bubblewrapWorkspace
|
||||
if relativeDir != "" {
|
||||
sandboxDir = filepath.ToSlash(filepath.Join(bubblewrapWorkspace, relativeDir))
|
||||
}
|
||||
// A cwd inside an extra write root is outside the /workspace remount; the
|
||||
// extra root is bound at its real host path, so chdir there directly.
|
||||
// (resolveCommandDir has already validated the cwd against the scope when
|
||||
// EnforceWorkspace is on; an unvalidated out-of-scope cwd just makes
|
||||
// bwrap's chdir fail closed.)
|
||||
if relativeDir == ".." || strings.HasPrefix(relativeDir, ".."+string(filepath.Separator)) {
|
||||
sandboxDir = filepath.ToSlash(spec.Dir)
|
||||
}
|
||||
args := []string{
|
||||
"--die-with-parent",
|
||||
"--unshare-pid",
|
||||
"--unshare-ipc",
|
||||
"--unshare-uts",
|
||||
"--proc", "/proc",
|
||||
"--dev", "/dev",
|
||||
"--tmpfs", "/tmp",
|
||||
"--bind", workspaceRoot, bubblewrapWorkspace,
|
||||
}
|
||||
for _, root := range writeRoots {
|
||||
// writeRoots[0] is the scope's workspace root; it is normalized by the
|
||||
// same Abs+EvalSymlinks pipeline resolveCommandDir applies to the
|
||||
// workspaceRoot parameter, so this equality reliably skips the workspace
|
||||
// (already remounted at /workspace) rather than double-binding it.
|
||||
if root == workspaceRoot {
|
||||
continue
|
||||
}
|
||||
args = append(args, "--bind", root, root)
|
||||
}
|
||||
args = append(args, "--chdir", sandboxDir)
|
||||
// Both deny and scoped isolate the network namespace with --unshare-net (no raw
|
||||
// host network). Scoped does NOT get the filtering proxy here: bubblewrap has no
|
||||
// bridge from its netns to the host loopback proxy, so scoped collapses to deny
|
||||
// until a real relay (e.g. slirp4netns) is added. allow shares the host network.
|
||||
if network := effectiveNetwork(policy); network == NetworkDeny || network == NetworkScoped {
|
||||
args = append(args, "--unshare-net")
|
||||
}
|
||||
for _, mount := range existingBubblewrapMounts() {
|
||||
args = append(args, "--ro-bind", mount, mount)
|
||||
}
|
||||
args = append(args, "--clearenv")
|
||||
setenvLines := sandboxEnvironment(policy, BackendBubblewrap, bubblewrapWorkspace)
|
||||
for _, env := range setenvLines {
|
||||
key, value, ok := strings.Cut(env, "=")
|
||||
if ok {
|
||||
args = append(args, "--setenv", key, value)
|
||||
}
|
||||
}
|
||||
// Optionally install the seccomp Unix-socket filter by prefixing the command
|
||||
// with the zero-seccomp helper, bound read-only into the sandbox at its host
|
||||
// path. Off by default; if the helper is not found the command runs without
|
||||
// the filter (bubblewrap's filesystem/network isolation still applies).
|
||||
command := append([]string{spec.Name}, spec.Args...)
|
||||
if policy.BlockUnixSockets {
|
||||
if helper := seccompHelper(); helper != "" {
|
||||
args = append(args, "--ro-bind", helper, helper)
|
||||
command = append([]string{helper}, command...)
|
||||
}
|
||||
}
|
||||
args = append(args, "--")
|
||||
args = append(args, command...)
|
||||
return CommandPlan{
|
||||
Backend: backend,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Wrapped: true,
|
||||
Name: backend.Executable,
|
||||
Args: args,
|
||||
SandboxDir: sandboxDir,
|
||||
}
|
||||
func seatbeltCommandPlan(execRequest SandboxExecutionRequest, policy Policy, backend Backend, egress *scopedEgress) CommandPlan {
|
||||
return seatbeltCommandPlanWithProfile(execRequest.Command, execRequest.WorkspaceRoot, execRequest.PermissionProfile, policy, backend, egress)
|
||||
}
|
||||
|
||||
// seccompHelper resolves the zero-seccomp wrapper used to install the AF_UNIX
|
||||
// seccomp filter inside the bubblewrap sandbox (Policy.BlockUnixSockets). It is a
|
||||
// package var so tests can stub discovery; it returns "" when the helper is not
|
||||
// found, in which case the sandbox runs without the extra filter rather than
|
||||
// failing the command.
|
||||
var seccompHelper = findSeccompHelper
|
||||
|
||||
// findSeccompHelper looks for the zero-seccomp helper next to the running
|
||||
// executable first (the expected install layout), then on PATH. It returns ""
|
||||
// when no helper is available.
|
||||
func findSeccompHelper() string {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
candidate := filepath.Join(filepath.Dir(exe), "zero-seccomp")
|
||||
// Require an executable regular file: selecting a present-but-non-executable
|
||||
// file would make the sandboxed command fail with EACCES instead of degrading
|
||||
// gracefully. (exec.LookPath below applies the same check on PATH.)
|
||||
if info, statErr := os.Stat(candidate); statErr == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0 {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath("zero-seccomp"); err == nil {
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sandboxExecCommandPlan(spec CommandSpec, workspaceRoot string, writeRoots []string, policy Policy, backend Backend, egress *scopedEgress) CommandPlan {
|
||||
func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, profile PermissionProfile, policy Policy, backend Backend, egress *scopedEgress) CommandPlan {
|
||||
var proxyPort, socksPort string
|
||||
if egress != nil {
|
||||
if _, port, err := net.SplitHostPort(egress.addr); err == nil {
|
||||
@@ -538,23 +527,30 @@ func sandboxExecCommandPlan(spec CommandSpec, workspaceRoot string, writeRoots [
|
||||
if policy.MonitorDenials {
|
||||
denialTag = nextSandboxDenialTag()
|
||||
}
|
||||
args := []string{"-p", sandboxExecProfile(writeRoots, policy, proxyPort, socksPort, denialTag), spec.Name}
|
||||
args := []string{"-p", seatbeltProfileFromPermissionProfile(profile, policy, proxyPort, socksPort, denialTag), spec.Name}
|
||||
args = append(args, spec.Args...)
|
||||
env := sandboxEnvironment(policy, BackendSandboxExec, workspaceRoot)
|
||||
envBackend := backend.Name
|
||||
if envBackend == "" {
|
||||
envBackend = BackendMacOSSeatbelt
|
||||
}
|
||||
env := sandboxEnvironment(policy, envBackend, workspaceRoot)
|
||||
if egress != nil {
|
||||
env = append(env, ProxyEnvWithSocks(egress.addr, egress.socksAddr)...)
|
||||
env = appendCACertEnv(env, egress)
|
||||
}
|
||||
plan := CommandPlan{
|
||||
Backend: backend,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Wrapped: true,
|
||||
Name: backend.Executable,
|
||||
Args: args,
|
||||
Dir: spec.Dir,
|
||||
Env: env,
|
||||
SandboxDir: spec.Dir,
|
||||
Backend: backend,
|
||||
TargetBackend: backend.TargetBackend(),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Wrapped: true,
|
||||
SandboxEnvMarkers: backend.SandboxEnvMarkers(policy),
|
||||
EnforcementLevel: backend.EnforcementLevel(policy),
|
||||
Name: backend.Executable,
|
||||
Args: args,
|
||||
Dir: spec.Dir,
|
||||
Env: env,
|
||||
SandboxDir: spec.Dir,
|
||||
}
|
||||
if egress != nil {
|
||||
plan.cleanup = egress.cleanup
|
||||
@@ -565,6 +561,33 @@ func sandboxExecCommandPlan(spec CommandSpec, workspaceRoot string, writeRoots [
|
||||
return plan
|
||||
}
|
||||
|
||||
func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy) PermissionProfile {
|
||||
fs := FileSystemPolicy{
|
||||
Kind: FileSystemUnrestricted,
|
||||
ReadRoots: []string{string(filepath.Separator)},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
}
|
||||
if policy.EnforceWorkspace {
|
||||
fs.Kind = FileSystemRestricted
|
||||
fs.WriteRoots = make([]WritableRoot, 0, len(writeRoots))
|
||||
for _, root := range writeRoots {
|
||||
fs.WriteRoots = append(fs.WriteRoots, WritableRoot{Root: root})
|
||||
}
|
||||
}
|
||||
fs.DenyRead = normalizeProfilePaths(policy.DenyRead)
|
||||
fs.DenyWrite = normalizeProfilePaths(policy.DenyWrite)
|
||||
return PermissionProfile{
|
||||
FileSystem: fs,
|
||||
Network: NetworkPolicy{
|
||||
Mode: effectiveNetwork(policy),
|
||||
AllowedDomains: normalizeDomains(policy.AllowedDomains),
|
||||
DeniedDomains: normalizeDomains(policy.DeniedDomains),
|
||||
ProxyRequired: effectiveNetwork(policy) == NetworkScoped,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func existingBubblewrapMounts() []string {
|
||||
candidates := []string{"/bin", "/usr", "/lib", "/lib64", "/sbin", "/etc"}
|
||||
mounts := []string{}
|
||||
@@ -697,24 +720,13 @@ func sandboxMachLookupRule() string {
|
||||
}
|
||||
|
||||
func sandboxExecProfile(writeRoots []string, policy Policy, proxyPort string, socksPort string, denialTag string) string {
|
||||
networkRule := networkRuleFor(policy, proxyPort, socksPort)
|
||||
writeRule := "(allow file-write*)"
|
||||
if policy.EnforceWorkspace {
|
||||
// The granted write roots are the only writable *project* locations. Temp
|
||||
// trees and the standard device nodes are the only additions, matching what
|
||||
// the bubblewrap backend already grants (--tmpfs /tmp, --dev /dev).
|
||||
filters := make([]string, 0, len(writeRoots)+len(sandboxWritableSubpaths)+len(sandboxWritableDevices))
|
||||
for _, root := range writeRoots {
|
||||
filters = append(filters, `(subpath "`+sandboxProfileString(root)+`")`)
|
||||
}
|
||||
for _, subpath := range sandboxWritableSubpaths {
|
||||
filters = append(filters, `(subpath "`+subpath+`")`)
|
||||
}
|
||||
for _, device := range sandboxWritableDevices {
|
||||
filters = append(filters, `(literal "`+device+`")`)
|
||||
}
|
||||
writeRule = "(allow file-write*\n " + strings.Join(filters, "\n ") + ")"
|
||||
}
|
||||
return seatbeltProfileFromPermissionProfile(seatbeltCompatibilityPermissionProfile(writeRoots, policy), policy, proxyPort, socksPort, denialTag)
|
||||
}
|
||||
|
||||
func seatbeltProfileFromPermissionProfile(profile PermissionProfile, policy Policy, proxyPort string, socksPort string, denialTag string) string {
|
||||
networkRule := networkRuleForProfile(profile.Network, proxyPort, socksPort)
|
||||
readRule := seatbeltReadRule(profile.FileSystem)
|
||||
writeRule := seatbeltWriteRule(profile.FileSystem)
|
||||
denyDefault := "(deny default)"
|
||||
if denialTag != "" {
|
||||
// Tag denials so the runtime log monitor can attribute them to THIS run; the
|
||||
@@ -726,39 +738,166 @@ func sandboxExecProfile(writeRoots []string, policy Policy, proxyPort string, so
|
||||
"(version 1)",
|
||||
denyDefault,
|
||||
"(allow process*)",
|
||||
"(allow process-info* (target same-sandbox))",
|
||||
"(allow sysctl-read)",
|
||||
"(allow sysctl-write (sysctl-name \"kern.grade_cputype\"))",
|
||||
"(allow iokit-open (iokit-registry-entry-class \"RootDomainUserClient\"))",
|
||||
"(allow ipc-posix-sem)",
|
||||
`(allow ipc-posix-shm-read-data ipc-posix-shm-write-create ipc-posix-shm-write-unlink (ipc-posix-name-regex #"^/__KMP_REGISTERED_LIB_[0-9]+$"))`,
|
||||
"(allow pseudo-tty)",
|
||||
`(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))`,
|
||||
`(allow file-read* file-write* (require-all (regex #"^/dev/ttys[0-9]+") (extension "com.apple.sandbox.pty")))`,
|
||||
`(allow file-ioctl (regex #"^/dev/ttys[0-9]+"))`,
|
||||
"(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))",
|
||||
"(allow user-preference-read)",
|
||||
// Let a sandboxed command signal itself and its own process group so scripts
|
||||
// that spawn and kill children (e.g. `sleep 30 & kill %1`, test runners,
|
||||
// timeouts) work. The target restriction keeps it from signalling any
|
||||
// process outside its own group.
|
||||
"(allow signal (target self) (target pgrp))",
|
||||
sandboxMachLookupRule(),
|
||||
"(allow file-read*)",
|
||||
seatbeltPlatformRuntimeRules(),
|
||||
readRule,
|
||||
writeRule,
|
||||
}
|
||||
// DenyWrite entries are emitted AFTER the write allow so seatbelt's
|
||||
// last-match-wins makes them override the workspace/AllowWrite binds — so
|
||||
// "DenyWrite wins" holds for shell commands too, not just the policy gate.
|
||||
rules = append(rules, denyWriteRules(policy)...)
|
||||
rules = append(rules, denyReadRules(profile.FileSystem)...)
|
||||
rules = append(rules, writeRootCarveoutDenyRules(profile.FileSystem)...)
|
||||
rules = append(rules, denyWriteRulesFromPaths(profile.FileSystem.DenyWrite)...)
|
||||
rules = append(rules, networkRule)
|
||||
return strings.Join(rules, "\n")
|
||||
return strings.Join(nonEmptyStrings(rules), "\n")
|
||||
}
|
||||
|
||||
func seatbeltReadRule(fs FileSystemPolicy) string {
|
||||
if fs.Kind == FileSystemUnrestricted {
|
||||
return "(allow file-read*)"
|
||||
}
|
||||
filters := make([]string, 0, len(fs.ReadRoots)+len(macosPlatformReadRoots()))
|
||||
for _, root := range fs.ReadRoots {
|
||||
filters = appendSeatbeltSubpathFilter(filters, root)
|
||||
}
|
||||
if fs.IncludePlatformRoots {
|
||||
for _, root := range macosPlatformReadRoots() {
|
||||
filters = appendSeatbeltSubpathFilter(filters, root)
|
||||
}
|
||||
}
|
||||
if len(filters) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "(allow file-read* file-test-existence\n " + strings.Join(filters, "\n ") + ")"
|
||||
}
|
||||
|
||||
func seatbeltWriteRule(fs FileSystemPolicy) string {
|
||||
if fs.Kind == FileSystemUnrestricted {
|
||||
return "(allow file-write*)"
|
||||
}
|
||||
filters := make([]string, 0, len(fs.WriteRoots)+len(sandboxWritableSubpaths)+len(sandboxWritableDevices))
|
||||
for _, root := range fs.WriteRoots {
|
||||
if filter := seatbeltWritableRootFilter(root); filter != "" {
|
||||
filters = append(filters, filter)
|
||||
}
|
||||
}
|
||||
if fs.AllowTemp {
|
||||
for _, subpath := range sandboxWritableSubpaths {
|
||||
filters = append(filters, `(subpath "`+sandboxProfileString(subpath)+`")`)
|
||||
}
|
||||
}
|
||||
for _, device := range sandboxWritableDevices {
|
||||
filters = append(filters, `(literal "`+sandboxProfileString(device)+`")`)
|
||||
}
|
||||
if len(filters) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "(allow file-write*\n " + strings.Join(filters, "\n ") + ")"
|
||||
}
|
||||
|
||||
func seatbeltWritableRootFilter(root WritableRoot) string {
|
||||
rootPath := strings.TrimSpace(root.Root)
|
||||
if rootPath == "" {
|
||||
return ""
|
||||
}
|
||||
return `(subpath "` + sandboxProfileString(rootPath) + `")`
|
||||
}
|
||||
|
||||
func seatbeltProtectedMetadataRegex(root string, name string) string {
|
||||
root = strings.TrimSpace(filepath.ToSlash(filepath.Clean(root)))
|
||||
name = strings.Trim(strings.TrimSpace(name), `/\`)
|
||||
if root == "" || name == "" || name == "." {
|
||||
return ""
|
||||
}
|
||||
root = strings.TrimRight(root, "/")
|
||||
if root == "" {
|
||||
root = "/"
|
||||
}
|
||||
escapedRoot := regexpQuoteMeta(root)
|
||||
escapedName := regexpQuoteMeta(name)
|
||||
if root == "/" {
|
||||
return "^/" + escapedName + "(/.*)?$"
|
||||
}
|
||||
return "^" + escapedRoot + "/" + escapedName + "(/.*)?$"
|
||||
}
|
||||
|
||||
func denyReadRules(fs FileSystemPolicy) []string {
|
||||
return denySeatbeltPathRules("file-read*", fs.DenyRead)
|
||||
}
|
||||
|
||||
func writeRootCarveoutDenyRules(fs FileSystemPolicy) []string {
|
||||
if fs.Kind != FileSystemRestricted {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, root := range fs.WriteRoots {
|
||||
for _, subpath := range root.ReadOnlySubpaths {
|
||||
subpath = strings.TrimSpace(subpath)
|
||||
if subpath == "" {
|
||||
continue
|
||||
}
|
||||
escaped := sandboxProfileString(subpath)
|
||||
out = append(out,
|
||||
`(deny file-write* (literal "`+escaped+`"))`,
|
||||
`(deny file-write* (subpath "`+escaped+`"))`,
|
||||
)
|
||||
}
|
||||
for _, name := range root.ProtectedMetadataNames {
|
||||
regex := seatbeltProtectedMetadataRegex(root.Root, name)
|
||||
if regex == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, `(deny file-write* (regex #"`+sandboxProfileRegex(regex)+`"))`)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// denyWriteRules returns seatbelt deny clauses for the policy's resolved
|
||||
// DenyWrite paths: a (subpath ...) clause for a directory, a (literal ...) clause
|
||||
// for a single file. Empty when DenyWrite is unset.
|
||||
func denyWriteRules(policy Policy) []string {
|
||||
resolved := resolvePolicyPaths(policy.DenyWrite)
|
||||
return denyWriteRulesFromPaths(resolvePolicyPaths(policy.DenyWrite))
|
||||
}
|
||||
|
||||
func denyWriteRulesFromPaths(paths []string) []string {
|
||||
return denySeatbeltPathRules("file-write*", paths)
|
||||
}
|
||||
|
||||
func denySeatbeltPathRules(action string, paths []string) []string {
|
||||
resolved := normalizeProfilePaths(paths)
|
||||
if len(resolved) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(resolved))
|
||||
out := make([]string, 0, len(resolved)*2)
|
||||
for _, path := range resolved {
|
||||
filter := `(subpath "` + sandboxProfileString(path) + `")`
|
||||
filters := []string{`(subpath "` + sandboxProfileString(path) + `")`}
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
filter = `(literal "` + sandboxProfileString(path) + `")`
|
||||
filters = []string{`(literal "` + sandboxProfileString(path) + `")`}
|
||||
} else {
|
||||
filters = append(filters, `(literal "`+sandboxProfileString(path)+`")`)
|
||||
}
|
||||
for _, filter := range filters {
|
||||
out = append(out, "(deny "+action+" "+filter+")")
|
||||
if action == "file-read*" {
|
||||
out = append(out, "(deny file-write-unlink "+filter+")")
|
||||
}
|
||||
}
|
||||
out = append(out, "(deny file-write* "+filter+")")
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -772,7 +911,11 @@ func denyWriteRules(policy Policy) []string {
|
||||
// ALL_PROXY=socks5://... must reach the SOCKS listener). A scoped policy with no
|
||||
// resolvable proxy port falls back to a full deny (fail closed).
|
||||
func networkRuleFor(policy Policy, proxyPort string, socksPort string) string {
|
||||
switch effectiveNetwork(policy) {
|
||||
return networkRuleForProfile(NetworkPolicy{Mode: effectiveNetwork(policy)}, proxyPort, socksPort)
|
||||
}
|
||||
|
||||
func networkRuleForProfile(network NetworkPolicy, proxyPort string, socksPort string) string {
|
||||
switch network.Mode {
|
||||
case NetworkAllow:
|
||||
return "(allow network*)"
|
||||
case NetworkScoped:
|
||||
@@ -805,3 +948,130 @@ func sandboxProfileString(value string) string {
|
||||
replacer := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`, "\r", `\r`)
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
func sandboxProfileRegex(value string) string {
|
||||
replacer := strings.NewReplacer(`"`, `\"`, "\n", `\n`, "\r", `\r`)
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
func appendSeatbeltSubpathFilter(filters []string, root string) []string {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return filters
|
||||
}
|
||||
return append(filters, `(subpath "`+sandboxProfileString(root)+`")`)
|
||||
}
|
||||
|
||||
func macosPlatformReadRoots() []string {
|
||||
return []string{
|
||||
"/bin",
|
||||
"/sbin",
|
||||
"/Applications",
|
||||
"/Library/Apple/System/Library/Frameworks",
|
||||
"/Library/Apple/System/Library/PrivateFrameworks",
|
||||
"/Library/Apple/usr/lib",
|
||||
"/usr/bin",
|
||||
"/usr/sbin",
|
||||
"/usr/lib",
|
||||
"/usr/libexec",
|
||||
"/usr/share",
|
||||
"/usr/local/lib",
|
||||
"/opt/homebrew/lib",
|
||||
"/etc",
|
||||
"/private/etc",
|
||||
"/var/db",
|
||||
"/private/var/db",
|
||||
"/System/Library",
|
||||
"/System/iOSSupport/System/Library/Frameworks",
|
||||
"/System/iOSSupport/System/Library/PrivateFrameworks",
|
||||
"/System/iOSSupport/System/Library/SubFrameworks",
|
||||
"/Library/Apple",
|
||||
"/Library/Preferences",
|
||||
"/dev",
|
||||
}
|
||||
}
|
||||
|
||||
func seatbeltPlatformRuntimeRules() string {
|
||||
return strings.Join([]string{
|
||||
`(allow file-map-executable`,
|
||||
` (subpath "/Library/Apple/System/Library/Frameworks")`,
|
||||
` (subpath "/Library/Apple/System/Library/PrivateFrameworks")`,
|
||||
` (subpath "/Library/Apple/usr/lib")`,
|
||||
` (subpath "/System/Library/Extensions")`,
|
||||
` (subpath "/System/Library/Frameworks")`,
|
||||
` (subpath "/System/Library/PrivateFrameworks")`,
|
||||
` (subpath "/System/Library/SubFrameworks")`,
|
||||
` (subpath "/System/iOSSupport/System/Library/Frameworks")`,
|
||||
` (subpath "/System/iOSSupport/System/Library/PrivateFrameworks")`,
|
||||
` (subpath "/System/iOSSupport/System/Library/SubFrameworks")`,
|
||||
` (subpath "/usr/lib"))`,
|
||||
`(allow system-mac-syscall (mac-policy-name "vnguard"))`,
|
||||
`(allow system-mac-syscall (require-all (mac-policy-name "Sandbox") (mac-syscall-number 67)))`,
|
||||
`(allow file-read-metadata file-test-existence`,
|
||||
` (literal "/etc")`,
|
||||
` (literal "/tmp")`,
|
||||
` (literal "/var")`,
|
||||
` (literal "/private/etc/localtime"))`,
|
||||
`(allow file-read-metadata file-test-existence (path-ancestors "/System/Volumes/Data/private"))`,
|
||||
`(allow file-read* file-test-existence (literal "/"))`,
|
||||
`(allow system-fsctl (fsctl-command FSIOC_CAS_BSDFLAGS))`,
|
||||
`(allow file-read* file-test-existence`,
|
||||
` (literal "/dev/autofs_nowait")`,
|
||||
` (literal "/dev/random")`,
|
||||
` (literal "/dev/urandom")`,
|
||||
` (literal "/private/etc/master.passwd")`,
|
||||
` (literal "/private/etc/passwd")`,
|
||||
` (literal "/private/etc/protocols")`,
|
||||
` (literal "/private/etc/services"))`,
|
||||
`(allow file-read* file-test-existence file-write-data`,
|
||||
` (literal "/dev/null")`,
|
||||
` (literal "/dev/zero"))`,
|
||||
`(allow file-read-data file-test-existence file-write-data (subpath "/dev/fd"))`,
|
||||
`(allow file-read* file-test-existence file-write-data file-ioctl (literal "/dev/dtracehelper"))`,
|
||||
`(allow file-read* file-test-existence file-write* (subpath "/tmp"))`,
|
||||
`(allow file-read* file-write* (subpath "/private/tmp"))`,
|
||||
`(allow file-read* file-write* (subpath "/var/tmp"))`,
|
||||
`(allow file-read* file-write* (subpath "/private/var/tmp"))`,
|
||||
`(allow file-read* file-test-existence`,
|
||||
` (literal "/System/Library/CoreServices")`,
|
||||
` (literal "/System/Library/CoreServices/.SystemVersionPlatform.plist")`,
|
||||
` (literal "/System/Library/CoreServices/SystemVersion.plist"))`,
|
||||
`(allow file-read-metadata (subpath "/var"))`,
|
||||
`(allow file-read-metadata (subpath "/private/var"))`,
|
||||
`(allow network-outbound (literal "/private/var/run/syslog"))`,
|
||||
`(allow ipc-posix-shm-read* (ipc-posix-name "apple.shm.notification_center"))`,
|
||||
`(allow file-read* (literal "/private/var/db/eligibilityd/eligibility.plist"))`,
|
||||
`(allow file-read* (extension "com.apple.app-sandbox.read"))`,
|
||||
`(allow file-read* file-write* (extension "com.apple.app-sandbox.read-write"))`,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func nonEmptyStrings(values []string) []string {
|
||||
out := values[:0]
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func regexpQuoteMeta(value string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`.`, `\.`,
|
||||
`+`, `\+`,
|
||||
`*`, `\*`,
|
||||
`?`, `\?`,
|
||||
`(`, `\(`,
|
||||
`)`, `\)`,
|
||||
`|`, `\|`,
|
||||
`[`, `\[`,
|
||||
`]`, `\]`,
|
||||
`{`, `\{`,
|
||||
`}`, `\}`,
|
||||
`^`, `\^`,
|
||||
`$`, `\$`,
|
||||
)
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//go:build linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLinuxHelperRealSandboxSmoke(t *testing.T) {
|
||||
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
|
||||
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
|
||||
}
|
||||
backend := SelectBackend(BackendOptions{})
|
||||
if !backend.Available || backend.Name != BackendLinuxBwrap {
|
||||
t.Skipf("Linux sandbox backend unavailable: %s", backend.Message)
|
||||
}
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||
t.Fatalf("Mkdir .git: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".git", "config"), []byte("[core]\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile .git/config: %v", err)
|
||||
}
|
||||
secretDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("hidden\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile secret: %v", err)
|
||||
}
|
||||
blockedDir := filepath.Join(root, "blocked")
|
||||
if err := os.Mkdir(blockedDir, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir blocked: %v", err)
|
||||
}
|
||||
|
||||
policy := DefaultPolicy()
|
||||
policy.DenyRead = []string{secretDir}
|
||||
policy.DenyWrite = []string{blockedDir}
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy, Backend: backend})
|
||||
output, runErr := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", strings.Join([]string{
|
||||
"set -eu",
|
||||
"echo ok > write-ok.txt",
|
||||
"test \"$(cat write-ok.txt)\" = ok",
|
||||
"echo tmp > /tmp/zero-sandbox-smoke",
|
||||
"test \"$(cat /tmp/zero-sandbox-smoke)\" = tmp",
|
||||
"cat .git/config >/dev/null",
|
||||
}, "\n")},
|
||||
Dir: root,
|
||||
})
|
||||
if runErr != nil {
|
||||
if linuxSandboxLaunchUnsupported(string(output)) {
|
||||
t.Skipf("Linux sandbox launch is unsupported in this environment: %v\n%s", runErr, output)
|
||||
}
|
||||
t.Fatalf("allowed smoke command failed: %v\n%s", runErr, output)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
script string
|
||||
marker string
|
||||
}{
|
||||
{
|
||||
name: "outside write",
|
||||
script: "if echo leak > /etc/zero_sandbox_smoke 2>/dev/null; then echo OUTSIDE_WRITE_SUCCEEDED; exit 42; fi",
|
||||
marker: "OUTSIDE_WRITE_SUCCEEDED",
|
||||
},
|
||||
{
|
||||
name: "deny read",
|
||||
script: "if cat " + shellQuote(filepath.Join(secretDir, "secret.txt")) + " >/dev/null 2>&1; then echo DENY_READ_SUCCEEDED; exit 42; fi",
|
||||
marker: "DENY_READ_SUCCEEDED",
|
||||
},
|
||||
{
|
||||
name: "deny write",
|
||||
script: "if echo leak > blocked/file 2>/dev/null; then echo DENY_WRITE_SUCCEEDED; exit 42; fi",
|
||||
marker: "DENY_WRITE_SUCCEEDED",
|
||||
},
|
||||
{
|
||||
name: "metadata write",
|
||||
script: "if echo leak > .git/config 2>/dev/null; then echo METADATA_WRITE_SUCCEEDED; exit 42; fi",
|
||||
marker: "METADATA_WRITE_SUCCEEDED",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
output, _ := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", tc.script},
|
||||
Dir: root,
|
||||
})
|
||||
if strings.Contains(string(output), tc.marker) {
|
||||
t.Fatalf("sandbox allowed %s; output=%s", tc.name, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if python, err := exec.LookPath("python3"); err == nil && python != "" {
|
||||
t.Run("network deny", func(t *testing.T) {
|
||||
output, runErr := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
|
||||
Name: python,
|
||||
Args: []string{"-c", "import socket; socket.create_connection(('1.1.1.1', 80), 2).close()"},
|
||||
Dir: root,
|
||||
})
|
||||
if runErr == nil {
|
||||
t.Fatalf("sandbox allowed outbound network; output=%s", output)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
t.Log("python3 not found; skipping real network deny probe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxLandlockRealSandboxSmoke(t *testing.T) {
|
||||
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
|
||||
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
|
||||
}
|
||||
helper, err := linuxSandboxHelperCommand()
|
||||
if err != nil {
|
||||
t.Skipf("Linux sandbox helper unavailable: %v", err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
outsideDir := t.TempDir()
|
||||
outsideFile := filepath.Join(outsideDir, "blocked.txt")
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{string(filepath.Separator)},
|
||||
WriteRoots: []WritableRoot{{Root: root}},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
}
|
||||
output, runErr := runLinuxLandlockSmokeCommand(t, helper, profile, root, []string{"/bin/sh", "-c", strings.Join([]string{
|
||||
"set -eu",
|
||||
"echo ok > " + shellQuote(filepath.Join(root, "write-ok.txt")),
|
||||
"test \"$(cat " + shellQuote(filepath.Join(root, "write-ok.txt")) + ")\" = ok",
|
||||
"if echo leak > " + shellQuote(outsideFile) + " 2>/dev/null; then echo LANDLOCK_OUTSIDE_WRITE_SUCCEEDED; exit 42; fi",
|
||||
}, "\n")})
|
||||
if runErr != nil {
|
||||
if landlockLaunchUnsupported(string(output)) {
|
||||
t.Skipf("Landlock is unsupported in this environment: %v\n%s", runErr, output)
|
||||
}
|
||||
t.Fatalf("Landlock smoke command failed: %v\n%s", runErr, output)
|
||||
}
|
||||
if strings.Contains(string(output), "LANDLOCK_OUTSIDE_WRITE_SUCCEEDED") {
|
||||
t.Fatalf("Landlock allowed write outside approved roots: %s", output)
|
||||
}
|
||||
if _, err := os.Stat(outsideFile); err == nil {
|
||||
t.Fatalf("Landlock wrote host file outside approved roots: %s", outsideFile)
|
||||
}
|
||||
|
||||
if python, err := exec.LookPath("python3"); err == nil && python != "" {
|
||||
output, runErr = runLinuxLandlockSmokeCommand(t, helper, profile, root, []string{
|
||||
python,
|
||||
"-c",
|
||||
"import socket; socket.create_connection(('1.1.1.1', 80), 2).close()",
|
||||
})
|
||||
if runErr == nil {
|
||||
t.Fatalf("Landlock mode allowed outbound network; output=%s", output)
|
||||
}
|
||||
} else {
|
||||
t.Log("python3 not found; skipping Landlock network deny probe")
|
||||
}
|
||||
}
|
||||
|
||||
func runLinuxSandboxSmokeCommand(t *testing.T, engine *Engine, spec CommandSpec) ([]byte, error) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
command, plan, err := engine.CommandContext(ctx, spec)
|
||||
if err != nil {
|
||||
t.Fatalf("CommandContext: %v", err)
|
||||
}
|
||||
output, runErr := command.CombinedOutput()
|
||||
if strings.Contains(string(output), "OUTSIDE_WRITE_SUCCEEDED") {
|
||||
t.Fatalf("sandbox allowed write outside workspace; plan=%#v output=%s", plan, output)
|
||||
}
|
||||
return output, runErr
|
||||
}
|
||||
|
||||
func runLinuxLandlockSmokeCommand(t *testing.T, helper LinuxSandboxHelperCommand, profile PermissionProfile, root string, command []string) ([]byte, error) {
|
||||
t.Helper()
|
||||
args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
|
||||
SandboxPolicyCWD: root,
|
||||
CommandCWD: root,
|
||||
PermissionProfile: profile,
|
||||
UseLandlock: true,
|
||||
Command: command,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLinuxSandboxCommandArgs: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, helper.Name, append(append([]string{}, helper.ArgsPrefix...), args...)...)
|
||||
if helper.Dir != "" {
|
||||
cmd.Dir = helper.Dir
|
||||
} else {
|
||||
cmd.Dir = root
|
||||
}
|
||||
cmd.Env = os.Environ()
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
func linuxSandboxLaunchUnsupported(output string) bool {
|
||||
for _, marker := range []string{
|
||||
"Operation not permitted",
|
||||
"Permission denied",
|
||||
"Invalid argument",
|
||||
"No permissions to create new namespace",
|
||||
"creating new namespace failed",
|
||||
"bubblewrap is not available",
|
||||
"Can't mount proc on",
|
||||
} {
|
||||
if strings.Contains(output, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func landlockLaunchUnsupported(output string) bool {
|
||||
for _, marker := range []string{
|
||||
"apply Landlock: query ABI",
|
||||
"operation not supported",
|
||||
"Operation not supported",
|
||||
"invalid argument",
|
||||
"Invalid argument",
|
||||
"function not implemented",
|
||||
"Function not implemented",
|
||||
} {
|
||||
if strings.Contains(output, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user