Skip to content
Merged
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
18 changes: 9 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 23 additions & 14 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions cmd/chat_config_panel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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()
Expand Down
23 changes: 14 additions & 9 deletions docs/user-guide/05-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

---

Expand Down Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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
{
Expand Down
19 changes: 0 additions & 19 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
27 changes: 0 additions & 27 deletions internal/auth/auth_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 0 additions & 11 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
16 changes: 0 additions & 16 deletions internal/config/xiaomi_setup.go
Original file line number Diff line number Diff line change
@@ -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)
}
20 changes: 8 additions & 12 deletions internal/config/xiaomi_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -21,39 +21,35 @@ 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("")
if loaded.XiaomiMimoTokenPlanRegion != "sgp" {
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")
}
}
16 changes: 0 additions & 16 deletions internal/config/zai_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
27 changes: 4 additions & 23 deletions internal/engine/agent_reexports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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) }
4 changes: 2 additions & 2 deletions internal/flags/y0.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down