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/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/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 5cf6e065..736fd472 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,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.
@@ -153,6 +153,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/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/mercury-sandbox-smoke.md b/docs/mercury-sandbox-smoke.md
new file mode 100644
index 00000000..3c7368f7
--- /dev/null
+++ b/docs/mercury-sandbox-smoke.md
@@ -0,0 +1,61 @@
+# Mercury sandbox smoke test
+
+This reproducible smoke test validates Mercury inside the sandbox with `glm-5.1` without changing agent logic.
+
+## What it automates
+
+- 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 one line: `OK` (duplicate streamed output fails)
+- stores both a raw transcript and an ANSI-stripped transcript
+
+## Requirements
+
+- `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
+
+By default, `scripts/run_mercury_sandbox_smoke.sh` derives sandbox paths from portable repo-relative locations:
+
+- 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
+```
+
+You can optionally override paths or the prompt:
+
+```bash
+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
+```
+
+## Output
+
+Transcripts are written to `tmp/mercury-smoke/`:
+
+- `*.log`: raw terminal output
+- `*.clean.txt`: cleaned output without ANSI sequences
+
+The script fails if:
+
+- `.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 one line `OK` (including duplicated `OK` output)
diff --git a/docs/permissions-model.md b/docs/permissions-model.md
new file mode 100644
index 00000000..eaae8266
--- /dev/null
+++ b/docs/permissions-model.md
@@ -0,0 +1,137 @@
+# 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 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.
+
+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..0f6fe902
--- /dev/null
+++ b/scripts/mercury_sandbox_smoke.py
@@ -0,0 +1,204 @@
+#!/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 = "Reply with OK only."
+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 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}")
+
+
+def parse_args() -> argparse.Namespace:
+ 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)
+ 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", "sandbox .env")
+ ensure_exists(entrypoint, "Mercury entrypoint")
+
+ 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"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)
+ 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("Could not extract assistant response content.")
+ 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.")
+ 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/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
new file mode 100755
index 00000000..4e7f8161
--- /dev/null
+++ b/scripts/run_mercury_sandbox_smoke.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+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:-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: 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: missing $ENTRYPOINT" >&2
+ echo "Tip: build Mercury first with 'npm run build'." >&2
+ exit 1
+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"
+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"
+
+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..0eb1f5cd
--- /dev/null
+++ b/src/capabilities/permissions.test.ts
@@ -0,0 +1,201 @@
+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' },
+ );
+ });
+
+ 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();
+
+ 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 8f17f40a..e2849e8f 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: {
@@ -144,67 +157,121 @@ const DEFAULT_MANIFEST: PermissionsManifest = {
};
const PERMISSIONS_FILE = join(getMercuryHome(), 'permissions.yaml');
+const MAX_SESSION_STATES = 100;
export class PermissionManager {
private manifest: PermissionsManifest;
private readonly cwd: string;
- private askHandler?: (prompt: string) => Promise;
- private autoApproveAll = false;
- private elevatedCommands: 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');
- }
- if (allowedTools.includes('read_file') || allowedTools.includes('list_dir')) {
- this.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('run_command');
}
}
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.ensureCurrentSession().pendingApprovals.add(baseCommand);
+ }
+
+ clearPendingApprovals(): void {
+ 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);
+ 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';
}
+ private getCurrentAskContext(): PermissionAskContext {
+ const session = this.ensureCurrentSession();
+ return {
+ channelId: this.currentChannelId,
+ channelType: session.channelType,
+ };
+ }
private load(): PermissionsManifest {
if (existsSync(PERMISSIONS_FILE)) {
try {
@@ -244,12 +311,7 @@ export class PermissionManager {
}
async checkFsAccess(path: string, mode: 'read' | 'write'): Promise<{ allowed: boolean; reason?: string }> {
- if (mode === 'read' && this.elevatedCommands.has('fs_read')) {
- return { allowed: true };
- }
- if (mode === 'write' && this.elevatedCommands.has('fs_write')) {
- return { allowed: true };
- }
+ const session = this.ensureCurrentSession();
const fs = this.manifest.capabilities.filesystem;
if (!fs.enabled) {
@@ -272,7 +334,7 @@ export class PermissionManager {
return { allowed: false, reason: `Permission denied: ${mode} access to ${path}` };
}
- if (!this.autoApproveAll && this.askHandler && this.currentChannelType !== 'internal') {
+ if (!session.autoApproveAll && this.askHandler && session.channelType !== 'internal') {
return this.requestScopeExternal(path, mode);
}
@@ -280,23 +342,13 @@ 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 };
}
const trimmed = command.trim();
- const baseCmd = trimmed.split(/\s+/)[0];
for (const pattern of shell.blocked) {
if (this.matchPattern(trimmed, pattern)) {
@@ -314,6 +366,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');
@@ -323,8 +391,8 @@ export class PermissionManager {
for (const pattern of shell.needsApproval) {
if (this.matchPattern(trimmed, pattern)) {
- if (this.askHandler && this.currentChannelType !== 'internal') {
- 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 };
}
@@ -338,8 +406,8 @@ export class PermissionManager {
}
}
- if (this.askHandler && this.currentChannelType !== 'internal') {
- 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 };
}
@@ -395,7 +463,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');
@@ -412,12 +480,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.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..a7c50a96 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'));
}
@@ -363,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 {
@@ -375,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..45935931
--- /dev/null
+++ b/src/core/agent-permissions.test.ts
@@ -0,0 +1,101 @@
+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', () => {
+ 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 });
+ });
+});
+
+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 70a93608..d4a4ca70 100644
--- a/src/core/agent.ts
+++ b/src/core/agent.ts
@@ -31,6 +31,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;
@@ -333,12 +352,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();
@@ -496,7 +518,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;
@@ -1227,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/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 dd9a2be6..bee9890b 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;
@@ -1010,10 +1011,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);
@@ -1023,9 +1023,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');
}
});
@@ -1041,10 +1040,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/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 {
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
`;
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) {