Skip to content

Security model

AgentsFence is a security control, so this document is deliberately specific about what it protects against and what it does not.

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 --json with 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.

Trusted

  • The user’s own messages, as delivered by Hermes in pre_llm_call.user_message for 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.

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

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 push and sudo are recognised inside terminal commands, including compound commands (;, &&, |). Commands treated as reads are checked option by option (sort -o, rg --pre, find -fprintf, yq -i are 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.
  • 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 doctor report the other plugins. While first, an AgentsFence ASK_USER can hide another plugin’s block; plugin_conflicts: strict avoids 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=1 skips all plugins. AgentsFence is not running and can’t warn you from inside Hermes; agentsfence doctor does.

  • 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_code run 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 an action argument are covered when the action isn’t a declared read; a tool whose name is the only signal isn’t. Map such tools explicitly in config.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_domains or deny web.*.

  • 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 cron approve mode auto-approve ASK_USER (never BLOCK). Fallback policies and strict_yolo: true turn those questions into BLOCK; allowlist detection uses Hermes’ exact plugin_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.

~/.hermes/agentsfence/config.yaml
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 calls
guards:
exfil_url: {max_query_chars: 100}
  • Map every MCP tool you depend on in tools:, with recipient_args, domain_args and path_args.
  • Prefer compiler.provider: rules or hermes if you don’t want task text sent to OpenRouter.
  • Run execution-heavy tasks in a Hermes container backend.
  • Review agentsfence audit after sensitive tasks.
  • Stored locally only, under ~/.hermes/agentsfence/, mode 0600: policy records and the audit log. Records survive Hermes restarts (so --resume keeps its restrictions) and are deleted by /new or 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 /fence can 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.