Security model
AgentsFence is a security control, so this document is deliberately specific about what it protects against and what it does not.
Reporting a vulnerability
Section titled “Reporting a vulnerability”Please do not open a public issue for a bypass. Report it privately through GitHub Security Advisories on this repository (“Report a vulnerability”). Include:
- the user intent,
- the tool call(s) that got through,
- the compiler provider and model,
- the output of
agentsfence audit -n 50 --jsonwith anything sensitive removed.
We aim to acknowledge reports within 3 business days.
A bypass is any case where a tool call executes that the task policy should have blocked or sent for approval, or where authority reaches a policy from anywhere other than the user’s own message. Both are in scope.
Threat model
Section titled “Threat model”Trusted
- The user’s own messages, as delivered by Hermes in
pre_llm_call.user_messagefor top-level sessions. - The Hermes runtime and its plugin dispatch:
resolve_pre_tool_block, the approval gate, and the tool registry. - The local machine, the AgentsFence configuration, and the user’s approval answers.
Untrusted
- Everything the agent reads: email bodies, web pages, documents, file contents, tool results, MCP server responses.
- The agent model’s reasoning and output, including arguments it chooses for tool calls and goals it writes for subagents.
- The policy compiler model’s output. It is treated as a proposal that deterministic hardening can only shrink.
Adversary capabilities assumed
- Can place arbitrary text in content the agent reads (indirect prompt injection).
- Can cause the agent model to emit any sequence of tool calls with any arguments.
- Can influence the compiler model only through the user’s message. Pasted and quoted content is stripped before compilation.
Out of scope
- A compromised Hermes installation or Python environment.
- A malicious user. AgentsFence protects users from their agent, not systems from their users.
- A local attacker with file-system access to
~/.hermes.
What AgentsFence guarantees
Section titled “What AgentsFence guarantees”These properties hold for tool calls Hermes dispatches through pre_tool_call, under the assumptions in the threat model. “Holds” means every counterexample we know of has a regression test. “Partial” means the property holds only within the stated scope.
External reviews found counterexamples to G2, G4 and G10: a patch Move File into a forbidden directory, explanations counted as requests, send evidence authorizing other external writes, write actions of multiplexed “read” tools, and another plugin’s approve winning on the first tool call. Each is fixed, and the regression tests listed below are named after them. Alpha changes are described on the getting-started page.
| # | Property | Status | How | Tests |
|---|---|---|---|---|
| G1 | No authority from content. Tool output, conversation history, subagent goals and model output never reach the compiler. In shared chats, only the task owner’s messages can widen a policy. | Holds | The compiler receives only user_message. Subagent sessions and their task ids are bound to the parent’s policy. Messages from non-owners only add prohibitions. |
test_policy_cannot_be_changed_by_tool_output, test_subagent_inherits_parent_policy_and_budget, test_nested_child_call_with_task_id_only_uses_parent_policy, test_group_chat_other_sender_cannot_widen_policy |
| G2 | No authority beyond your words. A consequential permission needs a verbatim quote containing a request for that kind of action: a clause that opens with the action’s verb, not negated. Action verbs used as the subject of an informational sentence remain informational; a summary may also request a separate output to an explicit path or address. A new sentence can introduce other commands. Evidence is checked per risk class, so a send request doesn’t authorize other external writes. Recipients must be the object of a send or share request. Domains and paths must appear as whole tokens; broad roots are refused. Compiled policies can’t allow unknown tools. | Holds for recognized request phrasings; partial for what is meant (see limits) | PolicyCompiler.harden(): request_clauses, per-class entry gating |
test_llm_cannot_invent_recipients, test_unrelated_quote_cannot_authorize_deletion, test_negated_quote_is_not_evidence, test_explanations_are_not_requests, test_informational_conjunction_does_not_grant_second_verb, test_new_sentence_after_explanation_is_a_separate_request, test_summary_with_concrete_output_keeps_that_request, test_send_evidence_does_not_authorize_other_external_writes, attack suite with the overbroad and quote_reuse compilers |
| G3 | Explicit prohibitions are absolute. “Don’t send / only read / don’t buy / never delete” are extracted deterministically from your whole message (even when a long message is truncated for the compiler). They override the compiler and are checked before grants and approvals. BLOCK always beats ASK. | Holds for recognized phrasings | extract_prohibitions(); most-restrictive-wins in the evaluator |
test_prohibitions, test_prompt_injection_in_email_is_blocked, test_prohibition_beats_guard_question, test_trailing_prohibition_survives_truncation |
| G4 | Destructive, financial and permission changes default to BLOCK; unknown tools default to ASK_USER. Arguments that change what a tool does are classified: browser_console with an expression is execution; multi-file patches are checked per file, including both sides of Move File, using Hermes’ grammar and parser (Delete File is a delete); tools that multiplex reads and writes behind an action argument are reads only for declared read actions; “safe” shell commands with write or launcher options are not reads. |
Holds for mapped tools and recognized commands | Class ceilings, evaluation.unauthorized, arg_present_risk, patch_args, read_actions, option-aware shell classifier |
test_read_only_policy_gates_consequential_actions, test_browser_console_expression_is_not_a_read, test_patch_headers_are_path_checked, test_patch_delete_header_is_a_delete, test_patch_move_into_forbidden_directory_is_blocked, test_patch_header_whitespace_variants_are_seen, test_read_mapped_tools_with_write_actions_are_not_reads, test_safe_command_names_with_mutating_forms_are_not_reads |
| G5 | Recipients are bound. For an automatically allowed call under a compiled policy, every extracted recipient in to, cc, bcc, nested fields, or an unrecognized non-content key must match an address you named. A separate exact-action approval can authorize an unbound recipient. A domain listed for browsing does not authorize other addresses at that domain. Matching is exact after NFKC normalization. A manually written policy can deliberately allow a whole recipient domain when it lists no individual recipients. |
Holds for recognized recipient fields and compiled policies | extract_targets, evaluator steps 6 and 8 |
test_recipient_binding_global, test_recipient_smuggled_under_unknown_key, test_authorized_communication_other_recipient_blocked_with_good_compiler, test_browsing_domain_does_not_widen_named_email_recipient, test_compiled_domain_is_not_recipient_authority, test_compiled_domain_still_asks_when_recipient_guard_disabled |
| G6 | Approvals cover exactly one action. A grant is bound to the tool, capability, risk class and an action fingerprint (the command, code, recipients, paths, and so on; message content excluded). “Once” is single-use. Hermes’ [a]lways is keyed to that action within that task. An auxiliary LLM’s “smart” approval never mints a grant. |
Holds | Grant.fingerprint, task-and-action-scoped rule_key |
test_session_grant_for_command_does_not_cover_other_commands, test_grant_for_one_code_snippet_only, test_grant_covers_only_exact_delta, test_unparseable_patch_asks_and_is_not_reusable, test_smart_llm_approval_mints_no_grant |
| G7 | Fail closed. Internal errors → BLOCK. Startup failure → a hook that blocks every call. Compiler failure → read-only fallback (prohibitions preserved). Under the fallback, questions become blocks for yolo, approvals.mode: off, exact allowlist hits and unattended cron runs. The same applies to ordinary policies with strict_yolo. A task-only call for an unknown task → BLOCK. ASK_USER with no human present → blocked by Hermes. |
Holds for the verified Hermes approval API | on_pre_tool_call wrapper, register() stub, compile() fallback, exact plugin_rule: allowlist lookup |
test_internal_error_fails_closed, test_register_failure_installs_blocking_stub, test_failure_falls_back_read_only, test_fallback_policy_blocks_instead_of_asking_under_auto_approve, test_allowlisted_question_blocks_under_fallback_or_strict_mode, test_hermes_allowlist_lookup_uses_exact_plugin_rule_key, test_cron_has_no_human_approval_even_without_yolo, test_exact_hermes_allowlist_cannot_auto_approve_fallback_question, test_hermes_cron_approve_cannot_auto_approve_fallback_question, test_strict_yolo_blocks_exact_hermes_allowlist_hit, test_ask_user_fails_closed_without_a_human |
| G8 | No budget races. Parallel calls can’t exceed max_calls, including calls waiting on an approval (their counters are reserved until you answer). Subagents share the parent’s counters. |
Holds | Evaluation and reservation under one lock | test_concurrent_calls_cannot_exceed_max_calls, test_pending_approval_reserves_budget |
| G9 | The agent can’t switch off its own fence. Blocked regardless of policy: modifying AgentsFence state, the plugin directory, or any profile’s Hermes config.yaml, .env, auth.json or plugins/ (directly, through a patch, or via $HERMES_HOME); running hermes plugins/config/hooks … or pip uninstall agentsfence. Other state-changing shell commands inside the Hermes home, and cross_profile writes, need approval. |
Holds for recognized paths and commands | Guards |
test_agent_cannot_disable_its_own_fence, test_hermes_home_via_variables_and_cd, test_patch_into_fence_state_is_blocked, test_sort_output_into_fence_state_is_blocked, test_cross_profile_and_other_profile_config |
| G10 | A BLOCK stops the tool, verified against real Hermes dispatch. This includes the case where another plugin that approves everything is loaded first and Hermes runs in --yolo, on the first tool call of the process as well as later ones. |
Holds while AgentsFence can reorder Hermes’ hook list (a Hermes internal) | Hook moved first at registration, at every pre_llm_call, and before each decision; Hermes resolve_pre_tool_block |
test_all_scenarios_through_real_hermes, test_other_plugins_approve_cannot_hide_block_on_first_call, test_first_call_without_pre_llm_call_is_protected_by_registration_order, test_other_plugins_approve_cannot_hide_block_later_in_task, test_register_moves_fence_ahead_of_already_loaded_plugins, test_fence_moves_itself_first |
| G11 | Restrictions survive restarts. Policies persist across Hermes restarts and --resume; only /new or policy expiry clears them. |
Holds | Records kept on shutdown finalize, pruned on expiry | test_shutdown_finalize_keeps_restrictions_for_resume, test_policy_persists_across_process_restart |
Additional heuristic defenses
Section titled “Additional heuristic defenses”These reduce risk, but they are heuristics, not guarantees:
- Exfiltration-shaped URLs anywhere in a call’s non-content arguments (long query strings, long encoded tokens, email addresses in queries, embedded credentials) → ASK_USER.
- Secret files (
~/.ssh,~/.aws/credentials,~/.hermes/.env, …) → ASK_USER even for reads. - Shell sub-classification:
rm,git reset --hard,git branch -D,curl -d/-F/-X POST,scp,ssh,git pushandsudoare recognised insideterminalcommands, including compound commands (;,&&,|). Commands treated as reads are checked option by option (sort -o,rg --pre,find -fprintf,yq -iare not reads). Environment-variable prefixes (LD_PRELOAD=…) and programs outside system directories (./ls) are always execution. - Pasted-content stripping: code fences (``` and ~~~), inline code,
>quotes, quoted strings, email header blocks, forwarded or replied messages, and<pasted_content>-style tags. - Verb inference for unmapped MCP tools (first verb wins; strong verbs such as delete, send, pay and transfer escalate).
- Memory and cron: persistent memory writes and scheduled jobs require authority, because both can carry an injection beyond the current task.
What AgentsFence does NOT protect against
Section titled “What AgentsFence does NOT protect against”-
Misreading your intent within your own words. Hardening ties each permission to a sentence of yours that asks for that kind of action. It can’t check that the model understood the sentence. For example, “remove the typo” could be read as permission to delete. For destructive or financial tasks, prefer explicit wording, or the rules compiler.
-
Other plugins’ questions and Hermes internals. AgentsFence keeps its hook first by reordering a Hermes-internal list. If a future Hermes changes that, reordering stops, and the audit log and
agentsfence doctorreport the other plugins. While first, an AgentsFenceASK_USERcan hide another plugin’sblock;plugin_conflicts: strictavoids that. We intend to propose a “block always wins” rule upstream in Hermes. -
Group chats without sender ids. Owner binding needs the platform to report who sent each message. Where it doesn’t, any participant’s message is treated as the owner’s.
-
Safe mode.
HERMES_SAFE_MODE=1skips all plugins. AgentsFence is not running and can’t warn you from inside Hermes;agentsfence doctordoes. -
Content manipulation inside authorized actions. If sending to John is authorized, an injection can change what the email says, including pasting sensitive data into it. AgentsFence decides whether an action may happen and to whom, not what it contains.
-
Generated text. AgentsFence asks the model to omit instructions found in retrieved content from ordinary summaries, but this is best-effort guidance. It does not deterministically sanitize the model’s answer or a summary saved through an authorized write.
-
Side effects inside authorized execution. A shell command or
execute_coderun that you authorized can do anything the OS allows. Use Hermes’ container backends (docker, modal, …) or an OS or network sandbox for tasks that need execution. -
Server-side effects of “read” tools. An MCP tool named
get_…that also mutates state is classified as READ. Tools that take anactionargument are covered when the action isn’t a declared read; a tool whose name is the only signal isn’t. Map such tools explicitly inconfig.yaml. -
Exfiltration through allowed reads that don’t look unusual. A short query string or a sequence of innocent-looking requests to an attacker’s domain can leak small amounts of data. If a task doesn’t need the open web, set
allowed_external_domainsor denyweb.*. -
Actions outside Hermes tool dispatch, such as gateway-level features, other plugins that call APIs directly, or tools that bypass
pre_tool_call. -
Approval fatigue. ASK_USER is only as good as the human answering it.
--yolo,approvals.mode: off, allowlists and cronapprovemode auto-approve ASK_USER (never BLOCK). Fallback policies andstrict_yolo: trueturn those questions into BLOCK; allowlist detection uses Hermes’ exactplugin_rule:key. -
Compiler errors in the permissive direction for READ. A task that should have been narrower than read-only (for example “only read this one file”) still gets general read access unless the compiler expresses path or domain limits.
-
Unusual request phrasing. Request detection is lexical: a clause must open with the action’s verb, after optional framing such as “please” or “can you”. A request phrased another way (“the drafts should be deleted”) grants nothing, so the action asks; the risk is friction, not overreach. A description that happens to open with an action verb still counts as a request.
-
Unmarked adversarial text inside the user’s own message. If you paste an attacker’s text inline without quoting it, AgentsFence treats it as your words. Hardening still requires verbatim evidence and literal recipients, but those can come from pasted text.
Hardening recommendations
Section titled “Hardening recommendations”evaluation: unauthorized: {UNKNOWN: BLOCK, EXECUTION: BLOCK} trust_inferred_reads: false # unmapped "get_*" tools must be mapped explicitly missing_policy: block strict_yolo: true plugin_conflicts: strict # if other plugins also decide tool callsguards: exfil_url: {max_query_chars: 100}- Map every MCP tool you depend on in
tools:, withrecipient_args,domain_argsandpath_args. - Prefer
compiler.provider: rulesorhermesif you don’t want task text sent to OpenRouter. - Run execution-heavy tasks in a Hermes container backend.
- Review
agentsfence auditafter sensitive tasks.
Data handling
Section titled “Data handling”- Stored locally only, under
~/.hermes/agentsfence/, mode 0600: policy records and the audit log. Records survive Hermes restarts (so--resumekeeps its restrictions) and are deleted by/newor when the policy expires (default 12 hours). - Never stored in the audit log: the raw user request (only a
sha256:prefix), the compiler’s free-text summary (length plus hash), and argument values other than leaf recipients, URLs, paths and actions (length plus hash). Policy records keep the one-sentence summary so/fencecan show it. - Sent over the network: your instruction text (with quoted material stripped) and the tool catalogue, sent to the configured compiler endpoint when a task starts, when a later message may change authority, and at most once more on invalid output. Nothing else, and no telemetry.