Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,17 @@ import (
)

func TestMain(m *testing.M) {
os.Exit(testenv.RunIsolatedMain(m))
dir, err := os.MkdirTemp("", "roborev-agy-settings-*")
if err == nil {
antigravitySettingsPathForTest = func() string {
return filepath.Join(dir, ".gemini", "antigravity-cli", "settings.json")
}
}
code := testenv.RunIsolatedMain(m)
if dir != "" {
_ = os.RemoveAll(dir)
}
os.Exit(code)
}

func TestAgentRegistry(t *testing.T) {
Expand Down
189 changes: 189 additions & 0 deletions internal/agent/antigravity_settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package agent

import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
)

// Official agy settings path. There is no documented --settings flag or env
// override that headless print-mode honors; permissions are read from
// ~/.gemini/antigravity-cli/settings.json.
// See https://antigravity.google/docs/cli/permissions/
func defaultAntigravitySettingsPath() string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return ""
}
return filepath.Join(home, ".gemini", "antigravity-cli", "settings.json")
}

// antigravitySettingsPathForTest, when set, redirects settings writes away
// from the developer's real ~/.gemini tree (agent tests do not isolate HOME).
var antigravitySettingsPathForTest func() string

func antigravitySettingsPath() string {
if antigravitySettingsPathForTest != nil {
return antigravitySettingsPathForTest()
}
return defaultAntigravitySettingsPath()
}

// Inspect commands reviews run (pwd/wc/ls/...) must be allowlisted. In
// headless print mode, unconfigured command() actions default to Ask and
// are soft-denied or hard-fail with "permission check failed for command".
var antigravityReviewAllowPermissions = []string{
"read_file(*)",
"command(pwd)",
"command(wc)",
"command(ls)",
"command(cat)",
"command(head)",
"command(tail)",
"command(stat)",
"command(file)",
}

var antigravitySettingsMu sync.Mutex

// ensureAntigravityReviewPermissions merges the allow-rules non-agentic
// reviews need into settings.json. Existing keys and allow entries are
// preserved; only missing allow strings are appended. Invalid JSON is
// left untouched.
func ensureAntigravityReviewPermissions(settingsPath string) error {
if settingsPath == "" {
return nil
}

antigravitySettingsMu.Lock()
defer antigravitySettingsMu.Unlock()

doc := map[string]any{}
raw, err := os.ReadFile(settingsPath)
switch {
case err == nil:
if trimmed := trimSpaceBytes(raw); len(trimmed) > 0 {
if err := json.Unmarshal(raw, &doc); err != nil {
return fmt.Errorf("parse %s: %w", settingsPath, err)
}
}
case os.IsNotExist(err):
// create below
default:
return fmt.Errorf("read %s: %w", settingsPath, err)
}

permissions, err := settingsObject(doc, "permissions")
if err != nil {
return fmt.Errorf("%s: %w", settingsPath, err)
}
doc["permissions"] = permissions

allow, changed, err := mergeAllowList(permissions["allow"], antigravityReviewAllowPermissions)
if err != nil {
return fmt.Errorf("%s permissions.allow: %w", settingsPath, err)
}
if !changed && fileExists(settingsPath) {
return nil
}
permissions["allow"] = allow

if err := writeSettingsJSON(settingsPath, doc); err != nil {
return fmt.Errorf("write %s: %w", settingsPath, err)
}
return nil
}

func ensureAntigravityReviewSettings() {
path := antigravitySettingsPath()
if path == "" {
log.Printf("antigravity: skipping settings merge; cannot resolve home directory")
return
}
if err := ensureAntigravityReviewPermissions(path); err != nil {
log.Printf("antigravity: could not merge review permissions into %s: %v", path, err)
}
}

func settingsObject(doc map[string]any, key string) (map[string]any, error) {
raw, ok := doc[key]
if !ok || raw == nil {
return map[string]any{}, nil
}
obj, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s is not a JSON object", key)
}
return obj, nil
}

func mergeAllowList(existing any, needed []string) (allow []any, changed bool, err error) {
switch v := existing.(type) {
case nil:
allow = nil
case []any:
allow = append([]any(nil), v...)
default:
return nil, false, fmt.Errorf("not a JSON array")
}

have := make(map[string]struct{}, len(allow))
for _, item := range allow {
s, ok := item.(string)
if !ok {
continue
}
have[s] = struct{}{}
}
for _, rule := range needed {
if _, ok := have[rule]; ok {
continue
}
allow = append(allow, rule)
have[rule] = struct{}{}
changed = true
}
if existing == nil {
changed = true
}
return allow, changed, nil
}

func writeSettingsJSON(path string, doc map[string]any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}

func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}

func trimSpaceBytes(b []byte) []byte {
i, j := 0, len(b)
for i < j && (b[i] == ' ' || b[i] == '\n' || b[i] == '\r' || b[i] == '\t') {
i++
}
for j > i && (b[j-1] == ' ' || b[j-1] == '\n' || b[j-1] == '\r' || b[j-1] == '\t') {
j--
}
return b[i:j]
}
146 changes: 146 additions & 0 deletions internal/agent/antigravity_settings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package agent

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestEnsureAntigravityReviewPermissionsCreatesFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.json")

require.NoError(t, ensureAntigravityReviewPermissions(path))

assertSettingsAllow(t, path, antigravityReviewAllowPermissions...)
}

func TestEnsureAntigravityReviewPermissionsMergesAllow(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "settings.json")
writeSettings(t, path, map[string]any{
"model": "gemini-3.1-pro-preview",
"permissions": map[string]any{
"allow": []any{"command(git)", "read_file(*)"},
"deny": []any{"command(rm -rf)"},
"ask": []any{"command(*)"},
},
"enableTerminalSandbox": true,
})

require.NoError(t, ensureAntigravityReviewPermissions(path))

doc := readSettings(t, path)
assert.Equal(t, "gemini-3.1-pro-preview", doc["model"])
assert.Equal(t, true, doc["enableTerminalSandbox"])

permissions := doc["permissions"].(map[string]any)
assert.Equal(t, []any{"command(rm -rf)"}, permissions["deny"])
assert.Equal(t, []any{"command(*)"}, permissions["ask"])

allow := asStrings(t, permissions["allow"])
assert.Equal(t, "command(git)", allow[0], "existing allow entries stay first")
assert.Contains(t, allow, "read_file(*)")
assert.Equal(t, 1, countStrings(allow, "read_file(*)"), "do not duplicate existing allows")
for _, rule := range antigravityReviewAllowPermissions {
assert.Contains(t, allow, rule)
}
}

func TestEnsureAntigravityReviewPermissionsIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.json")
require.NoError(t, ensureAntigravityReviewPermissions(path))
first := readRaw(t, path)

require.NoError(t, ensureAntigravityReviewPermissions(path))
second := readRaw(t, path)
assert.Equal(t, first, second)
}

func TestEnsureAntigravityReviewPermissionsDoesNotClobberInvalidJSON(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.json")
require.NoError(t, os.WriteFile(path, []byte("{not-json"), 0o644))

err := ensureAntigravityReviewPermissions(path)
require.Error(t, err)
assert.Equal(t, "{not-json", string(readRaw(t, path)))
}

func TestEnsureAntigravityReviewPermissionsRejectsNonObjectPermissions(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.json")
writeSettings(t, path, map[string]any{"permissions": "always-proceed"})

err := ensureAntigravityReviewPermissions(path)
require.Error(t, err)
doc := readSettings(t, path)
assert.Equal(t, "always-proceed", doc["permissions"])
}

func TestEnsureAntigravityReviewPermissionsEmptyPathIsNoop(t *testing.T) {
require.NoError(t, ensureAntigravityReviewPermissions(""))
}

func TestAntigravityReviewAllowPermissionsCoverProdFailures(t *testing.T) {
assert.Contains(t, antigravityReviewAllowPermissions, "read_file(*)")
for _, cmd := range []string{"pwd", "wc", "ls", "cat", "head", "tail", "stat", "file"} {
assert.Contains(t, antigravityReviewAllowPermissions, "command("+cmd+")")
}
}

func writeSettings(t *testing.T, path string, doc map[string]any) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
data, err := json.MarshalIndent(doc, "", " ")
require.NoError(t, err)
require.NoError(t, os.WriteFile(path, append(data, '\n'), 0o644))
}

func readSettings(t *testing.T, path string) map[string]any {
t.Helper()
var doc map[string]any
require.NoError(t, json.Unmarshal(readRaw(t, path), &doc))
return doc
}

func readRaw(t *testing.T, path string) []byte {
t.Helper()
raw, err := os.ReadFile(path)
require.NoError(t, err)
return raw
}

func assertSettingsAllow(t *testing.T, path string, want ...string) {
t.Helper()
doc := readSettings(t, path)
permissions, ok := doc["permissions"].(map[string]any)
require.True(t, ok)
allow := asStrings(t, permissions["allow"])
for _, rule := range want {
assert.Contains(t, allow, rule)
}
}

func asStrings(t *testing.T, raw any) []string {
t.Helper()
items, ok := raw.([]any)
require.True(t, ok)
out := make([]string, 0, len(items))
for _, item := range items {
s, ok := item.(string)
require.True(t, ok)
out = append(out, s)
}
return out
}

func countStrings(items []string, want string) int {
n := 0
for _, item := range items {
if item == want {
n++
}
}
return n
}
Loading