Skip to content

Commit 623b511

Browse files
committed
fix: harden schedule atomic writes against symlink attacks
writeJSONAtomic previously used a predictable temp path (path + ".tmp") and os.WriteFile, which follows symlinks. Replace it with internal/fsatomic.WriteFile, which uses a random temp name with O_EXCL, fsyncs data and directory, and renames over the target so a swapped-in symlink is replaced rather than followed. - Update internal/schedule/store.go to delegate to fsatomic.WriteFile. - Add TestAtomicWrite_SymlinkTargetReplaced regression test. - Update coverage tests that relied on the old predictable temp path to use read-only directories instead. - Update docs/SECURITY.md and AGENTS.md.
1 parent d384e26 commit 623b511

5 files changed

Lines changed: 73 additions & 20 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
134134
- **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.
135135
- **Project config sensitive-field rejection** (`internal/config/loader.go`) — `./odek.json` is untrusted, so `base_url`, `api_key`, `system`, and the `dangerous` section set there are ignored (with stderr warnings). These can only be configured from operator-controlled sources: `~/.odek/config.json`, `ODEK_*` env vars, or CLI flags.
136136
- **MCP subprocess environment sanitisation** (`internal/mcpclient/client.go`) — MCP server children receive only a minimal allowlist of safe environment variables plus explicit `env` overrides. Keys matching secret patterns (`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, etc.) are stripped, preventing a compromised or malicious MCP server from reading parent secrets.
137+
- **Schedule atomic-write hardening** (`internal/schedule/store.go` + `internal/fsatomic`) — schedule file writes now use `fsatomic.WriteFile`, which creates a random temp file with `O_EXCL`, fsyncs data and directory, and renames over the target. A swapped-in symlink is replaced rather than followed, closing the symlink-override attack on `schedules.json` / `schedule-state.json`.
137138
- **Telegram photo caption wrapping** (`cmd/odek/telegram.go`) — photo captions cross the Telegram trust boundary, so they are wrapped as untrusted content both when passed to the local vision model and when injected into the main agent's user message.
138139
- **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.
139140

docs/SECURITY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,10 @@ These fields can only be set from operator-controlled sources: `~/.odek/config.j
312312

313313
MCP server subprocesses no longer inherit the full odek process environment. They receive only a minimal allowlist of safe variables (e.g. `PATH`, `HOME`, `LANG`, `TMPDIR`) plus any explicit `env` overrides from the server config. Keys matching secret patterns — `*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_CREDENTIAL`, `*_PRIVATE_KEY`, etc. — are stripped even when listed in `env`. This prevents a compromised or malicious MCP server from reading secrets loaded from `~/.odek/secrets.env` or other provider keys that were present in the parent environment.
314314

315+
### 20. Schedule file atomic-write hardening
316+
317+
Schedule persistence (`schedules.json` and `schedule-state.json`) now writes through `internal/fsatomic.WriteFile`. It creates a uniquely-named temp file with `O_EXCL` (so a pre-created symlink cannot be opened), fsyncs the data and parent directory, and atomically renames over the target. This means a swapped-in symlink is replaced rather than followed, closing the symlink-override attack where an attacker points `schedules.json.tmp` or `schedule-state.json.tmp` at sensitive files.
318+
315319
### YOLO mode
316320

317321
```json

internal/schedule/coverage_test.go

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -88,19 +88,21 @@ func TestLoadState_Error(t *testing.T) {
8888

8989
// ── saveDoc / writeJSONAtomic error paths ─────────────────────────────────
9090

91-
// makeTmpDir creates a directory at "<file>.tmp" so writeJSONAtomic's WriteFile
92-
// to that temp path fails (it can't write a file over a directory).
93-
func makeTmpDir(t *testing.T, path string) {
91+
// makeReadOnly makes dir read-only so fsatomic.WriteFile's temp-file creation
92+
// fails, exercising the save error path.
93+
func makeReadOnly(t *testing.T, dir string) func() {
9494
t.Helper()
95-
if err := os.MkdirAll(path+".tmp", 0755); err != nil {
96-
t.Fatalf("mkdir %s.tmp: %v", path, err)
95+
if err := os.Chmod(dir, 0500); err != nil {
96+
t.Fatalf("chmod %s: %v", dir, err)
9797
}
98+
return func() { os.Chmod(dir, 0755) }
9899
}
99100

100101
func TestAdd_SaveDocError(t *testing.T) {
101102
dir := t.TempDir()
102103
st, _ := NewStoreAt(dir)
103-
makeTmpDir(t, filepath.Join(dir, schedulesFile))
104+
cleanup := makeReadOnly(t, dir)
105+
defer cleanup()
104106
if _, err := st.Add(sampleJob()); err == nil {
105107
t.Error("Add should fail when the definitions file can't be written")
106108
}
@@ -113,7 +115,8 @@ func TestRemove_SaveDocError(t *testing.T) {
113115
if err != nil {
114116
t.Fatalf("Add: %v", err)
115117
}
116-
makeTmpDir(t, filepath.Join(dir, schedulesFile))
118+
cleanup := makeReadOnly(t, dir)
119+
defer cleanup()
117120
if err := st.Remove(a.ID); err == nil {
118121
t.Error("Remove should fail when the definitions file can't be rewritten")
119122
}
@@ -170,11 +173,18 @@ func TestWriteJSONAtomic_Errors(t *testing.T) {
170173
t.Error("writeJSONAtomic should fail to marshal a channel")
171174
}
172175

173-
// Write error: the temp path is a directory.
174-
wpath := filepath.Join(dir, "w.json")
175-
makeTmpDir(t, wpath)
176+
// Write error: the directory is read-only, so temp-file creation fails.
177+
wdir := filepath.Join(dir, "wro")
178+
if err := os.Mkdir(wdir, 0755); err != nil {
179+
t.Fatal(err)
180+
}
181+
if err := os.Chmod(wdir, 0500); err != nil {
182+
t.Fatal(err)
183+
}
184+
defer os.Chmod(wdir, 0755)
185+
wpath := filepath.Join(wdir, "w.json")
176186
if err := writeJSONAtomic(wpath, map[string]int{"a": 1}); err == nil {
177-
t.Error("writeJSONAtomic should fail when the temp path is a directory")
187+
t.Error("writeJSONAtomic should fail when the directory is not writable")
178188
}
179189

180190
// Rename error: the destination is a non-empty directory, so rename of the
@@ -335,7 +345,8 @@ func TestReconcile_SkipSaveStateError(t *testing.T) {
335345
t.Fatal(err)
336346
}
337347
// Break state writes so the skip-record persistence fails (logged, not fatal).
338-
makeTmpDir(t, filepath.Join(dir, stateFile))
348+
cleanup := makeReadOnly(t, dir)
349+
defer cleanup()
339350
s := New(st, &fakeRunner{}, &fakeDeliverer{}, Options{})
340351
now := time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC)
341352
s.reconcile(now) // exercises the SaveState-error log branch
@@ -353,7 +364,8 @@ func TestExecute_SaveStateError(t *testing.T) {
353364
t0 := time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC)
354365
s.reconcile(t0)
355366
// Break state writes; the run still completes, the SaveState error is logged.
356-
makeTmpDir(t, filepath.Join(dir, stateFile))
367+
cleanup := makeReadOnly(t, dir)
368+
defer cleanup()
357369
s.fireDue(context.Background(), s.peekNext(job.ID))
358370
s.Wait()
359371
}

internal/schedule/store.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"sync"
1212
"syscall"
1313
"time"
14+
15+
"github.com/BackendStack21/odek/internal/fsatomic"
1416
)
1517

1618
// File names under ~/.odek.
@@ -350,20 +352,18 @@ func readJSON(path string, v any) error {
350352
// so a reader never observes a half-written file and a swapped-in symlink is
351353
// replaced rather than followed. Files are 0600 since tasks may reference
352354
// secrets.
355+
//
356+
// The actual atomic write is delegated to internal/fsatomic, which uses a
357+
// random temp name with O_EXCL (so a pre-created symlink cannot be opened)
358+
// and fsyncs both the data and the parent directory before returning.
353359
func writeJSONAtomic(path string, v any) error {
354360
data, err := json.MarshalIndent(v, "", " ")
355361
if err != nil {
356362
return fmt.Errorf("schedule: marshal %s: %w", filepath.Base(path), err)
357363
}
358-
tmp := path + ".tmp"
359-
if err := os.WriteFile(tmp, data, 0600); err != nil {
360-
os.Remove(tmp)
364+
if err := fsatomic.WriteFile(path, data, 0600); err != nil {
361365
return fmt.Errorf("schedule: write %s: %w", filepath.Base(path), err)
362366
}
363-
if err := os.Rename(tmp, path); err != nil {
364-
os.Remove(tmp)
365-
return fmt.Errorf("schedule: rename %s: %w", filepath.Base(path), err)
366-
}
367367
return nil
368368
}
369369

internal/schedule/store_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,42 @@ func TestAtomicWrite_NoTempLeftover(t *testing.T) {
271271
}
272272
}
273273

274+
func TestAtomicWrite_SymlinkTargetReplaced(t *testing.T) {
275+
dir := t.TempDir()
276+
277+
// Simulate an attacker swapping schedules.json with a symlink to a sensitive
278+
// file. The write must replace the symlink (the directory entry), not follow
279+
// it and overwrite the sensitive target.
280+
decoy := filepath.Join(dir, "decoy-sensitive.txt")
281+
if err := os.WriteFile(decoy, []byte("original-secret"), 0600); err != nil {
282+
t.Fatal(err)
283+
}
284+
target := filepath.Join(dir, schedulesFile)
285+
if err := os.Symlink(decoy, target); err != nil {
286+
t.Fatal(err)
287+
}
288+
289+
if err := writeJSONAtomic(target, scheduleDoc{Version: 1, Jobs: []Job{sampleJob()}}); err != nil {
290+
t.Fatalf("writeJSONAtomic: %v", err)
291+
}
292+
293+
got, err := os.ReadFile(decoy)
294+
if err != nil {
295+
t.Fatal(err)
296+
}
297+
if string(got) != "original-secret" {
298+
t.Errorf("symlink target was overwritten: %q", string(got))
299+
}
300+
301+
info, err := os.Lstat(target)
302+
if err != nil {
303+
t.Fatal(err)
304+
}
305+
if info.Mode()&os.ModeSymlink != 0 {
306+
t.Error("target is still a symlink; symlink was followed instead of replaced")
307+
}
308+
}
309+
274310
// ── Concurrency (run with -race) ────────────────────────────────────────
275311

276312
func TestConcurrentStateWrites(t *testing.T) {

0 commit comments

Comments
 (0)