From 5d3b6cb2a6ba5a2d175c82acf35528cef9066660 Mon Sep 17 00:00:00 2001 From: Raul Date: Tue, 28 Apr 2026 10:45:32 +0200 Subject: [PATCH 1/6] fix: stop duplicating CLI streamed output --- src/channels/cli.test.ts | 65 ++++++++++++++++++++++++++++++++++++++++ src/channels/cli.ts | 7 ----- 2 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 src/channels/cli.test.ts diff --git a/src/channels/cli.test.ts b/src/channels/cli.test.ts new file mode 100644 index 00000000..7cb4a7a5 --- /dev/null +++ b/src/channels/cli.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CLIChannel } from './cli.js'; + +const originalIsTTY = process.stdout.isTTY; +const originalColumns = process.stdout.columns; + +function streamChunks(...chunks: string[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield chunk; + } + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: originalIsTTY, + }); + + Object.defineProperty(process.stdout, 'columns', { + configurable: true, + value: originalColumns, + }); +}); + +describe('CLIChannel.stream', () => { + it('does not print the final streamed content twice in TTY mode', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: true, + }); + + Object.defineProperty(process.stdout, 'columns', { + configurable: true, + value: 80, + }); + + const writes: string[] = []; + const logs: string[] = []; + const channel = new CLIChannel('Mercury Sandbox'); + + vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + writes.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return true; + }) as typeof process.stdout.write); + + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }); + + const full = await channel.stream(streamChunks('O', 'K')); + + expect(full).toBe('OK'); + expect(writes.join('')).toBe(' OK\n'); + expect(logs.join('\n')).toContain('Mercury Sandbox:'); + expect(logs.join('\n')).not.toContain('OK'); + expect(writes.join('')).not.toContain('\x1b[J'); + expect(writes.join('')).not.toMatch(/\x1b\[\d+A/); + }); +}); diff --git a/src/channels/cli.ts b/src/channels/cli.ts index bb382200..8f3f3839 100644 --- a/src/channels/cli.ts +++ b/src/channels/cli.ts @@ -250,14 +250,7 @@ export class CLIChannel extends BaseChannel { } this.streamActive = false; - process.stdout.write(`\x1b[${visualLines}A`); - process.stdout.write('\x1b[J'); - if (full.trim()) { - const block = this.formatBlock(this.agentName, '', full); - for (const line of block) { - console.log(line); - } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(chalk.dim(' ' + '─'.repeat(50 - elapsed.length - 4) + ' ' + elapsed + 's')); } From 2dfc8d19de277d759be006af4edab796e1960291 Mon Sep 17 00:00:00 2001 From: Raul Date: Tue, 28 Apr 2026 11:58:54 +0200 Subject: [PATCH 2/6] fix: harden session-scoped permissions and remove root temp scope - isolate permission state by session/channel - keep Allow All restricted by shell blocklist and filesystem scoping - remove vestigial addRootTempScope contract/runtime path - align CLI/Telegram/manual/docs with the hardened model - add regression tests for permission mode and scheduled/system behavior --- .gitignore | 5 +- CHANGELOG.md | 4 +- DECISIONS.md | 4 +- README.md | 10 +- docs/mercury-sandbox-smoke.md | 54 ++++++++ docs/permissions-model.md | 123 ++++++++++++++++ scripts/mercury_sandbox_smoke.py | 200 +++++++++++++++++++++++++++ scripts/run_mercury_sandbox_smoke.sh | 44 ++++++ src/capabilities/permissions.test.ts | 117 ++++++++++++++++ src/capabilities/permissions.ts | 144 +++++++++++++------ src/channels/cli.ts | 7 +- src/channels/telegram.test.ts | 39 ++++++ src/channels/telegram.ts | 2 +- src/core/agent-permissions.test.ts | 31 +++++ src/core/agent.ts | 35 ++++- src/core/permission-mode.test.ts | 70 ++++++++++ src/core/permission-mode.ts | 18 +++ src/index.ts | 16 +-- src/utils/manual.ts | 5 +- 19 files changed, 852 insertions(+), 76 deletions(-) create mode 100644 docs/mercury-sandbox-smoke.md create mode 100644 docs/permissions-model.md create mode 100755 scripts/mercury_sandbox_smoke.py create mode 100755 scripts/run_mercury_sandbox_smoke.sh create mode 100644 src/capabilities/permissions.test.ts create mode 100644 src/channels/telegram.test.ts create mode 100644 src/core/agent-permissions.test.ts create mode 100644 src/core/permission-mode.test.ts create mode 100644 src/core/permission-mode.ts diff --git a/.gitignore b/.gitignore index 8c2c94ed..fe18b976 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,11 @@ node_modules/ dist/ .env *.log +log .DS_Store *.tsbuildinfo config/ soul/ -memory/ \ No newline at end of file +memory/ +.hermes/ +tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ecaef4..c0bbcb20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ Mercury has been in rapid development through 0.x releases. The Second Brain fea ### Permission Modes - **Ask Me** — confirm before file writes, shell commands that need approval, and scope changes (default on both CLI and Telegram) -- **Allow All** — auto-approve everything for the session (scopes, commands, loop continuation). Resets on restart. +- **Allow All** — auto-approve everything in the current session/channel while keeping the shell blocklist and filesystem scoping in force. Resets on restart. - CLI: arrow-key menu at session start. Telegram: inline keyboard on first message, `/permissions` to change. ### Step-by-Step Tool Feedback @@ -54,7 +54,7 @@ Mercury has been in rapid development through 0.x releases. The Second Brain fea - **Model selection during onboarding** — after validating an API key, Mercury fetches available models and lets you choose - **Telegram editable status messages** — streaming updates use `editMessageText` for live response editing - **Scheduled task notifications** — Mercury notifies the originating channel when a scheduled task runs -- **Full temporary scope for scheduled tasks** — tasks run in Allow All mode with auto-approved scopes +- **Scheduled tasks follow the restricted auto-approval model** — tasks run with `Allow All` behavior inside their originating session/channel, without any extra root filesystem scope ### Breaking Changes diff --git a/DECISIONS.md b/DECISIONS.md index 5f10557f..496c437c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -47,8 +47,8 @@ ## ADR-008: Scheduler with YAML persistence - **Context**: Mercury needs to set reminders, run periodic tasks, and trigger skills on a schedule. -- **Decision**: Expose `schedule_task`, `list_scheduled_tasks`, `cancel_scheduled_task` as AI-callable tools. Persist scheduled tasks to `~/.mercury/schedules.yaml`. Restore on startup. Tasks fire as internal (non-channel) messages through the agent loop. -- **Consequence**: Mercury can autonomously schedule work. Tasks survive restarts. Internal execution keeps scheduled tasks invisible to channels unless the agent explicitly sends output. +- **Decision**: Expose `schedule_task`, `list_scheduled_tasks`, `cancel_scheduled_task` as AI-callable tools. Persist scheduled tasks to `~/.mercury/schedules.yaml`. Restore on startup. Tasks run as system messages through the agent loop, preserving `sourceChannelId` / `sourceChannelType` when available instead of forcing a separate non-channel execution path. +- **Consequence**: Mercury can autonomously schedule work. Tasks survive restarts. Scheduled runs inherit channel context for delivery and session isolation when that context exists, while still using system-message auto-approval. This auto-approval does not grant root filesystem scope. ## ADR-009: Daemonization via Custom Hybrid Approach diff --git a/README.md b/README.md index 21c83d4d..17e68031 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ mercury doctor Every AI agent can read files, run commands, and fetch URLs. Most do it silently. **Mercury asks first — and remembers what matters.** -- **Permission-hardened** — Shell blocklist (`sudo`, `rm -rf /`, etc. never execute). Folder-level read/write scoping. Pending approval flow. Ask Me or Allow All per session. No surprises. +- **Permission-hardened** — Shell blocklist (`sudo`, `rm -rf /`, etc. never execute). Folder-level read/write scoping. Pending approval flow. Ask Me or Allow All per session. `Allow All` auto-approves within the current session/channel, but does not grant root filesystem access. - **Second Brain** — Persistent, structured memory with SQLite + FTS5 full-text search. 10 memory types, auto-extraction, conflict resolution, auto-consolidation. Mercury learns your preferences, goals, and habits without manual entry. - **Soul-driven** — Personality defined by markdown files you own (`soul.md`, `persona.md`, `taste.md`, `heartbeat.md`). No corporate wrapper. - **Token-aware** — Daily budget enforcement. Auto-concise when over 70%. `/budget` command to check, reset, or override. @@ -149,6 +149,14 @@ Type these during a conversation — they don't consume API tokens. Work on both | `/memory` | View and manage second brain memory | | `/unpair` | Telegram: reset all access | +### Permission modes + +- **Ask Me** — prompts before risky shell commands, writes, and permission escalations. +- **Allow All** — auto-approves within the current session/channel, but keeps the shell blocklist and filesystem scoping in force. +- Permission state is isolated by session/channel, so one Telegram chat or CLI session does not silently unlock another. +- System-triggered runs (internal flows and scheduled tasks) are auto-approved under the same restricted model — no extra root filesystem scope is added. +- See `docs/permissions-model.md` for the live permission model and implementation notes. + ## Built-in Tools | Category | Tools | diff --git a/docs/mercury-sandbox-smoke.md b/docs/mercury-sandbox-smoke.md new file mode 100644 index 00000000..20775bce --- /dev/null +++ b/docs/mercury-sandbox-smoke.md @@ -0,0 +1,54 @@ +# Mercury sandbox smoke test + +Smoke test reproducible para validar Mercury dentro del sandbox con `glm-5.1`, sin tocar la lógica del agente. + +## Qué automatiza + +- carga manualmente `sandbox/mercury-home/.env` +- exporta `MERCURY_HOME` +- fija el `cwd` al workspace del sandbox +- arranca Mercury en foreground con PTY usando `node dist/index.js start --foreground` +- selecciona `Ask Me` enviando `\r` +- manda `Di solo OK y nada más.` +- verifica que la respuesta útil del asistente sea únicamente `OK` +- guarda transcript raw y transcript limpio sin ANSI + +## Requisitos + +- `dist/index.js` debe existir en el repo (`npm run build` si hace falta) +- `python3` con `pexpect` disponible +- sandbox existente en: + - `MERCURY_SANDBOX_HOME=/home/raul/dev/mercury-test/sandbox/mercury-home` + - `MERCURY_SANDBOX_WORKSPACE=/home/raul/dev/mercury-test/sandbox/workspace` + +## Uso + +Desde el repo: + +```bash +./scripts/run_mercury_sandbox_smoke.sh +``` + +Opcionalmente puedes sobreescribir rutas o prompt: + +```bash +MERCURY_SANDBOX_HOME=/ruta/mercury-home \ +MERCURY_SANDBOX_WORKSPACE=/ruta/workspace \ +MERCURY_SMOKE_PROMPT='Di solo OK y nada más.' \ +./scripts/run_mercury_sandbox_smoke.sh +``` + +## Salida + +Los transcripts se guardan en `tmp/mercury-smoke/`: + +- `*.log`: salida raw de terminal +- `*.clean.txt`: salida limpiada, sin secuencias ANSI + +El script falla si: + +- no encuentra `.env` +- no encuentra `dist/index.js` +- el arranque no muestra `glm-5.1` +- no logra pasar el menú de permisos +- la respuesta del asistente no es exactamente `OK` diff --git a/docs/permissions-model.md b/docs/permissions-model.md new file mode 100644 index 00000000..3de03557 --- /dev/null +++ b/docs/permissions-model.md @@ -0,0 +1,123 @@ +# Mercury permission model + +This document reflects the live permission wiring in the local repo, not the older scheduler/security notes. + +## Summary + +- Mercury keeps permission state per session/channel. +- `Ask Me` and `Allow All` are session modes, not global switches. +- `Allow All` enables auto-approval for the current session/channel only. +- `Allow All` does **not** add root filesystem scope. +- Internal flows and scheduled tasks also use auto-approval, but under that same restricted model. + +## Session and channel isolation + +Permission state lives inside `PermissionManager` session state keyed by channel. That state includes: + +- `autoApproveAll` +- pending approvals +- temporary scopes +- channel type metadata + +Practical consequence: enabling `Allow All` in one Telegram chat or one CLI session does not silently affect another channel. + +Relevant files: + +- `src/capabilities/permissions.ts` +- `src/capabilities/permissions.test.ts` + +## Interactive modes + +Interactive channels expose two modes: + +- **Ask Me** — Mercury asks before risky shell commands, file writes, and permission escalations. +- **Allow All** — Mercury auto-approves those interactive prompts inside the current session/channel. + +The current wiring is in `src/core/permission-mode.ts`: + +```ts +permissions.setCurrentChannel(channelId, channelType); +permissions.setAutoApproveAll(mode === 'allow-all'); +``` + +That function does **not** add `addTempScope('/')` or any equivalent root filesystem grant. + +Relevant files: + +- `src/core/permission-mode.ts` +- `src/core/permission-mode.test.ts` +- `src/index.ts` + +## System messages: internal and scheduled + +Mercury uses one permission model for user sessions and system-triggered runs. + +`getMessagePermissionPolicy(...)` currently returns only: + +- `autoApproveAll: true` + +for: + +- internal messages (`channelType === 'internal'`) +- scheduled/system messages (`senderId === 'system'` outside the internal channel) + +This means scheduled runs are auto-approved, but they do not get a different unrestricted filesystem mode. + +Relevant files: + +- `src/core/agent.ts` +- `src/core/agent-permissions.test.ts` + +## Scheduled task context + +Scheduled tasks are not strictly “non-channel” anymore. When a task is created from a live channel, Mercury persists the origin context and replays the job as a system message tied to that source channel. + +That preserves: + +- correct delivery context +- session/channel isolation +- the same restricted auto-approval model + +Relevant files: + +- `src/capabilities/scheduler/schedule-task.ts` +- `src/capabilities/registry.ts` +- `src/core/agent.ts` + +## What Allow All does not do + +`Allow All` should not be documented as unrestricted filesystem access. + +What still applies: + +- shell blocklist +- filesystem scoping +- per-session/channel isolation + +So the correct mental model is: + +> `Allow All` removes confirmation prompts inside the current session. It does not remove Mercury's filesystem boundaries. + +## Local patch consolidation status + +The local repo now reflects a fully consolidated permission hardening pass with three practical outcomes: + +1. `Allow All` is wired through `applySessionPermissionMode(...)` without root temp scope. +2. Internal and scheduled runs stay auto-approved without `addTempScope('/')`. +3. The message permission contract now models only the active behavior (`autoApproveAll`), with session state isolation covered by dedicated tests. + +## Evidence and reproducibility + +Useful verification points in this repo: + +- `src/core/permission-mode.test.ts` +- `src/core/agent-permissions.test.ts` +- `src/capabilities/permissions.test.ts` +- `src/channels/telegram.test.ts` + +For sandbox validation, use: + +- `docs/mercury-sandbox-smoke.md` +- `scripts/run_mercury_sandbox_smoke.sh` + +The smoke test validates the sandbox startup path with `glm-5.1`, manual `.env` loading, workspace `cwd`, interactive permission selection, and a minimal `OK` roundtrip. \ No newline at end of file diff --git a/scripts/mercury_sandbox_smoke.py b/scripts/mercury_sandbox_smoke.py new file mode 100755 index 00000000..7f1a127f --- /dev/null +++ b/scripts/mercury_sandbox_smoke.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +import re +import signal +import sys +from pathlib import Path + +import pexpect + +DEFAULT_PROMPT = "Di solo OK y nada más." +DEFAULT_MODEL = "glm-5.1" +DEFAULT_TIMEOUT = 180 +ANSI_RE = re.compile(r"\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))") +SEPARATOR_RE = re.compile(r"^[\-─\s\d.]+s?$") + + +class TeeTranscript: + def __init__(self, raw_path: Path) -> None: + self.raw_path = raw_path + self.raw_path.parent.mkdir(parents=True, exist_ok=True) + self._file = raw_path.open("w", encoding="utf-8") + self._chunks: list[str] = [] + + def write(self, data: str) -> None: + self._chunks.append(data) + self._file.write(data) + self._file.flush() + + def flush(self) -> None: + self._file.flush() + + def close(self) -> None: + self._file.close() + + def snapshot(self) -> int: + return len(self.text) + + @property + def text(self) -> str: + return "".join(self._chunks) + + +def strip_ansi(text: str) -> str: + return ANSI_RE.sub("", text) + + +def normalize_lines(text: str) -> list[str]: + cleaned = strip_ansi(text).replace("\r", "") + return [line.rstrip() for line in cleaned.splitlines()] + + +def extract_assistant_payload(lines: list[str]) -> list[str]: + payload: list[str] = [] + capturing = False + + for raw_line in lines: + line = raw_line.strip() + if not line: + continue + if line.startswith("You:"): + capturing = False + continue + if line in {"Mercury Sandbox:", "Mercury:"}: + capturing = True + continue + if line.startswith("Mercury Sandbox is thinking"): + continue + if line.startswith("Confirm-before-act mode active"): + continue + if line.startswith("Mercury Sandbox is live"): + continue + if line.startswith("Ctrl+C to exit"): + continue + if line.startswith("Select permission mode"): + continue + if line.startswith("Choose how Mercury handles risky actions"): + continue + if line.startswith("↑↓ to move"): + continue + if line.startswith("Providers:") or line.startswith("Models:") or line.startswith("Skills:") or line.startswith("Creator:"): + continue + if SEPARATOR_RE.match(line): + continue + if capturing: + payload.append(line) + + return payload + + +def ensure_exists(path: Path, label: str) -> None: + if not path.exists(): + raise SystemExit(f"ERROR: {label} no existe: {path}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Smoke test reproducible para Mercury sandbox (glm-5.1).") + parser.add_argument("--workspace", required=True, type=Path) + parser.add_argument("--mercury-home", required=True, type=Path) + parser.add_argument("--entrypoint", required=True, type=Path) + parser.add_argument("--transcript", required=True, type=Path) + parser.add_argument("--prompt", default=DEFAULT_PROMPT) + parser.add_argument("--expected-model", default=DEFAULT_MODEL) + parser.add_argument("--startup-timeout", type=int, default=60) + parser.add_argument("--response-timeout", type=int, default=DEFAULT_TIMEOUT) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + workspace = args.workspace.resolve() + mercury_home = args.mercury_home.resolve() + entrypoint = args.entrypoint.resolve() + raw_transcript = args.transcript.resolve() + clean_transcript = raw_transcript.with_suffix(".clean.txt") + + ensure_exists(workspace, "workspace") + ensure_exists(mercury_home, "MERCURY_HOME") + ensure_exists(mercury_home / ".env", ".env del sandbox") + ensure_exists(entrypoint, "entrypoint de Mercury") + + env = os.environ.copy() + env["MERCURY_HOME"] = str(mercury_home) + env.setdefault("TERM", "xterm-256color") + + transcript = TeeTranscript(raw_transcript) + child: pexpect.spawn | None = None + + try: + print(f"[smoke] workspace={workspace}") + print(f"[smoke] mercury_home={mercury_home}") + print(f"[smoke] transcript={raw_transcript}") + print(f"[smoke] clean_transcript={clean_transcript}") + print(f"[smoke] expected_model={args.expected_model}") + + child = pexpect.spawn( + "node", + [str(entrypoint), "start", "--foreground"], + cwd=str(workspace), + env=env, + encoding="utf-8", + timeout=args.response_timeout, + ) + child.delaybeforesend = 0.05 + child.logfile = transcript + + child.expect("Select permission mode:", timeout=args.startup_timeout) + startup_lines = normalize_lines(transcript.text) + if not any(args.expected_model in line for line in startup_lines): + raise AssertionError(f"No encontré el modelo esperado '{args.expected_model}' en el arranque.") + + child.send("\r") + child.expect("Type a message and press Enter.", timeout=args.startup_timeout) + child.expect("You: ", timeout=args.startup_timeout) + + response_start = transcript.snapshot() + child.sendline(args.prompt) + child.expect("Mercury Sandbox:", timeout=args.response_timeout) + child.expect("You: ", timeout=args.response_timeout) + + response_segment = transcript.text[response_start:] + payload = extract_assistant_payload(normalize_lines(response_segment)) + if not payload: + raise AssertionError("No pude extraer contenido de la respuesta del asistente.") + if any(line != "OK" for line in payload): + raise AssertionError(f"La respuesta no fue exclusivamente 'OK': {payload}") + + clean_transcript.write_text(strip_ansi(transcript.text).replace("\r", ""), encoding="utf-8") + print("[smoke] PASS Mercury respondió solo OK.") + print(f"[smoke] assistant_payload={payload}") + print(f"[smoke] raw_transcript={raw_transcript}") + print(f"[smoke] clean_transcript={clean_transcript}") + return 0 + except (pexpect.EOF, pexpect.TIMEOUT, AssertionError) as exc: + clean_transcript.write_text(strip_ansi(transcript.text).replace("\r", ""), encoding="utf-8") + print(f"[smoke] FAIL {exc}", file=sys.stderr) + print(f"[smoke] raw_transcript={raw_transcript}", file=sys.stderr) + print(f"[smoke] clean_transcript={clean_transcript}", file=sys.stderr) + return 1 + finally: + if child is not None and child.isalive(): + try: + child.sendcontrol("c") + child.expect(pexpect.EOF, timeout=20) + except Exception: + try: + child.kill(signal.SIGINT) + except Exception: + pass + try: + child.close(force=True) + except Exception: + pass + transcript.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_mercury_sandbox_smoke.sh b/scripts/run_mercury_sandbox_smoke.sh new file mode 100755 index 00000000..a8c8cd79 --- /dev/null +++ b/scripts/run_mercury_sandbox_smoke.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SANDBOX_HOME="${MERCURY_SANDBOX_HOME:-/home/raul/dev/mercury-test/sandbox/mercury-home}" +WORKSPACE="${MERCURY_SANDBOX_WORKSPACE:-/home/raul/dev/mercury-test/sandbox/workspace}" +TRANSCRIPTS_DIR="${MERCURY_SMOKE_TRANSCRIPTS_DIR:-$REPO_ROOT/tmp/mercury-smoke}" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +TRANSCRIPT_PATH="$TRANSCRIPTS_DIR/mercury-smoke-$TIMESTAMP.log" +PROMPT="${MERCURY_SMOKE_PROMPT:-Di solo OK y nada más.}" +EXPECTED_MODEL="${MERCURY_SMOKE_EXPECTED_MODEL:-glm-5.1}" +ENTRYPOINT="$REPO_ROOT/dist/index.js" + +if [[ ! -f "$SANDBOX_HOME/.env" ]]; then + echo "ERROR: no existe $SANDBOX_HOME/.env" >&2 + exit 1 +fi + +if [[ ! -f "$ENTRYPOINT" ]]; then + echo "ERROR: no existe $ENTRYPOINT" >&2 + echo "Tip: compila Mercury antes con 'npm run build'." >&2 + exit 1 +fi + +mkdir -p "$TRANSCRIPTS_DIR" + +set -a +# shellcheck disable=SC1090 +source "$SANDBOX_HOME/.env" +set +a +export MERCURY_HOME="$SANDBOX_HOME" + +echo "[smoke] repo_root=$REPO_ROOT" +echo "[smoke] workspace=$WORKSPACE" +echo "[smoke] mercury_home=$MERCURY_HOME" +echo "[smoke] transcript=$TRANSCRIPT_PATH" + +python3 "$REPO_ROOT/scripts/mercury_sandbox_smoke.py" \ + --workspace "$WORKSPACE" \ + --mercury-home "$SANDBOX_HOME" \ + --entrypoint "$ENTRYPOINT" \ + --transcript "$TRANSCRIPT_PATH" \ + --prompt "$PROMPT" \ + --expected-model "$EXPECTED_MODEL" diff --git a/src/capabilities/permissions.test.ts b/src/capabilities/permissions.test.ts new file mode 100644 index 00000000..33e10b85 --- /dev/null +++ b/src/capabilities/permissions.test.ts @@ -0,0 +1,117 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PermissionManager } from './permissions.js'; + +const mercuryHomes: string[] = []; +const originalMercuryHome = process.env.MERCURY_HOME; + +function createPermissionManager(): PermissionManager { + const mercuryHome = mkdtempSync(join(tmpdir(), 'mercury-permissions-')); + mercuryHomes.push(mercuryHome); + process.env.MERCURY_HOME = mercuryHome; + return new PermissionManager(); +} + +afterEach(() => { + for (const mercuryHome of mercuryHomes.splice(0)) { + rmSync(mercuryHome, { recursive: true, force: true }); + } + + if (originalMercuryHome === undefined) { + delete process.env.MERCURY_HOME; + } else { + process.env.MERCURY_HOME = originalMercuryHome; + } +}); + +describe('PermissionManager session isolation', () => { + it('isolates allow-all mode by channel', () => { + const permissions = createPermissionManager(); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + permissions.setAutoApproveAll(true); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + expect(permissions.isAutoApproveAll()).toBe(true); + + permissions.setCurrentChannel('telegram:2', 'telegram'); + expect(permissions.isAutoApproveAll()).toBe(false); + }); + + it('keeps blocked shell commands blocked even in allow-all mode', async () => { + const permissions = createPermissionManager(); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + permissions.setAutoApproveAll(true); + + await expect(permissions.checkShellCommand('rm -rf /')).resolves.toMatchObject({ + allowed: false, + needsApproval: false, + reason: 'Blocked command: matches "rm -rf /"', + }); + }); + + it('keeps cwdOnly protection active even in allow-all mode', async () => { + const permissions = createPermissionManager(); + const outsidePath = join(tmpdir(), 'outside-scope.txt'); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + permissions.setAutoApproveAll(true); + + await expect(permissions.checkShellCommand(`cat ${outsidePath}`)).resolves.toMatchObject({ + allowed: false, + needsApproval: false, + reason: `No permission to access ${outsidePath}. Use approve_scope tool with path="${outsidePath}" and mode="write" to request access.`, + }); + }); + + it('isolates pending approvals by channel', async () => { + const permissions = createPermissionManager(); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + permissions.addPendingApproval('git'); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + await expect(permissions.checkShellCommand('git push origin main')).resolves.toMatchObject({ + allowed: true, + needsApproval: false, + }); + + permissions.setCurrentChannel('telegram:2', 'telegram'); + await expect(permissions.checkShellCommand('git push origin main')).resolves.toMatchObject({ + allowed: false, + needsApproval: true, + }); + }); + + it('isolates temp scopes by channel', async () => { + const permissions = createPermissionManager(); + const sharedPath = join(tmpdir(), 'isolated-scope', 'note.txt'); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + permissions.addTempScope(sharedPath, true, false); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + await expect(permissions.checkFsAccess(sharedPath, 'read')).resolves.toMatchObject({ allowed: true }); + + permissions.setCurrentChannel('telegram:2', 'telegram'); + await expect(permissions.checkFsAccess(sharedPath, 'read')).resolves.toMatchObject({ allowed: false }); + }); + + it('passes channel context to approval prompts', async () => { + const permissions = createPermissionManager(); + const askHandler = vi.fn().mockResolvedValue('yes'); + + permissions.onAsk(askHandler); + permissions.setCurrentChannel('telegram:42', 'telegram'); + + await permissions.requestScopeExternal('/tmp/shared', 'read'); + + expect(askHandler).toHaveBeenCalledWith( + 'Mercury needs read access to:\n/tmp/shared\n\nAllow access?', + { channelId: 'telegram:42', channelType: 'telegram' }, + ); + }); +}); diff --git a/src/capabilities/permissions.ts b/src/capabilities/permissions.ts index adb88693..c6635e47 100644 --- a/src/capabilities/permissions.ts +++ b/src/capabilities/permissions.ts @@ -38,6 +38,19 @@ export interface PermissionsManifest { }; } +export interface PermissionAskContext { + channelId: string; + channelType: string; +} + +interface SessionPermissionsState { + autoApproveAll: boolean; + elevatedCommands: Set; + pendingApprovals: Set; + tempScopes: FileScope[]; + channelType: string; +} + const DEFAULT_MANIFEST: PermissionsManifest = { capabilities: { filesystem: { @@ -148,70 +161,106 @@ const PERMISSIONS_FILE = join(getMercuryHome(), 'permissions.yaml'); export class PermissionManager { private manifest: PermissionsManifest; private readonly cwd: string; - private askHandler?: (prompt: string) => Promise; - private autoApproveAll = false; - private elevatedCommands: Set = new Set(); - private pendingApprovals: Set = new Set(); - private currentChannelType: string = 'cli'; - - private tempScopes: FileScope[] = []; + private askHandler?: (prompt: string, context: PermissionAskContext) => Promise; + private readonly sessionStates: Map = new Map(); + private currentChannelId = 'cli:default'; constructor() { this.cwd = process.cwd(); this.manifest = this.load(); + this.ensureSessionState(this.currentChannelId, 'cli'); + } + + setCurrentChannel(channelId: string, channelType: string): void { + this.currentChannelId = channelId; + this.ensureSessionState(channelId, channelType).channelType = channelType; } setCurrentChannelType(type: string): void { - this.currentChannelType = type; + this.ensureCurrentSession().channelType = type; } getCurrentChannelType(): string { - return this.currentChannelType; + return this.ensureCurrentSession().channelType; } - onAsk(handler: (prompt: string) => Promise): void { + onAsk(handler: (prompt: string, context: PermissionAskContext) => Promise): void { this.askHandler = handler; } setAutoApproveAll(value: boolean): void { - this.autoApproveAll = value; + this.ensureCurrentSession().autoApproveAll = value; } isAutoApproveAll(): boolean { - return this.autoApproveAll; + return this.ensureCurrentSession().autoApproveAll; } elevateForSkill(allowedTools: string[]): void { + const session = this.ensureCurrentSession(); if (allowedTools.includes('run_command')) { - this.elevatedCommands.add('run_command'); + session.elevatedCommands.add('run_command'); } if (allowedTools.includes('read_file') || allowedTools.includes('list_dir')) { - this.elevatedCommands.add('fs_read'); + session.elevatedCommands.add('fs_read'); } if (allowedTools.includes('write_file') || allowedTools.includes('create_file') || allowedTools.includes('delete_file')) { - this.elevatedCommands.add('fs_write'); + session.elevatedCommands.add('fs_write'); } } clearElevation(): void { - this.elevatedCommands.clear(); + this.ensureCurrentSession().elevatedCommands.clear(); } isElevated(tool: string): boolean { - if (this.elevatedCommands.has(tool)) return true; - return false; + return this.ensureCurrentSession().elevatedCommands.has(tool); } isShellElevated(): boolean { - return this.elevatedCommands.has('run_command'); + return this.ensureCurrentSession().elevatedCommands.has('run_command'); } addPendingApproval(baseCommand: string): void { - this.pendingApprovals.add(baseCommand); + this.ensureCurrentSession().pendingApprovals.add(baseCommand); } clearPendingApprovals(): void { - this.pendingApprovals.clear(); + this.ensureCurrentSession().pendingApprovals.clear(); + } + + private ensureCurrentSession(): SessionPermissionsState { + return this.ensureSessionState(this.currentChannelId); + } + + private ensureSessionState(channelId: string, channelType?: string): SessionPermissionsState { + let session = this.sessionStates.get(channelId); + if (!session) { + session = { + autoApproveAll: false, + elevatedCommands: new Set(), + pendingApprovals: new Set(), + tempScopes: [], + channelType: channelType ?? this.inferChannelType(channelId), + }; + this.sessionStates.set(channelId, session); + } else if (channelType) { + session.channelType = channelType; + } + return session; + } + + private inferChannelType(channelId: string): string { + const [channelType] = channelId.split(':'); + return channelType || 'cli'; + } + + private getCurrentAskContext(): PermissionAskContext { + const session = this.ensureCurrentSession(); + return { + channelId: this.currentChannelId, + channelType: session.channelType, + }; } private load(): PermissionsManifest { @@ -253,10 +302,12 @@ export class PermissionManager { } async checkFsAccess(path: string, mode: 'read' | 'write'): Promise<{ allowed: boolean; reason?: string }> { - if (mode === 'read' && this.elevatedCommands.has('fs_read')) { + const session = this.ensureCurrentSession(); + + if (mode === 'read' && session.elevatedCommands.has('fs_read')) { return { allowed: true }; } - if (mode === 'write' && this.elevatedCommands.has('fs_write')) { + if (mode === 'write' && session.elevatedCommands.has('fs_write')) { return { allowed: true }; } @@ -285,16 +336,7 @@ export class PermissionManager { } async checkShellCommand(command: string): Promise<{ allowed: boolean; reason?: string; needsApproval: boolean }> { - if (this.autoApproveAll) { - logger.info({ cmd: command.trim() }, 'Shell command auto-approved (auto-approve-all mode)'); - return { allowed: true, needsApproval: false }; - } - - if (this.isShellElevated()) { - logger.info({ cmd: command.trim() }, 'Shell command auto-approved (skill elevation)'); - return { allowed: true, needsApproval: false }; - } - + const session = this.ensureCurrentSession(); const shell = this.manifest.capabilities.shell; if (!shell.enabled) { return { allowed: false, reason: 'Shell capability is disabled', needsApproval: false }; @@ -302,12 +344,6 @@ export class PermissionManager { const trimmed = command.trim(); - const baseCmd = trimmed.split(/\s+/)[0]; - if (this.pendingApprovals.has(baseCmd)) { - logger.info({ cmd: trimmed }, 'Shell command auto-approved (pending approval)'); - return { allowed: true, needsApproval: false }; - } - for (const pattern of shell.blocked) { if (this.matchPattern(trimmed, pattern)) { return { allowed: false, reason: `Blocked command: matches "${pattern}"`, needsApproval: false }; @@ -324,6 +360,22 @@ export class PermissionManager { } } + if (session.autoApproveAll) { + logger.info({ cmd: trimmed }, 'Shell command auto-approved (auto-approve-all mode)'); + return { allowed: true, needsApproval: false }; + } + + if (this.isShellElevated()) { + logger.info({ cmd: trimmed }, 'Shell command auto-approved (skill elevation)'); + return { allowed: true, needsApproval: false }; + } + + const baseCmd = trimmed.split(/\s+/)[0]; + if (session.pendingApprovals.has(baseCmd)) { + logger.info({ cmd: trimmed }, 'Shell command auto-approved (pending approval)'); + return { allowed: true, needsApproval: false }; + } + for (const pattern of shell.autoApproved) { if (this.matchPattern(trimmed, pattern)) { logger.info({ cmd: trimmed }, 'Shell command auto-approved'); @@ -333,8 +385,8 @@ export class PermissionManager { for (const pattern of shell.needsApproval) { if (this.matchPattern(trimmed, pattern)) { - if (this.currentChannelType === 'telegram' && this.askHandler) { - const result = await this.askHandler(`Run command: ${trimmed}`); + if (session.channelType === 'telegram' && this.askHandler) { + const result = await this.askHandler(`Run command: ${trimmed}`, this.getCurrentAskContext()); if (result === 'yes') { return { allowed: true, needsApproval: false }; } @@ -348,8 +400,8 @@ export class PermissionManager { } } - if (this.currentChannelType === 'telegram' && this.askHandler) { - const result = await this.askHandler(`Run command: ${trimmed}`); + if (session.channelType === 'telegram' && this.askHandler) { + const result = await this.askHandler(`Run command: ${trimmed}`, this.getCurrentAskContext()); if (result === 'yes') { return { allowed: true, needsApproval: false }; } @@ -405,7 +457,7 @@ export class PermissionManager { } const prompt = `Mercury needs ${mode} access to:\n${path}\n\nAllow access?`; - const response = await this.askHandler(prompt); + const response = await this.askHandler(prompt, this.getCurrentAskContext()); if (response === 'always') { this.addScope(path, mode === 'read', mode === 'write'); @@ -422,12 +474,12 @@ export class PermissionManager { addTempScope(path: string, read: boolean, write: boolean): void { const resolved = resolve(path); - this.tempScopes.push({ path: resolved, read, write }); + this.ensureCurrentSession().tempScopes.push({ path: resolved, read, write }); logger.info({ path: resolved, read, write }, 'Temp permission scope added (session only)'); } private findTempScope(resolvedPath: string): FileScope | undefined { - for (const scope of this.tempScopes) { + for (const scope of this.ensureCurrentSession().tempScopes) { const scopeResolved = resolve(scope.path.replace(/^~/, homedir())); if (resolvedPath === scopeResolved || resolvedPath.startsWith(scopeResolved + sep)) { return scope; diff --git a/src/channels/cli.ts b/src/channels/cli.ts index 8f3f3839..a7c50a96 100644 --- a/src/channels/cli.ts +++ b/src/channels/cli.ts @@ -356,8 +356,8 @@ export class CLIChannel extends BaseChannel { console.log(''); const options: ArrowSelectOption[] = [ - { value: 'ask-me', label: 'Ask Me — confirm before file writes, shell commands, and scope changes' }, - { value: 'allow-all', label: 'Allow All — auto-approve everything (scopes, commands, loop continuation)' }, + { value: 'ask-me', label: 'Ask Me — confirm before file writes, shell commands, and permission escalations' }, + { value: 'allow-all', label: 'Allow All — auto-approve commands, writes, and loop continuation' }, ]; try { @@ -368,8 +368,7 @@ export class CLIChannel extends BaseChannel { if (selected === 'allow-all') { console.log(''); console.log(chalk.yellow(' ⚠ Allow All active for this session:')); - console.log(chalk.dim(' • All directory scopes auto-approved')); - console.log(chalk.dim(' • All shell commands auto-approved (except blocked)')); + console.log(chalk.dim(' • Shell commands and file writes auto-approved (except blocked commands)')); console.log(chalk.dim(' • Loop detection will auto-continue')); console.log(chalk.dim(' • Resets on restart')); console.log(''); diff --git a/src/channels/telegram.test.ts b/src/channels/telegram.test.ts new file mode 100644 index 00000000..92f852a1 --- /dev/null +++ b/src/channels/telegram.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getDefaultConfig } from '../utils/config.js'; +import { TelegramChannel } from './telegram.js'; + +describe('TelegramChannel approvals', () => { + it('sends permission prompts to the requested target chat', async () => { + const config = getDefaultConfig(); + config.channels.telegram.members = [{ + userId: 123, + chatId: 123, + approvedAt: '2026-04-28T06:39:00.000Z', + }]; + const channel = new TelegramChannel(config); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 1 }); + + Object.assign(channel as object, { + bot: { + api: { + sendMessage, + }, + }, + }); + + const approvalPromise = channel.askPermission('Run command: npm publish', 'telegram:123'); + + await Promise.resolve(); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage.mock.calls[0]?.[0]).toBe(123); + expect(sendMessage.mock.calls[0]?.[1]).toContain('Run command: npm publish'); + + const pendingApprovals = (channel as any).pendingApprovals as Map void>; + const approvalKey = Array.from(pendingApprovals.keys()).find((key) => key.endsWith(':yes')); + expect(approvalKey).toBeDefined(); + pendingApprovals.get(approvalKey!)?.(); + + await expect(approvalPromise).resolves.toBe('yes'); + }); +}); diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 4612a489..4543c6e5 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -543,7 +543,7 @@ export class TelegramChannel extends BaseChannel { .text('🔒 Ask Me', `${id}:ask-me`) .text('✅ Allow All', `${id}:allow-all`); - const html = `Permission Mode\nHow should Mercury handle risky actions this session?\n\n🔒 Ask Me — confirm before file writes, commands, and scope changes\n✅ Allow All — auto-approve everything (scopes, commands, loops)`; + const html = `Permission Mode\nHow should Mercury handle risky actions this session?\n\n🔒 Ask Me — confirm before file writes, commands, and permission escalations\n✅ Allow All — auto-approve commands, writes, and loops`; try { await this.bot.api.sendMessage(chatId, html, { diff --git a/src/core/agent-permissions.test.ts b/src/core/agent-permissions.test.ts new file mode 100644 index 00000000..c08209f2 --- /dev/null +++ b/src/core/agent-permissions.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { getMessagePermissionPolicy } from './agent.js'; + +describe('getMessagePermissionPolicy', () => { + it('keeps auto-approve for internal messages without a root temp scope concept in the contract', () => { + const policy = getMessagePermissionPolicy({ + channelType: 'internal', + senderId: 'user-123', + }); + + expect(policy).toEqual({ autoApproveAll: true }); + }); + + it('keeps auto-approve for scheduled system messages without a root temp scope concept in the contract', () => { + const policy = getMessagePermissionPolicy({ + channelType: 'cli', + senderId: 'system', + }); + + expect(policy).toEqual({ autoApproveAll: true }); + }); + + it('does not elevate normal external messages', () => { + const policy = getMessagePermissionPolicy({ + channelType: 'telegram', + senderId: 'user-456', + }); + + expect(policy).toEqual({ autoApproveAll: false }); + }); +}); diff --git a/src/core/agent.ts b/src/core/agent.ts index 9361b152..2906e312 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -30,6 +30,25 @@ import { saveConfig, } from '../utils/config.js'; +export interface MessagePermissionPolicy { + autoApproveAll: boolean; +} + +export function getMessagePermissionPolicy(msg: Pick): MessagePermissionPolicy { + const isInternal = msg.channelType === 'internal'; + const isScheduled = msg.senderId === 'system' && msg.channelType !== 'internal'; + + if (isInternal || isScheduled) { + return { + autoApproveAll: true, + }; + } + + return { + autoApproveAll: false, + }; +} + class ToolCallLoopDetector { private recentCalls: Array<{ tool: string; params: string; failed: boolean }> = []; private totalCalls = 0; @@ -332,12 +351,15 @@ export class Agent { this.lifecycle.transition('thinking'); const startTime = Date.now(); - const isInternal = msg.channelType === 'internal'; - const isScheduled = msg.senderId === 'system' && msg.channelType !== 'internal'; - if (isInternal || isScheduled) { - this.capabilities.permissions.setAutoApproveAll(true); - this.capabilities.permissions.addTempScope('/', true, true); - } + const permissionPolicy = getMessagePermissionPolicy(msg); + const isInternal = msg.channelType === 'internal'; + const isScheduled = msg.senderId === 'system' && msg.channelType !== 'internal'; + + this.capabilities.permissions.setCurrentChannel(msg.channelId, msg.channelType); + + if (permissionPolicy.autoApproveAll) { + this.capabilities.permissions.setAutoApproveAll(true); + } try { const trimmed = msg.content.trim(); @@ -495,7 +517,6 @@ export class Agent { } this.capabilities.setChannelContext(msg.channelId, msg.channelType); - this.capabilities.permissions.setCurrentChannelType(msg.channelType); const fallbackIterator = this.providers.getFallbackIterator(); let result: any = null; diff --git a/src/core/permission-mode.test.ts b/src/core/permission-mode.test.ts new file mode 100644 index 00000000..d13fe437 --- /dev/null +++ b/src/core/permission-mode.test.ts @@ -0,0 +1,70 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PermissionManager } from '../capabilities/permissions.js'; +import { applySessionPermissionMode } from './permission-mode.js'; + +const mercuryHomes: string[] = []; +const originalMercuryHome = process.env.MERCURY_HOME; + +function createPermissionManager(): PermissionManager { + const mercuryHome = mkdtempSync(join(tmpdir(), 'mercury-permission-mode-')); + mercuryHomes.push(mercuryHome); + process.env.MERCURY_HOME = mercuryHome; + return new PermissionManager(); +} + +afterEach(() => { + for (const mercuryHome of mercuryHomes.splice(0)) { + rmSync(mercuryHome, { recursive: true, force: true }); + } + + if (originalMercuryHome === undefined) { + delete process.env.MERCURY_HOME; + } else { + process.env.MERCURY_HOME = originalMercuryHome; + } +}); + +describe('applySessionPermissionMode', () => { + it('enables auto-approve for the provided channel without granting filesystem scope', () => { + const permissions = { + setCurrentChannel: vi.fn(), + setAutoApproveAll: vi.fn(), + addTempScope: vi.fn(), + }; + + applySessionPermissionMode('allow-all', 'telegram:1', 'telegram', permissions); + + expect(permissions.setCurrentChannel).toHaveBeenCalledWith('telegram:1', 'telegram'); + expect(permissions.setAutoApproveAll).toHaveBeenCalledWith(true); + expect(permissions.addTempScope).not.toHaveBeenCalled(); + }); + + it('applies the mode only to the target channel', () => { + const permissions = createPermissionManager(); + + applySessionPermissionMode('allow-all', 'telegram:1', 'telegram', permissions); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + expect(permissions.isAutoApproveAll()).toBe(true); + + permissions.setCurrentChannel('telegram:2', 'telegram'); + expect(permissions.isAutoApproveAll()).toBe(false); + }); + + it('does nothing when mode is undefined', () => { + const permissions = { + setCurrentChannel: vi.fn(), + setAutoApproveAll: vi.fn(), + addTempScope: vi.fn(), + }; + + applySessionPermissionMode(undefined, 'telegram:1', 'telegram', permissions); + + expect(permissions.setCurrentChannel).not.toHaveBeenCalled(); + expect(permissions.setAutoApproveAll).not.toHaveBeenCalled(); + expect(permissions.addTempScope).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/permission-mode.ts b/src/core/permission-mode.ts new file mode 100644 index 00000000..f0c39c49 --- /dev/null +++ b/src/core/permission-mode.ts @@ -0,0 +1,18 @@ +import type { PermissionManager } from '../capabilities/permissions.js'; +import type { PermissionMode } from '../channels/base.js'; + +type SessionPermissionApplier = Pick; + +export function applySessionPermissionMode( + mode: PermissionMode | undefined, + channelId: string, + channelType: string, + permissions: SessionPermissionApplier, +): void { + if (mode === undefined) { + return; + } + + permissions.setCurrentChannel(channelId, channelType); + permissions.setAutoApproveAll(mode === 'allow-all'); +} diff --git a/src/index.ts b/src/index.ts index 8de69a95..7d979b52 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ import { runWithWatchdog } from './cli/watchdog.js'; import { setGitHubToken } from './utils/github.js'; import { selectWithArrowKeys } from './utils/arrow-select.js'; import { ProviderModelFetchError, fetchProviderModelCatalog } from './utils/provider-models.js'; +import { applySessionPermissionMode } from './core/permission-mode.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkgVersion = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version; @@ -962,10 +963,9 @@ async function runAgent(isDaemon: boolean = false): Promise { tgChannel.setChatCommandContext(capabilities.getChatCommandContext()!); } - capabilities.permissions.onAsk(async (prompt: string) => { - const channelType = capabilities.permissions.getCurrentChannelType(); - if (channelType === 'telegram' && tgChannel) { - return tgChannel.askPermission(prompt); + capabilities.permissions.onAsk(async (prompt: string, context) => { + if (context.channelType === 'telegram' && tgChannel) { + return tgChannel.askPermission(prompt, context.channelId); } if (cliChannel) { return cliChannel.askPermission(prompt); @@ -975,9 +975,8 @@ async function runAgent(isDaemon: boolean = false): Promise { if (tgChannel) { tgChannel.setOnPermissionMode((mode, chatId) => { + applySessionPermissionMode(mode, `telegram:${chatId}`, 'telegram', capabilities.permissions); if (mode === 'allow-all') { - capabilities.permissions.setAutoApproveAll(true); - capabilities.permissions.addTempScope('/', true, true); logger.info({ chatId }, 'Telegram: Allow All mode set for session'); } }); @@ -993,10 +992,7 @@ async function runAgent(isDaemon: boolean = false): Promise { hr(); const mode = cliChannel && await cliChannel.askPermissionMode?.(); - if (mode === 'allow-all') { - capabilities.permissions.setAutoApproveAll(true); - capabilities.permissions.addTempScope('/', true, true); - } + applySessionPermissionMode(mode, 'cli:default', 'cli', capabilities.permissions); console.log(''); console.log(chalk.green(` ${name} is live. Type a message and press Enter.`)); diff --git a/src/utils/manual.ts b/src/utils/manual.ts index 877de325..ebc5f689 100644 --- a/src/utils/manual.ts +++ b/src/utils/manual.ts @@ -122,8 +122,9 @@ export function getManual(): string { 'Say "always" when prompted to permanently approve a command type.', 'Edit ~/.mercury/permissions.yaml to customize manually.', 'File access is scoped — new paths need approval (y/n/always).', - 'At session start, choose "Ask Me" (confirm each action) or "Allow All" (auto-approve everything).', - 'Scheduled tasks always run in Allow All mode.', + 'At session start, choose "Ask Me" (confirm each action) or "Allow All" (auto-approve within the current session/channel).', + 'Allow All keeps the shell blocklist and filesystem scoping in force — it does not grant root filesystem access.', + 'Scheduled tasks run as system messages with auto-approval, but still respect shell blocklist and file scoping.', ]; for (const p of perms) { From d6c8b7ca9cae5e0ebccd4d93aeb3b1192e4d0305 Mon Sep 17 00:00:00 2001 From: Raul Date: Tue, 28 Apr 2026 15:09:33 +0200 Subject: [PATCH 3/6] fix: bound permission session state growth --- src/capabilities/permissions.test.ts | 32 ++++++++++++++++++++++++++++ src/capabilities/permissions.ts | 20 +++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/capabilities/permissions.test.ts b/src/capabilities/permissions.test.ts index 33e10b85..a66145cb 100644 --- a/src/capabilities/permissions.test.ts +++ b/src/capabilities/permissions.test.ts @@ -114,4 +114,36 @@ describe('PermissionManager session isolation', () => { { channelId: 'telegram:42', channelType: 'telegram' }, ); }); + + it('bounds session state growth when many channels are seen', () => { + const permissions = createPermissionManager(); + + for (let index = 0; index < 200; index += 1) { + permissions.setCurrentChannel(`telegram:${index}`, 'telegram'); + } + + expect((permissions as unknown as { sessionStates: Map }).sessionStates.size).toBeLessThanOrEqual(100); + }); + + it('evicts the oldest channel state while keeping the current one', () => { + const permissions = createPermissionManager(); + + permissions.setCurrentChannel('telegram:oldest', 'telegram'); + permissions.setAutoApproveAll(true); + + for (let index = 0; index < 99; index += 1) { + permissions.setCurrentChannel(`telegram:fill:${index}`, 'telegram'); + } + + permissions.setCurrentChannel('telegram:current', 'telegram'); + permissions.setAutoApproveAll(true); + + permissions.setCurrentChannel('telegram:overflow', 'telegram'); + + permissions.setCurrentChannel('telegram:oldest', 'telegram'); + expect(permissions.isAutoApproveAll()).toBe(false); + + permissions.setCurrentChannel('telegram:current', 'telegram'); + expect(permissions.isAutoApproveAll()).toBe(true); + }); }); diff --git a/src/capabilities/permissions.ts b/src/capabilities/permissions.ts index d25e756f..26866fec 100644 --- a/src/capabilities/permissions.ts +++ b/src/capabilities/permissions.ts @@ -157,6 +157,7 @@ const DEFAULT_MANIFEST: PermissionsManifest = { }; const PERMISSIONS_FILE = join(getMercuryHome(), 'permissions.yaml'); +const MAX_SESSION_STATES = 100; export class PermissionManager { private manifest: PermissionsManifest; @@ -244,12 +245,27 @@ export class PermissionManager { channelType: channelType ?? this.inferChannelType(channelId), }; this.sessionStates.set(channelId, session); - } else if (channelType) { - session.channelType = channelType; + this.pruneSessionStates(); + } else { + this.sessionStates.delete(channelId); + this.sessionStates.set(channelId, session); + if (channelType) { + session.channelType = channelType; + } } return session; } + private pruneSessionStates(): void { + while (this.sessionStates.size > MAX_SESSION_STATES) { + const oldestChannelId = this.sessionStates.keys().next().value; + if (!oldestChannelId || oldestChannelId === this.currentChannelId) { + break; + } + this.sessionStates.delete(oldestChannelId); + } + } + private inferChannelType(channelId: string): string { const [channelType] = channelId.split(':'); return channelType || 'cli'; From 31f340a2c0a6ab8e5332e199ceaac213b8e4fba3 Mon Sep 17 00:00:00 2001 From: Raul Date: Tue, 28 Apr 2026 15:09:33 +0200 Subject: [PATCH 4/6] docs: make mercury sandbox smoke portable --- docs/mercury-sandbox-smoke.md | 71 +++++++++++++++------------- scripts/mercury_sandbox_smoke.py | 18 +++---- scripts/run_mercury_sandbox_smoke.sh | 23 ++++++--- 3 files changed, 65 insertions(+), 47 deletions(-) diff --git a/docs/mercury-sandbox-smoke.md b/docs/mercury-sandbox-smoke.md index 20775bce..bc931061 100644 --- a/docs/mercury-sandbox-smoke.md +++ b/docs/mercury-sandbox-smoke.md @@ -1,54 +1,61 @@ # Mercury sandbox smoke test -Smoke test reproducible para validar Mercury dentro del sandbox con `glm-5.1`, sin tocar la lógica del agente. +This reproducible smoke test validates Mercury inside the sandbox with `glm-5.1` without changing agent logic. -## Qué automatiza +## What it automates -- carga manualmente `sandbox/mercury-home/.env` -- exporta `MERCURY_HOME` -- fija el `cwd` al workspace del sandbox -- arranca Mercury en foreground con PTY usando `node dist/index.js start --foreground` -- selecciona `Ask Me` enviando `\r` -- manda `Di solo OK y nada más.` -- verifica que la respuesta útil del asistente sea únicamente `OK` -- guarda transcript raw y transcript limpio sin ANSI +- manually loads `mercury-home/.env` +- exports `MERCURY_HOME` +- sets the working directory to the sandbox workspace +- starts Mercury in foreground mode with a PTY via `node dist/index.js start --foreground` +- selects `Ask Me` by sending `\r` +- sends `Reply with OK only.` +- verifies that the useful assistant response is exactly `OK` +- stores both a raw transcript and an ANSI-stripped transcript -## Requisitos +## Requirements -- `dist/index.js` debe existir en el repo (`npm run build` si hace falta) -- `python3` con `pexpect` disponible -- sandbox existente en: - - `MERCURY_SANDBOX_HOME=/home/raul/dev/mercury-test/sandbox/mercury-home` - - `MERCURY_SANDBOX_WORKSPACE=/home/raul/dev/mercury-test/sandbox/workspace` +- `dist/index.js` must exist in the repo (`npm run build` if needed) +- `python3` with `pexpect` available +- a sandbox with: + - `MERCURY_SANDBOX_HOME` pointing to your `mercury-home` directory + - `MERCURY_SANDBOX_WORKSPACE` pointing to your sandbox workspace -## Uso +By default, `scripts/run_mercury_sandbox_smoke.sh` derives sandbox paths from portable repo-relative locations: -Desde el repo: +- first choice: `$REPO_ROOT/sandbox/mercury-home` and `$REPO_ROOT/sandbox/workspace` +- fallback convenience: `$REPO_ROOT/../sandbox/mercury-home` and `$REPO_ROOT/../sandbox/workspace` + +If your setup lives somewhere else, override the environment variables explicitly. + +## Usage + +From the repo root: ```bash ./scripts/run_mercury_sandbox_smoke.sh ``` -Opcionalmente puedes sobreescribir rutas o prompt: +You can optionally override paths or the prompt: ```bash -MERCURY_SANDBOX_HOME=/ruta/mercury-home \ -MERCURY_SANDBOX_WORKSPACE=/ruta/workspace \ -MERCURY_SMOKE_PROMPT='Di solo OK y nada más.' \ +MERCURY_SANDBOX_HOME=/path/to/mercury-home \ +MERCURY_SANDBOX_WORKSPACE=/path/to/workspace \ +MERCURY_SMOKE_PROMPT='Reply with OK only.' \ ./scripts/run_mercury_sandbox_smoke.sh ``` -## Salida +## Output -Los transcripts se guardan en `tmp/mercury-smoke/`: +Transcripts are written to `tmp/mercury-smoke/`: -- `*.log`: salida raw de terminal -- `*.clean.txt`: salida limpiada, sin secuencias ANSI +- `*.log`: raw terminal output +- `*.clean.txt`: cleaned output without ANSI sequences -El script falla si: +The script fails if: -- no encuentra `.env` -- no encuentra `dist/index.js` -- el arranque no muestra `glm-5.1` -- no logra pasar el menú de permisos -- la respuesta del asistente no es exactamente `OK` +- `.env` is missing +- `dist/index.js` is missing +- startup does not show `glm-5.1` +- it cannot get past the permissions menu +- the assistant response is not exactly `OK` diff --git a/scripts/mercury_sandbox_smoke.py b/scripts/mercury_sandbox_smoke.py index 7f1a127f..4517bde6 100755 --- a/scripts/mercury_sandbox_smoke.py +++ b/scripts/mercury_sandbox_smoke.py @@ -10,7 +10,7 @@ import pexpect -DEFAULT_PROMPT = "Di solo OK y nada más." +DEFAULT_PROMPT = "Reply with OK only." DEFAULT_MODEL = "glm-5.1" DEFAULT_TIMEOUT = 180 ANSI_RE = re.compile(r"\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))") @@ -92,11 +92,11 @@ def extract_assistant_payload(lines: list[str]) -> list[str]: def ensure_exists(path: Path, label: str) -> None: if not path.exists(): - raise SystemExit(f"ERROR: {label} no existe: {path}") + raise SystemExit(f"ERROR: {label} does not exist: {path}") def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Smoke test reproducible para Mercury sandbox (glm-5.1).") + parser = argparse.ArgumentParser(description="Reproducible smoke test for Mercury sandbox (glm-5.1).") parser.add_argument("--workspace", required=True, type=Path) parser.add_argument("--mercury-home", required=True, type=Path) parser.add_argument("--entrypoint", required=True, type=Path) @@ -118,8 +118,8 @@ def main() -> int: ensure_exists(workspace, "workspace") ensure_exists(mercury_home, "MERCURY_HOME") - ensure_exists(mercury_home / ".env", ".env del sandbox") - ensure_exists(entrypoint, "entrypoint de Mercury") + ensure_exists(mercury_home / ".env", "sandbox .env") + ensure_exists(entrypoint, "Mercury entrypoint") env = os.environ.copy() env["MERCURY_HOME"] = str(mercury_home) @@ -149,7 +149,7 @@ def main() -> int: child.expect("Select permission mode:", timeout=args.startup_timeout) startup_lines = normalize_lines(transcript.text) if not any(args.expected_model in line for line in startup_lines): - raise AssertionError(f"No encontré el modelo esperado '{args.expected_model}' en el arranque.") + raise AssertionError(f"Expected model '{args.expected_model}' was not shown during startup.") child.send("\r") child.expect("Type a message and press Enter.", timeout=args.startup_timeout) @@ -163,12 +163,12 @@ def main() -> int: response_segment = transcript.text[response_start:] payload = extract_assistant_payload(normalize_lines(response_segment)) if not payload: - raise AssertionError("No pude extraer contenido de la respuesta del asistente.") + raise AssertionError("Could not extract assistant response content.") if any(line != "OK" for line in payload): - raise AssertionError(f"La respuesta no fue exclusivamente 'OK': {payload}") + raise AssertionError(f"Assistant response was not exclusively 'OK': {payload}") clean_transcript.write_text(strip_ansi(transcript.text).replace("\r", ""), encoding="utf-8") - print("[smoke] PASS Mercury respondió solo OK.") + print("[smoke] PASS Mercury replied with OK only.") print(f"[smoke] assistant_payload={payload}") print(f"[smoke] raw_transcript={raw_transcript}") print(f"[smoke] clean_transcript={clean_transcript}") diff --git a/scripts/run_mercury_sandbox_smoke.sh b/scripts/run_mercury_sandbox_smoke.sh index a8c8cd79..82594757 100755 --- a/scripts/run_mercury_sandbox_smoke.sh +++ b/scripts/run_mercury_sandbox_smoke.sh @@ -2,23 +2,33 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SANDBOX_HOME="${MERCURY_SANDBOX_HOME:-/home/raul/dev/mercury-test/sandbox/mercury-home}" -WORKSPACE="${MERCURY_SANDBOX_WORKSPACE:-/home/raul/dev/mercury-test/sandbox/workspace}" + +if [[ -d "$REPO_ROOT/sandbox" ]]; then + DEFAULT_SANDBOX_ROOT="$REPO_ROOT/sandbox" +elif [[ -d "$REPO_ROOT/../sandbox" ]]; then + DEFAULT_SANDBOX_ROOT="$(cd "$REPO_ROOT/../sandbox" && pwd)" +else + DEFAULT_SANDBOX_ROOT="$REPO_ROOT/sandbox" +fi + +SANDBOX_HOME="${MERCURY_SANDBOX_HOME:-$DEFAULT_SANDBOX_ROOT/mercury-home}" +WORKSPACE="${MERCURY_SANDBOX_WORKSPACE:-$DEFAULT_SANDBOX_ROOT/workspace}" TRANSCRIPTS_DIR="${MERCURY_SMOKE_TRANSCRIPTS_DIR:-$REPO_ROOT/tmp/mercury-smoke}" TIMESTAMP="$(date +%Y%m%d-%H%M%S)" TRANSCRIPT_PATH="$TRANSCRIPTS_DIR/mercury-smoke-$TIMESTAMP.log" -PROMPT="${MERCURY_SMOKE_PROMPT:-Di solo OK y nada más.}" +PROMPT="${MERCURY_SMOKE_PROMPT:-Reply with OK only.}" EXPECTED_MODEL="${MERCURY_SMOKE_EXPECTED_MODEL:-glm-5.1}" ENTRYPOINT="$REPO_ROOT/dist/index.js" if [[ ! -f "$SANDBOX_HOME/.env" ]]; then - echo "ERROR: no existe $SANDBOX_HOME/.env" >&2 + echo "ERROR: missing $SANDBOX_HOME/.env" >&2 + echo "Tip: set MERCURY_SANDBOX_HOME if your sandbox lives elsewhere." >&2 exit 1 fi if [[ ! -f "$ENTRYPOINT" ]]; then - echo "ERROR: no existe $ENTRYPOINT" >&2 - echo "Tip: compila Mercury antes con 'npm run build'." >&2 + echo "ERROR: missing $ENTRYPOINT" >&2 + echo "Tip: build Mercury first with 'npm run build'." >&2 exit 1 fi @@ -31,6 +41,7 @@ set +a export MERCURY_HOME="$SANDBOX_HOME" echo "[smoke] repo_root=$REPO_ROOT" +echo "[smoke] sandbox_root=$DEFAULT_SANDBOX_ROOT" echo "[smoke] workspace=$WORKSPACE" echo "[smoke] mercury_home=$MERCURY_HOME" echo "[smoke] transcript=$TRANSCRIPT_PATH" From ca57405610f33d6a89d99a4091f32450af59f84b Mon Sep 17 00:00:00 2001 From: Raul Date: Tue, 28 Apr 2026 18:12:46 +0200 Subject: [PATCH 5/6] fix: align permission modes with filesystem scoping --- ARCHITECTURE.md | 2 + docs/docs.html | 10 ++-- docs/permissions-model.md | 14 ++++++ src/capabilities/permissions.test.ts | 52 +++++++++++++++++++ src/capabilities/permissions.ts | 13 ----- src/core/agent-permissions.test.ts | 74 +++++++++++++++++++++++++++- src/core/agent.ts | 6 +-- src/skills/loader.ts | 1 + 8 files changed, 149 insertions(+), 23 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da61587e..7246273d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -257,6 +257,8 @@ allowed-tools: Instructions for Mercury to follow when this skill is invoked... ``` +`allowed-tools` make those tools available while the skill runs, but they do not bypass filesystem scopes or blocked shell commands. + ### Progressive Disclosure - **Startup**: Only skill names + descriptions are loaded (token-efficient) diff --git a/docs/docs.html b/docs/docs.html index 42447e74..08e692ad 100644 --- a/docs/docs.html +++ b/docs/docs.html @@ -593,7 +593,7 @@

In-Chat Commands

/permissions - Change permission mode (Ask Me / Allow All) — Telegram only + Change permission mode (Ask Me / Allow All) on CLI or Telegram /tasks @@ -1379,17 +1379,17 @@

Permissions

permanently approve a command type

Edit ~/.mercury/permissions.yaml to customize. Skill elevation: skills with allowed-tools - in their SKILL.md get automatic approval for those tools during execution.

+ in their SKILL.md unlock those tools during execution, but filesystem scopes and shell blocklists still apply.

Permission Modes

At session start, Mercury asks you to choose a permission mode:

  • 🔒 Ask Me — Mercury asks for confirmation before file writes, shell commands that need approval, and scope changes. Default on both CLI and Telegram.
  • -
  • ✅ Allow All — Mercury auto-approves all directory scopes, all shell commands (except blocked), and loop-continuation prompts. No interruptions during the session. Resets on restart.
  • +
  • ✅ Allow All — Mercury auto-approves shell commands that need confirmation (except blocked commands) and loop-continuation prompts. Filesystem scopes still apply and new paths can still require approval. Resets on restart.

On CLI: an arrow-key menu appears at startup before the first prompt.

On Telegram: inline keyboard buttons appear with the first message. Use /permissions to change mode at any time.

-

Scheduled tasks always run in Allow All mode — filesystem access and shell commands are auto-approved. You receive a notification before each task executes.

+

Scheduled tasks always run with command and loop auto-approval, but shell blocklists and filesystem scoping still apply. You receive a notification before each task executes.

Second Brain

Mercury has a persistent, structured memory that grows with every conversation. When enabled, it automatically extracts, stores, and retrieves facts about you — your preferences, goals, projects, habits, and more.

@@ -1562,7 +1562,7 @@

Scheduling a skill

  • "Remind me daily at 9am to run the daily-digest skill"
  • Skills follow the Agent Skills specification. - They get elevated permissions via allowed-tools and are loaded with progressive disclosure to save + They declare tool access via allowed-tools, while Mercury still enforces filesystem scopes and shell safety boundaries, and loads them with progressive disclosure to save tokens.

    diff --git a/docs/permissions-model.md b/docs/permissions-model.md index 3de03557..eaae8266 100644 --- a/docs/permissions-model.md +++ b/docs/permissions-model.md @@ -84,6 +84,20 @@ Relevant files: - `src/capabilities/registry.ts` - `src/core/agent.ts` +## What skill allowed-tools do + +Skills can declare `allowed-tools` in `SKILL.md` to unlock specific tools while the skill is active. + +Important boundary: + +- `allowed-tools` do **not** bypass filesystem scopes +- `allowed-tools` do **not** bypass blocked shell commands +- filesystem access still requires an approved permanent or temporary scope + +So the correct model is: + +> Skills can unlock tool usage, but Mercury still enforces path boundaries and shell safety rules. + ## What Allow All does not do `Allow All` should not be documented as unrestricted filesystem access. diff --git a/src/capabilities/permissions.test.ts b/src/capabilities/permissions.test.ts index a66145cb..0eb1f5cd 100644 --- a/src/capabilities/permissions.test.ts +++ b/src/capabilities/permissions.test.ts @@ -115,6 +115,58 @@ describe('PermissionManager session isolation', () => { ); }); + it('does not let read_file skill elevation bypass filesystem scopes', async () => { + const permissions = createPermissionManager(); + const outsidePath = join(tmpdir(), 'outside-read-scope.txt'); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + permissions.elevateForSkill(['read_file']); + + await expect(permissions.checkFsAccess(outsidePath, 'read')).resolves.toMatchObject({ + allowed: false, + reason: `Permission denied for read access to ${outsidePath}`, + }); + }); + + it('does not let write_file skill elevation bypass filesystem scopes', async () => { + const permissions = createPermissionManager(); + const outsidePath = join(tmpdir(), 'outside-write-scope.txt'); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + permissions.elevateForSkill(['write_file']); + + await expect(permissions.checkFsAccess(outsidePath, 'write')).resolves.toMatchObject({ + allowed: false, + reason: `Permission denied for write access to ${outsidePath}`, + }); + }); + + it('keeps direct filesystem checks outside scope blocked even in allow-all mode', async () => { + const permissions = createPermissionManager(); + const outsidePath = join(tmpdir(), 'outside-allow-all.txt'); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + permissions.setAutoApproveAll(true); + + await expect(permissions.checkFsAccess(outsidePath, 'write')).resolves.toMatchObject({ + allowed: false, + reason: `Permission denied for write access to ${outsidePath}`, + }); + }); + + it('still allows filesystem access inside an approved scope for elevated skills', async () => { + const permissions = createPermissionManager(); + const scopedDir = join(tmpdir(), 'approved-skill-scope'); + const scopedFile = join(scopedDir, 'note.txt'); + + permissions.setCurrentChannel('telegram:1', 'telegram'); + permissions.addTempScope(scopedDir, true, true); + permissions.elevateForSkill(['read_file', 'write_file']); + + await expect(permissions.checkFsAccess(scopedFile, 'read')).resolves.toMatchObject({ allowed: true }); + await expect(permissions.checkFsAccess(scopedFile, 'write')).resolves.toMatchObject({ allowed: true }); + }); + it('bounds session state growth when many channels are seen', () => { const permissions = createPermissionManager(); diff --git a/src/capabilities/permissions.ts b/src/capabilities/permissions.ts index 26866fec..e2849e8f 100644 --- a/src/capabilities/permissions.ts +++ b/src/capabilities/permissions.ts @@ -202,12 +202,6 @@ export class PermissionManager { if (allowedTools.includes('run_command')) { session.elevatedCommands.add('run_command'); } - if (allowedTools.includes('read_file') || allowedTools.includes('list_dir')) { - session.elevatedCommands.add('fs_read'); - } - if (allowedTools.includes('write_file') || allowedTools.includes('create_file') || allowedTools.includes('delete_file')) { - session.elevatedCommands.add('fs_write'); - } } clearElevation(): void { @@ -319,13 +313,6 @@ export class PermissionManager { async checkFsAccess(path: string, mode: 'read' | 'write'): Promise<{ allowed: boolean; reason?: string }> { const session = this.ensureCurrentSession(); - if (mode === 'read' && session.elevatedCommands.has('fs_read')) { - return { allowed: true }; - } - if (mode === 'write' && session.elevatedCommands.has('fs_write')) { - return { allowed: true }; - } - const fs = this.manifest.capabilities.filesystem; if (!fs.enabled) { return { allowed: false, reason: 'Filesystem capability is disabled' }; diff --git a/src/core/agent-permissions.test.ts b/src/core/agent-permissions.test.ts index c08209f2..45935931 100644 --- a/src/core/agent-permissions.test.ts +++ b/src/core/agent-permissions.test.ts @@ -1,5 +1,58 @@ -import { describe, expect, it } from 'vitest'; -import { getMessagePermissionPolicy } from './agent.js'; +import { describe, expect, it, vi } from 'vitest'; +import { CLIChannel } from '../channels/cli.js'; +import { Agent, getMessagePermissionPolicy } from './agent.js'; + +class StubCLIChannel extends CLIChannel { + sent: string[] = []; + mode: 'allow-all' | 'ask-me' = 'allow-all'; + + override async send(content: string): Promise { + this.sent.push(content); + } + + override async askPermissionMode(): Promise<'allow-all' | 'ask-me'> { + return this.mode; + } +} + +function createAgentForPermissionCommand(channel: StubCLIChannel) { + const permissions = { + setCurrentChannel: vi.fn(), + setAutoApproveAll: vi.fn(), + addTempScope: vi.fn(), + }; + + const capabilities = { + permissions, + getChatCommandContext: vi.fn(() => ({})), + }; + + const channels = { + get: vi.fn(() => channel), + onIncomingMessage: vi.fn(), + }; + + const scheduler = { + setOnScheduledTask: vi.fn(), + onHeartbeat: vi.fn(), + }; + + const agent = new Agent( + { channels: { telegram: { streaming: true } } } as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + null, + channels as any, + {} as any, + capabilities as any, + scheduler as any, + ); + + return { agent, permissions, capabilities, channels, scheduler }; +} describe('getMessagePermissionPolicy', () => { it('keeps auto-approve for internal messages without a root temp scope concept in the contract', () => { @@ -29,3 +82,20 @@ describe('getMessagePermissionPolicy', () => { expect(policy).toEqual({ autoApproveAll: false }); }); }); + +describe('Agent /permissions command', () => { + it('does not grant a root temp scope when CLI switches to allow-all', async () => { + const channel = new StubCLIChannel('Mercury Sandbox'); + channel.mode = 'allow-all'; + const { agent, permissions } = createAgentForPermissionCommand(channel); + + await (agent as any).handleChatCommand('/permissions', 'cli', 'cli:default'); + + expect(permissions.setCurrentChannel).toHaveBeenCalledWith('cli:default', 'cli'); + expect(permissions.setAutoApproveAll).toHaveBeenCalledWith(true); + expect(permissions.addTempScope).not.toHaveBeenCalled(); + expect(channel.sent).toContain( + 'Allow All mode active for this session. Command approvals and loop prompts are auto-approved, but filesystem scopes and blocked shell commands still apply. Resets on restart.', + ); + }); +}); diff --git a/src/core/agent.ts b/src/core/agent.ts index f1e352af..d4a4ca70 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -1248,18 +1248,18 @@ Always specify owner and repo parameters on GitHub tools. The user's GitHub user if (cmd === '/permissions') { if (channelType === 'cli' && channel instanceof CLIChannel) { + this.capabilities.permissions.setCurrentChannel(channelId, channelType); const mode = await channel.askPermissionMode?.(); if (mode === 'allow-all') { this.capabilities.permissions.setAutoApproveAll(true); - this.capabilities.permissions.addTempScope('/', true, true); - await channel.send('Allow All mode active for this session. All scopes, commands, and loops auto-approved. Resets on restart.', channelId); + await channel.send('Allow All mode active for this session. Command approvals and loop prompts are auto-approved, but filesystem scopes and blocked shell commands still apply. Resets on restart.', channelId); } else { this.capabilities.permissions.setAutoApproveAll(false); await channel.send('Ask Me mode active. Risky actions will prompt for confirmation.', channelId); } return true; } - await channel.send('Use /permissions in CLI to switch permission mode. On Telegram, use the /permissions button or command.', channelId); + await channel.send('Use /permissions in CLI or Telegram to switch permission mode.', channelId); return true; } diff --git a/src/skills/loader.ts b/src/skills/loader.ts index 31d6357c..6db49d33 100644 --- a/src/skills/loader.ts +++ b/src/skills/loader.ts @@ -148,6 +148,7 @@ Describe what this skill enables Mercury to do. When invoked via the use_skill t - Keep instructions concise to save tokens - List only the tools you need in allowed-tools +- allowed-tools unlock tool usage, but they do not bypass filesystem scopes or blocked shell commands - The skill name must be unique among installed skills `; From a89caf11b942005632c082beb9c15f67dc098643 Mon Sep 17 00:00:00 2001 From: Raul Date: Tue, 28 Apr 2026 22:49:29 +0200 Subject: [PATCH 6/6] fix: harden mercury sandbox smoke for z.ai --- docs/mercury-sandbox-smoke.md | 4 +- scripts/mercury_sandbox_smoke.py | 8 +++- scripts/mercury_sandbox_smoke_test.py | 23 ++++++++++ scripts/run_mercury_sandbox_smoke.sh | 16 +++++++ src/providers/openai-compat.test.ts | 62 +++++++++++++++++++++++++++ src/providers/openai-compat.ts | 2 +- 6 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 scripts/mercury_sandbox_smoke_test.py create mode 100644 src/providers/openai-compat.test.ts diff --git a/docs/mercury-sandbox-smoke.md b/docs/mercury-sandbox-smoke.md index bc931061..3c7368f7 100644 --- a/docs/mercury-sandbox-smoke.md +++ b/docs/mercury-sandbox-smoke.md @@ -10,7 +10,7 @@ This reproducible smoke test validates Mercury inside the sandbox with `glm-5.1` - starts Mercury in foreground mode with a PTY via `node dist/index.js start --foreground` - selects `Ask Me` by sending `\r` - sends `Reply with OK only.` -- verifies that the useful assistant response is exactly `OK` +- verifies that the useful assistant response is exactly one line: `OK` (duplicate streamed output fails) - stores both a raw transcript and an ANSI-stripped transcript ## Requirements @@ -58,4 +58,4 @@ The script fails if: - `dist/index.js` is missing - startup does not show `glm-5.1` - it cannot get past the permissions menu -- the assistant response is not exactly `OK` +- the assistant response is not exactly one line `OK` (including duplicated `OK` output) diff --git a/scripts/mercury_sandbox_smoke.py b/scripts/mercury_sandbox_smoke.py index 4517bde6..0f6fe902 100755 --- a/scripts/mercury_sandbox_smoke.py +++ b/scripts/mercury_sandbox_smoke.py @@ -90,6 +90,11 @@ def extract_assistant_payload(lines: list[str]) -> list[str]: return payload +def validate_assistant_payload(payload: list[str]) -> None: + if payload != ["OK"]: + raise AssertionError(f"Assistant response was not exactly one line 'OK': {payload}") + + def ensure_exists(path: Path, label: str) -> None: if not path.exists(): raise SystemExit(f"ERROR: {label} does not exist: {path}") @@ -164,8 +169,7 @@ def main() -> int: payload = extract_assistant_payload(normalize_lines(response_segment)) if not payload: raise AssertionError("Could not extract assistant response content.") - if any(line != "OK" for line in payload): - raise AssertionError(f"Assistant response was not exclusively 'OK': {payload}") + validate_assistant_payload(payload) clean_transcript.write_text(strip_ansi(transcript.text).replace("\r", ""), encoding="utf-8") print("[smoke] PASS Mercury replied with OK only.") diff --git a/scripts/mercury_sandbox_smoke_test.py b/scripts/mercury_sandbox_smoke_test.py new file mode 100644 index 00000000..78eca6a7 --- /dev/null +++ b/scripts/mercury_sandbox_smoke_test.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import unittest + +from scripts.mercury_sandbox_smoke import extract_assistant_payload, normalize_lines, validate_assistant_payload + + +class MercurySandboxSmokeTest(unittest.TestCase): + def test_extracts_single_ok_payload_from_cleaned_transcript(self) -> None: + response_segment = """You: Reply with OK only.\nMercury Sandbox:\nMercury Sandbox is thinking...\nOK\nYou: \n""" + + payload = extract_assistant_payload(normalize_lines(response_segment)) + + self.assertEqual(payload, ["OK"]) + + def test_rejects_duplicate_ok_payload(self) -> None: + with self.assertRaisesRegex(AssertionError, "exactly one line 'OK'"): + validate_assistant_payload(["OK", "OK"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/run_mercury_sandbox_smoke.sh b/scripts/run_mercury_sandbox_smoke.sh index 82594757..4e7f8161 100755 --- a/scripts/run_mercury_sandbox_smoke.sh +++ b/scripts/run_mercury_sandbox_smoke.sh @@ -34,6 +34,22 @@ fi mkdir -p "$TRANSCRIPTS_DIR" +# Reset sandbox token budget so runs don't accumulate across the same day +TOKEN_USAGE="$SANDBOX_HOME/token-usage.json" +if [[ -f "$TOKEN_USAGE" ]]; then + TODAY="$(date +%Y-%m-%d)" + python3 - "$TOKEN_USAGE" "$TODAY" <<'PYEOF' +import json, sys +path, today = sys.argv[1], sys.argv[2] +data = json.loads(open(path).read()) +data["dailyUsed"] = 0 +data["lastResetDate"] = today +data["requestLog"] = [] +open(path, "w").write(json.dumps(data, indent=2)) +PYEOF + echo "[smoke] token budget reset for $TODAY" +fi + set -a # shellcheck disable=SC1090 source "$SANDBOX_HOME/.env" diff --git a/src/providers/openai-compat.test.ts b/src/providers/openai-compat.test.ts new file mode 100644 index 00000000..ad9e24a9 --- /dev/null +++ b/src/providers/openai-compat.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ProviderConfig } from '../utils/config.js'; + +const { + chatModel, + responsesModel, + chatMock, + defaultModelMock, + createOpenAIMock, +} = vi.hoisted(() => { + const chatModel = { transport: 'chat' }; + const responsesModel = { transport: 'responses' }; + const chatMock = vi.fn(() => chatModel); + const defaultModelMock = vi.fn(() => responsesModel); + const createOpenAIMock = vi.fn(() => + Object.assign(defaultModelMock, { + chat: chatMock, + }), + ); + + return { + chatModel, + responsesModel, + chatMock, + defaultModelMock, + createOpenAIMock, + }; +}); + +vi.mock('@ai-sdk/openai', () => ({ + createOpenAI: createOpenAIMock, +})); + +import { OpenAICompatProvider } from './openai-compat.js'; + +const config: ProviderConfig = { + name: 'openai', + apiKey: 'test-key', + baseUrl: 'https://example.com/v1', + model: 'test-model', + enabled: true, +}; + +describe('OpenAICompatProvider', () => { + beforeEach(() => { + createOpenAIMock.mockClear(); + defaultModelMock.mockClear(); + chatMock.mockClear(); + }); + + it('builds a chat completions model instead of the default responses model', () => { + const provider = new OpenAICompatProvider(config); + + expect(createOpenAIMock).toHaveBeenCalledWith({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + expect(chatMock).toHaveBeenCalledWith(config.model); + expect(defaultModelMock).not.toHaveBeenCalled(); + expect(provider.getModelInstance()).toBe(chatModel); + }); +}); diff --git a/src/providers/openai-compat.ts b/src/providers/openai-compat.ts index cb54ae67..8c568855 100644 --- a/src/providers/openai-compat.ts +++ b/src/providers/openai-compat.ts @@ -20,7 +20,7 @@ export class OpenAICompatProvider extends BaseProvider { apiKey: config.apiKey, baseURL: config.baseUrl, }); - this.modelInstance = this.client(config.model); + this.modelInstance = this.client.chat(config.model); } async generateText(prompt: string, systemPrompt: string): Promise {