Visibility Is Not Permission for Desktop Agent Tools
Desktop agents become safer when tool calls pass through explicit registry contracts for descriptors, validation, policy checks, execution, and reviewable results.
Flow
Fail-Closed Tool Dispatch
Every rejected state exits before the side-effecting adapter is reached.
1Normalize invocation
Parse the tool name, version, arguments, and requested surface.
2Resolve exact entry
Require one enabled registry record and compatible schema version.
3Validate arguments
Apply structural, semantic, and size constraints.
4Classify effects
Identify reads, local writes, network writes, and irreversible actions.
5Authorize context
Intersect actor, workspace, tool, and resource policy.
6Collect approval
Bind required consent to the exact operation and inputs.
7Execute with limits
Apply timeout, cancellation, output, and resource bounds.
8Describe outcome
Return structured evidence and explicit unknowns.
Desktop agents need tools. Without tools, they can only talk. With tools, they can inspect files, query local state, update documents, call APIs, or automate workflows.
That is also where risk enters the system.
The unsafe pattern is simple:
Illustrative anonymized example
tool exists
model sees tool
model emits tool call
runtime executes it
This collapses discovery and authority. It treats visibility as permission.
A safer desktop agent should route tool calls through a registry boundary.
The registry is not just a list of names. It is the runtime's approved capability map. It defines which tools are callable, what inputs they require, what validation rules apply, and which executor is allowed to run.
Illustrative anonymized example
Configured descriptors
Registry
Validated invocation request
Narrow executor
Reviewable dispatch result
The thesis is:
A desktop agent should expose tools through a registry boundary before any model output can become an executable action.
This does not mean the registry is a complete sandbox. It is not. A registry cannot prove that every tool behavior is safe. It can, however, make the executable surface explicit and reviewable.
Descriptors are not authority
A descriptor says what a tool claims to be.
Illustrative anonymized example
{
"name": "example.search_workspace",
"description": "Searches files in the current workspace.",
"inputs": {
"query": {
"type": "string",
"required": true
},
"maxResults": {
"type": "number",
"required": false
}
}
}
This is useful metadata, but it should not automatically make the tool executable.
- Configuration is not consent.
- Description is not authorization.
- Discovery is not dispatch.
A config-only tool descriptor should enter the system as a candidate. The registry decides whether it becomes an approved runtime capability.
Registry entries define callable capabilities
A registry entry can attach runtime rules to a descriptor.
Illustrative anonymized example
{
"tool": "example.search_workspace",
"capability": "workspace_search",
"requiredInputs": ["query"],
"sideEffects": "read_only",
"allowedSurfaces": ["chat", "command_palette"],
"logging": "required"
}
Now the system has something stronger than a prompt-visible tool description. It has a dispatch contract.
When the model or UI requests a tool call, the runtime checks the request against this contract.
Invocation requests must be validated
A tool call should be treated as untrusted input.
Illustrative anonymized example
{
"tool": "example.search_workspace",
"input": {
"query": "auth policy"
}
}
Before anything runs, the runtime should check:
- Is the tool name registered?
- Is the requested surface allowed?
- Are required inputs present?
- Are input types correct?
- Are values inside allowed boundaries?
- Is this tool allowed to run in the current workspace state?
If a model emits this:
Illustrative anonymized example
{
"tool": "example.search_workspace",
"input": {}
}
The executor should never see it.
The registry layer should reject it first:
Illustrative anonymized example
{
"status": "rejected",
"reason": "Missing required input: query"
}
Executors should be narrow
An executor is the code path that actually does something.
It may read state, write files, open windows, send network requests, or mutate local data. Because of that, executors should be narrow and boring.
A good executor does not decide whether it is allowed to run. It receives a validated invocation.
Bad design:
Illustrative anonymized example
Executor receives vague model output and decides what to do.
Better design:
Illustrative anonymized example
Executor receives a typed, validated request from the registry dispatch layer.
Reviewable evidence matters
Tool dispatch should produce evidence after the run.
Illustrative anonymized example
{
"tool": "example.search_workspace",
"status": "completed",
"validatedInputs": ["query"],
"sideEffects": "read_only",
"surface": "chat",
"timestamp": "example-timestamp"
}
For privacy, logs should avoid raw local descriptors, private paths, environment details, command transcripts, or sensitive file bodies unless the user explicitly asks for a local debug view.
The point is not to expose everything. The point is to make tool use inspectable without leaking private details.
Failure modes
Registry-backed tool calls should handle common failures.
| Failure | Safe behavior | |---|---| | Unknown tool | Reject before executor | | Missing required input | Reject with validation reason | | Invalid input type | Reject before dispatch | | Stale descriptor | Require refresh or re-registration | | UI bypass | Route UI actions through same registry | | Tool not allowed on current surface | Reject or require confirmation |
A registry does not remove the need for sandboxing, permissions, user confirmation, or careful executor design. But it gives the agent a clearer execution boundary.
A desktop agent should not run every tool it can see.
It should request a tool. The registry should validate the request. The executor should run only after the capability boundary has been crossed intentionally.
// Illustrative anonymized example
function normalizeInvocation(request, context, registry, policy) {
const tool = registry.get(request.toolName)
if (!tool) return { status: "denied", reason: "unknown_tool" }
const input = tool.schema.parse(request.arguments)
if (!tool.allowedSurfaces.includes(context.surface)) return { status: "denied", reason: "surface_not_allowed" }
if (!context.workspaceId) return { status: "denied", reason: "workspace_missing" }
if (!policy.allows(context, tool, input)) return { status: "denied", reason: "policy_denied" }
return { status: "allowed", tool, input }
}