Files
zero/internal/contextreport/contextreport_test.go
euxaristia 6fc1220f6a fix(sandbox): scrub sensitive credentials from sandbox environment (#660)
* fix(windows): retry atomic file renames on Access is denied / Sharing violation

* test(swarm): add unit tests for rename retry and non-retryable errors

* test(swarm): skip TestMailboxRenameRetry on non-Windows platforms

* fix(sandbox): scrub sensitive provider credentials from sandbox env

* refactor(sandbox): unify environment creation and reduce lines of code

* fix(sandbox): forward workspaceRoot through sandboxEnvironment, gate Windows env on backend

* style(sandbox,swarm): remove stray blank lines flagged by gofmt

* refactor(fsutil): extract shared rename retry helper, derive scrub list from provider catalog

Address CodeRabbit review feedback:

- Add internal/fsutil.RenameWithRetry with an injectable rename override and
  move the four duplicated renameWithRetry/isWindowsSharingViolation copies
  (cron, sessions, sandbox, swarm) onto it. Mailbox keeps its rename test
  seam by passing its field through. Add fsutil tests for the Windows retry
  and non-retryable paths.
- Remove the orphaned metadata.json.tmp in cron writeJob when the rename
  permanently fails, matching the sibling writers.
- Derive scrubSensitiveEnv keys from providercatalog AuthEnvVars plus a
  static list of non-catalog secrets, so new providers are scrubbed
  automatically. AWS_PROFILE and GOOGLE_APPLICATION_CREDENTIALS stay
  because they are pointers, not secrets.

* fix(sandbox): deny sandboxed reads of cloud credential stores by default

Address CodeRabbit finding that scrubbed env pointers still left the
underlying credential files readable under the read-all workspace posture:

- Add credentialDenyReadPaths: default profile-level deny-read entries for
  ~/.aws, ~/.config/gcloud, ~/.azure, and the GOOGLE_APPLICATION_CREDENTIALS
  target file, wired into both permission profile builders so the seatbelt
  and Linux helper backends enforce them. Entries are stat-filtered and a
  store nested under a user AllowRead entry is dropped, keeping an explicit
  opt-out.
- Keep the defaults out of Policy.DenyRead, whose emptiness gates escalated
  execution, and skip Windows so the profile deny list does not flip the
  runner off the WRITE_RESTRICTED token path.
- Scrub GOOGLE_APPLICATION_CREDENTIALS from the sandbox env; keep AWS_PROFILE
  since the deny-read rule on ~/.aws is the real protection and the SDKs
  fall back to the default profile regardless.
- Add credential deny path tests, extend the scrub test, relax the profile
  test's exact deny count to containment.

* test(cli): pin HOME in sandbox policy golden test

The default credential-store deny-read entries depend on what exists in the
real home directory (the macOS CI image ships ~/.aws), which leaked host
paths into the policy JSON golden comparison. Point HOME at an empty temp
directory so the golden stays hermetic.

* fix(sandbox): protect GOOGLE_APPLICATION_CREDENTIALS without a home, retry lock violations

Address CodeRabbit review feedback:

- credentialDenyReadPaths no longer returns early when the home lookup
  fails; home-based candidates become conditional and the
  GOOGLE_APPLICATION_CREDENTIALS target is always considered.
- fsutil.RenameWithRetry also retries ERROR_LOCK_VIOLATION (errno 33),
  matching the mcp lock handling; helper renamed to
  isWindowsSharingOrLockViolation.
- Extend tests: homeless GOOGLE_APPLICATION_CREDENTIALS case and a lock
  violation step in the retry sequence.

* fix(sandbox): tighten env-scrub test assertion and add missing secret keys

TestScrubSensitiveEnv only checked an allowlist plus a length count, so a
duplicate allowed key silently replacing a required one would still pass.
Compare the exact expected key/value map instead.

Also add ZERO_WEBSEARCH_API_KEY and ZERO_DAEMON_REMOTE_TOKEN to
scrubSensitiveEnv's sensitiveKeys list: both are real bearer secrets read
from the environment but were missing from the scrub list, leaving them
readable by sandboxed commands when set in the parent shell.
2026-07-14 21:46:10 +08:00

356 lines
11 KiB
Go

package contextreport
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/tools"
)
func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "AGENTS.md", strings.Repeat("project rules\n", 200))
registry := tools.NewRegistry()
for _, tool := range tools.CoreTools(root) {
registry.Register(tool)
}
report, err := Build(Options{
WorkspaceRoot: root,
Provider: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
Model: "gpt-4.1",
},
Registry: registry,
ContextWindow: 20_000,
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if report.Contract != ContractV1 {
t.Fatalf("Contract = %q, want %q", report.Contract, ContractV1)
}
if report.Runtime != RuntimeGo {
t.Fatalf("Runtime = %q, want %q", report.Runtime, RuntimeGo)
}
if report.Root != root {
t.Fatalf("Root = %q, want %q", report.Root, root)
}
if report.ProviderName != "openai" || report.ModelID != "gpt-4.1" || report.APIModel != "gpt-4.1" {
t.Fatalf("provider metadata = %#v", report)
}
if report.ContextWindow != 20_000 {
t.Fatalf("ContextWindow = %d, want 20000", report.ContextWindow)
}
if report.ProjectGuidelineFile != "AGENTS.md" {
t.Fatalf("ProjectGuidelineFile = %q, want AGENTS.md", report.ProjectGuidelineFile)
}
if report.ToolCount == 0 {
t.Fatal("ToolCount = 0, want core tools counted")
}
if report.UsedTokens <= 0 {
t.Fatalf("UsedTokens = %d, want > 0", report.UsedTokens)
}
if report.FreeTokens <= 0 {
t.Fatalf("FreeTokens = %d, UsedTokens = %d, ContextWindow = %d, want > 0", report.FreeTokens, report.UsedTokens, report.ContextWindow)
}
if report.FreeTokens+report.UsedTokens != report.ContextWindow {
t.Fatalf("free + used = %d, want %d", report.FreeTokens+report.UsedTokens, report.ContextWindow)
}
if !hasCategory(report, CategoryProjectGuidelines) {
t.Fatalf("missing project guideline category: %#v", report.Categories)
}
if !hasCategory(report, CategoryTools) {
t.Fatalf("missing tools category: %#v", report.Categories)
}
if !hasCategory(report, CategoryFree) {
t.Fatalf("missing free category: %#v", report.Categories)
}
}
func TestBuildHasStableJSONContractAndCategoryMath(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "ZERO.md", strings.Repeat("zero rules\n", 16))
registry := tools.NewRegistry()
for _, tool := range tools.CoreTools(root) {
registry.Register(tool)
}
report, err := Build(Options{
WorkspaceRoot: root,
Registry: registry,
ContextWindow: 50_000,
ProjectContextFiles: []string{"AGENTS.md", "ZERO.md"},
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
keys := categoryKeys(report)
for _, want := range []string{CategorySystemPrompt, CategoryProjectGuidelines, CategoryTools, CategoryFree} {
if !contains(keys, want) {
t.Fatalf("missing category %q in %v", want, keys)
}
}
if report.ProjectGuidelineFile != "ZERO.md" {
t.Fatalf("ProjectGuidelineFile = %q, want ZERO.md", report.ProjectGuidelineFile)
}
if report.FreeTokens+report.UsedTokens != report.ContextWindow {
t.Fatalf("free + used = %d, want %d", report.FreeTokens+report.UsedTokens, report.ContextWindow)
}
if report.UsedFraction != float64(report.UsedTokens)/float64(report.ContextWindow) {
t.Fatalf("UsedFraction = %v, want used/context ratio", report.UsedFraction)
}
var decoded struct {
Contract string `json:"contract"`
Runtime string `json:"runtime"`
Categories []Category `json:"categories"`
}
data, err := json.Marshal(report)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if decoded.Contract != ContractV1 || decoded.Runtime != RuntimeGo {
t.Fatalf("decoded identity = %q/%q", decoded.Contract, decoded.Runtime)
}
if len(decoded.Categories) != len(report.Categories) {
t.Fatalf("decoded %d categories, want %d", len(decoded.Categories), len(report.Categories))
}
}
func TestBuildClampsFreeBudgetWhenOverContextWindow(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "AGENTS.md", strings.Repeat("very large project rule\n", 1000))
report, err := Build(Options{
WorkspaceRoot: root,
ContextWindow: 10,
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if report.UsedTokens <= report.ContextWindow {
t.Fatalf("UsedTokens = %d, want over tiny context window %d", report.UsedTokens, report.ContextWindow)
}
if report.FreeTokens != 0 {
t.Fatalf("FreeTokens = %d, want clamp to 0", report.FreeTokens)
}
free := categoryByKey(report, CategoryFree)
if free == nil {
t.Fatalf("missing free category: %#v", report.Categories)
}
if free.Tokens != 0 {
t.Fatalf("free category tokens = %d, want 0", free.Tokens)
}
}
func TestBuildAccountsForWorkspaceMapContext(t *testing.T) {
root := t.TempDir()
baseline, err := Build(Options{
WorkspaceRoot: root,
ContextWindow: 10_000,
})
if err != nil {
t.Fatalf("Build baseline returned error: %v", err)
}
baselineCat := categoryByKey(baseline, CategoryWorkspaceMap)
if baselineCat == nil {
t.Fatalf("baseline missing workspace map category: %#v", baseline.Categories)
}
writeTestFile(t, root, "go.mod", "module example.test/repo\n")
writeTestFile(t, root, "cmd/zero/main.go", "package main\n")
writeTestFile(t, root, "README.md", "# Example\n")
report, err := Build(Options{
WorkspaceRoot: root,
ContextWindow: 10_000,
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
cat := categoryByKey(report, CategoryWorkspaceMap)
if cat == nil {
t.Fatalf("missing workspace map category: %#v", report.Categories)
}
if cat.Tokens <= 0 {
t.Fatalf("workspace map tokens = %d, want > 0", cat.Tokens)
}
workspaceDelta := cat.Tokens - baselineCat.Tokens
if workspaceDelta <= 0 {
t.Fatalf("workspace map token delta = %d, want > 0 (baseline=%d final=%d)", workspaceDelta, baselineCat.Tokens, cat.Tokens)
}
if usedDelta := report.UsedTokens - baseline.UsedTokens; usedDelta != workspaceDelta {
t.Fatalf("used token delta = %d, want workspace map delta %d", usedDelta, workspaceDelta)
}
if freeDelta := baseline.FreeTokens - report.FreeTokens; freeDelta != workspaceDelta {
t.Fatalf("free token delta = %d, want workspace map delta %d", freeDelta, workspaceDelta)
}
formatted := Format(report)
if !strings.Contains(formatted, "Workspace map") {
t.Fatalf("Format missing workspace map category:\n%s", formatted)
}
}
func TestBuildShowsWorkspaceMapScanErrors(t *testing.T) {
root := filepath.Join(t.TempDir(), "missing")
report, err := Build(Options{
WorkspaceRoot: root,
ContextWindow: 10_000,
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
cat := categoryByKey(report, CategoryWorkspaceMap)
if cat == nil {
t.Fatalf("missing workspace map error category: %#v", report.Categories)
}
if cat.Tokens != 0 {
t.Fatalf("workspace map error tokens = %d, want 0", cat.Tokens)
}
if !strings.Contains(cat.Name, "Workspace map (error:") {
t.Fatalf("workspace map category name = %q, want visible error", cat.Name)
}
if formatted := Format(report); !strings.Contains(formatted, "Workspace map (error:") {
t.Fatalf("Format missing workspace map error:\n%s", formatted)
}
}
func TestBuildWithoutProviderStillReturnsReport(t *testing.T) {
root := t.TempDir()
report, err := Build(Options{WorkspaceRoot: root})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if report.Root != root {
t.Fatalf("Root = %q, want %q", report.Root, root)
}
if report.ProviderName != "" || report.ModelID != "" || report.APIModel != "" {
t.Fatalf("provider fields = %q/%q/%q, want empty", report.ProviderName, report.ModelID, report.APIModel)
}
if report.ContextWindow != 0 || report.UsedFraction != 0 || report.FreeTokens != 0 {
t.Fatalf("unknown budget fields should be zero, got %#v", report)
}
if !hasCategory(report, CategorySystemPrompt) {
t.Fatalf("missing system prompt category: %#v", report.Categories)
}
if !hasCategory(report, CategoryFree) {
t.Fatalf("missing free category: %#v", report.Categories)
}
}
func TestFormatIncludesRootModelAndCategories(t *testing.T) {
report := Report{
Contract: ContractV1,
Runtime: RuntimeGo,
Root: "D:/repo",
ProviderName: "openai",
ModelID: "gpt-4.1",
APIModel: "gpt-4.1",
ContextWindow: 1000,
UsedTokens: 250,
FreeTokens: 750,
UsedFraction: 0.25,
ToolCount: 2,
Categories: []Category{{Key: CategorySystemPrompt, Name: "System prompt", Tokens: 100, Percent: 10}, {Key: CategoryFree, Name: "Free", Tokens: 750, Percent: 75}},
}
formatted := Format(report)
for _, want := range []string{"Zero context report", "root: D:/repo", "model: gpt-4.1", "api_model: gpt-4.1", "System prompt", "Free"} {
if !strings.Contains(formatted, want) {
t.Fatalf("Format missing %q:\n%s", want, formatted)
}
}
}
func TestFormatHandlesUnknownContextWindow(t *testing.T) {
report := Report{
Contract: ContractV1,
Runtime: RuntimeGo,
Root: "D:/repo",
ProviderName: "openai",
ModelID: "gpt-4.1",
APIModel: "gpt-4.1",
UsedTokens: 250,
ToolCount: 2,
Categories: []Category{{Key: CategorySystemPrompt, Name: "System prompt", Tokens: 100}, {Key: CategoryFree, Name: "Free"}},
}
formatted := Format(report)
for _, want := range []string{
"Zero context report",
"root: D:/repo",
"model: gpt-4.1",
"api_model: gpt-4.1",
"usage: 250 tokens (context window unknown)",
"System prompt: 100 tokens",
"Free: 0 tokens",
} {
if !strings.Contains(formatted, want) {
t.Fatalf("Format missing %q:\n%s", want, formatted)
}
}
}
func hasCategory(report Report, key string) bool {
return categoryByKey(report, key) != nil
}
func categoryByKey(report Report, key string) *Category {
for _, category := range report.Categories {
if category.Key == key {
return &category
}
}
return nil
}
func categoryKeys(report Report) []string {
keys := make([]string, 0, len(report.Categories))
for _, category := range report.Categories {
keys = append(keys, category.Key)
}
return keys
}
func contains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func writeTestFile(t *testing.T, root string, name string, content string) {
t.Helper()
path := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
}