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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
- **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.
- **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.

### Security findings (`sec_findings.md`)

`sec_findings.md` at the repository root is the running security audit log. It is
intentionally listed in `.gitignore` so that audit output and in-progress
findings are not committed to the repository by default. Do not commit this
file in pull requests unless you explicitly intend to publish a finalized
audit snapshot.

### Platform Support
CLI, REPL, Web UI, Telegram bot — all in a single binary.

Expand Down
3 changes: 3 additions & 0 deletions cmd/odek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,9 @@ func run(args []string) error {
GuardScanToolOutputs: f.GuardScanToolOutputs,
GuardScanTelegram: f.GuardScanTelegram,
})
if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil {
return err
}

// Resolve @references and --ctx file attachments in the task
cwd, _ := os.Getwd()
Expand Down
3 changes: 3 additions & 0 deletions cmd/odek/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ Flags:

// Load config
resolved := config.LoadConfig(cliFlags)
if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil {
return err
}

// Start agent loop (mcp)
sbCfg := sandboxConfig{
Expand Down
175 changes: 175 additions & 0 deletions cmd/odek/project_sandbox_approval.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package main

import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/BackendStack21/odek/internal/config"
"golang.org/x/term"
)

// projectSandboxApprovalsFile is the persistent store for user-approved
// project-level sandbox configurations. It lives under ~/.odek and is created
// 0600.
const projectSandboxApprovalsFile = "project_sandbox_approvals.json"

// approveProjectSandbox requires explicit operator approval before any
// project-level ./odek.json sandbox knobs are applied. This closes the C-1
// vector where a malicious repo exfiltrates host secrets via ${VAR}
// interpolation in sandbox_env, pulls an attacker-controlled image, or widens
// the container's network access.
//
// Approval can be granted in three ways:
// 1. Set ODEK_APPROVE_PROJECT_SANDBOX=1 (useful for CI/non-interactive use).
// 2. Answer the interactive prompt when running on a TTY.
// 3. A prior approval for the same project/sandbox fingerprint is persisted
// in ~/.odek/project_sandbox_approvals.json.
//
// If approval is required and cannot be obtained, approveProjectSandbox
// returns an error and the command should abort before creating the sandbox.
func approveProjectSandbox(resolved config.ResolvedConfig, stdin io.Reader, stdout io.Writer) error {
isTTY := stdin == os.Stdin && term.IsTerminal(int(os.Stdin.Fd()))
return approveProjectSandboxWithTTY(resolved, stdin, stdout, isTTY)
}

// approveProjectSandboxWithTTY is the testable core of approveProjectSandbox.
func approveProjectSandboxWithTTY(resolved config.ResolvedConfig, stdin io.Reader, stdout io.Writer, tty bool) error {
o := resolved.ProjectSandboxOverride
if !o.HasEnv && !o.HasImage && !o.HasNetwork && !o.HasVolumes {
return nil
}

if os.Getenv("ODEK_APPROVE_PROJECT_SANDBOX") == "1" {
return nil
}

projectDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("project sandbox approval: get working directory: %w", err)
}
projectDir, err = filepath.Abs(projectDir)
if err != nil {
return fmt.Errorf("project sandbox approval: abs working directory: %w", err)
}

approved, err := loadProjectSandboxApprovals()
if err != nil {
return fmt.Errorf("project sandbox approval: load approvals: %w", err)
}

key := projectSandboxApprovalKey(projectDir, o)
if approved[key] {
return nil
}

if !tty {
return fmt.Errorf(
"project-level sandbox config in %s requires explicit approval\n"+
"set ODEK_APPROVE_PROJECT_SANDBOX=1 to approve, or run interactively",
config.ProjectConfigPath(),
)
}

reader := bufio.NewReader(stdin)

fmt.Fprintln(stdout)
fmt.Fprintf(stdout, "WARNING: project config (%s) requests sandbox overrides:\n", config.ProjectConfigPath())
if o.HasImage {
fmt.Fprintf(stdout, " image: %s\n", o.Image)
}
if o.HasNetwork {
fmt.Fprintf(stdout, " network: %s\n", o.Network)
}
if o.HasEnv {
fmt.Fprintf(stdout, " env: %s\n", strings.Join(o.EnvKeys, ", "))
if o.EnvHasInterpolation {
fmt.Fprintln(stdout, " ⚠️ sandbox_env values contain ${...} interpolation against host environment variables")
}
}
if o.HasVolumes {
fmt.Fprintf(stdout, " volumes: %s\n", strings.Join(o.Volumes, ", "))
}
fmt.Fprintln(stdout)
fmt.Fprintln(stdout, "Allowing this means code in the sandbox can read workspace files and,")
fmt.Fprintln(stdout, "depending on network mode, contact external hosts.")
fmt.Fprintln(stdout)
fmt.Fprint(stdout, "Approve? [y = once / t = trust this project / N] ")

line, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("project sandbox approval: read prompt: %w", err)
}
line = strings.ToLower(strings.TrimSpace(line))

switch line {
case "y", "yes":
return nil
case "t", "trust":
approved[key] = true
if err := saveProjectSandboxApprovals(approved); err != nil {
return fmt.Errorf("project sandbox approval: save approvals: %w", err)
}
return nil
default:
return fmt.Errorf("project sandbox config was not approved")
}
}

// projectSandboxApprovalKey returns a stable key for the persisted approval
// store. A change to the project directory, image, network, env keys, or
// volumes invalidates the prior approval.
func projectSandboxApprovalKey(projectDir string, o config.ProjectSandboxOverride) string {
h := sha256.New()
fmt.Fprintf(h, "%s\x00%s\x00%s", projectDir, o.Image, o.Network)
for _, k := range o.EnvKeys {
fmt.Fprintf(h, "\x00env:%s", k)
}
for _, v := range o.Volumes {
fmt.Fprintf(h, "\x00vol:%s", v)
}
return hex.EncodeToString(h.Sum(nil))
}

// loadProjectSandboxApprovals reads the persisted approval map. A missing file
// is treated as an empty approval set.
func loadProjectSandboxApprovals() (map[string]bool, error) {
path := filepath.Join(expandHome("~/.odek"), projectSandboxApprovalsFile)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]bool), nil
}
return nil, err
}

var approvals map[string]bool
if err := json.Unmarshal(data, &approvals); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if approvals == nil {
approvals = make(map[string]bool)
}
return approvals, nil
}

// saveProjectSandboxApprovals writes the approval map to disk with 0600
// permissions.
func saveProjectSandboxApprovals(approvals map[string]bool) error {
dir := expandHome("~/.odek")
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
path := filepath.Join(dir, projectSandboxApprovalsFile)
data, err := json.MarshalIndent(approvals, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
153 changes: 153 additions & 0 deletions cmd/odek/project_sandbox_approval_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package main

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/BackendStack21/odek/internal/config"
)

func TestApproveProjectSandbox_NoOverride(t *testing.T) {
resolved := config.ResolvedConfig{}
if err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false); err != nil {
t.Fatalf("expected no approval needed when no override, got: %v", err)
}
}

func TestApproveProjectSandbox_EnvBypass(t *testing.T) {
resolved := config.ResolvedConfig{
ProjectSandboxOverride: config.ProjectSandboxOverride{
HasEnv: true,
EnvKeys: []string{"X"},
},
}
t.Setenv("ODEK_APPROVE_PROJECT_SANDBOX", "1")
if err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false); err != nil {
t.Fatalf("expected env approval, got: %v", err)
}
}

func TestApproveProjectSandbox_NonTTYRequiresEnv(t *testing.T) {
resolved := config.ResolvedConfig{
ProjectSandboxOverride: config.ProjectSandboxOverride{
HasEnv: true,
EnvKeys: []string{"X"},
},
}
os.Unsetenv("ODEK_APPROVE_PROJECT_SANDBOX")
err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false)
if err == nil {
t.Fatal("expected error for non-interactive unapproved project sandbox")
}
if !strings.Contains(err.Error(), "ODEK_APPROVE_PROJECT_SANDBOX") {
t.Errorf("error = %q, want ODEK_APPROVE_PROJECT_SANDBOX hint", err.Error())
}
}

func TestApproveProjectSandbox_TTYDeny(t *testing.T) {
resolved := config.ResolvedConfig{
ProjectSandboxOverride: config.ProjectSandboxOverride{
HasEnv: true,
EnvKeys: []string{"X"},
},
}
var out bytes.Buffer
err := approveProjectSandboxWithTTY(resolved, strings.NewReader("\n"), &out, true)
if err == nil {
t.Fatal("expected error when user denies approval")
}
if !strings.Contains(err.Error(), "not approved") {
t.Errorf("error = %q, want 'not approved'", err.Error())
}
if !strings.Contains(out.String(), "WARNING") {
t.Errorf("prompt = %q, want WARNING header", out.String())
}
}

func TestApproveProjectSandbox_TTYApproveOnce(t *testing.T) {
resolved := config.ResolvedConfig{
ProjectSandboxOverride: config.ProjectSandboxOverride{
HasEnv: true,
EnvKeys: []string{"X"},
},
}
var out bytes.Buffer
err := approveProjectSandboxWithTTY(resolved, strings.NewReader("y\n"), &out, true)
if err != nil {
t.Fatalf("expected approval, got: %v", err)
}
}

func TestApproveProjectSandbox_TTYTrustPersists(t *testing.T) {
homeDir := setupTestHome(t)
resolved := config.ResolvedConfig{
ProjectSandboxOverride: config.ProjectSandboxOverride{
HasEnv: true,
EnvKeys: []string{"X"},
},
}

var out bytes.Buffer
err := approveProjectSandboxWithTTY(resolved, strings.NewReader("t\n"), &out, true)
if err != nil {
t.Fatalf("expected trust approval, got: %v", err)
}

// Second call with same key and no input should succeed because of persisted trust.
err = approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false)
if err != nil {
t.Fatalf("expected persisted approval, got: %v", err)
}

approvalPath := filepath.Join(homeDir, ".odek", projectSandboxApprovalsFile)
if _, err := os.Stat(approvalPath); err != nil {
t.Fatalf("approval file not created: %v", err)
}
}

func TestApproveProjectSandbox_KeyChanges(t *testing.T) {
setupTestHome(t)
resolved := config.ResolvedConfig{
ProjectSandboxOverride: config.ProjectSandboxOverride{
HasEnv: true,
EnvKeys: []string{"X"},
},
}

var out bytes.Buffer
if err := approveProjectSandboxWithTTY(resolved, strings.NewReader("t\n"), &out, true); err != nil {
t.Fatalf("expected trust approval, got: %v", err)
}

// Add a new env key: previous trust should be invalidated.
resolved.ProjectSandboxOverride.EnvKeys = []string{"X", "Y"}
err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false)
if err == nil {
t.Fatal("expected error after key change invalidated trust")
}
}

func TestApproveProjectSandbox_PromptHidesValues(t *testing.T) {
resolved := config.ResolvedConfig{
ProjectSandboxOverride: config.ProjectSandboxOverride{
HasEnv: true,
EnvKeys: []string{"X"},
EnvHasInterpolation: true,
},
}
var out bytes.Buffer
_ = approveProjectSandboxWithTTY(resolved, strings.NewReader("\n"), &out, true)
prompt := out.String()
if !strings.Contains(prompt, "X") {
t.Errorf("prompt = %q, want env key X", prompt)
}
if strings.Contains(prompt, "${HOME}") || strings.Contains(prompt, "secret-value") {
t.Errorf("prompt should not contain env values; got %q", prompt)
}
if !strings.Contains(prompt, "${...}") {
t.Errorf("prompt = %q, want interpolation warning", prompt)
}
}
3 changes: 3 additions & 0 deletions cmd/odek/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ func replCmd(args []string) error {
SandboxCPUs: f.SandboxCPUs,
SandboxUser: f.SandboxUser,
})
if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil {
return err
}
systemMessage := buildSystemPrompt(resolved)

// session resume
Expand Down
6 changes: 6 additions & 0 deletions cmd/odek/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ func scheduleRunNow(args []string) error {
}

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

resolved := config.LoadConfig(config.CLIFlags{})
if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil {
return err
}
system := buildSystemPrompt(resolved)
st, err := schedule.NewStore()
if err != nil {
Expand Down
Loading
Loading