Skip to content

Commit bfbf8e4

Browse files
authored
L10: harden REPL history file permissions to 0600 (#82)
- 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.
1 parent a0700ce commit bfbf8e4

4 files changed

Lines changed: 74 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
151151
- **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.
152152
- **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.
153153
- **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.
154+
- **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.
154155
- **Serve sandbox default-on**`odek serve` enables `--sandbox` automatically unless `--no-sandbox` is passed.
155156
- **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`.
156157
- **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.

cmd/odek/repl_editor.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,9 @@ func (h *replHistory) persist() {
488488
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
489489
return
490490
}
491-
f, err := os.Create(path)
491+
// Harden any existing history file created with looser permissions.
492+
_ = os.Chmod(path, 0600)
493+
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
492494
if err != nil {
493495
return
494496
}

cmd/odek/repl_editor_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
// TestReplHistory_Persist_CreatesRestricted verifies that the REPL history
10+
// file is created with 0600 permissions.
11+
func TestReplHistory_Persist_CreatesRestricted(t *testing.T) {
12+
origHome := os.Getenv("HOME")
13+
home := t.TempDir()
14+
os.Setenv("HOME", home)
15+
defer os.Setenv("HOME", origHome)
16+
17+
h := newReplHistory()
18+
path := filepath.Join(home, ".odek", historyFilename)
19+
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
20+
t.Fatal(err)
21+
}
22+
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
23+
t.Fatal(err)
24+
}
25+
h.Load(path)
26+
h.Add("secret-api-key")
27+
28+
fi, err := os.Stat(path)
29+
if err != nil {
30+
t.Fatalf("stat history file: %v", err)
31+
}
32+
if fi.Mode().Perm() != 0600 {
33+
t.Errorf("history file mode = %o, want 0600", fi.Mode().Perm())
34+
}
35+
}
36+
37+
// TestReplHistory_Persist_HardensExisting verifies that an existing
38+
// world-readable history file is chmod'd to 0600 on the next persist.
39+
func TestReplHistory_Persist_HardensExisting(t *testing.T) {
40+
origHome := os.Getenv("HOME")
41+
home := t.TempDir()
42+
os.Setenv("HOME", home)
43+
defer os.Setenv("HOME", origHome)
44+
45+
path := filepath.Join(home, ".odek", historyFilename)
46+
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
47+
t.Fatal(err)
48+
}
49+
if err := os.WriteFile(path, []byte("old\n"), 0644); err != nil {
50+
t.Fatal(err)
51+
}
52+
53+
h := newReplHistory()
54+
h.Load(path)
55+
h.Add("new-secret")
56+
57+
fi, err := os.Stat(path)
58+
if err != nil {
59+
t.Fatalf("stat history file: %v", err)
60+
}
61+
if fi.Mode().Perm() != 0600 {
62+
t.Errorf("history file mode = %o, want 0600", fi.Mode().Perm())
63+
}
64+
}

docs/SECURITY.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,12 @@ Now:
661661

662662
`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.
663663

664+
### 39m. REPL history file permissions
665+
666+
`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.
667+
668+
`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.
669+
664670
### 40. `/api/resources` result limit cap
665671

666672
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.

0 commit comments

Comments
 (0)