From e26e89ffe859af8abf709d7c4ced297608d69f26 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 19 Jul 2026 10:37:20 +0200 Subject: [PATCH] L10: harden REPL history file permissions to 0600 - cmd/odek/repl_editor.go: chmod existing repl_history to 0600 and open new files with O_WRONLY|O_CREATE|O_TRUNC and mode 0600, replacing the previous os.Create default permissions. - cmd/odek/repl_editor_test.go: regression tests for restricted creation and hardening of existing world-readable files. - docs/SECURITY.md + AGENTS.md: document L10 mitigation. --- AGENTS.md | 1 + cmd/odek/repl_editor.go | 4 ++- cmd/odek/repl_editor_test.go | 64 ++++++++++++++++++++++++++++++++++++ docs/SECURITY.md | 6 ++++ 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 cmd/odek/repl_editor_test.go diff --git a/AGENTS.md b/AGENTS.md index 36f2df21..4d080c9c 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 6ded4fa8..2123a6fa 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 00000000..b1715f92 --- /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 342001de..99357331 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.