Minimum Viable Agent Security

·MMatthew Slipper

How we securely run agents internally.

We run an internal Agent called Scout. It writes documentation, automates release notes, summarizes analytics, and engages with our OSS community. It runs unattended, and does real work.

Many of our users run similar agents, and are nervous about it. Rightly so: an autonomous agent with access to GitHub and X - with no human in the loop - can do a lot of damage. It's the kind of thing that gets people writing essays.

We are not nervous. Here's why.

Infrastructure

Scout is built on top of Hermes, an open-source agent framework from Nous Research. Hermes is self-improving: it creates its own skills and curates its own memory across sessions. The longer it runs, the smarter it gets. Which is why we run each Scout deployment on a dedicated EC2 instance with full root access.

By design nothing else runs on the Scout box. We don't negotiate with agents about who owns what. Even without root, agents are escape artists. Sometimes it's a prompt injection, sometimes the model goes off-script. Either way, here are some things we've seen agents do to try and get around network controls:

  • Port scanned the internal subnet looking for other hosts to talk to.
  • Tried to SSH onto a remote host, and make requests from there.
  • Tried multiple different DNS resolvers, including manually resolving IPs over DNS-over-HTTP.

Hermes supports things like running agent commands in a Docker container. Don't do this. If an escape vector exists, an agent will find it, so it's better to eliminate the attack surface altogether and have dedicated hosts for each agent. The best boundary is a machine boundary.

Since the Scout host belongs to the agent, we run iron-proxy on a dedicated box in the same VPC. Security Groups then force Scout to exclusively communicate with iron-proxy, and prevent requests to naked IPs. Here's some example Terraform:

resource "aws_security_group" "scout_hermes" {
  name        = "scout-hermes"
  description = "sg for hermes instances"
  vpc_id      = aws_vpc.scout.id
 
  egress {
    description = "http to iron-proxy"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["${aws_instance.scout_iron_proxy.private_ip}/32"]
  }
 
  egress {
    description = "https to iron-proxy"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["${aws_instance.scout_iron_proxy.private_ip}/32"]
  }
 
  egress {
    description = "dns tcp to iron-proxy"
    from_port   = 53
    to_port     = 53
    protocol    = "tcp"
    cidr_blocks = ["${aws_instance.scout_iron_proxy.private_ip}/32"]
  }
 
  egress {
    description = "dns udp to iron-proxy"
    from_port   = 53
    to_port     = 53
    protocol    = "udp"
    cidr_blocks = ["${aws_instance.scout_iron_proxy.private_ip}/32"]
  }
 
  tags = {
    Name = "scout-hermes"
  }
}

You can set this up yourself by following our bare metal guide.

Proxy Rules

We use the iron-proxy control plane to configure egress rules and to inject credentials. Scout can't talk to anything we haven't whitelisted, and never receives credentials of any kind. These two properties solve the majority of attacks. The agent has nothing to exfiltrate, and nowhere to exfiltrate to.

Iron-proxy lets you lock down egress to specific API endpoints, not just hostnames. Whitelisting api.github.com lets your agent talk to every repo on GitHub. Attacks like Shai-Hulud exploit this by exfiltrating data through GitHub commits and public repos. We whitelist the specific repositories and API endpoints Scout needs to hit:

transforms:
  - name: allowlist
    config:
      rules:
        - host: "api.github.com"
          methods: ["PATCH"]
          paths: ["/repos/ironsh/iron-proxy/releases/*"]

You can often get deeper permissioning than the upstream provider allows. Classic GitHub PATs with the repo scope can perform almost any write action against any repo the user has access to. GitHub doesn't let you scope a PAT to one repo. Iron-proxy does.

Finally, credentials. Scout never holds a real secret. We store everything in AWS SSM Parameter Store and iron-proxy pulls credentials on-the-fly using workload identity. Real credentials are injected on outbound requests. From the agent's perspective, it communicates with bare, unauthenticated APIs. And just as we lock down egress to specific API endpoints, we do the same with secrets.

transforms:
  - name: secrets
    config:
      secrets:
        - source:
            type: aws_ssm
            name: "/ironsh/scout/hermes/github-write-pat"
            region: "us-east-1"
            with_decryption: true
            ttl: 15m
          inject:
            header: "Authorization"
            formatter: "Bearer {{ .Value }}"
          rules:
            - host: "api.github.com"
              paths: ["/repos/ironsh/iron-proxy/releases/*"]
              methods: ["PATCH"]
    # readonly PAT configured the same way, scoped to GET

What's Next

None of this is groundbreaking. The point is that good enough agent security is attainable today. Most of what stops people from running agents in production is the belief that doing so requires something elaborate. It doesn't. The floor is low.

We're most interested in the work that comes next. The next thing we're building is OAuth credential brokering, so an agent can act as you on Notion, Google Docs, Linear, and the rest. OAuth is structurally harder than API keys: someone has to run a browser flow, refresh tokens have to be rotated, and long-lived credentials need to live somewhere other than the agent.

Solving this introduces a concept that's going to matter a lot more over the next year: agent identity. Your agent isn't you, so it shouldn't authenticate as you. It should have its own identity, permissions, and audit trail. This is the prerequisite for agents that do consequential things without a human in the loop.

If you're running agents in production and want help locking them down, that's what we do. Find me at [email protected] or book a call.