Auth Proxy Injection for LLMs

View Markdown Other Articles

Article written by a human: Mike Cardwell

I've been developing a sandbox to run Claude inside of, for a little while now. It's basically a python script that runs a podman container with claude inside with your CWD mounted inside it. You optionally create a .claude-sandbox.toml file inside that directory, with configuration. There are a lot of config options (see https://gitlab.com/grepular/claude-sandbox ). I want to concentrate on my proxy injection solution though as I think it has a few ideas that might be a little novel.

I'm certainly not the first person to come up with this idea. You want to give Claude access to call an API, but you don't want to give it the credentials it needs to do that, as you don't want them including in a prompt which gets uploaded to Anthropic.

I added a feature to allow you to intercept outgoing requests from the sandbox and inject headers. If your .claude-sandbox.toml contains one or more [[proxy.inject]] sections, then a sidecar podman container is launched which runs mitmproxy on the same podman network. A custom CA is auto built for mitmproxy, and is added to the CA bundle in the main sandbox container.

Environment variables like HTTPS_PROXY are set inside the sandbox container to advise HTTP client software running in the sandbox to use this new proxy. It may seem like this is a flaw because Claude could just ignore it, but if Claude does ignore it, all that means is that we don't inject the auth header, so it doesn't get access to the API it is trying to access.

A very basic example of the config would be:

[[proxy.inject]]
host   = "api.github.com"
method = ["GET", "HEAD"]  # optional; restrict to read-only verbs
header = "Authorization"
value  = "Bearer secretToken"

Inside the sandbox, claude can not read the .claude-sandbox.toml file directly to see secretToken because an empty file is read-only mounted on top of it. It also does environment variable interpolation, so you could do:

value = "Bearer ${GITHUB_TOKEN}"

That environment variable doesn't get exposed to the sandbox container, it is only used to obtain the value for the auth/header injection system.

You'll notice you can filter based on the method type too. If there was a POST request, that wouldn't get the auth header injected in the above example.

So this was my first attempt, and it worked for some situations. I hooked up Claude to my Home Assistant API, and that also worked, except Home Assistant can also be controlled via a websocket, and the authentication for the websocket happened inside a websocket frame, not a HTTP header. So for this, I made my proxy injection tool websocket aware. If you include something like the following in your config:

ws_replace = "__HA_AUTH_TOKEN_OUTBOUND__"

It will look for any instances of __HA_AUTH_TOKEN_OUTBOUND__ in your first client->server websocket frame, and replace it with whatever the injection value is. This worked fine. I just told claude that my auth key was __HA_AUTH_TOKEN_OUTBOUND__.

In all cases, if the actual authentication token is returned in a HTTP response or websocket frame from server to client, it is stripped out. This is some basic protection to prevent simple situations where the auth token might be reflected back.

Then I decided I wanted to give Claude access to a GraphQL API, but I only wanted it to have access to do queries and subscriptions, not mutations. So I made the proxy injection tool graphql aware. There are several options, but You can basically do this:

graphql_exclude_operation = ["mutation"]

Now the auth header will only be injected if the request is a graphql request and not a mutation.

Sometimes the header you need to inject will contain a short lived token. E.g for the Tesla API, I wanted to inject an access token. Rather than specifying "value", I made it so that you can specify "command" instead. This will then execute a command on the host to get the value to inject. It can be as simple as printing out the value you want to stdout, or alternatively, printing out some JSON containing both the value and a cache time. For Teslas API, my config looks like this:

[[proxy.inject]]
host            = "owner-api.teslamotors.com"
header          = "Authorization"
command         = "tesla-access-token"
command_format  = "json"
retry_on_status = [401]

The tesla-access-token script on the host uses the long lived refresh token to obtain an access token, and then prints out some JSON like this:

{
    "value": "Bearer THE_SHORT_LIVED_ACCESS_TOKEN",
    "ttl":28500
}

The ttl in the json the script generates is actually extracted from the access token and has 5 minutes knocked off. With this script and the above config, any requests to owner-api.teslamotors.com have an Authorization header injected with an access token which lasts for about 8 hours. If a 401 response is received at any point, we immediately attempt to get a new access token and inject that, and repeat the request, without Claude knowing any of this is happening.

Another thing I wanted to be able to do was allow you to specify a list of commands that Claude should be able to execute on the host, without being able to read them. So I added host_command:

[[host_command]]
name    = "foo"
command = ["bar"]

With the above config, there will be a command in your PATH inside the sandbox named foo, which simply commuicates with the sandbox software running on the host via a unix socket. On the host, we execute bar with your stdin and command line args, and then return the output back over the socket. You're effectively telling claude it can run foo, and transparently to it, bar is actually being executed on the host instead.

This is a powerful option, but you need to be careful with it. For example, if you did this:

[[host_command]]
name    = "git"
command = ["git"]

Then inside the sandbox, Claude could create a git repo, create a commit hook shell script, and then execute a git command on the host which would run that shell script on the host, allowing it to escape the sandbox.

One of my mostly commonly used config blocks is this:

[[mount]]
src = ".git"

Mounts are read-only by default. So what this does is make the .git folder read-only. I don't like having Claude create my commits. I prefer to retain that control myself. So it doesn't need to write to the .git dir. Mounting it read only means that it can still search the git history, but it can't delete it or make other strange changes to it. It also don't have access to my ssh keys, so it can't do anything evil like an unexpected deployment, or a force push to delete history from my remote.

  PayPal   Patreon   Bitcoin Address RSS   Atom   Mastodon   Bluesky
← Read more