Permissions
Path Policy
Local filesystem tools use ToolPathResolver. Relative paths resolve against the
cwd captured when the tool is constructed. Absolute path strings are accepted
as tool inputs, but workspace and path-scope policies still enforce boundaries
on the resolved target. Paths are normalized with Path.resolve(strict=False).
Injected readers and writers place normalized logical PurePosixPath values
in permission requests. Plain backends use relative paths. Backends with
FilePathResolver, including mounted filesystems, define their path namespace.
Mounted requests use rooted paths such as /tenant/sessions/session-123/notes.md.
The permission check and I/O use the same resolved target. Normalize allowed
mounted roots with normalize_virtual_path before containment checks.
WorkspaceToolPermissionPolicy and
PathScopeToolPermissionPolicy deny these non-local paths. Use a custom policy
for the adapter’s storage namespace; operation grants and approval callbacks
remain available. See File I/O interfaces.
AgentLane is a framework, so first-party helpers stay permissive unless an
application passes an explicit policy. With no permissions= argument,
read, find, grep, patch, write, and bash keep their trusted local
behavior. Injected file tools also allow every operation by default,
subject to the adapter’s own access checks. A specific cwd only changes path
resolution; it is not a sandbox:
tools = base_harness_tools(cwd=WORKSPACE)
Applications opt into permissioning at tool construction time. The bundled policies are small and composable:
permissions=Noneallows every request. This is the canonical allow-all path for trusted app code and prototypes; there is no separate public allow-all policy to compose.WorkspaceToolPermissionPolicyis a single-root path boundary. It allows path operations only when the resolved target stays insideroot. It denies non-path operations unless they are explicitly included inallowed_operations, because a path boundary cannot prove shell or network side effects.PathScopeToolPermissionPolicyis an explicit set of approved files or directories. Use it when a coding assistant works from onecwdbut the host has approved files or directories outside that workspace.ToolPermissionGrantPolicyis a tool and operation allowlist. It does not sandbox paths and does not ask for approval.SideEffectApprovalToolPermissionPolicyreturnsrequire_approvalfor writes, patches, directory creation, command execution, and network access. The host application still owns the approval callback and UI.AllOfToolPermissionPolicycomposes policies conservatively: deny wins, then approval, then allow.
Policy defaults are intentionally explicit:
allowed_operations=Noneon path policies allows all path operations and denies command execution.ToolPermissionGrantPolicy(())has an empty allowlist and denies every request it checks.AllOfToolPermissionPolicy(())allows because no nested policy denies; use it only when an empty composition is intentional.
Composition semantics (strictest wins)
AllOfToolPermissionPolicy resolves nested decisions on the ordering
allow < require_approval < deny. It scans every nested policy: a deny is
terminal and returned immediately; a require_approval is remembered; an
allow never widens a decision a stricter policy already made. The final
outcome is the strictest decision any nested policy returned.
This has one trap worth stating plainly. SideEffectApprovalToolPermissionPolicy
returns require_approval for every side-effecting operation it covers. If you
also compose a ToolPermissionGrantPolicy (or workspace_tool_policy(grants=...))
to pre-grant bash:execute_command, that grant is outcome-inert by default:
the grant policy returns allow, but the side-effect policy still returns
require_approval, and strictest-wins keeps the approval. The configured grant
looks like it permits the operation but never actually skips approval.
Two supported ways to make a side-effect grant meaningful:
-
Pass the grants to the side-effect policy so a matching grant downgrades
require_approvaltoallowfor the operations it covers:AllOfToolPermissionPolicy( ( WorkspaceToolPermissionPolicy(WORKSPACE, allowed_operations=...), ToolPermissionGrantPolicy(grants), SideEffectApprovalToolPermissionPolicy(grants=grants), ) ) -
With the
workspace_tool_policy(...)helper, opt in withgrants_downgrade_side_effect_approval=True:workspace_tool_policy( WORKSPACE, grants=grants, require_bash_approval=True, grants_downgrade_side_effect_approval=True, )With the flag set, a request matching a grant returns
allowfrom the side-effect policy, sobash:execute_commandactually skips approval. Without it (the default), the grant stays inert andbashstill requires approval — the conservative behavior is the default so opting into trust is always explicit.grants=NoneonSideEffectApprovalToolPermissionPolicykeeps the always-require-approval behavior; only an explicit grant list downgrades.
For a path-only workspace boundary, pass WorkspaceToolPermissionPolicy:
tools = base_harness_tools(
cwd=WORKSPACE,
permissions=WorkspaceToolPermissionPolicy(WORKSPACE),
)
This policy only answers “does the resolved path stay inside this root?” It is
not an allowlist, approval workflow, or process sandbox. Used by itself, it
denies ToolOperation.EXECUTE_COMMAND; include that operation explicitly only
when a separate policy or executor will handle command execution.
For workspace plus approved outside files or directories, use
PathScopeToolPermissionPolicy and include every approved scope explicitly:
tools = base_harness_tools(
cwd=WORKSPACE,
permissions=PathScopeToolPermissionPolicy(
(
WORKSPACE,
EXTERNAL_REVIEW_FILE,
EXTERNAL_REFERENCE_DIR,
),
),
)
Existing directory scopes allow descendants. Existing file scopes allow only
that exact file. Non-existing scopes allow only that exact future path, which
keeps a file grant from silently becoming a directory grant. Empty paths=()
denies all path operations. Prefer absolute paths for policy scopes; relative
scope entries resolve when the policy is constructed, the same as
WorkspaceToolPermissionPolicy(...). Scope matching is a simple linear scan,
which keeps the bundled policy easy to understand for short approved lists.
Applications with hundreds of approved scopes or hot-loop checks should
provide an indexed custom policy.
Both path policies resolve symlinks and check the nearest existing parent for new paths. This is a framework permission boundary, not a TOCTOU-safe operating system sandbox; hosts that need hard filesystem isolation should provide a sandboxed executor, container, or remote worker. The nearest-parent check uses filesystem stats at permission time, which is appropriate for interactive agent tool calls but not a substitute for kernel-enforced isolation.
For the common application policy “stay inside this workspace, admit declared
host operations, apply a tool grant allowlist, and require app approval before
side effects”, use
workspace_tool_policy(...). It is an opinionated convenience constructor for
that workspace-app shape over the same public primitives, not a separate
policy system:
grants, invalid_entries = parse_tool_permission_grants(
"read, find, grep, write:create_file, patch:modify_file, "
"bash:execute_command"
)
if invalid_entries:
raise ValueError(f"Unsupported tool grants: {invalid_entries}")
tools = base_harness_tools(
cwd=WORKSPACE,
permissions=workspace_tool_policy(
WORKSPACE,
grants=grants,
require_approval_for_side_effects=True,
require_bash_approval=True,
),
approval_callback=approve,
)
Grant defaults are intentionally distinct. Omit grants or pass
grants=None when you do not want a grant allowlist; the helper will compose
only workspace and approval policies. Pass an empty iterable, grants=(),
only when the grant layer should exist and deny every tool.
allowed_non_path_operations is the host-extension point for operations a
workspace root cannot scope, such as app-provided network egress tools. It does
not admit bash; command execution stays controlled by
require_bash_approval=True.
require_approval_for_side_effects=True makes file creation, overwrites,
patches, directory creation, and admitted non-path side effects require
approval. bash is separate:
require_bash_approval=True is the only way this helper admits
bash:execute_command, and it always makes command execution require approval
before the process starts. If grants are configured, bash:execute_command
must still be granted. This does not sandbox the command after startup.
The helper composes the public low-level policies in this order:
WorkspaceToolPermissionPolicydenies path operations outside the root and denies non-path operations unless explicitly admitted byallowed_non_path_operationsorrequire_bash_approval=True.ToolPermissionGrantPolicy, when provided, denies requests that do not match a whole-tool or operation-level grant.SideEffectApprovalToolPermissionPolicyreturnsrequire_approvalfor file and admitted non-path side effects whenrequire_approval_for_side_effects=True, and for command execution whenrequire_bash_approval=True.AllOfToolPermissionPolicycombines those decisions conservatively: deny wins, then approval, then allow.
For the same grant and approval behavior with approved outside paths, compose the public policies directly:
tools = base_harness_tools(
cwd=WORKSPACE,
permissions=AllOfToolPermissionPolicy(
(
PathScopeToolPermissionPolicy(
(WORKSPACE, EXTERNAL_REVIEW_FILE),
),
ToolPermissionGrantPolicy(grants),
SideEffectApprovalToolPermissionPolicy(),
)
),
approval_callback=approve,
)
That composition still denies bash by default because path scopes are not
process sandboxes. To include command execution in this composition, pass an
explicit allowed_operations set that includes ToolOperation.EXECUTE_COMMAND
and any path operations the app also wants to allow.
The exported permission primitives are framework extension points:
ToolPermissionRequestdescribes one operation before a side effect.ToolPermissionDecisionreturnsallow,deny, orrequire_approval. Itsoutcomefield is aToolPermissionOutcomeenum (ALLOW,DENY,REQUIRE_APPROVAL), andToolApprovalCallbackis the callback type that resolvesrequire_approvaldecisions.WorkspaceToolPermissionPolicyis the built-in single-root path policy.PathScopeToolPermissionPolicyis the built-in multi-scope path policy.AllOfToolPermissionPolicycomposes policies conservatively: deny wins, then approval, then allow.ToolPermissionGrantPolicyandparse_tool_permission_grants()are small helpers for operation-level allowlists.SideEffectApprovalToolPermissionPolicyis the generic approval policy for side-effecting operations.ToolApprovalBrokeris the optional host-facing approval lifecycle helper. It tracks pending approval records and exposes a callback compatible with first-party tools without owning any UI. Related event, record, and status types are exported alongside it.evaluate_tool_permission()andformat_tool_permission_result()are reusable helpers for custom tools that want the same deny and approval-required result contract.evaluate_tool_permission()returnsNonewhen execution may proceed, or a model-facing block result string when execution must stop.
parse_tool_permission_grants() accepts comma-separated whole-tool entries
such as read and operation entries such as write:create_file. It returns
(grants, invalid_entries), preserves duplicates, and does not let later
entries override earlier ones. Callers should reject or report
invalid_entries before constructing a policy. Partial success is intentional:
CLI and environment-variable callers can collect every unsupported entry and
report them together instead of failing on the first one.
Whole-tool entries expand to that tool’s declared operation set. For example,
write becomes grants for create_file, overwrite_file, and
create_directory; it is not an unbounded wildcard for future or undeclared
operations. ToolPermissionGrant.all_operations(...) remains available for
programmatic callers that intentionally want a wildcard grant.
By default the parser only recognizes the built-in first-party tools (read,
find, grep, write, patch, bash), so a grant for an app-registered
egress tool such as web_search:network_access is reported as invalid. Pass
known_tools — a mapping from each extra tool name to the ToolOperation
values it exposes — to drive grants for those tools from a grant string:
grants, invalid_entries = parse_tool_permission_grants(
"web_search",
known_tools={"web_search": frozenset({ToolOperation.NETWORK_ACCESS})},
)
A known_tools entry whose name collides with a built-in is ignored; the
built-in operation set wins. Operation names outside a tool’s declared set are
still reported as invalid, so web_search:read_file is rejected even when
web_search is known.
To use that app egress tool with the workspace convenience policy, the host must also admit the non-path operation at the workspace boundary:
tools = base_harness_tools(
cwd=WORKSPACE,
permissions=workspace_tool_policy(
WORKSPACE,
grants=grants,
allowed_non_path_operations=(ToolOperation.NETWORK_ACCESS,),
require_approval_for_side_effects=True,
),
approval_callback=approve,
)
For programmatic grants, construct ToolPermissionGrant values directly.
ToolPermissionGrant.all_operations("read") makes whole-tool intent explicit;
ToolPermissionGrant("write", ToolOperation.CREATE_FILE) grants one
operation.
Custom policies only need a check(request) method:
class DenyBash:
def check(self, request: ToolPermissionRequest) -> ToolPermissionDecision:
if request.operation == ToolOperation.EXECUTE_COMMAND:
return ToolPermissionDecision.deny()
return ToolPermissionDecision.allow()
WorkspaceToolPermissionPolicy(...) resolves existing paths through
symlinks. New paths are checked through their nearest existing parent, so
writing nested/file.txt still requires the target parent chain to stay inside
the workspace. Absolute path strings are accepted as inputs, but the policy
denies resolved absolute targets outside the root.
If an app needs a different scope shape, use one of these explicit choices:
- Use a broader
WorkspaceToolPermissionPolicy(...)while keepingcwd=WORKSPACEnarrow for relative path resolution. - Use
PathScopeToolPermissionPolicy(...)for workspace plus approved outside files or directories. - Pass no policy for a fully trusted local tool set.
- Implement
ToolPermissionPolicy.check(...)for app-specific rules such as per-user grants, read-only subtrees, or remote policy decisions.
ToolPermissionRequest has three groups of fields. Tool-action fields
(tool_name, operation, cwd, path, command) are built by the tool
after argument validation and path resolution. Framework correlation
(run_id, agent_name, tool_call_id) comes from ToolExecutionContext.
metadata is for app-defined correlation and is merged with context metadata,
with request metadata winning on key conflicts. skill_name and reason are
optional caller hints.
At the execution boundary, a Tool handler receives one
ToolExecutionContext. ToolExecutor.execute(...) accepts a mapping keyed by
tool-call id because one executor invocation may run several tool calls in
parallel and each call needs its own correlation. Custom executors should
preserve that shape: accept per-call context at the batch boundary, then pass
one ToolExecutionContext into each individual Tool.run(...) call.
Denied calls return stable tool-result text before side effects:
permission denied: read is not allowed for `/workspace/private.txt`
Policies may also return require_approval. The core harness does not provide
interactive approval UI. Instead, tools accept an optional approval_callback
that an application, CLI, desktop app, or service can use to decide whether
the pending ToolPermissionRequest should proceed:
async def approve(
request: ToolPermissionRequest,
decision: ToolPermissionDecision,
) -> ToolPermissionDecision:
return ToolPermissionDecision.allow()
If no callback is configured, the tool returns a stable approval-required result and does not execute.
Hosts that need pending-state tracking can use ToolApprovalBroker instead
of writing their own request id, event, and future glue:
broker = ToolApprovalBroker()
tools = base_harness_tools(
cwd=WORKSPACE,
permissions=workspace_tool_policy(
WORKSPACE,
require_approval_for_side_effects=True,
),
approval_callback=broker.callback,
)
Each brokered request receives a stable request_id and a
ToolApprovalRecord containing the original ToolPermissionRequest, the
approval-required ToolPermissionDecision, current status, and final decision
when complete. Hosts can use broker.pending() for a tuple snapshot and
broker.events() for async lifecycle events emitted after the subscription
starts:
async for event in broker.events():
if event.status == ToolApprovalStatus.PENDING:
record = event.record
await render_host_approval_prompt(record.request_id, record.request)
Use broker.pending() to seed UI state before consuming the live event stream.
Call broker.close() when the host is shutting down broker event observation;
this unblocks active broker.events() subscribers and makes future event
subscriptions end immediately. Closing event observation does not resolve
pending approvals or synthesize a permission decision.
The host resolves a pending request with a normal permission decision:
await broker.resolve(request_id, ToolPermissionDecision.allow())
await broker.resolve(request_id, ToolPermissionDecision.deny())
await broker.resolve(request_id, original_approval_required_decision)
The broker does not own timeout, deadline, or cancellation policy. A host that
needs those behaviors should implement them at the application boundary and
resolve the request with an existing ToolPermissionDecision. For example, a
host-side timeout can preserve the normal approval-required tool output by
resolving with the original approval-required decision.
Custom tools use the same callback by delegating permission evaluation through
evaluate_tool_permission(...):
async def run_custom_tool(
request: CustomArgs,
*,
permissions: ToolPermissionPolicy | None,
approval_callback: ToolApprovalCallback | None,
) -> str:
blocked = await evaluate_tool_permission(
ToolPermissionRequest(
tool_name="custom_tool",
operation=ToolOperation.EXECUTE_COMMAND,
cwd=WORKSPACE,
),
policy=permissions,
approval_callback=approval_callback,
)
if blocked is not None:
return blocked
return await perform_custom_side_effect(request)
When a first-party tool runs through the default runner, permission requests
also receive framework correlation from an explicit
agentlane.models.ToolExecutionContext. The runner builds that context for
each model tool call, ToolExecutor passes it to the tool handler, and the
first-party permission helper copies these fields onto ToolPermissionRequest
before policy and approval evaluation:
run_idagent_nametool_call_idmetadatafor application-defined correlation
The framework does not render correlation metadata back to the model by
default. Applications can use it in policies, approval callbacks, audit logs,
or UI prompts. There is no hidden ambient permission context; custom tools that
need framework correlation should accept the ToolExecutionContext passed by
Tool.run(...).
Operations are intentionally small and tool-oriented:
| Tool | Operations |
|---|---|
read | read_file |
find | search_files |
grep | read_file for existing file paths, search_files for directories and missing paths |
write | create_file, overwrite_file, create_directory |
patch | modify_file |
bash | execute_command |
Egress tools (web search, API callers) use network_access; it is not bound to
a first-party local tool because the framework ships no egress tool, but the
operation is a stable part of ToolOperation for application egress tools.
write may issue two permission requests for one tool call. If the target’s
parent directory does not exist, it checks create_directory for that parent
first. It then checks create_file for a missing target or overwrite_file
for an existing target. Policies that audit or log requests should expect this
one-or-two request shape.
The shared policy can require approval for local bash before the process
starts, but the default local executor is not filesystem-confined after
startup. Because WorkspaceToolPermissionPolicy is a path sandbox, it denies
execute_command unless ToolOperation.EXECUTE_COMMAND is explicitly
included in allowed_operations or another host policy allows it.
Applications that need real process isolation should provide a sandboxed
BashExecutor, container, remote worker, or equivalent host boundary.
Network access (egress)
ToolOperation.NETWORK_ACCESS is the operation for tools that send data off
the machine, such as a web search or an API caller. Network egress has no
filesystem path for a path policy to bound, so an egress tool builds a
ToolPermissionRequest with operation=ToolOperation.NETWORK_ACCESS, an
optional command/payload describing the outbound data, and a human-readable
reason. Use this operation instead of borrowing EXECUTE_COMMAND for network
calls: a path or command sandbox would either deny the request or mislabel the
payload as a shell command.
NETWORK_ACCESS is classified as a side effect, so
SideEffectApprovalToolPermissionPolicy() (with default operations) returns
require_approval for it. The reason on the request flows through
format_tool_permission_result(...): when a decision carries no reason, the
default approval-required text names the tool’s network access rather than a
“command”, so an approval UI can render an egress prompt without special-casing
a synthetic command shape.
blocked = await evaluate_tool_permission(
ToolPermissionRequest(
tool_name="web_search",
operation=ToolOperation.NETWORK_ACCESS,
cwd=WORKSPACE,
command=query,
reason="approval required: outbound web search awaiting approval",
),
policy=permissions,
approval_callback=approval_callback,
)
if blocked is not None:
return blocked
Recording immediate decisions through the broker
By default ToolApprovalBroker.callback(...) only handles require_approval
decisions and raises on an already-decided allow/deny, because those need
no host resolution round-trip. A host that runs auto-allow or auto-deny modes
would then make those decisions outside the broker and keep its own counters,
so they never appear in broker.events().
Construct the broker with record_immediate_decisions=True to route every
decision through the same observable place:
broker = ToolApprovalBroker(record_immediate_decisions=True)
With the flag set, passing an allow/deny decision to callback(...)
records the request as resolved in the same step: the broker emits a pending
then a resolved event for it and returns the decision unchanged. The request
never lingers in broker.pending(). require_approval decisions still suspend
for host resolution exactly as before. The default stays False so existing
hosts that only broker approvals keep raising on non-approval decisions.