From d06d26dcbaf6ef044c8ff473e6a4732ee80d7bbe Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 4 Sep 2026 06:12:16 +0530 Subject: [PATCH 1/2] docs: fix false config claims (precedence, env vars, flags, defaults) SECURITY.md claimed an unregistered --allow-project-mcp flag and project-overridable model/provider/permissions; 05-configuration.md inverted user/project precedence and listed dead env vars. Both now match LoadSettings/projectSafeSettings and verified Getenv sites. .env.example lists only consumed vars. y0.go header corrected to default-on marketplace. --- .env.example | 18 +++++++------- SECURITY.md | 37 ++++++++++++++++++----------- docs/user-guide/05-configuration.md | 23 +++++++++++------- internal/flags/y0.go | 4 ++-- 4 files changed, 48 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index 732ae08b..c8c98f50 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,10 @@ -# graycode daemon environment variables — copy to .env and fill in +# graycode daemon environment variables — copy to .env and fill in. +# Only GRAYCODE_DAEMON_API_KEY is consumed from the environment. GRAYCODE_DAEMON_API_KEY= -GRAYCODE_DAEMON_PORT=4590 -GRAYCODE_DAEMON_HOST=127.0.0.1 -# Eyrie connection (LLM provider runtime) -EYRIE_API_KEY= -EYRIE_BASE_URL=http://localhost:8080 -# Harrier connection (memory service) -HARRIER_API_KEY= -HARRIER_ADDR=127.0.0.1:3456 + +# The following are NOT read by graycode-cli — setting them has no effect: +# GRAYCODE_DAEMON_PORT / GRAYCODE_DAEMON_HOST — daemon binds 127.0.0.1:4590; +# override with the --host / --port flags (see `graycode daemon start --help`) +# EYRIE_API_KEY / EYRIE_BASE_URL, HARRIER_API_KEY / HARRIER_ADDR — +# consumed by sibling services, not by this binary +# See docs/user-guide/05-configuration.md for the full precedence chain. diff --git a/SECURITY.md b/SECURITY.md index 1f364e46..17b0d5bf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -79,28 +79,37 @@ Project-level `.graycode/settings.json` can be committed to a git repository. An attacker who controls a repository could define MCP servers that execute arbitrary commands when a developer clones and runs graycode in that directory. -**Mitigation:** Project-level MCP servers are blocked by default. They are -only loaded when the user explicitly passes `--allow-project-mcp` on the -command line. Global MCP servers (from `~/.graycode/settings.json`) are always -loaded. +**Mitigation:** Project-level MCP servers are stripped from project config +(`projectSafeSettings` in `internal/config/settings.go`) and project +hooks/MCP/plugins/LSP additionally require folder trust: the project root +must be trusted via `graycode trust add` (`AllowProjectAutomation` in +`internal/trust/store.go`). There is no `--allow-project-mcp` flag. +Global MCP servers (from `~/.graycode/settings.json`) are always loaded. ### Security-sensitive fields -The following settings **cannot** be overridden by project-level config: -- MCP servers (blocked by default, require explicit `--allow-project-mcp` flag) +The following settings **cannot** be set by project-level config (stripped by +`projectSafeSettings`): +- `model`, `provider` (selection stays in global config) +- `auto_allow`, `allowed_tools`, `disallowed_tools`, `never_allow` (permissions) +- MCP servers, custom providers, `deployment_routing`, thinking flags - API keys (never stored in settings.json; use OS secret store via `/config`) -The following settings **can** be overridden by project config: -- `model`, `provider` (convenience, not a security risk) -- `theme`, `auto_allow`, `allowed_tools`, `disallowed_tools` -- `max_budget_usd`, `sandbox`, `autonomy` +The following settings **can** be set by project config (anything not stripped): +- `theme`, `autonomy`, `sandbox`, `max_budget_usd`, and other + repository-local behavior ### Config merge precedence -1. Global `~/.graycode/settings.json` (lowest priority) -2. Project `.graycode/settings.json` (overrides global) -3. CLI `--settings` flag (overrides both) -4. Environment variables (highest priority for specific keys) +Highest priority first (`LoadSettings` / `LoadSettingsWithOverride` in +`internal/config/settings.go`, per-command flag resolution in `cmd/options.go`): + +1. CLI `--settings` JSON override +2. Per-command CLI flags (e.g., `--model`, `--provider`) +3. Environment variables (only where explicitly read; there is no global env layer) +4. Project `.graycode/settings.json` (repository-safe subset only — see above) +5. Global `~/.graycode/settings.json` +6. Built-in defaults (lowest priority) Project-level config CANNOT escalate permissions beyond what global config allows. The `MergeSettings` function in `internal/config/settings.go` diff --git a/docs/user-guide/05-configuration.md b/docs/user-guide/05-configuration.md index a7764c3e..942042d0 100644 --- a/docs/user-guide/05-configuration.md +++ b/docs/user-guide/05-configuration.md @@ -8,11 +8,13 @@ Graycode reads configuration from settings files, environment variables, and has Configuration is resolved in this order (highest priority first): -1. **CLI flags** (e.g., `--provider`, `--model`) -2. **Environment variables** -3. **User settings** (`~/.graycode/settings.json`) -4. **Project settings** (`.graycode/settings.json`) -5. **Built-in defaults** +1. **CLI `--settings` JSON override** (`LoadSettingsWithOverride`) +2. **Per-command CLI flags** (e.g., `--provider`, `--model`) +3. **Environment variables** (only where explicitly read; there is no global env layer) +4. **Project settings** (`.graycode/settings.json`, repository-safe subset only — + `model`, `provider`, permissions, MCP servers and providers are stripped) +5. **User settings** (`~/.graycode/settings.json`) +6. **Built-in defaults** --- @@ -111,14 +113,14 @@ Key environment variables for configuration. | Variable | Description | |----------|-------------| | `GRAYCODE_Y0_FOLDER_TRUST` | Folder trust feature flag (default: `1`) | -| `GRAYCODE_Y0_MARKETPLACE` | Marketplace feature flag (default: `0`) | -| `GRAYCODE_DEPLOYMENT_ROUTING` | Enable deployment-aware routing | +| `GRAYCODE_Y0_MARKETPLACE` | Marketplace feature flag (default: `1`, set `0` to disable remote installs) | +| `GRAYCODE_DEPLOYMENT_ROUTING` | Not an environment variable: set `deployment_routing` in `settings.json` | ### Paths | Variable | Description | |----------|-------------| -| `GRAYCODE_HOME` | Override config directory (default: `~/.graycode`) | +| `GRAYCODE_HOME` | Harness home override used by identity only (default: `~/.graycode`); most config paths honor `GRAYCODE_CONFIG_DIR` / `GRAYCODE_STATE_DIR` / `GRAYCODE_CACHE_DIR` instead | --- @@ -161,7 +163,10 @@ network = "deny" ## MCP Servers -Configure MCP servers in `.graycode/settings.json` or project `.graycode/settings.json`: +Configure MCP servers in global `~/.graycode/settings.json` only — project +`.graycode/settings.json` cannot register MCP servers (stripped by +`projectSafeSettings`; project automation additionally requires folder trust, +see below): ```json { diff --git a/internal/flags/y0.go b/internal/flags/y0.go index 0b24b2ef..0dc83441 100644 --- a/internal/flags/y0.go +++ b/internal/flags/y0.go @@ -1,8 +1,8 @@ // Package flags provides process-level feature flags for staged Year 0 work. // // Flags are read from environment variables. -// Folder trust defaults on (PACK-03). Spawn v2 and marketplace remain opt-in -// until their cutovers complete. +// Folder trust and marketplace default on (PACK-03/PACK-05). Spawn v2 remains +// opt-in until its cutover completes. package flags import ( From 8cd0c4affa2844835fec7c1b9a51425ba3255741 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 4 Sep 2026 06:12:16 +0530 Subject: [PATCH 2/2] refactor: drop lying stubs, dead re-exports, region forwarder dupes TokenStore.Load/Save no-op stubs (+3 tests); engine NewBackgroundAgentPool{,WithContext}/FormatResults/BackgroundAgentPool re-exports (zero callers); ZAI/Xiaomi region forwarders collapsed to Needs/SetGatewayRegion/GatewayRegionLabel (callers migrated, tests rewritten to generic funcs). --- cmd/chat_config_panel.go | 4 ++-- internal/auth/auth.go | 19 ------------------- internal/auth/auth_extra_test.go | 27 --------------------------- internal/auth/auth_test.go | 11 ----------- internal/config/xiaomi_setup.go | 16 ---------------- internal/config/xiaomi_setup_test.go | 20 ++++++++------------ internal/config/zai_setup.go | 16 ---------------- internal/engine/agent_reexports.go | 27 ++++----------------------- 8 files changed, 14 insertions(+), 126 deletions(-) diff --git a/cmd/chat_config_panel.go b/cmd/chat_config_panel.go index 0c4701ab..af340265 100644 --- a/cmd/chat_config_panel.go +++ b/cmd/chat_config_panel.go @@ -91,7 +91,7 @@ func (m chatModel) configProviderKeyView() string { hint = "paste key for this gateway only · " + hint } if providerName == graycodeconfig.ProviderXiaomiTokenPlan { - reg := graycodeconfig.XiaomiTokenPlanRegionLabel() + reg := graycodeconfig.GatewayRegionLabel(graycodeconfig.ProviderXiaomiTokenPlan) if reg == "" { reg = "not set — esc and pick region with g or enter on gateway row" } @@ -580,7 +580,7 @@ func (m chatModel) finishConfigEntry() (chatModel, tea.Cmd) { m.restoreChatInput() return m, nil } - if providerName == graycodeconfig.ProviderXiaomiTokenPlan && graycodeconfig.NeedsXiaomiTokenPlanRegion(providerName) { + if providerName == graycodeconfig.ProviderXiaomiTokenPlan && graycodeconfig.NeedsGatewayRegion(providerName) { m.configEntry = configEntryNone m.wipeConfigKeyInput() m.restoreChatInput() diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 2a82e15a..2b9ff69b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -27,25 +27,6 @@ func NewTokenStore() *TokenStore { return &TokenStore{tokens: make(map[string]string)} } -// Load loads tokens from secure storage. -// Deprecated: Use SecureStorage directly to load tokens. This stub always -// returns an empty token map. Migrate callers to SecureStorage.Get/Set. -func (t *TokenStore) Load() error { - // Stub: no-op. Existing callers that relied on this get an empty token - // map. New code should use SecureStorage directly. - t.tokens = make(map[string]string) - return nil -} - -// Save saves tokens to secure storage. -// Deprecated: Use SecureStorage directly to persist tokens. This stub is a -// no-op. Migrate callers to SecureStorage.Set. -func (t *TokenStore) Save() error { - // Stub: no-op. Tokens held in-memory only; they are lost on process exit. - // Use SecureStorage for persistent, OS-keychain-backed storage. - return nil -} - // Get returns a token for a provider. func (t *TokenStore) Get(provider string) string { return t.tokens[provider] diff --git a/internal/auth/auth_extra_test.go b/internal/auth/auth_extra_test.go index af7e491f..a093f272 100644 --- a/internal/auth/auth_extra_test.go +++ b/internal/auth/auth_extra_test.go @@ -23,33 +23,6 @@ func TestNewTokenStore(t *testing.T) { } } -func TestTokenStore_Load(t *testing.T) { - ts := NewTokenStore() - // Pre-populate - ts.tokens["test"] = "old" - err := ts.Load() - if err != nil { - t.Fatalf("Load() error: %v", err) - } - // Load should reset to empty - if len(ts.tokens) != 0 { - t.Errorf("expected empty tokens after Load, got %d", len(ts.tokens)) - } -} - -func TestTokenStore_Save(t *testing.T) { - ts := NewTokenStore() - ts.tokens["test"] = "secret" - err := ts.Save() - if err != nil { - t.Fatalf("Save() error: %v", err) - } - // Save is a no-op stub, tokens should still be in memory - if ts.tokens["test"] != "secret" { - t.Error("token should still be in memory after Save") - } -} - func TestTokenStore_Get(t *testing.T) { ts := NewTokenStore() ts.tokens["provider1"] = "token1" diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 96963bd8..cdb32061 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -82,17 +82,6 @@ func TestTokenStore(t *testing.T) { } } }) - - t.Run("load and save are no-ops", func(t *testing.T) { - t.Parallel() - store := NewTokenStore() - if err := store.Load(); err != nil { - t.Errorf("Load() error = %v", err) - } - if err := store.Save(); err != nil { - t.Errorf("Save() error = %v", err) - } - }) } func TestGenerateNonce(t *testing.T) { diff --git a/internal/config/xiaomi_setup.go b/internal/config/xiaomi_setup.go index 07a431fe..e85c18e9 100644 --- a/internal/config/xiaomi_setup.go +++ b/internal/config/xiaomi_setup.go @@ -1,19 +1,3 @@ package config const ProviderXiaomiTokenPlan = "xiaomi_mimo_token_plan" // #nosec G101 -- provider ID string, not a credential - -// NeedsXiaomiTokenPlanRegion reports whether the Token Plan gateway still needs a cluster pick. -func NeedsXiaomiTokenPlanRegion(providerID string) bool { - return NeedsGatewayRegion(providerID) -} - -// SetXiaomiTokenPlanRegion persists region (cn, sgp, ams). Eyrie reads the -// provider state directly when probing; Graycode does not mutate process env. -func SetXiaomiTokenPlanRegion(region string) error { - return SetGatewayRegion(ProviderXiaomiTokenPlan, region) -} - -// XiaomiTokenPlanRegionLabel returns the saved cluster id for UI (cn, sgp, ams) or "" if unset. -func XiaomiTokenPlanRegionLabel() string { - return GatewayRegionLabel(ProviderXiaomiTokenPlan) -} diff --git a/internal/config/xiaomi_setup_test.go b/internal/config/xiaomi_setup_test.go index f3d3f569..21cac067 100644 --- a/internal/config/xiaomi_setup_test.go +++ b/internal/config/xiaomi_setup_test.go @@ -7,7 +7,7 @@ import ( eyriecfg "github.com/GrayCodeAI/eyrie/config" ) -func TestSetXiaomiTokenPlanRegion_ClearsStaleBaseHost(t *testing.T) { +func TestSetGatewayRegion_XiaomiClearsStaleBaseHost(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("GRAYCODE_CONFIG_DIR", dir) @@ -21,7 +21,7 @@ func TestSetXiaomiTokenPlanRegion_ClearsStaleBaseHost(t *testing.T) { if err := eyriecfg.SaveProviderConfig(cfg, ""); err != nil { t.Fatal(err) } - if err := SetXiaomiTokenPlanRegion("sgp"); err != nil { + if err := SetGatewayRegion(ProviderXiaomiTokenPlan, "sgp"); err != nil { t.Fatal(err) } loaded := eyriecfg.LoadProviderConfig("") @@ -29,31 +29,27 @@ func TestSetXiaomiTokenPlanRegion_ClearsStaleBaseHost(t *testing.T) { t.Fatalf("region = %q", loaded.XiaomiMimoTokenPlanRegion) } if got := os.Getenv("XIAOMI_MIMO_TOKEN_PLAN_BASE_URL"); got != "https://caller-owned.example.test/v1" { - t.Fatalf("SetXiaomiTokenPlanRegion mutated process env: %q", got) + t.Fatalf("SetGatewayRegion mutated process env: %q", got) } - // want := "https://token-plan-sgp.xiaomimimo.com/v1" - // if loaded.XiaomiMimoTokenPlanBaseURL != want { - // t.Fatalf("base = %q, want %s", loaded.XiaomiMimoTokenPlanBaseURL, want) - // } } -func TestNeedsXiaomiTokenPlanRegion_InvalidAndMissing(t *testing.T) { +func TestNeedsGatewayRegion_XiaomiInvalidAndMissing(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("GRAYCODE_CONFIG_DIR", dir) t.Setenv("EYRIE_CONFIG_DIR", dir) - if !NeedsXiaomiTokenPlanRegion(ProviderXiaomiTokenPlan) { + if !NeedsGatewayRegion(ProviderXiaomiTokenPlan) { t.Fatal("expected true when no config file") } if err := eyriecfg.SaveProviderConfig(&eyriecfg.ProviderConfig{Version: "1", XiaomiMimoTokenPlanRegion: "tokyo"}, ""); err != nil { t.Fatal(err) } - if !NeedsXiaomiTokenPlanRegion(ProviderXiaomiTokenPlan) { + if !NeedsGatewayRegion(ProviderXiaomiTokenPlan) { t.Fatal("expected true for invalid region") } - _ = SetXiaomiTokenPlanRegion("cn") - if NeedsXiaomiTokenPlanRegion(ProviderXiaomiTokenPlan) { + _ = SetGatewayRegion(ProviderXiaomiTokenPlan, "cn") + if NeedsGatewayRegion(ProviderXiaomiTokenPlan) { t.Fatal("expected false after valid region set") } } diff --git a/internal/config/zai_setup.go b/internal/config/zai_setup.go index 17df0053..0b77dc4b 100644 --- a/internal/config/zai_setup.go +++ b/internal/config/zai_setup.go @@ -4,19 +4,3 @@ const ( ProviderZAIPayg = "zai_payg" ProviderZAICoding = "zai_coding" ) - -// NeedsZAIRegion reports whether the Z.AI gateway still needs a region pick for the chosen plan. -func NeedsZAIRegion(providerID string) bool { - return NeedsGatewayRegion(providerID) -} - -// SetZAIRegion persists the region (international or cn) for the given Z.AI -// gateway. Eyrie reads provider state directly without process-env mutation. -func SetZAIRegion(providerID, region string) error { - return SetGatewayRegion(providerID, region) -} - -// ZAIRegionLabel returns the saved region label or "". -func ZAIRegionLabel(providerID string) string { - return GatewayRegionLabel(providerID) -} diff --git a/internal/engine/agent_reexports.go b/internal/engine/agent_reexports.go index ed6b297e..78421786 100644 --- a/internal/engine/agent_reexports.go +++ b/internal/engine/agent_reexports.go @@ -4,20 +4,14 @@ package engine import ( - "context" - "github.com/GrayCodeAI/graycode-cli/internal/engine/agent" ) type ( - SubAgentMode = agent.SubAgentMode - SubAgentConfig = agent.SubAgentConfig - SubAgentBudget = agent.SubAgentBudget - // Deprecated: use Session.SpawnController() and BackgroundAgentManager - // (taskruntime-backed) instead. BackgroundAgentPool is retained for - // compatibility with older callers and tests. - BackgroundAgentPool = agent.BackgroundAgentPool - BackgroundResult = agent.BackgroundResult + SubAgentMode = agent.SubAgentMode + SubAgentConfig = agent.SubAgentConfig + SubAgentBudget = agent.SubAgentBudget + BackgroundResult = agent.BackgroundResult ) const ( @@ -46,16 +40,3 @@ func FilterToolsForMode(mode SubAgentMode, available []string) []string { } func DefaultTurnsForMode(mode SubAgentMode) int { return agent.DefaultTurnsForMode(mode) } func IsReadOnlyMode(mode SubAgentMode) bool { return agent.IsReadOnlyMode(mode) } - -// Deprecated: prefer Session.SpawnController().SpawnBackground for async -// sub-agents. Retained for compatibility. -func NewBackgroundAgentPool() *BackgroundAgentPool { return agent.NewBackgroundAgentPool() } - -// Deprecated: prefer Session.SpawnController().SpawnBackground for async -// sub-agents. Retained for compatibility. -func NewBackgroundAgentPoolWithContext(ctx context.Context) *BackgroundAgentPool { - return agent.NewBackgroundAgentPoolWithContext(ctx) -} - -// Deprecated: prefer SpawnController for background result formatting. -func FormatResults(results []BackgroundResult) string { return agent.FormatResults(results) }