mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* config: add ValidateFile for structured config diagnostics
* doctor: add offsetToLineCol helper for JSON error positions
* doctor: validate config files in config.validation check
* cli: thread config paths into doctor and degrade on bad JSON
* doctor: inline read, single-parse config validation + utf8 col test
- Remove the osReadFile wrapper; call os.ReadFile directly at its one call site
- Add config.ValidateBytes([]byte) so each config file is read and JSON-parsed
once in configValidationCheck instead of twice (once for position, once via
ValidateFile); ValidateFile now reads then delegates to its own unmarshal for
the path-prefixed error message, keeping its public signature unchanged
- Fix the misleading skip comment in configValidationCheck: configFilesCheck
only checks path-string presence; the skip is a defensive guard for the
unreachable case where DefaultResolveOptions passed a non-empty path that
still fails to read
- Add utf8 multibyte case to TestOffsetToLineCol documenting byte-column
semantics (column counts bytes, not runes)
* sandbox: add Policy.MaxAutonomy ceiling defaulting to high
* sandbox: normalize both operands in autonomyAllowed to fail closed
* sandbox: clamp grant-allow and unsafe escalation to policy autonomy ceiling
* sandbox: add RequiredAutonomyForBatch helper with fail-closed mapping
* config: add sandbox.maxAutonomy field to file and resolved config
* config: merge sandbox.maxAutonomy across layers, env, and overrides
* cli: thread configured autonomy ceiling into sandbox engine construction
* cli: surface max_autonomy in sandbox policy formatters
* zerocommands: carry max autonomy in sandbox policy snapshot
* sandbox: default empty policy ceiling to high + reason const
Evaluate now treats an empty Policy.MaxAutonomy (from a directly-constructed
Policy that bypasses DefaultPolicy) as High instead of letting it normalize to
Low, which would silently clamp every Medium/High decision to Prompt. The
ceiling is a no-op unless explicitly configured.
Also extracts the "above policy ceiling" clamp reason into a package const
referenced at both engine clamp sites and the related tests, and adds a
non-vacuous engine test proving the empty-ceiling trap is closed.
* zerogit: add base-ref three-dot diff to changes inspect
* zerogit: cover base-ref backward-compat and real-git diff
* zerogit: propagate base ref through change snapshot
* cli: parse changes --base ref and reject on commit
* cli: thread base ref into changes inspect output and help
* changes: redact base in cli summary + guard --base= + rename test
- redactChangeSummary now redacts the Base field alongside Root/Branch/Commit/etc.
- --base= equals-form rejects leading-dash values via flagValueLooksLikeOption, closing the option-smuggling gap
- TestParseNameStatusRenameAndCopy asserts rename/copy three-field lines use the new/destination path with correct status, and confirms two-field modify is unaffected
* Add ParseDiffStat helper for git diff --stat summaries
* Add usage report aggregation over persisted session usage events
* Add zero usage report command over persisted session usage
* Wire usage command into CLI dispatch and help
* usage: validate --since format, fix redaction comments, add tests
Fix 1 (BLOCKING): parseUsageArgs now validates --since with time.Parse
against YYYY-MM-DD; returns exitUsage + "invalid --since %q: expected
YYYY-MM-DD" for bare strings like "foo", unpadded "2026-6-1", or
wrong-separator "06/01/2026". Valid dates are stored unchanged for the
existing lexical comparison.
Fix 2: Replace false "pre-redaction stat" claims in diffstat.go and
usage.go with accurate comments: the --stat summary line carries no
secret-bearing tokens so parsing the already-redacted DiffStat is safe.
Fix 3: Add three tests to usage_test.go —
- TestRunUsageDaysFilter: stubs deps.now, seeds events on two dates,
asserts --days 3 includes the recent date and excludes the old one.
- TestRunUsageInvalidSince: table-driven; verifies "foo", "2026-6-1",
"06/01/2026" each return exitUsage + validation message, while
"2026-06-01" returns exitSuccess.
- TestRunUsageEmptyStore: zero usage events → exitSuccess, output
contains header and "total" row, no panic.
* doctor: surface type-mismatch offsets and drop colliding flat line/col keys
Probe config validation against config.FileConfig instead of a bare any so
a structurally-valid document with a wrong field type (e.g. maxTurns as a
string) surfaces a *json.UnmarshalTypeError carrying the offset, instead of
losing line/col. Remove the flat top-level details["line"]/["col"] keys
that were overwritten by the last malformed file when several were present;
keep only the unambiguous per-path map entry. Tighten the existing malformed
test to assert concrete per-path line/col and add a type-mismatch test that
exercises the previously-dead UnmarshalTypeError branch.
* sandbox: fail closed on invalid configured autonomy ceiling
An invalid non-empty sandbox.maxAutonomy (e.g. "moderate") previously
survived Resolve (only trimmed, never validated), failed to normalize at the
sandbox bridge, and left the policy unchanged at the default High ceiling.
A typo therefore silently disabled the admin's intended ceiling: fail-open on
a security boundary.
Validate at resolve time (fail loud): Resolve now rejects a non-empty
maxAutonomy that does not normalize, returning a clear error. As defense in
depth, applyConfiguredAutonomyCeiling now clamps an unrecognised non-empty
value to the most restrictive ceiling (low) instead of returning the policy
unchanged, so a bad value can never widen the ceiling even via direct use.
Empty/unset stays valid and keeps the default High ceiling.
Add resolver tests for invalid-fails / valid-resolves and bridge tests for
the fail-closed clamp and valid mappings.
* sandbox: fail closed on malformed autonomy and surface policy resolve errors
Treat a genuinely-invalid request autonomy as the highest tier in the engine
so it exceeds a Medium/Low ceiling and clamps to Prompt instead of being
sanitized to Low and auto-allowing on the grant/unsafe path. Surface
resolveConfig failures in the policy command instead of silently falling back
to the permissive DefaultPolicy, which would misreport trust posture.
* usage: bucket and filter by UTC calendar day, reject empty --session
Normalize RFC3339 timestamps to their UTC calendar date before day bucketing
(report) and before the --since/--days cutoff comparison (CLI) so offset
timestamps land on the correct UTC day and the bucket and cutoff agree;
malformed timestamps fall back to the leading-10 slice. Reject empty
--session/--session-id values with a value-required error matching the --since
validation style.
* doctor: fail on unreadable config files and stop fabricating positions
Surface a present-but-unreadable config path (permissions, is-a-directory) as
a failing per-path validation detail instead of silently skipping it; a
genuinely-missing path stays a skip. Return ok=false for a JSON parse error
that carries no offset so it routes through the no-position ValidateBytes path
rather than fabricating a (1,1) line/col. Assert empty stderr on the JSON
doctor failure path.
* config: make sandbox maxAutonomy resolver tests hermetic
Pass an explicit empty Env to the maxAutonomy Resolve tests so host
environment variables cannot leak in through the nil-Env os.Getenv fallback.
* sandbox: preserve raw autonomy for ceiling check so invalid clamps under default ceiling
Defaulting invalid request.Autonomy to High only clamped under a Medium/Low
ceiling; under the default High ceiling autonomyAllowed(High, High) returned true
and still auto-allowed (fail-open). Keep the raw requested value for the ceiling
check so autonomyAllowed's unknown-tier guard fails it closed under ANY ceiling.
Adds default-High-ceiling regression tests for the unsafe and grant-allow paths.
---------
Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
340 lines
13 KiB
Go
340 lines
13 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/sessions"
|
|
"github.com/Gitlawb/zero/internal/zerogit"
|
|
)
|
|
|
|
func seedUsageStore(t *testing.T) *sessions.Store {
|
|
t.Helper()
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-01T09:00:00Z")})
|
|
session, err := store.Create(sessions.CreateInput{SessionID: "usage_s1", Title: "Usage", Cwd: "/repo", ModelID: "gpt-4.1", Provider: "openai"})
|
|
if err != nil {
|
|
t.Fatalf("Create returned error: %v", err)
|
|
}
|
|
for _, payload := range []map[string]any{
|
|
{"promptTokens": 1000, "completionTokens": 200, "totalTokens": 1200},
|
|
{"promptTokens": 500, "completionTokens": 100, "totalTokens": 600},
|
|
} {
|
|
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventUsage, Payload: payload}); err != nil {
|
|
t.Fatalf("AppendEvent returned error: %v", err)
|
|
}
|
|
}
|
|
return store
|
|
}
|
|
|
|
func stubInspectChanges(stat string) func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
|
return func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
|
return zerogit.ChangeSummary{Root: "/repo", DiffStat: stat}, nil
|
|
}
|
|
}
|
|
|
|
func TestRunUsageTextReport(t *testing.T) {
|
|
store := seedUsageStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"usage", "report"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store { return store },
|
|
inspectChanges: stubInspectChanges(" 1 file changed, 100 insertions(+), 30 deletions(-)"),
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
output := stdout.String()
|
|
for _, want := range []string{"2026-06-01", "1,800", "estimate", "net LOC", "+100", "-30"} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("usage report missing %q in:\n%s", want, output)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunUsageDefaultsToReport(t *testing.T) {
|
|
store := seedUsageStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"usage"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store { return store },
|
|
inspectChanges: stubInspectChanges(""),
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "2026-06-01") {
|
|
t.Fatalf("expected default report output, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunUsageJSONReport(t *testing.T) {
|
|
store := seedUsageStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"usage", "report", "--json"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store { return store },
|
|
inspectChanges: stubInspectChanges(" 1 file changed, 100 insertions(+), 30 deletions(-)"),
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
var report struct {
|
|
NetLOC int `json:"netLOC"`
|
|
Total struct {
|
|
Requests int `json:"requests"`
|
|
TotalTokens int `json:"totalTokens"`
|
|
} `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
|
|
t.Fatalf("usage JSON did not decode: %v\n%s", err, stdout.String())
|
|
}
|
|
if report.NetLOC != 70 || report.Total.Requests != 2 || report.Total.TotalTokens != 1800 {
|
|
t.Fatalf("unexpected usage JSON: %+v", report)
|
|
}
|
|
}
|
|
|
|
func TestRunUsageSinceFilter(t *testing.T) {
|
|
store := seedUsageStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"usage", "report", "--since", "2026-07-01"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store { return store },
|
|
inspectChanges: stubInspectChanges(""),
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if strings.Contains(stdout.String(), "2026-06-01") {
|
|
t.Fatalf("expected --since to filter out June events, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunUsageSessionFilter(t *testing.T) {
|
|
store := seedUsageStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"usage", "report", "--json", "--session", "missing_session"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store { return store },
|
|
inspectChanges: stubInspectChanges(""),
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
var report struct {
|
|
Total struct {
|
|
Requests int `json:"requests"`
|
|
} `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
|
|
t.Fatalf("usage JSON did not decode: %v\n%s", err, stdout.String())
|
|
}
|
|
if report.Total.Requests != 0 {
|
|
t.Fatalf("expected unknown session to yield 0 requests, got %d", report.Total.Requests)
|
|
}
|
|
}
|
|
|
|
func TestRunUsageHelp(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"usage", "--help"}, &stdout, &stderr, appDeps{})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
for _, want := range []string{"zero usage report", "--json", "--days", "--since", "--session"} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("usage help missing %q in:\n%s", want, stdout.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunUsageUnknownFlag(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"usage", "report", "--bogus"}, &stdout, &stderr, appDeps{})
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "unknown usage flag") {
|
|
t.Fatalf("expected unknown-flag error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
// TestRunUsageEmptySessionRejected verifies that an empty --session/--session-id
|
|
// value is rejected with exitUsage and the value-required error, matching the
|
|
// --since validation style, rather than silently filtering to no session.
|
|
func TestRunUsageEmptySessionRejected(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
flag string
|
|
}{
|
|
{name: "session equals empty", args: []string{"usage", "report", "--session="}, flag: "--session"},
|
|
{name: "session-id equals empty", args: []string{"usage", "report", "--session-id="}, flag: "--session-id"},
|
|
{name: "session spaced empty", args: []string{"usage", "report", "--session", " "}, flag: "--session"},
|
|
{name: "session-id spaced empty", args: []string{"usage", "report", "--session-id", ""}, flag: "--session-id"},
|
|
}
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store {
|
|
return sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
|
},
|
|
inspectChanges: stubInspectChanges(""),
|
|
})
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("%s: expected exitUsage (%d), got %d", tc.flag, exitUsage, exitCode)
|
|
}
|
|
msg := stderr.String()
|
|
if !strings.Contains(msg, tc.flag+" requires a value") {
|
|
t.Fatalf("%s: expected value-required error in stderr, got %q", tc.flag, msg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// seedUsageStoreDates creates a store with usage events spread across two
|
|
// different calendar dates. The "recent" session's events fall on recentDate
|
|
// and the "old" session's events fall on oldDate. Both dates must be
|
|
// YYYY-MM-DD strings that parse as time.RFC3339 with a T00:00:00Z suffix.
|
|
func seedUsageStoreDates(t *testing.T, recentDate, oldDate string) (*sessions.Store, func() time.Time) {
|
|
t.Helper()
|
|
|
|
// The store's Now function is used to timestamp every appended event.
|
|
// We swap it via this pointer so we can stamp the two sessions differently.
|
|
currentTime := mustParseTime(t, recentDate+"T09:00:00Z")
|
|
nowFunc := func() time.Time { return currentTime }
|
|
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: nowFunc})
|
|
|
|
// Session whose events fall on recentDate.
|
|
sess, err := store.Create(sessions.CreateInput{SessionID: "days_recent", Title: "Recent", Cwd: "/repo", ModelID: "gpt-4.1", Provider: "openai"})
|
|
if err != nil {
|
|
t.Fatalf("Create recent session: %v", err)
|
|
}
|
|
if _, err := store.AppendEvent(sess.SessionID, sessions.AppendEventInput{
|
|
Type: sessions.EventUsage,
|
|
Payload: map[string]any{"promptTokens": 100, "completionTokens": 20, "totalTokens": 120},
|
|
}); err != nil {
|
|
t.Fatalf("AppendEvent recent: %v", err)
|
|
}
|
|
|
|
// Swap the clock to oldDate before creating the old session's events.
|
|
currentTime = mustParseTime(t, oldDate+"T09:00:00Z")
|
|
|
|
sessOld, err := store.Create(sessions.CreateInput{SessionID: "days_old", Title: "Old", Cwd: "/repo", ModelID: "gpt-4.1", Provider: "openai"})
|
|
if err != nil {
|
|
t.Fatalf("Create old session: %v", err)
|
|
}
|
|
if _, err := store.AppendEvent(sessOld.SessionID, sessions.AppendEventInput{
|
|
Type: sessions.EventUsage,
|
|
Payload: map[string]any{"promptTokens": 200, "completionTokens": 40, "totalTokens": 240},
|
|
}); err != nil {
|
|
t.Fatalf("AppendEvent old: %v", err)
|
|
}
|
|
|
|
// Return the store and a fixed-time now func anchored at recentDate.
|
|
return store, fixedCLITime(recentDate + "T12:00:00Z")
|
|
}
|
|
|
|
func mustParseTime(t *testing.T, value string) time.Time {
|
|
t.Helper()
|
|
parsed, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
t.Fatalf("mustParseTime %q: %v", value, err)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
// TestRunUsageDaysFilter verifies that --days N excludes events outside the
|
|
// rolling window and includes events inside it.
|
|
func TestRunUsageDaysFilter(t *testing.T) {
|
|
// recentDate is within the last 3 days; oldDate is 10 days ago.
|
|
recentDate := "2026-06-06"
|
|
oldDate := "2026-05-29"
|
|
store, nowFunc := seedUsageStoreDates(t, recentDate, oldDate)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"usage", "report", "--days", "3"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store { return store },
|
|
inspectChanges: stubInspectChanges(""),
|
|
now: nowFunc, // anchored at 2026-06-06T12:00:00Z
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
output := stdout.String()
|
|
// The recent event (recentDate) must appear.
|
|
if !strings.Contains(output, recentDate) {
|
|
t.Fatalf("--days 3 should include %s, got:\n%s", recentDate, output)
|
|
}
|
|
// The old event (oldDate) must be excluded.
|
|
if strings.Contains(output, oldDate) {
|
|
t.Fatalf("--days 3 should exclude %s, got:\n%s", oldDate, output)
|
|
}
|
|
}
|
|
|
|
// TestRunUsageInvalidSince verifies that malformed --since values are rejected
|
|
// with exitUsage and the expected validation message, while a valid YYYY-MM-DD
|
|
// date is accepted.
|
|
func TestRunUsageInvalidSince(t *testing.T) {
|
|
cases := []struct {
|
|
since string
|
|
expectError bool
|
|
}{
|
|
{"foo", true},
|
|
{"2026-6-1", true}, // unpadded month/day
|
|
{"06/01/2026", true}, // wrong separator
|
|
{"2026-06-01", false}, // valid
|
|
}
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run("since="+tc.since, func(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"usage", "report", "--since", tc.since}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store {
|
|
return sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
|
},
|
|
inspectChanges: stubInspectChanges(""),
|
|
})
|
|
if tc.expectError {
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("--since %q: expected exitUsage (%d), got %d", tc.since, exitUsage, exitCode)
|
|
}
|
|
msg := stderr.String()
|
|
if !strings.Contains(msg, "invalid --since") {
|
|
t.Fatalf("--since %q: expected validation error in stderr, got %q", tc.since, msg)
|
|
}
|
|
if !strings.Contains(msg, "YYYY-MM-DD") {
|
|
t.Fatalf("--since %q: expected YYYY-MM-DD hint in stderr, got %q", tc.since, msg)
|
|
}
|
|
} else {
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("--since %q: expected exitSuccess (%d), got %d: %s", tc.since, exitSuccess, exitCode, stderr.String())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRunUsageEmptyStore verifies that running `usage report` against a store
|
|
// with no usage events exits successfully, prints the header, shows a zero
|
|
// total, and does not panic.
|
|
func TestRunUsageEmptyStore(t *testing.T) {
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"usage", "report"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store { return store },
|
|
inspectChanges: stubInspectChanges(""),
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("empty store: expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "Usage report") {
|
|
t.Fatalf("empty store: expected header in output, got:\n%s", output)
|
|
}
|
|
// Total row must show zero requests / tokens.
|
|
if !strings.Contains(output, "total") {
|
|
t.Fatalf("empty store: expected 'total' row in output, got:\n%s", output)
|
|
}
|
|
}
|