AIAgents

Securing Hermes Agent without losing your mind in the process

I spent an evening reading the source of an AI agent that had been running on my own machine for three weeks, and I came away with two feelings that do not normally coexist. The first was relief, because the people at Nous Research clearly thought about this harder than I expected. The second was a mild, creeping unease, because the parts they could not protect are exactly the parts I had been ignoring.

Hermes Agent is an autonomous agent with persistent memory. It keeps state across sessions, works through long goals on its own schedule, writes its own reusable skills from experience, and talks to you from Telegram, Discord, or Slack while it does it. That last detail is the one that changes everything. A coding assistant sits politely in your IDE waiting to be asked. Hermes runs on a VPS you are not looking at, at three in the morning, and reports back later.

That is the feature. It is also the problem. So this is a guide to putting Hermes somewhere useful without handing it the keys to your production environment, written after actually reading what it already does for you, which turns out to be more than most blog posts on this subject assume.

Why an always-on agent is a different animal

The security model of a chatbot is simple because a chatbot has no initiative. It answers, it stops, it waits. Nothing happens between your messages.

An autonomous agent inverts that. Hermes monitors, decides, and acts without a human in the loop, which means three properties collapse together in a way that traditional threat modelling does not handle well.

It has initiative, so the trigger for an action may be a cron job or a Slack message from someone who is not you. It has memory, so a decision it makes today can influence a decision it makes next month, long after you have forgotten the context. And it has tools, so its output is not text; it is a shell command, an API call, a kubectl apply.

Combine those, and you get a category of failure that does not exist in ordinary software. An attacker does not need to compromise the agent’s process. They only need to get some text in front of it. A poisoned README in a repo it clones, a crafted issue on GitHub, a message in a channel it monitors. Prompt injection is not a memory safety bug you can patch. It is a consequence of the agent doing its job, which is reading things and acting on them.

What Hermes already gives you, which is not nothing

Here is the part that most security write-ups skip, and skipping it makes them both unfair and less useful. Hermes ships a documented defence-in-depth model with eight layers, and if you deploy it without knowing what they are, you will end up rebuilding controls that already exist while leaving the real gaps open.

The ones worth knowing before you write a single line of infrastructure:

Dangerous command approval. Before running a shell command, Hermes matches it against a list of destructive patterns (rm -r, mkfs, dd if=, DROP TABLE, curl … | sh, writes to /etc/ or ~/.ssh/). The default smart mode uses an auxiliary model to triage, trivially safe commands pass, clearly dangerous ones are denied, ambiguous ones escalate to you. Approval prompts fail closed after a timeout.

A hardline blocklist underneath all of it. A handful of unrecoverable commands (rm -rf /, fork bombs, zeroing a block device) are refused regardless of –yolo, regardless of “approvals.mode: off”, regardless of you clicking “allow always”. There is no override flag. This is a genuinely good design decision, and I wish more tools had it.

File write safety. write_file and patch are blocked from touching credential stores (~/.ssh/, ~/.aws/, ~/.kube/, .env files anywhere on disk) with no approval prompt and no way to override from chat.

SSRF protection on every URL-capable tool. Private ranges, loopback, link-local (including 169.254.169.254, the cloud metadata endpoint), and cloud metadata hostnames are blocked by default, with redirect chains revalidated at each hop.

Context file injection scanning. AGENTS.md, .cursorrules, and similar files are scanned for injection patterns, hidden HTML comments, and invisible Unicode before they reach the system prompt.

Gateway authorization that defaults to deny. If you configure no allowlists, nobody can talk to the bot.

Now, the important caveat, which the documentation itself states plainly. The write guards apply only to write_file and patch. The terminal tool runs as the same OS user and can cat or overwrite those same paths with a shell command. The approval system is a guardrail against an honest-but-mistaken agent. It is explicitly not a sandbox against a hostile one.

That distinction is the whole reason the rest of this article exists. Everything above stops the agent from making a mistake. Almost none of it stops an agent that has been successfully talked into something.

What “secure” should mean here

Before the configuration, the goals. An agent deployment is defensible when five things are true.

It has its own identity. The agent acts as itself, never as you. Every action is attributable to a principal that exists only for the agent and dies with it.

It runs least privilege by default-deny. It reaches exactly the systems its job requires, and the list of those systems is written down somewhere reviewable.

Its credentials are short-lived, narrow, and ideally invisible to it. The best secret is one the agent never holds.

Its runtime is contained. A compromised agent stays a compromised agent instead of becoming a compromised host.

Everything it does is reconstructable from immutable logs. Not from asking the agent what it remembers doing, which is roughly as reliable as asking a witness.

And a sixth one, specific to Hermes and to any agent with a learning loop, which I did not appreciate until I read the skills documentation: what the agent learns is code, and it must be treated as code. More on that in step seven, which is the step I would keep if I could only keep one.

Step 1: Contain the runtime

Never run the agent on the host, and never as root. Hermes makes this a one-line decision because the terminal backend is configurable, and switching it to Docker moves execution into a container that Hermes hardens itself:

# ~/.hermes/config.yaml

terminal:

  backend: docker

  docker_image: "nikolaik/python-nodejs:python3.11-nodejs20"

  docker_forward_env: []      # explicit allowlist only, empty keeps secrets out

  container_cpu: 1

  container_memory: 2048      # MB

  container_disk: 20480       # MB

  container_persistent: false # fresh filesystem per session

Every container Hermes launches gets –cap-drop ALL (with DAC_OVERRIDE, CHOWN and FOWNER added back so package managers work), –security-opt no-new-privileges, a 256 process limit, and size-limited tmpfs mounts on /tmp and /var/tmp with noexec on the latter. That is a better default than most hand-rolled docker run lines I have reviewed in production, including some of mine.

Two things to know about this switch.

First, “container_persistent: false” is the setting people skip. In persistent mode, the sandbox filesystem survives across sessions, which means an attacker who lands something in /workspace on Monday still has it on Thursday. Ephemeral mode throws it away. Use ephemeral unless you have a concrete reason not to.

Second, and this one surprised me. When the backend is a container, Hermes skips the dangerous command checks entirely, on the reasoning that the container is now the boundary. That reasoning is correct, and it also means your blast radius is now exactly the container definition. If you bind-mount your home directory in, you have quietly deleted both layers at once.

If you want a real boundary instead of a shared kernel, run this inside a microVM. Firecracker or Cloud Hypervisor boots in tens of milliseconds and gives you a hardware isolation line, which is a proportionate response to a workload whose behaviour you cannot fully predict.

If you use the official Docker image, note the operational trap. The gateway runs as the unprivileged hermes user (uid 10000), but “docker exec” defaults to root, and files that root creates are unreadable to the gateway. Pairing approvals fail silently.

docker exec -u hermes hermes-agent hermes pairing approve telegram ABC12DEF

Step 2: Control the egress

Data exfiltration is the worst outcome of a successful prompt injection, and it is the one where network controls beat application controls decisively. The agent can be talked into anything. The firewall cannot.

Start with the two settings Hermes already exposes:

# ~/.hermes/config.yaml

security:

  allow_private_urls: false     # default, keep it that way on any gateway

  website_blocklist:

    enabled: true

    domains:

      - "*.internal.company.com"

      - "admin.example.com"

  tirith_enabled: true

  tirith_fail_open: false       # block when the scanner is unavailable

  allow_lazy_installs: false    # no runtime pip installs

“tirith_fail_open: false” is the change worth arguing about. The default is true, meaning commands proceed if the content scanner is missing or times out. That is the right default for a laptop and the wrong one for a production gateway, where a scanner that is not running should stop the line rather than wave things through.

Then put a real allowlist under it, at the network layer, where the agent’s opinions do not matter. On Kubernetes:

apiVersion: networking.k8s.io/v1

kind: NetworkPolicy

metadata:

  name: hermes-agent-egress

  namespace: agents

spec:

  podSelector:

    matchLabels:

      app: hermes-agent

  policyTypes:

    - Egress

  egress:

    # DNS only to the cluster resolver

    - to:

        - namespaceSelector:

            matchLabels:

              kubernetes.io/metadata.name: kube-system

          podSelector:

            matchLabels:

              k8s-app: kube-dns

      ports:

        - protocol: UDP

          port: 53

    # everything else goes through the proxy, nowhere else

    - to:

        - podSelector:

            matchLabels:

              app: egress-proxy

      ports:

        - protocol: TCP

          port: 3128

Nothing else leaves. When the injection eventually happens, and it will, the exfiltration attempt dies at the network layer and lands in your proxy logs, which is the best possible outcome. An attack that failed and told you about itself.

Step 3: Give the agent its own identity

If the agent uses your kubeconfig, the agent is you. On a bad day, that means it holds cluster admin, and every command it hallucinates is permanently attributed to your name in the audit log. Explaining that in a post-incident review is a specific kind of misery.

Give it a ServiceAccount scoped to the handful of verbs it actually needs:

apiVersion: v1

kind: ServiceAccount

metadata:

  name: hermes-agent

  namespace: agents

---

apiVersion: rbac.authorization.k8s.io/v1

kind: Role

metadata:

  name: hermes-agent-reader

  namespace: apps

rules:

  - apiGroups: [""]

    resources: ["pods", "pods/log", "events", "services"]

    verbs: ["get", "list", "watch"]

  - apiGroups: ["apps"]

    resources: ["deployments", "replicasets"]

    verbs: ["get", "list", "watch"]

---

apiVersion: rbac.authorization.k8s.io/v1

kind: RoleBinding

metadata:

  name: hermes-agent-reader

  namespace: apps

subjects:

  - kind: ServiceAccount

    name: hermes-agent

    namespace: agents

roleRef:

  kind: Role

  name: hermes-agent-reader

  apiGroup: rbac.authorization.k8s.io

Read-only, namespaced, no wildcards. When the agent needs to restart a deployment, resist the urge to add patch on deployments and instead give it one narrow verb on one named resource, or better, a pipeline it can trigger that a human owns. Every verb you add here is a verb an attacker inherits.

Apply the same paranoia everywhere else it touches: a dedicated GitHub App with repository-scoped permissions instead of your PAT, a dedicated cloud service account instead of your admin role.

Step 4: Keep credentials short-lived, or absent

Long-lived static credentials are a bad idea in ordinary software. Handed to an agent that can be talked into printing them, they are a liability with an expiry date you do not control.

The first discipline is passthrough hygiene. Hermes strips sensitive variables from child processes by default: execute_code blocks anything whose name contains KEY, TOKEN, SECRET, PASSWORD, CREDENTIAL, or AUTH, and MCP subprocesses receive only PATH, HOME, USER, LANG, LC_ALL, TERM, SHELL, TMPDIR, and XDG_*. Everything else is stripped. Do not undo this. Every name you add to docker_forward_env or terminal.env_passthrough is a secret that code in the container can read and send anywhere.

The second is to stop giving it the secret at all. This is where I have to correct something I believed when I started writing: I assumed you would have to build the credential-injection proxy yourself as a sidecar. You do not. Hermes ships one.

hermes egress setup

The egress proxy (iron-proxy, a TLS-intercepting single binary managed by the Hermes egress commands) holds your real API keys on the host and gives the sandbox nothing but opaque tokens. The agent asks the proxy to make the call. The proxy injects the credential on the way out. The sandbox never sees a usable secret, so an injection that convinces the agent to exfiltrate its credentials exfiltrates a token that is worthless outside the proxy.

This is the single highest-value control in the entire article. It takes one command, and it is documented in a corner of the docs that almost nobody reads. If you take one thing from this piece, take this.

For cloud access, the same principle applies through Workload Identity or IRSA. The pod’s identity is federated at the API boundary, and there is no key material on disk to steal.

Step 5: Build an audit trail you can actually query

You need to answer who did what and when, from logs the agent cannot edit. Three sources, aggregated centrally:

The proxy access log, which is your ground truth for every outbound request, including the ones that were blocked.

The Kubernetes API server audit log, filtered to the agent’s identity so it is readable:

apiVersion: audit.k8s.io/v1

kind: Policy

rules:

  - level: RequestResponse

    users: ["system:serviceaccount:agents:hermes-agent"]

  - level: Metadata

    resources:

      - group: ""

        resources: ["secrets", "configmaps"]

And Hermes’ own state, which lives in ~/.hermes/logs/ and ~/.hermes/state.db. That database is genuinely useful, because it records which dangerous commands were classified and which ones actually executed. There is even a command that mines it:

hermes approvals suggest --days 90

It prints the patterns you approved most often. Read it as a confession rather than a convenience: if you have approved git push –force fourteen times, you have not been reviewing those prompts. You have been dismissing them. Ship ~/.hermes/ to your SIEM on a schedule, and remember that these logs live inside the blast radius, so they corroborate the external ones rather than replacing them.

Step 6: Cap the blast radius of always-on

Always-on means the exposure window never closes, so put ceilings on everything that can run away.

# ~/.hermes/config.yaml

approvals:

  mode: manual          # no auxiliary-model triage in production

  timeout: 120

  cron_mode: deny       # headless jobs never auto-approve

  single_query_mode: deny

  deny:

    - "git push --force*"

    - "kubectl delete*"

    - "terraform apply*"

    - "*curl*|*sh*"

Note what approvals.deny is for. It sits below –yolo and “approvals.mode: off”, so it survives the moment six months from now when somebody adds –yolo to a script to unblock a deploy. Write the list for that person, because that person is you on a Friday.

Set a hard spending cap on the provider API key at the provider. And keep the gateway allowlist explicit. Never “GATEWAY_ALLOW_ALL_USERS=true”:

# ~/.hermes/.env

TELEGRAM_ALLOWED_USERS=123456789

SLACK_ALLOWED_USERS=U01ABC123

chmod 600 ~/.hermes/.env

Step 7: Treat what the agent learns as untrusted code

This is the step that does not appear in generic agent hardening guides, because it is specific to agents that learn, and it is the one I would fight to keep.

Hermes’ defining feature is its learning loop. When it solves something, it writes a reusable skill as a Markdown file, stores the outcome in persistent memory, and adjusts next time. Agent-created skills land in ~/.hermes/skills/.

Sit with that for a second. The agent writes procedure documents that the agent later follows. Which means a prompt injection does not have to steal anything today. It can instead persuade the agent to write a skill, and that skill will be loaded and followed next week, next month, in a session that has nothing to do with the original attack, triggered by a cron job while you are asleep. Every control in steps one through six is scoped to a session. This one crosses sessions. It is persistence, in the red-team sense of the word, implemented as a feature.

Nous clearly thought about this. Skills installed from the Hub and skills carried by repositories are scanned for prompt injection directives, credential exfiltration commands, and hidden text tricks, and a skill that fails the scan is quarantined so it does not appear in the index and refuses to load by name. Repository skills require an explicit “hermes skills trust” before they load at all.

But a scanner is a filter, and filters have false negatives. For anything touching production, turn the gates on:

# ~/.hermes/config.yaml

skills:

  write_approval: true    # every skill create/edit/delete waits for you

memory:

  memory_enabled: true

  write_approval: true    # same gate on memory writes

With these on, writes are staged under ~/.hermes/pending/skills/ and you review them like a pull request:

/skills pending

/skills diff <id>

/skills approve <id>

/skills reject <id>

Then go one step further and make the skills directory a git repository:

cd ~/.hermes/skills && git init && git add -A

git commit -m "baseline: approved skill set"

Now every change the agent proposes to its own behaviour produces a diff with a timestamp and an author, reviewed by a human, revertable with one command. This costs you a few minutes a week and converts the most alarming property of the agent into the most auditable one.

One more thing on state. If you use the SSH, Modal, or Daytona backends, Hermes pushes ~/.hermes/ into the remote sandbox and syncs changed files back to the host afterwards, including skills the agent created remotely. The sandbox boundary you carefully built is, for this specific directory, a two-way street. Plan accordingly.

What is still broken after all seven steps

Two things, and I would rather say them than pretend the checklist is complete.

The terminal tool remains a hole in the write guards. Hermes’ protected-path denylist stops write_file and patch from touching ~/.ssh/ or .env files, but the terminal tool runs as the same OS user and can cat them with a shell command. The documentation says so explicitly. The only real answer is the container or microVM boundary from step one, which is why step one is step one.

Your guardrails now live in five different places. Kubernetes RBAC, cloud IAM, a NetworkPolicy, a proxy allowlist, and a YAML file in a home directory. There is no single pane of glass showing what the agent can do, and no way to ask “can it reach the payments database?” without checking five systems and reasoning about their intersection. Until unified agent control planes exist, the answer is Terraform: put all five in one repository, in one module, reviewed together, so that at least the drift is visible.

module "hermes_agent" {

  source = "./modules/agent-sandbox"

  agent_name          = "hermes-prod"

  k8s_namespace       = "agents"

  allowed_egress_fqdn = ["api.github.com", "hooks.slack.com"]

  iam_role_arn        = aws_iam_role.hermes_scoped.arn

  spend_cap_usd       = 200

}

The bottom line

Hermes Agent is a serious piece of engineering, and after a week of reading its source, I trust it more than I did going in, not less. It will automate the work you have been putting off, run deployments while you sleep, and behave, most of the time, like the relentless junior engineer you never managed to hire.

The thing to internalise is that its defaults are tuned for a developer laptop, which is the correct choice for the audience it has. Production is a different audience, and the gap between those two configurations is roughly the seven steps above. None of it is exotic. It is a container backend, a network policy, a service account, one command to set up the egress proxy, some log shipping, a deny list, and a git repository for the skills directory.

Give Hermes a well-lit room with a door you control, and it will change how you work. Give it your kubeconfig and an open egress path, and it will also change how you work, though the meeting where you explain it will be considerably less pleasant.