Skip to content

Commit cd1845d

Browse files
authored
Merge pull request #51 from BackendStack21/fix/c1-project-sandbox-approval
fix: require approval for project-level sandbox config (C-1)
2 parents b14771d + 656cd5e commit cd1845d

18 files changed

Lines changed: 607 additions & 3 deletions

AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,14 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
182182
- **Secrets.env permission gate** (`internal/config/loader.go`) — refuses to load `~/.odek/secrets.env` when it is group/world-readable, preventing local users from reading API keys injected into the environment.
183183
- **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.
184184

185+
### Security findings (`sec_findings.md`)
186+
187+
`sec_findings.md` at the repository root is the running security audit log. It is
188+
intentionally listed in `.gitignore` so that audit output and in-progress
189+
findings are not committed to the repository by default. Do not commit this
190+
file in pull requests unless you explicitly intend to publish a finalized
191+
audit snapshot.
192+
185193
### Platform Support
186194
CLI, REPL, Web UI, Telegram bot — all in a single binary.
187195

cmd/odek/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,6 +1179,9 @@ func run(args []string) error {
11791179
GuardScanToolOutputs: f.GuardScanToolOutputs,
11801180
GuardScanTelegram: f.GuardScanTelegram,
11811181
})
1182+
if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil {
1183+
return err
1184+
}
11821185

11831186
// Resolve @references and --ctx file attachments in the task
11841187
cwd, _ := os.Getwd()

cmd/odek/mcp.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ Flags:
5050

5151
// Load config
5252
resolved := config.LoadConfig(cliFlags)
53+
if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil {
54+
return err
55+
}
5356

5457
// Start agent loop (mcp)
5558
sbCfg := sandboxConfig{
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"os"
11+
"path/filepath"
12+
"strings"
13+
14+
"github.com/BackendStack21/odek/internal/config"
15+
"golang.org/x/term"
16+
)
17+
18+
// projectSandboxApprovalsFile is the persistent store for user-approved
19+
// project-level sandbox configurations. It lives under ~/.odek and is created
20+
// 0600.
21+
const projectSandboxApprovalsFile = "project_sandbox_approvals.json"
22+
23+
// approveProjectSandbox requires explicit operator approval before any
24+
// project-level ./odek.json sandbox knobs are applied. This closes the C-1
25+
// vector where a malicious repo exfiltrates host secrets via ${VAR}
26+
// interpolation in sandbox_env, pulls an attacker-controlled image, or widens
27+
// the container's network access.
28+
//
29+
// Approval can be granted in three ways:
30+
// 1. Set ODEK_APPROVE_PROJECT_SANDBOX=1 (useful for CI/non-interactive use).
31+
// 2. Answer the interactive prompt when running on a TTY.
32+
// 3. A prior approval for the same project/sandbox fingerprint is persisted
33+
// in ~/.odek/project_sandbox_approvals.json.
34+
//
35+
// If approval is required and cannot be obtained, approveProjectSandbox
36+
// returns an error and the command should abort before creating the sandbox.
37+
func approveProjectSandbox(resolved config.ResolvedConfig, stdin io.Reader, stdout io.Writer) error {
38+
isTTY := stdin == os.Stdin && term.IsTerminal(int(os.Stdin.Fd()))
39+
return approveProjectSandboxWithTTY(resolved, stdin, stdout, isTTY)
40+
}
41+
42+
// approveProjectSandboxWithTTY is the testable core of approveProjectSandbox.
43+
func approveProjectSandboxWithTTY(resolved config.ResolvedConfig, stdin io.Reader, stdout io.Writer, tty bool) error {
44+
o := resolved.ProjectSandboxOverride
45+
if !o.HasEnv && !o.HasImage && !o.HasNetwork && !o.HasVolumes {
46+
return nil
47+
}
48+
49+
if os.Getenv("ODEK_APPROVE_PROJECT_SANDBOX") == "1" {
50+
return nil
51+
}
52+
53+
projectDir, err := os.Getwd()
54+
if err != nil {
55+
return fmt.Errorf("project sandbox approval: get working directory: %w", err)
56+
}
57+
projectDir, err = filepath.Abs(projectDir)
58+
if err != nil {
59+
return fmt.Errorf("project sandbox approval: abs working directory: %w", err)
60+
}
61+
62+
approved, err := loadProjectSandboxApprovals()
63+
if err != nil {
64+
return fmt.Errorf("project sandbox approval: load approvals: %w", err)
65+
}
66+
67+
key := projectSandboxApprovalKey(projectDir, o)
68+
if approved[key] {
69+
return nil
70+
}
71+
72+
if !tty {
73+
return fmt.Errorf(
74+
"project-level sandbox config in %s requires explicit approval\n"+
75+
"set ODEK_APPROVE_PROJECT_SANDBOX=1 to approve, or run interactively",
76+
config.ProjectConfigPath(),
77+
)
78+
}
79+
80+
reader := bufio.NewReader(stdin)
81+
82+
fmt.Fprintln(stdout)
83+
fmt.Fprintf(stdout, "WARNING: project config (%s) requests sandbox overrides:\n", config.ProjectConfigPath())
84+
if o.HasImage {
85+
fmt.Fprintf(stdout, " image: %s\n", o.Image)
86+
}
87+
if o.HasNetwork {
88+
fmt.Fprintf(stdout, " network: %s\n", o.Network)
89+
}
90+
if o.HasEnv {
91+
fmt.Fprintf(stdout, " env: %s\n", strings.Join(o.EnvKeys, ", "))
92+
if o.EnvHasInterpolation {
93+
fmt.Fprintln(stdout, " ⚠️ sandbox_env values contain ${...} interpolation against host environment variables")
94+
}
95+
}
96+
if o.HasVolumes {
97+
fmt.Fprintf(stdout, " volumes: %s\n", strings.Join(o.Volumes, ", "))
98+
}
99+
fmt.Fprintln(stdout)
100+
fmt.Fprintln(stdout, "Allowing this means code in the sandbox can read workspace files and,")
101+
fmt.Fprintln(stdout, "depending on network mode, contact external hosts.")
102+
fmt.Fprintln(stdout)
103+
fmt.Fprint(stdout, "Approve? [y = once / t = trust this project / N] ")
104+
105+
line, err := reader.ReadString('\n')
106+
if err != nil {
107+
return fmt.Errorf("project sandbox approval: read prompt: %w", err)
108+
}
109+
line = strings.ToLower(strings.TrimSpace(line))
110+
111+
switch line {
112+
case "y", "yes":
113+
return nil
114+
case "t", "trust":
115+
approved[key] = true
116+
if err := saveProjectSandboxApprovals(approved); err != nil {
117+
return fmt.Errorf("project sandbox approval: save approvals: %w", err)
118+
}
119+
return nil
120+
default:
121+
return fmt.Errorf("project sandbox config was not approved")
122+
}
123+
}
124+
125+
// projectSandboxApprovalKey returns a stable key for the persisted approval
126+
// store. A change to the project directory, image, network, env keys, or
127+
// volumes invalidates the prior approval.
128+
func projectSandboxApprovalKey(projectDir string, o config.ProjectSandboxOverride) string {
129+
h := sha256.New()
130+
fmt.Fprintf(h, "%s\x00%s\x00%s", projectDir, o.Image, o.Network)
131+
for _, k := range o.EnvKeys {
132+
fmt.Fprintf(h, "\x00env:%s", k)
133+
}
134+
for _, v := range o.Volumes {
135+
fmt.Fprintf(h, "\x00vol:%s", v)
136+
}
137+
return hex.EncodeToString(h.Sum(nil))
138+
}
139+
140+
// loadProjectSandboxApprovals reads the persisted approval map. A missing file
141+
// is treated as an empty approval set.
142+
func loadProjectSandboxApprovals() (map[string]bool, error) {
143+
path := filepath.Join(expandHome("~/.odek"), projectSandboxApprovalsFile)
144+
data, err := os.ReadFile(path)
145+
if err != nil {
146+
if os.IsNotExist(err) {
147+
return make(map[string]bool), nil
148+
}
149+
return nil, err
150+
}
151+
152+
var approvals map[string]bool
153+
if err := json.Unmarshal(data, &approvals); err != nil {
154+
return nil, fmt.Errorf("parse %s: %w", path, err)
155+
}
156+
if approvals == nil {
157+
approvals = make(map[string]bool)
158+
}
159+
return approvals, nil
160+
}
161+
162+
// saveProjectSandboxApprovals writes the approval map to disk with 0600
163+
// permissions.
164+
func saveProjectSandboxApprovals(approvals map[string]bool) error {
165+
dir := expandHome("~/.odek")
166+
if err := os.MkdirAll(dir, 0700); err != nil {
167+
return err
168+
}
169+
path := filepath.Join(dir, projectSandboxApprovalsFile)
170+
data, err := json.MarshalIndent(approvals, "", " ")
171+
if err != nil {
172+
return err
173+
}
174+
return os.WriteFile(path, data, 0600)
175+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/BackendStack21/odek/internal/config"
11+
)
12+
13+
func TestApproveProjectSandbox_NoOverride(t *testing.T) {
14+
resolved := config.ResolvedConfig{}
15+
if err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false); err != nil {
16+
t.Fatalf("expected no approval needed when no override, got: %v", err)
17+
}
18+
}
19+
20+
func TestApproveProjectSandbox_EnvBypass(t *testing.T) {
21+
resolved := config.ResolvedConfig{
22+
ProjectSandboxOverride: config.ProjectSandboxOverride{
23+
HasEnv: true,
24+
EnvKeys: []string{"X"},
25+
},
26+
}
27+
t.Setenv("ODEK_APPROVE_PROJECT_SANDBOX", "1")
28+
if err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false); err != nil {
29+
t.Fatalf("expected env approval, got: %v", err)
30+
}
31+
}
32+
33+
func TestApproveProjectSandbox_NonTTYRequiresEnv(t *testing.T) {
34+
resolved := config.ResolvedConfig{
35+
ProjectSandboxOverride: config.ProjectSandboxOverride{
36+
HasEnv: true,
37+
EnvKeys: []string{"X"},
38+
},
39+
}
40+
os.Unsetenv("ODEK_APPROVE_PROJECT_SANDBOX")
41+
err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false)
42+
if err == nil {
43+
t.Fatal("expected error for non-interactive unapproved project sandbox")
44+
}
45+
if !strings.Contains(err.Error(), "ODEK_APPROVE_PROJECT_SANDBOX") {
46+
t.Errorf("error = %q, want ODEK_APPROVE_PROJECT_SANDBOX hint", err.Error())
47+
}
48+
}
49+
50+
func TestApproveProjectSandbox_TTYDeny(t *testing.T) {
51+
resolved := config.ResolvedConfig{
52+
ProjectSandboxOverride: config.ProjectSandboxOverride{
53+
HasEnv: true,
54+
EnvKeys: []string{"X"},
55+
},
56+
}
57+
var out bytes.Buffer
58+
err := approveProjectSandboxWithTTY(resolved, strings.NewReader("\n"), &out, true)
59+
if err == nil {
60+
t.Fatal("expected error when user denies approval")
61+
}
62+
if !strings.Contains(err.Error(), "not approved") {
63+
t.Errorf("error = %q, want 'not approved'", err.Error())
64+
}
65+
if !strings.Contains(out.String(), "WARNING") {
66+
t.Errorf("prompt = %q, want WARNING header", out.String())
67+
}
68+
}
69+
70+
func TestApproveProjectSandbox_TTYApproveOnce(t *testing.T) {
71+
resolved := config.ResolvedConfig{
72+
ProjectSandboxOverride: config.ProjectSandboxOverride{
73+
HasEnv: true,
74+
EnvKeys: []string{"X"},
75+
},
76+
}
77+
var out bytes.Buffer
78+
err := approveProjectSandboxWithTTY(resolved, strings.NewReader("y\n"), &out, true)
79+
if err != nil {
80+
t.Fatalf("expected approval, got: %v", err)
81+
}
82+
}
83+
84+
func TestApproveProjectSandbox_TTYTrustPersists(t *testing.T) {
85+
homeDir := setupTestHome(t)
86+
resolved := config.ResolvedConfig{
87+
ProjectSandboxOverride: config.ProjectSandboxOverride{
88+
HasEnv: true,
89+
EnvKeys: []string{"X"},
90+
},
91+
}
92+
93+
var out bytes.Buffer
94+
err := approveProjectSandboxWithTTY(resolved, strings.NewReader("t\n"), &out, true)
95+
if err != nil {
96+
t.Fatalf("expected trust approval, got: %v", err)
97+
}
98+
99+
// Second call with same key and no input should succeed because of persisted trust.
100+
err = approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false)
101+
if err != nil {
102+
t.Fatalf("expected persisted approval, got: %v", err)
103+
}
104+
105+
approvalPath := filepath.Join(homeDir, ".odek", projectSandboxApprovalsFile)
106+
if _, err := os.Stat(approvalPath); err != nil {
107+
t.Fatalf("approval file not created: %v", err)
108+
}
109+
}
110+
111+
func TestApproveProjectSandbox_KeyChanges(t *testing.T) {
112+
setupTestHome(t)
113+
resolved := config.ResolvedConfig{
114+
ProjectSandboxOverride: config.ProjectSandboxOverride{
115+
HasEnv: true,
116+
EnvKeys: []string{"X"},
117+
},
118+
}
119+
120+
var out bytes.Buffer
121+
if err := approveProjectSandboxWithTTY(resolved, strings.NewReader("t\n"), &out, true); err != nil {
122+
t.Fatalf("expected trust approval, got: %v", err)
123+
}
124+
125+
// Add a new env key: previous trust should be invalidated.
126+
resolved.ProjectSandboxOverride.EnvKeys = []string{"X", "Y"}
127+
err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false)
128+
if err == nil {
129+
t.Fatal("expected error after key change invalidated trust")
130+
}
131+
}
132+
133+
func TestApproveProjectSandbox_PromptHidesValues(t *testing.T) {
134+
resolved := config.ResolvedConfig{
135+
ProjectSandboxOverride: config.ProjectSandboxOverride{
136+
HasEnv: true,
137+
EnvKeys: []string{"X"},
138+
EnvHasInterpolation: true,
139+
},
140+
}
141+
var out bytes.Buffer
142+
_ = approveProjectSandboxWithTTY(resolved, strings.NewReader("\n"), &out, true)
143+
prompt := out.String()
144+
if !strings.Contains(prompt, "X") {
145+
t.Errorf("prompt = %q, want env key X", prompt)
146+
}
147+
if strings.Contains(prompt, "${HOME}") || strings.Contains(prompt, "secret-value") {
148+
t.Errorf("prompt should not contain env values; got %q", prompt)
149+
}
150+
if !strings.Contains(prompt, "${...}") {
151+
t.Errorf("prompt = %q, want interpolation warning", prompt)
152+
}
153+
}

cmd/odek/repl.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ func replCmd(args []string) error {
6262
SandboxCPUs: f.SandboxCPUs,
6363
SandboxUser: f.SandboxUser,
6464
})
65+
if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil {
66+
return err
67+
}
6568
systemMessage := buildSystemPrompt(resolved)
6669

6770
// session resume

cmd/odek/schedule.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,9 @@ func scheduleRunNow(args []string) error {
266266
}
267267

268268
resolved := config.LoadConfig(config.CLIFlags{})
269+
if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil {
270+
return err
271+
}
269272
system := buildSystemPrompt(resolved)
270273
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
271274
defer cancel()
@@ -297,6 +300,9 @@ func scheduleDaemon(_ []string) error {
297300
defer unlock()
298301

299302
resolved := config.LoadConfig(config.CLIFlags{})
303+
if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil {
304+
return err
305+
}
300306
system := buildSystemPrompt(resolved)
301307
st, err := schedule.NewStore()
302308
if err != nil {

0 commit comments

Comments
 (0)