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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,40 @@ Initial open-source release of FrontierAgent.

### Fixed

- **One trusted runtime filesystem state** (`plugins/tools/_filesystem_state.py`).
The workspace, outputs, inputs and scratch directories are derived once and
read by everyone: mount resolution, the file-tool gate, the deliverable
roots, `create_file`, the shell variables handed to model commands, and the
system prompts. They used to be re-derived in six places from a mix of
environment variables, scope metadata and backend guesses, so a sub-agent
could write a directory its main agent could not, a relocated outputs root
left the prompt naming `/outputs` while the writers used the override, and
native mode's own outputs directory was under no allowed prefix.
- An installed state settles the workspace root, so `ExecutionScope` metadata
can no longer widen filesystem access. Without one (a benchmark runner
passing a trial directory) the checked metadata path remains.
- A remote or bwrap state grants no host writes and is never resolved with
this host's `realpath`; the canonical mount points are reported instead of
host spellings.
- The read-only inputs directory is refused to every writer through
`output_write_error`, including via a symlink inside the workspace.
- `plugins/skills/` and `data/` are read-only: the skills tree's symlinks are
deliberately trusted, so a writable one is a persistent prompt-injection
channel.
- **A write the path gate refused no longer reaches the host.** `write_file` and
`file_editor_create` fall back to the sandbox when local authorization fails,
and for the in-process `CurrentSandbox` that fallback is an `open()` in the
harness (as root, in container mode), so a refused path was written anyway.
That branch now authorizes the path itself; remote backends are unaffected.
- **Paths are quoted before reaching a sandbox shell.** `file_editor`'s view /
create / str_replace commands, `write_file`'s `mkdir -p`, and the Docker
`mkdir -p` interpolated the path unquoted, so a path containing `;` ran
commands that never passed the bash policy.
- **A system directory named as the workspace root grants nothing.** The root
arrives through `ExecutionScope` metadata (workload input), and
`{"workspace_root": "/etc"}` made `/etc` readable and writable. System roots
and direct children of `/usr` and `/etc` are refused; a run directory deep
under `/var` or `/opt` (a container volume, macOS `$TMPDIR`) still works.
- Surface finalize-gate bypasses on the final turn: an answer delivered despite
open task-board items now carries an unfinished-work note and a
`finalize_gate_bypassed` marker instead of reading as a clean success.
Expand Down
47 changes: 29 additions & 18 deletions plugins/tools/_deliverable_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,28 +48,27 @@ class _Policy:
def _runtime_outputs_root() -> str:
"""Return the filesystem output root visible to model tools.

Container and bwrap runs expose the canonical ``/outputs`` mount. Native
mode cannot create that top-level mount on hosts such as macOS (the root
filesystem is read-only), so its tools must use the run-local host path
exported by ``prepare_native_runtime`` instead.
Read from the trusted runtime state (see
:mod:`plugins.tools._filesystem_state`), which is also what the prompts
name and what ``_path_auth`` authorizes. It used to re-derive the root from
the environment and honour an override only under ``native``, so a
relocated outputs directory in container or bwrap mode left the prompt
saying ``/outputs`` while the file tools wrote somewhere this manifest never
recognised.
"""
backend = os.environ.get("SANDBOX_BACKEND", "").strip().lower()
if backend != "native" and os.environ.get("APODEX_IN_NATIVE") != "1":
return "/outputs"
configured = os.environ.get("FRONTIER_AGENT_OUTPUTS_DIR", "").strip()
if not configured or not os.path.isabs(configured):
return "/outputs"
return os.path.normpath(configured)
from plugins.tools._filesystem_state import DEFAULT_OUTPUTS_DIR, current_filesystem_state

return current_filesystem_state().outputs or DEFAULT_OUTPUTS_DIR


def _runtime_workspace_root() -> str:
"""Return the filesystem workspace root visible to model tools."""
if _runtime_outputs_root() == "/outputs":
return "/workspace"
configured = os.environ.get("FRONTIER_AGENT_WORKSPACE_DIR", "").strip()
if not configured or not os.path.isabs(configured):
return "/workspace"
return os.path.normpath(configured)
from plugins.tools._filesystem_state import (
DEFAULT_WORKSPACE_DIR,
current_filesystem_state,
)

return current_filesystem_state().workspace or DEFAULT_WORKSPACE_DIR


def _canonical_output_path(path: str) -> str:
Expand Down Expand Up @@ -572,7 +571,19 @@ def scratch_write_error() -> str | None:


def output_write_error(path: str) -> str | None:
"""Return an actionable error when ``path`` is not publishable."""
"""Return an actionable error when ``path`` is not publishable.

The single check every file writer already makes, so the read-only input
contract is enforced here rather than being added to each of them (and
forgotten by the next writer).
"""
from plugins.tools._filesystem_state import runtime_file_write_error
from plugins.tools._sandbox import resolve_runtime_path

# Check the target the writer will open, including canonical mount aliases.
# Otherwise /workspace/inputs/x bypasses a relocated read-only input root.
if error := runtime_file_write_error(resolve_runtime_path(path)):
return error
if error := spill_write_error(path):
return error
normalised = _canonical_output_path(path)
Expand Down
Loading
Loading