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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (958) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (981) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
7 changes: 7 additions & 0 deletions emrg/protocol.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,11 @@ class TaskRequest:
default_factory=lambda: datetime.now().isoformat()
)
images: Optional[list[dict]] = None
# Sandbox tier for the task's bash tool (rant 2026-08-20T15:46:50):
# "read-only" | "workspace-write" | "danger-full-access" (default None =
# danger-full-access, current behavior). Set by task config, never by
# the agent itself.
sandbox: Optional[str] = None

def to_dict(self) -> dict:
d = {
Expand All@@ -42,6 +47,8 @@ def to_dict(self) -> dict:
}
if self.images:
d["images"] = self.images
if self.sandbox:
d["sandbox"] = self.sandbox
return d


Expand Down
13 changes: 12 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -613,6 +613,9 @@ async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) ->
cwd=cwd,
prompt=prompt,
timestamp="",
# Upgrade writes install/ and source/ inside its own work dir —
# workspace-write tier (rant 2026-08-20T15:46:50).
sandbox="workspace-write",
)
if self._session_busy.get(session_id):
# Queue per existing semantics (host decision A: busy → pending,
Expand DownExpand Up@@ -772,6 +775,7 @@ async def _handle_client(self, ws) -> None:
prompt=data.get("prompt", ""),
timestamp=data.get("timestamp", ""),
images=data.get("images"),
sandbox=data.get("sandbox"),
)
except Exception as e:
await self._send(ws, {"error": f"invalid task: {e}"})
Expand DownExpand Up@@ -1467,6 +1471,7 @@ async def _process_message(
enabled=msg.get("enabled", True),
repo=msg.get("repo"),
description=msg.get("description"),
sandbox=msg.get("sandbox"),
)
if not ok:
await self._send(ws, {"type": "task_result", "error": res})
Expand All@@ -1478,7 +1483,7 @@ async def _process_message(
if not self._scheduler:
await self._send(ws, {"type": "task_result", "error": "scheduler not running"})
return
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description") if k in msg}
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description", "sandbox") if k in msg}
if "task_type" in fields:
fields["type"] = fields.pop("task_type")
ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields)
Expand DownExpand Up@@ -2536,6 +2541,12 @@ async def _run_tool_loop(
elif tc_name == "grep" and "path" not in args:
args["path"] = str(session.cwd)

# Sandbox tier (rant 2026-08-20T15:46:50): the task's
# configured sandbox is injected into the bash tool — the
# agent cannot choose it per call.
if tc_name == "bash" and req.sandbox:
args["sandbox"] = req.sandbox

# Execute
tool = self.tools.get(tc_name)
if tool:
Expand Down
40 changes: 40 additions & 0 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@

from emrg.config import config_dir
from emrg.connect import connect_to_server
from emrg.tools.bash_tool import SANDBOX_MODES
from websockets.exceptions import ConnectionClosed
from emrg.protocol import EvolutionLog, InstanceIdentity
from emrg.server.atomic import atomic_write_yaml
Expand DownExpand Up@@ -130,6 +131,7 @@ def _task_cfg_signature(cfg: dict) -> tuple:
json.dumps(conf, sort_keys=True),
cfg.get("interval", DEFAULT_INTERVAL),
bool(cfg.get("enabled", True)),
cfg.get("sandbox"),
)


Expand DownExpand Up@@ -198,6 +200,7 @@ def __init__(
interval: int,
identity: InstanceIdentity,
template_path: Path | None = None,
sandbox: str | None = None,
) -> None:
self.name = name
# Rant 2026-08-19T10:18:44: per-task logger — LoggerAdapter injects a
Expand DownExpand Up@@ -281,6 +284,32 @@ def __init__(
self._repo_configured = project_name == "emrg"
self._session_id = f"emrg-evolution-{name}"
self._source_dir = path or name
# Sandbox tier for this task's bash tool (rant 2026-08-20T15:46:50):
# explicit config wins; builtin tasks get suggested defaults; None =
# danger-full-access (current behavior).
self._sandbox = self._resolve_sandbox(name, config, sandbox)
if self._sandbox:
self._logger.info(
"TaskHandler[%s]: bash sandbox tier = %s", name, self._sandbox
)

@staticmethod
def _resolve_sandbox(name: str, config: dict, explicit: str | None) -> str | None:
"""Effective bash sandbox tier for a task.

Order: tasks.yml top-level ``sandbox:`` field → ``config.sandbox`` →
builtin defaults by task name → None (= danger-full-access, the
existing un-sandboxed behavior). Invalid values fall through to the
defaults rather than breaking the task.
"""
for cand in (explicit, config.get("sandbox")):
if cand in SANDBOX_MODES and cand != "danger-full-access":
return cand
if name == "emrg-task":
return "workspace-write" # evolution writes its own repo
if name.endswith("-opensource-task"):
return "read-only" # community work in host-owned repos
return None

# ── Saturation state (restored from disk across daemon restarts) ──

Expand DownExpand Up@@ -684,6 +713,7 @@ async def _run_evolution_cycle(self) -> None:
"prompt": prompt,
"stream": True,
"timestamp": cycle_time.isoformat(),
"sandbox": self._sandbox,
},
ensure_ascii=False,
)
Expand DownExpand Up@@ -961,6 +991,7 @@ def _build_handler(self, cfg: dict) -> TaskHandler:
interval=cfg.get("interval", DEFAULT_INTERVAL),
identity=self.identity,
template_path=template_path,
sandbox=cfg.get("sandbox"),
)

def _start_handler_for(self, cfg: dict) -> TaskHandler:
Expand DownExpand Up@@ -1267,12 +1298,15 @@ def task_create(
self, name: str, task_type: str, project: str,
interval: int | None = None, enabled: bool = True,
repo: str | None = None, description: str | None = None,
sandbox: str | None = None,
) -> tuple[bool, str | dict]:
"""Create a task. Returns (ok, error) or (ok, task-dict)."""
interval = DEFAULT_INTERVAL if interval is None else interval
err = self._validate_task_fields(name, task_type, project, interval)
if err:
return False, err
if sandbox is not None and sandbox not in SANDBOX_MODES:
return False, f"invalid sandbox {sandbox!r} (expected one of {', '.join(SANDBOX_MODES)})"
tasks = self._load_tasks()
if any(t.get("name") == name for t in tasks):
return False, f"task {name!r} already exists"
Expand All@@ -1287,6 +1321,8 @@ def task_create(
"enabled": bool(enabled),
"last_run": None,
}
if sandbox is not None:
task["sandbox"] = sandbox
if description:
task["description"] = description
tasks.append(task)
Expand DownExpand Up@@ -1327,6 +1363,10 @@ def task_update(self, name: str, **fields) -> tuple[bool, str | dict]:
task["enabled"] = bool(fields["enabled"])
if "description" in fields:
task["description"] = fields["description"]
if "sandbox" in fields:
if fields["sandbox"] is not None and fields["sandbox"] not in SANDBOX_MODES:
return False, f"invalid sandbox {fields['sandbox']!r} (expected one of {', '.join(SANDBOX_MODES)})"
task["sandbox"] = fields["sandbox"]
self._save_tasks(tasks)
logger.info("TaskScheduler: task %s updated", name)
return True, task
Expand Down
175 changes: 175 additions & 0 deletions emrg/tools/bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,159 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]:
return rewritten.rstrip(), path


# ── Sandbox — file-level isolation for the bash tool (rant 2026-08-20T15:46:50) ──
#
# Three tiers (default danger-full-access = current, un-sandboxed behavior):
# danger-full-access — no checks at all (existing behavior)
# read-only — no writes allowed: destructive commands (rm -r /
# rmdir / mv / cp -r) and shell redirects (> / >>)
# to any non-/dev/null target are blocked
# workspace-write — writes inside the workspace root (and the OS temp
# area) are allowed; destructive writes to protected
# daemon state files and to absolute paths outside
# the workspace are blocked
#
# Enforcement is deliberately heuristic (host design-finalized): a static
# command scan, NOT an OS-level sandbox (no bwrap/Seatbelt/ACL). The checked
# modes report enforcement="partial" — honest reporting, never pretending
# full OS-level isolation. The core value is blocking a hallucinated LLM's
# obviously destructive commands (rm -rf with a wrong path, writing the
# daemon's own state files).

SANDBOX_MODES = ("danger-full-access", "read-only", "workspace-write")

# Daemon state files — writing to these from a sandboxed task is always
# blocked (they are the daemon's own data, not agent scratch space).
_PROTECTED_FILES = (
"~/.emrg/config.toml",
"~/.emrg/emrgd.token",
"~/.emrg/tasks.yml",
"~/.emrg/projects.yml",
"~/.emrg/rants.jsonl",
)


def _extract_write_targets(cmd: str) -> list[str]:
"""Heuristic extraction of write targets from a command line.

Returns path tokens the command appears to write to:
- ``rm -r/-rf/-R <path>`` and ``rmdir <path>`` → the removed path
- ``mv <src> <dst>`` / ``cp -r <src> <dst>`` → the destination
- ``> / >> / 2> / &>`` redirects → the redirect target

Deliberately non-exhaustive (the sandbox only catches obvious
destructive writes — the boundary is honest: enforcement=partial).
"""
targets: list[str] = []
# rm -r / rm -rf / rm -R ... <path> (recursive delete)
for m in re.finditer(r"\brm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# rmdir <path>
for m in re.finditer(r"\brmdir\s+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# mv <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bmv\s+((?:-[a-zA-Z]*\s+)*[^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# cp -r <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bcp\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# shell redirects: > file / >> file / 2> file / &> file
for m in re.finditer(r"(?:\d*>>?|&>>?)\s*([^\s|;&]+)", cmd):
targets.append(m.group(1))
return targets


def _protected_paths() -> list[str]:
"""Canonicalized (realpath) protected daemon state files."""
out: list[str] = []
for p in _PROTECTED_FILES:
try:
out.append(os.path.realpath(os.path.expanduser(p)))
except OSError:
pass
return out


def _is_absolute_path(p: str) -> bool:
"""True when ``p`` is absolute (or drive-less rooted, e.g. ``/etc/hosts``
on Windows — ntpath.isabs returns False for those, but they still do not
resolve under the cwd, so the sandbox must treat them as absolute)."""
return os.path.isabs(p) or p.startswith("/") or p.startswith("\\")


def _is_within(path: str, root: str) -> bool:
"""True when ``path`` (absolute) is inside ``root`` (absolute) or equals it."""
try:
rp = os.path.realpath(path)
rr = os.path.realpath(root)
return rp == rr or rp.startswith(rr + os.sep)
except OSError:
return False


def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[bool, str | None, str]:
"""Static sandbox check for a bash command (rant 2026-08-20T15:46:50).

Returns ``(allowed, blocked_reason, enforcement)``:
- danger-full-access → (True, None, "full") — no checks, current behavior.
- read-only → blocks every destructive write (rm -r / rmdir / mv /
cp -r and shell redirects to any non-/dev/null target).
- workspace-write → blocks destructive writes to protected daemon
files, to ``~/.emrg`` itself, and to absolute paths outside the
workspace root (the OS temp dir is allowed — mirrors dsh's
workspace + backend-promised temp area).

Heuristic by design: static scan only, no OS-level boundary — checked
modes honestly report enforcement="partial".
"""
if mode not in SANDBOX_MODES:
return False, f"invalid sandbox mode {mode!r}", "partial"
if mode == "danger-full-access":
return True, None, "full"

targets = _extract_write_targets(cmd)
if mode == "read-only":
for t in targets:
if t != "/dev/null":
return False, (
f"read-only sandbox: blocked destructive write targeting {t!r}"
), "partial"
return True, None, "partial"

# workspace-write
if not targets:
return True, None, "partial"
protected = _protected_paths()
emrg_home = os.path.realpath(os.path.expanduser("~/.emrg"))
workdir_real = os.path.realpath(workdir) if workdir else None
for t in targets:
if t == "/dev/null":
continue
expanded = os.path.expanduser(t)
if not _is_absolute_path(expanded):
# Relative target: assumed in-workspace (cwd = the workspace root).
continue
real = os.path.realpath(expanded)
if real in protected:
return False, (
f"workspace-write sandbox: blocked write to protected daemon file {t!r}"
), "partial"
if real == emrg_home:
return False, (
f"workspace-write sandbox: blocked destructive write to {t!r} "
"(would erase the daemon's data directory)"
), "partial"
if workdir_real and not _is_within(real, workdir_real) and not _is_within(real, tempfile.gettempdir()):
return False, (
f"workspace-write sandbox: blocked write outside workspace {t!r}"
), "partial"
return True, None, "partial"


def _decode_output(data: bytes, os_name: str | None = None) -> str:
"""Decode subprocess output bytes without corrupting non-UTF-8 text.

Expand DownExpand Up@@ -166,10 +319,30 @@ async def execute(self, arguments: dict) -> ToolResult:
cmd = arguments.get("command", "")
timeout = arguments.get("timeout", 30)
workdir = arguments.get("workdir", None)
# Sandbox tier — daemon-injected per task config (the agent cannot
# choose its own sandbox; rant 2026-08-20T15:46:50).
sandbox = arguments.get("sandbox")

if not cmd:
return ToolResult(name="bash", content="Error: no command provided", error=True)

# Static file-level isolation check (rant 2026-08-20T15:46:50).
sandbox_tag: str | None = None
if sandbox and sandbox != "danger-full-access":
allowed, reason, enforcement = _check_sandbox(cmd, sandbox, workdir)
if not allowed:
logger.info("bash: BLOCKED by %s sandbox: %s", sandbox, reason)
return ToolResult(
name="bash",
content=(
f"⛔ [sandbox:{sandbox} enforcement={enforcement}] "
f"{reason} — command not executed"
),
error=True,
)
sandbox_tag = f"[sandbox:{sandbox} enforcement={enforcement}]"
logger.debug("bash: sandbox %s check passed", sandbox)

logger.debug("bash: running %r (timeout=%ds)", cmd[:100], timeout)

# Windows: cmd.exe cannot parse bash heredocs — translate the first
Expand DownExpand Up@@ -271,6 +444,8 @@ async def execute(self, arguments: dict) -> ToolResult:
if not parts:
parts.append("(no output)")
result = "\n".join(parts)
if sandbox_tag:
result = f"{sandbox_tag} ok\n{result}"
return ToolResult(name="bash", content=result)
except FileNotFoundError:
return ToolResult(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
emrg: bash tool sandbox — file-level isolation for task sessions by argszero · Pull Request #886 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (958) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (981) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
7 changes: 7 additions & 0 deletions emrg/protocol.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,11 @@ class TaskRequest:
default_factory=lambda: datetime.now().isoformat()
)
images: Optional[list[dict]] = None
# Sandbox tier for the task's bash tool (rant 2026-08-20T15:46:50):
# "read-only" | "workspace-write" | "danger-full-access" (default None =
# danger-full-access, current behavior). Set by task config, never by
# the agent itself.
sandbox: Optional[str] = None

def to_dict(self) -> dict:
d = {
Expand All@@ -42,6 +47,8 @@ def to_dict(self) -> dict:
}
if self.images:
d["images"] = self.images
if self.sandbox:
d["sandbox"] = self.sandbox
return d


Expand Down
13 changes: 12 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -613,6 +613,9 @@ async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) ->
cwd=cwd,
prompt=prompt,
timestamp="",
# Upgrade writes install/ and source/ inside its own work dir —
# workspace-write tier (rant 2026-08-20T15:46:50).
sandbox="workspace-write",
)
if self._session_busy.get(session_id):
# Queue per existing semantics (host decision A: busy → pending,
Expand DownExpand Up@@ -772,6 +775,7 @@ async def _handle_client(self, ws) -> None:
prompt=data.get("prompt", ""),
timestamp=data.get("timestamp", ""),
images=data.get("images"),
sandbox=data.get("sandbox"),
)
except Exception as e:
await self._send(ws, {"error": f"invalid task: {e}"})
Expand DownExpand Up@@ -1467,6 +1471,7 @@ async def _process_message(
enabled=msg.get("enabled", True),
repo=msg.get("repo"),
description=msg.get("description"),
sandbox=msg.get("sandbox"),
)
if not ok:
await self._send(ws, {"type": "task_result", "error": res})
Expand All@@ -1478,7 +1483,7 @@ async def _process_message(
if not self._scheduler:
await self._send(ws, {"type": "task_result", "error": "scheduler not running"})
return
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description") if k in msg}
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description", "sandbox") if k in msg}
if "task_type" in fields:
fields["type"] = fields.pop("task_type")
ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields)
Expand DownExpand Up@@ -2536,6 +2541,12 @@ async def _run_tool_loop(
elif tc_name == "grep" and "path" not in args:
args["path"] = str(session.cwd)

# Sandbox tier (rant 2026-08-20T15:46:50): the task's
# configured sandbox is injected into the bash tool — the
# agent cannot choose it per call.
if tc_name == "bash" and req.sandbox:
args["sandbox"] = req.sandbox

# Execute
tool = self.tools.get(tc_name)
if tool:
Expand Down
40 changes: 40 additions & 0 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@

from emrg.config import config_dir
from emrg.connect import connect_to_server
from emrg.tools.bash_tool import SANDBOX_MODES
from websockets.exceptions import ConnectionClosed
from emrg.protocol import EvolutionLog, InstanceIdentity
from emrg.server.atomic import atomic_write_yaml
Expand DownExpand Up@@ -130,6 +131,7 @@ def _task_cfg_signature(cfg: dict) -> tuple:
json.dumps(conf, sort_keys=True),
cfg.get("interval", DEFAULT_INTERVAL),
bool(cfg.get("enabled", True)),
cfg.get("sandbox"),
)


Expand DownExpand Up@@ -198,6 +200,7 @@ def __init__(
interval: int,
identity: InstanceIdentity,
template_path: Path | None = None,
sandbox: str | None = None,
) -> None:
self.name = name
# Rant 2026-08-19T10:18:44: per-task logger — LoggerAdapter injects a
Expand DownExpand Up@@ -281,6 +284,32 @@ def __init__(
self._repo_configured = project_name == "emrg"
self._session_id = f"emrg-evolution-{name}"
self._source_dir = path or name
# Sandbox tier for this task's bash tool (rant 2026-08-20T15:46:50):
# explicit config wins; builtin tasks get suggested defaults; None =
# danger-full-access (current behavior).
self._sandbox = self._resolve_sandbox(name, config, sandbox)
if self._sandbox:
self._logger.info(
"TaskHandler[%s]: bash sandbox tier = %s", name, self._sandbox
)

@staticmethod
def _resolve_sandbox(name: str, config: dict, explicit: str | None) -> str | None:
"""Effective bash sandbox tier for a task.

Order: tasks.yml top-level ``sandbox:`` field → ``config.sandbox`` →
builtin defaults by task name → None (= danger-full-access, the
existing un-sandboxed behavior). Invalid values fall through to the
defaults rather than breaking the task.
"""
for cand in (explicit, config.get("sandbox")):
if cand in SANDBOX_MODES and cand != "danger-full-access":
return cand
if name == "emrg-task":
return "workspace-write" # evolution writes its own repo
if name.endswith("-opensource-task"):
return "read-only" # community work in host-owned repos
return None

# ── Saturation state (restored from disk across daemon restarts) ──

Expand DownExpand Up@@ -684,6 +713,7 @@ async def _run_evolution_cycle(self) -> None:
"prompt": prompt,
"stream": True,
"timestamp": cycle_time.isoformat(),
"sandbox": self._sandbox,
},
ensure_ascii=False,
)
Expand DownExpand Up@@ -961,6 +991,7 @@ def _build_handler(self, cfg: dict) -> TaskHandler:
interval=cfg.get("interval", DEFAULT_INTERVAL),
identity=self.identity,
template_path=template_path,
sandbox=cfg.get("sandbox"),
)

def _start_handler_for(self, cfg: dict) -> TaskHandler:
Expand DownExpand Up@@ -1267,12 +1298,15 @@ def task_create(
self, name: str, task_type: str, project: str,
interval: int | None = None, enabled: bool = True,
repo: str | None = None, description: str | None = None,
sandbox: str | None = None,
) -> tuple[bool, str | dict]:
"""Create a task. Returns (ok, error) or (ok, task-dict)."""
interval = DEFAULT_INTERVAL if interval is None else interval
err = self._validate_task_fields(name, task_type, project, interval)
if err:
return False, err
if sandbox is not None and sandbox not in SANDBOX_MODES:
return False, f"invalid sandbox {sandbox!r} (expected one of {', '.join(SANDBOX_MODES)})"
tasks = self._load_tasks()
if any(t.get("name") == name for t in tasks):
return False, f"task {name!r} already exists"
Expand All@@ -1287,6 +1321,8 @@ def task_create(
"enabled": bool(enabled),
"last_run": None,
}
if sandbox is not None:
task["sandbox"] = sandbox
if description:
task["description"] = description
tasks.append(task)
Expand DownExpand Up@@ -1327,6 +1363,10 @@ def task_update(self, name: str, **fields) -> tuple[bool, str | dict]:
task["enabled"] = bool(fields["enabled"])
if "description" in fields:
task["description"] = fields["description"]
if "sandbox" in fields:
if fields["sandbox"] is not None and fields["sandbox"] not in SANDBOX_MODES:
return False, f"invalid sandbox {fields['sandbox']!r} (expected one of {', '.join(SANDBOX_MODES)})"
task["sandbox"] = fields["sandbox"]
self._save_tasks(tasks)
logger.info("TaskScheduler: task %s updated", name)
return True, task
Expand Down
175 changes: 175 additions & 0 deletions emrg/tools/bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,159 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]:
return rewritten.rstrip(), path


# ── Sandbox — file-level isolation for the bash tool (rant 2026-08-20T15:46:50) ──
#
# Three tiers (default danger-full-access = current, un-sandboxed behavior):
# danger-full-access — no checks at all (existing behavior)
# read-only — no writes allowed: destructive commands (rm -r /
# rmdir / mv / cp -r) and shell redirects (> / >>)
# to any non-/dev/null target are blocked
# workspace-write — writes inside the workspace root (and the OS temp
# area) are allowed; destructive writes to protected
# daemon state files and to absolute paths outside
# the workspace are blocked
#
# Enforcement is deliberately heuristic (host design-finalized): a static
# command scan, NOT an OS-level sandbox (no bwrap/Seatbelt/ACL). The checked
# modes report enforcement="partial" — honest reporting, never pretending
# full OS-level isolation. The core value is blocking a hallucinated LLM's
# obviously destructive commands (rm -rf with a wrong path, writing the
# daemon's own state files).

SANDBOX_MODES = ("danger-full-access", "read-only", "workspace-write")

# Daemon state files — writing to these from a sandboxed task is always
# blocked (they are the daemon's own data, not agent scratch space).
_PROTECTED_FILES = (
"~/.emrg/config.toml",
"~/.emrg/emrgd.token",
"~/.emrg/tasks.yml",
"~/.emrg/projects.yml",
"~/.emrg/rants.jsonl",
)


def _extract_write_targets(cmd: str) -> list[str]:
"""Heuristic extraction of write targets from a command line.

Returns path tokens the command appears to write to:
- ``rm -r/-rf/-R <path>`` and ``rmdir <path>`` → the removed path
- ``mv <src> <dst>`` / ``cp -r <src> <dst>`` → the destination
- ``> / >> / 2> / &>`` redirects → the redirect target

Deliberately non-exhaustive (the sandbox only catches obvious
destructive writes — the boundary is honest: enforcement=partial).
"""
targets: list[str] = []
# rm -r / rm -rf / rm -R ... <path> (recursive delete)
for m in re.finditer(r"\brm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# rmdir <path>
for m in re.finditer(r"\brmdir\s+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# mv <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bmv\s+((?:-[a-zA-Z]*\s+)*[^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# cp -r <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bcp\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# shell redirects: > file / >> file / 2> file / &> file
for m in re.finditer(r"(?:\d*>>?|&>>?)\s*([^\s|;&]+)", cmd):
targets.append(m.group(1))
return targets


def _protected_paths() -> list[str]:
"""Canonicalized (realpath) protected daemon state files."""
out: list[str] = []
for p in _PROTECTED_FILES:
try:
out.append(os.path.realpath(os.path.expanduser(p)))
except OSError:
pass
return out


def _is_absolute_path(p: str) -> bool:
"""True when ``p`` is absolute (or drive-less rooted, e.g. ``/etc/hosts``
on Windows — ntpath.isabs returns False for those, but they still do not
resolve under the cwd, so the sandbox must treat them as absolute)."""
return os.path.isabs(p) or p.startswith("/") or p.startswith("\\")


def _is_within(path: str, root: str) -> bool:
"""True when ``path`` (absolute) is inside ``root`` (absolute) or equals it."""
try:
rp = os.path.realpath(path)
rr = os.path.realpath(root)
return rp == rr or rp.startswith(rr + os.sep)
except OSError:
return False


def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[bool, str | None, str]:
"""Static sandbox check for a bash command (rant 2026-08-20T15:46:50).

Returns ``(allowed, blocked_reason, enforcement)``:
- danger-full-access → (True, None, "full") — no checks, current behavior.
- read-only → blocks every destructive write (rm -r / rmdir / mv /
cp -r and shell redirects to any non-/dev/null target).
- workspace-write → blocks destructive writes to protected daemon
files, to ``~/.emrg`` itself, and to absolute paths outside the
workspace root (the OS temp dir is allowed — mirrors dsh's
workspace + backend-promised temp area).

Heuristic by design: static scan only, no OS-level boundary — checked
modes honestly report enforcement="partial".
"""
if mode not in SANDBOX_MODES:
return False, f"invalid sandbox mode {mode!r}", "partial"
if mode == "danger-full-access":
return True, None, "full"

targets = _extract_write_targets(cmd)
if mode == "read-only":
for t in targets:
if t != "/dev/null":
return False, (
f"read-only sandbox: blocked destructive write targeting {t!r}"
), "partial"
return True, None, "partial"

# workspace-write
if not targets:
return True, None, "partial"
protected = _protected_paths()
emrg_home = os.path.realpath(os.path.expanduser("~/.emrg"))
workdir_real = os.path.realpath(workdir) if workdir else None
for t in targets:
if t == "/dev/null":
continue
expanded = os.path.expanduser(t)
if not _is_absolute_path(expanded):
# Relative target: assumed in-workspace (cwd = the workspace root).
continue
real = os.path.realpath(expanded)
if real in protected:
return False, (
f"workspace-write sandbox: blocked write to protected daemon file {t!r}"
), "partial"
if real == emrg_home:
return False, (
f"workspace-write sandbox: blocked destructive write to {t!r} "
"(would erase the daemon's data directory)"
), "partial"
if workdir_real and not _is_within(real, workdir_real) and not _is_within(real, tempfile.gettempdir()):
return False, (
f"workspace-write sandbox: blocked write outside workspace {t!r}"
), "partial"
return True, None, "partial"


def _decode_output(data: bytes, os_name: str | None = None) -> str:
"""Decode subprocess output bytes without corrupting non-UTF-8 text.

Expand DownExpand Up@@ -166,10 +319,30 @@ async def execute(self, arguments: dict) -> ToolResult:
cmd = arguments.get("command", "")
timeout = arguments.get("timeout", 30)
workdir = arguments.get("workdir", None)
# Sandbox tier — daemon-injected per task config (the agent cannot
# choose its own sandbox; rant 2026-08-20T15:46:50).
sandbox = arguments.get("sandbox")

if not cmd:
return ToolResult(name="bash", content="Error: no command provided", error=True)

# Static file-level isolation check (rant 2026-08-20T15:46:50).
sandbox_tag: str | None = None
if sandbox and sandbox != "danger-full-access":
allowed, reason, enforcement = _check_sandbox(cmd, sandbox, workdir)
if not allowed:
logger.info("bash: BLOCKED by %s sandbox: %s", sandbox, reason)
return ToolResult(
name="bash",
content=(
f"⛔ [sandbox:{sandbox} enforcement={enforcement}] "
f"{reason} — command not executed"
),
error=True,
)
sandbox_tag = f"[sandbox:{sandbox} enforcement={enforcement}]"
logger.debug("bash: sandbox %s check passed", sandbox)

logger.debug("bash: running %r (timeout=%ds)", cmd[:100], timeout)

# Windows: cmd.exe cannot parse bash heredocs — translate the first
Expand DownExpand Up@@ -271,6 +444,8 @@ async def execute(self, arguments: dict) -> ToolResult:
if not parts:
parts.append("(no output)")
result = "\n".join(parts)
if sandbox_tag:
result = f"{sandbox_tag} ok\n{result}"
return ToolResult(name="bash", content=result)
except FileNotFoundError:
return ToolResult(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: bash tool sandbox — file-level isolation for task sessions by argszero · Pull Request #886 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (958) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (981) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
7 changes: 7 additions & 0 deletions emrg/protocol.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,11 @@ class TaskRequest:
default_factory=lambda: datetime.now().isoformat()
)
images: Optional[list[dict]] = None
# Sandbox tier for the task's bash tool (rant 2026-08-20T15:46:50):
# "read-only" | "workspace-write" | "danger-full-access" (default None =
# danger-full-access, current behavior). Set by task config, never by
# the agent itself.
sandbox: Optional[str] = None

def to_dict(self) -> dict:
d = {
Expand All@@ -42,6 +47,8 @@ def to_dict(self) -> dict:
}
if self.images:
d["images"] = self.images
if self.sandbox:
d["sandbox"] = self.sandbox
return d


Expand Down
13 changes: 12 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -613,6 +613,9 @@ async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) ->
cwd=cwd,
prompt=prompt,
timestamp="",
# Upgrade writes install/ and source/ inside its own work dir —
# workspace-write tier (rant 2026-08-20T15:46:50).
sandbox="workspace-write",
)
if self._session_busy.get(session_id):
# Queue per existing semantics (host decision A: busy → pending,
Expand DownExpand Up@@ -772,6 +775,7 @@ async def _handle_client(self, ws) -> None:
prompt=data.get("prompt", ""),
timestamp=data.get("timestamp", ""),
images=data.get("images"),
sandbox=data.get("sandbox"),
)
except Exception as e:
await self._send(ws, {"error": f"invalid task: {e}"})
Expand DownExpand Up@@ -1467,6 +1471,7 @@ async def _process_message(
enabled=msg.get("enabled", True),
repo=msg.get("repo"),
description=msg.get("description"),
sandbox=msg.get("sandbox"),
)
if not ok:
await self._send(ws, {"type": "task_result", "error": res})
Expand All@@ -1478,7 +1483,7 @@ async def _process_message(
if not self._scheduler:
await self._send(ws, {"type": "task_result", "error": "scheduler not running"})
return
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description") if k in msg}
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description", "sandbox") if k in msg}
if "task_type" in fields:
fields["type"] = fields.pop("task_type")
ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields)
Expand DownExpand Up@@ -2536,6 +2541,12 @@ async def _run_tool_loop(
elif tc_name == "grep" and "path" not in args:
args["path"] = str(session.cwd)

# Sandbox tier (rant 2026-08-20T15:46:50): the task's
# configured sandbox is injected into the bash tool — the
# agent cannot choose it per call.
if tc_name == "bash" and req.sandbox:
args["sandbox"] = req.sandbox

# Execute
tool = self.tools.get(tc_name)
if tool:
Expand Down
40 changes: 40 additions & 0 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@

from emrg.config import config_dir
from emrg.connect import connect_to_server
from emrg.tools.bash_tool import SANDBOX_MODES
from websockets.exceptions import ConnectionClosed
from emrg.protocol import EvolutionLog, InstanceIdentity
from emrg.server.atomic import atomic_write_yaml
Expand DownExpand Up@@ -130,6 +131,7 @@ def _task_cfg_signature(cfg: dict) -> tuple:
json.dumps(conf, sort_keys=True),
cfg.get("interval", DEFAULT_INTERVAL),
bool(cfg.get("enabled", True)),
cfg.get("sandbox"),
)


Expand DownExpand Up@@ -198,6 +200,7 @@ def __init__(
interval: int,
identity: InstanceIdentity,
template_path: Path | None = None,
sandbox: str | None = None,
) -> None:
self.name = name
# Rant 2026-08-19T10:18:44: per-task logger — LoggerAdapter injects a
Expand DownExpand Up@@ -281,6 +284,32 @@ def __init__(
self._repo_configured = project_name == "emrg"
self._session_id = f"emrg-evolution-{name}"
self._source_dir = path or name
# Sandbox tier for this task's bash tool (rant 2026-08-20T15:46:50):
# explicit config wins; builtin tasks get suggested defaults; None =
# danger-full-access (current behavior).
self._sandbox = self._resolve_sandbox(name, config, sandbox)
if self._sandbox:
self._logger.info(
"TaskHandler[%s]: bash sandbox tier = %s", name, self._sandbox
)

@staticmethod
def _resolve_sandbox(name: str, config: dict, explicit: str | None) -> str | None:
"""Effective bash sandbox tier for a task.

Order: tasks.yml top-level ``sandbox:`` field → ``config.sandbox`` →
builtin defaults by task name → None (= danger-full-access, the
existing un-sandboxed behavior). Invalid values fall through to the
defaults rather than breaking the task.
"""
for cand in (explicit, config.get("sandbox")):
if cand in SANDBOX_MODES and cand != "danger-full-access":
return cand
if name == "emrg-task":
return "workspace-write" # evolution writes its own repo
if name.endswith("-opensource-task"):
return "read-only" # community work in host-owned repos
return None

# ── Saturation state (restored from disk across daemon restarts) ──

Expand DownExpand Up@@ -684,6 +713,7 @@ async def _run_evolution_cycle(self) -> None:
"prompt": prompt,
"stream": True,
"timestamp": cycle_time.isoformat(),
"sandbox": self._sandbox,
},
ensure_ascii=False,
)
Expand DownExpand Up@@ -961,6 +991,7 @@ def _build_handler(self, cfg: dict) -> TaskHandler:
interval=cfg.get("interval", DEFAULT_INTERVAL),
identity=self.identity,
template_path=template_path,
sandbox=cfg.get("sandbox"),
)

def _start_handler_for(self, cfg: dict) -> TaskHandler:
Expand DownExpand Up@@ -1267,12 +1298,15 @@ def task_create(
self, name: str, task_type: str, project: str,
interval: int | None = None, enabled: bool = True,
repo: str | None = None, description: str | None = None,
sandbox: str | None = None,
) -> tuple[bool, str | dict]:
"""Create a task. Returns (ok, error) or (ok, task-dict)."""
interval = DEFAULT_INTERVAL if interval is None else interval
err = self._validate_task_fields(name, task_type, project, interval)
if err:
return False, err
if sandbox is not None and sandbox not in SANDBOX_MODES:
return False, f"invalid sandbox {sandbox!r} (expected one of {', '.join(SANDBOX_MODES)})"
tasks = self._load_tasks()
if any(t.get("name") == name for t in tasks):
return False, f"task {name!r} already exists"
Expand All@@ -1287,6 +1321,8 @@ def task_create(
"enabled": bool(enabled),
"last_run": None,
}
if sandbox is not None:
task["sandbox"] = sandbox
if description:
task["description"] = description
tasks.append(task)
Expand DownExpand Up@@ -1327,6 +1363,10 @@ def task_update(self, name: str, **fields) -> tuple[bool, str | dict]:
task["enabled"] = bool(fields["enabled"])
if "description" in fields:
task["description"] = fields["description"]
if "sandbox" in fields:
if fields["sandbox"] is not None and fields["sandbox"] not in SANDBOX_MODES:
return False, f"invalid sandbox {fields['sandbox']!r} (expected one of {', '.join(SANDBOX_MODES)})"
task["sandbox"] = fields["sandbox"]
self._save_tasks(tasks)
logger.info("TaskScheduler: task %s updated", name)
return True, task
Expand Down
175 changes: 175 additions & 0 deletions emrg/tools/bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,159 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]:
return rewritten.rstrip(), path


# ── Sandbox — file-level isolation for the bash tool (rant 2026-08-20T15:46:50) ──
#
# Three tiers (default danger-full-access = current, un-sandboxed behavior):
# danger-full-access — no checks at all (existing behavior)
# read-only — no writes allowed: destructive commands (rm -r /
# rmdir / mv / cp -r) and shell redirects (> / >>)
# to any non-/dev/null target are blocked
# workspace-write — writes inside the workspace root (and the OS temp
# area) are allowed; destructive writes to protected
# daemon state files and to absolute paths outside
# the workspace are blocked
#
# Enforcement is deliberately heuristic (host design-finalized): a static
# command scan, NOT an OS-level sandbox (no bwrap/Seatbelt/ACL). The checked
# modes report enforcement="partial" — honest reporting, never pretending
# full OS-level isolation. The core value is blocking a hallucinated LLM's
# obviously destructive commands (rm -rf with a wrong path, writing the
# daemon's own state files).

SANDBOX_MODES = ("danger-full-access", "read-only", "workspace-write")

# Daemon state files — writing to these from a sandboxed task is always
# blocked (they are the daemon's own data, not agent scratch space).
_PROTECTED_FILES = (
"~/.emrg/config.toml",
"~/.emrg/emrgd.token",
"~/.emrg/tasks.yml",
"~/.emrg/projects.yml",
"~/.emrg/rants.jsonl",
)


def _extract_write_targets(cmd: str) -> list[str]:
"""Heuristic extraction of write targets from a command line.

Returns path tokens the command appears to write to:
- ``rm -r/-rf/-R <path>`` and ``rmdir <path>`` → the removed path
- ``mv <src> <dst>`` / ``cp -r <src> <dst>`` → the destination
- ``> / >> / 2> / &>`` redirects → the redirect target

Deliberately non-exhaustive (the sandbox only catches obvious
destructive writes — the boundary is honest: enforcement=partial).
"""
targets: list[str] = []
# rm -r / rm -rf / rm -R ... <path> (recursive delete)
for m in re.finditer(r"\brm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# rmdir <path>
for m in re.finditer(r"\brmdir\s+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# mv <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bmv\s+((?:-[a-zA-Z]*\s+)*[^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# cp -r <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bcp\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# shell redirects: > file / >> file / 2> file / &> file
for m in re.finditer(r"(?:\d*>>?|&>>?)\s*([^\s|;&]+)", cmd):
targets.append(m.group(1))
return targets


def _protected_paths() -> list[str]:
"""Canonicalized (realpath) protected daemon state files."""
out: list[str] = []
for p in _PROTECTED_FILES:
try:
out.append(os.path.realpath(os.path.expanduser(p)))
except OSError:
pass
return out


def _is_absolute_path(p: str) -> bool:
"""True when ``p`` is absolute (or drive-less rooted, e.g. ``/etc/hosts``
on Windows — ntpath.isabs returns False for those, but they still do not
resolve under the cwd, so the sandbox must treat them as absolute)."""
return os.path.isabs(p) or p.startswith("/") or p.startswith("\\")


def _is_within(path: str, root: str) -> bool:
"""True when ``path`` (absolute) is inside ``root`` (absolute) or equals it."""
try:
rp = os.path.realpath(path)
rr = os.path.realpath(root)
return rp == rr or rp.startswith(rr + os.sep)
except OSError:
return False


def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[bool, str | None, str]:
"""Static sandbox check for a bash command (rant 2026-08-20T15:46:50).

Returns ``(allowed, blocked_reason, enforcement)``:
- danger-full-access → (True, None, "full") — no checks, current behavior.
- read-only → blocks every destructive write (rm -r / rmdir / mv /
cp -r and shell redirects to any non-/dev/null target).
- workspace-write → blocks destructive writes to protected daemon
files, to ``~/.emrg`` itself, and to absolute paths outside the
workspace root (the OS temp dir is allowed — mirrors dsh's
workspace + backend-promised temp area).

Heuristic by design: static scan only, no OS-level boundary — checked
modes honestly report enforcement="partial".
"""
if mode not in SANDBOX_MODES:
return False, f"invalid sandbox mode {mode!r}", "partial"
if mode == "danger-full-access":
return True, None, "full"

targets = _extract_write_targets(cmd)
if mode == "read-only":
for t in targets:
if t != "/dev/null":
return False, (
f"read-only sandbox: blocked destructive write targeting {t!r}"
), "partial"
return True, None, "partial"

# workspace-write
if not targets:
return True, None, "partial"
protected = _protected_paths()
emrg_home = os.path.realpath(os.path.expanduser("~/.emrg"))
workdir_real = os.path.realpath(workdir) if workdir else None
for t in targets:
if t == "/dev/null":
continue
expanded = os.path.expanduser(t)
if not _is_absolute_path(expanded):
# Relative target: assumed in-workspace (cwd = the workspace root).
continue
real = os.path.realpath(expanded)
if real in protected:
return False, (
f"workspace-write sandbox: blocked write to protected daemon file {t!r}"
), "partial"
if real == emrg_home:
return False, (
f"workspace-write sandbox: blocked destructive write to {t!r} "
"(would erase the daemon's data directory)"
), "partial"
if workdir_real and not _is_within(real, workdir_real) and not _is_within(real, tempfile.gettempdir()):
return False, (
f"workspace-write sandbox: blocked write outside workspace {t!r}"
), "partial"
return True, None, "partial"


def _decode_output(data: bytes, os_name: str | None = None) -> str:
"""Decode subprocess output bytes without corrupting non-UTF-8 text.

Expand DownExpand Up@@ -166,10 +319,30 @@ async def execute(self, arguments: dict) -> ToolResult:
cmd = arguments.get("command", "")
timeout = arguments.get("timeout", 30)
workdir = arguments.get("workdir", None)
# Sandbox tier — daemon-injected per task config (the agent cannot
# choose its own sandbox; rant 2026-08-20T15:46:50).
sandbox = arguments.get("sandbox")

if not cmd:
return ToolResult(name="bash", content="Error: no command provided", error=True)

# Static file-level isolation check (rant 2026-08-20T15:46:50).
sandbox_tag: str | None = None
if sandbox and sandbox != "danger-full-access":
allowed, reason, enforcement = _check_sandbox(cmd, sandbox, workdir)
if not allowed:
logger.info("bash: BLOCKED by %s sandbox: %s", sandbox, reason)
return ToolResult(
name="bash",
content=(
f"⛔ [sandbox:{sandbox} enforcement={enforcement}] "
f"{reason} — command not executed"
),
error=True,
)
sandbox_tag = f"[sandbox:{sandbox} enforcement={enforcement}]"
logger.debug("bash: sandbox %s check passed", sandbox)

logger.debug("bash: running %r (timeout=%ds)", cmd[:100], timeout)

# Windows: cmd.exe cannot parse bash heredocs — translate the first
Expand DownExpand Up@@ -271,6 +444,8 @@ async def execute(self, arguments: dict) -> ToolResult:
if not parts:
parts.append("(no output)")
result = "\n".join(parts)
if sandbox_tag:
result = f"{sandbox_tag} ok\n{result}"
return ToolResult(name="bash", content=result)
except FileNotFoundError:
return ToolResult(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: bash tool sandbox — file-level isolation for task sessions by argszero · Pull Request #886 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (958) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (981) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
7 changes: 7 additions & 0 deletions emrg/protocol.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,11 @@ class TaskRequest:
default_factory=lambda: datetime.now().isoformat()
)
images: Optional[list[dict]] = None
# Sandbox tier for the task's bash tool (rant 2026-08-20T15:46:50):
# "read-only" | "workspace-write" | "danger-full-access" (default None =
# danger-full-access, current behavior). Set by task config, never by
# the agent itself.
sandbox: Optional[str] = None

def to_dict(self) -> dict:
d = {
Expand All@@ -42,6 +47,8 @@ def to_dict(self) -> dict:
}
if self.images:
d["images"] = self.images
if self.sandbox:
d["sandbox"] = self.sandbox
return d


Expand Down
13 changes: 12 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -613,6 +613,9 @@ async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) ->
cwd=cwd,
prompt=prompt,
timestamp="",
# Upgrade writes install/ and source/ inside its own work dir —
# workspace-write tier (rant 2026-08-20T15:46:50).
sandbox="workspace-write",
)
if self._session_busy.get(session_id):
# Queue per existing semantics (host decision A: busy → pending,
Expand DownExpand Up@@ -772,6 +775,7 @@ async def _handle_client(self, ws) -> None:
prompt=data.get("prompt", ""),
timestamp=data.get("timestamp", ""),
images=data.get("images"),
sandbox=data.get("sandbox"),
)
except Exception as e:
await self._send(ws, {"error": f"invalid task: {e}"})
Expand DownExpand Up@@ -1467,6 +1471,7 @@ async def _process_message(
enabled=msg.get("enabled", True),
repo=msg.get("repo"),
description=msg.get("description"),
sandbox=msg.get("sandbox"),
)
if not ok:
await self._send(ws, {"type": "task_result", "error": res})
Expand All@@ -1478,7 +1483,7 @@ async def _process_message(
if not self._scheduler:
await self._send(ws, {"type": "task_result", "error": "scheduler not running"})
return
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description") if k in msg}
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description", "sandbox") if k in msg}
if "task_type" in fields:
fields["type"] = fields.pop("task_type")
ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields)
Expand DownExpand Up@@ -2536,6 +2541,12 @@ async def _run_tool_loop(
elif tc_name == "grep" and "path" not in args:
args["path"] = str(session.cwd)

# Sandbox tier (rant 2026-08-20T15:46:50): the task's
# configured sandbox is injected into the bash tool — the
# agent cannot choose it per call.
if tc_name == "bash" and req.sandbox:
args["sandbox"] = req.sandbox

# Execute
tool = self.tools.get(tc_name)
if tool:
Expand Down
40 changes: 40 additions & 0 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@

from emrg.config import config_dir
from emrg.connect import connect_to_server
from emrg.tools.bash_tool import SANDBOX_MODES
from websockets.exceptions import ConnectionClosed
from emrg.protocol import EvolutionLog, InstanceIdentity
from emrg.server.atomic import atomic_write_yaml
Expand DownExpand Up@@ -130,6 +131,7 @@ def _task_cfg_signature(cfg: dict) -> tuple:
json.dumps(conf, sort_keys=True),
cfg.get("interval", DEFAULT_INTERVAL),
bool(cfg.get("enabled", True)),
cfg.get("sandbox"),
)


Expand DownExpand Up@@ -198,6 +200,7 @@ def __init__(
interval: int,
identity: InstanceIdentity,
template_path: Path | None = None,
sandbox: str | None = None,
) -> None:
self.name = name
# Rant 2026-08-19T10:18:44: per-task logger — LoggerAdapter injects a
Expand DownExpand Up@@ -281,6 +284,32 @@ def __init__(
self._repo_configured = project_name == "emrg"
self._session_id = f"emrg-evolution-{name}"
self._source_dir = path or name
# Sandbox tier for this task's bash tool (rant 2026-08-20T15:46:50):
# explicit config wins; builtin tasks get suggested defaults; None =
# danger-full-access (current behavior).
self._sandbox = self._resolve_sandbox(name, config, sandbox)
if self._sandbox:
self._logger.info(
"TaskHandler[%s]: bash sandbox tier = %s", name, self._sandbox
)

@staticmethod
def _resolve_sandbox(name: str, config: dict, explicit: str | None) -> str | None:
"""Effective bash sandbox tier for a task.

Order: tasks.yml top-level ``sandbox:`` field → ``config.sandbox`` →
builtin defaults by task name → None (= danger-full-access, the
existing un-sandboxed behavior). Invalid values fall through to the
defaults rather than breaking the task.
"""
for cand in (explicit, config.get("sandbox")):
if cand in SANDBOX_MODES and cand != "danger-full-access":
return cand
if name == "emrg-task":
return "workspace-write" # evolution writes its own repo
if name.endswith("-opensource-task"):
return "read-only" # community work in host-owned repos
return None

# ── Saturation state (restored from disk across daemon restarts) ──

Expand DownExpand Up@@ -684,6 +713,7 @@ async def _run_evolution_cycle(self) -> None:
"prompt": prompt,
"stream": True,
"timestamp": cycle_time.isoformat(),
"sandbox": self._sandbox,
},
ensure_ascii=False,
)
Expand DownExpand Up@@ -961,6 +991,7 @@ def _build_handler(self, cfg: dict) -> TaskHandler:
interval=cfg.get("interval", DEFAULT_INTERVAL),
identity=self.identity,
template_path=template_path,
sandbox=cfg.get("sandbox"),
)

def _start_handler_for(self, cfg: dict) -> TaskHandler:
Expand DownExpand Up@@ -1267,12 +1298,15 @@ def task_create(
self, name: str, task_type: str, project: str,
interval: int | None = None, enabled: bool = True,
repo: str | None = None, description: str | None = None,
sandbox: str | None = None,
) -> tuple[bool, str | dict]:
"""Create a task. Returns (ok, error) or (ok, task-dict)."""
interval = DEFAULT_INTERVAL if interval is None else interval
err = self._validate_task_fields(name, task_type, project, interval)
if err:
return False, err
if sandbox is not None and sandbox not in SANDBOX_MODES:
return False, f"invalid sandbox {sandbox!r} (expected one of {', '.join(SANDBOX_MODES)})"
tasks = self._load_tasks()
if any(t.get("name") == name for t in tasks):
return False, f"task {name!r} already exists"
Expand All@@ -1287,6 +1321,8 @@ def task_create(
"enabled": bool(enabled),
"last_run": None,
}
if sandbox is not None:
task["sandbox"] = sandbox
if description:
task["description"] = description
tasks.append(task)
Expand DownExpand Up@@ -1327,6 +1363,10 @@ def task_update(self, name: str, **fields) -> tuple[bool, str | dict]:
task["enabled"] = bool(fields["enabled"])
if "description" in fields:
task["description"] = fields["description"]
if "sandbox" in fields:
if fields["sandbox"] is not None and fields["sandbox"] not in SANDBOX_MODES:
return False, f"invalid sandbox {fields['sandbox']!r} (expected one of {', '.join(SANDBOX_MODES)})"
task["sandbox"] = fields["sandbox"]
self._save_tasks(tasks)
logger.info("TaskScheduler: task %s updated", name)
return True, task
Expand Down
175 changes: 175 additions & 0 deletions emrg/tools/bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,159 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]:
return rewritten.rstrip(), path


# ── Sandbox — file-level isolation for the bash tool (rant 2026-08-20T15:46:50) ──
#
# Three tiers (default danger-full-access = current, un-sandboxed behavior):
# danger-full-access — no checks at all (existing behavior)
# read-only — no writes allowed: destructive commands (rm -r /
# rmdir / mv / cp -r) and shell redirects (> / >>)
# to any non-/dev/null target are blocked
# workspace-write — writes inside the workspace root (and the OS temp
# area) are allowed; destructive writes to protected
# daemon state files and to absolute paths outside
# the workspace are blocked
#
# Enforcement is deliberately heuristic (host design-finalized): a static
# command scan, NOT an OS-level sandbox (no bwrap/Seatbelt/ACL). The checked
# modes report enforcement="partial" — honest reporting, never pretending
# full OS-level isolation. The core value is blocking a hallucinated LLM's
# obviously destructive commands (rm -rf with a wrong path, writing the
# daemon's own state files).

SANDBOX_MODES = ("danger-full-access", "read-only", "workspace-write")

# Daemon state files — writing to these from a sandboxed task is always
# blocked (they are the daemon's own data, not agent scratch space).
_PROTECTED_FILES = (
"~/.emrg/config.toml",
"~/.emrg/emrgd.token",
"~/.emrg/tasks.yml",
"~/.emrg/projects.yml",
"~/.emrg/rants.jsonl",
)


def _extract_write_targets(cmd: str) -> list[str]:
"""Heuristic extraction of write targets from a command line.

Returns path tokens the command appears to write to:
- ``rm -r/-rf/-R <path>`` and ``rmdir <path>`` → the removed path
- ``mv <src> <dst>`` / ``cp -r <src> <dst>`` → the destination
- ``> / >> / 2> / &>`` redirects → the redirect target

Deliberately non-exhaustive (the sandbox only catches obvious
destructive writes — the boundary is honest: enforcement=partial).
"""
targets: list[str] = []
# rm -r / rm -rf / rm -R ... <path> (recursive delete)
for m in re.finditer(r"\brm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# rmdir <path>
for m in re.finditer(r"\brmdir\s+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# mv <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bmv\s+((?:-[a-zA-Z]*\s+)*[^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# cp -r <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bcp\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# shell redirects: > file / >> file / 2> file / &> file
for m in re.finditer(r"(?:\d*>>?|&>>?)\s*([^\s|;&]+)", cmd):
targets.append(m.group(1))
return targets


def _protected_paths() -> list[str]:
"""Canonicalized (realpath) protected daemon state files."""
out: list[str] = []
for p in _PROTECTED_FILES:
try:
out.append(os.path.realpath(os.path.expanduser(p)))
except OSError:
pass
return out


def _is_absolute_path(p: str) -> bool:
"""True when ``p`` is absolute (or drive-less rooted, e.g. ``/etc/hosts``
on Windows — ntpath.isabs returns False for those, but they still do not
resolve under the cwd, so the sandbox must treat them as absolute)."""
return os.path.isabs(p) or p.startswith("/") or p.startswith("\\")


def _is_within(path: str, root: str) -> bool:
"""True when ``path`` (absolute) is inside ``root`` (absolute) or equals it."""
try:
rp = os.path.realpath(path)
rr = os.path.realpath(root)
return rp == rr or rp.startswith(rr + os.sep)
except OSError:
return False


def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[bool, str | None, str]:
"""Static sandbox check for a bash command (rant 2026-08-20T15:46:50).

Returns ``(allowed, blocked_reason, enforcement)``:
- danger-full-access → (True, None, "full") — no checks, current behavior.
- read-only → blocks every destructive write (rm -r / rmdir / mv /
cp -r and shell redirects to any non-/dev/null target).
- workspace-write → blocks destructive writes to protected daemon
files, to ``~/.emrg`` itself, and to absolute paths outside the
workspace root (the OS temp dir is allowed — mirrors dsh's
workspace + backend-promised temp area).

Heuristic by design: static scan only, no OS-level boundary — checked
modes honestly report enforcement="partial".
"""
if mode not in SANDBOX_MODES:
return False, f"invalid sandbox mode {mode!r}", "partial"
if mode == "danger-full-access":
return True, None, "full"

targets = _extract_write_targets(cmd)
if mode == "read-only":
for t in targets:
if t != "/dev/null":
return False, (
f"read-only sandbox: blocked destructive write targeting {t!r}"
), "partial"
return True, None, "partial"

# workspace-write
if not targets:
return True, None, "partial"
protected = _protected_paths()
emrg_home = os.path.realpath(os.path.expanduser("~/.emrg"))
workdir_real = os.path.realpath(workdir) if workdir else None
for t in targets:
if t == "/dev/null":
continue
expanded = os.path.expanduser(t)
if not _is_absolute_path(expanded):
# Relative target: assumed in-workspace (cwd = the workspace root).
continue
real = os.path.realpath(expanded)
if real in protected:
return False, (
f"workspace-write sandbox: blocked write to protected daemon file {t!r}"
), "partial"
if real == emrg_home:
return False, (
f"workspace-write sandbox: blocked destructive write to {t!r} "
"(would erase the daemon's data directory)"
), "partial"
if workdir_real and not _is_within(real, workdir_real) and not _is_within(real, tempfile.gettempdir()):
return False, (
f"workspace-write sandbox: blocked write outside workspace {t!r}"
), "partial"
return True, None, "partial"


def _decode_output(data: bytes, os_name: str | None = None) -> str:
"""Decode subprocess output bytes without corrupting non-UTF-8 text.

Expand DownExpand Up@@ -166,10 +319,30 @@ async def execute(self, arguments: dict) -> ToolResult:
cmd = arguments.get("command", "")
timeout = arguments.get("timeout", 30)
workdir = arguments.get("workdir", None)
# Sandbox tier — daemon-injected per task config (the agent cannot
# choose its own sandbox; rant 2026-08-20T15:46:50).
sandbox = arguments.get("sandbox")

if not cmd:
return ToolResult(name="bash", content="Error: no command provided", error=True)

# Static file-level isolation check (rant 2026-08-20T15:46:50).
sandbox_tag: str | None = None
if sandbox and sandbox != "danger-full-access":
allowed, reason, enforcement = _check_sandbox(cmd, sandbox, workdir)
if not allowed:
logger.info("bash: BLOCKED by %s sandbox: %s", sandbox, reason)
return ToolResult(
name="bash",
content=(
f"⛔ [sandbox:{sandbox} enforcement={enforcement}] "
f"{reason} — command not executed"
),
error=True,
)
sandbox_tag = f"[sandbox:{sandbox} enforcement={enforcement}]"
logger.debug("bash: sandbox %s check passed", sandbox)

logger.debug("bash: running %r (timeout=%ds)", cmd[:100], timeout)

# Windows: cmd.exe cannot parse bash heredocs — translate the first
Expand DownExpand Up@@ -271,6 +444,8 @@ async def execute(self, arguments: dict) -> ToolResult:
if not parts:
parts.append("(no output)")
result = "\n".join(parts)
if sandbox_tag:
result = f"{sandbox_tag} ok\n{result}"
return ToolResult(name="bash", content=result)
except FileNotFoundError:
return ToolResult(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' emrg: bash tool sandbox — file-level isolation for task sessions by argszero · Pull Request #886 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (958) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (981) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
7 changes: 7 additions & 0 deletions emrg/protocol.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,11 @@ class TaskRequest:
default_factory=lambda: datetime.now().isoformat()
)
images: Optional[list[dict]] = None
# Sandbox tier for the task's bash tool (rant 2026-08-20T15:46:50):
# "read-only" | "workspace-write" | "danger-full-access" (default None =
# danger-full-access, current behavior). Set by task config, never by
# the agent itself.
sandbox: Optional[str] = None

def to_dict(self) -> dict:
d = {
Expand All@@ -42,6 +47,8 @@ def to_dict(self) -> dict:
}
if self.images:
d["images"] = self.images
if self.sandbox:
d["sandbox"] = self.sandbox
return d


Expand Down
13 changes: 12 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -613,6 +613,9 @@ async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) ->
cwd=cwd,
prompt=prompt,
timestamp="",
# Upgrade writes install/ and source/ inside its own work dir —
# workspace-write tier (rant 2026-08-20T15:46:50).
sandbox="workspace-write",
)
if self._session_busy.get(session_id):
# Queue per existing semantics (host decision A: busy → pending,
Expand DownExpand Up@@ -772,6 +775,7 @@ async def _handle_client(self, ws) -> None:
prompt=data.get("prompt", ""),
timestamp=data.get("timestamp", ""),
images=data.get("images"),
sandbox=data.get("sandbox"),
)
except Exception as e:
await self._send(ws, {"error": f"invalid task: {e}"})
Expand DownExpand Up@@ -1467,6 +1471,7 @@ async def _process_message(
enabled=msg.get("enabled", True),
repo=msg.get("repo"),
description=msg.get("description"),
sandbox=msg.get("sandbox"),
)
if not ok:
await self._send(ws, {"type": "task_result", "error": res})
Expand All@@ -1478,7 +1483,7 @@ async def _process_message(
if not self._scheduler:
await self._send(ws, {"type": "task_result", "error": "scheduler not running"})
return
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description") if k in msg}
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description", "sandbox") if k in msg}
if "task_type" in fields:
fields["type"] = fields.pop("task_type")
ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields)
Expand DownExpand Up@@ -2536,6 +2541,12 @@ async def _run_tool_loop(
elif tc_name == "grep" and "path" not in args:
args["path"] = str(session.cwd)

# Sandbox tier (rant 2026-08-20T15:46:50): the task's
# configured sandbox is injected into the bash tool — the
# agent cannot choose it per call.
if tc_name == "bash" and req.sandbox:
args["sandbox"] = req.sandbox

# Execute
tool = self.tools.get(tc_name)
if tool:
Expand Down
40 changes: 40 additions & 0 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@

from emrg.config import config_dir
from emrg.connect import connect_to_server
from emrg.tools.bash_tool import SANDBOX_MODES
from websockets.exceptions import ConnectionClosed
from emrg.protocol import EvolutionLog, InstanceIdentity
from emrg.server.atomic import atomic_write_yaml
Expand DownExpand Up@@ -130,6 +131,7 @@ def _task_cfg_signature(cfg: dict) -> tuple:
json.dumps(conf, sort_keys=True),
cfg.get("interval", DEFAULT_INTERVAL),
bool(cfg.get("enabled", True)),
cfg.get("sandbox"),
)


Expand DownExpand Up@@ -198,6 +200,7 @@ def __init__(
interval: int,
identity: InstanceIdentity,
template_path: Path | None = None,
sandbox: str | None = None,
) -> None:
self.name = name
# Rant 2026-08-19T10:18:44: per-task logger — LoggerAdapter injects a
Expand DownExpand Up@@ -281,6 +284,32 @@ def __init__(
self._repo_configured = project_name == "emrg"
self._session_id = f"emrg-evolution-{name}"
self._source_dir = path or name
# Sandbox tier for this task's bash tool (rant 2026-08-20T15:46:50):
# explicit config wins; builtin tasks get suggested defaults; None =
# danger-full-access (current behavior).
self._sandbox = self._resolve_sandbox(name, config, sandbox)
if self._sandbox:
self._logger.info(
"TaskHandler[%s]: bash sandbox tier = %s", name, self._sandbox
)

@staticmethod
def _resolve_sandbox(name: str, config: dict, explicit: str | None) -> str | None:
"""Effective bash sandbox tier for a task.

Order: tasks.yml top-level ``sandbox:`` field → ``config.sandbox`` →
builtin defaults by task name → None (= danger-full-access, the
existing un-sandboxed behavior). Invalid values fall through to the
defaults rather than breaking the task.
"""
for cand in (explicit, config.get("sandbox")):
if cand in SANDBOX_MODES and cand != "danger-full-access":
return cand
if name == "emrg-task":
return "workspace-write" # evolution writes its own repo
if name.endswith("-opensource-task"):
return "read-only" # community work in host-owned repos
return None

# ── Saturation state (restored from disk across daemon restarts) ──

Expand DownExpand Up@@ -684,6 +713,7 @@ async def _run_evolution_cycle(self) -> None:
"prompt": prompt,
"stream": True,
"timestamp": cycle_time.isoformat(),
"sandbox": self._sandbox,
},
ensure_ascii=False,
)
Expand DownExpand Up@@ -961,6 +991,7 @@ def _build_handler(self, cfg: dict) -> TaskHandler:
interval=cfg.get("interval", DEFAULT_INTERVAL),
identity=self.identity,
template_path=template_path,
sandbox=cfg.get("sandbox"),
)

def _start_handler_for(self, cfg: dict) -> TaskHandler:
Expand DownExpand Up@@ -1267,12 +1298,15 @@ def task_create(
self, name: str, task_type: str, project: str,
interval: int | None = None, enabled: bool = True,
repo: str | None = None, description: str | None = None,
sandbox: str | None = None,
) -> tuple[bool, str | dict]:
"""Create a task. Returns (ok, error) or (ok, task-dict)."""
interval = DEFAULT_INTERVAL if interval is None else interval
err = self._validate_task_fields(name, task_type, project, interval)
if err:
return False, err
if sandbox is not None and sandbox not in SANDBOX_MODES:
return False, f"invalid sandbox {sandbox!r} (expected one of {', '.join(SANDBOX_MODES)})"
tasks = self._load_tasks()
if any(t.get("name") == name for t in tasks):
return False, f"task {name!r} already exists"
Expand All@@ -1287,6 +1321,8 @@ def task_create(
"enabled": bool(enabled),
"last_run": None,
}
if sandbox is not None:
task["sandbox"] = sandbox
if description:
task["description"] = description
tasks.append(task)
Expand DownExpand Up@@ -1327,6 +1363,10 @@ def task_update(self, name: str, **fields) -> tuple[bool, str | dict]:
task["enabled"] = bool(fields["enabled"])
if "description" in fields:
task["description"] = fields["description"]
if "sandbox" in fields:
if fields["sandbox"] is not None and fields["sandbox"] not in SANDBOX_MODES:
return False, f"invalid sandbox {fields['sandbox']!r} (expected one of {', '.join(SANDBOX_MODES)})"
task["sandbox"] = fields["sandbox"]
self._save_tasks(tasks)
logger.info("TaskScheduler: task %s updated", name)
return True, task
Expand Down
175 changes: 175 additions & 0 deletions emrg/tools/bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,159 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]:
return rewritten.rstrip(), path


# ── Sandbox — file-level isolation for the bash tool (rant 2026-08-20T15:46:50) ──
#
# Three tiers (default danger-full-access = current, un-sandboxed behavior):
# danger-full-access — no checks at all (existing behavior)
# read-only — no writes allowed: destructive commands (rm -r /
# rmdir / mv / cp -r) and shell redirects (> / >>)
# to any non-/dev/null target are blocked
# workspace-write — writes inside the workspace root (and the OS temp
# area) are allowed; destructive writes to protected
# daemon state files and to absolute paths outside
# the workspace are blocked
#
# Enforcement is deliberately heuristic (host design-finalized): a static
# command scan, NOT an OS-level sandbox (no bwrap/Seatbelt/ACL). The checked
# modes report enforcement="partial" — honest reporting, never pretending
# full OS-level isolation. The core value is blocking a hallucinated LLM's
# obviously destructive commands (rm -rf with a wrong path, writing the
# daemon's own state files).

SANDBOX_MODES = ("danger-full-access", "read-only", "workspace-write")

# Daemon state files — writing to these from a sandboxed task is always
# blocked (they are the daemon's own data, not agent scratch space).
_PROTECTED_FILES = (
"~/.emrg/config.toml",
"~/.emrg/emrgd.token",
"~/.emrg/tasks.yml",
"~/.emrg/projects.yml",
"~/.emrg/rants.jsonl",
)


def _extract_write_targets(cmd: str) -> list[str]:
"""Heuristic extraction of write targets from a command line.

Returns path tokens the command appears to write to:
- ``rm -r/-rf/-R <path>`` and ``rmdir <path>`` → the removed path
- ``mv <src> <dst>`` / ``cp -r <src> <dst>`` → the destination
- ``> / >> / 2> / &>`` redirects → the redirect target

Deliberately non-exhaustive (the sandbox only catches obvious
destructive writes — the boundary is honest: enforcement=partial).
"""
targets: list[str] = []
# rm -r / rm -rf / rm -R ... <path> (recursive delete)
for m in re.finditer(r"\brm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# rmdir <path>
for m in re.finditer(r"\brmdir\s+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# mv <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bmv\s+((?:-[a-zA-Z]*\s+)*[^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# cp -r <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bcp\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# shell redirects: > file / >> file / 2> file / &> file
for m in re.finditer(r"(?:\d*>>?|&>>?)\s*([^\s|;&]+)", cmd):
targets.append(m.group(1))
return targets


def _protected_paths() -> list[str]:
"""Canonicalized (realpath) protected daemon state files."""
out: list[str] = []
for p in _PROTECTED_FILES:
try:
out.append(os.path.realpath(os.path.expanduser(p)))
except OSError:
pass
return out


def _is_absolute_path(p: str) -> bool:
"""True when ``p`` is absolute (or drive-less rooted, e.g. ``/etc/hosts``
on Windows — ntpath.isabs returns False for those, but they still do not
resolve under the cwd, so the sandbox must treat them as absolute)."""
return os.path.isabs(p) or p.startswith("/") or p.startswith("\\")


def _is_within(path: str, root: str) -> bool:
"""True when ``path`` (absolute) is inside ``root`` (absolute) or equals it."""
try:
rp = os.path.realpath(path)
rr = os.path.realpath(root)
return rp == rr or rp.startswith(rr + os.sep)
except OSError:
return False


def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[bool, str | None, str]:
"""Static sandbox check for a bash command (rant 2026-08-20T15:46:50).

Returns ``(allowed, blocked_reason, enforcement)``:
- danger-full-access → (True, None, "full") — no checks, current behavior.
- read-only → blocks every destructive write (rm -r / rmdir / mv /
cp -r and shell redirects to any non-/dev/null target).
- workspace-write → blocks destructive writes to protected daemon
files, to ``~/.emrg`` itself, and to absolute paths outside the
workspace root (the OS temp dir is allowed — mirrors dsh's
workspace + backend-promised temp area).

Heuristic by design: static scan only, no OS-level boundary — checked
modes honestly report enforcement="partial".
"""
if mode not in SANDBOX_MODES:
return False, f"invalid sandbox mode {mode!r}", "partial"
if mode == "danger-full-access":
return True, None, "full"

targets = _extract_write_targets(cmd)
if mode == "read-only":
for t in targets:
if t != "/dev/null":
return False, (
f"read-only sandbox: blocked destructive write targeting {t!r}"
), "partial"
return True, None, "partial"

# workspace-write
if not targets:
return True, None, "partial"
protected = _protected_paths()
emrg_home = os.path.realpath(os.path.expanduser("~/.emrg"))
workdir_real = os.path.realpath(workdir) if workdir else None
for t in targets:
if t == "/dev/null":
continue
expanded = os.path.expanduser(t)
if not _is_absolute_path(expanded):
# Relative target: assumed in-workspace (cwd = the workspace root).
continue
real = os.path.realpath(expanded)
if real in protected:
return False, (
f"workspace-write sandbox: blocked write to protected daemon file {t!r}"
), "partial"
if real == emrg_home:
return False, (
f"workspace-write sandbox: blocked destructive write to {t!r} "
"(would erase the daemon's data directory)"
), "partial"
if workdir_real and not _is_within(real, workdir_real) and not _is_within(real, tempfile.gettempdir()):
return False, (
f"workspace-write sandbox: blocked write outside workspace {t!r}"
), "partial"
return True, None, "partial"


def _decode_output(data: bytes, os_name: str | None = None) -> str:
"""Decode subprocess output bytes without corrupting non-UTF-8 text.

Expand DownExpand Up@@ -166,10 +319,30 @@ async def execute(self, arguments: dict) -> ToolResult:
cmd = arguments.get("command", "")
timeout = arguments.get("timeout", 30)
workdir = arguments.get("workdir", None)
# Sandbox tier — daemon-injected per task config (the agent cannot
# choose its own sandbox; rant 2026-08-20T15:46:50).
sandbox = arguments.get("sandbox")

if not cmd:
return ToolResult(name="bash", content="Error: no command provided", error=True)

# Static file-level isolation check (rant 2026-08-20T15:46:50).
sandbox_tag: str | None = None
if sandbox and sandbox != "danger-full-access":
allowed, reason, enforcement = _check_sandbox(cmd, sandbox, workdir)
if not allowed:
logger.info("bash: BLOCKED by %s sandbox: %s", sandbox, reason)
return ToolResult(
name="bash",
content=(
f"⛔ [sandbox:{sandbox} enforcement={enforcement}] "
f"{reason} — command not executed"
),
error=True,
)
sandbox_tag = f"[sandbox:{sandbox} enforcement={enforcement}]"
logger.debug("bash: sandbox %s check passed", sandbox)

logger.debug("bash: running %r (timeout=%ds)", cmd[:100], timeout)

# Windows: cmd.exe cannot parse bash heredocs — translate the first
Expand DownExpand Up@@ -271,6 +444,8 @@ async def execute(self, arguments: dict) -> ToolResult:
if not parts:
parts.append("(no output)")
result = "\n".join(parts)
if sandbox_tag:
result = f"{sandbox_tag} ok\n{result}"
return ToolResult(name="bash", content=result)
except FileNotFoundError:
return ToolResult(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: bash tool sandbox — file-level isolation for task sessions by argszero · Pull Request #886 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (958) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (981) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
7 changes: 7 additions & 0 deletions emrg/protocol.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,11 @@ class TaskRequest:
default_factory=lambda: datetime.now().isoformat()
)
images: Optional[list[dict]] = None
# Sandbox tier for the task's bash tool (rant 2026-08-20T15:46:50):
# "read-only" | "workspace-write" | "danger-full-access" (default None =
# danger-full-access, current behavior). Set by task config, never by
# the agent itself.
sandbox: Optional[str] = None

def to_dict(self) -> dict:
d = {
Expand All@@ -42,6 +47,8 @@ def to_dict(self) -> dict:
}
if self.images:
d["images"] = self.images
if self.sandbox:
d["sandbox"] = self.sandbox
return d


Expand Down
13 changes: 12 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -613,6 +613,9 @@ async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) ->
cwd=cwd,
prompt=prompt,
timestamp="",
# Upgrade writes install/ and source/ inside its own work dir —
# workspace-write tier (rant 2026-08-20T15:46:50).
sandbox="workspace-write",
)
if self._session_busy.get(session_id):
# Queue per existing semantics (host decision A: busy → pending,
Expand DownExpand Up@@ -772,6 +775,7 @@ async def _handle_client(self, ws) -> None:
prompt=data.get("prompt", ""),
timestamp=data.get("timestamp", ""),
images=data.get("images"),
sandbox=data.get("sandbox"),
)
except Exception as e:
await self._send(ws, {"error": f"invalid task: {e}"})
Expand DownExpand Up@@ -1467,6 +1471,7 @@ async def _process_message(
enabled=msg.get("enabled", True),
repo=msg.get("repo"),
description=msg.get("description"),
sandbox=msg.get("sandbox"),
)
if not ok:
await self._send(ws, {"type": "task_result", "error": res})
Expand All@@ -1478,7 +1483,7 @@ async def _process_message(
if not self._scheduler:
await self._send(ws, {"type": "task_result", "error": "scheduler not running"})
return
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description") if k in msg}
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description", "sandbox") if k in msg}
if "task_type" in fields:
fields["type"] = fields.pop("task_type")
ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields)
Expand DownExpand Up@@ -2536,6 +2541,12 @@ async def _run_tool_loop(
elif tc_name == "grep" and "path" not in args:
args["path"] = str(session.cwd)

# Sandbox tier (rant 2026-08-20T15:46:50): the task's
# configured sandbox is injected into the bash tool — the
# agent cannot choose it per call.
if tc_name == "bash" and req.sandbox:
args["sandbox"] = req.sandbox

# Execute
tool = self.tools.get(tc_name)
if tool:
Expand Down
40 changes: 40 additions & 0 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@

from emrg.config import config_dir
from emrg.connect import connect_to_server
from emrg.tools.bash_tool import SANDBOX_MODES
from websockets.exceptions import ConnectionClosed
from emrg.protocol import EvolutionLog, InstanceIdentity
from emrg.server.atomic import atomic_write_yaml
Expand DownExpand Up@@ -130,6 +131,7 @@ def _task_cfg_signature(cfg: dict) -> tuple:
json.dumps(conf, sort_keys=True),
cfg.get("interval", DEFAULT_INTERVAL),
bool(cfg.get("enabled", True)),
cfg.get("sandbox"),
)


Expand DownExpand Up@@ -198,6 +200,7 @@ def __init__(
interval: int,
identity: InstanceIdentity,
template_path: Path | None = None,
sandbox: str | None = None,
) -> None:
self.name = name
# Rant 2026-08-19T10:18:44: per-task logger — LoggerAdapter injects a
Expand DownExpand Up@@ -281,6 +284,32 @@ def __init__(
self._repo_configured = project_name == "emrg"
self._session_id = f"emrg-evolution-{name}"
self._source_dir = path or name
# Sandbox tier for this task's bash tool (rant 2026-08-20T15:46:50):
# explicit config wins; builtin tasks get suggested defaults; None =
# danger-full-access (current behavior).
self._sandbox = self._resolve_sandbox(name, config, sandbox)
if self._sandbox:
self._logger.info(
"TaskHandler[%s]: bash sandbox tier = %s", name, self._sandbox
)

@staticmethod
def _resolve_sandbox(name: str, config: dict, explicit: str | None) -> str | None:
"""Effective bash sandbox tier for a task.

Order: tasks.yml top-level ``sandbox:`` field → ``config.sandbox`` →
builtin defaults by task name → None (= danger-full-access, the
existing un-sandboxed behavior). Invalid values fall through to the
defaults rather than breaking the task.
"""
for cand in (explicit, config.get("sandbox")):
if cand in SANDBOX_MODES and cand != "danger-full-access":
return cand
if name == "emrg-task":
return "workspace-write" # evolution writes its own repo
if name.endswith("-opensource-task"):
return "read-only" # community work in host-owned repos
return None

# ── Saturation state (restored from disk across daemon restarts) ──

Expand DownExpand Up@@ -684,6 +713,7 @@ async def _run_evolution_cycle(self) -> None:
"prompt": prompt,
"stream": True,
"timestamp": cycle_time.isoformat(),
"sandbox": self._sandbox,
},
ensure_ascii=False,
)
Expand DownExpand Up@@ -961,6 +991,7 @@ def _build_handler(self, cfg: dict) -> TaskHandler:
interval=cfg.get("interval", DEFAULT_INTERVAL),
identity=self.identity,
template_path=template_path,
sandbox=cfg.get("sandbox"),
)

def _start_handler_for(self, cfg: dict) -> TaskHandler:
Expand DownExpand Up@@ -1267,12 +1298,15 @@ def task_create(
self, name: str, task_type: str, project: str,
interval: int | None = None, enabled: bool = True,
repo: str | None = None, description: str | None = None,
sandbox: str | None = None,
) -> tuple[bool, str | dict]:
"""Create a task. Returns (ok, error) or (ok, task-dict)."""
interval = DEFAULT_INTERVAL if interval is None else interval
err = self._validate_task_fields(name, task_type, project, interval)
if err:
return False, err
if sandbox is not None and sandbox not in SANDBOX_MODES:
return False, f"invalid sandbox {sandbox!r} (expected one of {', '.join(SANDBOX_MODES)})"
tasks = self._load_tasks()
if any(t.get("name") == name for t in tasks):
return False, f"task {name!r} already exists"
Expand All@@ -1287,6 +1321,8 @@ def task_create(
"enabled": bool(enabled),
"last_run": None,
}
if sandbox is not None:
task["sandbox"] = sandbox
if description:
task["description"] = description
tasks.append(task)
Expand DownExpand Up@@ -1327,6 +1363,10 @@ def task_update(self, name: str, **fields) -> tuple[bool, str | dict]:
task["enabled"] = bool(fields["enabled"])
if "description" in fields:
task["description"] = fields["description"]
if "sandbox" in fields:
if fields["sandbox"] is not None and fields["sandbox"] not in SANDBOX_MODES:
return False, f"invalid sandbox {fields['sandbox']!r} (expected one of {', '.join(SANDBOX_MODES)})"
task["sandbox"] = fields["sandbox"]
self._save_tasks(tasks)
logger.info("TaskScheduler: task %s updated", name)
return True, task
Expand Down
175 changes: 175 additions & 0 deletions emrg/tools/bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,159 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]:
return rewritten.rstrip(), path


# ── Sandbox — file-level isolation for the bash tool (rant 2026-08-20T15:46:50) ──
#
# Three tiers (default danger-full-access = current, un-sandboxed behavior):
# danger-full-access — no checks at all (existing behavior)
# read-only — no writes allowed: destructive commands (rm -r /
# rmdir / mv / cp -r) and shell redirects (> / >>)
# to any non-/dev/null target are blocked
# workspace-write — writes inside the workspace root (and the OS temp
# area) are allowed; destructive writes to protected
# daemon state files and to absolute paths outside
# the workspace are blocked
#
# Enforcement is deliberately heuristic (host design-finalized): a static
# command scan, NOT an OS-level sandbox (no bwrap/Seatbelt/ACL). The checked
# modes report enforcement="partial" — honest reporting, never pretending
# full OS-level isolation. The core value is blocking a hallucinated LLM's
# obviously destructive commands (rm -rf with a wrong path, writing the
# daemon's own state files).

SANDBOX_MODES = ("danger-full-access", "read-only", "workspace-write")

# Daemon state files — writing to these from a sandboxed task is always
# blocked (they are the daemon's own data, not agent scratch space).
_PROTECTED_FILES = (
"~/.emrg/config.toml",
"~/.emrg/emrgd.token",
"~/.emrg/tasks.yml",
"~/.emrg/projects.yml",
"~/.emrg/rants.jsonl",
)


def _extract_write_targets(cmd: str) -> list[str]:
"""Heuristic extraction of write targets from a command line.

Returns path tokens the command appears to write to:
- ``rm -r/-rf/-R <path>`` and ``rmdir <path>`` → the removed path
- ``mv <src> <dst>`` / ``cp -r <src> <dst>`` → the destination
- ``> / >> / 2> / &>`` redirects → the redirect target

Deliberately non-exhaustive (the sandbox only catches obvious
destructive writes — the boundary is honest: enforcement=partial).
"""
targets: list[str] = []
# rm -r / rm -rf / rm -R ... <path> (recursive delete)
for m in re.finditer(r"\brm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# rmdir <path>
for m in re.finditer(r"\brmdir\s+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# mv <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bmv\s+((?:-[a-zA-Z]*\s+)*[^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# cp -r <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bcp\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# shell redirects: > file / >> file / 2> file / &> file
for m in re.finditer(r"(?:\d*>>?|&>>?)\s*([^\s|;&]+)", cmd):
targets.append(m.group(1))
return targets


def _protected_paths() -> list[str]:
"""Canonicalized (realpath) protected daemon state files."""
out: list[str] = []
for p in _PROTECTED_FILES:
try:
out.append(os.path.realpath(os.path.expanduser(p)))
except OSError:
pass
return out


def _is_absolute_path(p: str) -> bool:
"""True when ``p`` is absolute (or drive-less rooted, e.g. ``/etc/hosts``
on Windows — ntpath.isabs returns False for those, but they still do not
resolve under the cwd, so the sandbox must treat them as absolute)."""
return os.path.isabs(p) or p.startswith("/") or p.startswith("\\")


def _is_within(path: str, root: str) -> bool:
"""True when ``path`` (absolute) is inside ``root`` (absolute) or equals it."""
try:
rp = os.path.realpath(path)
rr = os.path.realpath(root)
return rp == rr or rp.startswith(rr + os.sep)
except OSError:
return False


def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[bool, str | None, str]:
"""Static sandbox check for a bash command (rant 2026-08-20T15:46:50).

Returns ``(allowed, blocked_reason, enforcement)``:
- danger-full-access → (True, None, "full") — no checks, current behavior.
- read-only → blocks every destructive write (rm -r / rmdir / mv /
cp -r and shell redirects to any non-/dev/null target).
- workspace-write → blocks destructive writes to protected daemon
files, to ``~/.emrg`` itself, and to absolute paths outside the
workspace root (the OS temp dir is allowed — mirrors dsh's
workspace + backend-promised temp area).

Heuristic by design: static scan only, no OS-level boundary — checked
modes honestly report enforcement="partial".
"""
if mode not in SANDBOX_MODES:
return False, f"invalid sandbox mode {mode!r}", "partial"
if mode == "danger-full-access":
return True, None, "full"

targets = _extract_write_targets(cmd)
if mode == "read-only":
for t in targets:
if t != "/dev/null":
return False, (
f"read-only sandbox: blocked destructive write targeting {t!r}"
), "partial"
return True, None, "partial"

# workspace-write
if not targets:
return True, None, "partial"
protected = _protected_paths()
emrg_home = os.path.realpath(os.path.expanduser("~/.emrg"))
workdir_real = os.path.realpath(workdir) if workdir else None
for t in targets:
if t == "/dev/null":
continue
expanded = os.path.expanduser(t)
if not _is_absolute_path(expanded):
# Relative target: assumed in-workspace (cwd = the workspace root).
continue
real = os.path.realpath(expanded)
if real in protected:
return False, (
f"workspace-write sandbox: blocked write to protected daemon file {t!r}"
), "partial"
if real == emrg_home:
return False, (
f"workspace-write sandbox: blocked destructive write to {t!r} "
"(would erase the daemon's data directory)"
), "partial"
if workdir_real and not _is_within(real, workdir_real) and not _is_within(real, tempfile.gettempdir()):
return False, (
f"workspace-write sandbox: blocked write outside workspace {t!r}"
), "partial"
return True, None, "partial"


def _decode_output(data: bytes, os_name: str | None = None) -> str:
"""Decode subprocess output bytes without corrupting non-UTF-8 text.

Expand DownExpand Up@@ -166,10 +319,30 @@ async def execute(self, arguments: dict) -> ToolResult:
cmd = arguments.get("command", "")
timeout = arguments.get("timeout", 30)
workdir = arguments.get("workdir", None)
# Sandbox tier — daemon-injected per task config (the agent cannot
# choose its own sandbox; rant 2026-08-20T15:46:50).
sandbox = arguments.get("sandbox")

if not cmd:
return ToolResult(name="bash", content="Error: no command provided", error=True)

# Static file-level isolation check (rant 2026-08-20T15:46:50).
sandbox_tag: str | None = None
if sandbox and sandbox != "danger-full-access":
allowed, reason, enforcement = _check_sandbox(cmd, sandbox, workdir)
if not allowed:
logger.info("bash: BLOCKED by %s sandbox: %s", sandbox, reason)
return ToolResult(
name="bash",
content=(
f"⛔ [sandbox:{sandbox} enforcement={enforcement}] "
f"{reason} — command not executed"
),
error=True,
)
sandbox_tag = f"[sandbox:{sandbox} enforcement={enforcement}]"
logger.debug("bash: sandbox %s check passed", sandbox)

logger.debug("bash: running %r (timeout=%ds)", cmd[:100], timeout)

# Windows: cmd.exe cannot parse bash heredocs — translate the first
Expand DownExpand Up@@ -271,6 +444,8 @@ async def execute(self, arguments: dict) -> ToolResult:
if not parts:
parts.append("(no output)")
result = "\n".join(parts)
if sandbox_tag:
result = f"{sandbox_tag} ok\n{result}"
return ToolResult(name="bash", content=result)
except FileNotFoundError:
return ToolResult(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: bash tool sandbox — file-level isolation for task sessions by argszero · Pull Request #886 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (958) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (981) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
7 changes: 7 additions & 0 deletions emrg/protocol.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,11 @@ class TaskRequest:
default_factory=lambda: datetime.now().isoformat()
)
images: Optional[list[dict]] = None
# Sandbox tier for the task's bash tool (rant 2026-08-20T15:46:50):
# "read-only" | "workspace-write" | "danger-full-access" (default None =
# danger-full-access, current behavior). Set by task config, never by
# the agent itself.
sandbox: Optional[str] = None

def to_dict(self) -> dict:
d = {
Expand All@@ -42,6 +47,8 @@ def to_dict(self) -> dict:
}
if self.images:
d["images"] = self.images
if self.sandbox:
d["sandbox"] = self.sandbox
return d


Expand Down
13 changes: 12 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -613,6 +613,9 @@ async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) ->
cwd=cwd,
prompt=prompt,
timestamp="",
# Upgrade writes install/ and source/ inside its own work dir —
# workspace-write tier (rant 2026-08-20T15:46:50).
sandbox="workspace-write",
)
if self._session_busy.get(session_id):
# Queue per existing semantics (host decision A: busy → pending,
Expand DownExpand Up@@ -772,6 +775,7 @@ async def _handle_client(self, ws) -> None:
prompt=data.get("prompt", ""),
timestamp=data.get("timestamp", ""),
images=data.get("images"),
sandbox=data.get("sandbox"),
)
except Exception as e:
await self._send(ws, {"error": f"invalid task: {e}"})
Expand DownExpand Up@@ -1467,6 +1471,7 @@ async def _process_message(
enabled=msg.get("enabled", True),
repo=msg.get("repo"),
description=msg.get("description"),
sandbox=msg.get("sandbox"),
)
if not ok:
await self._send(ws, {"type": "task_result", "error": res})
Expand All@@ -1478,7 +1483,7 @@ async def _process_message(
if not self._scheduler:
await self._send(ws, {"type": "task_result", "error": "scheduler not running"})
return
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description") if k in msg}
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description", "sandbox") if k in msg}
if "task_type" in fields:
fields["type"] = fields.pop("task_type")
ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields)
Expand DownExpand Up@@ -2536,6 +2541,12 @@ async def _run_tool_loop(
elif tc_name == "grep" and "path" not in args:
args["path"] = str(session.cwd)

# Sandbox tier (rant 2026-08-20T15:46:50): the task's
# configured sandbox is injected into the bash tool — the
# agent cannot choose it per call.
if tc_name == "bash" and req.sandbox:
args["sandbox"] = req.sandbox

# Execute
tool = self.tools.get(tc_name)
if tool:
Expand Down
40 changes: 40 additions & 0 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@

from emrg.config import config_dir
from emrg.connect import connect_to_server
from emrg.tools.bash_tool import SANDBOX_MODES
from websockets.exceptions import ConnectionClosed
from emrg.protocol import EvolutionLog, InstanceIdentity
from emrg.server.atomic import atomic_write_yaml
Expand DownExpand Up@@ -130,6 +131,7 @@ def _task_cfg_signature(cfg: dict) -> tuple:
json.dumps(conf, sort_keys=True),
cfg.get("interval", DEFAULT_INTERVAL),
bool(cfg.get("enabled", True)),
cfg.get("sandbox"),
)


Expand DownExpand Up@@ -198,6 +200,7 @@ def __init__(
interval: int,
identity: InstanceIdentity,
template_path: Path | None = None,
sandbox: str | None = None,
) -> None:
self.name = name
# Rant 2026-08-19T10:18:44: per-task logger — LoggerAdapter injects a
Expand DownExpand Up@@ -281,6 +284,32 @@ def __init__(
self._repo_configured = project_name == "emrg"
self._session_id = f"emrg-evolution-{name}"
self._source_dir = path or name
# Sandbox tier for this task's bash tool (rant 2026-08-20T15:46:50):
# explicit config wins; builtin tasks get suggested defaults; None =
# danger-full-access (current behavior).
self._sandbox = self._resolve_sandbox(name, config, sandbox)
if self._sandbox:
self._logger.info(
"TaskHandler[%s]: bash sandbox tier = %s", name, self._sandbox
)

@staticmethod
def _resolve_sandbox(name: str, config: dict, explicit: str | None) -> str | None:
"""Effective bash sandbox tier for a task.

Order: tasks.yml top-level ``sandbox:`` field → ``config.sandbox`` →
builtin defaults by task name → None (= danger-full-access, the
existing un-sandboxed behavior). Invalid values fall through to the
defaults rather than breaking the task.
"""
for cand in (explicit, config.get("sandbox")):
if cand in SANDBOX_MODES and cand != "danger-full-access":
return cand
if name == "emrg-task":
return "workspace-write" # evolution writes its own repo
if name.endswith("-opensource-task"):
return "read-only" # community work in host-owned repos
return None

# ── Saturation state (restored from disk across daemon restarts) ──

Expand DownExpand Up@@ -684,6 +713,7 @@ async def _run_evolution_cycle(self) -> None:
"prompt": prompt,
"stream": True,
"timestamp": cycle_time.isoformat(),
"sandbox": self._sandbox,
},
ensure_ascii=False,
)
Expand DownExpand Up@@ -961,6 +991,7 @@ def _build_handler(self, cfg: dict) -> TaskHandler:
interval=cfg.get("interval", DEFAULT_INTERVAL),
identity=self.identity,
template_path=template_path,
sandbox=cfg.get("sandbox"),
)

def _start_handler_for(self, cfg: dict) -> TaskHandler:
Expand DownExpand Up@@ -1267,12 +1298,15 @@ def task_create(
self, name: str, task_type: str, project: str,
interval: int | None = None, enabled: bool = True,
repo: str | None = None, description: str | None = None,
sandbox: str | None = None,
) -> tuple[bool, str | dict]:
"""Create a task. Returns (ok, error) or (ok, task-dict)."""
interval = DEFAULT_INTERVAL if interval is None else interval
err = self._validate_task_fields(name, task_type, project, interval)
if err:
return False, err
if sandbox is not None and sandbox not in SANDBOX_MODES:
return False, f"invalid sandbox {sandbox!r} (expected one of {', '.join(SANDBOX_MODES)})"
tasks = self._load_tasks()
if any(t.get("name") == name for t in tasks):
return False, f"task {name!r} already exists"
Expand All@@ -1287,6 +1321,8 @@ def task_create(
"enabled": bool(enabled),
"last_run": None,
}
if sandbox is not None:
task["sandbox"] = sandbox
if description:
task["description"] = description
tasks.append(task)
Expand DownExpand Up@@ -1327,6 +1363,10 @@ def task_update(self, name: str, **fields) -> tuple[bool, str | dict]:
task["enabled"] = bool(fields["enabled"])
if "description" in fields:
task["description"] = fields["description"]
if "sandbox" in fields:
if fields["sandbox"] is not None and fields["sandbox"] not in SANDBOX_MODES:
return False, f"invalid sandbox {fields['sandbox']!r} (expected one of {', '.join(SANDBOX_MODES)})"
task["sandbox"] = fields["sandbox"]
self._save_tasks(tasks)
logger.info("TaskScheduler: task %s updated", name)
return True, task
Expand Down
175 changes: 175 additions & 0 deletions emrg/tools/bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,159 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]:
return rewritten.rstrip(), path


# ── Sandbox — file-level isolation for the bash tool (rant 2026-08-20T15:46:50) ──
#
# Three tiers (default danger-full-access = current, un-sandboxed behavior):
# danger-full-access — no checks at all (existing behavior)
# read-only — no writes allowed: destructive commands (rm -r /
# rmdir / mv / cp -r) and shell redirects (> / >>)
# to any non-/dev/null target are blocked
# workspace-write — writes inside the workspace root (and the OS temp
# area) are allowed; destructive writes to protected
# daemon state files and to absolute paths outside
# the workspace are blocked
#
# Enforcement is deliberately heuristic (host design-finalized): a static
# command scan, NOT an OS-level sandbox (no bwrap/Seatbelt/ACL). The checked
# modes report enforcement="partial" — honest reporting, never pretending
# full OS-level isolation. The core value is blocking a hallucinated LLM's
# obviously destructive commands (rm -rf with a wrong path, writing the
# daemon's own state files).

SANDBOX_MODES = ("danger-full-access", "read-only", "workspace-write")

# Daemon state files — writing to these from a sandboxed task is always
# blocked (they are the daemon's own data, not agent scratch space).
_PROTECTED_FILES = (
"~/.emrg/config.toml",
"~/.emrg/emrgd.token",
"~/.emrg/tasks.yml",
"~/.emrg/projects.yml",
"~/.emrg/rants.jsonl",
)


def _extract_write_targets(cmd: str) -> list[str]:
"""Heuristic extraction of write targets from a command line.

Returns path tokens the command appears to write to:
- ``rm -r/-rf/-R <path>`` and ``rmdir <path>`` → the removed path
- ``mv <src> <dst>`` / ``cp -r <src> <dst>`` → the destination
- ``> / >> / 2> / &>`` redirects → the redirect target

Deliberately non-exhaustive (the sandbox only catches obvious
destructive writes — the boundary is honest: enforcement=partial).
"""
targets: list[str] = []
# rm -r / rm -rf / rm -R ... <path> (recursive delete)
for m in re.finditer(r"\brm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# rmdir <path>
for m in re.finditer(r"\brmdir\s+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# mv <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bmv\s+((?:-[a-zA-Z]*\s+)*[^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# cp -r <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bcp\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# shell redirects: > file / >> file / 2> file / &> file
for m in re.finditer(r"(?:\d*>>?|&>>?)\s*([^\s|;&]+)", cmd):
targets.append(m.group(1))
return targets


def _protected_paths() -> list[str]:
"""Canonicalized (realpath) protected daemon state files."""
out: list[str] = []
for p in _PROTECTED_FILES:
try:
out.append(os.path.realpath(os.path.expanduser(p)))
except OSError:
pass
return out


def _is_absolute_path(p: str) -> bool:
"""True when ``p`` is absolute (or drive-less rooted, e.g. ``/etc/hosts``
on Windows — ntpath.isabs returns False for those, but they still do not
resolve under the cwd, so the sandbox must treat them as absolute)."""
return os.path.isabs(p) or p.startswith("/") or p.startswith("\\")


def _is_within(path: str, root: str) -> bool:
"""True when ``path`` (absolute) is inside ``root`` (absolute) or equals it."""
try:
rp = os.path.realpath(path)
rr = os.path.realpath(root)
return rp == rr or rp.startswith(rr + os.sep)
except OSError:
return False


def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[bool, str | None, str]:
"""Static sandbox check for a bash command (rant 2026-08-20T15:46:50).

Returns ``(allowed, blocked_reason, enforcement)``:
- danger-full-access → (True, None, "full") — no checks, current behavior.
- read-only → blocks every destructive write (rm -r / rmdir / mv /
cp -r and shell redirects to any non-/dev/null target).
- workspace-write → blocks destructive writes to protected daemon
files, to ``~/.emrg`` itself, and to absolute paths outside the
workspace root (the OS temp dir is allowed — mirrors dsh's
workspace + backend-promised temp area).

Heuristic by design: static scan only, no OS-level boundary — checked
modes honestly report enforcement="partial".
"""
if mode not in SANDBOX_MODES:
return False, f"invalid sandbox mode {mode!r}", "partial"
if mode == "danger-full-access":
return True, None, "full"

targets = _extract_write_targets(cmd)
if mode == "read-only":
for t in targets:
if t != "/dev/null":
return False, (
f"read-only sandbox: blocked destructive write targeting {t!r}"
), "partial"
return True, None, "partial"

# workspace-write
if not targets:
return True, None, "partial"
protected = _protected_paths()
emrg_home = os.path.realpath(os.path.expanduser("~/.emrg"))
workdir_real = os.path.realpath(workdir) if workdir else None
for t in targets:
if t == "/dev/null":
continue
expanded = os.path.expanduser(t)
if not _is_absolute_path(expanded):
# Relative target: assumed in-workspace (cwd = the workspace root).
continue
real = os.path.realpath(expanded)
if real in protected:
return False, (
f"workspace-write sandbox: blocked write to protected daemon file {t!r}"
), "partial"
if real == emrg_home:
return False, (
f"workspace-write sandbox: blocked destructive write to {t!r} "
"(would erase the daemon's data directory)"
), "partial"
if workdir_real and not _is_within(real, workdir_real) and not _is_within(real, tempfile.gettempdir()):
return False, (
f"workspace-write sandbox: blocked write outside workspace {t!r}"
), "partial"
return True, None, "partial"


def _decode_output(data: bytes, os_name: str | None = None) -> str:
"""Decode subprocess output bytes without corrupting non-UTF-8 text.

Expand DownExpand Up@@ -166,10 +319,30 @@ async def execute(self, arguments: dict) -> ToolResult:
cmd = arguments.get("command", "")
timeout = arguments.get("timeout", 30)
workdir = arguments.get("workdir", None)
# Sandbox tier — daemon-injected per task config (the agent cannot
# choose its own sandbox; rant 2026-08-20T15:46:50).
sandbox = arguments.get("sandbox")

if not cmd:
return ToolResult(name="bash", content="Error: no command provided", error=True)

# Static file-level isolation check (rant 2026-08-20T15:46:50).
sandbox_tag: str | None = None
if sandbox and sandbox != "danger-full-access":
allowed, reason, enforcement = _check_sandbox(cmd, sandbox, workdir)
if not allowed:
logger.info("bash: BLOCKED by %s sandbox: %s", sandbox, reason)
return ToolResult(
name="bash",
content=(
f"⛔ [sandbox:{sandbox} enforcement={enforcement}] "
f"{reason} — command not executed"
),
error=True,
)
sandbox_tag = f"[sandbox:{sandbox} enforcement={enforcement}]"
logger.debug("bash: sandbox %s check passed", sandbox)

logger.debug("bash: running %r (timeout=%ds)", cmd[:100], timeout)

# Windows: cmd.exe cannot parse bash heredocs — translate the first
Expand DownExpand Up@@ -271,6 +444,8 @@ async def execute(self, arguments: dict) -> ToolResult:
if not parts:
parts.append("(no output)")
result = "\n".join(parts)
if sandbox_tag:
result = f"{sandbox_tag} ok\n{result}"
return ToolResult(name="bash", content=result)
except FileNotFoundError:
return ToolResult(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); emrg: bash tool sandbox — file-level isolation for task sessions by argszero · Pull Request #886 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (958) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (981) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
7 changes: 7 additions & 0 deletions emrg/protocol.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,11 @@ class TaskRequest:
default_factory=lambda: datetime.now().isoformat()
)
images: Optional[list[dict]] = None
# Sandbox tier for the task's bash tool (rant 2026-08-20T15:46:50):
# "read-only" | "workspace-write" | "danger-full-access" (default None =
# danger-full-access, current behavior). Set by task config, never by
# the agent itself.
sandbox: Optional[str] = None

def to_dict(self) -> dict:
d = {
Expand All@@ -42,6 +47,8 @@ def to_dict(self) -> dict:
}
if self.images:
d["images"] = self.images
if self.sandbox:
d["sandbox"] = self.sandbox
return d


Expand Down
13 changes: 12 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -613,6 +613,9 @@ async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) ->
cwd=cwd,
prompt=prompt,
timestamp="",
# Upgrade writes install/ and source/ inside its own work dir —
# workspace-write tier (rant 2026-08-20T15:46:50).
sandbox="workspace-write",
)
if self._session_busy.get(session_id):
# Queue per existing semantics (host decision A: busy → pending,
Expand DownExpand Up@@ -772,6 +775,7 @@ async def _handle_client(self, ws) -> None:
prompt=data.get("prompt", ""),
timestamp=data.get("timestamp", ""),
images=data.get("images"),
sandbox=data.get("sandbox"),
)
except Exception as e:
await self._send(ws, {"error": f"invalid task: {e}"})
Expand DownExpand Up@@ -1467,6 +1471,7 @@ async def _process_message(
enabled=msg.get("enabled", True),
repo=msg.get("repo"),
description=msg.get("description"),
sandbox=msg.get("sandbox"),
)
if not ok:
await self._send(ws, {"type": "task_result", "error": res})
Expand All@@ -1478,7 +1483,7 @@ async def _process_message(
if not self._scheduler:
await self._send(ws, {"type": "task_result", "error": "scheduler not running"})
return
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description") if k in msg}
fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description", "sandbox") if k in msg}
if "task_type" in fields:
fields["type"] = fields.pop("task_type")
ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields)
Expand DownExpand Up@@ -2536,6 +2541,12 @@ async def _run_tool_loop(
elif tc_name == "grep" and "path" not in args:
args["path"] = str(session.cwd)

# Sandbox tier (rant 2026-08-20T15:46:50): the task's
# configured sandbox is injected into the bash tool — the
# agent cannot choose it per call.
if tc_name == "bash" and req.sandbox:
args["sandbox"] = req.sandbox

# Execute
tool = self.tools.get(tc_name)
if tool:
Expand Down
40 changes: 40 additions & 0 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@

from emrg.config import config_dir
from emrg.connect import connect_to_server
from emrg.tools.bash_tool import SANDBOX_MODES
from websockets.exceptions import ConnectionClosed
from emrg.protocol import EvolutionLog, InstanceIdentity
from emrg.server.atomic import atomic_write_yaml
Expand DownExpand Up@@ -130,6 +131,7 @@ def _task_cfg_signature(cfg: dict) -> tuple:
json.dumps(conf, sort_keys=True),
cfg.get("interval", DEFAULT_INTERVAL),
bool(cfg.get("enabled", True)),
cfg.get("sandbox"),
)


Expand DownExpand Up@@ -198,6 +200,7 @@ def __init__(
interval: int,
identity: InstanceIdentity,
template_path: Path | None = None,
sandbox: str | None = None,
) -> None:
self.name = name
# Rant 2026-08-19T10:18:44: per-task logger — LoggerAdapter injects a
Expand DownExpand Up@@ -281,6 +284,32 @@ def __init__(
self._repo_configured = project_name == "emrg"
self._session_id = f"emrg-evolution-{name}"
self._source_dir = path or name
# Sandbox tier for this task's bash tool (rant 2026-08-20T15:46:50):
# explicit config wins; builtin tasks get suggested defaults; None =
# danger-full-access (current behavior).
self._sandbox = self._resolve_sandbox(name, config, sandbox)
if self._sandbox:
self._logger.info(
"TaskHandler[%s]: bash sandbox tier = %s", name, self._sandbox
)

@staticmethod
def _resolve_sandbox(name: str, config: dict, explicit: str | None) -> str | None:
"""Effective bash sandbox tier for a task.

Order: tasks.yml top-level ``sandbox:`` field → ``config.sandbox`` →
builtin defaults by task name → None (= danger-full-access, the
existing un-sandboxed behavior). Invalid values fall through to the
defaults rather than breaking the task.
"""
for cand in (explicit, config.get("sandbox")):
if cand in SANDBOX_MODES and cand != "danger-full-access":
return cand
if name == "emrg-task":
return "workspace-write" # evolution writes its own repo
if name.endswith("-opensource-task"):
return "read-only" # community work in host-owned repos
return None

# ── Saturation state (restored from disk across daemon restarts) ──

Expand DownExpand Up@@ -684,6 +713,7 @@ async def _run_evolution_cycle(self) -> None:
"prompt": prompt,
"stream": True,
"timestamp": cycle_time.isoformat(),
"sandbox": self._sandbox,
},
ensure_ascii=False,
)
Expand DownExpand Up@@ -961,6 +991,7 @@ def _build_handler(self, cfg: dict) -> TaskHandler:
interval=cfg.get("interval", DEFAULT_INTERVAL),
identity=self.identity,
template_path=template_path,
sandbox=cfg.get("sandbox"),
)

def _start_handler_for(self, cfg: dict) -> TaskHandler:
Expand DownExpand Up@@ -1267,12 +1298,15 @@ def task_create(
self, name: str, task_type: str, project: str,
interval: int | None = None, enabled: bool = True,
repo: str | None = None, description: str | None = None,
sandbox: str | None = None,
) -> tuple[bool, str | dict]:
"""Create a task. Returns (ok, error) or (ok, task-dict)."""
interval = DEFAULT_INTERVAL if interval is None else interval
err = self._validate_task_fields(name, task_type, project, interval)
if err:
return False, err
if sandbox is not None and sandbox not in SANDBOX_MODES:
return False, f"invalid sandbox {sandbox!r} (expected one of {', '.join(SANDBOX_MODES)})"
tasks = self._load_tasks()
if any(t.get("name") == name for t in tasks):
return False, f"task {name!r} already exists"
Expand All@@ -1287,6 +1321,8 @@ def task_create(
"enabled": bool(enabled),
"last_run": None,
}
if sandbox is not None:
task["sandbox"] = sandbox
if description:
task["description"] = description
tasks.append(task)
Expand DownExpand Up@@ -1327,6 +1363,10 @@ def task_update(self, name: str, **fields) -> tuple[bool, str | dict]:
task["enabled"] = bool(fields["enabled"])
if "description" in fields:
task["description"] = fields["description"]
if "sandbox" in fields:
if fields["sandbox"] is not None and fields["sandbox"] not in SANDBOX_MODES:
return False, f"invalid sandbox {fields['sandbox']!r} (expected one of {', '.join(SANDBOX_MODES)})"
task["sandbox"] = fields["sandbox"]
self._save_tasks(tasks)
logger.info("TaskScheduler: task %s updated", name)
return True, task
Expand Down
175 changes: 175 additions & 0 deletions emrg/tools/bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,159 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]:
return rewritten.rstrip(), path


# ── Sandbox — file-level isolation for the bash tool (rant 2026-08-20T15:46:50) ──
#
# Three tiers (default danger-full-access = current, un-sandboxed behavior):
# danger-full-access — no checks at all (existing behavior)
# read-only — no writes allowed: destructive commands (rm -r /
# rmdir / mv / cp -r) and shell redirects (> / >>)
# to any non-/dev/null target are blocked
# workspace-write — writes inside the workspace root (and the OS temp
# area) are allowed; destructive writes to protected
# daemon state files and to absolute paths outside
# the workspace are blocked
#
# Enforcement is deliberately heuristic (host design-finalized): a static
# command scan, NOT an OS-level sandbox (no bwrap/Seatbelt/ACL). The checked
# modes report enforcement="partial" — honest reporting, never pretending
# full OS-level isolation. The core value is blocking a hallucinated LLM's
# obviously destructive commands (rm -rf with a wrong path, writing the
# daemon's own state files).

SANDBOX_MODES = ("danger-full-access", "read-only", "workspace-write")

# Daemon state files — writing to these from a sandboxed task is always
# blocked (they are the daemon's own data, not agent scratch space).
_PROTECTED_FILES = (
"~/.emrg/config.toml",
"~/.emrg/emrgd.token",
"~/.emrg/tasks.yml",
"~/.emrg/projects.yml",
"~/.emrg/rants.jsonl",
)


def _extract_write_targets(cmd: str) -> list[str]:
"""Heuristic extraction of write targets from a command line.

Returns path tokens the command appears to write to:
- ``rm -r/-rf/-R <path>`` and ``rmdir <path>`` → the removed path
- ``mv <src> <dst>`` / ``cp -r <src> <dst>`` → the destination
- ``> / >> / 2> / &>`` redirects → the redirect target

Deliberately non-exhaustive (the sandbox only catches obvious
destructive writes — the boundary is honest: enforcement=partial).
"""
targets: list[str] = []
# rm -r / rm -rf / rm -R ... <path> (recursive delete)
for m in re.finditer(r"\brm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# rmdir <path>
for m in re.finditer(r"\brmdir\s+([^\s|;&]+)", cmd):
targets.append(m.group(1))
# mv <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bmv\s+((?:-[a-zA-Z]*\s+)*[^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# cp -r <src> <dst> — the destination is the last bare token
for m in re.finditer(r"\bcp\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+\s+[^\s|;&]+)", cmd):
toks = m.group(1).split()
if len(toks) >= 2:
targets.append(toks[-1])
# shell redirects: > file / >> file / 2> file / &> file
for m in re.finditer(r"(?:\d*>>?|&>>?)\s*([^\s|;&]+)", cmd):
targets.append(m.group(1))
return targets


def _protected_paths() -> list[str]:
"""Canonicalized (realpath) protected daemon state files."""
out: list[str] = []
for p in _PROTECTED_FILES:
try:
out.append(os.path.realpath(os.path.expanduser(p)))
except OSError:
pass
return out


def _is_absolute_path(p: str) -> bool:
"""True when ``p`` is absolute (or drive-less rooted, e.g. ``/etc/hosts``
on Windows — ntpath.isabs returns False for those, but they still do not
resolve under the cwd, so the sandbox must treat them as absolute)."""
return os.path.isabs(p) or p.startswith("/") or p.startswith("\\")


def _is_within(path: str, root: str) -> bool:
"""True when ``path`` (absolute) is inside ``root`` (absolute) or equals it."""
try:
rp = os.path.realpath(path)
rr = os.path.realpath(root)
return rp == rr or rp.startswith(rr + os.sep)
except OSError:
return False


def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[bool, str | None, str]:
"""Static sandbox check for a bash command (rant 2026-08-20T15:46:50).

Returns ``(allowed, blocked_reason, enforcement)``:
- danger-full-access → (True, None, "full") — no checks, current behavior.
- read-only → blocks every destructive write (rm -r / rmdir / mv /
cp -r and shell redirects to any non-/dev/null target).
- workspace-write → blocks destructive writes to protected daemon
files, to ``~/.emrg`` itself, and to absolute paths outside the
workspace root (the OS temp dir is allowed — mirrors dsh's
workspace + backend-promised temp area).

Heuristic by design: static scan only, no OS-level boundary — checked
modes honestly report enforcement="partial".
"""
if mode not in SANDBOX_MODES:
return False, f"invalid sandbox mode {mode!r}", "partial"
if mode == "danger-full-access":
return True, None, "full"

targets = _extract_write_targets(cmd)
if mode == "read-only":
for t in targets:
if t != "/dev/null":
return False, (
f"read-only sandbox: blocked destructive write targeting {t!r}"
), "partial"
return True, None, "partial"

# workspace-write
if not targets:
return True, None, "partial"
protected = _protected_paths()
emrg_home = os.path.realpath(os.path.expanduser("~/.emrg"))
workdir_real = os.path.realpath(workdir) if workdir else None
for t in targets:
if t == "/dev/null":
continue
expanded = os.path.expanduser(t)
if not _is_absolute_path(expanded):
# Relative target: assumed in-workspace (cwd = the workspace root).
continue
real = os.path.realpath(expanded)
if real in protected:
return False, (
f"workspace-write sandbox: blocked write to protected daemon file {t!r}"
), "partial"
if real == emrg_home:
return False, (
f"workspace-write sandbox: blocked destructive write to {t!r} "
"(would erase the daemon's data directory)"
), "partial"
if workdir_real and not _is_within(real, workdir_real) and not _is_within(real, tempfile.gettempdir()):
return False, (
f"workspace-write sandbox: blocked write outside workspace {t!r}"
), "partial"
return True, None, "partial"


def _decode_output(data: bytes, os_name: str | None = None) -> str:
"""Decode subprocess output bytes without corrupting non-UTF-8 text.

Expand DownExpand Up@@ -166,10 +319,30 @@ async def execute(self, arguments: dict) -> ToolResult:
cmd = arguments.get("command", "")
timeout = arguments.get("timeout", 30)
workdir = arguments.get("workdir", None)
# Sandbox tier — daemon-injected per task config (the agent cannot
# choose its own sandbox; rant 2026-08-20T15:46:50).
sandbox = arguments.get("sandbox")

if not cmd:
return ToolResult(name="bash", content="Error: no command provided", error=True)

# Static file-level isolation check (rant 2026-08-20T15:46:50).
sandbox_tag: str | None = None
if sandbox and sandbox != "danger-full-access":
allowed, reason, enforcement = _check_sandbox(cmd, sandbox, workdir)
if not allowed:
logger.info("bash: BLOCKED by %s sandbox: %s", sandbox, reason)
return ToolResult(
name="bash",
content=(
f"⛔ [sandbox:{sandbox} enforcement={enforcement}] "
f"{reason} — command not executed"
),
error=True,
)
sandbox_tag = f"[sandbox:{sandbox} enforcement={enforcement}]"
logger.debug("bash: sandbox %s check passed", sandbox)

logger.debug("bash: running %r (timeout=%ds)", cmd[:100], timeout)

# Windows: cmd.exe cannot parse bash heredocs — translate the first
Expand DownExpand Up@@ -271,6 +444,8 @@ async def execute(self, arguments: dict) -> ToolResult:
if not parts:
parts.append("(no output)")
result = "\n".join(parts)
if sandbox_tag:
result = f"{sandbox_tag} ok\n{result}"
return ToolResult(name="bash", content=result)
except FileNotFoundError:
return ToolResult(
Expand Down
Loading
Loading