diff --git a/AGENTS.md b/AGENTS.md index 36f2df2..4d080c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU - **Session file size cap** (`internal/session/session.go`) — session files larger than 32 MiB are rejected by `Load()` to prevent OOM from tampered or corrupted transcripts. - **Session ID write-path validation** (`internal/session/session.go`) — `saveLocked` validates `sess.ID` with `ValidateSessionID` before computing the destination path, and `Load` checks that the embedded ID matches the filename it was loaded from. A planted session file with an attacker-controlled `"id"` field can no longer redirect `Append`/`Save` to write outside the session directory. - **Skill file size cap** (`internal/skills/loader.go`) — `SKILL.md` files larger than 1 MiB are skipped so a malicious project cannot OOM the process at startup or bloat the system prompt. +- **REPL history file permissions** (`cmd/odek/repl_editor.go`) — `~/.odek/repl_history` is now created/hardened with `0600` permissions (and any existing world-readable file is `chmod`d on the next persist), preventing local users from reading pasted API keys, tokens, and URLs from the REPL history. - **Serve sandbox default-on** — `odek serve` enables `--sandbox` automatically unless `--no-sandbox` is passed. - **Sandbox volume confinement** (`internal/sandbox/sandbox.go`) — extra `--sandbox-volume` host paths must resolve to a location under the working directory, cannot contain `..` or symlink escapes, and cannot match sensitive prefixes such as `/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/home`, `/var`, `/run`, or `/var/run/docker.sock`. - **Sandbox read-only enforcement** (`cmd/odek/sandbox_file.go` + `cmd/odek/file_tool.go` + `cmd/odek/perf_tools.go`) — when a sandbox container is active, `write_file`, `patch`, and `batch_patch` translate host paths to `/workspace/...` and copy data into the container with `docker cp`, so a read-only workspace mount (`--sandbox-readonly`) is enforced for the agent's own file tools. diff --git a/cmd/odek/repl_editor.go b/cmd/odek/repl_editor.go index 6ded4fa..2123a6f 100644 --- a/cmd/odek/repl_editor.go +++ b/cmd/odek/repl_editor.go @@ -488,7 +488,9 @@ func (h *replHistory) persist() { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return } - f, err := os.Create(path) + // Harden any existing history file created with looser permissions. + _ = os.Chmod(path, 0600) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return } diff --git a/cmd/odek/repl_editor_test.go b/cmd/odek/repl_editor_test.go new file mode 100644 index 0000000..b1715f9 --- /dev/null +++ b/cmd/odek/repl_editor_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestReplHistory_Persist_CreatesRestricted verifies that the REPL history +// file is created with 0600 permissions. +func TestReplHistory_Persist_CreatesRestricted(t *testing.T) { + origHome := os.Getenv("HOME") + home := t.TempDir() + os.Setenv("HOME", home) + defer os.Setenv("HOME", origHome) + + h := newReplHistory() + path := filepath.Join(home, ".odek", historyFilename) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte{}, 0644); err != nil { + t.Fatal(err) + } + h.Load(path) + h.Add("secret-api-key") + + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("stat history file: %v", err) + } + if fi.Mode().Perm() != 0600 { + t.Errorf("history file mode = %o, want 0600", fi.Mode().Perm()) + } +} + +// TestReplHistory_Persist_HardensExisting verifies that an existing +// world-readable history file is chmod'd to 0600 on the next persist. +func TestReplHistory_Persist_HardensExisting(t *testing.T) { + origHome := os.Getenv("HOME") + home := t.TempDir() + os.Setenv("HOME", home) + defer os.Setenv("HOME", origHome) + + path := filepath.Join(home, ".odek", historyFilename) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("old\n"), 0644); err != nil { + t.Fatal(err) + } + + h := newReplHistory() + h.Load(path) + h.Add("new-secret") + + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("stat history file: %v", err) + } + if fi.Mode().Perm() != 0600 { + t.Errorf("history file mode = %o, want 0600", fi.Mode().Perm()) + } +} diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 342001d..9935733 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -661,6 +661,12 @@ Now: `saveLocked` now calls `ValidateSessionID` before computing the filesystem path, and `Load` checks that the ID inside the file matches the filename it was loaded from. Any mismatch aborts the operation instead of following the attacker-controlled embedded ID. +### 39m. REPL history file permissions + +`cmd/odek/repl_editor.go` created `~/.odek/repl_history` with `os.Create`, which uses mode `0666` masked by the process umask. On systems with a permissive umask, the file could be world-readable, leaking pasted API keys, tokens, and URLs to other local users. + +`persist()` now hardens any existing file with `os.Chmod(path, 0600)` and opens the file with `os.O_WRONLY|os.O_CREATE|os.O_TRUNC` and mode `0600`, matching the permission model already applied to sessions, audit logs, and Telegram logs. + ### 40. `/api/resources` result limit cap The `/api/resources?q=...&limit=N` autocomplete endpoint previously accepted any positive `limit` value. It is now capped to 100 results both in the HTTP handler and in `Registry.Search`. This prevents a prompt-injected or attacker-forged request from forcing an unbounded directory walk and returning a multi-megabyte JSON response.