Skip to content

Commit 90a0477

Browse files
jkyberneeesclaude
andauthored
fix(security): close ~/.odek/ write carve-out privilege escalation (#30)
* fix(security): close ~/.odek/ write carve-out privilege escalation A confined agent could rewrite its own trust anchors through the auto-allowed LocalWrite path: confineToCWD carved out all of ~/.odek/, and ~/.odek/config.json classified as LocalWrite — so a prompt-injected sub-agent could disable the sandbox or enable YOLO mode for the next run, or drop an auto-loaded SKILL.md. Shell rc files also fell through to auto-allowed LocalWrite. - ClassifyPath now escalates ~/.odek/config.json, ~/.odek/secrets.env, ~/.odek/skills/, and shell rc/profile files in $HOME to SystemWrite (prompt/deny). Crontab locations were already SystemWrite via the /etc, /var, /usr prefixes — locked in with tests. - confineToCWD excludes those trust anchors from the ~/.odek/ carve-out (skills are legitimately written via skill_save/skill_patch, not the generic file tools). - patch and batch_patch get the same restrictToCWD confinement write_file already had; both are now wired with it in builtinTools. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(danger): update ClassifyPath doc comment with new classification rules Axis 2.9 auto-remediation (vprotocol): the exported ClassifyPath function gained new SystemWrite cases (odek trust anchors, shell rc files) but the function-level comment still described only the old rules. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0b7607e commit 90a0477

7 files changed

Lines changed: 354 additions & 12 deletions

File tree

cmd/odek/file_tool.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,7 @@ func (t *searchFilesTool) searchFiles(args searchFilesArgs) (string, error) {
532532
type patchTool struct {
533533
dangerousConfig danger.DangerousConfig
534534
trustedClasses map[danger.RiskClass]bool
535+
restrictToCWD bool // when true, reject paths escaping the working directory
535536
}
536537

537538
func (t *patchTool) Name() string { return "patch" }
@@ -592,6 +593,16 @@ func (t *patchTool) Call(argsJSON string) (string, error) {
592593
return jsonError("old_string is required")
593594
}
594595

596+
// Path confinement: same as write_file — reject paths that escape the
597+
// working directory via ".." traversal or absolute paths.
598+
if t.restrictToCWD {
599+
resolved, err := confineToCWD(args.Path)
600+
if err != nil {
601+
return jsonError(err.Error())
602+
}
603+
args.Path = resolved
604+
}
605+
595606
// Security: classify and check patch operation
596607
risk := danger.ClassifyPath(args.Path)
597608
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
@@ -758,11 +769,20 @@ func confineToCWD(path string) (string, error) {
758769
}
759770

760771
// Allow paths under ~/.odek/ even when outside CWD — the agent
761-
// frequently writes skills, memory, and config to this directory.
772+
// frequently writes memory and other state to this directory. The
773+
// carve-out deliberately EXCLUDES odek's trust anchors (config.json,
774+
// secrets.env, skills/): a confined agent that can rewrite its own
775+
// config can disable the sandbox or enable YOLO mode on the next run,
776+
// and a dropped SKILL.md is auto-loaded into future prompts. Skills
777+
// are legitimately written through the dedicated skill_save/skill_patch
778+
// tools, not the generic file tools.
762779
home, homeErr := os.UserHomeDir()
763780
if homeErr == nil {
764781
odekPrefix := home + "/.odek/"
765782
if strings.HasPrefix(abs, odekPrefix) {
783+
if isProtectedOdekPath(strings.TrimPrefix(abs, odekPrefix)) {
784+
return "", fmt.Errorf("path %q is a protected odek configuration path and cannot be written by file tools", path)
785+
}
766786
return abs, nil
767787
}
768788
}
@@ -775,6 +795,15 @@ func confineToCWD(path string) (string, error) {
775795
return abs, nil
776796
}
777797

798+
// isProtectedOdekPath reports whether rel (a path relative to ~/.odek/,
799+
// already cleaned by confineToCWD) names one of odek's trust anchors that
800+
// must not be writable through the generic file tools. Keep in sync with
801+
// the SystemWrite escalation in danger.ClassifyPath.
802+
func isProtectedOdekPath(rel string) bool {
803+
return rel == "config.json" || rel == "secrets.env" ||
804+
rel == "skills" || strings.HasPrefix(rel, "skills"+string(filepath.Separator))
805+
}
806+
778807
func truncateDiff(s string, maxLen int) string {
779808
// Take first line for diff display
780809
firstLine := strings.SplitN(s, "\n", 2)[0]

cmd/odek/file_tool_test.go

Lines changed: 122 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -770,20 +770,60 @@ func TestConfineToCWD_DoubleDotEscape(t *testing.T) {
770770
}
771771
}
772772

773-
// TestConfineToCWD_AllowsOdekDir verifies that paths under ~/.odek/
774-
// are allowed by confineToCWD even when they are outside the project
775-
// CWD. The agent frequently needs to write skills, memory, and config
776-
// to ~/.odek/ — blocking these forces wasteful shell workarounds.
773+
// TestConfineToCWD_AllowsOdekDir verifies that non-sensitive paths under
774+
// ~/.odek/ (memory, state) are allowed by confineToCWD even when they are
775+
// outside the project CWD — blocking these forces wasteful shell workarounds.
777776
func TestConfineToCWD_AllowsOdekDir(t *testing.T) {
778777
home, err := os.UserHomeDir()
779778
if err != nil {
780779
t.Fatal(err)
781780
}
782-
odekPath := home + "/.odek/skills/test-skill/SKILL.md"
781+
for _, p := range []string{
782+
home + "/.odek/memory/episodes.json",
783+
home + "/.odek/sessions/abc.json",
784+
home + "/.odek/notes.md",
785+
} {
786+
if _, err := confineToCWD(p); err != nil {
787+
t.Errorf("non-sensitive ~/.odek/ path %q should be allowed, got: %v", p, err)
788+
}
789+
}
790+
}
791+
792+
// TestConfineToCWD_RejectsProtectedOdekPaths verifies that odek's trust
793+
// anchors are EXCLUDED from the ~/.odek/ carve-out. A confined agent that
794+
// can rewrite its own config.json can disable the sandbox or enable YOLO
795+
// mode on the next run; a dropped SKILL.md is auto-loaded into future
796+
// prompts; secrets.env is injected into the process environment. Skills
797+
// writes go through the dedicated skill_save/skill_patch tools instead.
798+
func TestConfineToCWD_RejectsProtectedOdekPaths(t *testing.T) {
799+
home, err := os.UserHomeDir()
800+
if err != nil {
801+
t.Fatal(err)
802+
}
803+
for _, p := range []string{
804+
home + "/.odek/config.json",
805+
home + "/.odek/secrets.env",
806+
home + "/.odek/skills/evil/SKILL.md",
807+
home + "/.odek/skills",
808+
// ".." traversal inside the carve-out must not reach the anchors
809+
home + "/.odek/memory/../config.json",
810+
} {
811+
if _, err := confineToCWD(p); err == nil {
812+
t.Errorf("protected odek path %q should be rejected by confineToCWD", p)
813+
}
814+
}
815+
}
783816

784-
_, err = confineToCWD(odekPath)
817+
// TestConfineToCWD_SkillsPrefixNotOverbroad verifies the skills/ exclusion
818+
// matches the directory itself, not sibling names sharing the prefix.
819+
func TestConfineToCWD_SkillsPrefixNotOverbroad(t *testing.T) {
820+
home, err := os.UserHomeDir()
785821
if err != nil {
786-
t.Errorf("~/.odek/ paths should be allowed by confineToCWD, got: %v", err)
822+
t.Fatal(err)
823+
}
824+
p := home + "/.odek/skills-backup/notes.md"
825+
if _, err := confineToCWD(p); err != nil {
826+
t.Errorf("%q is not under skills/ and should be allowed, got: %v", p, err)
787827
}
788828
}
789829

@@ -1059,6 +1099,81 @@ func TestWriteFile_PathConfinementAllow(t *testing.T) {
10591099
}
10601100
}
10611101

1102+
func TestPatch_PathConfinementReject(t *testing.T) {
1103+
tool := &patchTool{
1104+
restrictToCWD: true,
1105+
}
1106+
// ../ escape should be rejected before any classification or file I/O
1107+
result := callJSON(t, tool, `{"path":"../escape-test.txt","old_string":"a","new_string":"b"}`)
1108+
var r struct {
1109+
Error string `json:"error"`
1110+
}
1111+
mustUnmarshal(t, result, &r)
1112+
if r.Error == "" {
1113+
t.Fatal("expected error for path confinement violation")
1114+
}
1115+
if !strings.Contains(r.Error, "escapes the working directory") {
1116+
t.Errorf("error should mention escaping, got: %s", r.Error)
1117+
}
1118+
}
1119+
1120+
func TestPatch_PathConfinementAllow(t *testing.T) {
1121+
dir := t.TempDir()
1122+
path := filepath.Join(dir, "patchme.txt")
1123+
if err := os.WriteFile(path, []byte("hello world"), 0644); err != nil {
1124+
t.Fatal(err)
1125+
}
1126+
1127+
origDir, _ := os.Getwd()
1128+
os.Chdir(dir)
1129+
defer os.Chdir(origDir)
1130+
1131+
tool := &patchTool{
1132+
restrictToCWD: true,
1133+
}
1134+
result := callJSON(t, tool, `{"path":"patchme.txt","old_string":"world","new_string":"there"}`)
1135+
var r struct {
1136+
Success bool `json:"success"`
1137+
Error string `json:"error,omitempty"`
1138+
}
1139+
mustUnmarshal(t, result, &r)
1140+
if !r.Success {
1141+
t.Fatalf("expected success, got error: %s", r.Error)
1142+
}
1143+
1144+
data, _ := os.ReadFile(path)
1145+
if string(data) != "hello there" {
1146+
t.Errorf("content = %q, want %q", string(data), "hello there")
1147+
}
1148+
}
1149+
1150+
// TestPatch_RejectsProtectedOdekPaths verifies the patch tool cannot edit
1151+
// odek's trust anchors through the ~/.odek/ carve-out — closing the
1152+
// escalation where a confined agent patches its own config.json to
1153+
// disable the sandbox on the next run.
1154+
func TestPatch_RejectsProtectedOdekPaths(t *testing.T) {
1155+
home, err := os.UserHomeDir()
1156+
if err != nil {
1157+
t.Skip("no home dir")
1158+
}
1159+
tool := &patchTool{
1160+
restrictToCWD: true,
1161+
}
1162+
for _, p := range []string{
1163+
home + "/.odek/config.json",
1164+
home + "/.odek/skills/x/SKILL.md",
1165+
} {
1166+
result := callJSON(t, tool, `{"path":"`+p+`","old_string":"a","new_string":"b"}`)
1167+
var r struct {
1168+
Error string `json:"error"`
1169+
}
1170+
mustUnmarshal(t, result, &r)
1171+
if r.Error == "" {
1172+
t.Errorf("patch to %q should be rejected", p)
1173+
}
1174+
}
1175+
}
1176+
10621177
func TestWriteFile_SecurityDenied(t *testing.T) {
10631178
// Test that a deny configuration blocks the write
10641179
action := "deny"

cmd/odek/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,11 +1137,11 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
11371137
&readFileTool{dangerousConfig: dc},
11381138
&writeFileTool{dangerousConfig: dc, restrictToCWD: true},
11391139
&searchFilesTool{dangerousConfig: dc},
1140-
&patchTool{dangerousConfig: dc},
1140+
&patchTool{dangerousConfig: dc, restrictToCWD: true},
11411141
&batchReadTool{dangerousConfig: dc},
11421142
&globTool{dangerousConfig: dc},
11431143
&fileInfoTool{dangerousConfig: dc},
1144-
&batchPatchTool{dangerousConfig: dc},
1144+
&batchPatchTool{dangerousConfig: dc, restrictToCWD: true},
11451145
&parallelShellTool{dangerousConfig: dc, approver: approver},
11461146
newHTTPBatchTool(dc),
11471147
&mathEvalTool{},

cmd/odek/perf_tools.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const maxBatchPatches = 10
4747

4848
type batchPatchTool struct {
4949
dangerousConfig danger.DangerousConfig
50+
restrictToCWD bool // when true, reject paths escaping the working directory
5051
}
5152

5253
func (t *batchPatchTool) Name() string { return "batch_patch" }
@@ -129,6 +130,19 @@ func (t *batchPatchTool) Call(argsJSON string) (result string, err error) {
129130
continue
130131
}
131132

133+
// Path confinement: same as write_file/patch — reject paths that
134+
// escape the working directory.
135+
if t.restrictToCWD {
136+
resolved, err := confineToCWD(p.Path)
137+
if err != nil {
138+
entry.Error = err.Error()
139+
results[idx] = entry
140+
continue
141+
}
142+
p.Path = resolved
143+
entry.Path = resolved
144+
}
145+
132146
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
133147
Name: "batch_patch", Resource: p.Path, Risk: danger.ClassifyPath(p.Path),
134148
}, nil); err != nil {

cmd/odek/perf_tools_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,50 @@ func TestBatchPatch_Basic(t *testing.T) {
4444
}
4545
}
4646

47+
// TestBatchPatch_PathConfinement verifies batch_patch enforces the same
48+
// restrictToCWD confinement as write_file/patch: escapes are rejected
49+
// per-entry, and odek's trust anchors are excluded from the ~/.odek/
50+
// carve-out.
51+
func TestBatchPatch_PathConfinement(t *testing.T) {
52+
dir := t.TempDir()
53+
path := filepath.Join(dir, "ok.txt")
54+
os.WriteFile(path, []byte("hello world\n"), 0644)
55+
56+
origDir, _ := os.Getwd()
57+
os.Chdir(dir)
58+
defer os.Chdir(origDir)
59+
60+
home, _ := os.UserHomeDir()
61+
tool := &batchPatchTool{restrictToCWD: true}
62+
args := fmt.Sprintf(`{"patches":[
63+
{"path":"ok.txt","old_string":"world","new_string":"there"},
64+
{"path":"../escape.txt","old_string":"a","new_string":"b"},
65+
{"path":"%s/.odek/config.json","old_string":"a","new_string":"b"}
66+
]}`, home)
67+
result := callJSON(t, tool, args)
68+
69+
var r struct {
70+
Results []struct {
71+
Success bool `json:"success"`
72+
Error string `json:"error"`
73+
} `json:"results"`
74+
}
75+
mustUnmarshal(t, result, &r)
76+
77+
if len(r.Results) != 3 {
78+
t.Fatalf("Results = %d, want 3", len(r.Results))
79+
}
80+
if !r.Results[0].Success {
81+
t.Errorf("in-CWD patch should succeed: %s", r.Results[0].Error)
82+
}
83+
if r.Results[1].Success || !strings.Contains(r.Results[1].Error, "escapes the working directory") {
84+
t.Errorf("escape should be rejected, got success=%v err=%q", r.Results[1].Success, r.Results[1].Error)
85+
}
86+
if r.Results[2].Success || r.Results[2].Error == "" {
87+
t.Errorf("~/.odek/config.json should be rejected, got success=%v err=%q", r.Results[2].Success, r.Results[2].Error)
88+
}
89+
}
90+
4791
func TestBatchPatch_MultipleFiles(t *testing.T) {
4892
dir := t.TempDir()
4993
path1 := filepath.Join(dir, "a.txt")

internal/danger/classifier.go

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,18 @@ type ToolOperation struct {
136136
// ── Path-based classification ──────────────────────────────────────────
137137

138138
// ClassifyPath returns a RiskClass for a filesystem path.
139-
// /tmp/*, working directory → local_write; /etc/*, /root/* → system_write;
140-
// /boot/*, /dev/*, /sys/* → destructive; home sensitive dirs → system_write.
139+
//
140+
// Classification rules (highest wins):
141+
// - /boot, /dev, /proc, /sys, /mnt, /media → destructive
142+
// - /tmp, $TMPDIR → local_write
143+
// - /etc, /root, /var, /run, /lib, /usr → system_write
144+
// - $HOME/.ssh, .config, .gnupg, .aws, .kube, .docker, .gitconfig, .env → system_write
145+
// - $HOME/.odek/config.json, secrets.env, skills/ → system_write (odek trust anchors;
146+
// rewriting them can disable the sandbox or inject prompts on the next run)
147+
// - $HOME shell rc/profile files (.bashrc, .zshrc, .profile, .zshenv, etc.) → system_write
148+
// - everything else → local_write
149+
//
150+
// macOS: /private/{etc,var,tmp} are transparently normalised before matching.
141151
func ClassifyPath(path string) RiskClass {
142152
abs, err := filepath.Abs(path)
143153
if err != nil {
@@ -182,10 +192,40 @@ func ClassifyPath(path string) RiskClass {
182192
return SystemWrite
183193
}
184194
}
195+
// odek's own trust anchors. Rewriting ~/.odek/config.json can disable
196+
// the sandbox or set "action": "allow" (YOLO) for the next run; a
197+
// SKILL.md dropped under ~/.odek/skills/ is auto-loaded into future
198+
// prompts; secrets.env is injected into the process environment.
199+
// Auto-allowing these as LocalWrite would let a confined agent
200+
// escalate out of its own sandbox, so they classify as SystemWrite
201+
// (prompt/deny). Keep in sync with the carve-out exclusions in
202+
// cmd/odek/file_tool.go (isProtectedOdekPath).
203+
for _, sub := range []string{"/.odek/config.json", "/.odek/secrets.env", "/.odek/skills"} {
204+
if strings.HasPrefix(abs, home+sub) {
205+
return SystemWrite
206+
}
207+
}
208+
// Shell rc/profile files execute on the user's next shell start —
209+
// writing them is persistence/escalation, not a local file edit.
210+
if filepath.Dir(abs) == home && shellRCFiles[filepath.Base(abs)] {
211+
return SystemWrite
212+
}
185213
}
186214
return LocalWrite
187215
}
188216

217+
// shellRCFiles are dotfiles in $HOME that shells execute automatically on
218+
// startup/login. Writing any of them is code execution on the next shell,
219+
// so ClassifyPath escalates them to SystemWrite. Fish/nushell configs live
220+
// under ~/.config, which is already covered by the home-sensitive-dir list.
221+
var shellRCFiles = map[string]bool{
222+
".bashrc": true, ".bash_profile": true, ".bash_login": true,
223+
".bash_logout": true, ".bash_aliases": true, ".profile": true,
224+
".zshrc": true, ".zprofile": true, ".zshenv": true, ".zlogin": true,
225+
".zlogout": true, ".kshrc": true, ".cshrc": true, ".tcshrc": true,
226+
".login": true, ".logout": true,
227+
}
228+
189229
// ClassifyURL returns a RiskClass for a browser URL.
190230
// Internal IPs → system_write; external → network_egress.
191231
// Uses proper IP parsing (handles decimal, octal, hex, IPv6 compressed,

0 commit comments

Comments
 (0)