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.
Problem
The runtime today has two file-access gates: the workspace-root lock (writes must stay under
<workspace>) andvalidate_path(..-rejection). Both are coarse:/etc/hosts,~/.ssh/config, anything outside the workspace — with no policy surface to deny it.~/Documents/notes/**but nothing else").<workspace>/secrets/**").requires_approval = true/false), not path-aware.Proposal
A per-agent
file_policydeclarative tier system that compiles down to a path-evaluator the runtime calls at every read/write site.Schema
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 inconfig.tomlprovides the operator-default policy. Per-agentagent.tomlmay include a[file_policy]overlay that layers per-field over the global (non-empty replaces, omitted inherits, both-default returnsNoneto preserve legacy behavior). Compiled once per agent at boot viaKernelHandle::global_file_policy()+compile_effective_file_policyinagent_loop.Gate sites
Every tool that reads or writes the filesystem routes through
resolve_file_path(path, FileOp::{Read,Write}, tool_label):file_read,file_write,file_list,file_delete,file_move.shell_execruns a per-command path extractor (cat,cp,mv,rm,touch,sed -i,find -delete, etc.) and gates each touched path. Multi-target commands batch allPrompt-tier paths into a single approval;Denyshort-circuits before any approval surfaces.tool_apply_patch: enumeratesAddFile,UpdateFile(incl.move_todestination),DeleteFileas Write ops; same scan-then-prompt batching as shell.tool_media_describe,tool_media_transcribe,tool_speech_to_text,tool_image_analyze— all gate viaFileOp::Readbefore 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 = Nonewith a kernel attached → policy gate fails closed (no fake"unknown"attribution on a security-relevant gate). Pinned by test.policy.evaluateonly; 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)
resolve_with_policycanonicalizes;resolve_shell_pathis 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_generatewrites to/tmp/openfang_uploads/ungated. UUID filenames, no caller-controlled bytes — low impact, but adefault = "deny"policy is silently violated. Likely correct fix: document as kernel-managed cache rather than route through policy.xargs,awk -i inplace,chmod,chown,truncate. Underexec_policy = Allowlistan agent can escapefile_policyon these specific commands via the shell vector. Mechanical extension; ~one regression test per command.canonicalize_for_policyerror precedence.apply_patchAddFile to a non-existent parent surfaces"Failed to resolve parent directory"instead of a clean policy-error message. UX/debugging clarity issue.UpdateFile.move_tosource classified as Write only. Aread_pathscarve-out on the source dir produces a Write-deny when an agent moves a file out. Functionally correct, surprising. Doc-comment fix.run_agent_loop/run_agent_loop_streamingend-to-end. A future refactor dropping one would silently revert the global-policy wiring.<workspace>/secrets → /etc/private/...evaluates as<workspace>/secrets. Decision deferred until apolicy_explainUI exists to make resolved paths visible to operators.approval_policy.requires_approval = trueAND aprompt_pathshit. The combined-gate test pins the count at 2 so a future dedup landing flips the assertion to 1.tool_text_to_speechandtool_canvas_presentwrite to<workspace>/output/without consultingfile_policy. Same class astool_image_generate— system-generated filenames, no user-controlled bypass, but aread_pathscarve-out covering the output dir is silently overridden.Test plan
<workspace>and~expansion, layered overlay correctness (4 tests for global-applies / overlay-replaces-per-field / both-default-returns-None / missing-workspace).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.is_flag_rejects_double_dash_structurallyregression.