mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* audit batch 1: redact nested secrets fully; count image tokens in compaction estimate
- secrets.Redact: replace longest matches first so a containing PRIVATE KEY
block is redacted before a nested shorter match corrupts its exact string
(audit medium internal/secrets/scanner.go:81) + regression test.
- agent compaction estimateTokens: add a flat per-image token cost so an
image-heavy context trends toward compaction instead of reading as ~0
(audit medium internal/agent/compaction.go:58) + test.
- ledger: re-verify against current main; mark scanner.go:35 and
redaction.go:362 already-fixed-on-main, record this batch's fixes.
* audit batch 2: worktrees defaultRunGit separates stdout/stderr
Capture git stdout and stderr into separate buffers instead of CombinedOutput,
so parsed rev-parse output is not polluted by git warnings and CommandResult.Stderr
carries the real error text (audit medium internal/worktrees/worktrees.go:247) + test.
* audit: re-verify all 87 pending findings against current main
Independent agent per file re-checked each open/partial finding against the
actual current code (the 06-11 ledger was verified on a since-diverged branch),
with an adversarial second pass on every already_fixed verdict. Authoritative
result: 78 genuinely pending (66 still_open + 12 partial); 9 rows were already
fixed on main and are reclassified. Full table in 2026-06-13-reverification.md.
* audit batch 3 (agent): cancellation identity, always-allow w/o sandbox, compaction usage, dedup
- loop.go: check ctx.Err() before collected.Error so a cancelled run returns
context.Canceled (errors.Is holds) instead of a plain stringified error.
- loop.go: 'Always allow' with no sandbox engine now allows THIS call instead of
denying it (persistence is skipped when there's nowhere to persist).
- loop.go: remove a duplicate schema["items"] assignment in propertyToRuntimeMap.
- compaction.go: forward OnUsage (token accounting) through the summarizer while
still omitting OnText, so compaction's token cost is counted in usage/budgets.
- tests for all three behavioral fixes (each fails pre-fix).
* audit batch 4 (cli): list-tools -o json, interrupted -o json terminal, surface session-record failures
- exec --list-tools -o json now emits a JSON tool listing instead of falling
through to the human-readable text (audit medium exec.go:182) + test.
- interrupted run with -o json now emits a terminal error+done on stdout instead
of only printing to stderr, so a JSON consumer sees a clean end (medium exec.go:500) + test.
- execSessionRecorder: surface a latched session-append failure to stderr once at
run end (deferred) instead of silently dropping it (medium exec_sessions.go:114/115) + test.
- left app.go trailing-arg ignore as-is: documented-intentional, TUI has no argv prompt.
* audit batch 5 (zerogit): status -z porcelain (renames + non-ASCII paths); document env-drop on plain Runner
- parseStatus: switch `git status` to `--porcelain -z` and parse the
NUL-delimited form (audit medium zerogit.go:212). Previously the default
--short output was split on newlines, so a rename `R old -> new` was kept as
one bogus path and a non-ASCII/whitespace filename arrived C-quoted+escaped
(e.g. `"caf\303\251.txt"`). The -z form never quotes paths and emits a
rename/copy as `XY <dest>\0<src>`, so we now record the destination verbatim
and consume the source field. + TestParseStatusZHandlesRenamesAndSpecialPaths.
- resolveRunners: document that the plain-Runner EnvRunner adapter intentionally
drops env (audit low zerogit.go:417). The branch is reached only when a caller
supplies a custom Runner without a RunGitEnv — in practice fake-Runner tests.
Production leaves both nil and gets defaultRunGit/defaultRunGitEnv, which honor
GIT_INDEX_FILE, so snapshot-diff isolation holds. Routing env calls to real git
instead would break the fake-Runner test pattern and fix nothing in production.
- update Inspect/Commit mock tests to the -z status format and the new command.
* audit batch 6 (config): reject negative maxTurns; drop dead contract-gap helpers
- FileConfig.UnmarshalJSON now rejects a negative maxTurns instead of letting the
`MaxTurns > 0` merge gates silently drop it and fall back to the default,
hiding the misconfiguration (audit medium resolver.go:136). 0 is left as the
"unset" sentinel (omitempty) and still falls back to the default; the CLI
--max-turns flag continues to reject 0 too since there it is distinguishable
from absent. + TestResolveRejectsNegativeMaxTurns / ...ZeroFallsBackToDefault.
- remove dead config/contracts.go (ContractGap, DefaultContractGaps,
FindContractGapsByMilestone, ContractOwnerRuntime) and its test — referenced
only by that test, never by production (audit low contracts.go:12).
- NOT changed: ResolveMCP intentionally does not run the provider command
(TestResolveMCPDoesNotRunProviderCommand guards this), so the "ZERO_PROVIDER_COMMAND
MCP servers dropped" row is by-design, not a defect.
* audit batch 7 (mcp): bound captured MCP server stderr
connectStdio attached a plain bytes.Buffer as cmd.Stderr that is only ever read
on initialize failure, yet it kept growing for the entire process lifetime as a
long-lived server logged to stderr (audit medium client.go:98). Replace it with
a concurrency-safe boundedBuffer capped at 64 KiB that keeps the head (enough for
init-error diagnostics) and discards the rest, reporting full write lengths so
os/exec never sees a short write. + TestBoundedBufferCapsRetainedBytes.
Left as-is (not defects worth the risk in the MCP client):
- client.go writer.write under client.mu: already designed to release the lock
before the unbounded response wait; the residual concern is a server that stops
draining stdin, an involved async rewrite for a theoretical deadlock.
- readLoop not answering server-initiated requests (ping): responding safely
needs re-locking around the shared writer; no configured server relies on it.
* audit batch 8 (tools/providers): read_file truncation marker; drop dead provider branch
- read_file: when a max_lines cut occurs, append an explicit "[truncated: N more
line(s)... set start_line=X to continue]" marker to the output. Previously only
the Result.Truncated flag was set, which is invisible in the rendered text, so
the model could not distinguish a cut read from a complete one (audit low
read_file.go:89). + TestReadFileToolMarksTruncation.
- providers/factory: simplify `if !explicitProvider || isImplicitOpenAI(...)` to
`if !explicitProvider` and delete the now-unused isImplicitOpenAI. The clause
was dead: explicitProvider==true implies ProviderKind or Provider is set, but
isImplicitOpenAI required both empty, so it could never contribute a case
(audit low factory.go:118). Behavior-preserving; providers tests still green.
* audit batch 9 (sessions): Tree builds from a single List() snapshot (no N+1 disk scans)
Store.Tree recursed via ListChildren per node, and each ListChildren ran a full
store.List() that re-read every metadata.json from disk — O(nodes * total-sessions)
reads for one tree (audit medium lineage.go:140). Snapshot all sessions once, index
children by parent in memory, and recurse over that. Child ordering is factored into
sortChildSessions so ListChildren and Tree stay identical; path-scoped cycle
detection (delete(seen,...)) is preserved. Existing tree/lineage tests still pass.
Left as-is: store.go timestamp() second precision (RFC3339) — switching to
RFC3339Nano would break lexical UpdatedAt ordering against existing second-precision
records ("...00.5Z" sorts before "...00Z"), so it is not a safe one-line change.
* audit batch 10 (specialist): serialize accounting dedup-then-append
recordSpecialistStop and appendSpecialistUsageRollup checked for an existing event
(specialistEventExists → ReadEvents) and then appended under SEPARATE store locks,
so two concurrent finishers — a foreground onExit racing a TaskOutput poll, or a
background reaper — could both pass the dedup check and write duplicate stop/usage
events (audit medium accounting.go:72). Guard each check-then-append pair with a
package-level accountingMu (Executor is used by value, so a struct field would not
be shared). + TestRecordSpecialistStopDedupesUnderConcurrency (passes under -race).
* audit batch 11 (zeroruntime/gemini): O(1) text accumulation; gemini cached-token counts
- zeroruntime CollectStreamWithOptions accumulated assistant text with
`collected.Text += event.Content`, reallocating the whole string on every chunk
(O(n^2) over a long streamed response). Accumulate in a strings.Builder on the
collector and materialize it once in flush, the single finalization point hit
before every return (audit low helpers.go:104). Existing collect tests still pass.
- gemini decoded usageMetadata without cachedContentTokenCount, so a cached-prompt
response reported zero cached input tokens while the other providers report them
(audit medium gemini/types.go:88). Decode the field and thread it through
streamState into NormalizeUsage as CachedInputTokens. + extended
TestStreamCompletionEmitsTextUsageAndReasoningTokens.
* audit batch 12 (specialist): parse child stream without a line-length cap; drop dead helper
- ParseStream used a bufio.Scanner capped at 1 MiB, so one large stream-json line
from a child (a big tool result or final answer) hit bufio.ErrTooLong and aborted
the entire specialist run (audit medium streamer.go:43). Switch to a bufio.Reader
with ReadString, which has no per-line limit; the child is our own trusted
subprocess, so its output length is the legitimate bound. Line numbering and
blank-line/EOF handling are preserved. + TestParseStreamHandlesLineLargerThan1MiB.
- remove dead formatTaskOutput (output_tool.go:244): zero callers and zero tests;
it only forwarded to formatTaskOutputSummary, which is the one actually used.
* audit batch 13 (background/specialist): kill background tasks as a process group
Background children were launched in the parent's process group and terminated by
signalling only the single PID, so any process the task forked was orphaned and
outlived a cancel; the signal also risked hitting a recycled PID after the child
was reaped (audit medium process_posix.go:38/50).
- ConfigureChildProcessGroup (Setpgid on POSIX, no-op on Windows where taskkill /T
already tree-kills) is applied at launch so the child leads its own group.
- terminateProcess now SIGTERM/SIGKILLs the negative PID (the whole group), with a
pid>1 guard so a bogus 0/1 can never expand to "our own group" or "everything",
and treats ESRCH as already-gone. Liveness polls the group. Targeting a group
(rarely recycled vs. a bare PID) also shrinks the reaped-then-recycled window.
- specialist launchBackgroundProcess opts every child into its own group; on a
SetPID failure it now kills the child + group, marks the task errored, and records
the stop (deduped) instead of leaking an untracked orphan.
- tests launch as group leaders; new TestTerminateProcessKillsForkedChildren proves
a forked grandchild dies with the group.
* audit batch 14 (sandbox): quote-aware shell segment splitting
splitShellSegments used a quote-unaware strings.Replacer, so a shell operator
inside quotes was treated as a separator: `git commit -m "use top | less"` split
into a `less` segment and `echo "a; vim b"` into a `vim` segment, both falsely
flagged as interactive (audit medium safe_command.go:138).
Replace it with a quote-aware scanner that mirrors the shell: single quotes make
everything literal; double quotes keep $(...)/`...` substitution active but treat
|, ;, && as literal; unquoted text splits on the same operator set as before
(;, |, ||, &&) plus substitution boundaries. Real unquoted operators still split,
so an interactive program behind a separator — or inside a live `"$(...)"`
substitution — is still caught (no new false negatives, the security-relevant
direction). + TestDetectInteractiveQuoteAwareSeparators covering both directions.
* audit batch 15 (cron): collapse DST fall-back repeated hour to one fire
On a fall-back day the clock repeats an hour (e.g. 01:30 EDT then 01:30 EST), so
Next("30 1 * * *", <01:30 EDT>) returned the same-day 01:30 EST — firing a daily
job twice (audit medium schedule.go:171, which previously guarded only the
spring-forward gap).
Next now skips a match whose local wall-clock minute equals `after`'s but whose
absolute time is later (the repeated-hour duplicate) and keeps searching. Normal
forward search always advances the wall-clock, so this only triggers on the
fall-back repeat; when the scheduler passes the last fire time as `after`, the
repeated hour collapses to a single fire. + TestNextDSTFallBackDoesNotRepeatHour
(spring-forward termination test still passes).
Note: Next is stateless, so it dedupes relative to `after`; a caller that passes
an arbitrary mid-hour "now" instead of the last fire time is out of scope here.
* audit batch 16 (mcp): skip SSE notifications before the response; larger SSE buffer
- decodeSSERPCMessage returned the first non-empty `message` event unconditionally,
so a server-initiated notification/request arriving before the response made the
caller see an id mismatch and fail (audit medium network_client.go:621). It now
skips any message carrying a method (notifications/requests have one; a response
does not) and returns the first real response; the caller's rpcIDMatches still
validates the id. + TestDecodeSSERPCMessageSkipsNotifications.
- raise the per-event SSE scanner cap from 1 MiB to a bounded 8 MiB so a large but
legitimate MCP message no longer hits bufio.ErrTooLong and fails the request
(audit medium network_client.go:637); still bounded against an unbounded server.
Out of scope (kept): full reconnect of a persistent SSE stream after a fatal read
error — a larger change than this remediation.
* audit batch 17 (sessions): fsync session metadata before the atomic rename
writeMetadata wrote the temp file with os.WriteFile (no sync) and renamed it into
place, so a crash right after the rename could expose a metadata file whose bytes
were never flushed — a torn or empty file that corrupts the whole session (audit
medium store.go:632). Write+fsync the temp via a new writeFileSync helper before
renaming, closing that window. + TestWriteFileSyncRoundTrips.
Deliberately scoped:
- Event-log appends (appendEventLocked) are NOT fsynced per append: that would add
a disk flush to every event (every tool call), and a hard-crash loss of the log's
unsynced tail is acceptable, whereas torn METADATA is not. Metadata is the
integrity-critical part and is what this syncs.
- Directory fsync is omitted for cross-platform simplicity (it errors on Windows);
the file fsync already prevents the torn-contents failure, the serious case.
Left for follow-up (in this bucket but not safe/clear wins here):
- hooks append sequence still re-reads the file per append; the in-memory cache is
cross-process-unsafe and the safe last-line read is fiddly for a low-volume log.
- Fork event re-append/de-dup is nuanced usage-accounting and needs its own change.
* audit batch 18 (agent): count tool-defs in compaction estimate; don't deny an always-allow on persist failure
- maybeCompact's estimate omitted the tool definitions, which ride on every
request, so the real input could exceed the model limit while the message-only
estimate stayed under threshold and compaction never fired (audit medium
compaction.go:58). partitionTools is now computed before maybeCompact (it does
not depend on the messages) and the exposed tools' estimated tokens are added to
both the threshold check and the post-compaction shrink check. + estimateToolDefTokens
and TestEstimateToolDefTokensCountsDefinitions.
- PermissionDecisionAlwaysAllow denied the call when persistPermissionGrant failed,
overriding what the user explicitly allowed (audit medium loop.go:468). Persisting
is now best-effort: a failure (or no sandbox engine) just means the grant is not
remembered and the user is re-prompted next time, not denied now. This also drops
the dead requestEvent.GrantMatched/Grant writes (audit low loop.go:473) — the
emitted event derives them from the sandbox decision via buildPermissionEvent.
Left intentional (documented, not defects): the reactive-retry path omits OnText to
avoid duplicating mid-stream text (loop.go:183); OnContext/MeasureContext is a
complete, tested context-breakdown scaffold awaiting a consumer (types.go:183).
* audit batch 19 (usage): price escalated usage from the event's own model
exec persists payload["model"] on --allow-escalation runs (the model can change
mid-run), but BuildReport priced every event from the session's Metadata.ModelID
and never read the payload, so usage from an escalated model was mis-priced at the
base model (audit medium report.go:17). usageEventPayload now carries Model and
BuildReport prefers it, falling back to the session model when absent (older
events stay byte-identical and behave as before). + TestBuildReportPricesFromEventModelWhenPresent.
* audit batch 20 (sessions): don't copy usage events into a fork (no double-count)
Fork re-appended every parent event, including provider_usage, into the child, so
a usage report aggregating the parent and the fork double-counted the parent's
usage (audit medium store.go:387). Skip EventUsage when copying — it already
counted against the parent and is not part of the conversation history a fork
replays — and record the actual copied count in the fork marker. + TestStoreForkSkipsUsageEvents.
* audit batch 21 (cli/skills): warn on duplicate skill names instead of silently dropping
skills.Load deduplicates same-named skills (first directory wins) and records the
dropped collisions via skills.Duplicates, but nothing surfaced them, so a shadowed
skill just vanished with no signal — and Duplicates had no production caller (audit
low skills.go:91). `skills list` now reports each collision to stderr (keeping
stdout/--json clean), using Duplicates. + TestRunSkillsListWarnsOnDuplicateNames.
Note: skills.Get is kept — it is a by-name accessor used throughout the package's
tests, not dead code.
* audit batch 22 (cli/cron): capture the run error from stream-json stdout
Cron jobs run exec with --output-format stream-json, so a failure is reported as an
`error` event on STDOUT, but the run record's Error was set only from stderr — so a
failed job often recorded an empty/uninformative error while the real message sat
unread in outBuf (audit low cron_run.go:150). On a non-zero exit, scan the stdout
stream-json for the error event's message and prefer it, falling back to stderr.
+ extractStreamJSONError and TestExtractStreamJSONError.
* audit batch 23 (sandbox): richer macOS seatbelt profile (signal + mach-lookup)
The generated sandbox-exec profile was default-deny with only process/sysctl/file
allowances, so two real gaps remained on macOS (sandbox quality report H1):
- A sandboxed script could not signal the children it spawned — `kill` returned
"Operation not permitted" under seatbelt, breaking common `cmd & ... kill`,
test-runner, and timeout patterns. Add `(allow signal (target self) (target pgrp))`,
which stays scoped to the command's own process group.
- XPC to system daemons was denied, so keychain (securityd/trustd), user/group
lookup (opendirectoryd), preferences (cfprefsd), network config
(SystemConfiguration), launch services, and the pasteboard all failed. Add a
curated `(allow mach-lookup ...)` allowlist (`sandboxMachServices`).
Neither touches the file-write or network rules, so the workspace boundary is
unchanged — verified on a real macOS host: `security`/`scutil`/`pbpaste`/self-kill
now succeed while writes to $HOME and /etc are still denied. iokit-open was tried
and dropped as unnecessary. + unit assertions (TestSandboxExecProfileGrantsSignalAndMachLookup)
and extended the darwin integration test with a self-kill case and an
outside-workspace deny check.
* audit batch 24 (sandbox/tools): remove dead OnSandboxDecision hook
RunOptions.OnSandboxDecision had no setter anywhere, yet registry.RunWithOptions
spawned a goroutine (with panic recovery) for it on every sandboxed tool call —
pure per-call overhead for an unreachable callback (sandbox report M1). The sandbox
decision is already surfaced to observers via the permission event
(buildPermissionEvent reads the decision), so this hook is redundant; delete the
field and its call site.
* audit batch 25 (sandbox/cli): exact-path grants — create and revoke by path
The engine already matched ScopeFile grants and DeriveScope already produced them
from path/file tool args, but the CLI could neither create one explicitly nor
revoke a single one (sandbox report L3 / prompt 7). Add:
- GrantStore.RevokePath(tool, path): revokes only the grant whose scope matches the
given file/dir, leaving tool-wide and other-path grants intact (path canonicalized
to absolute like stored scopes). + TestGrantStoreRevokePathRemovesOnlyMatchingScope.
- `zero sandbox grants allow <tool> --path <p>`: persists an exact-file (ScopeFile)
grant, path resolved to absolute so it matches lookups and revokes.
- `zero sandbox grants revoke <tool> --path <p>`: revokes just that grant (default
still revokes all grants for the tool). + TestRunSandboxGrantsCreateAndRevokeByPath.
The TUI permission-card "Allow always (exact path)" option is intentionally left for
the TUI feature branches (it overlaps #187/#188); the engine + CLI now cover the
full create/match/revoke round-trip for file-scoped grants.
* audit batch 26 (sandbox): Engine.Precheck — report violations before execution
Add Engine.Precheck(ctx, request) []Violation, which reports the sandbox
violations that would block a tool request before it runs (sandbox report / prompt 6).
It reuses Evaluate so policy is not duplicated: an allowed or merely-prompted
request yields no violations; a denied request yields its violation (via the shared
violationsFromDecision helper, with a ViolationPolicyDenied fallback for a deny that
carries no structured violation). A nil engine yields nothing.
+ TestEnginePrecheckReportsViolationsBeforeExecution.
Wiring Precheck into the batch-confirmation prompt is intentionally left to the TUI
branch: it touches prompt rendering, and short-circuiting the agent loop's deny path
would skip the before/after-tool hooks that currently fire around a policy-denied
call. The engine API is in place for that consumer.
* audit batch 27 (sandbox): scoped-egress domain prompt with timeout + caching
The scoped-egress proxy failed closed on any host outside the allowlist. Add an
opt-in DomainPrompt callback (prompt 8): an UNKNOWN host (neither allowed nor
explicitly denied) is authorized at request time via the callback instead of being
refused outright. An explicitly denied host is still refused without prompting; the
callback is bounded by PromptTimeout (default 60s, timeout/error => deny); decisions
are cached for the proxy's lifetime so a host is prompted at most once. With no
callback set the strict fail-closed behavior is unchanged. Wired through both the
CONNECT and HTTP handlers via authorizeTarget.
+ TestEgressProxyAuthorizeDomainPrompt / ...TimeoutDenies / ...NilPromptFailsClosed.
The user-facing prompt that supplies the callback is left to the permission UI (TUI)
branch; the egress mechanism (callback + timeout + cache) is in place and tested.
* audit batch 28 (sandbox): AST-based shell analysis (AnalyzeCommand)
Add AnalyzeCommand(script) AnalysisResult, a static shell analyzer built on the
mvdan.cc/sh/v3 parser (prompt 12). Walking the parsed command tree makes it a more
precise second opinion than the regex detector: a program name only counts when it
is an actual command, so `echo "vim ..."`, `printf 'open with vim'`, and
`git commit -m "rm -rf /"` are clean, while real commands are flagged —
interactive (editors/pagers + REPLs, with the same -e/script suppression as
nonInteractiveREPLFlags), destructive (rm -rf/-fr, dd, mkfs, shred, ...), and
network (curl/wget/ssh/nc/...). An unparseable script yields TooComplex so callers
can treat it as higher-risk. + TestAnalyzeCommand (16 cases) / ...EmptyIsClean.
Dependency: mvdan.cc/sh/v3 pinned to v3.11.0 — compatible with the existing go 1.24
directive (the latest v3.13.1 would have bumped the module to go 1.25). It is kept
as a standalone analyzer for callers to consult; feeding it into Engine.Evaluate is
deliberately not done here, since that would change allow/deny classification.
* audit batch 29 (sandbox): unify proxy-env generation (ProxyEnv)
Export ProxyEnv as the single source of truth for proxy-env injection (prompt 9
req 1), replacing the unexported scopedProxyEnv used by the sandboxed-shell path.
+ TestProxyEnv.
Investigation that bounds the rest of prompt 9 (documented in the ProxyEnv doc):
- web_fetch already honors HTTP_PROXY/HTTPS_PROXY — its transport clones
http.DefaultTransport, whose Proxy is http.ProxyFromEnvironment.
- MCP children already inherit the parent env (mergeProcessEnv appends os.Environ),
so they pick up any proxy vars the agent has.
So the only missing link to scope FetchUrl/MCP is a SESSION-level egress proxy whose
address reaches the agent process. That is deliberately NOT wired here: doing it
safely requires allowlisting the active LLM provider's own domain first, or the
agent's provider calls would route through the scoped proxy and be denied — a
correctness pitfall that also can't be verified end-to-end on this host. Flagged
rather than shipped blind.
* audit batch 30 (sandbox): Linux seccomp Unix-socket block — UNVERIFIED, needs Linux CI
Adds a pure-Go (no cgo, no vendored binary) seccomp BPF filter that denies
socket(AF_UNIX) with EPERM on x86-64/arm64, closing the Unix-socket gap bubblewrap's
filesystem/network isolation leaves open (prompt 11):
- internal/sandbox/seccomp.go: the classic-BPF program as a platform-neutral
[]sockFilter, built and UNIT-TESTED (structure + every jump offset in range — the
classic way a hand-written BPF filter goes silently wrong).
- seccomp_linux.go: ApplyUnixSocketBlock installs it via prctl(NO_NEW_PRIVS) +
prctl(SET_SECCOMP, MODE_FILTER). seccomp_other.go is a no-op everywhere else.
- cmd/zero-seccomp: a tiny linux-only exec wrapper that applies the filter then
execs the real command (the standard way to install seccomp in a child with
os/exec); degrades gracefully (warns + runs) if the kernel lacks seccomp.
⚠️ HONEST CAVEAT: I developed this on macOS and CANNOT run the actual AF_UNIX block
here. darwin build, `GOOS=linux` cross-compile, vet, and the structure test all pass,
but the unit test verifies the program's SHAPE, not that the kernel actually blocks
AF_UNIX — that MUST be verified on Linux CI (create a Unix socket under the wrapper
and assert EPERM). Safe to ship as-is: it is linux-only, no-op on every other
platform, and not wired into the bwrap command yet (opt-in prefixing the sandboxed
command with zero-seccomp is the remaining integration), so it cannot regress any
current path.
* audit batch 31 (sandbox): macOS denial log monitor (opt-in, default off)
Re-adds the macOS seatbelt-denial monitor as an opt-in feature (prompt 10). When
Policy.MonitorDenials is set, the sandbox-exec profile tags its default-deny with a
marker and bash tails `log stream` for that tag during the command, appending any
captured denials as a <sandbox_violations> block to the command's stderr so the
model can see what was blocked.
- DenialMonitor (darwin) tails/parses tagged denials with a capped, deduped ring
buffer and noise filtering; no-op stub on other platforms. parseSandboxDenyLine is
unit-tested. + profile-tag test + appendSandboxViolations test.
- Default OFF: with MonitorTag empty, StartDenialMonitor is a no-op and the command
path is byte-for-byte unchanged, so there is no regression or per-command overhead
unless enabled.
CAVEAT: on at least one macOS host (verified live) seatbelt denials are NOT delivered
to the `log stream`-queryable unified log, so the monitor captures nothing there. It
is the standard mechanism and works on macOS versions that do log denials; shipping
it default-off means it never affects whether a command runs.
* audit batch 32 (sandbox): wire seccomp Unix-socket block into bubblewrap (opt-in)
Completes prompt 11: the zero-seccomp helper (which installs the AF_UNIX seccomp
filter) is now actually invoked by the sandbox instead of only existing as a
standalone binary.
- bubblewrapCommandPlan, when Policy.BlockUnixSockets is set, ro-binds the
discovered zero-seccomp helper into the sandbox at its host path and prefixes the
command with it so the filter is installed before the real argv runs.
- findSeccompHelper discovers the helper next to the running executable first, then
on PATH; it returns "" when absent, so the sandbox degrades gracefully (runs
without the extra filter — bubblewrap's fs/net isolation still applies) rather
than failing the command. Exposed as a package var so the wiring is unit-tested
on any OS.
- Reachable from config: SandboxConfig.BlockUnixSockets and .MonitorDenials now map
onto the policy in applyConfiguredSandboxPolicy (opt-in only — config can turn a
hardening flag on, never off). Both default OFF.
- Tests: helper-prefix present when enabled / absent by default; config flags apply
independently and stay off when omitted.
Default OFF and Linux-only effect; the runtime seccomp enforcement still needs a
Linux host/CI to exercise end-to-end (the wiring and BPF program are unit-tested).
* audit batch 33 (sandbox): enforce scoped/deny network policy in web_fetch & web_search
Completes prompt 9: the built-in network tools now honor the SAME allow/deny/scoped
policy the sandboxed shell egress proxy enforces, instead of bypassing it. Before
this, web_fetch/web_search would reach any public host even when the policy was
NetworkDeny or NetworkScoped — the egress proxy only constrained shell commands.
- New shared gate Engine.NetworkHostAllowed(host) reuses the egress domain matcher
(domainAllowed/normalizeDomains/effectiveNetwork) as the single source of truth:
allow → all hosts; deny → none; scoped → only AllowedDomains minus DeniedDomains
(empty allowlist collapses to deny). Disabled policy / nil engine impose nothing.
- web_fetch is now sandbox-aware: the host is checked before the request AND on every
redirect, so a denied/unlisted host fails closed before any dial. Plumbed via a
networkPolicyGate so the no-sandbox path is byte-for-byte unchanged.
- web_search is sandbox-aware: the configured backend's endpoint host is checked
before the query leaves the machine; blocked under deny, and under scoped unless the
backend host is allowlisted.
- The registry already routes sandbox-aware tools through RunWithSandbox when an
engine is present, so this activates automatically wherever the sandbox is wired.
- Tests: engine gate matrix (deny/allow/scoped/denylist/empty/disabled/nil + host:port);
web_fetch & web_search block denied/unlisted hosts without dialing and allow listed
ones; endpoint-host parsing.
* audit batch 34 (sandbox): prove network-policy wiring end-to-end + document opt-in flags
Verification follow-up to batches 31-33; no behaviour change.
- Adds a registry-level test that exercises the FULL chain for the scoped-network
enforcement: registry.RunWithOptions -> engine.Evaluate (passes a scoped,
egress-capable sandbox-exec backend) -> routes web_fetch through RunWithSandbox ->
per-host allowlist blocks an unlisted host before any dial. This proves the
Tool-interface value actually satisfies sandboxAwareTool at the registry boundary,
not just in a direct method call.
- Documents the two opt-in hardening flags (sandbox.blockUnixSockets,
sandbox.monitorDenials) and the scoped web_fetch/web_search behaviour in the README
so the config keys are discoverable. Both remain off by default.
Confirmed wiring: agent loop forwards options.Sandbox to the registry; the registry
routes sandbox-aware tools (web_fetch, web_search) through RunWithSandbox when an
engine is present; MonitorTag is set only on the macOS sandbox-exec plan; the
seccomp helper prefix is added only on the bubblewrap plan when BlockUnixSockets is
set; both config flags map onto the policy via applyConfiguredSandboxPolicy.
* audit batch 35 (sessions): fix Windows CI failure in TestWriteFileSyncRoundTrips
The exact-permission assertion (perm == 0o600) added in batch 17 fails on
windows-latest because Windows does not honor Unix permission bits: Go reports
0o666 for a writable file regardless of the mode passed to OpenFile. This was the
sole cause of the failing Smoke (windows-latest) CI job; macOS and Ubuntu passed.
Guard the perm check with runtime.GOOS != "windows" so it still verifies the mode
on the platforms that enforce it, while the round-trip (write + read-back) is still
asserted on every platform.
* audit batch 36: address CodeRabbit review (5 findings)
- background/process_posix: after SIGKILL, poll until the process group is
actually gone (SIGKILL is async) and return an error if it survives past a
second deadline, instead of returning nil while descendants still race.
- sandbox/safe_command: only treat ')' as a substitution boundary when it closes
an active $(...). A literal ')' (e.g. echo "a) less") no longer splits into a
fake `less` segment and is no longer misflagged as interactive. Depth-tracked so
nested $(...) still isolate their inner command. Direct splitShellSegments test
(fails pre-fix: yields ["echo \"a", "less\""]).
- cron/schedule: the DST fall-back collapse now only applies when `after` sits
exactly on the minute boundary (its "last fire time" form). With sub-minute
precision (e.g. 01:30:30 EDT) the first 01:30 fire already passed, so the repeated
01:30 EST is the legitimate next fire and is returned — restoring strictly-after
semantics. New test fails pre-fix (skipped to next day).
- config/types: maxTurns error message now reads "must be >= 0" to match the check
(0 is accepted; only < 0 is rejected). Negative-maxTurns test asserts the message.
- docs/audit: header date/branch metadata now matches the body (2026-06-13,
fix/audit-batch) instead of the stale 2026-06-11/fix/audit-critical-high snapshot.
Verified: gofmt clean; go vet clean; host + linux + windows builds; affected suites
green. Each behavioural fix ships with a test confirmed to fail without the change.
* audit batch 37: address CodeRabbit re-review (12 findings)
Findings on the audit code from batches 21-36; each behavioural fix ships with a
regression test (several confirmed to fail without the change).
sandbox/analyzer (AnalyzeCommand):
- Unwrap launcher prefixes (sudo/env/nice/bash -c/...) and recurse into `sh -c`
payloads so `sudo rm -rf`, `env curl …`, `bash -c 'vim x'` classify on the real
command, not the launcher. Mirrors DetectInteractiveCommand.
- Honor rm's `--` end-of-options marker so `rm -- -rf` (delete a file named -rf) is
not flagged destructive.
sandbox/engine: NetworkHostAllowed now shares one backend-aware effective-mode
helper with Evaluate, so scoped collapses to deny when the backend can't enforce
scoped egress — the per-tool gate can no longer permit traffic the engine-level
decision would fail closed.
sandbox/egress: cache domain-prompt decisions by host:port, not host, so one
"allow" can't authorize other ports on the same host.
sandbox/safe_command: the segment splitter is now escape-aware (\| , \" ) so an
escaped operator/quote can't manufacture a fake interactive segment.
sandbox/log_monitor (darwin): Wait() the `log stream` child after draining stdout
so the cancelled process isn't leaked as a zombie.
tools/web_search: confine the search backend to same-host redirects (with a limit)
so a redirect chain can't egress to a host the scoped/deny policy never authorized.
cli/sandbox: an explicit empty --path (`--path=` / `--path ""`) now fails closed
instead of silently widening an allow to tool-wide or a revoke to all-grants.
cli/skills: warn on stderr when duplicate-skill detection fails instead of
swallowing the error.
specialist/exec: don't mask a failed orphan-kill — join the TerminateProcess error
with the SetPID error so a surviving process is visible.
background/process_posix: resolve the PGID and only group-signal when pid leads its
own group; otherwise signal the individual PID. Avoids both a false-success on a
non-leader ESRCH and the hazard of signalling an unrelated (possibly our own) group.
Verified: gofmt + go vet clean; host + linux + windows builds; full go test ./...
green.
* audit batch 38: address CodeRabbit re-review round 2 (3 findings)
Follow-ups to batch 37, each with a regression test:
- safe_command (splitter): a ')' is now only a substitution boundary when it
closes an active, UNQUOTED $(...). A quote-state stack saved at each '$(' lets a
substitution run in a fresh quoting context and restores the outer quoting on
its matching ')'. Fixes `echo $(printf "a) less")` splitting into a fake `less`
segment, while still isolating the inner command of `echo "$(vim x)"`.
- egress: deduplicate in-flight domain prompts per host:port. Concurrent requests
for the same unknown target now wait on the first prompt and reuse its cached
decision instead of each firing a prompt (a prompt storm) and racing to write
the final decision. Race-tested with 25 concurrent authorizers → one prompt.
- analyzer + safe_command: wrapper option-value consumption is now wrapper-specific
(wrapperValueOptionsByProg) instead of a global option set. A valueless flag like
`sudo -n` no longer swallows the real payload, so `sudo -n rm -rf` /
`sudo -n curl …` are correctly classified destructive/network; `sudo -u root vim`
still consumes its value. Shared by the AST analyzer and the regex detector so
they can't diverge.
Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and
-race on sandbox/background all green.
* audit batch 39: address CodeRabbit re-review round 3 (12 findings)
All verified against current code; each behavioural fix has a regression test.
Security / correctness:
- tools/web_search: redirect policy now also refuses a scheme change, blocking a
same-host https→http downgrade that would send the query + bearer token over
plaintext.
- sandbox/safe_command: a ')' is only a substitution boundary when it closes an
active UNQUOTED $(...), and backticks now get the same quote-state save/restore
as $( — so `echo "`a | less`"` and `echo "$(true | less)"` no longer hide the
inner pager from interactive detection.
- sandbox/analyzer + safe_command: wrapper option-value consumption stays
wrapper-specific, and unwrapping no longer aborts on a dynamic ($x) wrapper arg
once a wrapper is active, so `env "$opts" curl` / `sudo "$x" rm -rf` are still
classified.
- sandbox/egress: in-flight prompts deduped per host:port (carried from batch 38);
the prompt callback is now cancellable (context cancelled on timeout) so a stuck
prompt can abort instead of leaking; signature threaded through.
- sandbox/runner: per-plan unique denial tag (pid+counter) so concurrent monitored
sandbox-exec runs can't ingest each other's denials; helper selection now
requires an executable regular file so a non-exec sibling degrades gracefully.
- mcp/network_client: aggregate SSE event-size cap (not just per-line) so a server
can't grow memory with many unterminated data: lines.
- specialist/accounting: a previously-recorded stop/usage with an empty runId is a
catch-all and now suppresses a later runId-bearing duplicate.
- sessions/lineage: Tree fetches the root via Get() first so a corrupt root
surfaces its real error instead of degrading to "not found".
Docs/tests:
- background/terminate: docstring now states the leader-only group-kill (PID-only
fallback for non-leaders); test child-liveness probe uses ESRCH specifically.
- cli/sandbox: grant usage strings include --path.
Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and
-race on sandbox/background all green.
* audit batch 40: address CodeRabbit re-review round 4 (8 fixed, 1 skipped)
Verified each against current code.
Fixed:
- sessions/lineage: PANIC FIX — store.Get returns (nil, nil) for a missing
session, so the batch-39 root prefetch could nil-deref. Guard root == nil and
return a not-found error. Regression test added (was a panic).
- agent/compaction: the reactive recover() now records lowWaterMark in the SAME
combined (messages + tool-defs) token domain maybeCompact uses, so the proactive
shrink-guard compares like with like. recover() takes tools; callers pass
request.Tools.
- mcp/network_client: SSE aggregate counter no longer counts a joining newline for
the first data line (only when prior content exists), avoiding premature rejects.
- sessions/store: fsync the parent directory after the metadata rename so the
rename is durable across a crash (no-op on Windows, which can't fsync a dir).
- sandbox/egress: parse the target port with strconv.Atoi instead of
net.LookupPort (no context-unaware resolver use).
- sandbox/runner: escape the per-plan denial tag through sandboxProfileString when
embedding it in the seatbelt profile.
- sandbox/seccomp_linux: guard against an empty filter program before taking
&kernelFilters[0] (defensive; the program is fixed non-empty today).
- cli/cron_run_test: assert last-error-wins with two error events.
- background/terminate, cli/sandbox usage, process_posix_test ESRCH (carried).
Skipped (with reason):
- zerogit worktree-rename (Y='R') test: git porcelain v1 -z only emits the
rename-source field when the INDEX column is R/C; the worktree column never
carries 'R' with a source field in v1, so the fixture would test output git
doesn't produce, and making parseStatus consume on code[1]=='R' would risk
mis-consuming the following entry for a plain ` R` status. Left as-is.
Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and
-race on sandbox/background all green.
* audit batch 41: address CodeRabbit re-review round 5 (4 findings)
All verified against current code; code fixes have regression tests.
- agent/compaction: summarizeWithFallback's reduce pass no longer swallows ALL
errors. A context-limit error still falls back to the joined partial summaries
(documented "extreme" case), but a non-context failure (auth/network/provider)
now surfaces unchanged, per the function's contract. Two tests (propagate vs.
joined-fallback); the propagate test fails without the fix.
- sandbox/safe_command + analyzer: wrapper value-consuming options now include the
long spellings (sudo --user root, env --unset NAME, timeout --signal …), so a
long flag's value is no longer mis-read as the program (interactive/destructive/
network detection bypass). A `--flag=value` token carries its own value and never
consumes the next token. Tests for space and = forms in both the AST analyzer and
the regex detector.
- docs/audit: the status-ledger summary table's open/partial cells (64/14) were the
lone outlier — the ledger's own narrative and 2026-06-13-reverification.md both
state 66/12. Corrected to 66/12 (66+12+97 = 175); derivation: body 69 open + 18
partial minus the 9 already-fixed-on-main (3 open + 6 partial) = 66/12 = 78
pending, fixed 88+9 = 97. Added a comment documenting the summary-vs-body
relationship. The reverification doc already states 66/12, so both now agree.
Verified: gofmt + go vet clean; host + linux + windows builds; full go test ./...
green.
* audit batch 42: address CodeRabbit re-review round 6 (1 fixed, 1 kept w/ proof)
CodeRabbit re-surfaced two "duplicate" refinements; CI was already green (its 6th
pass was a COMMENT with no new findings).
- cli/sandbox_test (TestRunSandboxGrantsRejectsEmptyPath): now seeds a tool-wide
grant and asserts grant-store immutability after EACH rejected empty-`--path`
call (reflect.DeepEqual before/after), so a mutation in one call can't be masked
by a later one — and a buggy "revoke all" is actually observable (it's a no-op on
an empty store). Stronger than the suggested patch.
- zerogit.parseStatus: KEPT consuming the rename source on the index column only
(code[0]), with an explanatory comment. Verified empirically that git porcelain
v1 -z reports a rename/copy (and emits the extra source field) only in the index
column: `git mv` → "R new\0old\0"; a worktree-only rename → " D old\0?? new\0"
(delete + untracked, never "R" in code[1]). Consuming on code[1]=='R'/'C' would
match no real git output and would only risk mis-consuming the next entry on
malformed input, so the suggestion is declined as incorrect for v1.
Verified: gofmt + go vet clean; host + linux + windows builds; affected suites green.
694 lines
27 KiB
Go
694 lines
27 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
)
|
|
|
|
func TestRunSandboxGrantsAllowListDenyRevokeAndClear(t *testing.T) {
|
|
store := newSandboxTestStore(t)
|
|
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"sandbox", "grants", "allow", "write_file", "--auto", "medium", "--reason", "workspace edits", "--json"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("allow exit = %d, stderr %q", exitCode, stderr.String())
|
|
}
|
|
var allowPayload struct {
|
|
Grant sandbox.Grant `json:"grant"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &allowPayload); err != nil {
|
|
t.Fatalf("decode allow JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if allowPayload.Grant.ToolName != "write_file" || allowPayload.Grant.Decision != sandbox.GrantAllow || allowPayload.Grant.MaxAutonomy != sandbox.AutonomyMedium {
|
|
t.Fatalf("unexpected allow payload: %#v", allowPayload)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"sandbox", "grants", "deny", "bash", "--auto=high", "--reason=network blocked"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("deny exit = %d, stderr %q", exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "bash") || !strings.Contains(stdout.String(), "deny") {
|
|
t.Fatalf("unexpected deny text: %q", stdout.String())
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"sandbox", "grants", "list", "--json"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("list exit = %d, stderr %q", exitCode, stderr.String())
|
|
}
|
|
var listPayload struct {
|
|
Grants []sandbox.Grant `json:"grants"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &listPayload); err != nil {
|
|
t.Fatalf("decode list JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if len(listPayload.Grants) != 2 || listPayload.Grants[0].ToolName != "bash" || listPayload.Grants[1].ToolName != "write_file" {
|
|
t.Fatalf("unexpected sorted grants: %#v", listPayload.Grants)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"sandbox", "grants", "revoke", "bash", "--json"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("revoke exit = %d, stderr %q", exitCode, stderr.String())
|
|
}
|
|
var revokePayload struct {
|
|
Revoked int `json:"revoked"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &revokePayload); err != nil {
|
|
t.Fatalf("decode revoke JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if revokePayload.Revoked != 1 {
|
|
t.Fatalf("revoked = %d, want 1", revokePayload.Revoked)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"sandbox", "grants", "clear", "--json"}, &stdout, &stderr, deps)
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("clear without confirm exit = %d, want usage", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "--confirm") {
|
|
t.Fatalf("expected confirm error, got %q", stderr.String())
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"sandbox", "grants", "clear", "--confirm", "--json"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("clear exit = %d, stderr %q", exitCode, stderr.String())
|
|
}
|
|
var clearPayload struct {
|
|
Cleared int `json:"cleared"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &clearPayload); err != nil {
|
|
t.Fatalf("decode clear JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if clearPayload.Cleared != 1 {
|
|
t.Fatalf("cleared = %d, want 1", clearPayload.Cleared)
|
|
}
|
|
}
|
|
|
|
func TestRunSandboxGrantsCreateAndRevokeByPath(t *testing.T) {
|
|
store := newSandboxTestStore(t)
|
|
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
|
|
path := filepath.Join(t.TempDir(), "secret.txt")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
run := func(args ...string) int {
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
return runWithDeps(args, &stdout, &stderr, deps)
|
|
}
|
|
|
|
// An exact-path grant and a tool-wide grant for the same tool.
|
|
if exit := run("sandbox", "grants", "allow", "write_file", "--path", path); exit != exitSuccess {
|
|
t.Fatalf("allow --path exit = %d, stderr %q", exit, stderr.String())
|
|
}
|
|
if exit := run("sandbox", "grants", "allow", "write_file"); exit != exitSuccess {
|
|
t.Fatalf("allow tool-wide exit = %d, stderr %q", exit, stderr.String())
|
|
}
|
|
|
|
// Revoking by path removes only the path-scoped grant.
|
|
if exit := run("sandbox", "grants", "revoke", "write_file", "--path", path, "--json"); exit != exitSuccess {
|
|
t.Fatalf("revoke --path exit = %d, stderr %q", exit, stderr.String())
|
|
}
|
|
var payload struct {
|
|
Revoked int `json:"revoked"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode revoke JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if payload.Revoked != 1 {
|
|
t.Fatalf("revoked = %d, want 1", payload.Revoked)
|
|
}
|
|
|
|
grants, err := store.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(grants) != 1 || grants[0].ScopeKind != sandbox.ScopeToolWide {
|
|
t.Fatalf("expected only the tool-wide grant to remain, got %#v", grants)
|
|
}
|
|
}
|
|
|
|
func TestRunSandboxGrantsRejectsEmptyPath(t *testing.T) {
|
|
store := newSandboxTestStore(t)
|
|
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
run := func(args ...string) int {
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
return runWithDeps(args, &stdout, &stderr, deps)
|
|
}
|
|
|
|
// Seed a tool-wide grant first so a buggy "revoke all for tool" or "allow
|
|
// tool-wide" from a rejected call would actually change the store and be caught
|
|
// (a revoke-all on an empty store is a silent no-op).
|
|
if _, err := store.Grant(sandbox.GrantInput{ToolName: "write_file", Decision: sandbox.GrantAllow, MaxAutonomy: sandbox.AutonomyHigh}); err != nil {
|
|
t.Fatalf("seed grant: %v", err)
|
|
}
|
|
|
|
// An explicit but empty --path must fail closed rather than silently widening
|
|
// an allow to tool-wide or a revoke to all-grants-for-tool. Check immutability
|
|
// after EACH rejected call so a mutation in one isn't masked by a later call.
|
|
for _, args := range [][]string{
|
|
{"sandbox", "grants", "allow", "write_file", "--path", ""},
|
|
{"sandbox", "grants", "allow", "write_file", "--path="},
|
|
{"sandbox", "grants", "revoke", "write_file", "--path", ""},
|
|
{"sandbox", "grants", "revoke", "write_file", "--path="},
|
|
} {
|
|
before, err := store.List()
|
|
if err != nil {
|
|
t.Fatalf("%v: List before: %v", args, err)
|
|
}
|
|
if exit := run(args...); exit == exitSuccess {
|
|
t.Fatalf("%v: expected a usage error for an empty --path, got success", args)
|
|
}
|
|
// Either rejection path is acceptable (the `--path=` form hits the
|
|
// non-empty check; the `--path ""` form is rejected as a missing value) —
|
|
// what matters is that an empty --path never silently widens scope.
|
|
if !strings.Contains(stderr.String(), "path") {
|
|
t.Fatalf("%v: stderr should explain the empty --path, got %q", args, stderr.String())
|
|
}
|
|
after, err := store.List()
|
|
if err != nil {
|
|
t.Fatalf("%v: List after: %v", args, err)
|
|
}
|
|
if !reflect.DeepEqual(after, before) {
|
|
t.Fatalf("%v: rejected call mutated grants; before=%#v after=%#v", args, before, after)
|
|
}
|
|
}
|
|
|
|
// Only the seeded grant remains — nothing added or removed by the rejected calls.
|
|
grants, err := store.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(grants) != 1 || grants[0].ScopeKind != sandbox.ScopeToolWide {
|
|
t.Fatalf("expected only the seeded tool-wide grant to remain, got %#v", grants)
|
|
}
|
|
}
|
|
|
|
func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
|
store := newSandboxTestStore(t)
|
|
deps := appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), 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 native sandbox adapter is not implemented",
|
|
}
|
|
},
|
|
}
|
|
|
|
for _, args := range [][]string{
|
|
{"sandbox", "policy"},
|
|
{"sandbox", "policy", "--json"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps(args, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("policy exit = %d, stderr %q", exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
if strings.Contains(strings.Join(args, " "), "--json") {
|
|
var payload struct {
|
|
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"`
|
|
} `json:"plan"`
|
|
Grants string `json:"grantsPath"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode policy JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if payload.Policy.Mode != sandbox.ModeEnforce || payload.Backend.Name != sandbox.BackendPolicyOnly || payload.Grants == "" {
|
|
t.Fatalf("unexpected policy JSON: %#v", payload)
|
|
}
|
|
if payload.Backend.Platform != "windows" || !payload.Backend.Fallback || payload.Backend.NativeIsolation || payload.Backend.CommandWrapping {
|
|
t.Fatalf("unexpected backend capability JSON: %#v", payload.Backend)
|
|
}
|
|
if payload.Plan.SupportLevel != string(sandbox.BackendSupportPolicyOnly) {
|
|
t.Fatalf("support level = %q, want policy-only", payload.Plan.SupportLevel)
|
|
}
|
|
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") {
|
|
t.Fatalf("expected JSON warnings to document Windows fallback, got %#v", payload.Plan.Warnings)
|
|
}
|
|
} else {
|
|
output := stdout.String()
|
|
for _, want := range []string{
|
|
"Zero sandbox policy",
|
|
"backend: policy-only",
|
|
"support_level: policy-only",
|
|
"backend_fallback: true",
|
|
"backend_command_wrapping: false",
|
|
"backend_native_isolation: false",
|
|
"backend_platform: windows",
|
|
"Windows native sandbox adapter is not implemented",
|
|
} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("expected policy text to contain %q, got %q", want, output)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
|
|
store := newSandboxTestStore(t)
|
|
deps := appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), 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: "darwin",
|
|
Fallback: true,
|
|
Message: "policy-only fallback",
|
|
}
|
|
},
|
|
}
|
|
|
|
t.Run("text", func(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"sandbox", "policy", "--effective"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("effective exit = %d, stderr %q", exitCode, stderr.String())
|
|
}
|
|
output := stdout.String()
|
|
for _, want := range []string{
|
|
"Zero effective sandbox policy",
|
|
"mode: enforce",
|
|
"network: deny",
|
|
"enforce_workspace: true",
|
|
"deny_destructive_shell: true",
|
|
"interactive_command_guard: enabled",
|
|
"support_level: policy-only",
|
|
} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("effective text missing %q, got %q", want, output)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("json", func(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"sandbox", "policy", "--effective", "--json"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("effective json exit = %d, stderr %q", exitCode, stderr.String())
|
|
}
|
|
var payload struct {
|
|
Policy struct {
|
|
Mode string `json:"mode"`
|
|
Network string `json:"network"`
|
|
EnforceWorkspace bool `json:"enforceWorkspace"`
|
|
DenyDestructiveShell bool `json:"denyDestructiveShell"`
|
|
} `json:"policy"`
|
|
Backend struct {
|
|
Name string `json:"name"`
|
|
} `json:"backend"`
|
|
Plan struct {
|
|
SupportLevel string `json:"supportLevel"`
|
|
} `json:"plan"`
|
|
Guards struct {
|
|
InteractiveCommand bool `json:"interactiveCommand"`
|
|
DestructiveShell bool `json:"destructiveShell"`
|
|
Network bool `json:"network"`
|
|
Workspace bool `json:"workspace"`
|
|
} `json:"guards"`
|
|
GrantsPath string `json:"grantsPath"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if payload.Policy.Mode != "enforce" || payload.Policy.Network != "deny" {
|
|
t.Fatalf("unexpected effective policy: %#v", payload.Policy)
|
|
}
|
|
if !payload.Policy.EnforceWorkspace || !payload.Policy.DenyDestructiveShell {
|
|
t.Fatalf("expected workspace + destructive guards enabled: %#v", payload.Policy)
|
|
}
|
|
if !payload.Guards.InteractiveCommand || !payload.Guards.DestructiveShell {
|
|
t.Fatalf("expected guards reported: %#v", payload.Guards)
|
|
}
|
|
if payload.Plan.SupportLevel != string(sandbox.BackendSupportPolicyOnly) || payload.GrantsPath == "" {
|
|
t.Fatalf("unexpected effective plan/grants: %#v %q", payload.Plan, payload.GrantsPath)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestEffectiveSandboxPolicyListsWriteRoots(t *testing.T) {
|
|
output := formatEffectiveSandboxPolicy("/ws", sandbox.DefaultPolicy(), sandbox.Backend{}, sandbox.BackendPlan{}, resolveSandboxGuards(sandbox.DefaultPolicy()), "/grants", []string{"/ws", "/extra"}, nil)
|
|
if !strings.Contains(output, "write_roots: /ws, /extra") {
|
|
t.Fatalf("expected write_roots line, got:\n%s", output)
|
|
}
|
|
if !strings.Contains(output, "enforce_workspace: true\nwrite_roots: /ws, /extra") {
|
|
t.Fatalf("write_roots should directly follow enforce_workspace, got:\n%s", output)
|
|
}
|
|
if strings.Contains(output, "write_roots_error") {
|
|
t.Fatalf("unexpected write_roots_error line without an error:\n%s", output)
|
|
}
|
|
}
|
|
|
|
func TestEffectiveSandboxPolicyShowsWriteRootsError(t *testing.T) {
|
|
scopeErr := errors.New(`write root "/gone": write root must exist`)
|
|
output := formatEffectiveSandboxPolicy("/ws", sandbox.DefaultPolicy(), sandbox.Backend{}, sandbox.BackendPlan{}, resolveSandboxGuards(sandbox.DefaultPolicy()), "/grants", []string{"/ws"}, scopeErr)
|
|
if !strings.Contains(output, "write_roots: /ws") {
|
|
t.Fatalf("expected fallback write_roots line, got:\n%s", output)
|
|
}
|
|
if !strings.Contains(output, `write_roots_error: write root "/gone": write root must exist`) {
|
|
t.Fatalf("expected write_roots_error line, got:\n%s", output)
|
|
}
|
|
}
|
|
|
|
func TestRunSandboxPolicyEffectiveListsConfiguredWriteRoots(t *testing.T) {
|
|
store := newSandboxTestStore(t)
|
|
extra := t.TempDir()
|
|
resolvedExtra, err := filepath.EvalSymlinks(extra)
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks(%q) returned error: %v", extra, err)
|
|
}
|
|
deps := appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), nil },
|
|
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{Sandbox: config.SandboxConfig{AdditionalWriteRoots: []string{extra}}}, nil
|
|
},
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"sandbox", "policy", "--effective"}, &stdout, &stderr, deps); code != exitSuccess {
|
|
t.Fatalf("effective exit = %d, stderr %q", code, stderr.String())
|
|
}
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "write_roots: ") {
|
|
t.Fatalf("effective text missing write_roots line:\n%s", output)
|
|
}
|
|
if !strings.Contains(output, resolvedExtra) {
|
|
t.Fatalf("write_roots should include the configured extra root %q:\n%s", resolvedExtra, output)
|
|
}
|
|
if strings.Contains(output, "write_roots_error") {
|
|
t.Fatalf("unexpected write_roots_error for valid roots:\n%s", output)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"sandbox", "policy", "--effective", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
|
|
t.Fatalf("effective json exit = %d, stderr %q", code, stderr.String())
|
|
}
|
|
var payload struct {
|
|
WriteRoots []string `json:"writeRoots"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if len(payload.WriteRoots) != 2 {
|
|
t.Fatalf("writeRoots = %#v, want workspace root + extra root", payload.WriteRoots)
|
|
}
|
|
if payload.WriteRoots[1] != resolvedExtra {
|
|
t.Fatalf("writeRoots[1] = %q, want %q", payload.WriteRoots[1], resolvedExtra)
|
|
}
|
|
if strings.Contains(stdout.String(), "writeRootsError") {
|
|
t.Fatalf("unexpected writeRootsError key for valid roots:\n%s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunSandboxPolicyEffectiveWriteRootsFailSoft(t *testing.T) {
|
|
store := newSandboxTestStore(t)
|
|
missing := filepath.Join(t.TempDir(), "does-not-exist")
|
|
deps := appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), nil },
|
|
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{Sandbox: config.SandboxConfig{AdditionalWriteRoots: []string{missing}}}, nil
|
|
},
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"sandbox", "policy", "--effective"}, &stdout, &stderr, deps); code != exitSuccess {
|
|
t.Fatalf("effective exit = %d, want success (stale config must fail soft), stderr %q", code, stderr.String())
|
|
}
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "write_roots_error: ") {
|
|
t.Fatalf("expected visible write_roots_error line for stale config entry:\n%s", output)
|
|
}
|
|
if !strings.Contains(output, "write_roots: ") {
|
|
t.Fatalf("expected workspace-only write_roots fallback line:\n%s", output)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"sandbox", "policy", "--effective", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
|
|
t.Fatalf("effective json exit = %d, want success (stale config must fail soft), stderr %q", code, stderr.String())
|
|
}
|
|
var payload struct {
|
|
WriteRoots []string `json:"writeRoots"`
|
|
WriteRootsError string `json:"writeRootsError"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if payload.WriteRootsError == "" {
|
|
t.Fatalf("expected writeRootsError in JSON for stale config entry:\n%s", stdout.String())
|
|
}
|
|
if !strings.Contains(payload.WriteRootsError, missing) {
|
|
t.Fatalf("writeRootsError = %q, want it to name the stale root %q", payload.WriteRootsError, missing)
|
|
}
|
|
if len(payload.WriteRoots) != 1 {
|
|
t.Fatalf("writeRoots = %#v, want workspace-only fallback", payload.WriteRoots)
|
|
}
|
|
}
|
|
|
|
func TestRunSandboxPolicyEffectiveHelpListed(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"sandbox", "policy", "--help"}, &stdout, &stderr, appDeps{})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("help exit = %d, stderr %q", exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "--effective") {
|
|
t.Fatalf("policy help should document --effective, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunSandboxHelpDoesNotOpenStore(t *testing.T) {
|
|
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) {
|
|
t.Fatal("newSandboxStore should not be called for help")
|
|
return nil, nil
|
|
}}
|
|
for _, args := range [][]string{
|
|
{"sandbox", "--help"},
|
|
{"sandbox", "grants", "--help"},
|
|
{"sandbox", "grants", "allow", "--help"},
|
|
{"sandbox", "policy", "--help"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps(args, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("help exit = %d, stderr %q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() == 0 {
|
|
t.Fatalf("expected help output")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunSandboxPolicyAppliesConfiguredCeiling(t *testing.T) {
|
|
store := newSandboxTestStore(t)
|
|
deps := appDeps{
|
|
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{Sandbox: config.SandboxConfig{MaxAutonomy: "medium"}}, nil
|
|
},
|
|
}
|
|
|
|
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())
|
|
}
|
|
var payload struct {
|
|
Policy sandbox.Policy `json:"policy"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode policy JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if payload.Policy.MaxAutonomy != sandbox.AutonomyMedium {
|
|
t.Fatalf("policy.MaxAutonomy = %q, want medium", payload.Policy.MaxAutonomy)
|
|
}
|
|
}
|
|
|
|
func TestRunSandboxPolicyTextShowsMaxAutonomy(t *testing.T) {
|
|
store := newSandboxTestStore(t)
|
|
deps := appDeps{
|
|
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{Sandbox: config.SandboxConfig{MaxAutonomy: "medium"}}, nil
|
|
},
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"sandbox", "policy"}, &stdout, &stderr, deps); code != exitSuccess {
|
|
t.Fatalf("policy exit = %d, stderr %q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "max_autonomy: medium") {
|
|
t.Fatalf("policy text missing max_autonomy line:\n%s", stdout.String())
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"sandbox", "policy", "--effective"}, &stdout, &stderr, deps); code != exitSuccess {
|
|
t.Fatalf("effective policy exit = %d, stderr %q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "max_autonomy: medium") {
|
|
t.Fatalf("effective policy text missing max_autonomy line:\n%s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunSandboxPolicySurfacesResolveConfigError(t *testing.T) {
|
|
store := newSandboxTestStore(t)
|
|
deps := appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), nil },
|
|
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, fmt.Errorf("invalid sandbox.maxAutonomy %q", "moderate")
|
|
},
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"sandbox", "policy"}, &stdout, &stderr, deps)
|
|
if exitCode != exitProvider {
|
|
t.Fatalf("policy exit = %d, want provider exit %d (resolve error surfaced, not silent DefaultPolicy fallback)", exitCode, exitProvider)
|
|
}
|
|
if !strings.Contains(stderr.String(), "invalid sandbox.maxAutonomy") {
|
|
t.Fatalf("expected surfaced resolve error in stderr, got %q", stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on resolve error, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestApplyConfiguredAutonomyCeiling(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
maxAutonomy string
|
|
want sandbox.Autonomy
|
|
}{
|
|
// Empty is a no-op: the default High ceiling is preserved.
|
|
{name: "empty keeps default high", maxAutonomy: "", want: sandbox.AutonomyHigh},
|
|
{name: "whitespace keeps default high", maxAutonomy: " ", want: sandbox.AutonomyHigh},
|
|
{name: "valid low", maxAutonomy: "low", want: sandbox.AutonomyLow},
|
|
{name: "valid medium", maxAutonomy: "medium", want: sandbox.AutonomyMedium},
|
|
{name: "valid high", maxAutonomy: "high", want: sandbox.AutonomyHigh},
|
|
{name: "case-insensitive medium", maxAutonomy: "MEDIUM", want: sandbox.AutonomyMedium},
|
|
// Fail-closed: an invalid non-empty value clamps to the most restrictive
|
|
// ceiling instead of leaving the High default in place.
|
|
{name: "invalid banana clamps to low", maxAutonomy: "banana", want: sandbox.AutonomyLow},
|
|
{name: "invalid moderate clamps to low", maxAutonomy: "moderate", want: sandbox.AutonomyLow},
|
|
{name: "invalid med clamps to low", maxAutonomy: "med", want: sandbox.AutonomyLow},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if base := sandbox.DefaultPolicy(); base.MaxAutonomy != sandbox.AutonomyHigh {
|
|
t.Fatalf("precondition: DefaultPolicy().MaxAutonomy = %q, want high", base.MaxAutonomy)
|
|
}
|
|
policy := applyConfiguredAutonomyCeiling(sandbox.DefaultPolicy(), tc.maxAutonomy)
|
|
if policy.MaxAutonomy != tc.want {
|
|
t.Fatalf("applyConfiguredAutonomyCeiling(_, %q).MaxAutonomy = %q, want %q", tc.maxAutonomy, policy.MaxAutonomy, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyConfiguredSandboxPolicyHardeningFlags(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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
func newSandboxTestStore(t *testing.T) *sandbox.GrantStore {
|
|
t.Helper()
|
|
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{
|
|
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
|
|
Now: fixedCLITime("2026-06-05T14:45:00Z"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewGrantStore returned error: %v", err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
func sandboxPolicyRestrictionContains(restrictions []string, value string) bool {
|
|
for _, restriction := range restrictions {
|
|
if strings.Contains(restriction, value) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func sandboxPolicyCapabilityStatus(capabilities []sandbox.BackendCapability, key string) sandbox.CapabilityStatus {
|
|
for _, capability := range capabilities {
|
|
if capability.Key == key {
|
|
return capability.Status
|
|
}
|
|
}
|
|
return ""
|
|
}
|