[eric] approvals: trust patterns for action approvals + smarter sensitive-file gating

This commit is contained in:
ciregenz
2026-05-20 01:56:38 -07:00
parent 9001cb8e38
commit a1fefa0ae8
17 changed files with 1018 additions and 62 deletions
+216 -39
View File
@@ -20,9 +20,11 @@ from backend.apps.tools_lib.tools_lib import (
_sanitize_server_name,
derive_mcp_config,
load_builtin_permissions,
load_trusted_sensitive_paths,
refresh_airtable_token,
refresh_google_token,
refresh_hubspot_token,
save_trusted_sensitive_paths,
)
from backend.config.paths import SESSIONS_DIR
from backend.apps.service.client import sync as _sync
@@ -1124,35 +1126,62 @@ class AgentManager:
def _default_for(tool_name: str) -> str:
return _DEFAULTS.get(tool_name, "always_allow")
# Path patterns that flip Write/Edit/NotebookEdit from always_allow
# to ask regardless of the user's base permission. Targets the
# narrow set of files a prompt-injected agent would use to exfil
# or persist (SSH keys, shell rc files, env files, cloud creds,
# system dirs). Normal in-project / in-workspace / in-Downloads
# edits never match — keeping the prompt-fatigue surface tiny.
# Defense-in-depth path gate: flips Write/Edit/NotebookEdit from
# always_allow to ask only for paths where a prompt-injected write
# would grant the attacker persistence or credential exfil that the
# user can't easily undo. Excludes routine dev artifacts (.env files,
# generic app-support dirs); those triggered approvals on every
# webapp build and contradicted the user's UI setting without
# blocking any real attacker (a project .env doesn't auto-execute
# and doesn't grant persistence; the threat is SSH auth keys, shell
# rc files that auto-source on login, publish tokens, keychains,
# and system dirs).
import fnmatch as _fnmatch
_SENSITIVE_PATH_PATTERNS = (
"*/.ssh", "*/.ssh/*",
"*/.aws/*", "*/.config/gcloud/*", "*/.kube/*",
"*/.gnupg/*", "*/.docker/config*",
"*/.zshrc", "*/.bashrc", "*/.bash_profile",
"*/.profile", "*/.zprofile", "*/.zshenv",
"*/.gitconfig", "*/.npmrc", "*/.pypirc", "*/.netrc",
"*/.env", "*/.env.*", "*.env",
"*/Library/Application Support/*", # macOS credential stores
"*/Library/Keychains/*",
"/etc/*", "/private/etc/*", "/System/*",
"/usr/local/etc/*",
)
# Each entry: pattern -> (short label, plain-English risk).
# Multiple patterns can describe the same folder, but the user-
# facing label/risk is what we show in the approval card, so it
# has to read clearly to a non-developer who has no idea what
# `~/.ssh/authorized_keys` is.
_SENSITIVE_PATH_INFO: dict[str, tuple[str, str]] = {
"*/.ssh": ("SSH folder (~/.ssh)", "Controls who can log in to your computer remotely."),
"*/.ssh/*": ("SSH folder (~/.ssh)", "Controls who can log in to your computer remotely."),
"*/.aws/*": ("AWS credentials (~/.aws)", "Cloud account access keys; can spend money and read your data."),
"*/.config/gcloud/*": ("Google Cloud credentials", "Cloud account access; can spend money and read your data."),
"*/.kube/*": ("Kubernetes config (~/.kube)", "Admin access to your Kubernetes clusters."),
"*/.gnupg/*": ("GPG encryption keys", "Your private encryption keys; lets attackers decrypt your data or sign as you."),
"*/.docker/config*": ("Docker credentials", "Login tokens for container registries."),
"*/.zshrc": ("Shell startup file (.zshrc)", "Runs automatically every time you open a terminal."),
"*/.bashrc": ("Shell startup file (.bashrc)", "Runs automatically every time you open a terminal."),
"*/.bash_profile": ("Shell startup file (.bash_profile)", "Runs automatically every time you log in."),
"*/.profile": ("Shell startup file (.profile)", "Runs automatically every time you log in."),
"*/.zprofile": ("Shell startup file (.zprofile)", "Runs automatically every time you log in."),
"*/.zshenv": ("Shell environment file (.zshenv)", "Runs automatically for every shell, including non-interactive ones."),
"*/.gitconfig": ("Global Git config", "Affects every Git command you run; can hijack commits."),
"*/.npmrc": ("npm auth file (~/.npmrc)", "Lets you publish npm packages; a token here can publish malicious packages as you."),
"*/.pypirc": ("PyPI auth file (~/.pypirc)", "Lets you publish Python packages; a token here can publish malicious packages as you."),
"*/.netrc": ("Stored login info (~/.netrc)", "Saved passwords for various services."),
"*/Library/Keychains/*": ("macOS Keychain", "Where macOS stores all your saved passwords."),
"/etc/*": ("System config (/etc)", "Affects the whole computer, not just your account."),
"/private/etc/*": ("System config (/etc)", "Affects the whole computer, not just your account."),
"/System/*": ("macOS system folder", "Affects the whole computer; should almost never be modified."),
"/usr/local/etc/*": ("System config (/usr/local/etc)", "Affects the whole computer, not just your account."),
}
_SENSITIVE_PATH_PATTERNS = tuple(_SENSITIVE_PATH_INFO.keys())
def _is_sensitive_write_path(file_path: str) -> bool:
def _match_sensitive_pattern(file_path: str) -> str | None:
"""Return the matched sensitive pattern, or None if the path isn't
sensitive OR if the user has previously trusted that pattern. The
trusted list is reloaded on every call so a trust decision made
during one approval takes effect for any later prompt fired in the
same turn (no in-process cache to invalidate).
"""
if not file_path or not isinstance(file_path, str):
return False
return None
try:
norm = os.path.normpath(os.path.expanduser(file_path))
except Exception:
return False
return None
# Normalize to forward slashes so the patterns match on Windows
# too — `os.path.normpath` produces backslashes on Windows
# (`C:\Users\eric\.ssh\authorized_keys`), and fnmatch treats
@@ -1162,13 +1191,109 @@ class AgentManager:
# would go through unchallenged.
if os.sep != '/':
norm = norm.replace(os.sep, '/')
trusted = set(load_trusted_sensitive_paths())
for pat in _SENSITIVE_PATH_PATTERNS:
if pat in trusted:
continue
if _fnmatch.fnmatch(norm, pat):
return True
return False
return pat
return None
_PATH_GATED_TOOLS = ("Write", "Edit", "NotebookEdit")
# OS-level scheduling across macOS/Linux/Windows. Agent must
# not install cron entries, launchd plists, Windows scheduled
# tasks, or PowerShell ScheduledTask cmdlets behind the user's
# back. Word-bounded so we don't flag stray strings in echo etc.
import re as _re_sched
_OS_SCHED_RE = _re_sched.compile(
r"\b("
r"crontab|launchctl|launchd|schtasks|systemd-run|"
r"systemctl\s+--user.*timer|at\s+\d|at\s+now|at\s+-f|"
# Windows PowerShell scheduled-task cmdlets:
r"Register-ScheduledTask|New-ScheduledTask|Set-ScheduledTask|"
r"Register-ScheduledJob|New-ScheduledJob"
r")\b",
_re_sched.IGNORECASE,
)
def _looks_like_os_scheduling(tool_input) -> bool:
if not isinstance(tool_input, dict):
return False
cmd = str(tool_input.get("command") or "")
if not cmd:
return False
return bool(_OS_SCHED_RE.search(cmd))
# Catastrophic-path Bash gate. Bash is intentionally NOT in
# _PATH_GATED_TOOLS because gating every `echo ... > /tmp/foo` would
# interrupt routine work; but a single redirected write to one of
# these paths can grant persistent attacker access (SSH keys,
# sudoers, Keychain) or break the OS in ways the user can't
# recover from. Scope is intentionally tighter than the Write/Edit
# list because Bash is the agent's hot path and we'd rather miss
# a borderline case than gate routine commands. The trust list is
# shared with Write/Edit so a single "Always allow" decision in
# the modal covers both surfaces.
_BASH_CATASTROPHIC_INFO: dict[str, tuple[str, str]] = {
"*/.ssh/*": ("SSH folder (~/.ssh)", "Controls who can log in to your computer remotely."),
"/etc/sudoers": ("Sudo permissions (/etc/sudoers)", "Controls which commands can run with admin privileges."),
"/etc/sudoers.d/*": ("Sudo permissions (/etc/sudoers.d)", "Controls which commands can run with admin privileges."),
"/etc/passwd": ("System user list (/etc/passwd)", "Defines every user account on this computer."),
"/etc/shadow": ("System password file (/etc/shadow)", "Stores password hashes for every user account."),
"*/Library/Keychains/*": ("macOS Keychain", "Where macOS stores all your saved passwords."),
"/System/*": ("macOS system folder", "Affects the whole computer; should almost never be modified."),
}
_BASH_CATASTROPHIC_PATTERNS = tuple(_BASH_CATASTROPHIC_INFO.keys())
# Token-extraction regex: pulls quoted strings AND bare path-like
# tokens out of the Bash command so we can match against the
# catastrophic list. Intentionally loose: false positives just
# mean an extra approval prompt, never a missed gate.
_BASH_PATH_TOKEN_RE = _re_sched.compile(
r"""(?P<quoted>"[^"]+"|'[^']+')|(?P<bare>[~/.][\w./~\-]*)"""
)
# Write operators we care about. Presence alone is not enough;
# we also need a sensitive target in the same command. Includes
# both shell redirection (`>`, `>>`, `tee`) and tools that take
# an explicit destination flag (`cp`, `mv`, `dd of=`, `sed -i`,
# `install`, `chmod`, `chown`, `rm`).
_BASH_WRITE_OP_RE = _re_sched.compile(
r"(?:>>?|\btee\b|\bsed\s+-i\b|\bcp\b|\bmv\b|\bdd\b[^|]*\bof=|\binstall\b|\bchmod\b|\bchown\b|\brm\b|\btouch\b|\bmkdir\b|\bln\b)",
_re_sched.IGNORECASE,
)
def _match_bash_catastrophic_pattern(command: str) -> str | None:
"""Return the matched catastrophic-path pattern for a Bash
command, or None if the command isn't writing to one (or the
user has trusted that pattern). Same trust-list as
_match_sensitive_pattern so toggling once covers both.
"""
if not command or not isinstance(command, str):
return None
if not _BASH_WRITE_OP_RE.search(command):
return None
trusted = set(load_trusted_sensitive_paths())
for raw_match in _BASH_PATH_TOKEN_RE.finditer(command):
tok = (raw_match.group("quoted") or raw_match.group("bare") or "")
if tok and tok[0] in ("'", '"'):
tok = tok[1:-1]
if not tok:
continue
try:
norm = os.path.normpath(os.path.expanduser(tok))
except Exception:
continue
if os.sep != '/':
norm = norm.replace(os.sep, '/')
for pat in _BASH_CATASTROPHIC_PATTERNS:
if pat in trusted:
continue
if _fnmatch.fnmatch(norm, pat):
return pat
return None
def _extract_target_path(tool_name: str, tool_input) -> str:
if not isinstance(tool_input, dict):
return ""
@@ -1176,16 +1301,35 @@ class AgentManager:
return str(tool_input.get("notebook_path") or "")
return str(tool_input.get("file_path") or "")
def _maybe_override_policy(policy: str, tool_name: str, tool_input) -> str:
"""Flip a permissive policy to 'ask' when the target path is
sensitive. Defense in depth: even if the user has Write set to
always_allow for productivity, a prompt-injected agent writing
to ~/.ssh/authorized_keys or ~/.zshrc gets surfaced for review."""
def _maybe_override_policy(policy: str, tool_name: str, tool_input) -> tuple[str, str | None]:
"""Returns (effective_policy, matched_sensitive_pattern).
Flips a permissive policy to 'ask' when the target path is
sensitive (and not in the user's trusted allowlist). Defense
in depth: even if the user has Write set to always_allow for
productivity, a prompt-injected agent writing to
~/.ssh/authorized_keys or ~/.zshrc gets surfaced for review;
but once the user opts into "always allow files like this"
for a given pattern, future writes to that pattern pass
through silently.
Also: Bash invocations that look like OS-level scheduling
(crontab, launchctl, schtasks, at, systemd-run --on-calendar)
are flipped to 'ask' regardless of permission policy; we
don't want the agent silently installing cron entries.
"""
if tool_name == "Bash" and _looks_like_os_scheduling(tool_input):
return "ask", None
if tool_name == "Bash" and isinstance(tool_input, dict):
bash_match = _match_bash_catastrophic_pattern(str(tool_input.get("command") or ""))
if bash_match:
return "ask", bash_match
if policy != "always_allow" or tool_name not in _PATH_GATED_TOOLS:
return policy
if _is_sensitive_write_path(_extract_target_path(tool_name, tool_input)):
return "ask"
return policy
return policy, None
matched = _match_sensitive_pattern(_extract_target_path(tool_name, tool_input))
if matched:
return "ask", matched
return policy, None
def _get_effective_policy(tool_name: str) -> str:
"""Return 'always_allow', 'deny', or 'ask' for any tool."""
@@ -1212,15 +1356,28 @@ class AgentManager:
return t.tool_permissions.get(mcp_tool_name, "ask")
return _default_for(tool_name)
async def _request_user_approval(tool_name: str, tool_input) -> dict:
async def _request_user_approval(
tool_name: str,
tool_input,
sensitive_pattern: str | None = None,
) -> dict:
"""Send an approval request via WebSocket and wait for the user's decision."""
safe_input = tool_input if isinstance(tool_input, dict) else {}
request_id = uuid4().hex
label, why = (None, None)
if sensitive_pattern:
if sensitive_pattern in _SENSITIVE_PATH_INFO:
label, why = _SENSITIVE_PATH_INFO[sensitive_pattern]
elif sensitive_pattern in _BASH_CATASTROPHIC_INFO:
label, why = _BASH_CATASTROPHIC_INFO[sensitive_pattern]
approval_req = ApprovalRequest(
id=request_id,
session_id=session_id,
tool_name=tool_name,
tool_input=safe_input,
sensitive_pattern=sensitive_pattern,
sensitive_label=label,
sensitive_why=why,
)
session.pending_approvals.append(approval_req)
session.status = "waiting_approval"
@@ -1232,8 +1389,27 @@ class AgentManager:
})
decision = await ws_manager.send_approval_request(
session_id, request_id, tool_name, safe_input
session_id, request_id, tool_name, safe_input,
sensitive_pattern=sensitive_pattern,
sensitive_label=label,
sensitive_why=why,
)
# If the user opted into trusting this pattern, persist now so
# any subsequent prompt against the same pattern (e.g. the
# PreToolUse hook re-evaluating after can_use_tool, or a later
# Write in the same session) skips the modal silently.
if (
decision.get("behavior") == "allow"
and decision.get("trust_pattern")
and sensitive_pattern
):
try:
existing = load_trusted_sensitive_paths()
if sensitive_pattern not in existing:
existing.append(sensitive_pattern)
save_trusted_sensitive_paths(existing)
except Exception:
logger.exception("Failed to persist trusted sensitive path")
approval_latency_ms = int((datetime.now() - approval_req.created_at).total_seconds() * 1000)
try:
@@ -1258,8 +1434,9 @@ class AgentManager:
return decision
async def can_use_tool(tool_name, input_data, context):
sensitive_pattern: str | None = None
if tool_name != "AskUserQuestion":
policy = _maybe_override_policy(
policy, sensitive_pattern = _maybe_override_policy(
_get_effective_policy(tool_name), tool_name, input_data
)
if policy == "always_allow":
@@ -1267,7 +1444,7 @@ class AgentManager:
if policy == "deny":
return PermissionResultDeny(message="Tool denied by permission policy")
decision = await _request_user_approval(tool_name, input_data)
decision = await _request_user_approval(tool_name, input_data, sensitive_pattern=sensitive_pattern)
if decision.get("behavior") == "allow":
return PermissionResultAllow(
updated_input=decision.get("updated_input", input_data)
@@ -1284,7 +1461,7 @@ class AgentManager:
if tool_name and tool_name != "AskUserQuestion":
tool_input = input_data.get("tool_input", {})
policy = _maybe_override_policy(
policy, sensitive_pattern = _maybe_override_policy(
_get_effective_policy(tool_name), tool_name, tool_input
)
@@ -1298,7 +1475,7 @@ class AgentManager:
}
if policy == "ask":
decision = await _request_user_approval(tool_name, tool_input)
decision = await _request_user_approval(tool_name, tool_input, sensitive_pattern=sensitive_pattern)
if decision.get("behavior") == "allow":
if tool_use_id:
+1
View File
@@ -111,6 +111,7 @@ async def handle_approval(response: ApprovalResponse):
"behavior": response.behavior,
"message": response.message,
"updated_input": response.updated_input,
"trust_pattern": response.trust_pattern,
})
return {"ok": True}
+16
View File
@@ -20,12 +20,28 @@ class ApprovalRequest(BaseModel):
tool_name: str
tool_input: dict[str, Any]
created_at: datetime = Field(default_factory=datetime.now)
# Set when this approval was triggered by the sensitive-path override
# rather than the user's normal "ask" policy. Three correlated fields:
# - sensitive_pattern: the fnmatch pattern (canonical id; what we
# persist into the trusted allowlist if the user opts in).
# - sensitive_label: short human label (e.g. "SSH folder (~/.ssh)").
# - sensitive_why: plain-English risk explanation; lets the modal
# justify itself to a non-developer.
# All three None for ordinary "ask" approvals.
sensitive_pattern: Optional[str] = None
sensitive_label: Optional[str] = None
sensitive_why: Optional[str] = None
class ApprovalResponse(BaseModel):
request_id: str
behavior: Literal["allow", "deny"]
message: Optional[str] = None
updated_input: Optional[dict[str, Any]] = None
# When the user checked "Always allow files like this" on a sensitive-
# path approval, the backend persists the matched fnmatch pattern
# (from ApprovalRequest.sensitive_pattern) to disk so future writes
# against the same pattern skip the modal.
trust_pattern: bool = False
class Message(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
+10 -2
View File
@@ -178,6 +178,9 @@ class ConnectionManager:
async def send_approval_request(
self, session_id: str, request_id: str, tool_name: str, tool_input: dict,
timeout: float = 600.0,
sensitive_pattern: str | None = None,
sensitive_label: str | None = None,
sensitive_why: str | None = None,
) -> dict:
"""Send an approval request and wait for the user's response.
@@ -188,11 +191,16 @@ class ConnectionManager:
future = asyncio.get_event_loop().create_future()
self.pending_futures[request_id] = future
await self.send_to_session(session_id, "agent:approval_request", {
payload: dict = {
"request_id": request_id,
"tool_name": tool_name,
"tool_input": tool_input,
})
}
if sensitive_pattern:
payload["sensitive_pattern"] = sensitive_pattern
payload["sensitive_label"] = sensitive_label
payload["sensitive_why"] = sensitive_why
await self.send_to_session(session_id, "agent:approval_request", payload)
try:
result = await asyncio.wait_for(future, timeout=timeout)
+25 -1
View File
@@ -179,7 +179,20 @@ def _decode_data_param(d: str) -> tuple[str, str]:
async def outputs_lifespan():
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(WORKSPACE_DIR, exist_ok=True)
yield
try:
yield
finally:
# Reap every per-app subprocess. Without this each `bash run.sh`
# (and its vite/uvicorn descendants) reparents to PID 1 when the
# main backend dies, leaving ghost listeners on the .env-pinned
# ports that block the next OpenSwarm launch's reload preview.
try:
from backend.apps.outputs.runtime import manager as runtime_manager
killed = await runtime_manager.stop_all()
if killed:
logger.info("outputs lifespan: reaped %d workspace runtimes on shutdown", killed)
except Exception:
logger.exception("outputs lifespan: stop_all failed")
outputs = SubApp("outputs", outputs_lifespan)
@@ -580,6 +593,17 @@ async def runtime_get_status(workspace_id: str):
return _runtime_status_payload(workspace_id)
@outputs.router.post("/shutdown-all")
async def runtime_shutdown_all():
"""Reap every workspace subprocess. Electron POSTs this during
will-quit so app subprocesses die BEFORE the main backend gets
SIGTERM'd; without it `bash run.sh` + its vite/uvicorn descendants
reparent to PID 1 and squat on .env-pinned ports forever."""
from backend.apps.outputs.runtime import manager as runtime_manager
killed = await runtime_manager.stop_all()
return {"ok": True, "killed": killed}
@outputs.router.put("/workspace/{workspace_id}/file/{filepath:path}")
async def write_workspace_file(workspace_id: str, filepath: str, body: dict):
"""Write (create/overwrite) a single file in a workspace."""
+147 -2
View File
@@ -181,6 +181,94 @@ def _find_free_port() -> int:
return s.getsockname()[1]
def _kill_descendant_tree(pid: int, sig_name: str = "TERM") -> None:
"""Recursively signal every descendant of `pid`, leaves-first. The
webapp template's run.sh installs `trap cleanup EXIT` (no TERM), so a
plain SIGTERM to the bash wrapper exits bash silently and leaves
vite/uvicorn grandchildren reparented to PID 1, squatting on the
workspace's ports. Walking the tree ourselves bypasses the template's
signal-handling habits entirely. POSIX uses `pgrep -P` to enumerate
direct children; Windows is covered by `taskkill /T /F` (job-object
walk). All failures are swallowed; missing PIDs mean the process
already exited, which is the desired state anyway."""
if os.name == "nt":
try:
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
)
except Exception:
pass
return
try:
out = subprocess.run(
["pgrep", "-P", str(pid)],
capture_output=True,
text=True,
timeout=2,
)
children = [int(p) for p in out.stdout.split() if p.strip().isdigit()]
except Exception:
children = []
for child in children:
_kill_descendant_tree(child, sig_name)
sig = getattr(signal, f"SIG{sig_name}", signal.SIGTERM)
for child in children:
try:
os.kill(child, sig)
except (ProcessLookupError, PermissionError, OSError):
pass
def _is_port_free(port: int) -> bool:
"""True if nothing currently holds a TCP listener on 127.0.0.1:port.
Cheap kernel-probe; resolves on bind success. Used as the cross-session
safety net: if a prior OpenSwarm run left a ghost subprocess holding
the .env-persisted FRONTEND_PORT, we detect it here and reallocate
rather than handing run.sh a port that will EADDRINUSE."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", port))
return True
except OSError:
return False
def _write_env_value(env_path: str, key: str, value: str) -> None:
"""Update KEY=VALUE in an existing `.env`, preserving every other
line. Creates the file if missing. Used when a persisted port collides
with a ghost from a prior session and we have to reallocate before
spawning run.sh."""
lines: list[str] = []
found = False
if os.path.exists(env_path):
try:
with open(env_path, encoding="utf-8") as f:
lines = f.readlines()
except Exception:
lines = []
for i, raw in enumerate(lines):
stripped = raw.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
k = stripped.split("=", 1)[0].strip()
if k == key:
lines[i] = f"{key}={value}\n"
found = True
break
if not found:
if lines and not lines[-1].endswith("\n"):
lines[-1] = lines[-1] + "\n"
lines.append(f"{key}={value}\n")
try:
with open(env_path, "w", encoding="utf-8") as f:
f.writelines(lines)
except Exception:
logger.exception("failed writing %s=%s to %s", key, value, env_path)
def _is_new_mode(workspace_path: str) -> bool:
"""A workspace is "new-mode" (webapp-template scaffold) if it has a
`run.sh` at its root. Old-mode workspaces are flat `index.html`-only
@@ -358,12 +446,25 @@ class AppRuntime:
fp_raw = _read_env_value(env_path, "FRONTEND_PORT")
bp_raw = _read_env_value(env_path, "BACKEND_PORT")
# FRONTEND_PORT is allocated by seed_workspace; should always be
# a number. If missing, log + fall back to a fresh allocation
# rare edge case (workspace seeded by an older OpenSwarm).
# a number. If missing, fall back to a fresh allocation (rare
# edge case: workspace seeded by an older OpenSwarm).
try:
self.frontend_port = int(fp_raw) if fp_raw else _find_free_port()
except ValueError:
self.frontend_port = _find_free_port()
# Port-collision safety net: if a ghost subprocess from a prior
# OpenSwarm run is still bound to the persisted port (force-quit,
# crash, OS killed the parent before stop_all could reap), Vite
# would EADDRINUSE silently. Re-probe and reallocate, then rewrite
# .env so the bash run.sh subprocess reads the new port.
if self.frontend_port and not _is_port_free(self.frontend_port):
new_port = _find_free_port()
self._broadcast(LogLine(
"runtime",
f"[runtime] persisted FRONTEND_PORT {self.frontend_port} is in use; reallocating to {new_port}",
))
self.frontend_port = new_port
_write_env_value(env_path, "FRONTEND_PORT", str(new_port))
# BACKEND_PORT may be the literal string "NONE" (frontend-only
# app — the common case) or a number once `backend_init.sh` has
# run. Only populate self.port when there's a real backend.
@@ -372,6 +473,16 @@ class AppRuntime:
self.port = int(bp_raw)
except ValueError:
self.port = None
# Same collision check for the backend port; a leaked uvicorn
# from a prior session would otherwise block the new spawn.
if self.port and not _is_port_free(self.port):
new_port = _find_free_port()
self._broadcast(LogLine(
"runtime",
f"[runtime] persisted BACKEND_PORT {self.port} is in use; reallocating to {new_port}",
))
self.port = new_port
_write_env_value(env_path, "BACKEND_PORT", str(new_port))
else:
self.port = None
@@ -542,10 +653,16 @@ class AppRuntime:
self._frontend_ready_task.cancel()
return
try:
# Walk the descendant tree first so vite/uvicorn grandchildren
# die before bash exits and orphans them to PID 1. The webapp
# template's run.sh only traps EXIT, not TERM, so a flat
# SIGTERM to bash kills bash silently and leaves vite alive.
_kill_descendant_tree(self.process.pid, "TERM")
self.process.terminate()
try:
await asyncio.wait_for(self.process.wait(), timeout=_TERMINATE_GRACE_SECONDS)
except asyncio.TimeoutError:
_kill_descendant_tree(self.process.pid, "KILL")
self.process.kill()
await self.process.wait()
except ProcessLookupError:
@@ -767,5 +884,33 @@ class AppRuntimeManager:
await rt.restart()
return rt
async def stop_all(self) -> int:
"""Terminate every active + idle workspace subprocess. Called on
FastAPI lifespan shutdown AND from Electron's pre-quit POST. Without
this, each `bash run.sh` (and its vite/uvicorn descendants) reparents
to PID 1 when the main backend dies, leaving ghost listeners on the
persisted FRONTEND_PORT/BACKEND_PORT that block the NEXT OpenSwarm
launch's app reload. Wakes any SIGSTOP'd idle entries before reaping
so they can run their own shutdown. Parallel via gather; with the
per-runtime 3s SIGTERM grace, worst case is one ~3s wait rather than
N*3s. Idempotent; safe to invoke from multiple shutdown paths."""
async with self._lock:
victims: list[AppRuntime] = []
for rt in list(self.runtimes.values()):
victims.append(rt)
for rt in list(self._idle_lru.values()):
_resume_process_tree(rt.process)
victims.append(rt)
self.runtimes.clear()
self._idle_lru.clear()
self._attached.clear()
if not victims:
return 0
await asyncio.gather(
*(rt.stop() for rt in victims),
return_exceptions=True,
)
return len(victims)
manager = AppRuntimeManager()
+41 -1
View File
@@ -26,7 +26,7 @@ OPENSWARM_OAUTH_BASE_URL = os.environ.get(
"OPENSWARM_OAUTH_BASE_URL", "https://api.openswarm.com"
).rstrip("/")
from backend.config.paths import BACKEND_DIR, DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH
from backend.config.paths import BACKEND_DIR, DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH, TRUSTED_SENSITIVE_PATHS_PATH
load_dotenv(os.path.join(BACKEND_DIR, ".env"))
if os.environ.get("OPENSWARM_PACKAGED") == "1":
@@ -105,11 +105,51 @@ def save_builtin_permissions(perms: dict[str, str]):
json.dump(perms, f, indent=2)
def load_trusted_sensitive_paths() -> list[str]:
if not os.path.exists(TRUSTED_SENSITIVE_PATHS_PATH):
return []
try:
with open(TRUSTED_SENSITIVE_PATHS_PATH) as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
return []
raw = data.get("patterns") if isinstance(data, dict) else None
if not isinstance(raw, list):
return []
return [p for p in raw if isinstance(p, str) and p]
def save_trusted_sensitive_paths(patterns: list[str]):
os.makedirs(os.path.dirname(TRUSTED_SENSITIVE_PATHS_PATH), exist_ok=True)
seen: list[str] = []
for p in patterns:
if isinstance(p, str) and p and p not in seen:
seen.append(p)
with open(TRUSTED_SENSITIVE_PATHS_PATH, "w") as f:
json.dump({"patterns": seen}, f, indent=2)
@tools_lib.router.get("/builtin/permissions")
async def get_builtin_permissions():
return {"permissions": load_builtin_permissions()}
@tools_lib.router.get("/trusted-sensitive-paths")
async def get_trusted_sensitive_paths():
"""Patterns the user has opted into always-allow for sensitive-path writes."""
return {"patterns": load_trusted_sensitive_paths()}
@tools_lib.router.put("/trusted-sensitive-paths")
async def replace_trusted_sensitive_paths(body: dict):
"""Replace the full list; Settings page uses this to revoke entries."""
incoming = body.get("patterns") or []
if not isinstance(incoming, list):
return {"patterns": load_trusted_sensitive_paths()}
save_trusted_sensitive_paths([p for p in incoming if isinstance(p, str) and p])
return {"patterns": load_trusted_sensitive_paths()}
@tools_lib.router.put("/builtin/permissions")
async def update_builtin_permissions(body: dict):
valid_tools = {t.name for t in BUILTIN_TOOLS}
+1
View File
@@ -34,6 +34,7 @@ OUTPUTS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "outputs_workspace")
SKILLS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "skills_workspace")
DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout")
BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json")
TRUSTED_SENSITIVE_PATHS_PATH = os.path.join(DATA_ROOT, "trusted_sensitive_paths.json")
# Per-install auth token for the localhost WS + HTTP API. Regenerated
# every backend start. Only code running as the current OS user (Electron
+9 -6
View File
@@ -223,6 +223,7 @@ async def websocket_session(websocket: WebSocket, session_id: str):
"behavior": payload.get("behavior", "deny"),
"message": payload.get("message"),
"updated_input": payload.get("updated_input"),
"trust_pattern": bool(payload.get("trust_pattern")),
})
elif event == "agent:edit_message":
from backend.apps.agents.agent_manager import agent_manager
@@ -368,6 +369,7 @@ async def websocket_dashboard(websocket: WebSocket):
"behavior": payload.get("behavior", "deny"),
"message": payload.get("message"),
"updated_input": payload.get("updated_input"),
"trust_pattern": bool(payload.get("trust_pattern")),
})
elif event == "browser:result":
ws_manager.resolve_browser_command(
@@ -702,14 +704,10 @@ async def session_compact(session_id: str):
@app.post("/api/agents/sessions/{session_id}/clear")
async def session_clear(session_id: str):
"""Reset a session to a fresh sdk_session_id (Phase 2 /clear slash cmd).
Preserves session.messages (so the chat UI keeps the visible history)
but clears the SDK-side conversation by minting a new sdk_session_id.
Also drops active_mcps so the user starts fresh.
"""
"""Wipe the session's UI history AND its SDK convo state (/clear slash cmd, Reset history button)."""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.ws_manager import ws_manager as _ws
from backend.apps.agents.models import MessageBranch
session = agent_manager.sessions.get(session_id)
if not session:
return JSONResponse({"error": "session not found"}, status_code=404)
@@ -719,6 +717,11 @@ async def session_clear(session_id: str):
session.tokens = {"input": 0, "output": 0}
session.cost_usd = 0.0
session.needs_fork = False
session.messages = []
session.pending_approvals = []
session.branches = {"main": MessageBranch(id="main")}
session.active_branch_id = "main"
session.tool_group_meta = {}
await _ws.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": session.status,
@@ -0,0 +1,303 @@
"""End-to-end test for the per-workspace runtime cleanup + port collision fix.
What this proves:
1. AppRuntimeManager.stop_all() reaps active runtimes.
2. AppRuntimeManager.stop_all() reaps idle (LRU) runtimes too.
3. AppRuntimeManager.stop_all() resumes SIGSTOP'd idle runtimes before reaping (otherwise the SIGTERM is queued and the process never dies).
4. _is_port_free() correctly detects collisions.
5. _write_env_value() updates a single key without clobbering siblings.
6. _start_new_mode() rewrites .env's FRONTEND_PORT when the persisted port is in use, and the spawned child sees the rewritten value.
7. Same collision-rewrite happens for BACKEND_PORT when it's not "NONE".
Run with: backend/.venv/bin/python backend/tests/test_outputs_runtime_cleanup.py
"""
import asyncio
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from backend.apps.outputs.runtime import (
AppRuntime,
AppRuntimeManager,
_find_free_port,
_is_port_free,
_read_env_value,
_write_env_value,
)
# --- Fixture: matches the production webapp_template run.sh signal habits ---
# trap cleanup EXIT only, no TERM. Reproduces the actual bug: SIGTERM kills
# bash silently, EXIT trap doesn't fire on uncaught signal, python child gets
# reparented to launchd. _kill_descendant_tree must walk the tree to nuke it.
FAKE_RUN_SH = """#!/bin/bash
set -e
if [ -f .env ]; then
set -a; . ./.env; set +a
fi
echo "[fake-run] FRONTEND_PORT=${FRONTEND_PORT:-unset} pid=$$"
python3 -c "
import socket, time, os
s = socket.socket()
s.bind(('127.0.0.1', int(os.environ['FRONTEND_PORT'])))
s.listen(1)
print(f'[fake-run] bound on {os.environ[\\"FRONTEND_PORT\\"]}', flush=True)
while True:
time.sleep(1)
" &
PYTHON_PID=$!
# Mirror the real template: EXIT trap only. bash's default SIGTERM handler
# exits without running EXIT, so this MUST NOT keep our descendant alive
# if our kill-tree walker works correctly.
cleanup() { kill $PYTHON_PID 2>/dev/null; }
trap cleanup EXIT
wait $PYTHON_PID
"""
def _make_fake_workspace(tmp: str, frontend_port: int, backend_port: str = "NONE") -> str:
ws = os.path.join(tmp, "ws")
os.makedirs(ws)
with open(os.path.join(ws, "run.sh"), "w") as f:
f.write(FAKE_RUN_SH)
os.chmod(os.path.join(ws, "run.sh"), 0o755)
with open(os.path.join(ws, ".env"), "w") as f:
f.write(f"# header comment\nSOMETHING_ELSE=untouched\nFRONTEND_PORT={frontend_port}\nBACKEND_PORT={backend_port}\nTRAILING=keep\n")
return ws
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except OSError:
return False
# --- Test 1: helpers ---
def test_is_port_free():
p = _find_free_port()
assert _is_port_free(p), "freshly-allocated port should be free"
s = socket.socket()
s.bind(("127.0.0.1", p))
s.listen(1)
try:
assert not _is_port_free(p), "_is_port_free must return False while bound"
finally:
s.close()
print("PASS test_is_port_free")
def test_write_env_value():
with tempfile.TemporaryDirectory() as tmp:
env = os.path.join(tmp, ".env")
with open(env, "w") as f:
f.write("A=1\nB=2\nC=3\n# comment\n")
_write_env_value(env, "B", "999")
assert _read_env_value(env, "A") == "1"
assert _read_env_value(env, "B") == "999"
assert _read_env_value(env, "C") == "3"
# New key appended.
_write_env_value(env, "D", "new")
assert _read_env_value(env, "D") == "new"
# Comment line + sibling values preserved.
with open(env) as f:
body = f.read()
assert "# comment" in body, "comment line dropped"
assert "A=1" in body and "C=3" in body
print("PASS test_write_env_value")
# --- Test 2: stop_all reaps an active runtime (real spawn). ---
async def test_stop_all_kills_active():
with tempfile.TemporaryDirectory() as tmp:
port = _find_free_port()
ws = _make_fake_workspace(tmp, port)
m = AppRuntimeManager()
rt = await m.attach("ws1", ws)
assert rt.running, "runtime should be running after attach"
pid = rt.process.pid
# Wait for the child python to actually bind the port.
for _ in range(40):
if not _is_port_free(port):
break
await asyncio.sleep(0.05)
else:
raise AssertionError(f"fake child never bound on {port}")
killed = await m.stop_all()
assert killed >= 1, f"stop_all reported {killed} reaped"
# Bash + python child must be gone within the grace window.
for _ in range(60):
if not _pid_alive(pid):
break
await asyncio.sleep(0.1)
else:
raise AssertionError(f"pid {pid} still alive after stop_all")
# Port must be released too.
for _ in range(40):
if _is_port_free(port):
break
await asyncio.sleep(0.05)
else:
raise AssertionError(f"port {port} not released after stop_all")
assert not m.runtimes and not m._idle_lru, "manager should be empty after stop_all"
print("PASS test_stop_all_kills_active")
# --- Test 3: stop_all reaps an idle (LRU + SIGSTOP'd) runtime. ---
async def test_stop_all_kills_idle():
with tempfile.TemporaryDirectory() as tmp:
port = _find_free_port()
ws = _make_fake_workspace(tmp, port)
m = AppRuntimeManager()
rt = await m.attach("ws-idle", ws)
pid = rt.process.pid
# Detach -> moves into LRU + SIGSTOP'd. If stop_all forgets to
# SIGCONT before SIGTERM, the kill queues and the process hangs.
await m.detach("ws-idle")
assert "ws-idle" in m._idle_lru, "should be in idle LRU"
# Confirm the process is suspended (T state on Linux, T on darwin).
# Skip the OS check; just rely on the eventual kill working.
killed = await m.stop_all()
assert killed == 1
for _ in range(60):
if not _pid_alive(pid):
break
await asyncio.sleep(0.1)
else:
raise AssertionError("idle process never died, stop_all probably didn't SIGCONT first")
for _ in range(40):
if _is_port_free(port):
break
await asyncio.sleep(0.05)
else:
raise AssertionError("port from idle runtime not released")
print("PASS test_stop_all_kills_idle")
# --- Test 4: persisted port collision triggers .env rewrite + new spawn. ---
async def test_port_collision_reallocates_env():
with tempfile.TemporaryDirectory() as tmp:
squatted_port = _find_free_port()
ws = _make_fake_workspace(tmp, squatted_port)
# Squat the persisted port so the runtime can't use it.
squatter = socket.socket()
squatter.bind(("127.0.0.1", squatted_port))
squatter.listen(1)
try:
m = AppRuntimeManager()
rt = await m.attach("ws-collide", ws)
# Wait for either spawn-failure or new port binding.
for _ in range(40):
if rt.frontend_port and rt.frontend_port != squatted_port:
break
await asyncio.sleep(0.05)
assert rt.frontend_port != squatted_port, \
f"frontend_port should have changed from {squatted_port}, got {rt.frontend_port}"
# .env should reflect the new port (so run.sh and subsequent
# restarts pick it up too).
written = _read_env_value(os.path.join(ws, ".env"), "FRONTEND_PORT")
assert written == str(rt.frontend_port), \
f".env not rewritten; expected {rt.frontend_port}, found {written}"
# Sibling .env keys untouched.
assert _read_env_value(os.path.join(ws, ".env"), "SOMETHING_ELSE") == "untouched"
assert _read_env_value(os.path.join(ws, ".env"), "TRAILING") == "keep"
await m.stop_all()
finally:
squatter.close()
print("PASS test_port_collision_reallocates_env")
# --- Test 5: stop_all is idempotent. ---
async def test_stop_all_idempotent():
m = AppRuntimeManager()
n = await m.stop_all()
assert n == 0
n = await m.stop_all()
assert n == 0
print("PASS test_stop_all_idempotent")
# --- Test 6: vite-like grandchild dies even with EXIT-only trap. ---
async def test_descendant_tree_killed_despite_exit_only_trap():
"""Regression for the actual prod bug: webapp_template run.sh has only
`trap cleanup EXIT` (no TERM), so a flat SIGTERM to bash exits bash
silently and reparents the vite/uvicorn grandchild to PID 1. stop()
must walk the descendant tree to nuke the grandchild explicitly."""
with tempfile.TemporaryDirectory() as tmp:
port = _find_free_port()
ws = _make_fake_workspace(tmp, port)
m = AppRuntimeManager()
rt = await m.attach("ws-tree", ws)
bash_pid = rt.process.pid
# Wait until the python grandchild is actually listening on the port,
# so we know it exists as a separate process.
for _ in range(60):
if not _is_port_free(port):
break
await asyncio.sleep(0.05)
else:
raise AssertionError("grandchild never bound the port")
# Find the grandchild PID via pgrep -P (same call our walker uses).
out = subprocess.run(
["pgrep", "-P", str(bash_pid)],
capture_output=True, text=True, timeout=2,
)
grand_pids = [int(p) for p in out.stdout.split() if p.strip().isdigit()]
assert grand_pids, "expected at least one bash child"
# The python process may be one further level down (`python -c ...` is
# the leaf, bash spawned via `&` puts it directly under bash).
all_descendants: list[int] = []
def collect(pid: int) -> None:
r = subprocess.run(
["pgrep", "-P", str(pid)],
capture_output=True, text=True, timeout=2,
)
for line in r.stdout.split():
if line.strip().isdigit():
pid_i = int(line)
all_descendants.append(pid_i)
collect(pid_i)
for g in grand_pids:
all_descendants.append(g)
collect(g)
await m.stop_all()
# Every descendant must be gone, not just bash.
for _ in range(80):
still_alive = [p for p in all_descendants if _pid_alive(p)]
if not still_alive:
break
await asyncio.sleep(0.1)
else:
raise AssertionError(
f"descendants still alive after stop_all: {still_alive} "
"(EXIT-only trap let them escape)"
)
for _ in range(40):
if _is_port_free(port):
break
await asyncio.sleep(0.05)
else:
raise AssertionError(f"port {port} still held by ghost grandchild")
print("PASS test_descendant_tree_killed_despite_exit_only_trap")
async def main():
test_is_port_free()
test_write_env_value()
await test_stop_all_idempotent()
await test_stop_all_kills_active()
await test_stop_all_kills_idle()
await test_port_collision_reallocates_env()
await test_descendant_tree_killed_despite_exit_only_trap()
print("\nALL TESTS PASSED")
if __name__ == "__main__":
asyncio.run(main())
+40
View File
@@ -1200,6 +1200,46 @@ app.on('window-all-closed', () => {
app.quit();
});
// Ask the backend to reap every per-app subprocess (bash run.sh / vite /
// uvicorn descendants) BEFORE we SIGTERM the backend itself. SIGTERM on
// the backend PID doesn't propagate to those children, so without this
// they reparent to PID 1 and squat on the workspace's .env-pinned ports,
// breaking the NEXT launch's app reload. Fire-and-forget with a hard
// timeout so a wedged backend can't block quit indefinitely.
function postShutdownAllApps(timeoutMs = 2000) {
return new Promise((resolve) => {
if (!backendPort) return resolve();
const req = http.request({
hostname: '127.0.0.1',
port: backendPort,
path: '/api/outputs/shutdown-all',
method: 'POST',
headers: authToken
? { 'Authorization': `Bearer ${authToken}`, 'Content-Length': 0 }
: { 'Content-Length': 0 },
timeout: timeoutMs,
}, (res) => {
res.on('data', () => {});
res.on('end', resolve);
});
req.on('error', () => resolve());
req.on('timeout', () => { try { req.destroy(); } catch (_) {} resolve(); });
req.end();
});
}
let drainingForQuit = false;
app.on('before-quit', async (event) => {
if (drainingForQuit) return;
event.preventDefault();
drainingForQuit = true;
try {
await postShutdownAllApps(2000);
} catch (_) {}
app.quit();
});
app.on('will-quit', () => {
if (!isDev) killBackend();
});
@@ -0,0 +1,131 @@
import React, { useEffect, useState, useCallback } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { API_BASE } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const TRUSTED_API = `${API_BASE}/tools/trusted-sensitive-paths`;
// Mirrors the backend _SENSITIVE_PATH_INFO mapping. Kept here intentionally
// (rather than fetched) because the user-facing label is the only part the
// settings page renders, and a static dictionary keeps the page snappy and
// works offline. If a pattern is unknown (older backend), fall back to the
// raw pattern string.
const PATTERN_LABELS: Record<string, string> = {
'*/.ssh': 'SSH folder (~/.ssh)',
'*/.ssh/*': 'SSH folder (~/.ssh)',
'*/.aws/*': 'AWS credentials (~/.aws)',
'*/.config/gcloud/*': 'Google Cloud credentials',
'*/.kube/*': 'Kubernetes config (~/.kube)',
'*/.gnupg/*': 'GPG encryption keys',
'*/.docker/config*': 'Docker credentials',
'*/.zshrc': 'Shell startup file (.zshrc)',
'*/.bashrc': 'Shell startup file (.bashrc)',
'*/.bash_profile': 'Shell startup file (.bash_profile)',
'*/.profile': 'Shell startup file (.profile)',
'*/.zprofile': 'Shell startup file (.zprofile)',
'*/.zshenv': 'Shell environment file (.zshenv)',
'*/.gitconfig': 'Global Git config',
'*/.npmrc': 'npm auth file (~/.npmrc)',
'*/.pypirc': 'PyPI auth file (~/.pypirc)',
'*/.netrc': 'Stored login info (~/.netrc)',
'*/Library/Keychains/*': 'macOS Keychain',
'/etc/*': 'System config (/etc)',
'/private/etc/*': 'System config (/etc)',
'/System/*': 'macOS system folder',
'/usr/local/etc/*': 'System config (/usr/local/etc)',
'/etc/sudoers': 'Sudo permissions (/etc/sudoers)',
'/etc/sudoers.d/*': 'Sudo permissions (/etc/sudoers.d)',
'/etc/passwd': 'System user list (/etc/passwd)',
'/etc/shadow': 'System password file (/etc/shadow)',
};
export const TrustedFilePatterns: React.FC = () => {
const c = useClaudeTokens();
const [patterns, setPatterns] = useState<string[] | null>(null);
const load = useCallback(async () => {
try {
const res = await fetch(TRUSTED_API);
if (!res.ok) return;
const data = await res.json();
setPatterns(Array.isArray(data.patterns) ? data.patterns : []);
} catch {
setPatterns([]);
}
}, []);
useEffect(() => { load(); }, [load]);
const revoke = useCallback(async (pat: string) => {
if (!patterns) return;
const next = patterns.filter((p) => p !== pat);
setPatterns(next);
try {
await fetch(TRUSTED_API, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ patterns: next }),
});
} catch {
// On failure, reload from server so UI matches truth.
load();
}
}, [patterns, load]);
if (patterns === null) return null;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
Trusted file patterns
</Typography>
<Typography sx={{ fontSize: '0.8rem', color: c.text.secondary, lineHeight: 1.45 }}>
Files like SSH keys and shell startup files normally ask before each write, even when you've set Write to "always allow". Patterns you've chosen to always allow appear below. Remove one to start asking again.
</Typography>
{patterns.length === 0 ? (
<Typography sx={{ fontSize: '0.78rem', color: c.text.tertiary, fontStyle: 'italic', mt: 0.5 }}>
No trusted patterns yet. You'll see a checkbox the first time the agent tries to write a sensitive file.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: 1.5, overflow: 'hidden', mt: 0.5 }}>
{patterns.map((pat, idx) => (
<Box
key={pat}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 1,
borderTop: idx === 0 ? 'none' : `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface,
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.82rem', color: c.text.primary, fontWeight: 500 }}>
{PATTERN_LABELS[pat] || pat}
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.tertiary, fontFamily: c.font.mono, mt: 0.15 }}>
{pat}
</Typography>
</Box>
<IconButton
size="small"
onClick={() => revoke(pat)}
aria-label={`Remove ${PATTERN_LABELS[pat] || pat}`}
sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error } }}
>
<DeleteOutlineIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
))}
</Box>
)}
</Box>
);
};
export default TrustedFilePatterns;
@@ -499,8 +499,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
if (!isDraft) dispatch(updateThinkingLevel({ sessionId: id, level }));
}, [id, isDraft, dispatch]);
const handleApprove = (requestId: string, updatedInput?: Record<string, any>) => {
dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput }));
const handleApprove = (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean) => {
dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput, trustPattern }));
};
const handleDeny = (requestId: string, message?: string) => {
@@ -6,6 +6,9 @@ import TextField from '@mui/material/TextField';
import Chip from '@mui/material/Chip';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import SendIcon from '@mui/icons-material/Send';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
@@ -180,7 +183,7 @@ function getMcpInputSummary(actionName: string, toolInput: Record<string, any>):
interface Props {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onApprove: (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean) => void;
onDeny: (requestId: string, message?: string) => void;
}
@@ -326,7 +329,7 @@ type Answers = Record<number, string | string[]>;
export interface QuestionFormProps {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onApprove: (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean) => void;
onDeny: (requestId: string, message?: string) => void;
compact?: boolean;
}
@@ -589,12 +592,14 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
const [denyMessage, setDenyMessage] = useState('');
const [showDenyInput, setShowDenyInput] = useState(false);
const [detailsExpanded, setDetailsExpanded] = useState(false);
const [trustPattern, setTrustPattern] = useState(false);
const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]);
const meta = useMcpToolMeta(parsed);
const accentColor = meta.integration?.color || c.status.warning;
const summary = parsed.isMcp ? getMcpInputSummary(parsed.actionName, request.tool_input) : '';
const isSensitive = !!request.sensitive_pattern;
if (!parsed.isMcp) {
return (
@@ -613,7 +618,7 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
{getToolIcon(request.tool_name)}
</Box>
<Typography sx={{ color: c.status.warning, fontWeight: 700, fontSize: '0.85rem' }}>
Permission Required
{isSensitive ? 'Sensitive file' : 'Permission Required'}
</Typography>
<Chip
label={request.tool_name}
@@ -630,10 +635,62 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
/>
</Box>
{isSensitive && (
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'flex-start',
bgcolor: 'rgba(181,51,51,0.08)',
border: '1px solid rgba(181,51,51,0.25)',
borderRadius: 1.5,
px: 1.25,
py: 1,
mb: 1.25,
}}
>
<WarningAmberIcon sx={{ fontSize: 18, color: c.status.error, mt: 0.1, flexShrink: 0 }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.82rem', lineHeight: 1.3 }}>
This file is sensitive: {request.sensitive_label}
</Typography>
{request.sensitive_why && (
<Typography sx={{ color: c.text.secondary, fontSize: '0.78rem', lineHeight: 1.35, mt: 0.3 }}>
{request.sensitive_why} OpenSwarm asks every time because a bad change here is hard to undo. Approve only if you asked for this.
</Typography>
)}
</Box>
</Box>
)}
<Box sx={{ mb: 1.5 }}>
<ToolPreview request={request} tokens={c} />
</Box>
{isSensitive && (
<FormControlLabel
sx={{
mb: 1,
ml: 0,
alignItems: 'flex-start',
'& .MuiFormControlLabel-label': { fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.35, pt: 0.5 },
}}
control={
<Checkbox
size="small"
checked={trustPattern}
onChange={(e) => setTrustPattern(e.target.checked)}
sx={{ p: 0.5, color: c.text.tertiary, '&.Mui-checked': { color: c.status.warning } }}
/>
}
label={
<>
Always allow files like this <strong>({request.sensitive_label})</strong>. You can change this later in Settings &rarr; Trusted file patterns.
</>
}
/>
)}
{showDenyInput && (
<TextField
placeholder="Reason for denying (optional)..."
@@ -657,7 +714,7 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
<Button
variant="contained"
startIcon={<CheckIcon />}
onClick={() => onApprove(request.id)}
onClick={() => onApprove(request.id, undefined, isSensitive && trustPattern)}
sx={{ bgcolor: c.status.success, '&:hover': { bgcolor: '#1e4d15' }, fontWeight: 600, fontSize: '0.8rem' }}
>
Approve
@@ -896,7 +953,7 @@ interface ToolGroup {
interface BatchApprovalBarProps {
requests: ApprovalRequest[];
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onApprove: (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean) => void;
onDeny: (requestId: string, message?: string) => void;
}
@@ -1042,7 +1099,7 @@ interface GroupRowProps {
group: ToolGroup;
expanded: boolean;
onToggle: () => void;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onApprove: (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean) => void;
onDeny: (requestId: string, message?: string) => void;
onApproveGroup: () => void;
onDenyGroup: () => void;
+4 -2
View File
@@ -55,6 +55,7 @@ import { setChecking, setUpdateError, setInstalling } from '@/shared/state/updat
import { fetchModes } from '@/shared/state/modesSlice';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
import DirectoryBrowser from '@/app/components/DirectoryBrowser';
import TrustedFilePatterns from '@/app/components/TrustedFilePatterns';
import { CommandsContent } from '@/app/pages/Commands/Commands';
import { API_BASE } from '@/shared/config';
import PlanPicker from '@/app/components/PlanPicker';
@@ -2194,8 +2195,9 @@ const Settings: React.FC = () => {
)}
</Box>
{/* Restart the onboarding tour. Wipes local progress so the
Get-Started panel re-opens at step 1 with no completed steps. */}
<TrustedFilePatterns />
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ ...labelSx, mb: 0.25 }}>Onboarding tour</Typography>
+6 -1
View File
@@ -45,6 +45,9 @@ export interface ApprovalRequest {
tool_name: string;
tool_input: Record<string, any>;
created_at: string;
sensitive_pattern?: string | null;
sensitive_label?: string | null;
sensitive_why?: string | null;
}
export interface MessageBranch {
@@ -415,16 +418,18 @@ export const handleApproval = createAsyncThunk(
behavior,
message,
updatedInput,
trustPattern,
}: {
requestId: string;
behavior: 'allow' | 'deny';
message?: string;
updatedInput?: Record<string, any>;
trustPattern?: boolean;
}) => {
const res = await fetch(`${AGENTS_API}/approval`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_id: requestId, behavior, message, updated_input: updatedInput }),
body: JSON.stringify({ request_id: requestId, behavior, message, updated_input: updatedInput, trust_pattern: !!trustPattern }),
});
if (!res.ok) {
throw new Error(`Approval request failed (${res.status})`);
@@ -541,6 +541,9 @@ class WebSocketManager {
tool_name: data.tool_name,
tool_input: data.tool_input,
created_at: new Date().toISOString(),
sensitive_pattern: data.sensitive_pattern ?? null,
sensitive_label: data.sensitive_label ?? null,
sensitive_why: data.sensitive_why ?? null,
},
}));
}