Summary
Add declarative config-time path scoping for filesystem-touching tools (terminal, file, code_execution, mcp__filesystem__*). A profile's config.yaml should be able to say "this agent's terminal tool can only touch paths under X, Y, Z" and have the runtime enforce it before the tool executes — no plugin, no fork, no kernel sandbox required.
This is the declarative companion to sibling issue #9388 (imperative pre_tool_call REJECT via plugin). Both have valid use cases: scoping should be config-first for the simple majority, with plugins as the escape hatch for policies that need runtime context.
Motivation
The concrete gap today
In v0.9.0, when a profile declares tools.terminal.enabled: true, the agent gets unrestricted shell access as whatever user the runtime runs as. There is no declarative way to say:
tools:
terminal:
enabled: true
allowed_paths: # NEW — proposed
- ~/forge-zero
- ${HERMES_HOME}
- /tmp
denied_paths: # NEW — proposed
- ~/.hermes
- ~/.ssh
- ~/.aws
file:
enabled: true
allowed_read_paths: # NEW — proposed (can be wider than write)
- ~/forge-zero
- ${HERMES_HOME}
- ~/Documents
allowed_write_paths: # NEW — proposed
- ~/forge-zero
- ${HERMES_HOME}
Today this has to be implemented via:
A first-class config field solves the 80% case without any of that.
Related issues
- #4281 — Enforce sandboxed execution for messaging platform sessions (this proposal would be one of the mechanisms)
- #3897 — Per-user tool restrictions in gateway (RBAC) — path scoping is one dimension of that
- #8943 — Docker sandbox for non-main terminal sessions (this proposal is the lightweight alternative for users who don't want a container per session)
- #8028 — Security: PYTHONPATH injection — related concern about tool scope
Real deployment context
Multi-tenant setup on a single host with productive agent A (clinical data in ~/.hermes/) and commercial agent B (Telegram-facing, co-founder role, sensitive prospect data in ~/.hermes-forge-runtime/). Agent B must never read from A's tree or from shared user credentials (~/.ssh, ~/.aws, ~/Library/Keychains), even under prompt injection.
I currently solve this with three layers of defense (structural isolation + sandbox-exec + a runtime plugin). The config-time scoping proposed here would replace Layer 3 for the common case and make Layer 2 optional-not-mandatory for users whose threat model is "prevent accidental LLM misbehavior" rather than "prevent adversarial escape from a compromised process".
Full write-up: forge/specs/spec-005-profile-isolation-3-layers.md.
Proposed behavior
terminal tool
Before invoking the shell command, parse the command string with shlex.split and extract path-like tokens (args starting with / or ~, redirect targets for >, >>, <, output args for curl -o, wget -O). For each path:
- Expand
~ and env vars, resolve to absolute
- If
allowed_paths is set and path is not under any allowed prefix → reject
- If path is under any
denied_paths prefix → reject
- Otherwise → proceed
On shell metacharacters that shlex cannot audit ($(), <(), <<<, backticks, eval, source): fail-closed by default, with an opt-out tools.terminal.allow_metacharacters: true for users who know what they're doing. This matches the approach I validated in production — the false-positive rate is low because users can always drop complex logic into a script in the allowed path.
file tool
Same path expansion/resolution. allowed_read_paths controls reads (read_file), allowed_write_paths controls writes (write_file, edit_file, file_append). Different allowlists for read vs write is useful — e.g., I allow reads from ~/Documents (user might say "read my notes file") but not writes.
MCP filesystem
Inspect all path-bearing args (path, source, destination, target, ...), not just the first one. Operations like move or copy have both a source (read) and destination (write); the check should cover both. Use _MCP_WRITE_OPS set (write/create/delete/move/append/edit/copy/rename/overwrite/mkdir/remove) to determine whether the op is a write; default-deny for unknown ops.
Error surfaced to the LLM
When rejected, the tool result should be a structured message the LLM can act on:
{
"error": "Tool call rejected: path outside allowed scope",
"tool_name": "terminal",
"path": "/Users/boskov/.ssh/id_rsa",
"allowed_paths": ["~/forge-zero", "~/.forge-zero-runtime", "/tmp"],
"hint": "Try a path under one of the allowed roots, or ask the user."
}
This is the same structured-reject UX as #9388. Ideally both share the same JSON envelope.
Backward compatibility
- If
allowed_paths / denied_paths is not set → current behavior (no restriction). Zero impact on existing profiles.
- Field is per-profile, merged with tool-specific config only (no cross-profile bleed).
- No changes to tool schemas seen by the LLM — the LLM doesn't need to know about the allowlist, it just gets the reject error when it tries to escape.
Implementation notes
The path-parsing logic is ~100 lines of Python and I have a battle-tested implementation (_check_terminal_command, _collect_path_args, _path_check) in the forge-lockdown plugin linked in #9388. Happy to adapt it for upstream if maintainers want.
For macOS/Linux path semantics: Path(s).expanduser().resolve() handles the 95% case. Symlink traversal is resolved at check time, which is what you want for security (a symlink pointing at ~/.ssh/id_rsa placed inside an allowed path resolves to the real target and gets rejected).
Alternatives considered
Priority ordering (suggestion)
If implementation bandwidth is limited:
tools.terminal.allowed_paths / denied_paths — highest impact, covers the common LLM-shell-exfiltration scenario
tools.file.allowed_read_paths / allowed_write_paths
- MCP filesystem multi-arg path inspection
- Opt-out flag for metacharacters
Checklist
Summary
Add declarative config-time path scoping for filesystem-touching tools (
terminal,file,code_execution,mcp__filesystem__*). A profile'sconfig.yamlshould be able to say "this agent'sterminaltool can only touch paths under X, Y, Z" and have the runtime enforce it before the tool executes — no plugin, no fork, no kernel sandbox required.This is the declarative companion to sibling issue #9388 (imperative
pre_tool_callREJECT via plugin). Both have valid use cases: scoping should be config-first for the simple majority, with plugins as the escape hatch for policies that need runtime context.Motivation
The concrete gap today
In v0.9.0, when a profile declares
tools.terminal.enabled: true, the agent gets unrestricted shell access as whatever user the runtime runs as. There is no declarative way to say:Today this has to be implemented via:
pre_tool_callhook should support REJECT semantics (10-line patch) #9388 to be effective)sandbox-execon macOS,bwrapon Linux, containers) — effective but adds operational complexity and doesn't give the LLM a useful error messageA first-class config field solves the 80% case without any of that.
Related issues
Real deployment context
Multi-tenant setup on a single host with productive agent A (clinical data in
~/.hermes/) and commercial agent B (Telegram-facing, co-founder role, sensitive prospect data in~/.hermes-forge-runtime/). Agent B must never read from A's tree or from shared user credentials (~/.ssh,~/.aws,~/Library/Keychains), even under prompt injection.I currently solve this with three layers of defense (structural isolation +
sandbox-exec+ a runtime plugin). The config-time scoping proposed here would replace Layer 3 for the common case and make Layer 2 optional-not-mandatory for users whose threat model is "prevent accidental LLM misbehavior" rather than "prevent adversarial escape from a compromised process".Full write-up: forge/specs/spec-005-profile-isolation-3-layers.md.
Proposed behavior
terminaltoolBefore invoking the shell command, parse the command string with
shlex.splitand extract path-like tokens (args starting with/or~, redirect targets for>,>>,<, output args forcurl -o,wget -O). For each path:~and env vars, resolve to absoluteallowed_pathsis set and path is not under any allowed prefix → rejectdenied_pathsprefix → rejectOn shell metacharacters that
shlexcannot audit ($(),<(),<<<, backticks,eval,source): fail-closed by default, with an opt-outtools.terminal.allow_metacharacters: truefor users who know what they're doing. This matches the approach I validated in production — the false-positive rate is low because users can always drop complex logic into a script in the allowed path.filetoolSame path expansion/resolution.
allowed_read_pathscontrols reads (read_file),allowed_write_pathscontrols writes (write_file,edit_file,file_append). Different allowlists for read vs write is useful — e.g., I allow reads from~/Documents(user might say "read my notes file") but not writes.MCP filesystem
Inspect all path-bearing args (
path,source,destination,target, ...), not just the first one. Operations likemoveorcopyhave both a source (read) and destination (write); the check should cover both. Use_MCP_WRITE_OPSset (write/create/delete/move/append/edit/copy/rename/overwrite/mkdir/remove) to determine whether the op is a write; default-deny for unknown ops.Error surfaced to the LLM
When rejected, the tool result should be a structured message the LLM can act on:
{ "error": "Tool call rejected: path outside allowed scope", "tool_name": "terminal", "path": "/Users/boskov/.ssh/id_rsa", "allowed_paths": ["~/forge-zero", "~/.forge-zero-runtime", "/tmp"], "hint": "Try a path under one of the allowed roots, or ask the user." }This is the same structured-reject UX as #9388. Ideally both share the same JSON envelope.
Backward compatibility
allowed_paths/denied_pathsis not set → current behavior (no restriction). Zero impact on existing profiles.Implementation notes
The path-parsing logic is ~100 lines of Python and I have a battle-tested implementation (
_check_terminal_command,_collect_path_args,_path_check) in theforge-lockdownplugin linked in #9388. Happy to adapt it for upstream if maintainers want.For macOS/Linux path semantics:
Path(s).expanduser().resolve()handles the 95% case. Symlink traversal is resolved at check time, which is what you want for security (a symlink pointing at~/.ssh/id_rsaplaced inside an allowed path resolves to the real target and gets rejected).Alternatives considered
pre_tool_callhook should support REJECT semantics (10-line patch) #9388): works, but requires every deployment to write/vendor a plugin for what is 95% the same policy. Config-time scoping is DRY across deployments.Priority ordering (suggestion)
If implementation bandwidth is limited:
tools.terminal.allowed_paths/denied_paths— highest impact, covers the common LLM-shell-exfiltration scenariotools.file.allowed_read_paths/allowed_write_pathsChecklist