Security & Firewall
UGENT has a multi-layer security architecture: injection firewall, tool security policy, rate limiting, OS-sandboxed execution, role-based capability policies, and per-user ownership of conversations, delegated work and swarm tasks.
Injection Firewall
A deterministic, hook-based security layer that enforces source-boundary separation: untrusted text may provide evidence but must never grant authority.
Configuration
The firewall is configured in firewall.toml (discovered like other config files):
[firewall]
enabled = true
strict_mode = false
[firewall.strict_mode_categories]
sink = "permissive" # warn-only for bash/web_fetch
memory = "global" # follow global strict_mode
mcp = "global" # follow global strict_mode
[firewall.strict_mode_tools]
"memory_*" = "strict" # always block suspicious memory writesHow It Works
Phase 1 — Input Sanitization:
- Strips ANSI/OSC terminal escape sequences (clipboard writes, title-set, hyperlinks)
- Classifies text provenance via a taint lattice: Trusted, Untrusted, Tainted
Phase 2 — Taint Propagation:
- Each tool's output is classified by trust level (web fetch, MCP, search → untrusted)
- Taint is monotonic per session — never downgrades
- Per-actor taint partitioning: one user's session flags can't affect another's
Phase 3 — Egress Control:
- Once injection is observed in a session, sensitive sink tools (shell, network) are constrained
- Observe-and-warn by default; blocks in strict mode
- Memory writes are quarantined — strict mode blocks poisoned writes
MCP Pinning
SHA-256 fingerprints of MCP tool definitions (name + description + parameter schema). If a definition changes ("rug-pull"), the tool is withheld until re-approved via /firewall approve.
Pin baselines persist to ~/.ugent/state/firewall_pins.json — they survive process restarts.
[firewall]
enable_mcp_pinning = trueSkills Firewall
Scans skill bodies for authority-grab attempts at load time:
[firewall]
block_malicious_skills = true # default: refuse Critical findings at loadDisable Flag
ugent --disable-firewallWarns loudly that a security layer is disabled. Use only for debugging.
Tool Security Policy
Workspace Boundaries
[security]
allow_outside_workspace = true # allow file access outside workspace root
allow_sensitive_file_read = true # allow reading .env, keys, etc.
enforce_gitignore_boundary = true # respect .gitignore
allow_ignored_paths = false # deny access to gitignored filesIn a workspace with a folder per department or per person, [security.actor_scope] confines each person's turns to their own folders; see File Confinement.
Bash Auto-Confirm
[security]
auto_confirm_bash = true # auto-approve bash commands (review risk first)
bash_backend = "auto" # system | wsl | autoWhen auto_confirm_bash is disabled, every bash command requires user confirmation via the interaction dialog.
Rate Limiting
Each tool may be called a limited number of times within a rolling window:
[security.tool_rate_limits]
window_seconds = 10 # default
default_max_calls = 10 # default, for any tool not listed below
[security.tool_rate_limits.per_tool]
web_search = 3
read_file = 20
bash = 10Without the section, web_search (and the web-search MCP tool) get 3 calls a window and read_file 20. Writing the section at all replaces those built-in limits, even with no per_tool table, so list every tool you want limited.
Config File Protection
The write_file and edit_file tools refuse to overwrite security-critical config files by basename:
ugent.toml,actor_scope.toml,firewall.toml,sandbox.toml,mcp.toml,routing.toml,cron.toml,firewall_pins.json
Reads are not restricted — the agent can read config and logs for debugging. Human editor edits are unaffected.
Role-Based Capability Policies
Role-based capability policies control what each worker agent can do:
| Capability | Tools | Granted to |
|---|---|---|
CommandExecution | bash, kill_shell | Master, Coder, Tester, Executor, Architect |
file_read | read_file, search_files, find_files | All roles |
file_write | write_file, edit_file | Master, Coder, Executor, Architect |
AgentInteraction | ask_user_input, task_management, use_skill | Master, Planner, Coder, Tester, Executor, Architect |
RuntimeControl | session_control, checkpoint_control, cron_management | Master only |
SecurityGuard tasks are non-delegating and read-only — they cannot run shell commands, write files, delegate, or use runtime-control tools.
These policies gate what tools may be requested. For kernel-enforced containment of the processes those tools spawn, see OS Sandbox.
Who a Channel Caller Acts For
On the web channel an API key identifies the calling application, not the person using it. Each key therefore has a role, and every conversation has an owner, so holding a key does not open every conversation.
| Role | Who | May name | A request that names nobody acts as |
|---|---|---|---|
operator | A trusted front end that signs in its own users and checks which conversations are theirs. UGENT_WEB_API_KEY is one unless [web.auth] api_key_role says otherwise. | Any user | The operator, which may observe and control any conversation |
application | An integrating application. Keys added under [[web.auth.keys]] are this by default. | Only federated users of the issuers the key lists | The key's own anonymous user, web-user@<name>, never the shared web-user |
A verified identity token names its user whatever the key's role. An application key cannot list a reserved issuer, and the status and sandbox reads, the /sandbox commands and directory sync refuse it. Keys are configured with the web channel API.
A conversation belongs to whoever first sends a message in it, uploads a file to it or starts a call on it. Its session records the actor whose turn created it, and the conversation also records its first sender, so a first turn that is stopped or fails does not leave the conversation open to the next sender. A sender who is not identified leaves the conversation unowned.
| Request | Allowed when |
|---|---|
| A chat turn on an existing conversation, a voice message included | It comes from the conversation's owner. An unowned older session is claimed by an operator key's turn and refused to an application key's. |
| A reply while a question is pending | It comes from the actor whose turn asked the question. |
Events, files, /btw and idle /recall by session id | The caller is the operator or the session's owner. A session with no recorded owner is also open to a user an operator key names, except to /btw and /recall. |
| Stop | The caller is the operator or the turn's own actor, or names the exact turn id. |
A session snapshot (GET /v1/ugent/snapshot) | The caller is the session's recorded owner. A request that names no user reads as the key's anonymous user, as on chat. Anyone else is refused with not permitted to read that session. |
- A refused turn runs nothing and records nothing. The web channel answers
403, or ends a stream with the reason. - An event subscription returns only the records its caller may see. Owners are read again on every poll, so a conversation that changes hands stops reaching its old watcher at once.
- A download, an upload and an attachment named by file id all need the file's conversation to be the caller's.
- A live call names its conversation, and the user it is for, when it opens, and is refused unless the conversation is the caller's. An operator key that names no user acts as the operator, as on chat. The turns it hands over run as the user the page names.
- Other channel plugins carry no caller identity, so these checks apply to the web channel only: an IM group chat runs many people in one conversation.
One gap remains: a non-streaming caller that already knows a brand-new conversation's id and its owner's turn id, and wins a race to that conversation, could read the owner's reply.
Delegated Work Acts for Its User
A turn the runtime starts on a user's behalf — a delegated worker's turn, or a parent resumed once its workers finish — acts for the user whose turn delegated the work. Memory, the file gate and the context engine all see that user, and the worker passes the same user on to anything it delegates.
- The runtime stamps the user on the task from the delegating turn. Nothing in a tool's arguments can name it.
- A resumed parent acts for the user it was suspended as, read back from the tasks it delegated, so this holds across a restart.
- A runtime-started turn whose task carries no user acts for nobody: no memory, no personal files, and a context engine that requires an actor refuses it. Only turns typed at the TUI or REPL, and cron and autorun jobs, act as the owner.
- A user's task is that user's.
delegate_status,delegate_followup,revise_planandcollect_resultstreat a task that acts for someone else as one that does not exist.
Swarm Peers
Swarm lets trusted UGENT nodes delegate tasks to each other over mutual TLS. Trust is the gate for everything a peer can do.
- Trust is mutual, and by name. Each node runs
/swarm trust add <name> <url>for the other, under the other node's ownnode_name: a token is accepted only from the trust entry whose name equals the peer id inside it. A trust added or removed while the worker runs takes effect within about 5 seconds, without a restart. - A node serves only when enabled. A swarm worker opens no port and creates no identity unless its
ugentd.tomlexists and setsenabled = true, which/swarm initwrites. Run by hand it exits with the reason; under the plugin runtime it stays up and reportsdegradedwithswarm is disabled: .... - A task is its submitter's. Only the peer that submitted a task can read its status and result, or cancel it. Any other peer gets the answer an unknown id gets,
task <id> not found, so it learns nothing about whether the task exists. - A remote task acts for the delegating user. The sending node names the user whose turn delegated the task, taken from the task and never from the model's arguments. The receiving node runs the task as that user, for memory, file access and the context engine. A claim to be the receiving node's owner is dropped with a warning and the task acts for nobody, as does a task that names no user. No remote task runs as the receiving node's owner.
- The claim is checked. A claim that is not a string, is longer than 1,024 characters or contains a control character is refused with
-32602before anything is stored, and the sending side reports the peer's own reason, for exampletask.actor must not contain control characters. - Restricted turns stay local. A task from a turn with opt-outs or a narrowed tool list is never sent to a peer, because the swarm protocol does not carry those restrictions. An explicit swarm placement for such a task is refused with the reason.
A trusted peer's word on its users is taken as given
An actor id means the same person on both nodes only when they share an identity namespace, such as one SSO, so trust a peer only if you would accept its word on who its users are. The context engine checks again on its side: asserting a user needs a key with the actor:assert scope and a directory entry for that user.
Mixed versions: a node built before remote tasks carried their user sends none, so its tasks act for nobody on a current node, and a receiving node built before then ignores the field and runs every task as its owner.