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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion cmd/odek/repl_editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
64 changes: 64 additions & 0 deletions cmd/odek/repl_editor_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
6 changes: 6 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading