{"_id":"@braedonsaunders/appkit-egress-proxy","_rev":"4-109ba422b562c66b7c8e34e4aca61ac7","name":"@braedonsaunders/appkit-egress-proxy","dist-tags":{"latest":"0.2.1"},"versions":{"0.1.0":{"name":"@braedonsaunders/appkit-egress-proxy","version":"0.1.0","keywords":["agents","appkit","application-framework","egress","proxy","sni","ssrf","typescript"],"author":{"name":"Braedon Saunders"},"license":"AGPL-3.0-or-later","_id":"@braedonsaunders/appkit-egress-proxy@0.1.0","maintainers":[{"name":"braedonsaunders","email":"bsaunders@rassaun.com"}],"homepage":"https://github.com/braedonsaunders/appkit/tree/main/packages/egress-proxy#readme","bugs":{"url":"https://github.com/braedonsaunders/appkit/issues"},"dist":{"shasum":"9118c559487afff55f0fa42f3c7e32f6d84f30d5","tarball":"https://registry.npmjs.org/@braedonsaunders/appkit-egress-proxy/-/appkit-egress-proxy-0.1.0.tgz","fileCount":19,"integrity":"sha512-a1Jsq7h7sC9T5CZ0PsU4xYmB64LQZBcZ3AWNvMzbTL8EacAvw4z+mptxr1lzReNqczNbxyincOql/TWifWVSfg==","signatures":[{"sig":"MEQCIDvCZS0SQGTLkiPq26szLXeOwlWnu80t17f6NVIGOJD2AiBkQvY5c+hPW0UZ+Zz9OVJcI/jb+DzaDgzDU8/gNl8Ehw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@braedonsaunders%2fappkit-egress-proxy@0.1.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":159026},"main":"./index.js","type":"module","_from":"file:braedonsaunders-appkit-egress-proxy-0.1.0.tgz","types":"./index.d.ts","engines":{"node":">=22"},"exports":{".":{"types":"./index.d.ts","import":"./index.js","default":"./index.js"},"./package.json":"./package.json"},"_npmUser":{"name":"braedonsaunders","email":"bsaunders@rassaun.com"},"_resolved":"/tmp/03bf753cd26a8263340ee414fb89457b/braedonsaunders-appkit-egress-proxy-0.1.0.tgz","_integrity":"sha512-a1Jsq7h7sC9T5CZ0PsU4xYmB64LQZBcZ3AWNvMzbTL8EacAvw4z+mptxr1lzReNqczNbxyincOql/TWifWVSfg==","repository":{"url":"git+https://github.com/braedonsaunders/appkit.git","type":"git","directory":"packages/egress-proxy"},"_npmVersion":"10.9.8","description":"Fail-closed egress chokepoint for agent sandboxes — explicit HTTP and CONNECT proxying plus transparent DNAT interception with SNI and Host sniffing, policy and audit ports, and no TLS interception.","directories":{},"_nodeVersion":"22.23.2","_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/appkit-egress-proxy_0.1.0_1787007448832_0.5656236366474436","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@braedonsaunders/appkit-egress-proxy","version":"0.1.1","keywords":["agents","appkit","application-framework","egress","proxy","sni","ssrf","typescript"],"author":{"name":"Braedon Saunders"},"license":"AGPL-3.0-or-later","_id":"@braedonsaunders/appkit-egress-proxy@0.1.1","maintainers":[{"name":"braedonsaunders","email":"bsaunders@rassaun.com"}],"homepage":"https://github.com/braedonsaunders/appkit/tree/main/packages/egress-proxy#readme","bugs":{"url":"https://github.com/braedonsaunders/appkit/issues"},"dist":{"shasum":"16fc543d3aa7a06f7533a743cb471b3f3f437bd2","tarball":"https://registry.npmjs.org/@braedonsaunders/appkit-egress-proxy/-/appkit-egress-proxy-0.1.1.tgz","fileCount":23,"integrity":"sha512-svFopWJDiWvkDDR0yyen8Sqp3dtHejo2i/h0D8bWakADV6U+AYc40wAD0Q0jMt5vCXTl4nBOd/UKew6cqO/a2g==","signatures":[{"sig":"MEUCIQDZ+nvOKrLhAbySCaEZo7n0z0HxCJSLagAEvkSkBMjemAIgUbr8fLVfKuVaX8/uAbeu1yuI6H9/g3f2rn7JEeVbQ04=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@braedonsaunders%2fappkit-egress-proxy@0.1.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":222182},"main":"./index.js","type":"module","_from":"file:braedonsaunders-appkit-egress-proxy-0.1.1.tgz","types":"./index.d.ts","engines":{"node":">=22"},"exports":{".":{"types":"./index.d.ts","import":"./index.js","default":"./index.js"},"./package.json":"./package.json","./secure-fetch":{"types":"./secure-fetch.d.ts","import":"./secure-fetch.js","default":"./secure-fetch.js"}},"_npmUser":{"name":"braedonsaunders","email":"bsaunders@rassaun.com"},"_resolved":"/tmp/d5ce3690f00c4064dcd2040643beb4fc/braedonsaunders-appkit-egress-proxy-0.1.1.tgz","_integrity":"sha512-svFopWJDiWvkDDR0yyen8Sqp3dtHejo2i/h0D8bWakADV6U+AYc40wAD0Q0jMt5vCXTl4nBOd/UKew6cqO/a2g==","repository":{"url":"git+https://github.com/braedonsaunders/appkit.git","type":"git","directory":"packages/egress-proxy"},"_npmVersion":"10.9.8","description":"Fail-closed egress chokepoint for agent sandboxes — explicit HTTP and CONNECT proxying plus transparent DNAT interception with SNI and Host sniffing, policy and audit ports, and no TLS interception.","directories":{},"_nodeVersion":"22.23.2","_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/appkit-egress-proxy_0.1.1_1787098999169_0.7485964670341496","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@braedonsaunders/appkit-egress-proxy","version":"0.2.0","keywords":["agents","appkit","application-framework","egress","proxy","sni","ssrf","typescript"],"author":{"name":"Braedon Saunders"},"license":"AGPL-3.0-or-later","_id":"@braedonsaunders/appkit-egress-proxy@0.2.0","maintainers":[{"name":"braedonsaunders","email":"bsaunders@rassaun.com"}],"homepage":"https://github.com/braedonsaunders/appkit/tree/main/packages/egress-proxy#readme","bugs":{"url":"https://github.com/braedonsaunders/appkit/issues"},"dist":{"shasum":"1eb3e7e1e3a367d91350760ceb35aca106f03637","tarball":"https://registry.npmjs.org/@braedonsaunders/appkit-egress-proxy/-/appkit-egress-proxy-0.2.0.tgz","fileCount":23,"integrity":"sha512-sUX7T+rqb8y1iJT89tsWttwv6OsJd9cvriFER66lny5tA1hvxis8JYVtw/LHQar39WtZiGW4fVt2Dkw58MaAyQ==","signatures":[{"sig":"MEQCICWHu0xoMm2AmQ3TeLXbOK1n2KLvmQiG1VVrIlDfCvOWAiAT/Ck+TyjDYGA4nmwyfb0ZkzT9qYNoi2m1CltVRqWA9A==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@braedonsaunders%2fappkit-egress-proxy@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":233048},"main":"./index.js","type":"module","_from":"file:braedonsaunders-appkit-egress-proxy-0.2.0.tgz","types":"./index.d.ts","engines":{"node":">=22"},"exports":{".":{"types":"./index.d.ts","import":"./index.js","default":"./index.js"},"./package.json":"./package.json","./secure-fetch":{"types":"./secure-fetch.d.ts","import":"./secure-fetch.js","default":"./secure-fetch.js"}},"_npmUser":{"name":"braedonsaunders","email":"bsaunders@rassaun.com"},"_resolved":"/tmp/fcdd6ee4bc73bd4bdf128ebfe593a031/braedonsaunders-appkit-egress-proxy-0.2.0.tgz","_integrity":"sha512-sUX7T+rqb8y1iJT89tsWttwv6OsJd9cvriFER66lny5tA1hvxis8JYVtw/LHQar39WtZiGW4fVt2Dkw58MaAyQ==","repository":{"url":"git+https://github.com/braedonsaunders/appkit.git","type":"git","directory":"packages/egress-proxy"},"_npmVersion":"10.9.8","description":"Fail-closed egress chokepoint for agent sandboxes — explicit HTTP and CONNECT proxying plus transparent DNAT interception with SNI and Host sniffing, policy and audit ports, and no TLS interception.","directories":{},"_nodeVersion":"22.23.2","_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/appkit-egress-proxy_0.2.0_1789003724565_0.5663153348232437","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"name":"@braedonsaunders/appkit-egress-proxy","version":"0.2.1","description":"Fail-closed egress chokepoint for agent sandboxes — explicit HTTP and CONNECT proxying plus transparent DNAT interception with SNI and Host sniffing, policy and audit ports, and no TLS interception.","license":"AGPL-3.0-or-later","type":"module","exports":{".":{"types":"./index.d.ts","import":"./index.js","default":"./index.js"},"./secure-fetch":{"types":"./secure-fetch.d.ts","import":"./secure-fetch.js","default":"./secure-fetch.js"},"./package.json":"./package.json"},"main":"./index.js","types":"./index.d.ts","author":{"name":"Braedon Saunders"},"repository":{"type":"git","url":"git+https://github.com/braedonsaunders/appkit.git","directory":"packages/egress-proxy"},"homepage":"https://github.com/braedonsaunders/appkit/tree/main/packages/egress-proxy#readme","bugs":{"url":"https://github.com/braedonsaunders/appkit/issues"},"engines":{"node":">=22"},"keywords":["agents","appkit","application-framework","egress","proxy","sni","ssrf","typescript"],"_id":"@braedonsaunders/appkit-egress-proxy@0.2.1","_integrity":"sha512-3MzES7XMzgX6UuX+Ndh9Png5KwkAokJ1A59QQdc/GUzfxiTjfL/Iw1+RLUMnwE8oq4KMO+WdkTDUZRmxGf69mA==","_resolved":"/tmp/235f8d5a9454d94da89a736dcf576e95/braedonsaunders-appkit-egress-proxy-0.2.1.tgz","_from":"file:braedonsaunders-appkit-egress-proxy-0.2.1.tgz","_nodeVersion":"22.23.2","_npmVersion":"10.9.8","dist":{"integrity":"sha512-3MzES7XMzgX6UuX+Ndh9Png5KwkAokJ1A59QQdc/GUzfxiTjfL/Iw1+RLUMnwE8oq4KMO+WdkTDUZRmxGf69mA==","shasum":"c2bce09c19df04868667f1164fa9a823ddcb6e6b","tarball":"https://registry.npmjs.org/@braedonsaunders/appkit-egress-proxy/-/appkit-egress-proxy-0.2.1.tgz","fileCount":23,"unpackedSize":235650,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@braedonsaunders%2fappkit-egress-proxy@0.2.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCELg6FkvmaCUjOgzLuXVGAV6RwDhJsp1lSH8TGA7IMtAIgdfdmMBhsXkEtbtQT1G2hS3tnJsN4YmrfLFKLYbKGo1Y="}]},"_npmUser":{"name":"braedonsaunders","email":"bsaunders@rassaun.com"},"directories":{},"maintainers":[{"name":"braedonsaunders","email":"bsaunders@rassaun.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/appkit-egress-proxy_0.2.1_1789076506041_0.5373533080566602"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-17T22:57:28.578Z","modified":"2026-09-10T21:41:46.506Z","0.1.0":"2026-08-17T22:57:29.050Z","0.1.1":"2026-08-19T00:23:19.321Z","0.2.0":"2026-09-10T01:28:44.693Z","0.2.1":"2026-09-10T21:41:46.169Z"},"bugs":{"url":"https://github.com/braedonsaunders/appkit/issues"},"author":{"name":"Braedon Saunders"},"license":"AGPL-3.0-or-later","homepage":"https://github.com/braedonsaunders/appkit/tree/main/packages/egress-proxy#readme","keywords":["agents","appkit","application-framework","egress","proxy","sni","ssrf","typescript"],"repository":{"type":"git","url":"git+https://github.com/braedonsaunders/appkit.git","directory":"packages/egress-proxy"},"description":"Fail-closed egress chokepoint for agent sandboxes — explicit HTTP and CONNECT proxying plus transparent DNAT interception with SNI and Host sniffing, policy and audit ports, and no TLS interception.","maintainers":[{"name":"braedonsaunders","email":"bsaunders@rassaun.com"}],"readme":"# @braedonsaunders/appkit-egress-proxy\n\nThe fail-closed egress chokepoint for agent sandboxes: explicit HTTP and\nCONNECT proxying plus transparent DNAT interception, with the destination\nrecovered from TLS SNI or the Host header, a caller-supplied allow/deny\npolicy, and an audit entry for every decision. Zero runtime dependencies —\nNode stdlib only.\n\n## Why the proxy sits outside the guest\n\nAn agent with a root shell inside its own machine can unset any proxy\nenvironment variable, rewrite its own resolver, or open raw sockets.\nPer-process egress controls inside that boundary are therefore not\nenforcement — they are a suggestion the agent is one line of shell away from\ndeclining. This proxy is built to run on the *host*, outside the boundary the\nagent controls, with all guest traffic transparently DNAT'd into it. The\nguest cannot opt out: there is no proxy setting to unset, and the redirect\ncovers every application including those that ignore `HTTP_PROXY`.\n\nOne listener accepts three connection shapes:\n\n- **Explicit HTTP proxying** — absolute-form requests\n  (`GET http://host/path HTTP/1.1`). The head is rewritten to origin form,\n  hop-by-hop and proxy-credential headers (`proxy-authorization` among them)\n  are stripped, `connection: close` is forced so a pipelined second request\n  can never ride an already-authorized flow, and the body is spliced through.\n- **CONNECT tunneling** — the host check happens at CONNECT time; a denial is\n  a 403 before any client byte reaches the destination; an allow becomes a\n  bidirectional pipe. TLS is never broken open.\n- **Transparent interception** — a DNAT'd flow where the client never learns a\n  proxy exists. For TLS the ClientHello is peeked and the SNI extracted with a\n  pure parser, without terminating TLS; for plain HTTP the Host header is\n  sniffed the same way. A flow whose destination cannot be recovered — a\n  truncated or malformed ClientHello, a request with no Host header, raw bytes\n  in an unknown protocol — is denied and audited. Nothing is guessed.\n\n## Usage\n\n```ts\nimport { createEgressProxy } from '@braedonsaunders/appkit-egress-proxy'\n\nconst proxy = createEgressProxy({\n  policy: ({ host, port, protocol, principal }) =>\n    allowlistFor(principal).has(host) ? 'allow' : 'deny',\n  audit: (entry) => ledger.append(entry),\n  listen: { host: '10.200.0.1', port: 3128 },\n  principalFor: (socket) => agentByTapAddress(socket.remoteAddress),\n})\n\nconst bound = await proxy.listen()\n// ... proxy.stats() → { active, total, denied }\nawait proxy.close()\n```\n\nServer-side HTTP clients use the same public-address policy through the\nDNS-pinned secure transport:\n\n```ts\nimport { secureFetch } from '@braedonsaunders/appkit-egress-proxy/secure-fetch'\n\nconst response = await secureFetch('https://api.example.com/data', {\n  timeoutMs: 15_000,\n  maxResponseBytes: 1024 * 1024,\n})\n```\n\nThe transport accepts HTTPS only, validates every DNS answer before opening\nthe socket, connects to the validated address without re-resolving, validates\nredirects at every hop, and bounds request size, response size, and time.\n\n## The policy and audit port contract\n\nThe **policy** port is consulted once per flow, before any byte reaches the\ndestination, with `{ host, port, protocol, principal }`. `host` is the\nnormalized hostname (IDNA, lower-case, brackets stripped). `protocol` is\n`https` for SNI-sniffed flows and CONNECT to port 443, `http` for plain HTTP\nin either form, and `tcp` for CONNECT to any other port — the tunnel is\nopaque, so the payload protocol is unknown. The policy may be synchronous or\nasynchronous. Anything other than a clean `'allow'` return — a `'deny'`, a\nthrow, a rejection — denies the flow. The consuming application supplies the\nactual rules; this package only enforces them at the boundary.\n\nThe **audit** port receives an entry for every decision, allow and deny alike,\nwith host, port, protocol, principal, decision, reason, and timestamp:\n\n- `decision` — every allow and every deny, including denials for unparseable\n  destinations (where `host` is null) and policy exceptions.\n- `upstream-error` — a failure after an allow: either the destination was\n  refused by host policy (`decision: 'deny'`) or the connection itself failed\n  (`decision: 'allow'`).\n- `flow-closed` — byte counts in each direction when an established flow ends.\n\nThe audit sink must not throw; a sink that does loses that one entry, never\nthe connection.\n\nDenied flows that speak HTTP to the proxy get a 403 with a short plain-text\nbody naming the denial. Transparent TLS flows are simply closed — there is no\nway to say 403 into a TLS handshake without impersonating the destination.\n\n## Upstream address policy\n\nAllowing a hostname is not the same as allowing the address it resolves to: a\nguest can register a public name that resolves to `10.0.0.5`. By default the\nproxy resolves allowed destinations itself and requires **every** DNS answer\nto be public — rejecting the whole answer set rather than picking a public\nanswer, since round-robin fallback would otherwise reach a private address —\nand then connects to the checked address rather than re-resolving. The same\nblock lists cover IPv4 and IPv6 (kept separate because Node's `BlockList`\ntreats IPv4 input as v4-mapped IPv6 when a mapped subnet is present, which\nwould reject every IPv4 address). Supply `resolveUpstream` to integrate a\ndifferent resolver; whatever it returns is dialled verbatim, so it inherits\nresponsibility for the address check.\n\nThe canonical host rules and DNS-pinned HTTPS transport live in this package.\n`@braedonsaunders/appkit-sync/egress` remains a compatibility re-export for\nexisting connector consumers.\n\n## Transparent deployment sketch\n\nOn the sandbox host, redirect everything the guest emits on its tap device\ninto the proxy. The guest routes normally; the host rewrites the destination\nbefore the packet ever leaves.\n\n```sh\n# All TCP from the guest tap is redirected into the egress proxy.\n# The guest cannot opt out — there is no proxy setting to unset, and this\n# covers every application including those that ignore HTTP_PROXY.\niptables -t nat -A PREROUTING -i tap-agent0 -p tcp \\\n  -j REDIRECT --to-ports 3128\n\n# Or, when the proxy runs on another interface of the same host:\niptables -t nat -A PREROUTING -i tap-agent0 -p tcp \\\n  -j DNAT --to-destination 10.200.0.1:3128\n\n# UDP is not proxied: give the guest a host-side resolver and drop the rest,\n# or QUIC and arbitrary UDP become an unaudited side channel.\niptables -A FORWARD -i tap-agent0 -p udp ! --dport 53 -j DROP\n```\n\nUnder `REDIRECT`, the original destination port is recoverable via\n`SO_ORIGINAL_DST`; supply it through `originalDestinationFor` and the proxy\nwill use that port for transparent flows. Without it, transparent TLS assumes\n443 and transparent HTTP uses the Host header port or 80\n(`transparentHttpsPort` / `transparentHttpPort` override the defaults). The\nhostname always comes from SNI or the Host header — a name is what the policy\nreasons about.\n\nAttribute flows to agents with `principalFor`, typically by the source address\nof the guest tap. The principal reaches both the policy and every audit entry.\n\n## No MITM by default\n\nTLS is never terminated. The host check happens at CONNECT time or from the\npeeked SNI, so the proxy sees who the guest talks to but never what is said.\nThere is no TLS interception code path in this package at all — no\ncertificate authority, no re-encryption, no flag to turn one on — so a\ndeployment cannot drift into interception by misconfiguration.\n\n## Scope and limitations\n\n- Destination identity comes from client-supplied bytes (SNI, Host). The\n  policy check gates the *name*, and the upstream connection goes to the\n  address that name resolves to on the host side — so lying in the SNI sends\n  the guest's bytes to the named host, not to the one it was trying to smuggle\n  traffic toward. Distinguishing virtual hosts behind one address is the\n  policy's concern, not the transport's.\n- One destination per connection. Explicit HTTP forces `connection: close`;\n  transparent flows splice bytes to a single checked destination.\n- TCP only. UDP (including QUIC and DNS) must be handled at the firewall, as\n  in the sketch above.\n\n## Deferred\n\n- **MITM with a generated CA** for body-level audit is deliberately not\n  implemented, matching the build spec's deferred list (§9). Shipping a\n  half-implementation would put a CA key on the chokepoint for no enforcement\n  gain; the decision point returns if body-level audit ever becomes a real\n  requirement.\n- **Built-in `SO_ORIGINAL_DST` recovery.** Reading the original destination is\n  a platform-specific `getsockopt`; the `originalDestinationFor` hook is the\n  seam where a Linux deployment plugs it in.\n","readmeFilename":"README.md"}