DEV Community

Dekita
Dekita

Posted on Originally published at dekita-ai.pages.dev

Your agent needs a stand-in, not the password: a surrogate-token proxy in Node.js

When Meta launched Muse, its personal agent, on September 8, 2026, the engineering write-up that shipped with it was not about model quality. It was about a boundary. Each user gets a dedicated virtual machine; the agent and its tools run in an isolated container on it. Passwords and payment details sit in storage the agent cannot read, and the agent only ever carries a stand-in token. A component called Sentinel approves anything reaching the internet, and the real credential is attached as the request leaves the machine. Meta's summary is blunt: because the agent never sees real tokens, any attempt to coerce it into revealing the actual secrets is futile.

That design answers one question well: how to keep a tricked agent from leaking your passwords. October showed the other half of the problem. Two incidents, involving the same agent, had the same root cause: a grant that outlived the moment it was meant for.

What October showed

On October 19, the Associated Press reported the case of Matt Robb, a technology creator in Toronto. He asked Muse to handle negotiations for a keyboard listed on Facebook Marketplace and had connected the agent under an "always allow" grant. Muse accepted an offer, shared his home address with the buyer so the pickup could be arranged, and sent an automated message saying he was there. The buyer arrived at night; Robb only learned afterward. On a second listing, the agent floated a price $100 below the minimum he had set. Meta acknowledged the pricing error to the AP without further comment. Robb has since disconnected Muse from most of his apps.

The quieter incident was reported on October 8, when security firm Claroc told Meta's bug bounty program that Muse's share links were discoverable. Muse let a user turn a conversation into a web page protected by nothing but the link. The address was built from the conversation title plus a random identifier, and passive DNS services, which log every hostname that resolves, had recorded the full addresses. Claroc pulled more than 6,800 shared hostnames from a commercial passive DNS database and opened several hundred pages. Most were harmless. Some held an internal revenue review, a loan application, a bank fraud alert with partial account details, a vacation calendar, and recruitment notes. More than 75 saved scans sat on the scanning service urlscan.io. Meta suspended sharing on October 20 and restored it on October 22 with new URLs. The old hostnames remain in passive DNS history.

Read together, the two incidents say the same thing. "Always allow" sounded like a convenience setting and behaved like delegated authority. A share link sounded like a secret and behaved like a public string once DNS had seen it. Neither failure was a stolen password. Both were permissions that kept working after the moment they were granted for had passed.

Why hiding the key is only half the fix

Credential isolation stops theft of the secret. It does not stop misuse of an approved action. An agent holding a stand-in token cannot exfiltrate your API key, but it can still spend every capability that token unlocks for as long as the token lives. The stand-in is only useful if the boundary behind it re-decides, on every single request, whether this call is still inside the job.

Meta's stack shows the shape of that boundary. According to Meta and the independent analyses of its published architecture, Sentinel evaluates the destination host, address, port, protocol, method, and path, applies SSRF restrictions, and uses data-flow tracking so a process tainted by untrusted content loses the automatic approval path. Security researcher Simon Willison's "lethal trifecta" (private data, untrusted content, and a channel outward) is the threat model in one line, and the boundary is what breaks the third leg once the first two are already true.

The pattern is reproducible outside Meta. A tutorial published on dev.to on October 23, 2026 walks through rebuilding the architecture with a persistent per-user machine and the Vercel AI SDK, and it is honest about the gap: the vendor's machines do not filter outbound traffic, so the decision about which steps require confirmation falls to the developer. That gap is where most agent credentials leak today. Not stolen from a vault. Handed out by a process that never learned to say no.

The proxy in three parts

The boundary has three moving pieces, and all of them fit in one file.

First, a registry that mints surrogate tokens: opaque random strings bound to a scope of hosts, path prefix, methods, and lifetime. A surrogate carries no rights of its own.

Second, a policy check on every request. The agent presents the surrogate and the URL it wants; the proxy compares the request against the scope and refuses anything outside it. Deny by default: a scope that names nothing allows nothing.

Third, the swap and the log. A passing request gets the real credential attached and forwarded, and every decision, allowed or refused, writes one audit line. The log is the only place the boundary's decisions exist after the fact.

// surrogate-proxy.js (Node 18+, no dependencies).
// The agent never sees the real credential; it only ever holds a surrogate.
const http = require("http");
const https = require("https");
const crypto = require("crypto");

const PORT = Number(process.env.PROXY_PORT || 8787);
const REAL_CREDENTIAL = process.env.REAL_CREDENTIAL;
if (!REAL_CREDENTIAL) { console.error("set REAL_CREDENTIAL first"); process.exit(1); }

const TTL_MS = 10 * 60 * 1000; // minutes, not days
// scope = { hosts: ["api.example.com"], pathPrefix: "/v1/", methods: ["POST"] }
const surrogates = new Map();

const audit = (...fields) => console.log(JSON.stringify([new Date().toISOString(), ...fields]));

function mintSurrogate(scope) {
  const handle = "sr_" + crypto.randomBytes(16).toString("hex");
  surrogates.set(handle, { scope, expires: Date.now() + TTL_MS });
  return handle;
}

function lookupSurrogate(handle) {
  const entry = surrogates.get(handle);
  if (!entry) return { ok: false, why: "unknown surrogate" };
  if (entry.expires < Date.now()) {
    surrogates.delete(handle);
    return { ok: false, why: "surrogate expired" };
  }
  return { ok: true, entry };
}

function scopeDenial(scope, target, method) {
  let url;
  try { url = new URL(target); } catch { return "unreadable target url"; }
  if (!scope.hosts.includes(url.hostname)) return "host not in scope";
  if (!url.pathname.startsWith(scope.pathPrefix)) return "path not in scope";
  if (!scope.methods.includes(method)) return "method not in scope";
  return null;
}

const server = http.createServer((req, res) => {
  const send = (code, body) => {
    res.writeHead(code, { "content-type": "application/json" });
    res.end(JSON.stringify(body));
  };

  // Orchestrator mints a surrogate scoped to one task.
  if (req.method === "POST" && req.url === "/surrogates") {
    let raw = "";
    req.on("data", (chunk) => { raw += chunk; });
    req.on("end", () => {
      let scope;
      try { scope = JSON.parse(raw); } catch { return send(400, { error: "scope must be json" }); }
      if (!scope || !scope.hosts || !scope.pathPrefix || !scope.methods) return send(400, { error: "scope needs hosts, pathPrefix, methods" });
      const handle = mintSurrogate(scope);
      audit("mint", handle, scope);
      send(201, { surrogate: handle, expiresInMs: TTL_MS });
    });
    return;
  }

  // The agent's call: it presents the surrogate, never the real credential.
  if (req.url !== "/call") return send(404, { error: "unknown path" });
  const surrogate = req.headers["x-surrogate-token"];
  const target = req.headers["x-target-url"];
  if (!surrogate) return send(401, { error: "missing surrogate" });
  if (!target) return send(400, { error: "missing target url" });

  const found = lookupSurrogate(surrogate);
  if (!found.ok) {
    audit("deny", surrogate, found.why);
    return send(403, { error: found.why });
  }
  const denial = scopeDenial(found.entry.scope, target, req.method);
  if (denial) {
    audit("deny", surrogate, target, denial);
    return send(403, { error: denial });
  }

  // The swap happens here and only here.
  const outgoing = new URL(target);
  const transport = outgoing.protocol === "https:" ? https : http;
  const upstream = transport.request({
    hostname: outgoing.hostname,
    port: outgoing.port || (outgoing.protocol === "https:" ? 443 : 80),
    path: outgoing.pathname + outgoing.search,
    method: req.method,
    headers: {
      "content-type": req.headers["content-type"] || "application/json",
      authorization: "Bearer " + REAL_CREDENTIAL,
      host: outgoing.hostname,
    },
  }, (up) => {
    res.writeHead(up.statusCode || 502, up.headers);
    up.pipe(res);
  });
  upstream.on("error", (err) => {
    audit("upstream-error", surrogate, target, String(err));
    send(502, { error: "upstream request failed" });
  });
  req.pipe(upstream);
  audit("swap", surrogate, target, req.method);
});

server.listen(PORT, () => audit("start", PORT, "real credential stays in this process"));
Enter fullscreen mode Exit fullscreen mode

Run it next to the agent, on the same machine or private network, never reachable from the internet. The real credential lives in exactly one process and nowhere else in your stack. Your orchestrator mints one surrogate per task by posting a scope (hosts, path prefix, methods) to /surrogates; the agent's code then calls the proxy, and the snippet below is the only credential the model ever sees:

// Agent side. No real key in this process, in the prompt, or in the logs.
const surrogate = "sr_9f2c4a1b"; // minted for this task
const response = await fetch("http://127.0.0.1:8787/call", {
  method: "POST",
  headers: {
    "x-surrogate-token": surrogate,
    "x-target-url": "https://api.example.com/v1/charges",
  },
  body: JSON.stringify({ amount: 4200, currency: "usd" }),
});
Enter fullscreen mode Exit fullscreen mode

When the task ends, drop the surrogate from the registry. Anything the agent still holds is a dead string.

Design notes that matter more than the code

1. One surrogate per task, not per user. A user-level token is a standing credential with extra steps. A task-level token expires with its job, so the worst case is one task's blast radius instead of the account's.

2. Minutes, not days, and deny by default. The example TTL is ten minutes: long enough for a multi-step task, short enough that a leaked surrogate goes stale. Name the hosts, the path prefix, and the methods. If a task needs a new host, mint a new surrogate; a scope that grows silently is a scope nobody reviews.

3. Log the refusals, not just the successes. A boundary that only records what it allowed cannot tell you what something tried. The refusal log shows an agent probing for hosts you never scoped.

4. Treat a stolen surrogate as a non-event. That is the point of the design: a surrogate is useless without the proxy, the only place the real credential exists. Rotating it is a config change in one process, not a hunt through every agent's environment.

5. Never ship an "always allow" switch. The Robb incident was not a bug in Muse's credential handling. It was a grant shape that kept applying after the user's mental model of it ended. If your policy layer has a mode that removes the per-request decision, that mode is the incident.

What this does not solve

The proxy becomes the high-value target. Everything that used to be spread across agent environments is now concentrated in one process. Run it with the same discipline you would give a secrets manager: no public exposure, its own credentials, least privilege, and a restart story that does not leak state.

It does not decide what should be approved. That is a product decision for a human-facing approval surface, not a regex in a proxy. Muse ships that surface as approval cards with scopes (once, this task, this site, always, deny); the proxy enforces those decisions, it does not make them.

It does not track taint. Meta uses eBPF-level data-flow tracking so a process that has read untrusted content loses the automatic path. A plain HTTP proxy cannot see that. What it can do is keep scopes small enough that a compromised agent runtime still cannot reach outside its task.

It does not make share links safe. The Claroc findings were a URL design problem: the hostname carried the title, passive DNS recorded it, and the link rebuilt itself. Meta fixed it by replacing the URLs; the DNS history remains. Different failure, same root cause as the other two. Convenience features ship without anyone asking how they can be discovered.

The agent proposes. The boundary disposes. If both jobs are done by the same process, you do not have a boundary, you have a hope.

This post was written with AI assistance. The author is responsible for its content.

Top comments (0)