diff --git a/CLAUDE.md b/CLAUDE.md index a4a6e507f..216cf0588 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,13 @@ PRs must meet **≥ 80% coverage on changed lines**. Enforced by [`.github/workf **Agent access mode** — the `[autonomy]` block (`src/openhuman/config/schema/autonomy.rs`) drives the agent's filesystem/shell reach via `SecurityPolicy` (`src/openhuman/security/policy.rs`). Tiers: `level` (`readonly` = read-only / `supervised` = "ask before edit" / `full` = full access) × `workspace_only` × `trusted_roots` (per-folder `read`/`readwrite` grants outside the workspace, overriding `forbidden_paths` for their subtree) × `allow_tool_install` (gates `install_tool`). Edit live via the `config.update_autonomy_settings` RPC or **Settings → Agent access** (`AgentAccessPanel.tsx`); changes swap the process-global policy in `security::live_policy` and apply to new sessions. The default projects home is `~/OpenHuman/projects` (`config::default_projects_dir`, env `OPENHUMAN_PROJECTS_DIR`), auto-created at startup and injected as a ReadWrite trusted root — distinct from the hidden internal `~/.openhuman/workspace`. +**Action sandbox vs internal workspace** — `Config` exposes two distinct path roots (`src/openhuman/config/schema/types.rs`): + +- **`action_dir`** — the agent's read/write root. **Acting tools (`shell`, `node_exec`, `npm_exec`, `file_write`, `edit_file`, `apply_patch`, `git_operations`, `codegraph_*`) resolve relative paths and default CWD to `action_dir`, not `workspace_dir`.** Defaults to `config::default_action_dir()`, which falls back to `default_projects_dir()` (`~/OpenHuman/projects`); override with `OPENHUMAN_ACTION_DIR`. Auto-created at startup (`channels/runtime/startup.rs`). `action_dir` is independently configurable from `default_projects_dir()` even though they share a default. +- **`workspace_dir`** — internal product state (`~/.openhuman/users//workspace`). Holds `memory/`, `memory_tree/`, `sessions/`, `session_raw/`, `vault/`, `approval/`, `subconscious/`, `task_sources/`, `mcp_clients/`, `cron/`, `devices/`, `whatsapp_data/`, `redirect_links/`, `codegraph/`, `state/`, `.openhuman/`, plus secret files (`core.token`, `dev-keychain.json`, `.env`, `SOUL.md`, `IDENTITY.md`, `HEARTBEAT.md`, `PROFILE.md`). Agent tools **cannot** write here — `SecurityPolicy::is_workspace_internal_path` (`src/openhuman/security/policy.rs:1097`) is the source of truth for the denylist (`WORKSPACE_INTERNAL_DIRS` / `WORKSPACE_INTERNAL_FILES` at `policy.rs:173-202`), enforced fail-closed in `is_path_string_allowed` regardless of tier (`workspace_only`, `full`, or any `trusted_root` that happens to overlap). Both sides are canonicalised before comparison, so symlink escapes don't bypass. + +Overriding `OPENHUMAN_ACTION_DIR` does **not** weaken the internal denylist — `workspace_dir` is always blocked even if `action_dir` is relocated to overlap it. Adding new internal-state subdirectories under `workspace_dir` requires extending `WORKSPACE_INTERNAL_DIRS` (or `WORKSPACE_INTERNAL_FILES`) in the same change; otherwise an agent tool with a `trusted_root` grant could write into the new subtree. + **Command permission model (deterministic, fail-closed):** `classify_command` buckets a command into `CommandClass` (`Read` / `Write` / `Network` / `Install` / `Destructive`); an unrecognized command is **`Write`**, never `Read`. `gate_decision(class, tier)` → `Allow` / `Prompt` / `Block`: read-only allows only reads; ask-before-edit prompts every act (file *create* is free, *edit-existing* prompts); full runs read+write but **always-asks** Network/Install/Destructive. Acting tools (`shell`/`node_exec`/`npm_exec`/`file_write`/`edit_file`/`apply_patch`/`git_operations`/`curl`) return `external_effect_with_args() == true` for `Prompt` classes so the harness routes them through the `ApprovalGate` *before* `execute()`; read-only `Block` + structural guards (`check_gated_command`) are enforced in-tool. The LLM may pass a `category` (escalate-only: `max(rust_floor, declared)`). System/credential dirs are an **unconditional** cross-platform block (`is_always_forbidden`, trusted-root-proof). Enforcement is in Rust (`classify_command`/`gate_decision`/`check_gated_command`/`is_path_string_allowed`/`validate_path`), never the system prompt. > ⚠️ **The approval prompt is ON by default** (opt out with `OPENHUMAN_APPROVAL_GATE=0`/`false`, `jsonrpc.rs`). `ApprovalGate::init_global` installs unless disabled, so `try_global()` is `Some` and the prompt is wired end-to-end; with `OPENHUMAN_APPROVAL_GATE=0` the harness skips the intercept and `Prompt`-class calls **run unprompted**. The gate parks only for **interactive chat turns** (a `tokio` task-local chat context is set in `channels/providers/web.rs`; background triage/cron turns carry no context and are allowed through, not gated). It publishes `DomainEvent::ApprovalRequested`, which `ApprovalSurfaceSubscriber` bridges to the `approval_request` web-channel socket event; the frontend (`ChatApprovalRequestEvent` → `chatRuntime.pendingApprovalByThread` → `ApprovalRequestCard` above the composer) surfaces Approve/Deny, routing to the `openhuman.approval_decide` RPC. A typed `yes`/`no` chat reply is also honoured server-side (web.rs ingress router runs before the "newer request aborts the in-flight turn" path); any other text cancels the parked turn and is taken as a fresh message. Unanswered prompts still park to the 10-min TTL → Deny. Read-only blocking, path hardening, structural guards, and classification **are** live regardless of the flag. Full access ships as documented full-trust (not sandboxed).