@agentproto/egress 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/index.d.ts +196 -0
- package/dist/index.mjs +165 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 agentproto contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# @agentproto/egress
|
|
2
|
+
|
|
3
|
+
Outbound traffic control for agent sandboxes. Mode registry, provider allowlist, transport-agnostic proxy core.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @agentproto/egress
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Concept
|
|
10
|
+
|
|
11
|
+
Agent CLIs make outbound HTTP calls to upstream APIs (OpenAI, Anthropic, custom MCP servers, …). Letting the agent hold raw API keys means a `printenv` or prompt-injection-driven exfil leaks them. This package implements **the egress side of "agent uses the credential but never sees it"**: cooperative mode injects `$$SECRET[NAME]$$` placeholders into the agent's env; the host's egress proxy substitutes the real value at the network boundary.
|
|
12
|
+
|
|
13
|
+
## Modes
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
off no controls — raw creds in agent env (today's default)
|
|
17
|
+
cooperative $$SECRET[NAME]$$ placeholders + BASE_URL envs + HTTP_PROXY env
|
|
18
|
+
strict cooperative + sandbox-level enforcement (NAT or runtime mandate)
|
|
19
|
+
paranoid strict + TLS-MITM (catches anything new)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Hosts pick which modes their tier policy exposes. Bootstrap consumers branch on the mode's **declarative flags** (`emitsPlaceholders`, `emitsBaseUrlEnvs`, …) — never on the id, so adding a new mode never edits a switch.
|
|
23
|
+
|
|
24
|
+
## Usage (host adapter)
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import {
|
|
28
|
+
proxyEgressRequest,
|
|
29
|
+
COMMON_EGRESS_PROVIDERS,
|
|
30
|
+
EgressError,
|
|
31
|
+
} from "@agentproto/egress"
|
|
32
|
+
|
|
33
|
+
// In your HTTP framework (Hono / Express / Bun):
|
|
34
|
+
const result = await proxyEgressRequest({
|
|
35
|
+
request: {
|
|
36
|
+
providerId: "openai",
|
|
37
|
+
path: "/chat/completions",
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: req.headers,
|
|
40
|
+
body: req.body,
|
|
41
|
+
search: req.search,
|
|
42
|
+
},
|
|
43
|
+
providers: COMMON_EGRESS_PROVIDERS,
|
|
44
|
+
resolver: async (name) => yourVault.lookup(guildId, name),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const upstream = await fetch(result.url, {
|
|
48
|
+
method: result.method,
|
|
49
|
+
headers: result.headers,
|
|
50
|
+
body: result.body,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Audit the substitutions
|
|
54
|
+
for (const r of result.substitutions) {
|
|
55
|
+
yourAudit.log({ guildId, secretName: r.name, resolved: r.resolved })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return upstream
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Modules
|
|
62
|
+
|
|
63
|
+
- **`./modes`** — `EgressModeRegistry` + `DEFAULT_EGRESS_MODES`
|
|
64
|
+
- **`./providers`** — `EgressProvider` + `COMMON_EGRESS_PROVIDERS` (openai, anthropic) + `composeEgressProviders` for extending
|
|
65
|
+
- **`./proxy`** — `proxyEgressRequest` + `EgressError`
|
|
66
|
+
|
|
67
|
+
## Related
|
|
68
|
+
|
|
69
|
+
- **`@agentproto/secrets`** — declares `SecretExposure` shapes (env / file / egress-substitute) and the `$$SECRET[NAME]$$` substitution engine. This package depends on it.
|
|
70
|
+
- **AIP-19** — SECRETS.md doctype declares what secrets exist + how they're exposed
|
|
71
|
+
- **AIP-X (future)** — would standardize the egress mode taxonomy and the cooperative-mode wire shape
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { SecretResolver, SubstitutionRecord } from '@agentproto/secrets/exposure';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Egress mode registry — describes how outbound traffic from an agent
|
|
5
|
+
* sandbox is controlled at the network boundary.
|
|
6
|
+
*
|
|
7
|
+
* Four canonical modes ship by default; hosts can `extend` with
|
|
8
|
+
* custom modes for their own infra. Bootstrap consumers branch on
|
|
9
|
+
* the mode's **declarative flags**, never on the id, so adding a
|
|
10
|
+
* new mode never edits a switch.
|
|
11
|
+
*
|
|
12
|
+
* # Mode hierarchy (weakest → strongest)
|
|
13
|
+
* off — no controls. Real credentials in agent env. Today's default.
|
|
14
|
+
* cooperative — agent sees `$$SECRET[NAME]$$` placeholders + BASE_URL envs
|
|
15
|
+
* + HTTP_PROXY env. Substituted by host's egress proxy at
|
|
16
|
+
* network boundary. Bypassable from inside (libs that
|
|
17
|
+
* ignore HTTP_PROXY); but agent has no real credentials.
|
|
18
|
+
* strict — cooperative + sandbox-level network enforcement (iptables
|
|
19
|
+
* NAT to local sidecar, or HTTP_PROXY enforced by container
|
|
20
|
+
* runtime). Agent literally cannot reach the network
|
|
21
|
+
* except via the proxy.
|
|
22
|
+
* paranoid — strict + TLS-MITM at the sidecar. Even calls to unknown
|
|
23
|
+
* destinations get intercepted. Catches anything new the
|
|
24
|
+
* agent might attempt.
|
|
25
|
+
*
|
|
26
|
+
* Hosts pick which modes are exposed to which guilds via their own
|
|
27
|
+
* tier/policy system (Guilde uses `PlanPolicy.egressModes`).
|
|
28
|
+
*/
|
|
29
|
+
type EgressModeId = "off" | "cooperative" | "strict" | "paranoid";
|
|
30
|
+
interface EgressModeDefinition {
|
|
31
|
+
id: EgressModeId;
|
|
32
|
+
label: string;
|
|
33
|
+
description: string;
|
|
34
|
+
/** Surface-level state. UI shows a "preview" badge when not stable. */
|
|
35
|
+
status: "stable" | "preview" | "experimental";
|
|
36
|
+
/**
|
|
37
|
+
* Declarative bootstrap behavior. Bootstrap consumers branch on these
|
|
38
|
+
* flags, not on the id, so adding a new mode never edits a switch.
|
|
39
|
+
*/
|
|
40
|
+
emitsPlaceholders: boolean;
|
|
41
|
+
emitsBaseUrlEnvs: boolean;
|
|
42
|
+
emitsHttpProxyEnvs: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Sandbox runtime requirements. When the host's runtime can't satisfy
|
|
45
|
+
* these (e.g. managed e2b without NET_ADMIN), the mode is unavailable
|
|
46
|
+
* AND the bootstrap should refuse to silently degrade — surface the
|
|
47
|
+
* shortfall so the operator can pick a different mode explicitly.
|
|
48
|
+
*/
|
|
49
|
+
requiresSandboxNetCap: boolean;
|
|
50
|
+
requiresMitmCa: boolean;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Composable registry. Mirror of `EntitlementRegistry` shape from
|
|
54
|
+
* `@agstudio/core`/billing — same chainable methods so hosts can
|
|
55
|
+
* customize without forking the default set.
|
|
56
|
+
*/
|
|
57
|
+
declare class EgressModeRegistry {
|
|
58
|
+
private readonly entries;
|
|
59
|
+
register(def: EgressModeDefinition): this;
|
|
60
|
+
get(id: EgressModeId): EgressModeDefinition | undefined;
|
|
61
|
+
getAll(): EgressModeDefinition[];
|
|
62
|
+
/** Append entries from another source. Later wins on id collision. */
|
|
63
|
+
extend(defs: readonly EgressModeDefinition[]): this;
|
|
64
|
+
/** Drop entries by id. Useful for hosts that want to forbid a built-in. */
|
|
65
|
+
without(...ids: readonly EgressModeId[]): this;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Default registry pre-loaded with the four canonical modes. Hosts
|
|
69
|
+
* import this directly OR call `new EgressModeRegistry().extend(DEFAULT_EGRESS_MODES)`
|
|
70
|
+
* to start from a clean slate they own.
|
|
71
|
+
*/
|
|
72
|
+
declare const DEFAULT_EGRESS_MODES: readonly EgressModeDefinition[];
|
|
73
|
+
/** Convenience factory — most hosts use this directly. */
|
|
74
|
+
declare function createDefaultEgressModeRegistry(): EgressModeRegistry;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Egress provider allowlist — the set of upstream URLs the proxy is
|
|
78
|
+
* willing to forward traffic to. Keeps the route from being a generic
|
|
79
|
+
* "proxy anywhere" hole.
|
|
80
|
+
*
|
|
81
|
+
* Hosts pick their own subset and may extend with private upstreams.
|
|
82
|
+
* Each entry is keyed by a stable provider id (`openai`, `anthropic`,
|
|
83
|
+
* …) used in the egress URL: `${apiUrl}/egress/<providerId>/...`.
|
|
84
|
+
*
|
|
85
|
+
* `pathPrefix` lets entries that have a versioned root (`/v1` for
|
|
86
|
+
* openai) match the SDK's default base URL convention — the agent's
|
|
87
|
+
* SDK sends `${BASE_URL}/chat/completions` and the proxy resolves to
|
|
88
|
+
* `${upstream}${pathPrefix}/chat/completions`.
|
|
89
|
+
*/
|
|
90
|
+
interface EgressProvider {
|
|
91
|
+
id: string;
|
|
92
|
+
/** Public origin (scheme + host). No trailing slash; no path. */
|
|
93
|
+
upstream: string;
|
|
94
|
+
/** Path prefix appended after the provider id in the egress URL.
|
|
95
|
+
* e.g. `/v1` for openai so SDKs configured with
|
|
96
|
+
* `OPENAI_BASE_URL=${apiUrl}/egress/openai/v1` work natively. */
|
|
97
|
+
pathPrefix?: string;
|
|
98
|
+
/** Header name SDKs use to send credentials. Information-only —
|
|
99
|
+
* substitution happens on whatever header the agent sends; this
|
|
100
|
+
* is for UI hints and audit grouping. */
|
|
101
|
+
authHeader?: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The shipped allowlist. Hosts compose by spreading this + their own.
|
|
105
|
+
* Kept narrow on purpose — the user-visible decision is "which providers
|
|
106
|
+
* does my proxy accept", not "which providers does the SDK universe
|
|
107
|
+
* support".
|
|
108
|
+
*/
|
|
109
|
+
declare const COMMON_EGRESS_PROVIDERS: Record<string, EgressProvider>;
|
|
110
|
+
/** Convenience: build a registry from a base + extensions. */
|
|
111
|
+
declare function composeEgressProviders(...sources: Array<Record<string, EgressProvider>>): Record<string, EgressProvider>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Transport-agnostic egress proxy core.
|
|
115
|
+
*
|
|
116
|
+
* Takes a normalized request shape + a provider lookup + a secret
|
|
117
|
+
* resolver, returns a normalized rewritten request ready to forward.
|
|
118
|
+
* Doesn't know about Hono, Express, or any HTTP framework — hosts
|
|
119
|
+
* adapt their server framework to this shape.
|
|
120
|
+
*
|
|
121
|
+
* Why split: keeps the proxy logic testable in isolation + lets new
|
|
122
|
+
* runtime hosts (Bun, Deno, Cloudflare Workers, etc.) reuse the same
|
|
123
|
+
* core without dragging in node:http or similar.
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/** Body type permissive enough for any reasonable runtime — node `fetch`,
|
|
127
|
+
* Bun, Deno, Cloudflare Workers, browser. We don't pull in DOM types
|
|
128
|
+
* (that would expose them to every consumer of this package); the
|
|
129
|
+
* host's `fetch` call accepts whatever shape it understands. */
|
|
130
|
+
type EgressBody = string | Uint8Array | ArrayBuffer | ReadableStream<Uint8Array> | {
|
|
131
|
+
stream(): ReadableStream<Uint8Array>;
|
|
132
|
+
};
|
|
133
|
+
/** Inbound request shape — host-framework-agnostic. */
|
|
134
|
+
interface EgressRequest {
|
|
135
|
+
/** Provider id from the URL — `/egress/<providerId>/...`. */
|
|
136
|
+
providerId: string;
|
|
137
|
+
/** Path inside the provider — what comes after `/egress/<providerId>`.
|
|
138
|
+
* Should start with `/`. */
|
|
139
|
+
path: string;
|
|
140
|
+
/** HTTP method, uppercase. */
|
|
141
|
+
method: string;
|
|
142
|
+
/** Header bag — string values only (multi-value headers should be
|
|
143
|
+
* pre-joined by the host framework). */
|
|
144
|
+
headers: Record<string, string>;
|
|
145
|
+
/** Optional request body. Substitution does NOT scan the body in
|
|
146
|
+
* v0 — only headers. Body opt-in per provider lands later. */
|
|
147
|
+
body?: EgressBody | null;
|
|
148
|
+
/** Query string verbatim (with leading `?`) or empty string. */
|
|
149
|
+
search: string;
|
|
150
|
+
}
|
|
151
|
+
/** Outbound, rewritten request shape — what the host fetches. */
|
|
152
|
+
interface RewrittenEgressRequest {
|
|
153
|
+
url: string;
|
|
154
|
+
method: string;
|
|
155
|
+
headers: Record<string, string>;
|
|
156
|
+
body?: EgressBody | null;
|
|
157
|
+
/** Audit record — names of secrets that were substituted into the
|
|
158
|
+
* outbound headers, in match order. */
|
|
159
|
+
substitutions: SubstitutionRecord[];
|
|
160
|
+
}
|
|
161
|
+
interface ProxyEgressRequestOptions {
|
|
162
|
+
request: EgressRequest;
|
|
163
|
+
/** Provider allowlist — typically `COMMON_EGRESS_PROVIDERS` merged
|
|
164
|
+
* with host-specific entries. Unknown provider id → throws
|
|
165
|
+
* EgressError("unknown_provider"). */
|
|
166
|
+
providers: Record<string, EgressProvider>;
|
|
167
|
+
/** Per-call secret resolver. The host wires its vault here. Returns
|
|
168
|
+
* null when a placeholder name doesn't resolve — the proxy keeps
|
|
169
|
+
* the placeholder verbatim and the upstream then 401s, which is
|
|
170
|
+
* exactly the right surface (agent learns the secret isn't allowed
|
|
171
|
+
* via the upstream's own auth-failure response). */
|
|
172
|
+
resolver: SecretResolver;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Build the rewritten request for an inbound egress call. The host
|
|
176
|
+
* uses the result with `fetch(rewritten.url, rewritten)` (or its
|
|
177
|
+
* runtime's equivalent). Audit records are returned alongside so the
|
|
178
|
+
* host can log per-call substitutions to its usage_events table.
|
|
179
|
+
*
|
|
180
|
+
* Throws `EgressError` with stable codes for the known failure modes
|
|
181
|
+
* — host framework adapter maps to its own HTTP responses (typically
|
|
182
|
+
* 400 / 404 / 500).
|
|
183
|
+
*/
|
|
184
|
+
declare function proxyEgressRequest(opts: ProxyEgressRequestOptions): Promise<RewrittenEgressRequest>;
|
|
185
|
+
/** Stable error class for proxy-level failures. Codes:
|
|
186
|
+
* - `unknown_provider` — providerId not in allowlist
|
|
187
|
+
* - `secret_value_empty` — resolver returned an empty value
|
|
188
|
+
* - `secret_value_unsafe` — resolved value contained CR/LF/NUL
|
|
189
|
+
* - `invalid_placeholder_name`— placeholder name failed regex
|
|
190
|
+
*/
|
|
191
|
+
declare class EgressError extends Error {
|
|
192
|
+
readonly code: string;
|
|
193
|
+
constructor(code: string, message: string);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export { COMMON_EGRESS_PROVIDERS, DEFAULT_EGRESS_MODES, EgressError, type EgressModeDefinition, type EgressModeId, EgressModeRegistry, type EgressProvider, type EgressRequest, type ProxyEgressRequestOptions, type RewrittenEgressRequest, composeEgressProviders, createDefaultEgressModeRegistry, proxyEgressRequest };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { substituteSecrets, SecretSubstitutionError } from '@agentproto/secrets/exposure';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @agentproto/egress v0.1.0-alpha
|
|
5
|
+
* Outbound traffic control for agent sandboxes — mode registry,
|
|
6
|
+
* proxy core, audit hooks. Cooperative mode delegates substitution
|
|
7
|
+
* to @agentproto/secrets/exposure.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// src/modes.ts
|
|
11
|
+
var EgressModeRegistry = class {
|
|
12
|
+
entries = /* @__PURE__ */ new Map();
|
|
13
|
+
register(def) {
|
|
14
|
+
this.entries.set(def.id, def);
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
17
|
+
get(id) {
|
|
18
|
+
return this.entries.get(id);
|
|
19
|
+
}
|
|
20
|
+
getAll() {
|
|
21
|
+
return [...this.entries.values()];
|
|
22
|
+
}
|
|
23
|
+
/** Append entries from another source. Later wins on id collision. */
|
|
24
|
+
extend(defs) {
|
|
25
|
+
for (const def of defs) this.register(def);
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
/** Drop entries by id. Useful for hosts that want to forbid a built-in. */
|
|
29
|
+
without(...ids) {
|
|
30
|
+
for (const id of ids) this.entries.delete(id);
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var DEFAULT_EGRESS_MODES = [
|
|
35
|
+
{
|
|
36
|
+
id: "off",
|
|
37
|
+
label: "Direct (off)",
|
|
38
|
+
description: "No egress controls. Real credentials injected into agent env / files. Lowest latency; widest blast radius if the agent leaks.",
|
|
39
|
+
status: "stable",
|
|
40
|
+
emitsPlaceholders: false,
|
|
41
|
+
emitsBaseUrlEnvs: false,
|
|
42
|
+
emitsHttpProxyEnvs: false,
|
|
43
|
+
requiresSandboxNetCap: false,
|
|
44
|
+
requiresMitmCa: false
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "cooperative",
|
|
48
|
+
label: "Cooperative",
|
|
49
|
+
description: "Agent sees only $$SECRET[NAME]$$ placeholders. Real credentials substituted at the host's egress proxy. Network bypassable by libs that ignore HTTP_PROXY, but the agent has nothing real to bypass with.",
|
|
50
|
+
status: "stable",
|
|
51
|
+
emitsPlaceholders: true,
|
|
52
|
+
emitsBaseUrlEnvs: true,
|
|
53
|
+
emitsHttpProxyEnvs: true,
|
|
54
|
+
requiresSandboxNetCap: false,
|
|
55
|
+
requiresMitmCa: false
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "strict",
|
|
59
|
+
label: "Strict",
|
|
60
|
+
description: "Cooperative + sandbox-level enforcement (iptables NAT or runtime-level proxy mandate). Agent cannot reach the network except via the proxy.",
|
|
61
|
+
status: "preview",
|
|
62
|
+
emitsPlaceholders: true,
|
|
63
|
+
emitsBaseUrlEnvs: true,
|
|
64
|
+
emitsHttpProxyEnvs: true,
|
|
65
|
+
requiresSandboxNetCap: true,
|
|
66
|
+
requiresMitmCa: false
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "paranoid",
|
|
70
|
+
label: "Paranoid",
|
|
71
|
+
description: "Strict + TLS-MITM at the sidecar. Catches any outbound call, including to destinations the host didn't anticipate. Breaks SDKs with cert pinning.",
|
|
72
|
+
status: "preview",
|
|
73
|
+
emitsPlaceholders: true,
|
|
74
|
+
emitsBaseUrlEnvs: true,
|
|
75
|
+
emitsHttpProxyEnvs: true,
|
|
76
|
+
requiresSandboxNetCap: true,
|
|
77
|
+
requiresMitmCa: true
|
|
78
|
+
}
|
|
79
|
+
];
|
|
80
|
+
function createDefaultEgressModeRegistry() {
|
|
81
|
+
return new EgressModeRegistry().extend(DEFAULT_EGRESS_MODES);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/providers.ts
|
|
85
|
+
var COMMON_EGRESS_PROVIDERS = {
|
|
86
|
+
openai: {
|
|
87
|
+
id: "openai",
|
|
88
|
+
upstream: "https://api.openai.com",
|
|
89
|
+
pathPrefix: "/v1",
|
|
90
|
+
authHeader: "Authorization"
|
|
91
|
+
},
|
|
92
|
+
anthropic: {
|
|
93
|
+
id: "anthropic",
|
|
94
|
+
upstream: "https://api.anthropic.com",
|
|
95
|
+
authHeader: "x-api-key"
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
function composeEgressProviders(...sources) {
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const src of sources) Object.assign(out, src);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
async function proxyEgressRequest(opts) {
|
|
104
|
+
const provider = opts.providers[opts.request.providerId];
|
|
105
|
+
if (!provider) {
|
|
106
|
+
throw new EgressError(
|
|
107
|
+
"unknown_provider",
|
|
108
|
+
`Unknown egress provider '${opts.request.providerId}'.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const url = buildUpstreamUrl(provider, opts.request.path, opts.request.search);
|
|
112
|
+
const rewrittenHeaders = {};
|
|
113
|
+
const allRecords = [];
|
|
114
|
+
for (const [name, value] of Object.entries(opts.request.headers)) {
|
|
115
|
+
if (HOP_BY_HOP.has(name.toLowerCase())) continue;
|
|
116
|
+
try {
|
|
117
|
+
const result = await substituteSecrets(value, opts.resolver);
|
|
118
|
+
rewrittenHeaders[name] = result.output;
|
|
119
|
+
allRecords.push(...result.replacements);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err instanceof SecretSubstitutionError) {
|
|
122
|
+
const sub = err;
|
|
123
|
+
throw new EgressError(sub.code, sub.message);
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
delete rewrittenHeaders["host"];
|
|
129
|
+
delete rewrittenHeaders["Host"];
|
|
130
|
+
return {
|
|
131
|
+
url,
|
|
132
|
+
method: opts.request.method,
|
|
133
|
+
headers: rewrittenHeaders,
|
|
134
|
+
body: opts.request.body ?? null,
|
|
135
|
+
substitutions: allRecords
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function buildUpstreamUrl(provider, path, search) {
|
|
139
|
+
const base = provider.upstream.replace(/\/$/, "");
|
|
140
|
+
const prefix = provider.pathPrefix?.replace(/\/$/, "") ?? "";
|
|
141
|
+
const normPath = path.startsWith("/") ? path : `/${path}`;
|
|
142
|
+
return `${base}${prefix}${normPath}${search}`;
|
|
143
|
+
}
|
|
144
|
+
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
145
|
+
"connection",
|
|
146
|
+
"keep-alive",
|
|
147
|
+
"proxy-authenticate",
|
|
148
|
+
"proxy-authorization",
|
|
149
|
+
"te",
|
|
150
|
+
"trailer",
|
|
151
|
+
"transfer-encoding",
|
|
152
|
+
"upgrade"
|
|
153
|
+
]);
|
|
154
|
+
var EgressError = class extends Error {
|
|
155
|
+
code;
|
|
156
|
+
constructor(code, message) {
|
|
157
|
+
super(message);
|
|
158
|
+
this.code = code;
|
|
159
|
+
this.name = "EgressError";
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export { COMMON_EGRESS_PROVIDERS, DEFAULT_EGRESS_MODES, EgressError, EgressModeRegistry, composeEgressProviders, createDefaultEgressModeRegistry, proxyEgressRequest };
|
|
164
|
+
//# sourceMappingURL=index.mjs.map
|
|
165
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/modes.ts","../src/providers.ts","../src/proxy.ts"],"names":[],"mappings":";;;;;;;;;;AAyDO,IAAM,qBAAN,MAAyB;AAAA,EACb,OAAA,uBAAc,GAAA,EAAwC;AAAA,EAEvE,SAAS,GAAA,EAAiC;AACxC,IAAA,IAAA,CAAK,OAAA,CAAQ,GAAA,CAAI,GAAA,CAAI,EAAA,EAAI,GAAG,CAAA;AAC5B,IAAA,OAAO,IAAA;AAAA,EACT;AAAA,EAEA,IAAI,EAAA,EAAoD;AACtD,IAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,GAAA,CAAI,EAAE,CAAA;AAAA,EAC5B;AAAA,EAEA,MAAA,GAAiC;AAC/B,IAAA,OAAO,CAAC,GAAG,IAAA,CAAK,OAAA,CAAQ,QAAQ,CAAA;AAAA,EAClC;AAAA;AAAA,EAGA,OAAO,IAAA,EAA6C;AAClD,IAAA,KAAA,MAAW,GAAA,IAAO,IAAA,EAAM,IAAA,CAAK,QAAA,CAAS,GAAG,CAAA;AACzC,IAAA,OAAO,IAAA;AAAA,EACT;AAAA;AAAA,EAGA,WAAW,GAAA,EAAoC;AAC7C,IAAA,KAAA,MAAW,EAAA,IAAM,GAAA,EAAK,IAAA,CAAK,OAAA,CAAQ,OAAO,EAAE,CAAA;AAC5C,IAAA,OAAO,IAAA;AAAA,EACT;AACF;AAOO,IAAM,oBAAA,GAAwD;AAAA,EACnE;AAAA,IACE,EAAA,EAAI,KAAA;AAAA,IACJ,KAAA,EAAO,cAAA;AAAA,IACP,WAAA,EACE,+HAAA;AAAA,IACF,MAAA,EAAQ,QAAA;AAAA,IACR,iBAAA,EAAmB,KAAA;AAAA,IACnB,gBAAA,EAAkB,KAAA;AAAA,IAClB,kBAAA,EAAoB,KAAA;AAAA,IACpB,qBAAA,EAAuB,KAAA;AAAA,IACvB,cAAA,EAAgB;AAAA,GAClB;AAAA,EACA;AAAA,IACE,EAAA,EAAI,aAAA;AAAA,IACJ,KAAA,EAAO,aAAA;AAAA,IACP,WAAA,EACE,2MAAA;AAAA,IACF,MAAA,EAAQ,QAAA;AAAA,IACR,iBAAA,EAAmB,IAAA;AAAA,IACnB,gBAAA,EAAkB,IAAA;AAAA,IAClB,kBAAA,EAAoB,IAAA;AAAA,IACpB,qBAAA,EAAuB,KAAA;AAAA,IACvB,cAAA,EAAgB;AAAA,GAClB;AAAA,EACA;AAAA,IACE,EAAA,EAAI,QAAA;AAAA,IACJ,KAAA,EAAO,QAAA;AAAA,IACP,WAAA,EACE,6IAAA;AAAA,IACF,MAAA,EAAQ,SAAA;AAAA,IACR,iBAAA,EAAmB,IAAA;AAAA,IACnB,gBAAA,EAAkB,IAAA;AAAA,IAClB,kBAAA,EAAoB,IAAA;AAAA,IACpB,qBAAA,EAAuB,IAAA;AAAA,IACvB,cAAA,EAAgB;AAAA,GAClB;AAAA,EACA;AAAA,IACE,EAAA,EAAI,UAAA;AAAA,IACJ,KAAA,EAAO,UAAA;AAAA,IACP,WAAA,EACE,mJAAA;AAAA,IACF,MAAA,EAAQ,SAAA;AAAA,IACR,iBAAA,EAAmB,IAAA;AAAA,IACnB,gBAAA,EAAkB,IAAA;AAAA,IAClB,kBAAA,EAAoB,IAAA;AAAA,IACpB,qBAAA,EAAuB,IAAA;AAAA,IACvB,cAAA,EAAgB;AAAA;AAEpB;AAGO,SAAS,+BAAA,GAAsD;AACpE,EAAA,OAAO,IAAI,kBAAA,EAAmB,CAAE,MAAA,CAAO,oBAAoB,CAAA;AAC7D;;;AC9GO,IAAM,uBAAA,GAA0D;AAAA,EACrE,MAAA,EAAQ;AAAA,IACN,EAAA,EAAI,QAAA;AAAA,IACJ,QAAA,EAAU,wBAAA;AAAA,IACV,UAAA,EAAY,KAAA;AAAA,IACZ,UAAA,EAAY;AAAA,GACd;AAAA,EACA,SAAA,EAAW;AAAA,IACT,EAAA,EAAI,WAAA;AAAA,IACJ,QAAA,EAAU,2BAAA;AAAA,IACV,UAAA,EAAY;AAAA;AAEhB;AAGO,SAAS,0BACX,OAAA,EAC6B;AAChC,EAAA,MAAM,MAAsC,EAAC;AAC7C,EAAA,KAAA,MAAW,GAAA,IAAO,OAAA,EAAS,MAAA,CAAO,MAAA,CAAO,KAAK,GAAG,CAAA;AACjD,EAAA,OAAO,GAAA;AACT;AC8BA,eAAsB,mBACpB,IAAA,EACiC;AACjC,EAAA,MAAM,QAAA,GAAW,IAAA,CAAK,SAAA,CAAU,IAAA,CAAK,QAAQ,UAAU,CAAA;AACvD,EAAA,IAAI,CAAC,QAAA,EAAU;AACb,IAAA,MAAM,IAAI,WAAA;AAAA,MACR,kBAAA;AAAA,MACA,CAAA,yBAAA,EAA4B,IAAA,CAAK,OAAA,CAAQ,UAAU,CAAA,EAAA;AAAA,KACrD;AAAA,EACF;AAEA,EAAA,MAAM,GAAA,GAAM,iBAAiB,QAAA,EAAU,IAAA,CAAK,QAAQ,IAAA,EAAM,IAAA,CAAK,QAAQ,MAAM,CAAA;AAG7E,EAAA,MAAM,mBAA2C,EAAC;AAClD,EAAA,MAAM,aAAmC,EAAC;AAC1C,EAAA,KAAA,MAAW,CAAC,MAAM,KAAK,CAAA,IAAK,OAAO,OAAA,CAAQ,IAAA,CAAK,OAAA,CAAQ,OAAO,CAAA,EAAG;AAGhE,IAAA,IAAI,UAAA,CAAW,GAAA,CAAI,IAAA,CAAK,WAAA,EAAa,CAAA,EAAG;AACxC,IAAA,IAAI;AACF,MAAA,MAAM,MAAA,GAAS,MAAM,iBAAA,CAAkB,KAAA,EAAO,KAAK,QAAQ,CAAA;AAC3D,MAAA,gBAAA,CAAiB,IAAI,IAAI,MAAA,CAAO,MAAA;AAChC,MAAA,UAAA,CAAW,IAAA,CAAK,GAAG,MAAA,CAAO,YAAY,CAAA;AAAA,IACxC,SAAS,GAAA,EAAc;AACrB,MAAA,IAAI,eAAe,uBAAA,EAAyB;AAM1C,QAAA,MAAM,GAAA,GAA+B,GAAA;AACrC,QAAA,MAAM,IAAI,WAAA,CAAY,GAAA,CAAI,IAAA,EAAM,IAAI,OAAO,CAAA;AAAA,MAC7C;AACA,MAAA,MAAM,GAAA;AAAA,IACR;AAAA,EACF;AAKA,EAAA,OAAO,iBAAiB,MAAM,CAAA;AAC9B,EAAA,OAAO,iBAAiB,MAAM,CAAA;AAE9B,EAAA,OAAO;AAAA,IACL,GAAA;AAAA,IACA,MAAA,EAAQ,KAAK,OAAA,CAAQ,MAAA;AAAA,IACrB,OAAA,EAAS,gBAAA;AAAA,IACT,IAAA,EAAM,IAAA,CAAK,OAAA,CAAQ,IAAA,IAAQ,IAAA;AAAA,IAC3B,aAAA,EAAe;AAAA,GACjB;AACF;AAEA,SAAS,gBAAA,CACP,QAAA,EACA,IAAA,EACA,MAAA,EACQ;AACR,EAAA,MAAM,IAAA,GAAO,QAAA,CAAS,QAAA,CAAS,OAAA,CAAQ,OAAO,EAAE,CAAA;AAChD,EAAA,MAAM,SAAS,QAAA,CAAS,UAAA,EAAY,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA,IAAK,EAAA;AAE1D,EAAA,MAAM,WAAW,IAAA,CAAK,UAAA,CAAW,GAAG,CAAA,GAAI,IAAA,GAAO,IAAI,IAAI,CAAA,CAAA;AACvD,EAAA,OAAO,GAAG,IAAI,CAAA,EAAG,MAAM,CAAA,EAAG,QAAQ,GAAG,MAAM,CAAA,CAAA;AAC7C;AAGA,IAAM,UAAA,uBAAiB,GAAA,CAAI;AAAA,EACzB,YAAA;AAAA,EACA,YAAA;AAAA,EACA,oBAAA;AAAA,EACA,qBAAA;AAAA,EACA,IAAA;AAAA,EACA,SAAA;AAAA,EACA,mBAAA;AAAA,EACA;AACF,CAAC,CAAA;AAQM,IAAM,WAAA,GAAN,cAA0B,KAAA,CAAM;AAAA,EAC5B,IAAA;AAAA,EACT,WAAA,CAAY,MAAc,OAAA,EAAiB;AACzC,IAAA,KAAA,CAAM,OAAO,CAAA;AACb,IAAA,IAAA,CAAK,IAAA,GAAO,IAAA;AACZ,IAAA,IAAA,CAAK,IAAA,GAAO,aAAA;AAAA,EACd;AACF","file":"index.mjs","sourcesContent":["/**\n * Egress mode registry — describes how outbound traffic from an agent\n * sandbox is controlled at the network boundary.\n *\n * Four canonical modes ship by default; hosts can `extend` with\n * custom modes for their own infra. Bootstrap consumers branch on\n * the mode's **declarative flags**, never on the id, so adding a\n * new mode never edits a switch.\n *\n * # Mode hierarchy (weakest → strongest)\n * off — no controls. Real credentials in agent env. Today's default.\n * cooperative — agent sees `$$SECRET[NAME]$$` placeholders + BASE_URL envs\n * + HTTP_PROXY env. Substituted by host's egress proxy at\n * network boundary. Bypassable from inside (libs that\n * ignore HTTP_PROXY); but agent has no real credentials.\n * strict — cooperative + sandbox-level network enforcement (iptables\n * NAT to local sidecar, or HTTP_PROXY enforced by container\n * runtime). Agent literally cannot reach the network\n * except via the proxy.\n * paranoid — strict + TLS-MITM at the sidecar. Even calls to unknown\n * destinations get intercepted. Catches anything new the\n * agent might attempt.\n *\n * Hosts pick which modes are exposed to which guilds via their own\n * tier/policy system (Guilde uses `PlanPolicy.egressModes`).\n */\n\nexport type EgressModeId = \"off\" | \"cooperative\" | \"strict\" | \"paranoid\"\n\nexport interface EgressModeDefinition {\n id: EgressModeId\n label: string\n description: string\n /** Surface-level state. UI shows a \"preview\" badge when not stable. */\n status: \"stable\" | \"preview\" | \"experimental\"\n /**\n * Declarative bootstrap behavior. Bootstrap consumers branch on these\n * flags, not on the id, so adding a new mode never edits a switch.\n */\n emitsPlaceholders: boolean\n emitsBaseUrlEnvs: boolean\n emitsHttpProxyEnvs: boolean\n /**\n * Sandbox runtime requirements. When the host's runtime can't satisfy\n * these (e.g. managed e2b without NET_ADMIN), the mode is unavailable\n * AND the bootstrap should refuse to silently degrade — surface the\n * shortfall so the operator can pick a different mode explicitly.\n */\n requiresSandboxNetCap: boolean\n requiresMitmCa: boolean\n}\n\n/**\n * Composable registry. Mirror of `EntitlementRegistry` shape from\n * `@agstudio/core`/billing — same chainable methods so hosts can\n * customize without forking the default set.\n */\nexport class EgressModeRegistry {\n private readonly entries = new Map<EgressModeId, EgressModeDefinition>()\n\n register(def: EgressModeDefinition): this {\n this.entries.set(def.id, def)\n return this\n }\n\n get(id: EgressModeId): EgressModeDefinition | undefined {\n return this.entries.get(id)\n }\n\n getAll(): EgressModeDefinition[] {\n return [...this.entries.values()]\n }\n\n /** Append entries from another source. Later wins on id collision. */\n extend(defs: readonly EgressModeDefinition[]): this {\n for (const def of defs) this.register(def)\n return this\n }\n\n /** Drop entries by id. Useful for hosts that want to forbid a built-in. */\n without(...ids: readonly EgressModeId[]): this {\n for (const id of ids) this.entries.delete(id)\n return this\n }\n}\n\n/**\n * Default registry pre-loaded with the four canonical modes. Hosts\n * import this directly OR call `new EgressModeRegistry().extend(DEFAULT_EGRESS_MODES)`\n * to start from a clean slate they own.\n */\nexport const DEFAULT_EGRESS_MODES: readonly EgressModeDefinition[] = [\n {\n id: \"off\",\n label: \"Direct (off)\",\n description:\n \"No egress controls. Real credentials injected into agent env / files. Lowest latency; widest blast radius if the agent leaks.\",\n status: \"stable\",\n emitsPlaceholders: false,\n emitsBaseUrlEnvs: false,\n emitsHttpProxyEnvs: false,\n requiresSandboxNetCap: false,\n requiresMitmCa: false,\n },\n {\n id: \"cooperative\",\n label: \"Cooperative\",\n description:\n \"Agent sees only $$SECRET[NAME]$$ placeholders. Real credentials substituted at the host's egress proxy. Network bypassable by libs that ignore HTTP_PROXY, but the agent has nothing real to bypass with.\",\n status: \"stable\",\n emitsPlaceholders: true,\n emitsBaseUrlEnvs: true,\n emitsHttpProxyEnvs: true,\n requiresSandboxNetCap: false,\n requiresMitmCa: false,\n },\n {\n id: \"strict\",\n label: \"Strict\",\n description:\n \"Cooperative + sandbox-level enforcement (iptables NAT or runtime-level proxy mandate). Agent cannot reach the network except via the proxy.\",\n status: \"preview\",\n emitsPlaceholders: true,\n emitsBaseUrlEnvs: true,\n emitsHttpProxyEnvs: true,\n requiresSandboxNetCap: true,\n requiresMitmCa: false,\n },\n {\n id: \"paranoid\",\n label: \"Paranoid\",\n description:\n \"Strict + TLS-MITM at the sidecar. Catches any outbound call, including to destinations the host didn't anticipate. Breaks SDKs with cert pinning.\",\n status: \"preview\",\n emitsPlaceholders: true,\n emitsBaseUrlEnvs: true,\n emitsHttpProxyEnvs: true,\n requiresSandboxNetCap: true,\n requiresMitmCa: true,\n },\n]\n\n/** Convenience factory — most hosts use this directly. */\nexport function createDefaultEgressModeRegistry(): EgressModeRegistry {\n return new EgressModeRegistry().extend(DEFAULT_EGRESS_MODES)\n}\n","/**\n * Egress provider allowlist — the set of upstream URLs the proxy is\n * willing to forward traffic to. Keeps the route from being a generic\n * \"proxy anywhere\" hole.\n *\n * Hosts pick their own subset and may extend with private upstreams.\n * Each entry is keyed by a stable provider id (`openai`, `anthropic`,\n * …) used in the egress URL: `${apiUrl}/egress/<providerId>/...`.\n *\n * `pathPrefix` lets entries that have a versioned root (`/v1` for\n * openai) match the SDK's default base URL convention — the agent's\n * SDK sends `${BASE_URL}/chat/completions` and the proxy resolves to\n * `${upstream}${pathPrefix}/chat/completions`.\n */\n\nexport interface EgressProvider {\n id: string\n /** Public origin (scheme + host). No trailing slash; no path. */\n upstream: string\n /** Path prefix appended after the provider id in the egress URL.\n * e.g. `/v1` for openai so SDKs configured with\n * `OPENAI_BASE_URL=${apiUrl}/egress/openai/v1` work natively. */\n pathPrefix?: string\n /** Header name SDKs use to send credentials. Information-only —\n * substitution happens on whatever header the agent sends; this\n * is for UI hints and audit grouping. */\n authHeader?: string\n}\n\n/**\n * The shipped allowlist. Hosts compose by spreading this + their own.\n * Kept narrow on purpose — the user-visible decision is \"which providers\n * does my proxy accept\", not \"which providers does the SDK universe\n * support\".\n */\nexport const COMMON_EGRESS_PROVIDERS: Record<string, EgressProvider> = {\n openai: {\n id: \"openai\",\n upstream: \"https://api.openai.com\",\n pathPrefix: \"/v1\",\n authHeader: \"Authorization\",\n },\n anthropic: {\n id: \"anthropic\",\n upstream: \"https://api.anthropic.com\",\n authHeader: \"x-api-key\",\n },\n}\n\n/** Convenience: build a registry from a base + extensions. */\nexport function composeEgressProviders(\n ...sources: Array<Record<string, EgressProvider>>\n): Record<string, EgressProvider> {\n const out: Record<string, EgressProvider> = {}\n for (const src of sources) Object.assign(out, src)\n return out\n}\n","/**\n * Transport-agnostic egress proxy core.\n *\n * Takes a normalized request shape + a provider lookup + a secret\n * resolver, returns a normalized rewritten request ready to forward.\n * Doesn't know about Hono, Express, or any HTTP framework — hosts\n * adapt their server framework to this shape.\n *\n * Why split: keeps the proxy logic testable in isolation + lets new\n * runtime hosts (Bun, Deno, Cloudflare Workers, etc.) reuse the same\n * core without dragging in node:http or similar.\n */\n\nimport {\n substituteSecrets,\n SecretSubstitutionError,\n type SecretResolver,\n type SubstitutionRecord,\n} from \"@agentproto/secrets/exposure\"\nimport type { EgressProvider } from \"./providers.js\"\n\n/** Body type permissive enough for any reasonable runtime — node `fetch`,\n * Bun, Deno, Cloudflare Workers, browser. We don't pull in DOM types\n * (that would expose them to every consumer of this package); the\n * host's `fetch` call accepts whatever shape it understands. */\nexport type EgressBody =\n | string\n | Uint8Array\n | ArrayBuffer\n | ReadableStream<Uint8Array>\n | { stream(): ReadableStream<Uint8Array> } // Blob-shaped\n\n/** Inbound request shape — host-framework-agnostic. */\nexport interface EgressRequest {\n /** Provider id from the URL — `/egress/<providerId>/...`. */\n providerId: string\n /** Path inside the provider — what comes after `/egress/<providerId>`.\n * Should start with `/`. */\n path: string\n /** HTTP method, uppercase. */\n method: string\n /** Header bag — string values only (multi-value headers should be\n * pre-joined by the host framework). */\n headers: Record<string, string>\n /** Optional request body. Substitution does NOT scan the body in\n * v0 — only headers. Body opt-in per provider lands later. */\n body?: EgressBody | null\n /** Query string verbatim (with leading `?`) or empty string. */\n search: string\n}\n\n/** Outbound, rewritten request shape — what the host fetches. */\nexport interface RewrittenEgressRequest {\n url: string\n method: string\n headers: Record<string, string>\n body?: EgressBody | null\n /** Audit record — names of secrets that were substituted into the\n * outbound headers, in match order. */\n substitutions: SubstitutionRecord[]\n}\n\nexport interface ProxyEgressRequestOptions {\n request: EgressRequest\n /** Provider allowlist — typically `COMMON_EGRESS_PROVIDERS` merged\n * with host-specific entries. Unknown provider id → throws\n * EgressError(\"unknown_provider\"). */\n providers: Record<string, EgressProvider>\n /** Per-call secret resolver. The host wires its vault here. Returns\n * null when a placeholder name doesn't resolve — the proxy keeps\n * the placeholder verbatim and the upstream then 401s, which is\n * exactly the right surface (agent learns the secret isn't allowed\n * via the upstream's own auth-failure response). */\n resolver: SecretResolver\n}\n\n/**\n * Build the rewritten request for an inbound egress call. The host\n * uses the result with `fetch(rewritten.url, rewritten)` (or its\n * runtime's equivalent). Audit records are returned alongside so the\n * host can log per-call substitutions to its usage_events table.\n *\n * Throws `EgressError` with stable codes for the known failure modes\n * — host framework adapter maps to its own HTTP responses (typically\n * 400 / 404 / 500).\n */\nexport async function proxyEgressRequest(\n opts: ProxyEgressRequestOptions\n): Promise<RewrittenEgressRequest> {\n const provider = opts.providers[opts.request.providerId]\n if (!provider) {\n throw new EgressError(\n \"unknown_provider\",\n `Unknown egress provider '${opts.request.providerId}'.`\n )\n }\n\n const url = buildUpstreamUrl(provider, opts.request.path, opts.request.search)\n\n // Substitute headers. Body left as-is for v0.\n const rewrittenHeaders: Record<string, string> = {}\n const allRecords: SubstitutionRecord[] = []\n for (const [name, value] of Object.entries(opts.request.headers)) {\n // Strip per-hop headers — they describe the ingress hop, not the\n // upstream. Standard reverse-proxy hygiene.\n if (HOP_BY_HOP.has(name.toLowerCase())) continue\n try {\n const result = await substituteSecrets(value, opts.resolver)\n rewrittenHeaders[name] = result.output\n allRecords.push(...result.replacements)\n } catch (err: unknown) {\n if (err instanceof SecretSubstitutionError) {\n // Re-raise as EgressError so host framework sees a single\n // exception class (with the original code preserved). Local\n // binding annotation works around a tsup-dts narrowing quirk\n // where `err` stays `unknown` in the d.ts pipeline despite\n // the runtime `instanceof` narrowing it for esbuild.\n const sub: SecretSubstitutionError = err\n throw new EgressError(sub.code, sub.message)\n }\n throw err\n }\n }\n\n // Don't drop the upstream Host header — let `fetch` populate it\n // from the URL. The original Host (the proxy's hostname) is\n // wrong for the upstream.\n delete rewrittenHeaders[\"host\"]\n delete rewrittenHeaders[\"Host\"]\n\n return {\n url,\n method: opts.request.method,\n headers: rewrittenHeaders,\n body: opts.request.body ?? null,\n substitutions: allRecords,\n }\n}\n\nfunction buildUpstreamUrl(\n provider: EgressProvider,\n path: string,\n search: string\n): string {\n const base = provider.upstream.replace(/\\/$/, \"\")\n const prefix = provider.pathPrefix?.replace(/\\/$/, \"\") ?? \"\"\n // Ensure exactly one slash between prefix and path.\n const normPath = path.startsWith(\"/\") ? path : `/${path}`\n return `${base}${prefix}${normPath}${search}`\n}\n\n/** Per-RFC-7230. Filtered before forwarding upstream. */\nconst HOP_BY_HOP = new Set([\n \"connection\",\n \"keep-alive\",\n \"proxy-authenticate\",\n \"proxy-authorization\",\n \"te\",\n \"trailer\",\n \"transfer-encoding\",\n \"upgrade\",\n])\n\n/** Stable error class for proxy-level failures. Codes:\n * - `unknown_provider` — providerId not in allowlist\n * - `secret_value_empty` — resolver returned an empty value\n * - `secret_value_unsafe` — resolved value contained CR/LF/NUL\n * - `invalid_placeholder_name`— placeholder name failed regex\n */\nexport class EgressError extends Error {\n readonly code: string\n constructor(code: string, message: string) {\n super(message)\n this.code = code\n this.name = \"EgressError\"\n }\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentproto/egress",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "@agentproto/egress — outbound traffic control for agent sandboxes. Mode registry (off / cooperative / strict / paranoid), provider allowlist, transport-agnostic proxy core, audit hooks. Cooperative mode delegates substitution to @agentproto/secrets so the agent never sees raw upstream credentials. Strict / paranoid modes (network-level enforcement via NAT or TLS-MITM) ship as conventions; the helpers land per-host as the sandbox runtimes mature.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agentproto",
|
|
7
|
+
"egress",
|
|
8
|
+
"proxy",
|
|
9
|
+
"secrets",
|
|
10
|
+
"agent-sandbox",
|
|
11
|
+
"open-standard"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://agentproto.sh",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/agentproto/ts",
|
|
17
|
+
"directory": "packages/egress"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/agentproto/ts/issues"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "dist/index.mjs",
|
|
25
|
+
"module": "dist/index.mjs",
|
|
26
|
+
"types": "dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.mjs",
|
|
31
|
+
"default": "./dist/index.mjs"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@agentproto/secrets": "0.1.0-alpha.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^25.6.2",
|
|
48
|
+
"tsup": "^8.5.1",
|
|
49
|
+
"typescript": "^5.9.3",
|
|
50
|
+
"vitest": "^3.2.4",
|
|
51
|
+
"@agentproto/tooling": "0.1.0-alpha.0"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"dev": "tsup --watch",
|
|
55
|
+
"build": "tsup",
|
|
56
|
+
"clean": "rm -rf dist",
|
|
57
|
+
"check-types": "tsc --noEmit",
|
|
58
|
+
"test": "vitest run --passWithNoTests",
|
|
59
|
+
"test:watch": "vitest"
|
|
60
|
+
}
|
|
61
|
+
}
|