RFD 076: Tool Access Grants
- Status: Accepted
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-04-14
- Required by: RFD 075, RFD 078
Summary
This RFD adds a typed access policy to tool configuration that declares what resources a tool can access. The policy flows to tools as part of their runtime context. Tools self-enforce the policy by checking grants before performing operations. OS-level enforcement is tackled separately in RFD 075.
Motivation
Individual tools implement ad-hoc safeguards today — unix_utils applies a sandbox-exec profile on macOS and runs with clean_env: true; the fs_* tools join user-supplied paths against the workspace root. These protections are local to each tool and are not expressible in configuration. There is no typed, declarative access policy in the host/tool contract that a user's tool config can use to say "this tool may only write to .config/jp/tools." The only configurable safeguard is RunMode::Ask, which controls whether a tool runs — not what it can do while running.
This creates two problems:
Tool config can't scope access. A tool-writing config that enables
fs_create_filewants to grant write access to.config/jp/toolsand read access to the rest of the workspace. Today there is no way to express this. The tool gets full access to everything.Tools can't provide good error messages for out-of-scope operations. When a future OS-level sandbox denies a syscall, the tool gets a raw permission error with no context. If the tool knows its access policy, it can reject the operation early with a helpful message that names the configured grants and suggests how to fix the config.
This RFD solves the first problem and lays the groundwork for the second. It establishes the access policy types and configuration surface that RFD 075 consumes for OS-level enforcement. Getting the policy semantics right here is a prerequisite for 075 — the two layers must agree on what each rule means.
Design
Configuration
A new access field on tool configuration declares resource grants. It is accepted in two scopes: on an individual tool, and on the [conversation.tools.'*'] defaults block (see Scope inheritance). Three resource types are supported: filesystem (fs), network (net), and environment variables (env).
# tool config (e.g. in .jp/config.toml)
[conversation.tools.fs_create_file]
enable = true
run = "unattended"
[[conversation.tools.fs_create_file.access.fs]]
path = "."
read = true
[[conversation.tools.fs_create_file.access.fs]]
path = ".config/jp/tools"
read = true
write = true
[[conversation.tools.fs_modify_file.access.fs]]
path = "."
read = true
[[conversation.tools.fs_modify_file.access.fs]]
path = ".config/jp/tools"
read = true
write = trueWhen no access field is present, the tool has unrestricted workspace access across all resource types (fs, net, env). This preserves backward compatibility — existing tools and configs work without changes.
Default-deny applies per resource type. Declaring at least one rule in access.fs shifts filesystem access to default-deny; access.net and access.env remain unrestricted until they, too, have at least one rule. This avoids the footgun where adding a single network grant silently denies filesystem access. A resource type is considered "declared" when its list contains at least one rule after merging; absent or empty lists mean "unrestricted" for that type.
Config validation rejects access on tools whose finalized (post-merge) source is builtin or mcp. source is a required field, so a ToolConfig with access and no source is already rejected at load — this check runs after all layers are merged so that a layer that only adds access (without restating source) is allowed, and the builtin/mcp rejection observes the effective source. access is designed for the local subprocess contract: the policy is serialized into the Context JSON that JP passes to tool binaries, and those binaries check it before acting. Builtin tools are in-process Rust code with their own configuration semantics, and MCP tools run on external servers JP does not control. Neither participates in the access surface of this RFD. Silently accepting access on those sources would create false confidence in a security-relevant field, so it is a hard error at config load. If host-side semantics for builtin or MCP tools are added later (e.g., JP-side enforcement for builtins, MCP argument proxying), a follow-up RFD will define them. This rejection covers access declared on a named tool; for grants that reach a tool through the * defaults, see Declared versus inherited grants.
Cross-layer merging
access.fs, access.net, and access.env are each a MergeableVec (from the jp_config types module). Default merge strategy is append: rules from later config layers are added to the pool defined by earlier layers, and longest-prefix-match resolves which rule applies to a given target.
# layer A (e.g. project default)
[[conversation.tools.fs_create_file.access.fs]]
path = "."
read = true
# layer B (e.g. user override)
[[conversation.tools.fs_create_file.access.fs]]
path = ".config/jp/tools"
read = true
write = trueResult: two fs rules, read on . and read+write on .config/jp/tools.
To replace rather than append, a config layer uses the Merged form with an explicit strategy:
[conversation.tools.fs_create_file.access.fs]
strategy = "replace"
value = [
{ path = ".config/jp/tools", read = true, write = true },
]This applies the standard jp_config merge primitives uniformly — users already familiar with MergeableVec semantics elsewhere (attachments, instructions, sections) don't learn new rules for access.
Scope inheritance
access is declared in one of two scopes:
- Per-tool —
[conversation.tools.<name>.access]. - Defaults —
[conversation.tools.'*'.access], which applies to every local tool that declares noaccessof its own.
Scope resolution is replace, not field-by-field fill: a tool that declares any access block ignores the * block entirely. Reading a tool's access block therefore tells the whole truth about what that tool may do.
The rejected alternative is appending the * rules to the tool's own. That leaves a grant in force which is not visible at the place the reader is looking, and it makes narrowing impossible: with append, the only way for a tool to take away something * granted is to add a longer-prefix rule with the capabilities set false and rely on specificity resolution to shadow the broader grant. That is denial-by-prefix-arithmetic, the same class of subtlety Inheritance-based evaluation is rejected for. The cost of replace is repetition — a tool that wants the defaults plus one more rule restates the defaults — and that is the intended trade. A copied rule is auditable at the tool; an inherited one is not.
The two axes are independent:
| Axis | Rule |
|---|---|
Between scopes (* vs per-tool) | Replace — the most specific non-empty scope wins whole |
| Between layers within one scope | Append (see Cross-layer merging) |
So * and each tool are separate rule pools, each accumulated across config layers, and the tool's policy is its own pool when non-empty, otherwise the * pool. strategy = "replace" still resets a pool from within a layer.
The unit of replacement is the whole access block, not one resource type. A scope counts as non-empty when any of fs, net, or env holds a rule, and the block that wins carries all three lists. So a tool declaring only access.fs rules also drops the * block's access.env rules, and reverts to unrestricted environment access unless it restates them. This follows from the same reason replace beats append — a tool's block is read as the complete account of what that tool may do, and per-resource-type inheritance would make two of the three lists invisible at the tool. It is the sharpest edge of the design: check every resource type when you give a tool rules of its own, not just the one you came to change.
One consequence is worth stating plainly: a single rule under [conversation.tools.'*'.access] switches every local tool without its own block to default-deny at once. That is what the scope is for, but the blast radius is wide — a * block granting read on . and nothing else revokes every such tool's write access.
Declared versus inherited grants
The builtin/mcp rejection above applies to access declared on a named tool. Access reaching a tool through the * scope is skipped for builtin and MCP tools, not rejected.
The rejection exists because a user who writes [conversation.tools.some_mcp_tool.access] has named that tool, and would otherwise believe the grant applies to it. That reasoning does not transfer to *, which describes "the tools that consume access grants" — it makes no claim about any individual tool, so there is no false confidence to protect against. Without the distinction the * scope would be unusable: any workspace with a single MCP server configured would fail to load as soon as *.access was set.
The same rule extends to any future scope broader than one tool (tool groups, per RFDs 055–057): grants written at a scope the user did not name tool-by-tool apply to the local tools in that scope and pass over the rest.
Rule evaluation
Each resource type defines its own specificity metric (see per-type sections below). When multiple rules match a target, the most specific rule wins — its capabilities apply in full, without inheritance from less specific rules. When multiple rules share the same specificity, the last rule in the vector wins. MergeableVec's default append semantics mean later config layers append to earlier ones, so the most recently declared rule takes precedence on ties.
Filesystem rules
Each filesystem rule grants capabilities at a path prefix relative to the workspace root. The path "." matches the entire workspace. Rule paths are literal: glob characters like * and ? have no special meaning and are treated as literal path segments. Matching is component-aware, not byte-based — a rule path = "src" matches src/lib.rs but not src_generated/foo.rs.
[[access.fs]]
path = "."
read = true
[[access.fs]]
path = ".config/jp/tools"
read = true
write = true
[[access.fs]]
path = ".env"
# all capabilities default to false — denies all access to .envCapabilities
Five atomic capabilities and one alias:
| Field | Description |
|---|---|
read | Read file contents, list directory entries |
create | Create new files and directories |
update | Modify existing files (content changes, renames) |
delete | Remove files and directories |
execute | Execute files as programs |
write | Alias for create + update + delete |
The write alias sets the default for create, update, and delete. Explicit atomic values override the alias:
# Full write access
write = true
# → create=true, update=true, delete=true
# Write without delete
write = true
delete = false
# → create=true, update=true, delete=false
# Create only, no update or delete
create = true
# → create=true, update=false, delete=falseAll capabilities default to false. The write field is settable in config and on the struct (via deserialization), but there is no write() accessor on FsRule — consumers read the atomic capabilities via create(), update(), and delete(), which apply the alias expansion.
How tools map to capabilities
| Tool | Checks |
|---|---|
fs_read_file | read on target |
fs_list_files | read on listed directories |
fs_grep_files | read on searched paths |
fs_create_file | create (new) or update (exists) |
fs_modify_file | update on target(s) |
fs_delete_file | delete on target |
fs_move_file | delete on source; create (new) or update |
| (existing) on target |
A directory target widens these checks to the whole subtree, and the parent directory that fs_delete_file and fs_move_file clean up is checked on its own. Both are covered by Operations that reach a subtree.
Evaluation: longest prefix match
When multiple rules match a target path, the most specific rule wins. Specificity is determined by path component count — more components means more specific. The winning rule applies in full; capabilities are not inherited from less specific rules.
# Rule A: workspace root (0 components after normalization)
path = "."
read = true
write = true
# Rule B: src directory (1 component)
path = "src"
read = true
# Rule C: generated code (2 components)
path = "src/generated"
read = true
write = trueREADME.md→ matches Rule A → read + writesrc/lib.rs→ matches Rule B → read onlysrc/generated/schema.rs→ matches Rule C → read + writetests/main.rs→ matches Rule A → read + write
If no rule matches a target path, all capabilities are denied (default-deny). See Rule evaluation for tie-breaking.
Rules are self-contained — each rule is readable in isolation. The cost is some repetition (Rule B must re-state read = true), but this avoids subtle bugs from implicit inheritance where a less specific rule silently grants capabilities that a more specific rule intended to restrict.
Operations that reach a subtree
Longest-prefix match answers a question about one path. Some operations act on more than the path they name: renaming or removing a directory takes everything beneath it, so a grant on the directory alone would step straight past a deeper rule that closes part of the tree.
For those, evaluation asks the same question of the subtree. The capability is permitted only when the winning rule at the target grants it and no rule below the target denies it. Each deeper rule is re-evaluated at its own path rather than read on its own, so a rule a later config layer has overridden does not deny.
[[access.fs]]
path = "."
read = true
write = true
[[access.fs]]
path = "docs/ticket"
read = true
write = falsedocs/notes.md→deletegranted: the root rule wins, and nothing below the target denies.docs/ticket/T-02wt0kx-fix-the-header.md→deletedenied: thedocs/ticketrule wins.docs, as a directory →deletedenied: the root rule grants it, butdocs/ticketdenies below the target and removingdocswould take that tree with it.
The subtree question applies to create, update, and delete on a directory target. read on a directory lists that directory and discloses nothing beneath it, and execute names one file, so both stay single-path questions. A target that does not exist yet is not a directory, and is asked about as the single entry it is.
Two consequences for tools:
- A move's destination is shaped by its source. Moving a directory onto a path that does not exist yet lands every path under the source below the destination. The destination is therefore a subtree question whenever the source is a directory, whatever sits at the destination.
- A path the call never named still needs a grant.
fs_delete_fileandfs_move_fileremove the source's parent directory when the operation leaves it empty. That is a second deletion, of a directory the caller did not mention, and a policy may grantdeleteon the files in a tree while refusing the tree itself. Without a grant the directory stays and the call still reports the deletion it was asked for.
The distinction is between what a rule says and what an operation reaches. Rules stay non-inheriting and readable in isolation; the reach of the operation decides how many of them are consulted.
Rule path canonicalization
Rule paths themselves are canonicalized at policy compilation time using the same algorithm as Path evaluation below. Before the merged AccessConfig is converted to a jp_tool::AccessPolicy (and before it crosses the wire to the tool), each rule's path is joined with ctx.root, normalized, resolved through symlinks on each existing ancestor, and stripped back to workspace-relative form. Rules rejected during canonicalization (workspace escape, absolute path outside ctx.root, symlink target outside workspace) fail config load with a clear error naming the offending rule.
This ensures a rule path = "src" still matches target paths when src/ is a symlink to another directory inside the workspace — both rule and target are reduced to the same canonical form before comparison. It also means the cooperative layer and the OS sandbox (RFD 075) agree on the resolved paths they enforce against.
Path evaluation
Filesystem rule evaluation operates on a canonical, workspace-relative form of the target path. Raw user input is never evaluated directly. For any target path input, an implementation must:
- If
inputis relative, join it withctx.root. If it is absolute and not underctx.root, reject as out-of-workspace. - Normalize the joined path, removing redundant separators and resolving
..segments lexically. If normalization escapesctx.root, reject as workspace-escape. - Resolve symlinks on the path and on each existing ancestor. If the resolved path is outside
ctx.root, reject as workspace-escape. When the target does not yet exist (e.g.,createonnew/dir/file.txtwherenew/is also missing), resolve the nearest existing ancestor, then append the remaining relative suffix. The missing components are not resolved — they cannot contain symlinks that haven't been created yet. - Strip the
ctx.rootprefix to produce the workspace-relative canonical form. - Evaluate against
AccessPolicyusing longest-prefix match on that form, extended to the subtree for an operation that reaches one (see Operations that reach a subtree).
The action field on Context (Run vs FormatArguments) does not gate these checks at the host layer. Whether and when a tool calls check_* is the tool's choice — FormatArguments implementations typically return early before any I/O and therefore never reach a check. OS-level enforcement via RFD 075 always applies regardless of action, so the tool is treated as a potentially hostile black box: cooperative checks improve error quality but are not the security boundary.
This canonical form is the authoritative shape of a filesystem rule. RFD 075 generates OS-level enforcement (sandbox-exec profiles, Landlock rulesets) against resolved absolute paths — the same paths step 3 produces before stripping the workspace prefix. The cooperative layer (this RFD) and the OS layer see the same rule mean the same thing, which includes the subtree reach: a sandbox that grants a directory whose subtree carries a deny would permit through the kernel exactly what the cooperative check refuses.
JP's in-tree Rust tools link against the jp_tool crate, which provides Context::check_read and friends (see Runtime types) as a reference implementation of the steps above. Tools written in other languages receive Context as a JSON object and must implement the same algorithm, or rely on RFD 075's OS-level enforcement. Diverging from the canonical form means the tool's self-check disagrees with the OS sandbox — which is why the steps here are normative, not a convenience.
Network rules
Network rules match against parsed URIs, not raw strings. Each rule specifies a host (required), with optional scheme, port, and path prefix:
[[access.net]]
host = "api.github.com"
allow = true
[[access.net]]
host = "api.github.com"
path_prefix = "/admin"
allow = falseBoth rule and target hosts are normalized identically before matching: parsed via url::Host, converted to ASCII (Punycode) form, then lowercased. This means host = "münchen.de" in config matches target URIs whether they arrive in Unicode or Punycode form. Rule host values that fail to parse as a host are rejected at config load.
Evaluation then matches:
scheme: exact match if specified; any scheme if absent.host: exact match, case-insensitive, after normalization. No string prefix matching —api.github.comdoes not matchapi.github.com.evil.com.port: exact match if specified; default-port-for-scheme if absent.path_prefix: segment-aware prefix match if specified; any path if absent./adminmatches/admin/usersbut not/administration.
Examples with the rules above:
https://api.github.com/repos→ matches first rule → allowedhttps://api.github.com/admin/users→ matches second rule → deniedhttps://api.github.com.evil.com/→ no host match → deniedhttps://example.com→ no match → denied
Specificity for longest match is the sum of matched-component counts: presence of scheme + presence of port + path segment count. host is required and therefore constant across matching rules, so it does not contribute to tie-breaking. When two rules match a target, the more specific wins; ties resolve per Rule evaluation.
Raw string prefix matching on URIs would accept api.github.com.evil.com as a match for api.github.com and fail similarly on userinfo smuggling and port variants. Structured matching is the only defensible primitive here, and it must be defined at the policy layer so RFD 075's OS-level enforcement consumes the same model without re-interpreting string rules.
Environment variable rules
Environment variable rules match variable names either exactly or as a prefix, distinguished by a trailing * in the name field. A name without * is an exact match; name = "AWS_*" is a prefix match where * is a sentinel (not part of the matched prefix).
[[access.env]]
name = "GITHUB_TOKEN"
read = true
[[access.env]]
name = "AWS_*"
read = true
[[access.env]]
name = "AWS_SECRET_ACCESS_KEY"
read = falseGITHUB_TOKEN→ exact match on first rule → allowedGITHUB_TOKEN_LOG→ no match (first rule is exact, not prefix) → deniedAWS_REGION→ matchesAWS_*prefix → allowedAWS_SECRET_ACCESS_KEY→ matches exact deny → deniedHOME→ no match → denied
Choosing explicit * over a trailing-underscore convention makes exact-vs-prefix a syntactic distinction rather than a convention. A rule name = "AWS_TOKEN" unambiguously matches only AWS_TOKEN, never AWS_TOKEN_LOG, regardless of the target variable's naming style.
Specificity is the byte length of the literal portion of name (excluding the trailing * if present). On ties at the same literal length, exact rules beat prefix rules — exact AWS_TOKEN (9 bytes, exact) beats AWS_TOKEN* (9 bytes, prefix) on variable AWS_TOKEN. Component count is not used; environment variable names have no universal separator.
A literal * cannot appear anywhere in a name value except as the trailing sentinel. This keeps the matching algorithm simple and is not a practical restriction: POSIX env var names cannot contain *.
Matching is case-sensitive, unlike host matching for net rules. A rule set means the same thing wherever it is read, which matters because config is routinely shared across machines through a checked-in .jp/config.toml.
This does not by itself preserve a deny on a platform whose OS resolves variable names case-insensitively. Given * granting reads and GITHUB_TOKEN denying them, a Windows request for github_token misses the exact deny, selects the wildcard grant, and then resolves to the same OS variable; on Unix the same request usually finds no variable at all. Canonicalizing the requested name alone does not close this — an exact rule written in another case still misses.
NOTE
Open question. The case-insensitive-platform rule needs a deny-preserving definition before the first env consumer ships. Candidates are canonicalizing requests and rule names to one form, or matching denies case-insensitively while keeping grants exact. Neither is settled, and the choice belongs with the consumer that first needs it.
Runtime types
The access policy types live in jp_tool, which defines the wire format between the host (JP) and tool binaries. Context is serialized to JSON and passed to each tool invocation. The types here are the finalized policy — merging across config layers happens host-side (see Cross-layer merging) before serialization, so the tool sees a resolved Vec<FsRule>, not a MergeableVec. MergeableVec lives in jp_config and is never exposed on the wire; this keeps jp_tool free of config-layer dependencies.
A new access field carries the policy:
/// Contextual information available to a tool.
pub struct Context {
pub root: Utf8PathBuf,
pub action: Action,
/// Access grants for this tool invocation.
///
/// When `None`, the tool has unrestricted workspace access (backward
/// compatibility). When `Some`, only explicitly granted capabilities
/// are available — unmatched paths/resources are denied.
#[serde(default)]
pub access: Option<AccessPolicy>,
}
pub struct AccessPolicy {
#[serde(default)]
pub fs: Vec<FsRule>,
#[serde(default)]
pub net: Vec<NetRule>,
#[serde(default)]
pub env: Vec<EnvRule>,
}
pub struct NetRule {
pub host: String,
#[serde(default)]
pub scheme: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub path_prefix: Option<String>,
#[serde(default)]
pub allow: bool,
}
pub struct EnvRule {
pub name: String,
#[serde(default)]
pub read: bool,
}FsRule uses private fields with accessor methods to encapsulate the write alias expansion:
pub struct FsRule {
path: Utf8PathBuf,
#[serde(default)]
read: Option<bool>,
#[serde(default)]
write: Option<bool>,
#[serde(default)]
create: Option<bool>,
#[serde(default)]
update: Option<bool>,
#[serde(default)]
delete: Option<bool>,
#[serde(default)]
execute: Option<bool>,
}
impl FsRule {
pub fn path(&self) -> &Utf8Path { &self.path }
pub fn read(&self) -> bool { self.read.unwrap_or(false) }
pub fn create(&self) -> bool {
self.create.unwrap_or_else(|| self.write.unwrap_or(false))
}
pub fn update(&self) -> bool {
self.update.unwrap_or_else(|| self.write.unwrap_or(false))
}
pub fn delete(&self) -> bool {
self.delete.unwrap_or_else(|| self.write.unwrap_or(false))
}
pub fn execute(&self) -> bool { self.execute.unwrap_or(false) }
}AccessPolicy provides low-level evaluation on already-canonicalized paths:
#[derive(Debug, thiserror::Error)]
pub enum FsAccessError {
/// Target is absolute and not under the workspace root.
#[error("path is outside the workspace: {0}")]
Outside(Utf8PathBuf),
/// Target escapes the workspace via `..` or a symlink.
#[error("path escapes the workspace: {0}")]
Escape(Utf8PathBuf),
/// No rule grants the requested capability.
#[error("access denied: {capability} on {target}")]
Denied {
capability: &'static str,
target: Utf8PathBuf,
grants: Vec<Utf8PathBuf>,
},
}
impl AccessPolicy {
/// Evaluate a capability on a canonicalized, workspace-relative path.
/// Callers must canonicalize first; see `Context::check_*`.
pub fn permits(&self, capability: Capability, canonical: &Utf8Path) -> bool { /* ... */ }
/// The same question asked of the path and everything beneath it.
pub fn permits_subtree(&self, capability: Capability, canonical: &Utf8Path) -> bool { /* ... */ }
/// The decision every consumer goes through: a mutating capability on a
/// directory is answered for the subtree, everything else for the path.
pub fn permits_entry(
&self,
capability: Capability,
canonical: &Utf8Path,
is_dir: bool,
) -> bool { /* ... */ }
}FsAccessError is scoped to filesystem checks. When net and env cooperative consumers land, they define their own error types (NetAccessError, EnvAccessError) with shape-appropriate context — host/port for net, variable name for env. There is no unified AccessError union; each resource type carries the fields that make sense for it.
For tools written in Rust that link jp_tool as a library, Context exposes a reference implementation of the Path evaluation steps. Each method canonicalizes input, then delegates to AccessPolicy. On success it returns the resolved absolute path so the caller can perform the operation without re-resolving. When self.access is None (unrestricted), canonicalization still runs and workspace-escape is still rejected — only the capability check is skipped.
impl Context {
pub fn check_read(&self, input: &Utf8Path)
-> Result<Utf8PathBuf, FsAccessError>;
pub fn check_create(&self, input: &Utf8Path)
-> Result<Utf8PathBuf, FsAccessError>;
pub fn check_update(&self, input: &Utf8Path)
-> Result<Utf8PathBuf, FsAccessError>;
pub fn check_delete(&self, input: &Utf8Path)
-> Result<Utf8PathBuf, FsAccessError>;
pub fn check_execute(&self, input: &Utf8Path)
-> Result<Utf8PathBuf, FsAccessError>;
}FsAccessError::Denied carries the configured grants so callers can produce helpful error messages that name what the tool is allowed to do, not just what it was denied.
Tools written in other languages deserialize Context from JSON and must implement the same algorithm to participate in cooperative enforcement. They receive access as a JSON object matching the shape of AccessPolicy. The path-evaluation steps are language-neutral; only the Rust convenience methods are specific to jp_tool.
Config-layer types
jp_config defines partial/mergeable counterparts for each rule type. They share the on-wire shape of jp_tool's types but derive schematic::Config for the partial/merge/delta machinery, and use MergeableVec to participate in the standard cross-layer merge model:
// crates/jp_config/src/conversation/tool/access.rs
#[derive(Debug, Clone, PartialEq, Config)]
#[config(rename_all = "snake_case")]
pub struct AccessConfig {
#[setting(
nested,
partial_via = MergeableVec::<FsRuleConfig>,
merge = vec_with_strategy,
)]
pub fs: Vec<FsRuleConfig>,
#[setting(
nested,
partial_via = MergeableVec::<NetRuleConfig>,
merge = vec_with_strategy,
)]
pub net: Vec<NetRuleConfig>,
#[setting(
nested,
partial_via = MergeableVec::<EnvRuleConfig>,
merge = vec_with_strategy,
)]
pub env: Vec<EnvRuleConfig>,
}
#[derive(Debug, Clone, PartialEq, Config)]
#[config(rename_all = "snake_case")]
pub struct FsRuleConfig {
pub path: Utf8PathBuf,
pub read: Option<bool>,
pub write: Option<bool>,
pub create: Option<bool>,
pub update: Option<bool>,
pub delete: Option<bool>,
pub execute: Option<bool>,
}
// NetRuleConfig and EnvRuleConfig follow the same pattern:
// plain fields that deserialize from the TOML shape shown earlier, with
// Config-derived partials so they participate in layered merging.ToolConfig and ToolsDefaultsConfig each gain an access: Option<AccessConfig> field with standard AssignKeyValue, PartialConfigDelta, and ToPartial impls alongside the existing fields like options. Neither participates in FillDefaults: scope resolution is replace, so it happens in ToolConfigWithDefaults::access() (the shape of run() and result()) rather than through the field-by-field fill style uses. After merging, the finalized AccessConfig is converted to jp_tool::AccessPolicy at the boundary in jp_llm::execute_local — rule paths are canonicalized (see Rule path canonicalization) and hosts are normalized (see Network rules) during conversion. Only the finalized AccessPolicy is serialized into the Context JSON the tool receives; MergeableVec and the partial types never cross the wire.
Data flow
- Tool config declares
accesson a named tool or on the[conversation.tools.'*']defaults. After all config layers are merged, config load rejectsaccessdeclared on a tool whose finalized source isbuiltinormcp. - Config layers merge per-subfield within each scope:
access.fs,access.net, andaccess.enveach merge independently asMergeableVec. Default strategy is append; replace requires explicitstrategy = "replace"(see Cross-layer merging). The merged result is anAccessConfigwith plainVec<_>fields per scope. Scope resolution then picks one pool per tool: the tool's own when non-empty, otherwise the*pool for a local tool and nothing for a builtin or MCP tool (see Scope inheritance). jp_llm::execute_local()converts the mergedAccessConfigto ajp_tool::AccessPolicy: rule paths are canonicalized againstctx.root, hosts are normalized, and the resulting policy is serialized into the context JSON passed to the tool binary.- The tool binary deserializes
Contextwithaccess: Option<AccessPolicy>. - Before performing an operation on a path, the tool runs it through the Path evaluation steps and checks the result against the policy. Rust tools using
jp_toolcallctx.check_read(path)(orcheck_create, etc.); tools in other languages implement the algorithm directly. V1 cooperative enforcement is applied only to thefs_*tool family — subprocess-style tools likeunix_utilsrely on RFD 075's OS-level sandbox foraccess.fsconfinement. - On denial, the tool returns an error naming the denied capability and listing configured grants so the user knows what to change.
Relationship to RFD 075
RFD 075 introduces OS-level sandboxing for subprocess tools and consumes this RFD's AccessPolicy types to generate platform-native sandbox profiles (macOS sandbox-exec, Linux Landlock). 075 extends AccessPolicy with subprocess CommandRule for spawn restrictions but does not modify the fs, net, or env rule semantics defined here.
Two consequences for this design:
- This RFD defines the authoritative policy model. Every rule type must have semantics precise enough that 075 can enforce them at the kernel level. This is why fs rules specify a canonical evaluation form (see Path evaluation) and why net rules use structured matching (see Network rules) instead of raw string prefix. Getting the semantics wrong here propagates into 075.
- The two layers always agree. This RFD provides cooperative self-checks with helpful error messages; 075 provides mandatory OS enforcement of the same rules. A tool that self-checks and passes must also pass the OS sandbox, modulo platform-specific capability mapping — for example, Landlock distinguishes
create/update/delete, whilesandbox-execcollapses them tofile-write*.
| Concern | This RFD (cooperative) | RFD 075 (OS-level) |
|---|---|---|
| Enforcement | Tool self-checks | OS sandbox |
| Failure mode | Helpful error message | Raw permission denied |
| Bypass | Tool can ignore | OS enforces |
| Policy source | Defines AccessPolicy | Consumes AccessPolicy |
Drawbacks
Tools must opt into checking. The policy is informational — a tool that doesn't call ctx.check_update() (or equivalent) silently ignores the policy. This is acceptable for JP's own tools (we'll add the checks) but means third-party tools get no protection until RFD 075's OS-level enforcement is in place.
Self-contained rule evaluation requires repetition. A more specific rule must re-state all capabilities, even those that a less specific rule already grants. This is a deliberate trade-off for clarity — every rule is readable in isolation — but adds verbosity for complex policies. A future inherit flag could reduce this if it becomes painful in practice.
Merge strategy is configurable, not implicit. access.fs, access.net, and access.env use MergeableVec, which defaults to append and lets users opt into replace, prepend, or dedup. This avoids the "last-wins only" trap but means users writing strict policies must understand the merge model — an append from an upstream layer cannot be overridden by adding rules; it requires an explicit strategy = "replace".
Accessor types collapse unset and false. FsRule::read() and the other capability accessors return bool, not Option<bool>. Combined with self-contained rule evaluation, there is no meaningful distinction between "unset" and "explicitly false" at evaluation time. The cost is that a future inherit flag, if added, would require an API change to expose tri-state values to consumers. See Inheritance-based evaluation for the variant this design rules out.
Alternatives
Access policy via options
Use the existing options mechanism (IndexMap<String, JsonValue>) to pass access policy. This avoids new config types but loses schema validation, forces tools to parse Value manually, and conflates behavioral configuration with security policy.
Inheritance-based evaluation
Instead of self-contained rule evaluation, more specific rules could inherit unset capabilities from less specific rules. This reduces repetition but requires Option<bool> to distinguish "not set" from "explicitly false" at every level, and creates subtle bugs where a parent rule silently grants capabilities the child intended to restrict. Self-contained evaluation is simpler and safer.
Non-Goals
- OS-level enforcement. This RFD does not sandbox tool subprocesses. RFD 075 defines OS-level enforcement that consumes the types this RFD establishes.
- MCP tool access control. MCP tools run on external servers. The server's security is the server operator's responsibility. Config load rejects
accessdeclared on an MCP or builtin tool, and the*defaults pass over them (see Configuration). - Group-level access defaults and overrides.
accessis declared per-tool or on the*defaults; there is no per-group scope. The example in Motivation is intentionally repetitive. If RFDs 055–057 (group defaults/overrides) land, a group becomes a third scope between*and the tool, resolved by the same replace rule and the same declared-versus-inherited rule; defining the group merge model itself is out of scope here.
Risks and Open Questions
Env prefix overlap between explicit prefixes. The largest class of over-grant surprises is eliminated by requiring explicit
*for prefix matching (see Environment variable rules) —AWS_TOKENnever silently matchesAWS_TOKEN_LOG. Residual overlaps between two prefix rules remain possible:AWS_SEC*(7 bytes) andAWS_SECRET_*(11 bytes) both matchAWS_SECRET_KEY; the longer wins. Documented in guidance; revisit if it proves painful in practice.Symlink resolution cost. Every fs check resolves symlinks via
canonicalize, which performs filesystem lookups. For tools that check many paths (e.g.,fs_grep_files), this adds per-path syscalls. The alternative — lexical-only checks — would diverge from RFD 075's OS enforcement and create a policy gap, which is worse. If measurable overhead appears, a cache keyed on(dev, inode)can be added.
Implementation Plan
V1 defines the shared fs, net, and env rule types in jp_tool and enforces fs cooperatively across the fs_* tool family. net and env consumers (e.g., web_fetch network checks, unix_utils env forwarding) are deliberately not part of V1 — enforcing them well requires the consuming tool in scope for design, not after. RFD 075 reuses the same policy model for OS-level enforcement across all three rule types.
Phase 1: Types and evaluation in jp_tool
Add AccessPolicy, FsRule, NetRule, EnvRule, and FsAccessError to jp_tool. Implement the path-canonicalization helper (reused by tools for target paths and by the host for rule paths at AccessConfig → AccessPolicy conversion) and Context::check_* methods. Implement structured net matching (scheme/host/port/path_prefix) with url::Host normalization for both rule and target hosts, and explicit-* env matching with literal-length specificity. Add access: Option<AccessPolicy> to Context with #[serde(default)]. Unit tests for evaluation logic covering workspace escape, symlink resolution, host normalization (including IDN/Punycode), port defaulting, and exact-vs-prefix env ties.
No dependency. Can merge independently.
Phase 2: Config types in jp_config
Add AccessConfig, FsRuleConfig, NetRuleConfig, EnvRuleConfig in jp_config with MergeableVec wrappers and the standard partial/delta/ ToPartial impls. Add access: Option<AccessConfig> field to ToolConfig and an accessor on ToolConfigWithDefaults. Implement the AccessConfig → jp_tool::AccessPolicy conversion, which canonicalizes rule paths against ctx.root (rejecting workspace-escape at config load) and normalizes rule hosts. Post-merge config validation rejects access on tools whose finalized source is builtin or mcp with a clear error.
Depends on Phase 1.
Phase 3: Plumbing in jp_llm
Include access policy in the JSON context passed to tool commands in execute_local() and the FormatArguments path.
Depends on Phase 2.
Phase 4: fs_* enforcement
Replace ad-hoc path joining in the fs_* tool family with ctx.check_read(), check_create(), check_update(), check_delete(), and check_execute(). Return clear error messages naming the denied capability and listing configured grants.
unix_utils is deliberately out of scope for V1 access.fs grant enforcement. It invokes subprocesses that read files opaquely (e.g., sort /etc/passwd), and per-argument path analysis fights the subprocess model. V1 only updates unix_utils's existing argument scanner to delegate to the canonicalization helper for workspace-escape detection — matching today's behavior, just with shared code. Full enforcement of access.fs on subprocess-spawned reads is RFD 075's responsibility via OS-level sandboxing.
Depends on Phase 3.
Phase 5: *-scope grants
Add access: Option<AccessConfig> to ToolsDefaultsConfig and resolve it in ToolConfigWithDefaults::access() under the replace rule, skipping builtin and MCP tools. The non-empty test spans every resource list, so a block holding only env rules is a policy like any other. Narrow the post-merge validation to access declared on a tool, so an inherited grant no longer trips the builtin/mcp rejection.
--mount needs one adjustment. It writes rules into a tool's own scope, which under replace detaches that tool from *, and a rule in one resource list switches only that list to default-deny. The injected block therefore carries the tool's prior reach across both edges: a copy of the * block when the tool declared nothing of its own, plus a workspace-wide fs rule whenever the scope that applied granted no fs rules, since the mount rule would otherwise be the tool's entire filesystem policy. Without that, mounting into a tool covered by a restrictive * block would silently widen it back to full workspace access, and mounting into one covered by an env-only block would revoke the workspace access it had.
Depends on Phase 2. Independent of Phases 3 and 4.
References
- RFD 075 — OS-level sandboxing for subprocess tools. Consumes this RFD's
AccessPolicytypes to generate platform-native sandbox profiles and extends them with subprocessCommandRulespawn restrictions. - RFD 016 — WASM plugin architecture. Shares the same general capability-based model (filesystem, network, commands) but uses different field names and a simpler
allow/writablefilesystem model. A future RFD may migrate RFD 016's WASM plugin config to consumeAccessPolicydirectly. - RFD 042 — Tool options. Established the
optionsmechanism; this RFD explains why access policy is a first-class field instead. - Deno security model — Inspiration for the grant-based, default-deny permission model.