Skip to content

Per-agent file_policy: deny/prompt/read/write tier system across all read+write tool sites #1181

@benhoverter

Description

@benhoverter

Problem

The runtime today has two file-access gates: the workspace-root lock (writes must stay under <workspace>) and validate_path (..-rejection). Both are coarse:

  • An agent can read anything readable by the daemon UID — /etc/hosts, ~/.ssh/config, anything outside the workspace — with no policy surface to deny it.
  • Operators can't grant a narrow read-carve-out to a path outside the workspace (e.g. "this agent may read ~/Documents/notes/** but nothing else").
  • Operators can't require human approval for writes to specific subtrees (e.g. "prompt before touching <workspace>/secrets/**").
  • Approval policy is binary at the tool level (requires_approval = true/false), not path-aware.

Proposal

A per-agent file_policy declarative tier system that compiles down to a path-evaluator the runtime calls at every read/write site.

Schema

[file_policy]
default = "deny" | "prompt" | "read" | "write"   # baseline tier when no path matches
deny_paths   = ["/etc/**", "**/.ssh/**"]          # always denied; highest precedence
prompt_paths = ["<workspace>/secrets/**"]         # requires human approval
read_paths   = ["~/Documents/notes/**"]           # read allowed without approval
write_paths  = ["<workspace>/**"]                 # read+write allowed without approval

Tiers are ordered Deny > Prompt > Read > Write. Most-specific glob wins within a tier; cross-tier the higher-restriction tier wins. <workspace> and ~ resolve at compile time.

Layering

A global [file_policy] block in config.toml provides the operator-default policy. Per-agent agent.toml may include a [file_policy] overlay that layers per-field over the global (non-empty replaces, omitted inherits, both-default returns None to preserve legacy behavior). Compiled once per agent at boot via KernelHandle::global_file_policy() + compile_effective_file_policy in agent_loop.

Gate sites

Every tool that reads or writes the filesystem routes through resolve_file_path(path, FileOp::{Read,Write}, tool_label):

  • MCP file tools: file_read, file_write, file_list, file_delete, file_move.
  • Shell vector: shell_exec runs a per-command path extractor (cat, cp, mv, rm, touch, sed -i, find -delete, etc.) and gates each touched path. Multi-target commands batch all Prompt-tier paths into a single approval; Deny short-circuits before any approval surfaces.
  • tool_apply_patch: enumerates AddFile, UpdateFile (incl. move_to destination), DeleteFile as Write ops; same scan-then-prompt batching as shell.
  • Media tools: tool_media_describe, tool_media_transcribe, tool_speech_to_text, tool_image_analyze — all gate via FileOp::Read before the engine availability check, so a deny path returns a policy error even if the engine is unconfigured (testable without a fake engine).

Prompt-tier hits surface through the existing approval surfacer with the path list as the action summary. Approved decisions are summarized with the resolved path and the raw input token, so operators can audit both what was matched and what the agent typed.

Failure-closed invariants

  • caller_agent_id = None with a kernel attached → policy gate fails closed (no fake "unknown" attribution on a security-relevant gate). Pinned by test.
  • Path resolution outside the workspace that can't be canonicalized → fall back to lexical normalize for policy.evaluate only; if Deny, return policy error before any disk touch.
  • default = "deny" with an empty workspace → all tool calls deny.

Out of scope (intentional, listed for triage)

  • Resolver asymmetry between MCP and shell vectors. resolve_with_policy canonicalizes; resolve_shell_path is lexical and POSIX-corrects ... Same path string can succeed via shell and fail via MCP. Recommended fix: align both on the lexical resolver as canon.
  • tool_image_generate writes to /tmp/openfang_uploads/ ungated. UUID filenames, no caller-controlled bytes — low impact, but a default = "deny" policy is silently violated. Likely correct fix: document as kernel-managed cache rather than route through policy.
  • Shell extractor command-table gaps. Missing: xargs, awk -i inplace, chmod, chown, truncate. Under exec_policy = Allowlist an agent can escape file_policy on these specific commands via the shell vector. Mechanical extension; ~one regression test per command.
  • canonicalize_for_policy error precedence. apply_patch AddFile to a non-existent parent surfaces "Failed to resolve parent directory" instead of a clean policy-error message. UX/debugging clarity issue.
  • UpdateFile.move_to source classified as Write only. A read_paths carve-out on the source dir produces a Write-deny when an agent moves a file out. Functionally correct, surprising. Doc-comment fix.
  • No end-to-end manifest+global layering test through a real tool. Helper unit tests pin shape; nothing pins the call sites in run_agent_loop / run_agent_loop_streaming end-to-end. A future refactor dropping one would silently revert the global-policy wiring.
  • Symlink following. Lexical resolver does not unmask symlinks. A symlink at <workspace>/secrets → /etc/private/... evaluates as <workspace>/secrets. Decision deferred until a policy_explain UI exists to make resolved paths visible to operators.
  • Approval-decision dedup. v1 deliberately accepts double-prompt under approval_policy.requires_approval = true AND a prompt_paths hit. The combined-gate test pins the count at 2 so a future dedup landing flips the assertion to 1.
  • tool_text_to_speech and tool_canvas_present write to <workspace>/output/ without consulting file_policy. Same class as tool_image_generate — system-generated filenames, no user-controlled bypass, but a read_paths carve-out covering the output dir is silently overridden.

Test plan

  • Schema: parse + compile unit tests covering tier precedence, glob matching, <workspace> and ~ expansion, layered overlay correctness (4 tests for global-applies / overlay-replaces-per-field / both-default-returns-None / missing-workspace).
  • Evaluator: per-tier verdict tests; deny-precedence; most-specific-glob wins.
  • Per-tool gate sites: deny-blocks-before-engine-check (4 media tools), deny-blocks-before-disk-touch (apply_patch), deny-short-circuits-batched-approval (apply_patch, shell), read_paths_inside_workspace_blocks_writes, double-prompt-pin under combined gates, fail-closed-when-agent-id-missing.
  • Shell path extractor: per-command tests including the is_flag_rejects_double_dash_structurally regression.
  • 1021/1021 runtime lib tests pass at HEAD of the implementation branch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-designNeeds architecture discussion

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions