From 57ae9caf292787b68d116a7e4950967268599ee7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 09:14:46 +0000 Subject: [PATCH 1/4] docs: fix remaining operator-doc mismatches vs code Correct claims that had drifted from current behavior: continue sandbox pinning (no --no-sandbox flag; project sandbox:false ignored), non_interactive enum, Telegram trust/friction vs TTY, schedule floor, serve @-resources and compaction wiring, event data fields, plan UI polling, memory search defaults, serve.log rotation, and the local init template so odek init stays warning-free. Co-authored-by: admin --- README.md | 8 +++++--- cmd/odek/init_template_test.go | 1 + cmd/odek/main.go | 9 +-------- docs/API.md | 18 +++++++++++------- docs/CHEATSHEET.md | 21 ++++++++++----------- docs/CLI.md | 26 +++++++++++++++----------- docs/CONFIG.md | 29 ++++++++++++++++------------- docs/DEVELOPMENT.md | 2 +- docs/DOCKER_COMPOSE_USER_GUIDE.md | 19 ++++++++----------- docs/EXTENSIONS.md | 7 ++++++- docs/MAINTENANCE.md | 2 +- docs/MEMORY.md | 14 +++++++++----- docs/PLANNING.md | 28 +++++++++++++--------------- docs/SANDBOXING.md | 4 ++-- docs/SCHEDULES.md | 2 +- docs/SECURITY.md | 18 +++++++++--------- docs/SESSIONS.md | 6 ++++-- docs/SUBAGENTS.md | 7 +++++-- docs/TELEGRAM.md | 1 + docs/TOOL_SELECTION.md | 2 ++ docs/WEBUI.md | 20 +++++++++++++------- internal/loop/plan.go | 2 +- odek.go | 6 ++++-- 23 files changed, 139 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 931c85c7..b20cf311 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,10 @@ One binary. One loop. Zero frameworks. ReAct (Reasoning + Acting) — think, therefore act. ```bash -# Install (requires Go ≥ 1.25.13 — see "Build requirements" below) -go install github.com/BackendStack21/odek/cmd/odek@latest +# Install from a v2 release tag (requires Go ≥ 1.25.13 — see GETTING_STARTED.md). +# Do not use @latest: Go ignores v2 tags on this module path and would +# install an older v1 release. +go install github.com/BackendStack21/odek/cmd/odek@v2.11.1 # Use (provider env key — DEEPSEEK_API_KEY for the default provider) export DEEPSEEK_API_KEY=sk-... @@ -26,7 +28,7 @@ odek is not a framework. It's a **runtime** — the smallest possible surface ar | | odek | Python agents (LangChain, CrewAI, etc.) | |---|---|---| -| Dependencies | **5.** 2× 21no.de, 3× golang.org/x | 200+ packages | +| Dependencies | **6.** 3× 21no.de, 3× golang.org/x | 200+ packages | | Binary size | ~11 MB static | 50-200 MB with venv | | Startup | **Instant** | 2-10s (Python imports) | | Sandbox | **Default-on** Docker sandbox (`--no-sandbox` to opt out) | Requires manual Docker setup | diff --git a/cmd/odek/init_template_test.go b/cmd/odek/init_template_test.go index 1d5e7ca5..5bb29eab 100644 --- a/cmd/odek/init_template_test.go +++ b/cmd/odek/init_template_test.go @@ -91,6 +91,7 @@ func TestLocalConfigTemplate_RemainsProjectSafe(t *testing.T) { `"provider"`, `"providers"`, `"api_key"`, `"base_url"`, `"llm"`, `"system"`, `"dangerous"`, `"memory"`, `"guard"`, `"maintenance"`, `"telegram"`, `"web_search"`, `"embedding"`, `"sessions"`, `"trusted_proxies"`, `"profiles"`, + `"subagent"`, `"max_concurrent"`, `"catchup"`, `"sandbox"`, `"compaction"`, `"limits"`, `"prompt_caching"`, `"stream"`, `"announce_budget"`, } { diff --git a/cmd/odek/main.go b/cmd/odek/main.go index 6638ee38..febcd778 100644 --- a/cmd/odek/main.go +++ b/cmd/odek/main.go @@ -1583,17 +1583,10 @@ const localConfigTemplate = `{ "max_lazy_slots": 5, "verbose": false }, - "subagent": { - "max_concurrency": 3, - "timeout_seconds": 1800, - "max_iterations": 15 - }, "mcp_servers": {}, "schedules": { "enabled": true, - "max_concurrent": 2, - "timezone": "UTC", - "catchup": false + "timezone": "UTC" } }` diff --git a/docs/API.md b/docs/API.md index a202a1e0..7c969f0b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -188,7 +188,9 @@ type Config struct { // stream (schema odek.event/v1): run_started, iteration_completed, // tool_call_started/completed/failed, session_saved, // context_trimmed, budget_exceeded, plan_created, plan_updated, - // plan_blocked, run_completed, run_failed. + // plan_blocked, subagent_denied, subagent_spawned, + // subagent_completed, subagent_concurrency_wait, + // run_completed, run_failed. // Dispatch is non-blocking (buffered, drop-on-full) and // panic-isolated — a slow or panicking handler can never stall // or crash the loop. Events never contain raw tool arguments @@ -400,17 +402,19 @@ agent, err := odek.New(odek.Config{ Use `RunWithMessages` to continue conversations across turns, loading prior message history: ```go +import "github.com/BackendStack21/odek/internal/session" + // First turn -answer, messages, err := agent.RunWithMessages(ctx, []llm.Message{ +answer, messages, err := agent.RunWithMessages(ctx, []session.Message{ {Role: "user", Content: "Read the main.go file"}, }) // Second turn — continue the conversation -messages = append(messages, llm.Message{Role: "user", Content: "Now refactor it"}) +messages = append(messages, session.Message{Role: "user", Content: "Now refactor it"}) answer, messages, err = agent.RunWithMessages(ctx, messages) // Third turn — continue again -messages = append(messages, llm.Message{Role: "user", Content: "Add error handling"}) +messages = append(messages, session.Message{Role: "user", Content: "Add error handling"}) answer, messages, err = agent.RunWithMessages(ctx, messages) ``` @@ -427,7 +431,7 @@ sess, _ := store.Create(messages, "deepseek-v4-flash", "Refactor auth") // Later... sess, _ := store.Load("20260520-abc123") msgs := sess.GetMessages() -msgs = append(msgs, llm.Message{Role: "user", Content: "Add tests"}) +msgs = append(msgs, session.Message{Role: "user", Content: "Add tests"}) answer, allMsgs, err := agent.RunWithMessages(ctx, msgs) store.Append(sess.ID, allMsgs[len(msgs):]) ``` @@ -588,8 +592,8 @@ Memory is enabled by default when odek loads a config file with memory settings. agent, _ := odek.New(odek.Config{ Model: "deepseek-v4-flash", APIKey: os.Getenv("DEEPSEEK_API_KEY"), - // Memory is enabled via config file (~/.odek/config.json or ./odek.json) - // In CLI mode, the --memory flag enables it automatically + // Memory is enabled via ~/.odek/config.json (the memory section is + // operator-only — ./odek.json cannot set it) }) // Each turn — memory manager is nil if disabled diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index ebe8f012..91317e00 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -29,7 +29,7 @@ odek memory extended pending # List atoms pending review odek memory extended confirm # Approve a pending-review atom odek memory extended forget # Delete an atom -# Sandbox (ON by default for run/continue/repl — see Sandbox section) +# Sandbox (ON by default for run/continue/repl/serve — see Sandbox section) odek run --sandbox "build safely" # Explicit: hard-fails if Docker is unavailable odek run --no-sandbox "quick task" # Explicit opt-out odek serve --sandbox --sandbox-readonly --sandbox-network none @@ -86,7 +86,7 @@ odek run --events-jsonl events.jsonl --events-include-args "task" # + raw (reda } ``` -Priority: `~/.odek/config.json` ← `./odek.json` ← `ODEK_*` env ← CLI flags. (The `dangerous` section is operator-only: a project `./odek.json` cannot set it, so a cloned repo can't lower its own guardrails.) +Priority: `~/.odek/secrets.env` ← `~/.odek/config.json` ← `./odek.json` ← `ODEK_*` env ← CLI flags. (The `dangerous` section is operator-only: a project `./odek.json` cannot set it, so a cloned repo can't lower its own guardrails.) ### Risk Classes & Approvals @@ -184,7 +184,7 @@ docker run -d --name searxng -p 8888:8080 \ searxng/searxng:2026.6.8-f3fab143b ``` -Then point odek at it (global `~/.odek/config.json` or project `./odek.json`): +Then point odek at it in global `~/.odek/config.json` (`web_search` in `./odek.json` is ignored): ```json { "web_search": { "base_url": "http://127.0.0.1:8888" } } @@ -201,8 +201,8 @@ instance, `server.limiter: false` (drops the Redis/Valkey dependency). ``` ~/.odek/memory/ ├── facts/ -│ ├── user.md → User profile (cap: 1,500 chars) -│ └── env.md → Environment facts (cap: 2,500 chars) +│ ├── user.md → User profile (default cap: 4,000 chars) +│ └── env.md → Environment facts (default cap: 8,000 chars) ├── project-facts/ → Per-project overlays (optional) └── episodes/ ├── .md → LLM-extracted session summaries @@ -273,21 +273,21 @@ odek repl --sandbox --sandbox-memory 2g --sandbox-cpus 2 - **Implicit default + Docker unavailable** (or unapproved project `Dockerfile.odek`) → degrades to unsandboxed with a loud notice, instead of breaking Docker-less machines. - **`ODEK_REQUIRE_SANDBOX=1`** → any unsandboxed outcome is fatal, including explicit opt-outs (the hard constraint outranks contradictory flags). -- `odek continue` pins the session's original sandbox posture — no mid-conversation containment flips. +- `odek continue` pins the session's original sandbox posture — no mid-conversation containment flips. It does not accept `--no-sandbox`; override with `ODEK_NO_SANDBOX=1` / trusted `"sandbox": false`. -Flags: `--sandbox`, `--no-sandbox`, `--sandbox-image`, `--sandbox-network`, `--sandbox-readonly`, `--sandbox-memory`, `--sandbox-cpus`, `--sandbox-user`. +Flags (`run` / `repl` / `serve`): `--sandbox`, `--no-sandbox`, `--sandbox-image`, `--sandbox-network`, `--sandbox-readonly`, `--sandbox-memory`, `--sandbox-cpus`, `--sandbox-user`. Env vars: `ODEK_SANDBOX=true`, `ODEK_SANDBOX_IMAGE`, `ODEK_SANDBOX_NETWORK`, `ODEK_NO_SANDBOX=1`, `ODEK_REQUIRE_SANDBOX=1`, etc. > **Project config approval:** sandbox knobs set in `./odek.json` (`sandbox_env`, `sandbox_image`, `sandbox_network`, `sandbox_volumes`) require an interactive approval prompt. Use `ODEK_APPROVE_PROJECT_SANDBOX=1` in CI/scripts, or set sandbox config via `~/.odek/config.json` / env vars / CLI flags instead. A project config can enable the sandbox but never disable it. -Default network: `bridge` (internet access). Set `none` for air-gapped execution. +Default network: `none` (air-gapped). Set `bridge` for internet access. ## Telegram Bot - Requires `ODEK_TELEGRAM_BOT_TOKEN` env var -- Slash commands: `/start`, `/help`, `/new`, `/plan`, `/plans`, `/plan_view`, `/plan_delete`, `/plan_resume`, `/plan_status`, `/sessions`, `/resume`, `/prune`, `/stats`, `/stop`, `/mode`, `/restart` -- Plans: stored as `~/.odek/plans/.md`; `/plan` generates via agent, `/plan_resume` injects most recent plan into session; `/plan_status` shows the agent's structured loop plan (distinct concept — see docs/PLANNING.md) +- Slash commands: `/start`, `/help`, `/new`, `/plan`, `/plans`, `/plan_view`, `/plan_delete`, `/plan_resume`, `/plan_status`, `/sessions`, `/resume`, `/prune`, `/stats`, `/jobs`, `/stop`, `/mode`, `/restart`, `/schedules`, `/schedule` +- Plans: stored as `~/.odek/plans/chat/.md`; `/plan` generates via agent, `/plan_resume` injects most recent plan into session; `/plan_status` shows the agent's structured loop plan (distinct concept — see docs/PLANNING.md) - Voice messages: automatically processed via `DownloadVoice` → OGG files in `~/.odek/media/` - Photos: automatically processed via `DownloadPhoto` → JPG files in `~/.odek/media/` - Conversations persist across bot restarts (`tg-` sessions) @@ -351,7 +351,6 @@ odek mcp --sandbox | `ODEK_ANNOUNCE_BUDGET` | announce_budget (default on; parent hints, not `subagent.announce_budget`) | | `ODEK_STREAM` | stream (default on) | | `ODEK_MAX_CONCURRENCY` | max_concurrency | -| `ODEK_CTX` | ctx (comma-separated file paths) | | `DEEPSEEK_API_KEY` | `providers.deepseek` (default provider) | | `OPENAI_API_KEY` | `providers.openai` (also DeepSeek leftover) | | `ANTHROPIC_API_KEY` | `providers.anthropic` | diff --git a/docs/CLI.md b/docs/CLI.md index 209dea24..431cdbe2 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -50,7 +50,7 @@ Unknown flags are a **hard error** — they are never folded into the task text | `--sandbox` | bool | default on | Execute shell commands inside Docker container. Defaults ON when no layer sets it; degrades loudly to unsandboxed when Docker is unavailable (fatal with `ODEK_REQUIRE_SANDBOX=1`). Explicit `--sandbox` keeps the hard-fail behavior. | | `--no-sandbox` | bool | — | Explicitly disable the sandbox (same as `ODEK_NO_SANDBOX=1`); silences the default-on behavior. | | `--deliver` | bool | false | Deliver the agent's final response to the configured Telegram `default_chat_id`. Requires `telegram.bot_token` + `telegram.default_chat_id` in config. Handy for host-cron one-shots; for recurring tasks prefer the native scheduler (`odek schedule`, see [Schedules](SCHEDULES.md)). | -| `--interaction-mode ` | string | `engaging` | Tool-call rendering: `engaging` (emoji narration) or `verbose` (raw tool output) | +| `--interaction-mode ` | string | `engaging` | Tool-call rendering: `engaging` (emoji narration), `enhance` (persistent), `verbose` (raw tool output), or `off` | | `--no-color` | bool | false | Disable colored terminal output | | `--prompt-caching` | bool | `true` | Enable Anthropic-format `cache_control` markers (system + memory + first user + last tool). On by default. OpenAI-format providers are unaffected — they rely on prefix stability. See [CACHING.md](CACHING.md) | | `--no-prompt-caching` | bool | `false` | Disable prompt caching (overrides config/default) | @@ -117,7 +117,7 @@ A partial-progress summary is produced only when the tool-call budget fired and Cost enforcement is active only when `max_cost_usd` **and** both per-million prices (`input_cost_per_million_usd`, `output_cost_per_million_usd`) are configured; otherwise a stderr warning is printed and the token budgets stay active. odek never hard-codes provider prices. -**Current limitation:** budgets apply to `odek run` only — not `continue`, the REPL, `serve`, or Telegram. +**Current limitation:** budgets apply to `odek run` and `odek subagent` only — not `continue`, the REPL, `serve`, or Telegram. ## Exit codes @@ -225,6 +225,8 @@ When running without `--sandbox`, odek classifies every shell command by risk an | 🔴 network_egress | **prompt** | `curl`, `git push`, `ssh`, `scp` | | 🔴 code_execution | **prompt** | `curl url \| bash`, `eval`, `node -e`, `go run` | | 🟠 install | **prompt** | `npm install`, `pip install`, `go install ` | +| 🟠 persistence | **prompt** | writes to shell profiles, git hooks, CI workflows, cron/systemd | +| 🟠 unread_exec | **prompt** | executing a script whose contents were not read this session | | 🔴 unknown | **deny** | any command whose program name isn't recognised; MCP tools (`__`); pipe-fed `xargs ` whose stdin payload isn't statically determinable | | ⬛ blocked | **deny** | Fork bombs, `dd` to block devices | @@ -239,13 +241,13 @@ The approval prompt accepts: - `T` — Trust all commands of this class for this session - `?` — Show full context -Configurable via `dangerous` section in `~/.odek/config.json` or `./odek.json`: +Configurable via the `dangerous` section in `~/.odek/config.json` (operator-only; `./odek.json` is ignored with a warning): ```json { "dangerous": { "action": "prompt", - "non_interactive": "deny", + "non_interactive": "read_only", "classes": { "destructive": "prompt", "network_egress": "allow" @@ -256,7 +258,7 @@ Configurable via `dangerous` section in `~/.odek/config.json` or `./odek.json`: } ``` -Only `"allow"` and `"deny"` are valid `non_interactive` values; anything else (including the previously accepted `"prompt"`) is rejected at load time with a warning and treated as `"deny"`, because a non-interactive environment cannot prompt. +Valid `non_interactive` values are `"read_only"` (built-in default: inspection proceeds, writes/exec/egress deny), `"deny"` (block all prompted operations), and `"allow"` (run everything). Anything else — including the previously accepted `"prompt"` — is rejected at load time with a warning and treated as `"deny"`. See [docs/SECURITY.md](SECURITY.md) for details. @@ -347,9 +349,9 @@ Procedure for building optimized Docker images. | `--sandbox-memory ` | — | Memory limit (e.g. `512m`, `2g`) | | `--sandbox-cpus ` | — | CPU limit (e.g. `0.5`, `2`) | | `--sandbox-user ` | — | Run as user (`uid:gid`) | -| `--no-sandbox` | — | (serve only) Disable the default-on sandbox. Prints a warning. | +| `--no-sandbox` | — | Disable the default-on sandbox for `run` / `repl` / `serve` (same as `ODEK_NO_SANDBOX=1`). Prints a warning unless `ODEK_SUPPRESS_SANDBOX_WARNING=1`. `odek continue` does **not** accept this flag. | -`odek serve` enables `--sandbox` by default. `odek run` and `odek repl` keep sandbox opt-in but print a startup warning when running unsandboxed. Set `ODEK_SUPPRESS_SANDBOX_WARNING=1` to silence the warning if you've made an informed decision. +`odek run`, `repl`, and `serve` default the sandbox **on**. `odek continue` pins the session's stored sandbox bit (it does not inherit a new default-on). To override that pin, set `ODEK_NO_SANDBOX=1` / `ODEK_SANDBOX=false` or `"sandbox": false` in **trusted** config (`~/.odek/config.json`) — project `./odek.json` `"sandbox": false` is ignored. `odek mcp` is opt-in (`--sandbox`). `odek serve` hard-fails if Docker is missing; `run` / `repl` / `continue` degrade loudly to unsandboxed unless `ODEK_REQUIRE_SANDBOX=1`. **Project-level sandbox approval:** if `./odek.json` sets `sandbox_env`, `sandbox_image`, `sandbox_network`, or `sandbox_volumes`, odek prompts for approval before applying them. In CI or scripted invocations, set `ODEK_APPROVE_PROJECT_SANDBOX=1` to auto-approve, or place sandbox config in `~/.odek/config.json` / `ODEK_*` env vars / CLI flags instead, which do not require approval. @@ -384,6 +386,7 @@ See [SECURITY.md](SECURITY.md) for the full threat model. | Flag | Description | |------|-------------| | `--global`, `-g` | Create global config at `~/.odek/config.json` | +| `--local`, `-l` | Create project config at `./odek.json` | | `--force`, `-f` | Overwrite existing file without prompting | ## Background commands @@ -484,10 +487,11 @@ odek run --deliver "Check the CI pipeline status" Config sources from lowest to highest priority: ``` -1. ~/.odek/config.json ← Global defaults -2. ./odek.json ← Project overrides -3. ODEK_* env vars ← Runtime overrides -4. CLI flags ← Explicit invocation (highest) +1. ~/.odek/secrets.env ← API keys (never committed) +2. ~/.odek/config.json ← Global defaults +3. ./odek.json ← Project overrides (untrusted; sensitive sections ignored) +4. ODEK_* env vars ← Runtime overrides +5. CLI flags ← Explicit invocation (highest) ``` See [Configuration](CONFIG.md) for details. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 26fe2021..63df0706 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -55,12 +55,14 @@ Shared across all projects: ``` > **Sandbox default (changed):** when no layer sets `sandbox`, `odek run` / -> `odek continue` / `odek repl` now default it **on**, degrading loudly to +> `odek repl` / `odek serve` default it **on**, degrading loudly to > unsandboxed only when Docker is unavailable or a project -> `Dockerfile.odek`/sandbox knob lacks approval. Opt out explicitly with -> `--no-sandbox`, `ODEK_NO_SANDBOX=1`, or `"sandbox": false`; make any -> fallback fatal with `ODEK_REQUIRE_SANDBOX=1`. An explicit `--sandbox` -> keeps the hard-fail-on-error behavior. +> `Dockerfile.odek`/sandbox knob lacks approval (`serve` hard-fails instead). +> Opt out explicitly with `--no-sandbox`, `ODEK_NO_SANDBOX=1`, or `"sandbox": false` +> in trusted config. `odek continue` pins the session's stored sandbox bit and +> does not accept `--no-sandbox`. Make any fallback fatal with +> `ODEK_REQUIRE_SANDBOX=1`. An explicit `--sandbox` keeps the hard-fail-on-error +> behavior. > > Sandbox resource keys (`sandbox_image`, `sandbox_network`, `sandbox_readonly`, > `sandbox_memory`, `sandbox_cpus`, `sandbox_user`) follow the standard @@ -98,10 +100,11 @@ Same schema as global. Only set the fields you want to override: > - `api_key` — v1 alias; prefer `providers..api_key` or the provider env key in `~/.odek/secrets.env` (`DEEPSEEK_API_KEY`, `ZAI_API_KEY`, …). `ODEK_API_KEY` is a selected-provider override only > - `system` — use `~/.odek/config.json`, `ODEK_SYSTEM`, or `--system` > - `dangerous` — use `~/.odek/config.json` -> - `embedding` / `memory` / `sessions` / `skills.dirs` / `skills.embedding` / `web_search` — use `~/.odek/config.json` +> - `embedding` / `memory` / `sessions` / `skills.dirs` / `skills.embedding` / `web_search` / `transcription` / `vision` — use `~/.odek/config.json` > - `telegram` — use `~/.odek/config.json` or `ODEK_TELEGRAM_*` env vars > - `guard` — use `~/.odek/config.json` or `ODEK_GUARD_*` env vars > - `trusted_proxies` — use `~/.odek/config.json` or `ODEK_TRUSTED_PROXIES` +> - `subagent` / `profiles` / `maintenance` — use `~/.odek/config.json` > > If any of these appear in `./odek.json`, odek ignores them and prints a warning. > @@ -444,7 +447,7 @@ On exhaustion odek emits a `budget_exceeded` runtime event, persists the latest **Cost-disabled warning:** when `max_cost_usd` is set but neither `model_prices[model]` nor the flat pair yields both positive prices for the run's model, odek prints a stderr warning that cost enforcement is disabled (token budgets stay active) — the gap is never silent. -**Current limitation:** budget enforcement is wired into `odek run` only. `odek continue`, the REPL, `odek serve`, and the Telegram bot do not yet enforce limits. +**Current limitation:** budget enforcement is wired into `odek run` and `odek subagent` only. `odek continue`, the REPL, `odek serve`, and the Telegram bot do not yet enforce limits. Tests: `internal/budget/`, `internal/config/limits_test.go`, `internal/loop/budget_test.go`, `cmd/odek/budget_test.go`. @@ -995,7 +998,7 @@ Environment overrides: | `ODEK_SCHEDULES_DANGEROUS_ALLOWLIST` | Comma-separated command strings | | `ODEK_SCHEDULES_DANGEROUS_DENYLIST` | Comma-separated command strings | | `ODEK_SCHEDULES_DANGEROUS_ACTION` | Global default action: `allow`, `deny`, or `prompt` | -| `ODEK_SCHEDULES_DANGEROUS_NON_INTERACTIVE` | `allow`, `deny`, or `prompt` (ignored: scheduled runs force `deny`) | +| `ODEK_SCHEDULES_DANGEROUS_NON_INTERACTIVE` | `allow`, `deny`, or `read_only` (ignored: scheduled runs force `deny`) | Safety floor that cannot be overridden: - `non_interactive` is always `deny` (no human is present to approve). @@ -1032,7 +1035,7 @@ Every field has an `ODEK_MAINTENANCE_*` environment override. | `interval_minutes` | `ODEK_MAINTENANCE_INTERVAL_MINUTES` | `60` | Minutes between sweeps. The first sweep runs after one interval, never at startup. | | `sessions_max_age_days` | `ODEK_MAINTENANCE_SESSIONS_MAX_AGE_DAYS` | `30` | Delete sessions (and their index/vector-index entries) older than this. `0` = keep forever. | | `audit_max_age_days` | `ODEK_MAINTENANCE_AUDIT_MAX_AGE_DAYS` | `14` | Delete `~/.odek/sessions/audit/*.json` records older than this. `0` = keep forever. | -| `log_max_mb` | `ODEK_MAINTENANCE_LOG_MAX_MB` | `50` | Rotate `~/.odek/telegram.log` and `~/.odek/schedule.log` larger than this: current log becomes `.1` (one backup generation) and a fresh empty log is started. `0` = no rotation. | +| `log_max_mb` | `ODEK_MAINTENANCE_LOG_MAX_MB` | `50` | Rotate `~/.odek/telegram.log`, `~/.odek/schedule.log`, and `~/.odek/serve.log` larger than this: current log becomes `.1` (one backup generation) and a fresh empty log is started. `0` = no rotation. | | `plans_max_age_days` | `ODEK_MAINTENANCE_PLANS_MAX_AGE_DAYS` | `30` | Delete Telegram plan files (`~/.odek/plans/**/*.md`) older than this; emptied chat directories are removed. `0` = keep forever. | | `artifacts_max_age_hours` | `ODEK_MAINTENANCE_ARTIFACTS_MAX_AGE_HOURS` | `24` | Delete sub-agent result artifact subtrees (`~/.odek/artifacts//`) older than this. This is the **backstop** — the primary lifecycle is the session-cleanup cascade (deleting a session removes its artifacts immediately). `0` = keep forever. | @@ -1241,7 +1244,7 @@ odek init --force The **global template** covers the full schema: connection (`provider`, `providers`, `model`, `llm`), execution (`max_iterations`, `max_tool_parallel`, `prompt_caching`, `compaction`, `announce_budget`, `interaction_mode`), sandbox resource knobs (the `sandbox` key itself is deliberately absent — unset inherits the default-on posture), `dangerous` (with `non_interactive` pinned to the documented `read_only` default), `guard`, `tools`, `profiles`, `skills`, `memory` (including the `extract_facts` / `auto_approve_episodes` opt-outs), `subagent` (including `max_depth`, `announce_budget`, `budget_inherit`, `default_profile`), `limits`, `planning`, `mcp_servers`, `web_search`, `transcription`, `vision`, `trusted_proxies`, `schedules`, `maintenance`, and `telegram`. Blocks whose mere presence changes behavior (`embedding`, `memory.embedding`, `sessions.embedding`, `skills.embedding`) are intentionally omitted — add them only when you actually run an embedder. Top-level `base_url` / `api_key` remain v1 aliases (see [MIGRATION.md](MIGRATION.md)). -The **local template** contains only fields a project may legitimately set (`model`, `thinking`, iteration/parallelism limits, `interaction_mode`, sandbox resource knobs, `tools.disabled`, `skills` without `dirs`, `subagent`, `mcp_servers`, `schedules`). Operator-only fields (`provider`, `providers`, `llm`, `api_key`, `base_url`, `system`, `dangerous`, `memory`, `sessions`, `embedding`, `guard`, `maintenance`, `telegram`, `web_search`, `trusted_proxies`, `tools.enabled`, `skills.dirs`) belong in `~/.odek/config.json`. Note that project configs may only *enable* the sandbox — `"sandbox": false` is rejected, so neither template pins it locally. `compaction`, `prompt_caching`, `stream`, and `announce_budget` are likewise omitted from the local template: they default to on, and pinning `"…": false` in a fresh project config would silently disable them (add the key explicitly if you want any of them off). +The **local template** contains only fields a project may legitimately set (`model`, `thinking`, iteration/parallelism limits, `interaction_mode`, sandbox resource knobs, `tools.disabled`, `skills` without `dirs`, `mcp_servers`, `schedules` timezone/enabled). Operator-only fields (`provider`, `providers`, `llm`, `api_key`, `base_url`, `system`, `dangerous`, `memory`, `sessions`, `embedding`, `guard`, `maintenance`, `telegram`, `web_search`, `transcription`, `vision`, `trusted_proxies`, `tools.enabled`, `skills.dirs`, `subagent`, `profiles`) belong in `~/.odek/config.json`. Note that project configs may only *enable* the sandbox — `"sandbox": false` is rejected, so neither template pins it locally. `compaction`, `prompt_caching`, `stream`, and `announce_budget` are likewise omitted from the local template: they default to on, and pinning `"…": false` in a fresh project config would silently disable them (add the key explicitly if you want any of them off). ## Recommended minimal config @@ -1286,7 +1289,7 @@ Why each key is pinned: Deliberately **not** set, because the defaults are the recommendation: -- `sandbox` — on by default for `run`/`continue`/`repl`; never turn it off on a host that runs untrusted code. +- `sandbox` — on by default for `run`/`continue`/`repl`/`serve`; never turn it off on a host that runs untrusted code. - `memory.extract_facts: false` and `memory.auto_approve_episodes: false` — the secure defaults; flip only with the trade-offs understood (see [`extract_facts`](#extract_facts--automatic-fact-learning-opt-in-off-by-default)). - `dangerous` — the built-in class defaults (destructive/blocked/unknown denied, writes and egress prompted) are the right posture; tighten per-project with an `allowlist`/`denylist` only when needed. - `web_search.base_url` — empty hides the tool; set it only if you run a SearXNG instance. @@ -1337,8 +1340,8 @@ odek run --memory-extended-enabled "remember that I prefer Go over Python" # Or configure it globally in ~/.odek/config.json (memory cannot be set in ./odek.json) # { "memory": { "extended": { "enabled": true } } } -# Sub-agent config (project-level) -echo '{"subagent": {"max_concurrency": 5, "timeout_seconds": 300}}' > ./odek.json +# Sub-agent config is operator-only (ignored from ./odek.json) +# { "subagent": { "max_concurrency": 5, "timeout_seconds": 300 } } → ~/.odek/config.json # CLI flag always wins odek run --model gpt-4o --base-url https://api.openai.com/v1 "task" diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 44ede40c..2e08f121 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -67,7 +67,7 @@ internal/ buffer.go Ring buffer for turn summaries episodes.go EpisodeStore with search + LLM ranking scan.go Security scan (invisible Unicode, injection, credentials) - tool.go memory tool for the agent (6 actions) + tool.go memory tool for the agent (16 actions) provenance.go Episode trust-signal derivation (untrusted-source taint) *_test.go Tests across all subsystems skills/ diff --git a/docs/DOCKER_COMPOSE_USER_GUIDE.md b/docs/DOCKER_COMPOSE_USER_GUIDE.md index c32ec41a..a99d9474 100644 --- a/docs/DOCKER_COMPOSE_USER_GUIDE.md +++ b/docs/DOCKER_COMPOSE_USER_GUIDE.md @@ -342,10 +342,7 @@ services: Notes: -- `--no-sandbox` is required **for `serve` only**: `odek serve` turns the nested‑Docker - sandbox on by default, so without this flag it would try to launch sandbox containers and - fail. `odek run`, `odek repl`, and `odek telegram` are already unsandboxed by default and - do **not** accept a `--no-sandbox` flag (it would be parsed as part of the task). +- `--no-sandbox` is required when odek itself runs **inside** a container and would otherwise try nested Docker: `odek serve`, `odek run`, and `odek repl` all default the sandbox **on**. `odek telegram` does not start a sandbox container (and ignores extra argv). Without `--no-sandbox`, `serve` / `run` / `repl` try to launch sandbox containers and fail. - The Web UI binds to `0.0.0.0:8080` *inside* the container; the `ports` mapping exposes it only on the host's `127.0.0.1`. Use a reverse proxy (Caddy/nginx) if you need remote access. @@ -395,9 +392,10 @@ docker compose run --rm -it \ odek-restricted repl ``` -> `repl` (like `run`) is unsandboxed by default, so no `--no-sandbox` is needed — only -> `serve` requires it. The `command:` in the Compose service is overridden by the `repl` -> argument here. +> `repl` and `run` default the sandbox **on**, so pass `--no-sandbox` when +> odek itself is already in a container (nested Docker will fail). Only +> `serve` required it historically; that is no longer unique. The `command:` +> in the Compose service is overridden by the `repl` argument here. > One‑shot `odek run ""` works too, but it is non‑interactive: with the Restricted > policy above, `non_interactive: "read_only"` lets read‑only/inspection commands proceed @@ -417,11 +415,10 @@ No prompts, no human in the loop. Best for disposable containers. mkdir -p workspace docker compose --profile godmode run --rm odek-godmode \ - run "Clone nothing — just create build.sh, make it executable, and run it." + run --no-sandbox "Clone nothing — just create build.sh, make it executable, and run it." ``` -The trailing `run ""` overrides the service's default `command:` (`serve`). No -`--no-sandbox` is needed — `run` is unsandboxed by default. +The trailing `run --no-sandbox ""` overrides the service's default `command:` (`serve`). Nested Docker is not available inside the Compose service, so `--no-sandbox` is required — `run` defaults the sandbox on. Every command the agent issues runs immediately. The blast radius is the container: the only writable host mount is `./workspace`, everything else is the container's ephemeral @@ -509,7 +506,7 @@ global `action` → built‑in defaults. The `blocked` class is always denied re | Symptom | Likely cause / fix | | --- | --- | -| `odek serve` exits complaining about sandbox / Docker | You omitted `--no-sandbox`. Odek tried to start nested sandbox containers. Add `--no-sandbox` to the `command`. | +| `odek serve` / `odek run` / `odek repl` exits complaining about sandbox / Docker | You omitted `--no-sandbox`. Odek tried to start nested sandbox containers. Add `--no-sandbox` to the `command`. | | Agent says "operation denied by configuration" for normal commands | You're running non‑interactively under the Restricted policy (`non_interactive: "read_only"` — only read‑only commands proceed). Use the Web UI / `repl -it`, or add the command to `allowlist`. | | Approval modal never appears; risky commands just run | The Godmode policy is mounted, or `action` is `allow`. Check `/home/odek/.odek/config.json` inside the container. | | "no API key" / auth errors | `.env` not loaded or key invalid. Confirm `env_file: .env` is set and the provider env key (`DEEPSEEK_API_KEY`, `ZAI_API_KEY`, …) matches `ODEK_PROVIDER`. | diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 1f9246be..82ea30f2 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -197,6 +197,11 @@ Per-type `data` fields: | `plan_updated` | `steps`, `done`, `in_progress`, `blocked`, `pending`, `version` | | `plan_blocked` | `steps`, `blocked`, `version` | | `subagent_denied` | `task_index`, `class`, `reason` (emitted by `delegate_tasks` for each policy denial a child reports) | +| `subagent_spawned` | `task_id`, `pid`, `depth`, `timeout_seconds`, `goal_sha256` (16 hex chars: first 8 bytes of SHA-256 of the goal; the goal itself is never logged) | +| `subagent_completed` | `task_id`, `status`, plus optional `iterations`, `duration_seconds`, `tokens_used`, `artifact_count` when the child result carried them | +| `subagent_concurrency_wait` | `task_index`, `waited_ms` | + +`budget_warning` and `reply_ledger_mismatch` are **not** `odek.event/v1` types. They are `loop.SignalEvent`s (`Config.AgentSignalHandler`, WebSocket `agent_signal`). `call_id` is the stable correlation key between a `tool_call_started` and its matching `tool_call_completed`/`tool_call_failed` event: the provider's @@ -317,4 +322,4 @@ metadata-only lines in the model context, content inlined for text artifacts ≤ 32 KiB, everything else readable by the parent via the `artifact_read` tool (id-keyed; paths never enter the model context). See `docs/SUBAGENTS.md — Result artifacts` and `docs/SECURITY.md` for the -invariants; `SUBAGENT_RESULT_ARTIFACTS_PLAN.md` documents the design. +invariants. diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md index 2fc14b47..91a05be0 100644 --- a/docs/MAINTENANCE.md +++ b/docs/MAINTENANCE.md @@ -41,7 +41,7 @@ commands (`odek run`, `odek repl`, …) do not run the janitor — use | Plans | `~/.odek/plans/**/*.md` (by mtime) | `plans_max_age_days` | 30 days | | Sub-agent artifacts | `~/.odek/artifacts///` and `~/.odek/artifacts/unfiled//` (task dirs by their own mtime; aged session dirs also go wholesale) | `artifacts_max_age_hours` | 24 hours (backstop — live removal happens on session delete) | | Telegram media | `~/.odek/media/` (by mtime) | fixed: 1 hour | freed bytes reported | -| Logs | `~/.odek/telegram.log`, `~/.odek/schedule.log` | `log_max_mb` | 50 MB (rotated) | +| Logs | `~/.odek/telegram.log`, `~/.odek/schedule.log`, `~/.odek/serve.log` | `log_max_mb` | 50 MB (rotated) | Age for sessions is measured from the session's `updated_at`; for audit records, plans, and media from the file's modification time. Sub-agent diff --git a/docs/MEMORY.md b/docs/MEMORY.md index d9de3d72..2310e36b 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -273,7 +273,9 @@ fire from the post-session background goroutines. The agent loop also emits `loop.SignalEvent`s for previously-silent self-healing (`context_trimmed` when message groups are dropped to fit the context window, -`tool_recovery` when a repeatedly-failing tool triggers a corrective hint), +`tool_recovery` when a repeatedly-failing tool triggers a corrective hint, +`tool_running` while a call is still executing, `budget_warning` at 50/75/90% of +a budget, `reply_ledger_mismatch` when a final reply denies completed mutations), surfaced the same way via `Config.AgentSignalHandler`. ## Architecture @@ -284,16 +286,18 @@ The episode index (`episodes/index.json`) is cached in memory after the first re ### Search Ranking -Episode search uses **RandomProjections** (go-vector) for similarity by default: +Episode **auto-recall** (`FormatEpisodeContext`, once per turn) uses +**RandomProjections** (go-vector) for similarity — never an LLM call, even +when `llm_search` is on: 1. Fit RP embedder on episode summaries + query (64 dims, ~1ms) 2. Embed each summary and the query into 64-dimensional vectors 3. Score by cosine similarity between query vector and each summary vector -4. Return top-3 results sorted by score +4. Over-fetch 8 vector hits and keep the top 3 -Per-turn auto-recall (`FormatEpisodeContext`) over-fetches 8 vector hits and keeps the top 3. The query is the latest user message, plus remaining plan step titles when a plan exists (titles only — notes stay out). Untrusted, unpromoted episodes are excluded. This path never calls the LLM, even when `llm_search` is on. +The query is the latest user message, plus remaining plan step titles when a plan exists (titles only — notes stay out). Untrusted, unpromoted episodes are excluded. -Explicit `memory search` is separate: zero LLM calls with `llm_search: false`; by default (`llm_search: true`) ranking uses an LLM SimpleCall to order episodes by relevance to the query — higher quality, higher latency + token cost. +Explicit `memory search` is separate: by default (`llm_search: true`) ranking uses an LLM SimpleCall to order episodes by relevance to the query — higher quality, higher latency + token cost. Set `llm_search: false` to use the same RP cosine ranker (zero LLM calls). ### Pluggable Embeddings (`memory.embedding`) diff --git a/docs/PLANNING.md b/docs/PLANNING.md index c4afa6c1..9bb2b7a1 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -234,7 +234,7 @@ update (can ride the same parallel batch)." "required": ["id"] }, "description": "update only: applied in array order; unknown id or -invalid transition fails the whole call (atomic)." +unknown status fails the whole call (atomic)." }, "step_id": { "type": "string", "description": "complete only" } }, @@ -407,17 +407,16 @@ uncoupled by design. ### WebUI — plan panel -A `plan` tab in the management drawer (Alt+M) shows the active session's -structured plan: a summary header plus glyph/id/title/note rows mirroring -the Telegram renderer. Strictly read-only — no mutation controls — and -model-derived step text reaches the DOM exclusively via `textContent`. The -panel polls `GET /api/sessions/{id}/plan` every 5 s while visible (drawer -open + plan tab active + document visible), refreshes instantly on tab -activation, session switch, and `visibilitychange`, and stops otherwise. -Polling — not WebSocket push — because runtime events currently land only -in the `/api/events` ring; nothing relays them over WS today. Polling is -the documented transport until such a relay lands; responses are tiny, so -the cadence is cheap. +The inspector **Now** workspace (`⌘.`) shows the active session's structured +plan: a summary header plus glyph/id/title/note rows mirroring the Telegram +renderer. Strictly read-only — no mutation controls — and model-derived step +text reaches the DOM exclusively via `textContent`. The panel polls +`GET /api/sessions/{id}/plan` at **1 s** while a turn is running and **3 s** +while Now is visible and idle, refreshes instantly on tab activation, session +switch, and `visibilitychange`, and stops otherwise. Polling — not WebSocket +push — because runtime events currently land only in the `/api/events` ring; +nothing relays them over WS today. Polling is the documented transport until +such a relay lands; responses are tiny, so the cadence is cheap. --- @@ -510,15 +509,14 @@ semantics, payload minimality, `ExtractPlan`), `cmd/odek/serve_plan_test.go` version-bumping mutation via `PlanStore.SetOnChange` → the engine emit path; counts + version only, never titles/notes (see Observability); `docs/EXTENSIONS.md` rows added. -- Exported extractor `loop.ExtractPlan([]llm.Message) (*PlanState, bool)` — +- Exported extractor `loop.ExtractPlan([]session.Message) (*PlanState, bool)` — newest-parseable-wins, fail-closed corrupt-drop, unwraps the nonce'd wrapper; shared by REST and Telegram. - Read-only REST view `GET /api/sessions/{id}/plan` (`found:false` when absent; GET-only; see Surface Integration). - Telegram `/plan_status` — structured plan for the chat-scoped session, coexisting with the markdown-file `/plan` family. -- WebUI plan panel — session-drawer tab polling `GET …/plan` every 5 s while - visible. +- WebUI plan panel — inspector Now workspace (`⌘.`), polling `GET …/plan` at 1 s live / 3 s idle. - Emoji mapping `plan` → 📋 in `internal/render/render.go` and the WebUI mirror `cmd/odek/ui/js/render.js`; the vestigial `todo` special-case was retired in both (falls through to the default 🔧). diff --git a/docs/SANDBOXING.md b/docs/SANDBOXING.md index d26f20d6..29bb5234 100644 --- a/docs/SANDBOXING.md +++ b/docs/SANDBOXING.md @@ -1,6 +1,6 @@ # Sandboxing -odek runs agent shell commands inside an **isolated Docker container** — sandboxing is **on by default** for `odek run`, `odek repl`, and `odek serve`, and can be opted out with `--no-sandbox` / `ODEK_NO_SANDBOX=1` (or made fatal-when-off with `ODEK_REQUIRE_SANDBOX=1`). This document covers all configuration options, the `Dockerfile.odek` build system, security guarantees, and best practices. +odek runs agent shell commands inside an **isolated Docker container** — sandboxing is **on by default** for `odek run`, `odek continue`, `odek repl`, and `odek serve`. Opt out with `--no-sandbox` / `ODEK_NO_SANDBOX=1` on `run` / `repl` / `serve` (or make any unsandboxed outcome fatal with `ODEK_REQUIRE_SANDBOX=1`). `odek continue` does not take `--no-sandbox`; it pins the session's stored sandbox bit unless trusted config or `ODEK_NO_SANDBOX=1` / `ODEK_SANDBOX=false` sets an explicit policy. This document covers all configuration options, the `Dockerfile.odek` build system, security guarantees, and best practices. ## Quick start @@ -50,7 +50,7 @@ All sandbox settings are available in `~/.odek/config.json`, `./odek.json`, `ODE | Field | Env var | CLI flag | Type | Default | Description | |-------|---------|----------|------|---------|-------------| -| `sandbox` | `ODEK_SANDBOX` | `--sandbox` / `--no-sandbox` | bool | **on** (run/repl/serve) | Sandbox isolation is default-on; `--no-sandbox` or `ODEK_NO_SANDBOX=1` opts out; `ODEK_REQUIRE_SANDBOX=1` makes any unsandboxed outcome fatal | +| `sandbox` | `ODEK_SANDBOX` | `--sandbox` / `--no-sandbox` (`run` / `repl` / `serve`; not `continue`) | bool | **on** (run/continue/repl/serve) | Sandbox isolation is default-on; `--no-sandbox` or `ODEK_NO_SANDBOX=1` opts out on surfaces that accept the flag; `continue` pins the session bit unless trusted config / env is explicit; `ODEK_REQUIRE_SANDBOX=1` makes any unsandboxed outcome fatal | | `sandbox_image` | `ODEK_SANDBOX_IMAGE` | `--sandbox-image` | string | `alpine:latest` | Docker image for the sandbox container | | `sandbox_network` | `ODEK_SANDBOX_NETWORK` | `--sandbox-network` | string | `none` | Docker network mode | | `sandbox_readonly` | `ODEK_SANDBOX_READONLY` | `--sandbox-readonly` | bool | `false` | Mount working directory read-only | diff --git a/docs/SCHEDULES.md b/docs/SCHEDULES.md index ae0f821e..2caaa75d 100644 --- a/docs/SCHEDULES.md +++ b/docs/SCHEDULES.md @@ -256,7 +256,7 @@ Every field also has an `ODEK_SCHEDULES_DANGEROUS_*` environment override: | `ODEK_SCHEDULES_DANGEROUS_ALLOWLIST` | Comma-separated command strings | | `ODEK_SCHEDULES_DANGEROUS_DENYLIST` | Comma-separated command strings | | `ODEK_SCHEDULES_DANGEROUS_ACTION` | Global default action: `allow`, `deny`, or `prompt` | -| `ODEK_SCHEDULES_DANGEROUS_NON_INTERACTIVE` | `allow`, `deny`, or `prompt` (ignored: scheduled runs force `deny`) | +| `ODEK_SCHEDULES_DANGEROUS_NON_INTERACTIVE` | `allow`, `deny`, or `read_only` (ignored: scheduled runs force `deny`) | See [CONFIG.md](CONFIG.md) for the full field reference. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 5842f317..0f7f42ac 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -39,7 +39,7 @@ Unsandboxed runs print a one-time stderr warning that the agent has full host ac - The container runs as the invoking user's `uid:gid`, not the image default (root for virtually every base image), so workspace writes land as the real user's identity and cannot plant root-owned files or set ownership that breaks later host tooling. The numeric user has no passwd entry, so `HOME` defaults to the writable tmpfs `/tmp` unless `sandbox_env` supplies one. Platforms without a numeric uid (Windows) keep the image default. Userns remapping is deliberately not forced: it requires `/etc/subuid` + `/etc/subgid` setup that often does not exist, and a failed `docker run` would break every sandboxed session. - Container destroyed on exit. The teardown `docker exec` that kills the in-container process group after a timeout or cancellation runs under its own 10-second deadline, so a hung Docker daemon cannot wedge the tool call after its timeout already fired. -**The sandbox is on by default for CLI runs.** `odek run`, `odek continue`, and `odek repl` sandbox every session unless something opts out: `--no-sandbox` / `ODEK_NO_SANDBOX=1`, or an explicit `"sandbox": false` in trusted config. When the sandbox is wanted only by *default* (nobody asked for it explicitly) and Docker is unavailable — or a project `Dockerfile.odek`/sandbox knobs lack approval — the run degrades to unsandboxed with a loud notice instead of failing, since breaking every Docker-less user is not containment either. That fallback is reversible policy, not fate: `ODEK_REQUIRE_SANDBOX=1` makes any unsandboxed outcome fatal (including an opt-out — the operator's hard constraint outranks contradictory flags), and an explicit `--sandbox` always hard-fails as before. `odek continue` pins the session's original sandbox posture rather than inheriting the new default, so containment never flips mid-conversation. The rationale is simple: the sandbox is the one control that actually contains the "agent ran attacker-controlled code" class — the failure mode where model quality does not help — so isolation is what you get unless you deliberately give it up. `odek serve` keeps its own default-on behavior. **Deliberate policy call:** a repo that ships an unapproved `Dockerfile.odek` forces the implicit default into the unsandboxed fallback (with the warning naming the fix — approve the project or start Docker). That is exactly the pre-default behavior for such repos, strictly improved by the notice; headless operators who want it fatal set `ODEK_REQUIRE_SANDBOX=1`. +**The sandbox is on by default for CLI runs.** `odek run`, `odek repl`, and `odek serve` sandbox every session unless something opts out: `--no-sandbox` / `ODEK_NO_SANDBOX=1`, or an explicit `"sandbox": false` in trusted config (`~/.odek/config.json` — project `./odek.json` cannot turn it off). When the sandbox is wanted only by *default* (nobody asked for it explicitly) and Docker is unavailable — or a project `Dockerfile.odek`/sandbox knobs lack approval — the run degrades to unsandboxed with a loud notice instead of failing, since breaking every Docker-less user is not containment either. That fallback is reversible policy, not fate: `ODEK_REQUIRE_SANDBOX=1` makes any unsandboxed outcome fatal (including an opt-out — the operator's hard constraint outranks contradictory flags), and an explicit `--sandbox` always hard-fails as before. `odek continue` pins the session's original sandbox posture rather than inheriting the new default, so containment never flips mid-conversation; it does not accept `--no-sandbox` (override via trusted config or `ODEK_NO_SANDBOX=1` / `ODEK_SANDBOX=false`). The rationale is simple: the sandbox is the one control that actually contains the "agent ran attacker-controlled code" class — the failure mode where model quality does not help — so isolation is what you get unless you deliberately give it up. `odek serve` keeps its own default-on behavior. **Deliberate policy call:** a repo that ships an unapproved `Dockerfile.odek` forces the implicit default into the unsandboxed fallback (with the warning naming the fix — approve the project or start Docker). That is exactly the pre-default behavior for such repos, strictly improved by the notice; headless operators who want it fatal set `ODEK_REQUIRE_SANDBOX=1`. **Implicit `Dockerfile.odek` builds are approval-gated.** A `Dockerfile.odek` in the working directory is repo-controlled, and `docker build` executes its `RUN` instructions outside the sandbox threat model (default capabilities, entire working directory readable as build context). The implicit build is therefore gated like project sandbox overrides: an interactive TTY prompt at startup (`y` = once, `t` = trust this project), persisted approvals in `~/.odek/project_sandbox_approvals.json`, or `ODEK_APPROVE_PROJECT_SANDBOX=1` for CI. Non-TTY runs without approval fail closed. The approval key includes the **Dockerfile content hash**, so editing the file invalidates a prior trust and forces re-review, and `setupSandbox` re-verifies approval immediately before building — closing the window where a Dockerfile appears or changes after startup (e.g. a serve-mode sandbox created per WebSocket connection). Builds run with `--network=none` by default, so `RUN` steps cannot fetch payloads or exfiltrate build-context data; `ODEK_SANDBOX_BUILD_NETWORK=1` (operator-only) opts back into networked builds for legitimate package installs. @@ -164,8 +164,8 @@ Regression suites (`internal/danger/classifier_bypass_test.go` and `hardening_te When a classification is set to `prompt`, an approver pauses the agent until the user decides. Three implementations share the same policy helpers: the **TTYApprover** (CLI / REPL, reads from `/dev/tty`), the **WSApprover** (Web UI — sends `approval_request` over WebSocket and relays responses through a non-blocking send on a capacity-1 channel, so a duplicate, late, or raced response cannot block the read goroutine), and the **TelegramApprover** (inline keyboards). -- **Trust shortcuts are withheld for dangerous classes.** The "trust class for session" shortcut is hidden for `destructive`, `blocked`, `unknown`, `persistence`, `unread_exec`, and the synthetic `tool_batch` class. The exclusion lives in one shared place, `danger.TrustShortcutAllowed` — used by the TTY and Web approvers and mirrored in the Telegram approver — and a forged or stale "trust" response for those classes is refused: the Web approver coerces it to a single approve of the pending call, the Telegram approver denies it, and the TTY approver re-prompts with a notice. One Trust click on a batch card can never auto-pass every per-tool prompt for the session. -- **Friction mode** engages after 3 approvals of the same class in 60 s: the next prompt requires typing the literal word `approve` (no single-letter / button shortcut) and imposes a 1.5 s pause before accepting input. This breaks reflex click-through under sustained LLM-driven approval pressure. +- **Trust shortcuts are withheld for dangerous classes.** The "trust class for session" shortcut is hidden for `destructive`, `blocked`, `unknown`, `persistence`, `unread_exec`, and the synthetic `tool_batch` class on the TTY and Web approvers (`danger.TrustShortcutAllowed`). A forged or stale "trust" response for those classes is refused: the Web approver coerces it to a single approve of the pending call, and the TTY approver re-prompts with a notice. The Telegram approver withholds Trust only for `destructive` / `blocked` / `unknown` / `tool_batch` — not `persistence` / `unread_exec`. One Trust click on a batch card can never auto-pass every per-tool prompt for the session. +- **Friction mode** engages after 3 approvals of the same class in 60 s. On TTY the next prompt requires typing the literal word `approve` (no single-letter shortcut) and a 1.5 s pause before accepting input. Telegram (and the Web UI) hide the Trust shortcut and warn; they do not require a typed `approve` or a pause. - TTY prompts are serialized process-wide (one mutex, one shared approval log), so concurrent tool calls cannot print overlapping prompts, and the friction counter and trust cache persist across prompts and across `shell`/`parallel_shell` tool instances. - **Non-interactive defaults to read-only.** When no TTY is available (headless/CI/piped input), prompted operations fall back to the `non_interactive` action, whose built-in default is `"read_only"`: read-only inspection proceeds — `safe`-classified shell commands (`ls`, `cat`, `tree`) and native read tools over ordinary paths — while writes, execution, egress, and reads of sensitive locations (anything at `system_write` or above) are denied. `"deny"` (block everything prompted, including reads) and `"allow"` remain available; an explicitly configured *invalid* value fails closed to `"deny"` with a load-time warning. The read_only default exists because containment via inability is not safe-and-useful: a headless agent that cannot even `ls` gets its operator to flip `non_interactive` to `allow`, which removes every protection — `read_only` is the setting that survives contact with a deadline. @@ -173,7 +173,7 @@ When a classification is set to `prompt`, an approver pauses the agent until the ### Reply/ledger reconciliation -Detection that lands *after* a side effect is reporting, not prevention — but a final reply that **misreports** the side effect is worse than silence, because a confident all-clear actively stops the user from looking. (Observed in the field: the agent planted a persistence hook, then read the payload, correctly identified the injection, and replied "the setup is blocked" — it wasn't.) Before a final answer is returned, the loop diffs its claims against a run-scoped ledger of completed mutating tool calls (`write_file`/`patch`/`batch_patch` successes; `shell`/`parallel_shell` commands classified `local_write` or higher, evaluated per parallel_shell entry so one failed sibling cannot erase its successful neighbors; failed calls excluded). When a reply denies actions the ledger shows completed ("I did not run…", "no changes were made", "the setup is blocked"), odek appends a clearly-attributed consistency notice — the runtime speaking, not the model — naming up to five of the actions, and emits a `reply_ledger_mismatch` signal. The notice header carries an unpredictable `[ref ]` so model output cannot pre-forge the attribution shape; that is best-effort, not proof — the authoritative record is the `reply_ledger_mismatch` signal in the event stream. Claim patterns are deliberately conservative: accurate replies, read-only runs, and denials that match reality are never annotated. +Detection that lands *after* a side effect is reporting, not prevention — but a final reply that **misreports** the side effect is worse than silence, because a confident all-clear actively stops the user from looking. (Observed in the field: the agent planted a persistence hook, then read the payload, correctly identified the injection, and replied "the setup is blocked" — it wasn't.) Before a final answer is returned, the loop diffs its claims against a run-scoped ledger of completed mutating tool calls (`write_file`/`patch`/`batch_patch` successes; `shell`/`parallel_shell` commands classified `local_write` or higher, evaluated per parallel_shell entry so one failed sibling cannot erase its successful neighbors; failed calls excluded). When a reply denies actions the ledger shows completed ("I did not run…", "no changes were made", "the setup is blocked"), odek appends a clearly-attributed consistency notice — the runtime speaking, not the model — naming up to five of the actions, and emits a `reply_ledger_mismatch` signal. The notice header carries an unpredictable `[ref ]` so model output cannot pre-forge the attribution shape; that is best-effort, not proof — the authoritative record is the `reply_ledger_mismatch` `loop.SignalEvent` (WS `agent_signal`, `Config.AgentSignalHandler`), not an `odek.event/v1` JSONL type. Claim patterns are deliberately conservative: accurate replies, read-only runs, and denials that match reality are never annotated. ### Memory taint tracking @@ -286,7 +286,7 @@ A task selects a profile via `delegate_tasks`' `profile` field or `odek subagent | Profile setting | Effect when selected | |---|---| -| `max_risk` | Every class ranked strictly above the cap is forced to `deny` (via the same shared clamp the per-task `max_risk` uses — covering `persistence` and `unread_exec` too). | +| `max_risk` | Every class ranked strictly above the cap is forced to `deny` (via the same shared clamp the per-task `max_risk` uses — covering `persistence`; `unread_exec` is enforced by the trust lockdown, not this cap). | | `allowlist` | **Replaces** the global `dangerous.allowlist` wholesale for profiled sub-agents. | | `tools` | **Replaces** the global `tools` enabled/disabled filter for profiled sub-agents. | @@ -442,7 +442,7 @@ Session files live in an agent-writable directory, so every path constructed fro `odek telegram` can host a native cron scheduler, and any chat/user on the bot allowlist can reach the `/schedule` commands. Because scheduled jobs run headlessly while no one is watching: - Mutating `/schedule` commands (`add`, `rm`, `enable`, `disable`, `run`) are restricted to configured operator chats/users (`schedules.telegram_admin_chats` / `telegram_admin_users`, falling back to `telegram.default_chat_id`). If neither list nor fallback is configured, mutating commands are rejected; read-only commands still work. -- The headless runner forces `non_interactive` to `deny` and clamps destructive, code-execution, install, system-write, network-egress, unknown, and blocked risk classes to `deny`, regardless of the active `dangerous` profile. +- The headless runner forces `non_interactive` to `deny` and always denies `destructive`, `blocked`, `persistence`, and `unread_exec`. Other classes (`code_execution`, `install`, `system_write`, `network_egress`, `unknown`) can still be granted via `schedules.dangerous`. - Results written to `~/.odek/schedule.log` are redacted for secrets before they are persisted. Schedule persistence is hardened against local tampering: state files (`schedules.json`, `schedule-state.json`) are written atomically through `internal/fsatomic`, size-capped (see [Resource bounds](#resource-bounds)), stored in a `0700` directory, and mutating operations serialize across processes with an exclusive `flock` on `~/.odek/schedules.lock`. A lock that cannot be opened or acquired is a hard error — `odek schedule add`, `rm`, `enable`, and state writes abort instead of proceeding without cross-process serialization and clobbering each other's writes. @@ -477,7 +477,7 @@ Hostile or accidental input is bounded everywhere it is sized, to keep it from O | MCP artifact file / refs per envelope | 64 MiB / 64 | | Sub-agent progress stream | 100 K lines / 100 MiB (overflow cancels the child) | | Telegram media download | 5 MiB per file (default) + optional per-chat quota | -| Telegram plan files | reply preview bounded at 3800 chars (`maxTelegramPlanChars`) | +| Telegram plan files | 1 MiB on disk (`maxPlanBytes`); `/plan_status` reply preview bounded at 3800 chars | | Config files | 5 MiB | | `IDENTITY.md` / `--system` | 256 KiB | | Skill files | 1 MiB | @@ -627,7 +627,7 @@ Background jobs inherit the shell tool's security model with no downgrade: | Parent `shell`/`printenv` reads `DEEPSEEK_API_KEY` after startup | `LoadConfig` unsets provider key env vars; SDK uses in-memory credentials | | Runaway sub-agent floods parent with progress NDJSON | 100 K line / 100 MiB cap cancels the child | | `odek subagent --task` deletes an arbitrary user file | Deletion scoped to temp-dir `odek-task-*.json` files | -| Reflex-approve a destructive class after many benign ones | Friction mode: typed `approve` + 1.5 s pause | +| Reflex-approve a destructive class after many benign ones | Friction: TTY requires typed `approve` + 1.5 s pause; Web UI / Telegram hide Trust and warn (no typed pause). REST typed `confirm` only when `dangerous.rest_approval_friction` is on | | One Trust click on a batch card auto-passes everything | `tool_batch`/`unknown`/`destructive` never class-trustable, in all three approvers | | Batch card hides `parallel_shell`/`batch_patch`/browser/MCP payloads | Every command/path classified and shown in full; `SetTrustAll` refused when anything is unclassifiable | | Prompt-injected task runs unattended in CI/pipe | Default `non_interactive` is `"read_only"` — inspection proceeds, mutations fail closed | @@ -639,7 +639,7 @@ Background jobs inherit the shell tool's security model with no downgrade: | Injection payload hidden in an unread script as base64/hex | Pre-exec content audit enriches the `unread_exec` approval with scanner findings (incl. single-layer decode) | | Wrapper script / CI job / Makefile target prepends flags to odek's argv | Unknown CLI flags are a hard error, never folded into the task text | | Agent's final reply claims "blocked"/"no changes" after mutating calls completed | Reply/ledger reconciliation appends an odek-attributed consistency notice + `reply_ledger_mismatch` signal | -| Ran attacker-controlled code on the host | Sandbox defaults ON for run/continue/repl with explicit opt-out (`--no-sandbox`/`ODEK_NO_SANDBOX`); `ODEK_REQUIRE_SANDBOX=1` enforces | +| Ran attacker-controlled code on the host | Sandbox defaults ON for run/repl/serve (`--no-sandbox` / `ODEK_NO_SANDBOX`); continue pins the session bit; `ODEK_REQUIRE_SANDBOX=1` enforces | | Agent overwrites `~/.odek/schedules.json`, sessions, or approvals via file tools | Trust anchors classify `system_write` and are rejected by the CWD carve-out | | Agent writes through a workspace symlink (`etc -> /etc`) | Write tools resolve directory symlinks before classification | | Agent invokes `odek skill promote`/`memory promote` on itself | `odek` self-invocations are `system_write` | diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 810cd21a..934099ec 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -104,10 +104,12 @@ From inside the Telegram bot, session recall is seamless: the current user messa ## Programmatic API ```go +import "github.com/BackendStack21/odek/internal/session" + agent, err := odek.New(odek.Config{...}) // Multi-turn with explicit message history -messages := []llm.Message{ +messages := []session.Message{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: task}, } @@ -183,7 +185,7 @@ odek continue "Run the test suite" # → odek: session was sandboxed — enabling sandbox for this continuation ``` -This prevents accidentally escaping the sandbox on resume. The sandbox image/network/memory still come from the **current** config — only the toggle bit is persisted. To force-disable sandbox on resume, pass `odek continue` in a project with `"sandbox": false` in `./odek.json` and the session flag will be overridden by the explicit config. +This prevents accidentally escaping the sandbox on resume. The sandbox image/network/memory still come from the **current** config — only the toggle bit is persisted. To force-disable sandbox on resume, set `"sandbox": false` in **trusted** config (`~/.odek/config.json`) or `ODEK_NO_SANDBOX=1` / `ODEK_SANDBOX=false`. `odek continue` does not accept `--no-sandbox`, and `"sandbox": false` in `./odek.json` is ignored. ## Provider persistence diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index dea4d8eb..6a65a35d 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -61,7 +61,7 @@ Each sub-agent gets a **fresh context** — no parent history, no conversation s ## Tool: `delegate_tasks` -The `delegate_tasks` tool is available in all odek modes (CLI, REPL, Web UI). The agent calls it automatically when it identifies independent sub-tasks. +The `delegate_tasks` tool is available in CLI, REPL, Web UI, and Telegram. The agent calls it automatically when it identifies independent sub-tasks. Headless `odek schedule` and `odek mcp` do not register it. ### Schema @@ -90,7 +90,10 @@ The `delegate_tasks` tool is available in all odek modes (CLI, REPL, Web UI). Th "code_execution", "network_egress", "install", "blocked"] }, // Optional cap on the allowed risk class. Calls above the // cap are denied without prompting — use for read-only - // fan-out tasks. + // fan-out tasks. Operator profiles.*.max_risk also + // accepts persistence, unknown, and unread_exec + // (validated at load; unread_exec is enforced by the + // trust lockdown, not this clamp). "profile": { "type": "string" } // Optional. Operator-defined capability profile name // (top-level `profiles` config). Its max_risk, allowlist, // and tool filter OVERRIDE the operator's global config diff --git a/docs/TELEGRAM.md b/docs/TELEGRAM.md index 27b477c8..02bbade4 100644 --- a/docs/TELEGRAM.md +++ b/docs/TELEGRAM.md @@ -250,6 +250,7 @@ defense-in-depth. | `/help` | Show all available commands with descriptions | | `/new` | Archive the current session and start a fresh conversation. Archived sessions are timestamped (`tg---`) and remain visible via `odek session list` | | `/stats` | Show session statistics (turn count, model used, etc.) | +| `/jobs` | List background jobs for this chat | | `/stop` | Cancel a running agent task | | `/mode` | Show current agent modes (interaction_mode, tool_progress, sandbox) | | `/restart` | Gracefully restart the bot process. Restricted to operator chats/users and rate-limited to once per 60 seconds. | diff --git a/docs/TOOL_SELECTION.md b/docs/TOOL_SELECTION.md index 17086c02..f35cc486 100644 --- a/docs/TOOL_SELECTION.md +++ b/docs/TOOL_SELECTION.md @@ -28,6 +28,7 @@ environment supports: `list_tools` (live registry + enabled/disabled filter state + MCP server posture with credential argv redacted) - Artifacts: `artifact_read` (parent-side reader for sub-agent result artifacts) +- Background: `bg_start`, `bg_list`, `bg_status`, `bg_output`, `bg_stop` (on by default when `background.enabled` is true) - MCP tools: prefixed as `__` (only when `mcp_servers` are configured) @@ -220,6 +221,7 @@ Use these exact names in config, env vars, and CLI flags: | Session search | `session_search` | | Skills | `skill_load`, `skill_list` | | Sub-agent support | `list_subagent_profiles`, `artifact_read` | +| Background | `bg_start`, `bg_list`, `bg_status`, `bg_output`, `bg_stop` (default on) | | Introspection | `config_view`, `list_tools` | | Principal channel | `clarify` (CLI / REPL when `/dev/tty` is available; Web UI over WebSocket; Telegram). Not registered for sub-agents, schedule, MCP, or headless REST runs. | | Telegram-only | `send_message` (auto-injected by `odek telegram`) | diff --git a/docs/WEBUI.md b/docs/WEBUI.md index ba020ea0..aeb501ca 100644 --- a/docs/WEBUI.md +++ b/docs/WEBUI.md @@ -241,7 +241,7 @@ All `/api/*` endpoints require the per-instance CSRF token (`odek_ws_token` cook Every response carries `Cache-Control: no-store`. The surface covers six groups, detailed below: **sessions** (search/list, detail, rename/pin, export, delete, cancel), **budgets** (`/api/limits`), **agent state** -(models, profiles, resources, tools, memory, skills), **headless runs** +(models, resources, tools, memory, skills), **headless runs** (`/api/prompt` + `/api/runs/*`), **observability** (health, usage, events, connections), and **administration** (config view, MCP listing, memory consolidate, skills promote, shutdown). Session-scoped reads and mutations @@ -253,8 +253,8 @@ never holds the instance token. ### `GET /api/resources?q=&limit=` -`@`-reference search over workspace files, saved sessions, and skills — the -completion backend. `limit` defaults to 10, capped at 100. +`@`-reference search over workspace files and saved sessions — the +completion backend. Skills are not included. `limit` defaults to 10, capped at 100. ```jsonc [ @@ -474,8 +474,11 @@ answerable over REST: | `/api/runs/{id}/approvals` | GET | Pending approval requests (risk, command) | | `/api/runs/{id}/approvals/{aid}` | POST | `{action: "approve" \| "deny" \| "trust"}` | -Answers flow through the same `wsApprover` path as the WebUI — trust caching -and friction behave identically. Tainted/dangerous classes still never offer +Answers flow through the same `wsApprover` path as the WebUI — trust +caching matches, and friction flags are exposed the same way. Typed +confirm on this REST bridge is **opt-in**: default `approve`/`trust` stay +single-field; set `dangerous.rest_approval_friction` to require a +`confirm` field that repeats the action. Tainted/dangerous classes still never offer `trust`. The registry keeps the newest ~100 runs (≥20 completed) and evicts oldest completed first. @@ -523,7 +526,7 @@ handler's defers tear down the agent and sandbox cleanly. ### `GET /api/config` Sanitized resolved-config view: provider id (not the `providers` map), model, sandbox knobs, stream/compaction/ -caching flags, parent `announce_budget`, `thinking` as `""` / `disabled` / `low` / `medium` / `high` (not a boolean), iteration/parallelism limits, memory/skills/tool-filter +caching flags (`compaction` reports the resolved config bit; serve agents currently leave rolling compaction off — see Flags), parent `announce_budget`, `thinking` as `""` / `disabled` / `low` / `medium` / `high` (not a boolean), iteration/parallelism limits, memory/skills/tool-filter summaries, maintenance retention, dangerous default action, guard scan toggles, sub-agent budgets (`subagent`), background-command settings (`background`), and execution budgets with effective token prices @@ -572,6 +575,9 @@ listings return pinned sessions first, and both list and detail carry | `--no-stream` | — | Disable live streaming (bulk `token` events only) | | `--prompt-caching` | on | Enable prompt-caching markers | | `--no-prompt-caching` | — | Disable prompt caching | +| `--compaction` | on (parsed) | Parsed into resolved config and shown on `GET /api/config`. Serve agents currently do **not** copy this onto `odek.Config`, so rolling compaction stays at the library default (`false`). CLI `run` / `continue` / `repl` honor the flag. | +| `--announce-budget` / `--no-announce-budget` | on | Parent 50/75/90% budget-awareness hints | +| `--planning` / `--no-planning` | on | Register the built-in `plan` tool | | `--help`, `-h` | — | Show usage | Plus the shared sandbox flags (`--sandbox`, `--no-sandbox`, `--sandbox-image`, …) — see `odek serve --help`. @@ -784,7 +790,7 @@ match as plain text. The bundled WebUI implements this in | WebSocket handler | `serve.go` (`handleWS`) | Per-connection agent lifecycle, connection registry, ping/pong, server `keepalive`, `cancel` / `session_switch` | | Prompt handler | `serve.go` (`handlePrompt`) | Transport-agnostic (event-sink) prompt path: `@` refs, attachments, audit, per-turn persistence, streaming-suppression logic — shared by the socket and headless REST runs | | Approvals | `wsapprover.go` | WS approver with friction, class-trust, and a configurable approval timeout | -| Management REST | `serve_api.go` | health, sessions (search/pagination/pin/export), memory (+consolidate), skills (+promote), tools, profiles, config view, MCP listing, shutdown | +| Management REST | `serve_api.go` | health, sessions (search/pagination/pin/export), memory (+consolidate), skills (+promote), tools, config view, MCP listing, shutdown | | Runs + observability | `serve_runs.go` | headless run engine (`POST /api/prompt`), remote approval bridge, events ring, usage stats, connection registry | | Resource API | `serve.go` (`handleResourceSearch`) | `@` completion search endpoint | diff --git a/internal/loop/plan.go b/internal/loop/plan.go index 3fa04439..3f1962a1 100644 --- a/internal/loop/plan.go +++ b/internal/loop/plan.go @@ -978,7 +978,7 @@ func (t *PlanTool) Schema() any { }, "required": []string{"id"}, }, - "description": "update only: applied in array order; unknown id or invalid transition fails the whole call (atomic).", + "description": "update only: applied in array order; unknown id or unknown status fails the whole call (atomic).", }, "step_id": map[string]any{ "type": "string", diff --git a/odek.go b/odek.go index 73e55f08..a75ef58a 100644 --- a/odek.go +++ b/odek.go @@ -211,7 +211,7 @@ type Config struct { MaxToolParallel int // SkillEventHandler, if set, is invoked when a skill lifecycle event - // occurs (loaded, autoloaded, saved, deleted, etc.). Used by WebUI + // occurs (loaded, autoloaded, used, deleted, etc.). Used by WebUI // (WebSocket streaming) and Telegram (inline messages). SkillEventHandler func(event skills.SkillEvent) @@ -231,7 +231,9 @@ type Config struct { // (schema odek.event/v1 — see docs/EXTENSIONS.md): run_started, // iteration_completed, tool_call_started/completed/failed, // session_saved, context_trimmed, budget_exceeded, plan_created, - // plan_updated, plan_blocked, run_completed, run_failed. + // plan_updated, plan_blocked, subagent_denied, subagent_spawned, + // subagent_completed, subagent_concurrency_wait, run_completed, + // run_failed. // // Dispatch is non-blocking (buffered channel, drop-on-full) and // panic-isolated: a slow or panicking handler can never stall or crash From 89e986571b0b6172e166c86b137cfb86185ca72c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 09:31:31 +0000 Subject: [PATCH 2/4] docs: correct over-claims from the accuracy pass ODEK_NO_SANDBOX does not override a sandboxed continue pin (only ODEK_SANDBOX=false / trusted config set SandboxExplicit first). Sandboxed resume and odek serve hard-fail without Docker. Schedule still registers delegate_tasks. Web UI friction is typed approve + 1.5s; Telegram Trust is still offered for persistence/unread_exec. Co-authored-by: admin --- docs/CHEATSHEET.md | 2 +- docs/CLI.md | 2 +- docs/CONFIG.md | 3 ++- docs/SANDBOXING.md | 4 ++-- docs/SECURITY.md | 14 +++++++------- docs/SESSIONS.md | 2 +- docs/SUBAGENTS.md | 2 +- 7 files changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 91317e00..3e3a4c32 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -273,7 +273,7 @@ odek repl --sandbox --sandbox-memory 2g --sandbox-cpus 2 - **Implicit default + Docker unavailable** (or unapproved project `Dockerfile.odek`) → degrades to unsandboxed with a loud notice, instead of breaking Docker-less machines. - **`ODEK_REQUIRE_SANDBOX=1`** → any unsandboxed outcome is fatal, including explicit opt-outs (the hard constraint outranks contradictory flags). -- `odek continue` pins the session's original sandbox posture — no mid-conversation containment flips. It does not accept `--no-sandbox`; override with `ODEK_NO_SANDBOX=1` / trusted `"sandbox": false`. +- `odek continue` pins the session's original sandbox posture — no mid-conversation containment flips. It does not accept `--no-sandbox`; override with `ODEK_SANDBOX=false` / trusted `"sandbox": false`. `ODEK_NO_SANDBOX=1` does not override the pin. Flags (`run` / `repl` / `serve`): `--sandbox`, `--no-sandbox`, `--sandbox-image`, `--sandbox-network`, `--sandbox-readonly`, `--sandbox-memory`, `--sandbox-cpus`, `--sandbox-user`. diff --git a/docs/CLI.md b/docs/CLI.md index 431cdbe2..8478f48a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -351,7 +351,7 @@ Procedure for building optimized Docker images. | `--sandbox-user ` | — | Run as user (`uid:gid`) | | `--no-sandbox` | — | Disable the default-on sandbox for `run` / `repl` / `serve` (same as `ODEK_NO_SANDBOX=1`). Prints a warning unless `ODEK_SUPPRESS_SANDBOX_WARNING=1`. `odek continue` does **not** accept this flag. | -`odek run`, `repl`, and `serve` default the sandbox **on**. `odek continue` pins the session's stored sandbox bit (it does not inherit a new default-on). To override that pin, set `ODEK_NO_SANDBOX=1` / `ODEK_SANDBOX=false` or `"sandbox": false` in **trusted** config (`~/.odek/config.json`) — project `./odek.json` `"sandbox": false` is ignored. `odek mcp` is opt-in (`--sandbox`). `odek serve` hard-fails if Docker is missing; `run` / `repl` / `continue` degrade loudly to unsandboxed unless `ODEK_REQUIRE_SANDBOX=1`. +`odek run`, `repl`, and `serve` default the sandbox **on**. `odek continue` pins the session's stored sandbox bit (it does not inherit a new default-on). To override that pin, set `ODEK_SANDBOX=false` or `"sandbox": false` in **trusted** config (`~/.odek/config.json`) — those mark the policy explicit *before* the pin. `ODEK_NO_SANDBOX=1` does **not** override a sandboxed session on continue (it is only consulted when nothing has set `SandboxExplicit`). `odek continue` does not accept `--no-sandbox`, and project `./odek.json` `"sandbox": false` is ignored. `odek mcp` is opt-in (`--sandbox`). `odek serve` hard-fails if Docker is missing. `run` / `repl` implicit default-on degrades loudly to unsandboxed unless `ODEK_REQUIRE_SANDBOX=1`. A sandboxed `continue` is an explicit want, so a missing Docker is fatal. **Project-level sandbox approval:** if `./odek.json` sets `sandbox_env`, `sandbox_image`, `sandbox_network`, or `sandbox_volumes`, odek prompts for approval before applying them. In CI or scripted invocations, set `ODEK_APPROVE_PROJECT_SANDBOX=1` to auto-approve, or place sandbox config in `~/.odek/config.json` / `ODEK_*` env vars / CLI flags instead, which do not require approval. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 63df0706..0f95372d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -60,7 +60,8 @@ Shared across all projects: > `Dockerfile.odek`/sandbox knob lacks approval (`serve` hard-fails instead). > Opt out explicitly with `--no-sandbox`, `ODEK_NO_SANDBOX=1`, or `"sandbox": false` > in trusted config. `odek continue` pins the session's stored sandbox bit and -> does not accept `--no-sandbox`. Make any fallback fatal with +> does not accept `--no-sandbox`. Override the pin with `ODEK_SANDBOX=false` or +> trusted `"sandbox": false` (`ODEK_NO_SANDBOX=1` does not). Make any fallback fatal with > `ODEK_REQUIRE_SANDBOX=1`. An explicit `--sandbox` keeps the hard-fail-on-error > behavior. > diff --git a/docs/SANDBOXING.md b/docs/SANDBOXING.md index 29bb5234..01738602 100644 --- a/docs/SANDBOXING.md +++ b/docs/SANDBOXING.md @@ -1,6 +1,6 @@ # Sandboxing -odek runs agent shell commands inside an **isolated Docker container** — sandboxing is **on by default** for `odek run`, `odek continue`, `odek repl`, and `odek serve`. Opt out with `--no-sandbox` / `ODEK_NO_SANDBOX=1` on `run` / `repl` / `serve` (or make any unsandboxed outcome fatal with `ODEK_REQUIRE_SANDBOX=1`). `odek continue` does not take `--no-sandbox`; it pins the session's stored sandbox bit unless trusted config or `ODEK_NO_SANDBOX=1` / `ODEK_SANDBOX=false` sets an explicit policy. This document covers all configuration options, the `Dockerfile.odek` build system, security guarantees, and best practices. +odek runs agent shell commands inside an **isolated Docker container** — sandboxing is **on by default** for `odek run`, `odek continue`, `odek repl`, and `odek serve`. Opt out with `--no-sandbox` / `ODEK_NO_SANDBOX=1` on `run` / `repl` / `serve` (or make any unsandboxed outcome fatal with `ODEK_REQUIRE_SANDBOX=1`). `odek continue` does not take `--no-sandbox`; it pins the session's stored sandbox bit unless trusted `"sandbox": false` or `ODEK_SANDBOX=false` already set an explicit policy. `ODEK_NO_SANDBOX=1` does not override that pin. This document covers all configuration options, the `Dockerfile.odek` build system, security guarantees, and best practices. ## Quick start @@ -50,7 +50,7 @@ All sandbox settings are available in `~/.odek/config.json`, `./odek.json`, `ODE | Field | Env var | CLI flag | Type | Default | Description | |-------|---------|----------|------|---------|-------------| -| `sandbox` | `ODEK_SANDBOX` | `--sandbox` / `--no-sandbox` (`run` / `repl` / `serve`; not `continue`) | bool | **on** (run/continue/repl/serve) | Sandbox isolation is default-on; `--no-sandbox` or `ODEK_NO_SANDBOX=1` opts out on surfaces that accept the flag; `continue` pins the session bit unless trusted config / env is explicit; `ODEK_REQUIRE_SANDBOX=1` makes any unsandboxed outcome fatal | +| `sandbox` | `ODEK_SANDBOX` | `--sandbox` / `--no-sandbox` (`run` / `repl` / `serve`; not `continue`) | bool | **on** (run/continue/repl/serve) | Sandbox isolation is default-on. `--no-sandbox` or `ODEK_NO_SANDBOX=1` opts out on `run`/`repl`/`serve`. `continue` pins the session bit unless trusted `"sandbox": false` or `ODEK_SANDBOX=false` set `SandboxExplicit` first (`ODEK_NO_SANDBOX=1` does not). `ODEK_REQUIRE_SANDBOX=1` makes any unsandboxed outcome fatal | | `sandbox_image` | `ODEK_SANDBOX_IMAGE` | `--sandbox-image` | string | `alpine:latest` | Docker image for the sandbox container | | `sandbox_network` | `ODEK_SANDBOX_NETWORK` | `--sandbox-network` | string | `none` | Docker network mode | | `sandbox_readonly` | `ODEK_SANDBOX_READONLY` | `--sandbox-readonly` | bool | `false` | Mount working directory read-only | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0f7f42ac..c749ef40 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -39,7 +39,7 @@ Unsandboxed runs print a one-time stderr warning that the agent has full host ac - The container runs as the invoking user's `uid:gid`, not the image default (root for virtually every base image), so workspace writes land as the real user's identity and cannot plant root-owned files or set ownership that breaks later host tooling. The numeric user has no passwd entry, so `HOME` defaults to the writable tmpfs `/tmp` unless `sandbox_env` supplies one. Platforms without a numeric uid (Windows) keep the image default. Userns remapping is deliberately not forced: it requires `/etc/subuid` + `/etc/subgid` setup that often does not exist, and a failed `docker run` would break every sandboxed session. - Container destroyed on exit. The teardown `docker exec` that kills the in-container process group after a timeout or cancellation runs under its own 10-second deadline, so a hung Docker daemon cannot wedge the tool call after its timeout already fired. -**The sandbox is on by default for CLI runs.** `odek run`, `odek repl`, and `odek serve` sandbox every session unless something opts out: `--no-sandbox` / `ODEK_NO_SANDBOX=1`, or an explicit `"sandbox": false` in trusted config (`~/.odek/config.json` — project `./odek.json` cannot turn it off). When the sandbox is wanted only by *default* (nobody asked for it explicitly) and Docker is unavailable — or a project `Dockerfile.odek`/sandbox knobs lack approval — the run degrades to unsandboxed with a loud notice instead of failing, since breaking every Docker-less user is not containment either. That fallback is reversible policy, not fate: `ODEK_REQUIRE_SANDBOX=1` makes any unsandboxed outcome fatal (including an opt-out — the operator's hard constraint outranks contradictory flags), and an explicit `--sandbox` always hard-fails as before. `odek continue` pins the session's original sandbox posture rather than inheriting the new default, so containment never flips mid-conversation; it does not accept `--no-sandbox` (override via trusted config or `ODEK_NO_SANDBOX=1` / `ODEK_SANDBOX=false`). The rationale is simple: the sandbox is the one control that actually contains the "agent ran attacker-controlled code" class — the failure mode where model quality does not help — so isolation is what you get unless you deliberately give it up. `odek serve` keeps its own default-on behavior. **Deliberate policy call:** a repo that ships an unapproved `Dockerfile.odek` forces the implicit default into the unsandboxed fallback (with the warning naming the fix — approve the project or start Docker). That is exactly the pre-default behavior for such repos, strictly improved by the notice; headless operators who want it fatal set `ODEK_REQUIRE_SANDBOX=1`. +**The sandbox is on by default for CLI runs.** `odek run`, `odek repl`, and `odek serve` sandbox every session unless something opts out: `--no-sandbox` / `ODEK_NO_SANDBOX=1`, or an explicit `"sandbox": false` in trusted config (`~/.odek/config.json` — project `./odek.json` cannot turn it off). When the sandbox is wanted only by *default* (nobody asked for it explicitly) and Docker is unavailable — or a project `Dockerfile.odek`/sandbox knobs lack approval — **`odek run` / `odek repl` degrade** to unsandboxed with a loud notice instead of failing, since breaking every Docker-less user is not containment either. **`odek serve` hard-fails** if Docker is missing (no degrade path). That fallback is reversible policy, not fate: `ODEK_REQUIRE_SANDBOX=1` makes any unsandboxed outcome fatal (including an opt-out — the operator's hard constraint outranks contradictory flags), and an explicit `--sandbox` always hard-fails as before. `odek continue` pins the session's original sandbox posture rather than inheriting the new default, so containment never flips mid-conversation; it does not accept `--no-sandbox`. Override the pin with trusted `"sandbox": false` or `ODEK_SANDBOX=false` (`ODEK_NO_SANDBOX=1` does not — the pin already marks the want explicit). A sandboxed resume therefore hard-fails if Docker is down. The rationale is simple: the sandbox is the one control that actually contains the "agent ran attacker-controlled code" class — the failure mode where model quality does not help — so isolation is what you get unless you deliberately give it up. **Deliberate policy call:** a repo that ships an unapproved `Dockerfile.odek` forces the implicit default into the unsandboxed fallback on `run`/`repl` (with the warning naming the fix — approve the project or start Docker). That is exactly the pre-default behavior for such repos, strictly improved by the notice; headless operators who want it fatal set `ODEK_REQUIRE_SANDBOX=1`. **Implicit `Dockerfile.odek` builds are approval-gated.** A `Dockerfile.odek` in the working directory is repo-controlled, and `docker build` executes its `RUN` instructions outside the sandbox threat model (default capabilities, entire working directory readable as build context). The implicit build is therefore gated like project sandbox overrides: an interactive TTY prompt at startup (`y` = once, `t` = trust this project), persisted approvals in `~/.odek/project_sandbox_approvals.json`, or `ODEK_APPROVE_PROJECT_SANDBOX=1` for CI. Non-TTY runs without approval fail closed. The approval key includes the **Dockerfile content hash**, so editing the file invalidates a prior trust and forces re-review, and `setupSandbox` re-verifies approval immediately before building — closing the window where a Dockerfile appears or changes after startup (e.g. a serve-mode sandbox created per WebSocket connection). Builds run with `--network=none` by default, so `RUN` steps cannot fetch payloads or exfiltrate build-context data; `ODEK_SANDBOX_BUILD_NETWORK=1` (operator-only) opts back into networked builds for legitimate package installs. @@ -156,16 +156,16 @@ The classifier resists the common evasion families (see the package doc in `inte Regression suites (`internal/danger/classifier_bypass_test.go` and `hardening_test.go`) pin the known-closed evasions. If you find a new bypass, those test files are the place to add it. -**The `persistence` class (deferred execution).** Anything whose entire purpose is *deferred* execution has a class of its own — keyed on write **targets**, not command shape, because the write is neither destructive, nor egress, nor an in-session install, and the payload fires later in a context the user trusts. Covered targets: shell profiles (`.bashrc`, `.zshrc`, `.profile`, `.zprofile`, fish `config.fish`, …), direnv `.envrc`, `.git/hooks/*`, CI workflow files (`.github/workflows/`, `.gitlab-ci.yml`, …), cron (`crontab` installation, `/etc/cron.*`), systemd system and user units, macOS LaunchAgents/LaunchDaemons, `/etc/profile.d`, `npm pkg set`/`npm set-script` lifecycle hooks, and `jq '.scripts…'` rewrites of `package.json`. Write tools additionally sniff content: a `package.json` edit that plants an install lifecycle script (`preinstall`, `postinstall`, `prepare`, …) or a `conftest.py` edit that plants an `autouse=True` fixture escalates even though the file itself is ordinary. The class ranks above `system_write`, prompts by default, is denied under non-interactive `deny`, and — like `destructive` — is never eligible for the session-trust shortcut: its writes execute *outside* the session that granted the trust. Reads keep the plain classifier (`ClassifyPath`); only writes (`ClassifyPathWrite`) escalate, so reading a CI workflow or hook file stays frictionless. +**The `persistence` class (deferred execution).** Anything whose entire purpose is *deferred* execution has a class of its own — keyed on write **targets**, not command shape, because the write is neither destructive, nor egress, nor an in-session install, and the payload fires later in a context the user trusts. Covered targets: shell profiles (`.bashrc`, `.zshrc`, `.profile`, `.zprofile`, fish `config.fish`, …), direnv `.envrc`, `.git/hooks/*`, CI workflow files (`.github/workflows/`, `.gitlab-ci.yml`, …), cron (`crontab` installation, `/etc/cron.*`), systemd system and user units, macOS LaunchAgents/LaunchDaemons, `/etc/profile.d`, `npm pkg set`/`npm set-script` lifecycle hooks, and `jq '.scripts…'` rewrites of `package.json`. Write tools additionally sniff content: a `package.json` edit that plants an install lifecycle script (`preinstall`, `postinstall`, `prepare`, …) or a `conftest.py` edit that plants an `autouse=True` fixture escalates even though the file itself is ordinary. The class ranks above `system_write`, prompts by default, is denied under non-interactive `deny`, and — like `destructive` — is withheld from the session-trust shortcut on TTY and Web (`danger.TrustShortcutAllowed`): its writes execute *outside* the session that granted the trust. Telegram still offers Trust for `persistence` / `unread_exec`. Reads keep the plain classifier (`ClassifyPath`); only writes (`ClassifyPathWrite`) escalate, so reading a CI workflow or hook file stays frictionless. -**The `unread_exec` class (unread-script gate).** Executing a repo-supplied script — directly (`./env.sh`), via an interpreter (`bash env.sh`, `python tool.py`), or by sourcing it (`source env.sh`) — whose contents have not been read **in this session** gates as `unread_exec`. A read ledger (`danger.RecordRead`/`WasRead`) is populated by full-file `read_file`/`batch_read` calls (a partial offset/limit window over a longer file does not count — the payload can ride below the fold), by file tools that author content (`write_file`/`patch`/`batch_patch`), and by plain successful shell viewers (`cat file`, `head file` — any pipe or redirect disables recording, because `cat payload.sh > run.sh` produces a copy the model never saw). A **failed** read never licenses execution — the observed failure mode of a capable model whose `cat` errored on a path typo and fell back to running the file stays gated. The gate intercepts approval even when `code_execution` was set to `allow` or its class trusted (the entire point is per-script review), is never session-trust-shortcuttable, and participates in configuration like a class: `"unread_exec": "deny"` blocks unread-script execution outright; `"unread_exec": "allow"` permits it only when the underlying class is also allowed — both must allow. **Fingerprinted licenses (TOCTOU).** The ledger binds each read to the file state at display time (size + mtime, and sha256 for files up to 1 MiB): a file mutated after its read — via another tool, a lifecycle hook, or a background process — loses its license and the gate re-fires until the mutated content is re-read (re-reading renews the fingerprint, because now the model has seen THAT). **Pre-execution content audit.** When the gate prompts, the approval description carries content evidence from the local injection scanner over the target's leading 256 KiB, including a best-effort single-layer base64/hex decode of embedded blobs — the human decides with the bytes, not just a path. The audit is read-only and never populates the ledger (the auditor is not the model). **Session-keyed ledgers.** Long-lived surfaces (`serve`, `telegram`, `schedule`) stamp `danger.WithLedgerKey` on the run context; file/shell tools record and gate against that key, so a read in session A cannot license execution in session B. `Classify()` / `ClassifyScriptGate()` without a context still use the process-global default ledger (CLI-shaped tests and the classifier itself). +**The `unread_exec` class (unread-script gate).** Executing a repo-supplied script — directly (`./env.sh`), via an interpreter (`bash env.sh`, `python tool.py`), or by sourcing it (`source env.sh`) — whose contents have not been read **in this session** gates as `unread_exec`. A read ledger (`danger.RecordRead`/`WasRead`) is populated by full-file `read_file`/`batch_read` calls (a partial offset/limit window over a longer file does not count — the payload can ride below the fold), by file tools that author content (`write_file`/`patch`/`batch_patch`), and by plain successful shell viewers (`cat file`, `head file` — any pipe or redirect disables recording, because `cat payload.sh > run.sh` produces a copy the model never saw). A **failed** read never licenses execution — the observed failure mode of a capable model whose `cat` errored on a path typo and fell back to running the file stays gated. The gate intercepts approval even when `code_execution` was set to `allow` or its class trusted (the entire point is per-script review), is never session-trust-shortcuttable on TTY and Web (`danger.TrustShortcutAllowed`; Telegram still offers Trust), and participates in configuration like a class: `"unread_exec": "deny"` blocks unread-script execution outright; `"unread_exec": "allow"` permits it only when the underlying class is also allowed — both must allow. **Fingerprinted licenses (TOCTOU).** The ledger binds each read to the file state at display time (size + mtime, and sha256 for files up to 1 MiB): a file mutated after its read — via another tool, a lifecycle hook, or a background process — loses its license and the gate re-fires until the mutated content is re-read (re-reading renews the fingerprint, because now the model has seen THAT). **Pre-execution content audit.** When the gate prompts, the approval description carries content evidence from the local injection scanner over the target's leading 256 KiB, including a best-effort single-layer base64/hex decode of embedded blobs — the human decides with the bytes, not just a path. The audit is read-only and never populates the ledger (the auditor is not the model). **Session-keyed ledgers.** Long-lived surfaces (`serve`, `telegram`, `schedule`) stamp `danger.WithLedgerKey` on the run context; file/shell tools record and gate against that key, so a read in session A cannot license execution in session B. `Classify()` / `ClassifyScriptGate()` without a context still use the process-global default ledger (CLI-shaped tests and the classifier itself). ### Tool-call approval When a classification is set to `prompt`, an approver pauses the agent until the user decides. Three implementations share the same policy helpers: the **TTYApprover** (CLI / REPL, reads from `/dev/tty`), the **WSApprover** (Web UI — sends `approval_request` over WebSocket and relays responses through a non-blocking send on a capacity-1 channel, so a duplicate, late, or raced response cannot block the read goroutine), and the **TelegramApprover** (inline keyboards). - **Trust shortcuts are withheld for dangerous classes.** The "trust class for session" shortcut is hidden for `destructive`, `blocked`, `unknown`, `persistence`, `unread_exec`, and the synthetic `tool_batch` class on the TTY and Web approvers (`danger.TrustShortcutAllowed`). A forged or stale "trust" response for those classes is refused: the Web approver coerces it to a single approve of the pending call, and the TTY approver re-prompts with a notice. The Telegram approver withholds Trust only for `destructive` / `blocked` / `unknown` / `tool_batch` — not `persistence` / `unread_exec`. One Trust click on a batch card can never auto-pass every per-tool prompt for the session. -- **Friction mode** engages after 3 approvals of the same class in 60 s. On TTY the next prompt requires typing the literal word `approve` (no single-letter shortcut) and a 1.5 s pause before accepting input. Telegram (and the Web UI) hide the Trust shortcut and warn; they do not require a typed `approve` or a pause. +- **Friction mode** engages after 3 approvals of the same class in 60 s. On TTY **and the bundled Web UI** the next prompt requires typing the literal word `approve` (no single-letter shortcut) and a 1.5 s pause before accepting input. Telegram hides the Trust shortcut and warns; a button `approve` still works (no typed word, no pause). REST typed `confirm` is opt-in (`dangerous.rest_approval_friction`). - TTY prompts are serialized process-wide (one mutex, one shared approval log), so concurrent tool calls cannot print overlapping prompts, and the friction counter and trust cache persist across prompts and across `shell`/`parallel_shell` tool instances. - **Non-interactive defaults to read-only.** When no TTY is available (headless/CI/piped input), prompted operations fall back to the `non_interactive` action, whose built-in default is `"read_only"`: read-only inspection proceeds — `safe`-classified shell commands (`ls`, `cat`, `tree`) and native read tools over ordinary paths — while writes, execution, egress, and reads of sensitive locations (anything at `system_write` or above) are denied. `"deny"` (block everything prompted, including reads) and `"allow"` remain available; an explicitly configured *invalid* value fails closed to `"deny"` with a load-time warning. The read_only default exists because containment via inability is not safe-and-useful: a headless agent that cannot even `ls` gets its operator to flip `non_interactive` to `allow`, which removes every protection — `read_only` is the setting that survives contact with a deadline. @@ -627,11 +627,11 @@ Background jobs inherit the shell tool's security model with no downgrade: | Parent `shell`/`printenv` reads `DEEPSEEK_API_KEY` after startup | `LoadConfig` unsets provider key env vars; SDK uses in-memory credentials | | Runaway sub-agent floods parent with progress NDJSON | 100 K line / 100 MiB cap cancels the child | | `odek subagent --task` deletes an arbitrary user file | Deletion scoped to temp-dir `odek-task-*.json` files | -| Reflex-approve a destructive class after many benign ones | Friction: TTY requires typed `approve` + 1.5 s pause; Web UI / Telegram hide Trust and warn (no typed pause). REST typed `confirm` only when `dangerous.rest_approval_friction` is on | +| Reflex-approve a destructive class after many benign ones | Friction: TTY and Web UI require typed `approve` + 1.5 s pause; Telegram hides Trust and warns (button still works). REST typed `confirm` only when `dangerous.rest_approval_friction` is on | | One Trust click on a batch card auto-passes everything | `tool_batch`/`unknown`/`destructive` never class-trustable, in all three approvers | | Batch card hides `parallel_shell`/`batch_patch`/browser/MCP payloads | Every command/path classified and shown in full; `SetTrustAll` refused when anything is unclassifiable | | Prompt-injected task runs unattended in CI/pipe | Default `non_interactive` is `"read_only"` — inspection proceeds, mutations fail closed | -| Agent plants one documented line in a shell profile / git hook / CI workflow / crontab | `persistence` class: deferred-execution write targets prompt (and deny headless), never trust-shortcuttable | +| Agent plants one documented line in a shell profile / git hook / CI workflow / crontab | `persistence` class: deferred-execution write targets prompt (and deny headless); TTY/Web withhold Trust, Telegram still offers it | | `package.json` edit smuggles a `preinstall` hook; `conftest.py` an autouse fixture | Write-side content sniffing escalates to `persistence` | | Agent runs the script it just fixed to verify it (payload inside the documented fix) | `unread_exec` gate: unread script file operands require per-script approval | | `cat env.sh` fails, agent falls back to executing it | Failed reads never populate the session read ledger — execution stays gated | @@ -639,7 +639,7 @@ Background jobs inherit the shell tool's security model with no downgrade: | Injection payload hidden in an unread script as base64/hex | Pre-exec content audit enriches the `unread_exec` approval with scanner findings (incl. single-layer decode) | | Wrapper script / CI job / Makefile target prepends flags to odek's argv | Unknown CLI flags are a hard error, never folded into the task text | | Agent's final reply claims "blocked"/"no changes" after mutating calls completed | Reply/ledger reconciliation appends an odek-attributed consistency notice + `reply_ledger_mismatch` signal | -| Ran attacker-controlled code on the host | Sandbox defaults ON for run/repl/serve (`--no-sandbox` / `ODEK_NO_SANDBOX`); continue pins the session bit; `ODEK_REQUIRE_SANDBOX=1` enforces | +| Ran attacker-controlled code on the host | Sandbox defaults ON for run/repl/serve (`--no-sandbox` / `ODEK_NO_SANDBOX`); continue pins the session bit (override: `ODEK_SANDBOX=false` / trusted config); `serve` and sandboxed `continue` hard-fail without Docker; `ODEK_REQUIRE_SANDBOX=1` enforces | | Agent overwrites `~/.odek/schedules.json`, sessions, or approvals via file tools | Trust anchors classify `system_write` and are rejected by the CWD carve-out | | Agent writes through a workspace symlink (`etc -> /etc`) | Write tools resolve directory symlinks before classification | | Agent invokes `odek skill promote`/`memory promote` on itself | `odek` self-invocations are `system_write` | diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 934099ec..e66d3fc4 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -185,7 +185,7 @@ odek continue "Run the test suite" # → odek: session was sandboxed — enabling sandbox for this continuation ``` -This prevents accidentally escaping the sandbox on resume. The sandbox image/network/memory still come from the **current** config — only the toggle bit is persisted. To force-disable sandbox on resume, set `"sandbox": false` in **trusted** config (`~/.odek/config.json`) or `ODEK_NO_SANDBOX=1` / `ODEK_SANDBOX=false`. `odek continue` does not accept `--no-sandbox`, and `"sandbox": false` in `./odek.json` is ignored. +This prevents accidentally escaping the sandbox on resume. The sandbox image/network/memory still come from the **current** config — only the toggle bit is persisted. To force-disable sandbox on resume, set `"sandbox": false` in **trusted** config (`~/.odek/config.json`) or `ODEK_SANDBOX=false` — those mark the policy explicit *before* the pin. `ODEK_NO_SANDBOX=1` does **not** override a sandboxed session. `odek continue` does not accept `--no-sandbox`, and `"sandbox": false` in `./odek.json` is ignored. A sandboxed resume hard-fails if Docker is unavailable (the pin is an explicit want). ## Provider persistence diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 6a65a35d..03787eea 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -61,7 +61,7 @@ Each sub-agent gets a **fresh context** — no parent history, no conversation s ## Tool: `delegate_tasks` -The `delegate_tasks` tool is available in CLI, REPL, Web UI, and Telegram. The agent calls it automatically when it identifies independent sub-tasks. Headless `odek schedule` and `odek mcp` do not register it. +The `delegate_tasks` tool is available in CLI, REPL, Web UI, Telegram, and headless scheduled runs. The agent calls it automatically when it identifies independent sub-tasks. `odek mcp` does not register it. ### Schema From 1b9862e300cbd4d6661b8f68dc179220dcca5868 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 10:01:32 +0000 Subject: [PATCH 3/4] fix: wire compaction and Telegram Trust to shared policy Serve and Telegram now copy resolved.Compaction onto odek.Config so rolling compaction matches run/repl/schedule. Serve accepts --no-compaction. Telegram Trust uses danger.TrustShortcutAllowed, so persistence and unread_exec cannot be session-trusted. Co-authored-by: admin --- cmd/odek/compaction_wire_test.go | 23 +++++++++ cmd/odek/serve.go | 5 ++ cmd/odek/telegram.go | 1 + docs/CLI.md | 2 +- docs/SECURITY.md | 8 +-- docs/TELEGRAM.md | 8 +-- docs/WEBUI.md | 4 +- internal/telegram/approver.go | 10 ++-- internal/telegram/approver_test.go | 83 +++++++++++++++++++----------- 9 files changed, 99 insertions(+), 45 deletions(-) create mode 100644 cmd/odek/compaction_wire_test.go diff --git a/cmd/odek/compaction_wire_test.go b/cmd/odek/compaction_wire_test.go new file mode 100644 index 00000000..3352d750 --- /dev/null +++ b/cmd/odek/compaction_wire_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// TestLongLivedAgents_CopyResolvedCompaction pins that serve and Telegram +// pass resolved.Compaction into odek.Config. Those surfaces parse the flag +// / config bit; omitting the field left rolling compaction at the library +// default (off) while GET /api/config still reported it on. +func TestLongLivedAgents_CopyResolvedCompaction(t *testing.T) { + for _, name := range []string{"serve.go", "telegram.go"} { + b, err := os.ReadFile(name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if !strings.Contains(string(b), "Compaction:") || !strings.Contains(string(b), "resolved.Compaction") { + t.Errorf("%s must copy resolved.Compaction onto odek.Config", name) + } + } +} diff --git a/cmd/odek/serve.go b/cmd/odek/serve.go index 6ffc1e2b..0cad7641 100644 --- a/cmd/odek/serve.go +++ b/cmd/odek/serve.go @@ -385,6 +385,8 @@ func serveCmd(args []string) error { promptCaching = boolPtr(false) case "--compaction": compaction = boolPtr(true) + case "--no-compaction": + compaction = boolPtr(false) case "--announce-budget": announceBudget = boolPtr(true) case "--no-announce-budget": @@ -693,6 +695,8 @@ Flags: --sandbox-user user Container user (e.g. 1000:1000) --prompt-caching Enable prompt caching (default: on) --no-prompt-caching Disable prompt caching + --compaction Enable rolling compaction (default: on) + --no-compaction Disable rolling compaction --announce-budget Enable parent budget-awareness hints (default: on) --no-announce-budget Disable parent budget-awareness hints --stream Stream LLM responses live to the Web UI (default: on) @@ -978,6 +982,7 @@ func newServeAgent(resolved config.ResolvedConfig, system string, runKey string, // Disable with --no-prompt-caching / ODEK_PROMPT_CACHING=false / // config "prompt_caching": false. Library odek.New stays opt-in. PromptCaching: resolved.PromptCaching, + Compaction: resolved.Compaction, AnnounceBudget: &resolved.AnnounceBudget, // Live streaming: forward SSE fragments to the browser as // thinking_delta / token_delta events (docs/STREAMING.md). Default diff --git a/cmd/odek/telegram.go b/cmd/odek/telegram.go index d7543522..4005c0df 100644 --- a/cmd/odek/telegram.go +++ b/cmd/odek/telegram.go @@ -1798,6 +1798,7 @@ func handleChatMessage( ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled}, Renderer: rend, PromptCaching: resolved.PromptCaching, + Compaction: resolved.Compaction, AnnounceBudget: &resolved.AnnounceBudget, MemoryConfig: resolved.Memory, MemoryDir: expandHome("~/.odek/memory"), diff --git a/docs/CLI.md b/docs/CLI.md index 8478f48a..b0b254e7 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -25,7 +25,7 @@ | `odek memory extended [args]` | Extended-memory operations: delete/promote/pin atoms, list or confirm/reject pending-review atoms, quarantine listing, manual compaction, store stats, consolidate, and proactive-nudge management | | `odek audit ` | Print the prompt-injection audit log for a session (JSON) | | `odek audit --list` | List sessions with non-zero ingest counts and divergence flags | -| `odek serve [--addr ] [--open] [--no-sandbox] [--trusted-proxies ] [--log-file ]` | Web UI server (default `127.0.0.1:8080`). Sandbox is on by default; pass `--no-sandbox` to disable. Flags: `--tool` / `--no-tool` (repeatable), `--prompt-caching` / `--no-prompt-caching`, `--compaction`, `--announce-budget` / `--no-announce-budget`, `--planning` / `--no-planning`, `--stream` / `--no-stream`, `--log-file` (durable run/turn log, default `~/.odek/serve.log`). Binding to a non-loopback address prints a loud warning because anyone with the token can drive the agent. `--trusted-proxies` honours `X-Forwarded-For`/`X-Real-Ip` only from those addresses. | +| `odek serve [--addr ] [--open] [--no-sandbox] [--trusted-proxies ] [--log-file ]` | Web UI server (default `127.0.0.1:8080`). Sandbox is on by default; pass `--no-sandbox` to disable. Flags: `--tool` / `--no-tool` (repeatable), `--prompt-caching` / `--no-prompt-caching`, `--compaction` / `--no-compaction`, `--announce-budget` / `--no-announce-budget`, `--planning` / `--no-planning`, `--stream` / `--no-stream`, `--log-file` (durable run/turn log, default `~/.odek/serve.log`). Binding to a non-loopback address prints a loud warning because anyone with the token can drive the agent. `--trusted-proxies` honours `X-Forwarded-For`/`X-Real-Ip` only from those addresses. | | `odek subagent --goal [flags]` | Run a focused sub-task; outputs JSON on stdout. Spawned by `delegate_tasks` tool. Flags: `--goal`, `--task `, `--context`, `--timeout` (≤1800s), `--max-iter` (≤100), `--profile `, `--parent-session `, `--quiet`, `--stream`. | | `odek init [--global|--local] [--force]` | Create a config file template (scope-aware: full schema globally, project-safe fields locally) | | `odek mcp [--sandbox]` | MCP server over stdio (built-in tools minus `delegate_tasks` / `memory`). Also loads `mcp_servers`. Sandbox is opt-in (`--sandbox`), unlike `odek run`. See [MCP.md](MCP.md) | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index c749ef40..bfadbca9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -156,15 +156,15 @@ The classifier resists the common evasion families (see the package doc in `inte Regression suites (`internal/danger/classifier_bypass_test.go` and `hardening_test.go`) pin the known-closed evasions. If you find a new bypass, those test files are the place to add it. -**The `persistence` class (deferred execution).** Anything whose entire purpose is *deferred* execution has a class of its own — keyed on write **targets**, not command shape, because the write is neither destructive, nor egress, nor an in-session install, and the payload fires later in a context the user trusts. Covered targets: shell profiles (`.bashrc`, `.zshrc`, `.profile`, `.zprofile`, fish `config.fish`, …), direnv `.envrc`, `.git/hooks/*`, CI workflow files (`.github/workflows/`, `.gitlab-ci.yml`, …), cron (`crontab` installation, `/etc/cron.*`), systemd system and user units, macOS LaunchAgents/LaunchDaemons, `/etc/profile.d`, `npm pkg set`/`npm set-script` lifecycle hooks, and `jq '.scripts…'` rewrites of `package.json`. Write tools additionally sniff content: a `package.json` edit that plants an install lifecycle script (`preinstall`, `postinstall`, `prepare`, …) or a `conftest.py` edit that plants an `autouse=True` fixture escalates even though the file itself is ordinary. The class ranks above `system_write`, prompts by default, is denied under non-interactive `deny`, and — like `destructive` — is withheld from the session-trust shortcut on TTY and Web (`danger.TrustShortcutAllowed`): its writes execute *outside* the session that granted the trust. Telegram still offers Trust for `persistence` / `unread_exec`. Reads keep the plain classifier (`ClassifyPath`); only writes (`ClassifyPathWrite`) escalate, so reading a CI workflow or hook file stays frictionless. +**The `persistence` class (deferred execution).** Anything whose entire purpose is *deferred* execution has a class of its own — keyed on write **targets**, not command shape, because the write is neither destructive, nor egress, nor an in-session install, and the payload fires later in a context the user trusts. Covered targets: shell profiles (`.bashrc`, `.zshrc`, `.profile`, `.zprofile`, fish `config.fish`, …), direnv `.envrc`, `.git/hooks/*`, CI workflow files (`.github/workflows/`, `.gitlab-ci.yml`, …), cron (`crontab` installation, `/etc/cron.*`), systemd system and user units, macOS LaunchAgents/LaunchDaemons, `/etc/profile.d`, `npm pkg set`/`npm set-script` lifecycle hooks, and `jq '.scripts…'` rewrites of `package.json`. Write tools additionally sniff content: a `package.json` edit that plants an install lifecycle script (`preinstall`, `postinstall`, `prepare`, …) or a `conftest.py` edit that plants an `autouse=True` fixture escalates even though the file itself is ordinary. The class ranks above `system_write`, prompts by default, is denied under non-interactive `deny`, and — like `destructive` — is withheld from the session-trust shortcut on TTY, Web, and Telegram (`danger.TrustShortcutAllowed`): its writes execute *outside* the session that granted the trust. Reads keep the plain classifier (`ClassifyPath`); only writes (`ClassifyPathWrite`) escalate, so reading a CI workflow or hook file stays frictionless. -**The `unread_exec` class (unread-script gate).** Executing a repo-supplied script — directly (`./env.sh`), via an interpreter (`bash env.sh`, `python tool.py`), or by sourcing it (`source env.sh`) — whose contents have not been read **in this session** gates as `unread_exec`. A read ledger (`danger.RecordRead`/`WasRead`) is populated by full-file `read_file`/`batch_read` calls (a partial offset/limit window over a longer file does not count — the payload can ride below the fold), by file tools that author content (`write_file`/`patch`/`batch_patch`), and by plain successful shell viewers (`cat file`, `head file` — any pipe or redirect disables recording, because `cat payload.sh > run.sh` produces a copy the model never saw). A **failed** read never licenses execution — the observed failure mode of a capable model whose `cat` errored on a path typo and fell back to running the file stays gated. The gate intercepts approval even when `code_execution` was set to `allow` or its class trusted (the entire point is per-script review), is never session-trust-shortcuttable on TTY and Web (`danger.TrustShortcutAllowed`; Telegram still offers Trust), and participates in configuration like a class: `"unread_exec": "deny"` blocks unread-script execution outright; `"unread_exec": "allow"` permits it only when the underlying class is also allowed — both must allow. **Fingerprinted licenses (TOCTOU).** The ledger binds each read to the file state at display time (size + mtime, and sha256 for files up to 1 MiB): a file mutated after its read — via another tool, a lifecycle hook, or a background process — loses its license and the gate re-fires until the mutated content is re-read (re-reading renews the fingerprint, because now the model has seen THAT). **Pre-execution content audit.** When the gate prompts, the approval description carries content evidence from the local injection scanner over the target's leading 256 KiB, including a best-effort single-layer base64/hex decode of embedded blobs — the human decides with the bytes, not just a path. The audit is read-only and never populates the ledger (the auditor is not the model). **Session-keyed ledgers.** Long-lived surfaces (`serve`, `telegram`, `schedule`) stamp `danger.WithLedgerKey` on the run context; file/shell tools record and gate against that key, so a read in session A cannot license execution in session B. `Classify()` / `ClassifyScriptGate()` without a context still use the process-global default ledger (CLI-shaped tests and the classifier itself). +**The `unread_exec` class (unread-script gate).** Executing a repo-supplied script — directly (`./env.sh`), via an interpreter (`bash env.sh`, `python tool.py`), or by sourcing it (`source env.sh`) — whose contents have not been read **in this session** gates as `unread_exec`. A read ledger (`danger.RecordRead`/`WasRead`) is populated by full-file `read_file`/`batch_read` calls (a partial offset/limit window over a longer file does not count — the payload can ride below the fold), by file tools that author content (`write_file`/`patch`/`batch_patch`), and by plain successful shell viewers (`cat file`, `head file` — any pipe or redirect disables recording, because `cat payload.sh > run.sh` produces a copy the model never saw). A **failed** read never licenses execution — the observed failure mode of a capable model whose `cat` errored on a path typo and fell back to running the file stays gated. The gate intercepts approval even when `code_execution` was set to `allow` or its class trusted (the entire point is per-script review), is never session-trust-shortcuttable (`danger.TrustShortcutAllowed`, all three approvers), and participates in configuration like a class: `"unread_exec": "deny"` blocks unread-script execution outright; `"unread_exec": "allow"` permits it only when the underlying class is also allowed — both must allow. **Fingerprinted licenses (TOCTOU).** The ledger binds each read to the file state at display time (size + mtime, and sha256 for files up to 1 MiB): a file mutated after its read — via another tool, a lifecycle hook, or a background process — loses its license and the gate re-fires until the mutated content is re-read (re-reading renews the fingerprint, because now the model has seen THAT). **Pre-execution content audit.** When the gate prompts, the approval description carries content evidence from the local injection scanner over the target's leading 256 KiB, including a best-effort single-layer base64/hex decode of embedded blobs — the human decides with the bytes, not just a path. The audit is read-only and never populates the ledger (the auditor is not the model). **Session-keyed ledgers.** Long-lived surfaces (`serve`, `telegram`, `schedule`) stamp `danger.WithLedgerKey` on the run context; file/shell tools record and gate against that key, so a read in session A cannot license execution in session B. `Classify()` / `ClassifyScriptGate()` without a context still use the process-global default ledger (CLI-shaped tests and the classifier itself). ### Tool-call approval When a classification is set to `prompt`, an approver pauses the agent until the user decides. Three implementations share the same policy helpers: the **TTYApprover** (CLI / REPL, reads from `/dev/tty`), the **WSApprover** (Web UI — sends `approval_request` over WebSocket and relays responses through a non-blocking send on a capacity-1 channel, so a duplicate, late, or raced response cannot block the read goroutine), and the **TelegramApprover** (inline keyboards). -- **Trust shortcuts are withheld for dangerous classes.** The "trust class for session" shortcut is hidden for `destructive`, `blocked`, `unknown`, `persistence`, `unread_exec`, and the synthetic `tool_batch` class on the TTY and Web approvers (`danger.TrustShortcutAllowed`). A forged or stale "trust" response for those classes is refused: the Web approver coerces it to a single approve of the pending call, and the TTY approver re-prompts with a notice. The Telegram approver withholds Trust only for `destructive` / `blocked` / `unknown` / `tool_batch` — not `persistence` / `unread_exec`. One Trust click on a batch card can never auto-pass every per-tool prompt for the session. +- **Trust shortcuts are withheld for dangerous classes.** The "trust class for session" shortcut is hidden for `destructive`, `blocked`, `unknown`, `persistence`, `unread_exec`, and the synthetic `tool_batch` class on TTY, Web, and Telegram (`danger.TrustShortcutAllowed`). A forged or stale "trust" response for those classes is refused: the Web approver coerces it to a single approve of the pending call, the Telegram approver denies it, and the TTY approver re-prompts with a notice. One Trust click on a batch card can never auto-pass every per-tool prompt for the session. - **Friction mode** engages after 3 approvals of the same class in 60 s. On TTY **and the bundled Web UI** the next prompt requires typing the literal word `approve` (no single-letter shortcut) and a 1.5 s pause before accepting input. Telegram hides the Trust shortcut and warns; a button `approve` still works (no typed word, no pause). REST typed `confirm` is opt-in (`dangerous.rest_approval_friction`). - TTY prompts are serialized process-wide (one mutex, one shared approval log), so concurrent tool calls cannot print overlapping prompts, and the friction counter and trust cache persist across prompts and across `shell`/`parallel_shell` tool instances. - **Non-interactive defaults to read-only.** When no TTY is available (headless/CI/piped input), prompted operations fall back to the `non_interactive` action, whose built-in default is `"read_only"`: read-only inspection proceeds — `safe`-classified shell commands (`ls`, `cat`, `tree`) and native read tools over ordinary paths — while writes, execution, egress, and reads of sensitive locations (anything at `system_write` or above) are denied. `"deny"` (block everything prompted, including reads) and `"allow"` remain available; an explicitly configured *invalid* value fails closed to `"deny"` with a load-time warning. The read_only default exists because containment via inability is not safe-and-useful: a headless agent that cannot even `ls` gets its operator to flip `non_interactive` to `allow`, which removes every protection — `read_only` is the setting that survives contact with a deadline. @@ -631,7 +631,7 @@ Background jobs inherit the shell tool's security model with no downgrade: | One Trust click on a batch card auto-passes everything | `tool_batch`/`unknown`/`destructive` never class-trustable, in all three approvers | | Batch card hides `parallel_shell`/`batch_patch`/browser/MCP payloads | Every command/path classified and shown in full; `SetTrustAll` refused when anything is unclassifiable | | Prompt-injected task runs unattended in CI/pipe | Default `non_interactive` is `"read_only"` — inspection proceeds, mutations fail closed | -| Agent plants one documented line in a shell profile / git hook / CI workflow / crontab | `persistence` class: deferred-execution write targets prompt (and deny headless); TTY/Web withhold Trust, Telegram still offers it | +| Agent plants one documented line in a shell profile / git hook / CI workflow / crontab | `persistence` class: deferred-execution write targets prompt (and deny headless); Trust withheld on TTY, Web, and Telegram | | `package.json` edit smuggles a `preinstall` hook; `conftest.py` an autouse fixture | Write-side content sniffing escalates to `persistence` | | Agent runs the script it just fixed to verify it (payload inside the documented fix) | `unread_exec` gate: unread script file operands require per-script approval | | `cat env.sh` fails, agent falls back to executing it | Failed reads never populate the session read ledger — execution stays gated | diff --git a/docs/TELEGRAM.md b/docs/TELEGRAM.md index 02bbade4..e07cb909 100644 --- a/docs/TELEGRAM.md +++ b/docs/TELEGRAM.md @@ -215,10 +215,10 @@ The handler uses `sync.Map` for `TelegramApprover` instances, keyed by `chatID`. Each approval prompt shows the full command and risk class. The **Trust Session** shortcut is hidden for the highest-impact classes (`destructive`, `blocked`, -`unknown`, and the synthetic `tool_batch` class) so they must be approved -per-call. After three approvals of the same class within 60 seconds, friction -mode hides the Trust Session shortcut and adds a warning, breaking reflexive -tap-through. +`unknown`, `persistence`, `unread_exec`, and the synthetic `tool_batch` class) +so they must be approved per-call. After three approvals of the same class +within 60 seconds, friction mode hides the Trust Session shortcut and adds a +warning, breaking reflexive tap-through. ### Outbound Media diff --git a/docs/WEBUI.md b/docs/WEBUI.md index aeb501ca..c676a267 100644 --- a/docs/WEBUI.md +++ b/docs/WEBUI.md @@ -526,7 +526,7 @@ handler's defers tear down the agent and sandbox cleanly. ### `GET /api/config` Sanitized resolved-config view: provider id (not the `providers` map), model, sandbox knobs, stream/compaction/ -caching flags (`compaction` reports the resolved config bit; serve agents currently leave rolling compaction off — see Flags), parent `announce_budget`, `thinking` as `""` / `disabled` / `low` / `medium` / `high` (not a boolean), iteration/parallelism limits, memory/skills/tool-filter +caching flags, parent `announce_budget`, `thinking` as `""` / `disabled` / `low` / `medium` / `high` (not a boolean), iteration/parallelism limits, memory/skills/tool-filter summaries, maintenance retention, dangerous default action, guard scan toggles, sub-agent budgets (`subagent`), background-command settings (`background`), and execution budgets with effective token prices @@ -575,7 +575,7 @@ listings return pinned sessions first, and both list and detail carry | `--no-stream` | — | Disable live streaming (bulk `token` events only) | | `--prompt-caching` | on | Enable prompt-caching markers | | `--no-prompt-caching` | — | Disable prompt caching | -| `--compaction` | on (parsed) | Parsed into resolved config and shown on `GET /api/config`. Serve agents currently do **not** copy this onto `odek.Config`, so rolling compaction stays at the library default (`false`). CLI `run` / `continue` / `repl` honor the flag. | +| `--compaction` / `--no-compaction` | on | Rolling compaction (dropped-turn digest). Copied onto serve agents from resolved config. | | `--announce-budget` / `--no-announce-budget` | on | Parent 50/75/90% budget-awareness hints | | `--planning` / `--no-planning` | on | Register the built-in `plan` tool | | `--help`, `-h` | — | Show usage | diff --git a/internal/telegram/approver.go b/internal/telegram/approver.go index f08f8378..039804b8 100644 --- a/internal/telegram/approver.go +++ b/internal/telegram/approver.go @@ -152,12 +152,12 @@ func (a *TelegramApprover) recordApproval(cls danger.RiskClass) { // PromptCommand sends an approval request with inline keyboard and waits // for the user to respond. Returns nil on approve/trust, error on deny/timeout. -// allowTrustForClass mirrors the TTY/Web approver policy: the highest-impact -// classes must never be session-trusted, and the synthetic `tool_batch` class -// must not be trusted because a single batch approval could hide multiple -// unrelated dangerous tools. +// allowTrustForClass is the Telegram face of danger.TrustShortcutAllowed: +// destructive, blocked, unknown, persistence, unread_exec, and the synthetic +// tool_batch class must never be session-trusted. A forged or stale Trust +// callback for those classes is denied (same as a tap on Deny). func allowTrustForClass(cls danger.RiskClass) bool { - return cls != danger.Destructive && cls != danger.Blocked && cls != danger.Unknown && cls != "tool_batch" + return danger.TrustShortcutAllowed(cls) } func (a *TelegramApprover) PromptCommand(cls danger.RiskClass, cmd, description string) error { diff --git a/internal/telegram/approver_test.go b/internal/telegram/approver_test.go index 14cfde4a..057dd0f1 100644 --- a/internal/telegram/approver_test.go +++ b/internal/telegram/approver_test.go @@ -538,40 +538,65 @@ func TestTelegramApprover_TrustDisabledForHighImpactClasses(t *testing.T) { a := NewTelegramApprover(bot, 1, 0) - done := make(chan error, 1) - go func() { - done <- a.PromptCommand(danger.Destructive, "rm -rf /", "") - }() + for _, cls := range []danger.RiskClass{danger.Destructive, danger.Persistence, danger.UnreadExec} { + done := make(chan error, 1) + go func(cls danger.RiskClass) { + done <- a.PromptCommand(cls, "probe", "") + }(cls) + + var body string + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + rec.mu.Lock() + if len(rec.requests) > 0 { + body = rec.requests[len(rec.requests)-1].Body + } + rec.mu.Unlock() + if body != "" { + break + } + time.Sleep(10 * time.Millisecond) + } + if body == "" { + t.Fatalf("%s: prompt request was not sent", cls) + } + if strings.Contains(body, "Trust Session") { + t.Errorf("%s prompt should not offer Trust Session: %q", cls, body) + } - // Wait for the prompt request to be sent. - var body string - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - rec.mu.Lock() - if len(rec.requests) > 0 { - body = rec.requests[len(rec.requests)-1].Body + id := extractCallbackID(body, cbPrefixApprove) + if id == "" { + t.Fatalf("%s: could not extract approve callback id", cls) } - rec.mu.Unlock() - if body != "" { - break + a.HandleCallback(cbPrefixApprove+id, 0) + if err := <-done; err != nil { + t.Fatalf("%s: approve should succeed: %v", cls, err) } - time.Sleep(10 * time.Millisecond) - } - if body == "" { - t.Fatal("prompt request was not sent") - } - if strings.Contains(body, "Trust Session") { - t.Errorf("destructive prompt should not offer Trust Session: %q", body) + rec.mu.Lock() + rec.requests = nil + rec.mu.Unlock() } +} - // Extract the callback ID and send an approve so PromptCommand returns. - id := extractCallbackID(body, cbPrefixApprove) - if id == "" { - t.Fatal("could not extract approve callback id") - } - a.HandleCallback(cbPrefixApprove+id, 0) - if err := <-done; err != nil { - t.Fatalf("approve should succeed: %v", err) +func TestAllowTrustForClass_MatchesTrustShortcutAllowed(t *testing.T) { + classes := []danger.RiskClass{ + danger.Safe, + danger.LocalWrite, + danger.SystemWrite, + danger.Persistence, + danger.Destructive, + danger.NetworkEgress, + danger.CodeExecution, + danger.Install, + danger.Unknown, + danger.Blocked, + danger.UnreadExec, + "tool_batch", + } + for _, cls := range classes { + if got, want := allowTrustForClass(cls), danger.TrustShortcutAllowed(cls); got != want { + t.Errorf("allowTrustForClass(%s) = %v, want %v", cls, got, want) + } } } From b387733af5cd5ec9ac83f0bcb7e8032162f35ff8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 10:07:58 +0000 Subject: [PATCH 4/4] docs: drop leftover continue-as-default-on claims Continue pins the session sandbox bit; it is not a new default-on like run/repl/serve. Tighten the compaction wiring pin to the assignment line and point go install at the forthcoming v2.11.2 tag. Co-authored-by: admin --- GETTING_STARTED.md | 5 +++-- README.md | 2 +- cmd/odek/compaction_wire_test.go | 10 +++++++++- docs/CHEATSHEET.md | 4 ++-- docs/CONFIG.md | 2 +- docs/SANDBOXING.md | 4 ++-- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index ac5a140a..66090a39 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -147,8 +147,9 @@ settings belong in the global config. ## 4. Sandbox (Docker, optional) Tool execution runs inside an isolated Docker container **by default** for -`odek run`, `odek continue`, `odek repl`, and `odek serve`. If you don't have -Docker installed, opt out explicitly: +`odek run`, `odek repl`, and `odek serve`. `odek continue` pins the session's +stored sandbox bit rather than applying a new default. If you don't have +Docker installed, opt out explicitly on `run`/`repl`/`serve`: ```bash export ODEK_NO_SANDBOX=1 # add to ~/.zshrc / ~/.bashrc diff --git a/README.md b/README.md index b20cf311..b7c40ce4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ One binary. One loop. Zero frameworks. ReAct (Reasoning + Acting) — think, the # Install from a v2 release tag (requires Go ≥ 1.25.13 — see GETTING_STARTED.md). # Do not use @latest: Go ignores v2 tags on this module path and would # install an older v1 release. -go install github.com/BackendStack21/odek/cmd/odek@v2.11.1 +go install github.com/BackendStack21/odek/cmd/odek@v2.11.2 # Use (provider env key — DEEPSEEK_API_KEY for the default provider) export DEEPSEEK_API_KEY=sk-... diff --git a/cmd/odek/compaction_wire_test.go b/cmd/odek/compaction_wire_test.go index 3352d750..03586787 100644 --- a/cmd/odek/compaction_wire_test.go +++ b/cmd/odek/compaction_wire_test.go @@ -16,7 +16,15 @@ func TestLongLivedAgents_CopyResolvedCompaction(t *testing.T) { if err != nil { t.Fatalf("read %s: %v", name, err) } - if !strings.Contains(string(b), "Compaction:") || !strings.Contains(string(b), "resolved.Compaction") { + found := false + for _, line := range strings.Split(string(b), "\n") { + s := strings.TrimSpace(line) + if strings.HasPrefix(s, "Compaction:") && strings.Contains(s, "resolved.Compaction") { + found = true + break + } + } + if !found { t.Errorf("%s must copy resolved.Compaction onto odek.Config", name) } } diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 3e3a4c32..6cf791f8 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -29,7 +29,7 @@ odek memory extended pending # List atoms pending review odek memory extended confirm # Approve a pending-review atom odek memory extended forget # Delete an atom -# Sandbox (ON by default for run/continue/repl/serve — see Sandbox section) +# Sandbox (ON by default for run/repl/serve; continue pins — see Sandbox section) odek run --sandbox "build safely" # Explicit: hard-fails if Docker is unavailable odek run --no-sandbox "quick task" # Explicit opt-out odek serve --sandbox --sandbox-readonly --sandbox-network none @@ -261,7 +261,7 @@ delegate_tasks tasks=[{goal: "task A", context: "..."}, {goal: "task B"}] ## Sandbox -**On by default** for `odek run` / `odek continue` / `odek repl` — the container is the control for "agent ran attacker-controlled code", so isolation is what you get unless you deliberately give it up. +**On by default** for `odek run` / `odek repl` / `odek serve` — the container is the control for "agent ran attacker-controlled code", so isolation is what you get unless you deliberately give it up. ```bash odek run "install deps" # default-on: sandboxed when Docker is up diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 0f95372d..d0264691 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1290,7 +1290,7 @@ Why each key is pinned: Deliberately **not** set, because the defaults are the recommendation: -- `sandbox` — on by default for `run`/`continue`/`repl`/`serve`; never turn it off on a host that runs untrusted code. +- `sandbox` — on by default for `run`/`repl`/`serve`; `continue` pins the session bit; never turn it off on a host that runs untrusted code. - `memory.extract_facts: false` and `memory.auto_approve_episodes: false` — the secure defaults; flip only with the trade-offs understood (see [`extract_facts`](#extract_facts--automatic-fact-learning-opt-in-off-by-default)). - `dangerous` — the built-in class defaults (destructive/blocked/unknown denied, writes and egress prompted) are the right posture; tighten per-project with an `allowlist`/`denylist` only when needed. - `web_search.base_url` — empty hides the tool; set it only if you run a SearXNG instance. diff --git a/docs/SANDBOXING.md b/docs/SANDBOXING.md index 01738602..d93609fe 100644 --- a/docs/SANDBOXING.md +++ b/docs/SANDBOXING.md @@ -1,6 +1,6 @@ # Sandboxing -odek runs agent shell commands inside an **isolated Docker container** — sandboxing is **on by default** for `odek run`, `odek continue`, `odek repl`, and `odek serve`. Opt out with `--no-sandbox` / `ODEK_NO_SANDBOX=1` on `run` / `repl` / `serve` (or make any unsandboxed outcome fatal with `ODEK_REQUIRE_SANDBOX=1`). `odek continue` does not take `--no-sandbox`; it pins the session's stored sandbox bit unless trusted `"sandbox": false` or `ODEK_SANDBOX=false` already set an explicit policy. `ODEK_NO_SANDBOX=1` does not override that pin. This document covers all configuration options, the `Dockerfile.odek` build system, security guarantees, and best practices. +odek runs agent shell commands inside an **isolated Docker container** — sandboxing is **on by default** for `odek run`, `odek repl`, and `odek serve`. Opt out with `--no-sandbox` / `ODEK_NO_SANDBOX=1` on those commands (or make any unsandboxed outcome fatal with `ODEK_REQUIRE_SANDBOX=1`). `odek continue` does not take `--no-sandbox`; it pins the session's stored sandbox bit unless trusted `"sandbox": false` or `ODEK_SANDBOX=false` already set an explicit policy. `ODEK_NO_SANDBOX=1` does not override that pin. This document covers all configuration options, the `Dockerfile.odek` build system, security guarantees, and best practices. ## Quick start @@ -50,7 +50,7 @@ All sandbox settings are available in `~/.odek/config.json`, `./odek.json`, `ODE | Field | Env var | CLI flag | Type | Default | Description | |-------|---------|----------|------|---------|-------------| -| `sandbox` | `ODEK_SANDBOX` | `--sandbox` / `--no-sandbox` (`run` / `repl` / `serve`; not `continue`) | bool | **on** (run/continue/repl/serve) | Sandbox isolation is default-on. `--no-sandbox` or `ODEK_NO_SANDBOX=1` opts out on `run`/`repl`/`serve`. `continue` pins the session bit unless trusted `"sandbox": false` or `ODEK_SANDBOX=false` set `SandboxExplicit` first (`ODEK_NO_SANDBOX=1` does not). `ODEK_REQUIRE_SANDBOX=1` makes any unsandboxed outcome fatal | +| `sandbox` | `ODEK_SANDBOX` | `--sandbox` / `--no-sandbox` (`run` / `repl` / `serve`; not `continue`) | bool | **on** (run/repl/serve); continue pins | Sandbox isolation is default-on for `run`/`repl`/`serve`. `--no-sandbox` or `ODEK_NO_SANDBOX=1` opts out on those commands. `continue` pins the session bit unless trusted `"sandbox": false` or `ODEK_SANDBOX=false` set `SandboxExplicit` first (`ODEK_NO_SANDBOX=1` does not). `ODEK_REQUIRE_SANDBOX=1` makes any unsandboxed outcome fatal | | `sandbox_image` | `ODEK_SANDBOX_IMAGE` | `--sandbox-image` | string | `alpine:latest` | Docker image for the sandbox container | | `sandbox_network` | `ODEK_SANDBOX_NETWORK` | `--sandbox-network` | string | `none` | Docker network mode | | `sandbox_readonly` | `ODEK_SANDBOX_READONLY` | `--sandbox-readonly` | bool | `false` | Mount working directory read-only |