Skip to content

feat(runtime): per-agent file_policy with deny/prompt/read/write tiers across all read+write tool sites#1183

Closed
benhoverter wants to merge 27 commits into
RightNow-AI:mainfrom
benhoverter:feat/anai-40-file-policy
Closed

feat(runtime): per-agent file_policy with deny/prompt/read/write tiers across all read+write tool sites#1183
benhoverter wants to merge 27 commits into
RightNow-AI:mainfrom
benhoverter:feat/anai-40-file-policy

Conversation

@benhoverter
Copy link
Copy Markdown
Contributor

Closes #1181.

Stacks on #1182. This PR depends on the capability-gate / ApprovalManager push surface from #1182 (the Prompt-tier hits in this branch surface through that surfacer). Until #1182 lands, this PR will appear to include #1182's commits — review the file_policy delta only, or wait for #1182 to merge for a clean diff.

Summary

  • Adds a per-agent [file_policy] declarative tier system (deny / prompt / read / write) with glob-matched path lists, layered global ↔ per-agent overlay, compiled once per agent at boot.
  • Routes every read/write tool site through a single resolve_file_path(path, FileOp, tool_label) chokepoint: MCP file tools, shell_exec (via per-command path extractor), tool_apply_patch, and all four media tools (media_describe, media_transcribe, speech_to_text, image_analyze).
  • Prompt-tier hits surface through the ApprovalManager push surface added in feat(runtime,bridge,channels): MCP bridge for CC subprocesses + shell pre-gate uplift + approval push surface #1182. Multi-path commands batch all Prompt paths into a single approval; Deny short-circuits before any approval surfaces.
  • Fail-closed on missing caller identity — no fake "unknown" attribution on a security-relevant gate.

Schema

[file_policy]
default = "deny" | "prompt" | "read" | "write"
deny_paths   = ["/etc/**", "**/.ssh/**"]
prompt_paths = ["<workspace>/secrets/**"]
read_paths   = ["~/Documents/notes/**"]
write_paths  = ["<workspace>/**"]

Tier precedence: 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. Both run_agent_loop and run_agent_loop_streaming call sites.

Notable invariants

  • caller_agent_id = None with a kernel attached → policy gate fails closed (no fake "unknown"). 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.
  • Media tools gate via FileOp::Read before the engine availability check, so a deny path returns a policy error even when the engine is unconfigured (testable without constructing a fake engine).
  • 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.

Known gaps (deferred follow-ups)

These were surfaced in feature-level review and deferred. Documented in the issue (#1181) for triage:

  • Resolver asymmetry between MCP (canonicalize-based) and shell (lexical POSIX-correcting ..). Same path string can succeed via shell and fail via MCP. Recommended fix: align both on the lexical resolver.
  • tool_image_generate writes to /tmp/openfang_uploads/ ungated. UUID filenames; low impact, but a default = "deny" policy is silently violated. Likely correct fix: document as kernel-managed cache.
  • tool_text_to_speech / tool_canvas_present write to <workspace>/output/ ungated — same class as image_generate.
  • Shell extractor command-table gaps: xargs, awk -i inplace, chmod, chown, truncate. Mechanical extension.
  • canonicalize_for_policy error precedenceapply_patch AddFile to a non-existent parent surfaces "Failed to resolve parent directory" instead of a clean policy-error message.
  • UpdateFile.move_to source classified as Write only — surprising under a read_paths carve-out on the source dir. Doc-comment fix.
  • No end-to-end manifest+global layering test through a real tool — helper unit tests pin shape but the call sites in run_agent_loop / run_agent_loop_streaming aren't pinned end-to-end.
  • Symlink following — lexical resolver doesn't unmask symlinks. 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.

Test plan

  • cargo check --workspace clean.
  • cargo test -p openfang-runtime --lib1021/1021 passing (was 1010 pre-feature, +11 from cross-component fixups, +6 from media-tool fixups, etc.).
  • Schema + compile: tier precedence, glob matching, <workspace> and ~ expansion, layered overlay correctness (4 dedicated tests for global_applies / overlay_replaces_per_field / both_default_returns_None / missing_workspace).
  • Per-tool gate sites: read_paths_inside_workspace_blocks_writes, test_combined_gates_double_prompt_under_requires_approval, test_file_policy_prompt_fails_closed_when_agent_id_missing, test_apply_patch_deny_blocks_before_disk_touch, test_apply_patch_multi_path_prompt_batches_into_one_approval, test_apply_patch_deny_sibling_short_circuits_whole_patch, test_media_describe_deny_path_blocks_before_engine_check, test_media_transcribe_deny_path_blocks_before_engine_check, test_image_analyze_deny_path_blocks_before_read.
  • Shell path extractor: per-command tests including the is_flag_rejects_double_dash_structurally regression that caught a real bypass during step-4 review.

🤖 Generated with Claude Code

benhoverter and others added 27 commits May 4, 2026 17:28
Standalone crate exposing OpenFang's tool surface to MCP clients (primarily
Claude Code subprocesses) over stdio. Per architectural decision in ANAI-22:
not folded into openfang-runtime — keeps the protocol adapter out of the
kernel/compactor blast radius and the dep graph clean.

This commit is scaffolding only:
  * Cargo manifest with rmcp 1.x (server, transport-io, macros)
  * lib.rs: ToolDispatcher seam trait (runtime-implements, bridge-consumes,
    one-way dep), ToolDispatchError enum, Bridge struct wrapping an
    Optional<Arc<dyn ToolDispatcher>>, single stub `ping` tool
  * main.rs: stdio MCP server entrypoint, tracing -> stderr (stdout is the
    transport), no dispatcher attached
  * Workspace members updated

Identity is bound at Bridge construction time, not per-call — the security
invariant tracked by ANAI-31. Real tool surface mapping lands in ANAI-30.

cargo check -p openfang-mcp-bridge: clean.
cargo check --workspace: clean (pre-existing imap-proto future-incompat
warning unrelated).

Refs: ANAI-22, ANAI-29

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the daemon-side foundation for the MCP bridge per the ANAI-30 plan
(topology 1b: daemon → CC → bridge → unix socket → daemon dispatcher).

- New `protocol` module in openfang-mcp-bridge: Frame/Hello/HelloAck/
  CallRequest/CallResponse types with length-prefixed JSON framing
  (1 MiB cap, 4-byte BE length prefix). Gated by `ipc-codec` feature
  so type-only consumers can drop the tokio io traits.
- New `bridge_ipc` module in openfang-api: BridgeIpcServer binds
  <home_dir>/run/bridge.sock (0600), accept loop with graceful
  shutdown via Notify, per-connection Hello validation and CallRequest
  → CallResponse loop.
- run_daemon spawns the listener; failure is non-fatal (HTTP keeps
  serving; bridge just unavailable). Socket file removed on shutdown.

Step 1 stub: the dispatcher returns CallResult::Error
("not yet wired"). Step 2 replaces this with a call into
openfang_runtime::tool_runner::execute_tool, scoped to the four-tool
allowlist (file_read, file_list, agent_list, channel_send). Identity
binding + token-table auth land in ANAI-31.

Tests: 3 protocol roundtrip tests + 4 IPC handler tests
(handshake/dispatch end-to-end via tempfile socket, version mismatch
rejection, empty-token rejection).

Refs ANAI-30, ANAI-22.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the step-1 stub in `BridgeIpcServer` with a real call into
`openfang_runtime::tool_runner::execute_tool`, mirroring the argument
bundle used by the HTTP /mcp endpoint in routes.rs.

- Added ALLOWED_TOOLS allowlist: file_read, file_list, agent_list,
  channel_send. Rejection happens at the protocol layer (CallResult::Error)
  before any kernel touch.
- Added dispatch_call(): snapshots the skill registry, builds a
  KernelHandle from Arc<OpenFangKernel>, and invokes execute_tool.
- ToolResult mapped to CallResult::Ok { content, is_error }, preserving
  the Ok/Error distinction (Error = bridge couldn't dispatch; Ok with
  is_error = tool ran but returned an error).
- Identity stub: caller_agent_id taken at face value from
  CallRequest::agent_id. Real per-spawn token-bound identity lands in
  ANAI-31.

Test: ipc_handshake_and_allowlist_gate verifies wire shape end-to-end:
disallowed tool gets allowlist Error, allowed tool gets Ok response. Real
execute_tool integration tests come once the daemon spawns the bridge
for real (ANAI-31).
…l surface

Replaces the stub `ping` tool with the four ANAI-30 tools (file_read,
file_list, agent_list, channel_send) and wires the bridge binary to forward
each `tools/call` over the daemon IPC socket established in step 1.

Library (lib.rs):
- ToolDispatcher::call now returns DispatchOk { content, is_error }
  preserving the tool-error-vs-dispatch-error distinction across the seam
- built_in_tools() declares the four-tool slice; schemas mirror
  runtime::tool_runner::builtin_tool_definitions() (kept in lockstep)
- Bridge: manual ServerHandler impl (drops the #[tool_router] macro). Filters
  advertised tools by intersecting built_in_tools() with
  ToolDispatcher::allowed_tools(); double-checks before dispatch
- Bridge::new now requires a dispatcher (was Option<_>)

Binary (main.rs):
- Reads OPENFANG_BRIDGE_SOCKET / TOKEN / AGENT_ID env vars (last is stub for
  ANAI-30; ANAI-31 derives identity from token)
- Connects to daemon, performs Hello/HelloAck handshake, exits on rejection
- IpcDispatcher: bridge-side ToolDispatcher impl. Forwards each call via mpsc
  to an actor task that owns the stream; correlation-by-request_id with a
  PendingMap<u64, oneshot> so concurrent tools/call invocations don't
  serialize at the dispatcher layer
- Reader task drains pending oneshots with an error on connection close so
  in-flight calls don't hang; production path exits the process so CC
  notices and tears down (gated behind cfg(not(test)))

Tests:
- lib: built_in_tools_has_anai30_slice, permitted_tools_intersects_with_dispatcher_allowed
- main: ipc_dispatcher_round_trip_and_correlation — fake daemon listener,
  full handshake, two concurrent calls, verifies per-id correlation and the
  NotPermitted gate

Workspace check clean. Daemon-side bridge_ipc tests still pass (4/4).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
End-to-end topology now exists at the type level:
daemon → claude (per-prompt) → openfang-mcp-bridge → IPC → daemon

- Add `caller_agent_id: Option<String>` to CompletionRequest. Plumbed
  through all construction sites; agent_loop populates it with
  session.agent_id, everywhere else passes None.
- Daemon (`server.rs::run_daemon`): after BridgeIpcServer starts,
  publish OPENFANG_BRIDGE_SOCKET and OPENFANG_BRIDGE_BIN as process env
  for subprocess drivers to discover. Bridge bin defaults to a sibling
  of current_exe; operators can override with OPENFANG_BRIDGE_BIN. Both
  set with `unsafe` (edition 2024) but only during single-threaded
  daemon startup, before any subprocess spawns.
- BridgeIpcServer gains `socket_path()` accessor.
- ClaudeCodeDriver: per-spawn `try_build_bridge_mcp_config`. When
  caller_agent_id is set AND both discovery env vars are present,
  generate a UUID token, write `<home>/run/cc-mcp-<uuid>.json` (0600),
  and add `--mcp-config <path> --strict-mcp-config` to the claude args.
  RAII guard removes the file on drop so per-spawn token lifetime is
  bounded by the CC subprocess.
- apply_env_filter extended to strip OPENFANG_BRIDGE_* from CC's child
  env. Bridge gets these only via the explicit `env` map in the
  mcp-config — CC inheriting them would risk a stray bridge picking up
  the daemon socket without a fresh per-spawn token.
- Tests:
  - test_build_bridge_mcp_config_shape — verifies wire shape claude
    expects: mcpServers.openfang.{command,args,env} with exactly the
    three discovery vars in env (no extras to leak state).
  - test_apply_env_filter_strips_bridge_discovery_vars — confirms
    filter removes all four bridge vars from CC's child env.
  - test_bridge_mcp_config_drop_removes_file — RAII cleanup invariant.

Stub points still flagged: token validated as non-empty (ANAI-31
replaces with daemon-issued per-spawn token table); agent_id taken
in-band from CallRequest (ANAI-31 derives from token).

11 CC driver tests pass. bridge_ipc (4) and bridge crate (6) tests
unchanged. Workspace check clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…NABLED

Default-off kill switch so we can deploy the bridge code path without
inlining it into every CC invocation. When the gate is unset or not in
{1, true}, try_build_bridge_mcp_config returns None and CC is spawned
exactly as it was pre-step-4 — no --mcp-config, no temp file, no bridge
child. Validation flow: deploy with gate off (sanity), launchctl setenv
OPENFANG_BRIDGE_ENABLED 1, bounce daemon, observe; if anything regresses,
flip back to 0 and bounce for instant recovery.

Daemon still starts the IPC listener and publishes BRIDGE_SOCKET/BIN env
unconditionally — both are harmless without a bridge child connecting.
Pure additive switch; zero behavior change when off.

Test exercises the full truth table for bridge_enabled() (unset, truthy
variants, falsy/garbage variants) and confirms the gate suppresses
config generation regardless of other env. Single test owns the global
env var so no serial_test infra needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bridge IPC handshake works standalone (bridge binary connects + Hello/HelloAck
ok against the live socket), and the daemon-side `wired CC --mcp-config for
OpenFang bridge` debug line confirms the flag is being passed to claude. But
no `bridge IPC accepted connection` events ever fire — meaning claude is
launched with `--mcp-config` but isn't spawning the MCP server subprocess.

Without `--debug`, claude swallows MCP launch errors silently. And we drop
CC's stderr on success spawns, so any silent rejection is invisible.

Add (both spawn paths):
- `--debug` flag when bridge config is wired, so MCP errors print to stderr.
- Always log a 4 KB tail of CC stderr at info when bridge_wired, regardless
  of success/failure. Streaming path now drains stderr concurrently to avoid
  pipe deadlock under chatty --debug output.

Existing 12/12 claude_code unit tests still pass; workspace check clean.

Diagnostic only — once the cause is identified we'll pare back to bounded
on-demand logging.
- bridge_ipc: promote handshake/dispatch events to INFO and add an
  `accepted connection` log on accept. Operators can now observe the
  full bridge lifecycle from daemon stderr without crawling through
  ~/.claude/debug/<uuid>.txt.
- claude_code driver: gate --debug + the 4 KB CC-stderr-tail diagnostic
  behind a new OPENFANG_BRIDGE_DEBUG env var (off by default). With
  proper INFO logs daemon-side, the noisy --debug output and the
  per-spawn ~/.claude/debug/ files are no longer load-bearing.
- server: validate operator-supplied OPENFANG_BRIDGE_BIN path at boot
  and log the resolution outcome (override vs. probe). Catches deploy
  ordering bugs where the env points at a binary that doesn't exist.

Stderr is still drained concurrently in the streaming path — required
whenever --debug might be on, cheap when it isn't.
Relocate builtin_tool_definitions() from runtime::tool_runner to
openfang_types::tool::registry as the single source of truth. Bridge
now derives its advertised surface from the registry, filtered by a
substrate-level BRIDGE_DENY allowlist (currently empty).

CC sees the full kernel surface; per-agent gating remains agent.toml.
web_fetch and web_search are no longer carved out — treat CC as an
API model: tools route through OpenFang, not hidden channels.

- openfang-types::tool: tool.rs → tool/{mod,registry}.rs
- tool_runner re-exports builtin_tool_definitions for callsite stability
- openfang-mcp-bridge: adds openfang-types dep (types-only, runtime-free
  invariant preserved); built_in_tools() is now ~7 lines
- Tests: drift sentinel for BRIDGE_DENY, full-surface assertion,
  ANAI-32 canonical-nine sanity (8/8 passing)

Validated end-to-end against live daemon: file_list, file_read,
agent_list, memory_recall, web_fetch, file_write all round-trip.
Lift the pure-syntactic shell validators -- metacharacter denylist and exec_policy allowlist -- out of the per-tool match arm in execute_tool and run them BEFORE the approval gate. Without this, denied commands were sent for human approval, approved, and only then rejected by the metachar denylist inside the per-tool arm. That wasted operator attention on commands guaranteed to fail. Validators remain inside the shell_exec arm as defense-in-depth.

Scoped to shell_exec only -- not all is_shell_tool entries. process_start has a different input shape and its own validators. Widening the pre-gate is a separate change.

For the deferred path -- commands that clear the pre-gate but still need human approval -- to reach a human, add the missing push surface.

ApprovalManager. Tokio broadcast of Submitted / Resolved / TimedOut lifecycle events, plus a subscribe API. Lag-tolerant. Slow subscribers get RecvError::Lagged and resync via list_pending. Tracing now includes agent_id, tool_name, risk, and decided_by on every lifecycle line.

channel_bridge. Spawn an approval surfacer that consumes those events, resolves the agent bindings via the registry, and pushes a formatted prompt to the most-specific bound channel and channel_id. Submission prompts include short id, agent, tool, risk, action summary -- truncated -- and timeout, with /approve and /reject hints. Resolved and TimedOut events post a follow-up so the prompt is not left dangling.

Tests added on the approval side cover Submitted+TimedOut and Resolved event delivery, plus UTF-8-safe log truncation.

Validated live on the daemon -- tests A through F. Metachar denial is synchronous, no approval burned. Allowlist match on argv0 basename clears without prompting. Approval surfacer delivers prompts to the bound channel for commands that fall through to approval.
…cy (dependency: ApprovalManager + capability gate)
Introduces a unified [file_policy] block in OpenFangConfig with optional
per-agent override on AgentManifest. This is the foundation for gating
all filesystem access (MCP file tools, shell commands) through one
policy regardless of vector.

This commit lands the schema, serde wiring, and evaluator only — no
enforcement seams yet. MCP tools and the shell extractor follow in
subsequent commits.

- New crates/openfang-types/src/file_policy.rs:
  * FilePolicy (config) and CompiledFilePolicy (runtime)
  * DefaultTier { Deny, Prompt, ReadOnly }
  * FileOp { Read, Write }, FileDecision { Allow, Prompt, Deny }
  * Precedence: deny > prompt > write > read > workspace > default
  * Tilde expansion at compile time (dirs::home_dir)
  * Field-by-field merged_over for per-agent override
  * 12 unit tests covering precedence, tier behavior, glob errors,
    workspace boundary (substring-safe), tilde expansion
- Wired file_policy into OpenFangConfig (global) and AgentManifest
  (per-agent override).
- globset added to workspace deps and openfang-types.

Branch base: origin/main with feat/anai-32-capability-enforcement
merged in as the dependency carrier (ApprovalManager etc.).
Must-fix:
- Read-paths inside workspace no longer silently allow writes. When a path
  matches read_paths and op = Write, evaluator skips the workspace fallback
  and falls through to the default tier. Regression test added.

API tightening (cheaper before step 3 multiplies call sites):
- Workspace bound at compile time. FilePolicy::compile(workspace) returns
  CompiledFilePolicy. Evaluate signature is now (path, op), no per-call
  workspace footgun.
- default field is Option<DefaultTier>. None means inherit, fallback to
  Deny. Some(_) is an explicit override. Lets an overlay downgrade a base
  default (e.g. base = Prompt, overlay = Some(Deny)), previously impossible.
- merged_over(base, overlay) replaced with overlay.layered_over(&base) as
  an instance method so the type system catches argument flips.
- Tilde expansion. Forward-slash normalize on Windows, globset::escape on
  the home portion so a HOME containing glob meta does not become wildcards.
- Glob matching. Drop the os_str + lossy double is_match. globset::is_match
  takes &Path directly. The OsStr/lossy split could give wrong answers on
  non-UTF8 paths.

Nits:
- Split GlobBuildError into Pattern + Build variants. FilePolicyError gains
  a GlobSetBuild variant. No more empty-string pattern in errors.
- Replace bespoke path_starts_with with Path::starts_with (already
  component-wise, no allocs).
- non_exhaustive on FilePolicyError.

Tests:
- 15 passing (was 12). New: read_paths_inside_workspace_blocks_writes,
  write_beats_read_on_overlap, layered_over_can_downgrade_default.
- debug_assert path.is_absolute() inside evaluate to surface caller
  contract violations during dev builds.

Workspace cargo check clean across all 13 crates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step 3 of the unified [file_policy] rollout. Replaces the workspace
hard-lock in file_read / file_write / file_list with policy evaluation
when an agent ships a [file_policy] block.

- tool_runner::resolve_file_path now takes Option<&CompiledFilePolicy>
  and FileOp. With a policy: canonicalize first (so symlinks evaluate by
  target), reject `..` outright, then call CompiledFilePolicy::evaluate.
  Without a policy: legacy workspace_sandbox / naive validation. The
  legacy fallback preserves today's behavior for agents that haven't
  opted into [file_policy] yet.
- channel_send (local-file attachments) and speech_to_text route through
  the same seam — same paths must obey the same gate regardless of which
  tool reads them.
- agent_loop and bridge_ipc compile the per-agent FilePolicy against the
  workspace root once per call. Compile errors log and fall back to None
  (legacy lock) — fail closed against unsafe configs rather than open.
- HTTP /mcp surface in routes.rs passes None: that endpoint has no
  per-agent manifest binding to compile against.
- FileDecision::Prompt fails closed pre-step-5 with an error explaining
  the gap (per-path approval surfacing lands in step 5).

Fills in `file_policy: None` on six AgentManifest test constructors
(kernel/registry/heartbeat) that were missed when the field was added in
ea9896d — only surfaced now because step 3 made the workspace builds
cover those test files.

Doc fix: agent.rs::AgentManifest::file_policy comment referenced
FilePolicy::merged_over; renamed to layered_over in the fixup commit.

Tests: 4 new tool_runner cases (allow-outside, deny-on-read-only,
prompt-fails-closed, workspace-implicit-allow). 8/8 file-tool tests
pass; 961/961 openfang-runtime lib tests pass; cargo check clean.
Adds a small command->arg-extractor table that recognizes the common
file-touching shell commands (cat/rg/grep/head/tail/less/wc/ls/find for
reads; rm/mkdir/cp/mv/tee for writes) and pulls path arguments out of
parsed argv. Both tool_shell_exec and tool_process_start now run extracted
paths through CompiledFilePolicy::evaluate before spawn:

- Allow -> proceed
- Deny  -> reject with reason (op surfaced)
- Prompt -> soft-deny pre-step-5 (per-path approval surfacer not yet wired,
  matches the MCP file-tool degradation already in place)

Unknown commands fall through to exec_policy unchanged - file_policy does
not gate them in v1, per the brief.

Implementation notes:

- shell_path_extractor.rs is a pure module: argv in, (path, op) pairs out.
  No I/O, no globbing, no canonicalization. Caller anchors relative paths
  against the agent CWD (workspace) and lexically normalizes before
  evaluation.
- Gate is applied in both Allowlist and Full exec_policy modes -
  file_policy is orthogonal to exec_policy and must hold even when shell
  access is unrestricted.
- Shell metacharacters are rejected upstream, so redirect operators
  (>, >>, |, etc.) cannot reach the extractor and don't need handling.
- tool_process_start gets workspace_root + file_policy added to its
  signature, mirroring tool_shell_exec.

Tests: 17 extractor unit tests + 7 gate integration tests + lexical
normalize regression. All 71 tool_runner tests pass; workspace cargo
check clean across all 13 crates.
Must-fix:
- lexically_normalize no longer escapes root. Pre-fixup, /ws/sub/../../../etc
  collapsed to /../etc, which then string-bypassed deny_paths = ["/etc/**"]
  glob matching. Now /.. is a no-op (POSIX) and we land at /etc — which DOES
  match the deny rule. Regression tests at the normalizer and gate level.

Should-fix:
- Extractor table grown for week-one destructive commands: touch (write),
  sed -i / -i.bak / --in-place= (write — flag-aware), dd if=/of= (key=value
  parse), and find -delete / -fprint{,0,f} / -fls (find primaries that
  promote roots to Write or emit explicit Write sinks). find -delete was
  previously classified Read — false negative on a destructive op.
- gate_shell_argv_against_file_policy fails closed when a policy is
  configured but workspace_root is None and the path is relative. Pre-fixup
  this was a silent `continue`; a future caller forgetting to thread the
  anchor would have bypassed the gate.
- Gate takes a tool_label parameter; tool_process_start no longer monkey-
  patches errors via .replace("shell_exec", "process_start"). Brittle and
  could corrupt user-controlled strings containing "shell_exec".
- Bare `-` after `--` is now treated as a filename per POSIX (was skipped
  unconditionally as stdin/stdout).
- Deny / Prompt errors now surface raw token AND resolved path so the
  agent can see exactly what was extracted vs what was evaluated.

Nits:
- Module doc note: file_policy enforcement on the shell vector requires
  exec_policy.mode = "allowlist" to be meaningful. In Full mode, an agent
  can rename binaries to escape the basename-keyed extractor; the MCP
  file_* tools remain fully gated regardless.
- is_flag("--") now returns false structurally, not via short-circuit luck
  in the caller. Test pins the contract.

Tests: 28 extractor (was 17) + 11 gate-level integration (was 7) +
2 lexical-normalize. 75/75 tool_runner tests pass; workspace cargo check
clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Manager

A `FileDecision::Prompt` from `CompiledFilePolicy::evaluate` no longer
fails closed unconditionally — instead it surfaces a human approval
request via `KernelHandle::request_approval` (the same seam ANAI-32
landed for shell_exec). Approved → access proceeds; denied/timed-out →
tool returns Err with the decision shape.

Plumbed `kernel` + `caller_agent_id` into:
- `resolve_file_path` / `resolve_with_policy` (MCP file_read/write/list,
  channel_send file attachments, speech_to_text)
- `gate_shell_argv_against_file_policy` (shell_exec, process_start)

New helper `request_file_policy_approval` builds a summary including the
operation, resolved path, and policy reason so the approver has enough
context without inspecting raw input.

Fail-closed posture preserved: when no kernel handle is threaded
through (unit-test path, or a future caller forgets), Prompt still
errors with an explanatory message — never silently allow.

Tests: +5 (5/5 new pass; 80 tool_runner total, was 75).
- test_file_read_prompt_path_approved_proceeds
- test_file_write_prompt_path_denied_blocks
- test_shell_gate_prompt_path_approved_passes
- test_shell_gate_prompt_path_denied_blocks
- test_file_policy_deny_does_not_invoke_approver

Existing prompt-fails-closed tests renamed to *_prompt_path_no_kernel
to reflect the new no-kernel-handle semantics.

ApprovalStubKernel inside the test module records request_approval
calls and returns a canned decision — keeps tests free of the live
ApprovalManager + kernel wiring.

Workspace `cargo check --tests` clean. 1006/1006 openfang-runtime lib
tests pass.
Must-promoted: file_policy Prompt now fails closed when caller_agent_id is
None (was: log "unknown" attribution). Without an agent_id we'd be writing
fake-looking audit entries on a security-relevant gate; refuse instead.

Should-fix:
- Batch multi-path argv into a single approval call. `cp a b c` with three
  prompt-tier paths now costs one prompt, not three sequential ones. The
  shell gate scans all paths first, short-circuits on hard Deny *before*
  surfacing siblings, then issues one batched approval covering every
  Prompt item.
- Stub kernel ApprovalStubKernel: `last: Option` → `calls: Vec`, with new
  `requires_approval` toggle so step-5 tests can exercise the combined
  up-front + in-tool double-gate.
- Approval summary: drop redundant tool_label (already passed as the
  second arg to request_approval), add raw token alongside resolved
  path so the operator sees what the agent actually requested.
- Drop "risk classification" wording from helper doc — no classification
  exists.

Tests (+4, 84/84 in tool_runner module):
- test_shell_gate_batches_multipath_prompt_into_one_request — pins single
  approval call for two-path `cp` argv with a Prompt-tier `/var/log/**`.
- test_shell_gate_deny_short_circuits_before_prompt_batch — pins zero
  surfacer calls when a Deny sibling is present alongside a Prompt path.
- test_file_policy_prompt_fails_closed_when_agent_id_missing — pins the
  must-fix attribution refusal.
- test_combined_gates_double_prompt_under_requires_approval — pins v1
  behavior under both gates active (two prompts per user action). If
  dedup lands later, this test flips to assert one and gets retitled.

Existing approved-prompt test extended to assert raw token in summary
and absence of duplicated tool_label.

1010/1010 openfang-runtime lib tests pass.
Feature-level code review surfaced three real bypasses that the per-step
reviews could not have caught — they live between components.

Must-fix #1: KernelConfig.file_policy was dead code.
The agent_loop only compiled manifest.file_policy; the global
[file_policy] block in config.toml was documented in the schema but
never read. Added KernelHandle::global_file_policy() (default returns
FilePolicy::default(); OpenFangKernel overrides) and a new
compile_effective_file_policy helper in agent_loop that layers the
overlay over the global before compiling. Replaces the inline match at
both run_agent_loop and run_agent_loop_streaming call sites. The pure
layer_and_compile_file_policy split lets tests exercise overlay-
inheritance without needing a KernelHandle stub. 4 new tests pin: global
applies when overlay is None, overlay layers per-field over global
(non-empty replaces, omitted inherits), both-default returns None
(legacy behavior preserved), and missing workspace returns None.

Must-fix #2: tool_apply_patch was unhooked from file_policy.
The highest-fidelity write tool went straight to apply_patch::apply_patch
with no per-target evaluation. New gate_apply_patch_against_file_policy
enumerates AddFile, UpdateFile (incl. move_to destination), and
DeleteFile as Write ops, scans-then-prompts (deny short-circuits before
any approval surfaces, all Prompt-tier paths batch into one approval).
Mirrors the shell-gate two-pass design. New canonicalize_for_policy
helper extracted from resolve_with_policy so apply_patch and MCP file
tools share resolution logic. 4 new tests pin: deny path blocks before
disk touch, multi-path prompt batches into one approval, deny sibling
short-circuits the whole patch, and legacy no-policy path is unchanged.

Must-fix RightNow-AI#3: tool_media_describe / tool_media_transcribe bypassed file_policy.
Both called tokio::fs::read with only validate_path (..-rejection).
Sibling tool_speech_to_text was wired correctly in step 3; these two
were missed. Routed through resolve_file_path with FileOp::Read. Gate
fires *before* the media engine availability check so a deny path
returns a policy error even when the engine is missing — testable
without constructing a fake MediaEngine. 2 new tests.

Tests: 1020/1020 openfang-runtime lib tests pass (was 1010, +10).
- 4 new in agent_loop::tests for layered_over correctness.
- 4 new in tool_runner::tests for apply_patch gate.
- 2 new in tool_runner::tests for media_describe/transcribe gate.

Workspace cargo check clean.

Should-fix items RightNow-AI#4 (resolver asymmetry), RightNow-AI#5 (image_generate temp
writes), and RightNow-AI#6 (extractor command-table gaps) deferred to ANAI-40
follow-ups; documented in projects/openfang-fork/issues/anai-40-followups.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Review-7 must-fix #1: tool_image_analyze called tokio::fs::read with
no file_policy, no workspace_root, and no validate_path either — the
sibling of media_describe/media_transcribe (wired in the review-6
fixup), missed because it had no validate_path call to grep for. The
bypass was total: a deny_paths glob outside the workspace was silently
ignored, and an agent could read /etc/passwd even with deny_paths =
["/etc/**"] configured.

Threaded workspace_root, file_policy, kernel, and caller_agent_id
through tool_image_analyze and routed through resolve_file_path with
FileOp::Read and tool_label = "image_analyze". The gate fires before
the disk read so a deny path returns a policy error rather than a
read error. Existing JSON output schema preserved (path field still
echoes the user-provided raw input).

New test test_image_analyze_deny_path_blocks_before_read mirrors
test_media_describe_deny_path_blocks_before_engine_check: writes a
real PNG outside the workspace, applies a deny_paths glob covering
the outside dir, asserts the call returns a "denied by file_policy"
error and that the approval stub recorded zero calls (deny short-
circuits before the surfacer fires).

Tests: 1021/1021 openfang-runtime lib tests pass (was 1020, +1).

Should-fix items RightNow-AI#4 (canonicalize_for_policy error precedence), RightNow-AI#5
(UpdateFile.move_to source-Read semantics), and RightNow-AI#6 (no end-to-end
manifest+global layering test through a real tool), plus three nits,
deferred to ANAI-40 follow-ups; documented in
projects/openfang-fork/issues/anai-40-followups.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Apply rustfmt to shell_path_extractor.rs introduced by this branch's work. Upstream-drift files left untouched (separate concern).
Apply rustfmt to files introduced or modified by this branch. Upstream-drift files (kernel, agent_loop, channels, anthropic/openai drivers, host_functions, model_catalog, types message) intentionally left untouched as a separate concern.
Reflow the trailing paragraph in extract_sed's doc comment so
clippy doesn't read it as a continuation of the bullet list.
No behavior change.
Both bridge-MCP-config wiring sites in the Claude Code driver were
using .map(|cfg| { side-effects; cfg }) on Option<NamedTempFile>,
which clippy flags as manual_inspect. .inspect() expresses intent
directly. No behavior change.
Brings in fmt + clippy fixes from anai-32:
- b023dc1 chore: cargo fmt --all (ANAI-32 territory)
- f1eb38d chore(clippy): replace map(|cfg| {...; cfg}) with inspect
The MCP bridge IPC is unix-domain-socket-only by construction (daemon
listens on a unix socket; bridge subprocess connects to it). The bridge
crate and the daemon-side `bridge_ipc` module unconditionally imported
`tokio::net::{UnixStream, UnixListener}`, which broke Windows CI with
E0432 unresolved-import errors in `openfang-mcp-bridge::main` and
`openfang-api::bridge_ipc`.

Gates:
- `openfang-mcp-bridge::main` — entire body cfg-gated to `unix`; on
  non-unix the binary is a no-op stub that prints a clear message and
  exits non-zero. Tests gated `cfg(all(test, unix))`.
- `openfang-api::lib` — `pub mod bridge_ipc` gated to `unix`.
- `openfang-api::server::run_daemon` — `BridgeIpcServer::start` call
  gated to `unix`; non-unix logs a single info line and proceeds without
  bridge IPC. The CC driver's existing missing-socket fallthrough means
  CC subprocesses spawn without `--mcp-config` on Windows, matching the
  bridge-disabled path.

No behavioral change on Linux/macOS. Windows users get a daemon that
boots without bridge support; MCP-routed tools are unavailable until a
Windows-native transport (named pipes / TCP loopback) lands as a
follow-up.

Verified: cargo check --workspace, cargo check --workspace --tests,
cargo test -p openfang-mcp-bridge -p openfang-api --lib, cargo fmt
--check, and cargo clippy all clean on macOS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brings in the Windows cfg-gate for the MCP bridge (cbb1105).
@benhoverter
Copy link
Copy Markdown
Contributor Author

Superseded by ANAI-45 — closing unmerged.

This PR is being closed in favor of ANAI-45 (trust pipeline unification). The four-tier path schema here (read_paths / write_paths / prompt_paths / deny_paths) is being replaced, not extended, by a per-op rules schema:

[file_policy.defaults]
read   = "prompt"
write  = "deny"
delete = "deny"

[file_policy.read]
silent = ["~/Documents/GitHub/Repos/**"]
deny   = ["~/.ssh/**", "**/.env"]

[file_policy.write]
silent = ["~/Documents/GitHub/Repos/openfang/**"]
prompt = ["~/Documents/GitHub/Repos/**"]
deny   = ["~/.ssh/**", "**/.env"]

Three ops (read/write/delete), three tiers per op (silent/prompt/deny), most-specific-glob wins with strictest-tier on tie. The blanket-tier UX failure that motivated this rework — no per-op granularity per path — is the core thing fixed.

ANAI-45 also routes shell_exec through file_policy on extracted path arguments, closing the gap where cat <path> was authorized by exec_policy alone with no path awareness. That gap is the primary reason this needs to be one coherent design rather than file_policy and exec_policy as separate landings.

Zero reviews here and zero installed users make a clean replacement the simpler move — no legacy-tier desugaring to write, no two-release deprecation window.

Branch feat/anai-40-file-policy will be deleted after close. The ANAI-45 PR will reference this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant