Skip to content

feat: declarative tools.*.allowed_paths config-time scoping for filesystem tools #9389

@Ti0aceite

Description

@Ti0aceite

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:

  1. Expand ~ and env vars, resolve to absolute
  2. If allowed_paths is set and path is not under any allowed prefix → reject
  3. If path is under any denied_paths prefix → reject
  4. 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:

  1. tools.terminal.allowed_paths / denied_paths — highest impact, covers the common LLM-shell-exfiltration scenario
  2. tools.file.allowed_read_paths / allowed_write_paths
  3. MCP filesystem multi-arg path inspection
  4. Opt-out flag for metacharacters

Checklist

  • Declarative (no code required per deployment)
  • Backward compatible (opt-in via config field)
  • Complements rather than duplicates #9388 — this is the common case, that one is the escape hatch
  • Production-validated path-parsing logic available for adaptation
  • PR — happy to contribute once a maintainer signals the API shape is acceptable

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Low — cosmetic, nice to havearea/configConfig system, migrations, profilescomp/toolsTool registry, model_tools, toolsetstype/featureNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions