Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1135,13 +1135,13 @@ Categorisation lives at the call sites (fs tools resolve workspace/home/outside

**Dangling-symlink containment.** `canonicalizeMutationTarget` classifies by where a write actually lands. For an existing path `realpath` follows links. For a **dangling** leaf symlink (`leak.txt` → a not-yet-existing file outside the workspace) `realpath` throws ENOENT; naively re-gluing the leaf to its parent would call it a workspace write, but `writeFile` follows the link and creates the file at the target. So on ENOENT we `lstat` the leaf, and if it is a symlink we resolve its target (recursively, chain-bounded) and classify that instead. Only a genuinely new file (non-symlink ENOENT leaf) uses the deepest-existing-ancestor fallback, whose missing suffix cannot contain symlinks because it does not exist. This is what makes the panel's "symlinks pointing outside still ask" copy true for broken links too.

**Level changes are machine-local (Telegram carve-out, by design).** `/privacy level` exists only in the TUI / CLI, not the Telegram inbound-handler. The ladder is a persistent trust posture for *this host* — it decides what runs unattended — so raising it should require access to the physical machine, not a remote chat. The Telegram operator still approves or denies each gated action per-request through `ApprovalBridge`; only the durable level is TUI/CLI-local. A compromised bot token therefore cannot silently raise the standing trust level.
**Level changes are machine-local (Telegram carve-out, by design).** `/privacy level` exists only in the TUI / CLI, not the Telegram inbound-handler. The ladder is a persistent trust posture for *this host* — it decides what runs unattended — so raising it should require access to the physical machine, not a remote chat. The Telegram operator decides each gated action through `ApprovalBridge` — per call, or for the rest of the session via a grant (see §"Session grants") — but the durable level stays TUI/CLI-local. A compromised bot token therefore cannot silently raise the standing trust level.

Slash commands: `/privacy` opens the tab; `/privacy analytics on|off|status` drives analytics (`/analytics` stays as the top-level alias); `/privacy level 1..5` moves the ladder; `/privacy approve on|off` survives as the alias pair for levels 5 and 1. The approval prompt itself points here — its footer says approving with `y` grants one call and the ladder lives on the Privacy tab.

### Session grants (prompt-side approval, issue #79)

On top of the standing ladder the approval prompt offers two point exceptions, keyed at the prompt and scoped to the current session only. `[s]` ("allow this kind this session") approves the call and grants the whole `ApprovalCategory`; `[a]` ("allow all `<binary>` this session") approves and grants one shell command shape. `[y]` still approves the single call with no grant, `[n]`/`esc` deny. The grant is offered on the TUI `ApprovalModal` (keys routed in `app-key-bindings.ts`) and the CLI `run` stdin prompt; both surfaces have physical machine access, matching the level carve-out. Telegram/`ApprovalBridge` never grants: a remote channel approves per-request only, so a compromised bot token cannot raise session trust any more than it can raise the standing level.
On top of the standing ladder the approval prompt offers two point exceptions, keyed at the prompt and scoped to the current session only. `[s]` ("allow this kind this session") approves the call and grants the whole `ApprovalCategory`; `[a]` ("allow all `<binary>` this session") approves and grants one shell command shape. `[y]` still approves the single call with no grant, `[n]`/`esc` deny. The grant is offered on the TUI `ApprovalModal` (keys routed in `app-key-bindings.ts`), the CLI `run` stdin prompt, and — since issue #230 — the Telegram `ApprovalBridge`, as the `🔓 Grant …` rows of the inline keyboard. The first two surfaces have physical machine access, matching the level carve-out; Telegram does not, and #230 took that asymmetry on knowingly rather than by oversight: without a grant a remote operator re-approves every `git diff` one at a time, and the only escape left was raising the *standing* level to 5, which is strictly more permissive and durable. What the earlier "Telegram never grants" rule was protecting still holds: a Telegram grant is in-memory and session-scoped like any other, it cannot cover `trust_config`, it cannot bypass the hardline shell guard, and it cannot move the standing level — `/privacy level` stays TUI/CLI-only, so the durable posture still needs the machine. What genuinely changed: a remote chat can now raise trust *for the rest of one session*, which it previously could not.

Grants live in-memory on the `ApprovalGate`, keyed by the id of the session that made them (`grantsBySession: Map<string, {categories, shapes}>`); they never persist to `config.json` (session-scoped is a safer default than a durable global toggle, which is what the issue asks for). Because grants are keyed by session id and `autoApproval` matches on `request.sessionId`, a request from a *different* session — a background-task turn on the scheduler, a second live session on the same runtime — shares the gate but never rides another session's grant: "session-scoped" is a structural fact, not a promise every caller remembers to keep. `clearSessionGrants()` additionally fires on `newSession()` / `switchSession()` as a belt-and-braces reset of the leaving session. The standing level is untouched: a grant is a point exception layered over the posture, not a move of the posture. The gate checks grants in `request()` **after** the standing level and **before** emitting a prompt (`autoApproval`), returning a distinct reason (`session grant` / `session grant: <shape>`).

Expand All @@ -1157,7 +1157,7 @@ The shape is the guard's own normalised binary (basename, lowercased) that the s
6. **A write to the agent's own trust config is never silent below level 5.** `config.json` and `.env` map to `trust_config` (pinned at level 5) by realpath match, so the model cannot raise its own `approvalLevel` or swap a token without a prompt. Pinned by `fs-approval-scope.test.ts` (symlink / `..` / fresh-install / batch cases) and the level-4 `bootstrap.test.ts` case (`os.fs.write` to `config.json` prompts with category `trust_config` even where `fs_write_home` is silent).
7. **Persist first, then hot-apply, and say when they diverge.** `PrivacyOrchestrator.setApprovalLevel` writes `config.json` before touching the gate; if the hot-apply throws after a successful persist, the sticky error names the already-rewritten `config.json` so the operator knows the next boot picks the new value up. Levels clamp to [1, 5] at every runtime surface; the config parser rejects non-integers outright.
8. **The prompt carries its category to every host UX.** `ApprovalRequest.category` is forwarded so a host shows *why* the prompt fired, not just the tool name: the TUI `ApprovalModal`, the CLI `run` stdin prompt, and the Telegram `ApprovalBridge` render a human label (`formatApprovalCategory`, e.g. `file write · home`); the sidecar protocol adds an **optional** `category` to `ApprovalRequestPayload` (back-compat: pre-ladder hosts ignore it) and the HTTP `/api/events` SSE already streams the full request. The Tauri host UI is a separate TS consumer of the protocol type — surfacing the new field there is its own follow-up, but nothing is silently dropped on the wire. Pinned by `approval-modal.test.tsx`, `approval-bridge.test.ts`, and the label matrix in `approval-level.test.ts`.
9. **Session grants never bypass hardline or `trust_config`, and never persist.** A grant is a session-scoped, in-memory point exception layered over the standing level; it silences its category/shape by returning early in `ApprovalGate.request` before a prompt is emitted. It cannot bypass the hardline shell guard: hardline returns `block` in `shell.ts` **before** `requireApproval` reaches the gate, so a catastrophic command is stopped whether or not `shell` (or the `rm` shape) is granted. It cannot silence `trust_config`: `isGrantableCategory` excludes it on both the record path (`resolve`) and the auto-approve path (`request`), so a config/`.env` write always prompts even after a broad grant. Honest boundary: the `trust_config` guard covers the fs tools (`categorizeFsMutation`), not the shell. A write to config via a shell redirect (`echo >> config.json`) stays under the `shell` category and is silenced by a shell grant, the same as at standing level 4 (operator). Grants do not widen this pre-existing class; closing it means intercepting shell redirects, a separate piece of work. Grants are TUI/CLI-local (never from Telegram) and keyed by session id, so a request from another session — a background-task turn, a second live session on the same runtime — never rides a grant it did not make; `clearSessionGrants()` also drops the leaving session's grants on every session change. Pinned by `approval-gate.test.ts` (grant category / grant shape / trust_config refused / per-session isolation for category and shape / targeted-and-full clear / union snapshot / no-shape shell / `canGrant*` offer logic), `shell.test.ts` (interpreter shape suppression), the Privacy panel + reducer + orchestrator tests (the read-only grants view), and the two `bootstrap.test.ts` end-to-end cases (a category grant silences later shell commands while `rm -rf /` stays hardline-blocked; a shape grant silences one binary while another still prompts).
9. **Session grants never bypass hardline or `trust_config`, and never persist.** A grant is a session-scoped, in-memory point exception layered over the standing level; it silences its category/shape by returning early in `ApprovalGate.request` before a prompt is emitted. It cannot bypass the hardline shell guard: hardline returns `block` in `shell.ts` **before** `requireApproval` reaches the gate, so a catastrophic command is stopped whether or not `shell` (or the `rm` shape) is granted. It cannot silence `trust_config`: `isGrantableCategory` excludes it on both the record path (`resolve`) and the auto-approve path (`request`), so a config/`.env` write always prompts even after a broad grant. Honest boundary: the `trust_config` guard covers the fs tools (`categorizeFsMutation`), not the shell. A write to config via a shell redirect (`echo >> config.json`) stays under the `shell` category and is silenced by a shell grant, the same as at standing level 4 (operator). Grants do not widen this pre-existing class; closing it means intercepting shell redirects, a separate piece of work. Grants can be made from the TUI, the CLI, or the Telegram keyboard (see the §"Session grants" note on that asymmetry), and are keyed by session id, so a request from another session — a background-task turn, a second live session on the same runtime — never rides a grant it did not make; `clearSessionGrants()` also drops the leaving session's grants on every session change. Pinned by `approval-gate.test.ts` (grant category / grant shape / trust_config refused / per-session isolation for category and shape / targeted-and-full clear / union snapshot / no-shape shell / `canGrant*` offer logic), `shell.test.ts` (interpreter shape suppression), the Privacy panel + reducer + orchestrator tests (the read-only grants view), and the two `bootstrap.test.ts` end-to-end cases (a category grant silences later shell commands while `rm -rf /` stays hardline-blocked; a shape grant silences one binary while another still prompts).

## Durable tasks

Expand Down Expand Up @@ -1524,7 +1524,7 @@ Telegram has its own dedicated session, persisted as a pointer in `<stateDir>/te

When a `runtime.runTurn` call originated on Telegram (`{ origin: "telegram" }`), `ApprovalRouter` ([src/approval/approval-router.ts](src/approval/approval-router.ts)) routes the `ApprovalRequest` to `ApprovalBridge` ([approval-bridge.ts](src/channels/telegram/approval-bridge.ts)) instead of falling through to the host UI. The bridge:

- Sends a 2-button inline keyboard (`✅ Approve` / `❌ Deny`) to the owner's DM as plain text (no MarkdownV2 — escaping rules are easy to get wrong with tool names containing backticks / underscores).
- Sends an inline keyboard to the owner's DM as plain text (no MarkdownV2 — escaping rules are easy to get wrong with tool names containing backticks / underscores): `✅ Approve` / `❌ Deny`, plus a `🔓 Grant category for session` row when `canGrantCategory` allows one and a `🔓 Grant "<shape>" for session` row when `canGrantShape` does. The scope is re-checked against the retained request when the button comes back — `callback_data` is operator-supplied and a keyboard outlives the state it was built from — so a scope the gate would drop degrades to a plain approval and the toast never announces an exception that was not recorded.
- Validates the callback `userId` against the live `ownerUserId` mirror — a stale callback from a previous owner is rejected.
- Auto-denies after 8 minutes (`config.telegram.approvalTimeoutMs`) and edits the original message to `⏱ timed out — auto-denied` with the buttons removed.
- Folds button-click / timeout / external-cancel into a single `approvals.resolve()` call; double-resolution is prevented by a `pending` map check.
Expand Down
Loading
Loading