@open-everywhere/create 0.6.0 → 0.8.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/README.md +6 -4
- package/package.json +3 -3
- package/src/scaffold.ts +2 -2
- package/src/steps/build-deploy.ts +2 -2
- package/template/.dockerignore +35 -0
- package/template/AGENTS.md +7 -0
- package/template/Dockerfile +60 -0
- package/template/README.md +13 -0
- package/template/agent/access/a2a-inbound.ts +69 -0
- package/template/agent/agent.ts +25 -8
- package/template/agent/google-chat/policy.ts +34 -0
- package/template/agent/instructions.md +35 -20
- package/template/agent/lib/register-config.ts +20 -1
- package/template/agent/meetings/policy.ts +2 -4
- package/template/agent/memory/config/domains/org.ts +2 -2
- package/template/agent/model/models.ts +17 -0
- package/template/agent/model/stacks.ts +23 -6
- package/template/agent/routines/collectors.ts +28 -0
- package/template/agent/settings/settings.ts +21 -2
- package/template/agent/spend/model-pricing.ts +3 -1
- package/template/claude/skills/quickstart-open-everywhere/SKILL.md +96 -9
- package/template/claude/skills/upgrade-open-everywhere/SKILL.md +17 -4
- package/template/deploy/egress-proxy/Dockerfile +30 -0
- package/template/deploy/egress-proxy/control/acl.ts +228 -0
- package/template/deploy/egress-proxy/control/handlers.ts +174 -0
- package/template/deploy/egress-proxy/control/helper.cjs +108 -0
- package/template/deploy/egress-proxy/control/server.ts +122 -0
- package/template/deploy/egress-proxy/entrypoint.sh +62 -0
- package/template/deploy/egress-proxy/gen-ca.sh +42 -0
- package/template/deploy/egress-proxy/install-ca.sh +39 -0
- package/template/deploy/egress-proxy/squid.conf +93 -0
- package/template/deploy/egress-proxy/tier-b.conf +42 -0
- package/template/docker-compose.yml +135 -0
- package/template/gitignore +4 -0
- package/template/package.json +5 -3
- package/template/roster.config.ts +2 -2
- package/template/roster.lock.json +14 -1
- package/template/scripts/goldens/roster-plan.txt +13 -0
- package/template/scripts/model-config-check.ts +81 -0
- package/template/scripts/ops-mcp.ts +1 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Pure ACL logic for the Open Everywhere egress-proxy control service.
|
|
2
|
+
//
|
|
3
|
+
// This is the enforcement decision the Squid `external_acl_type` helper calls per request:
|
|
4
|
+
// given a source IP + destination host (+ method/path when the request is decrypted in
|
|
5
|
+
// Tier B), decide allow/deny against the compiled SandboxNetworkPolicy the app PUSHED for
|
|
6
|
+
// that source, and — in Tier B — return the credential-injection headers to stamp.
|
|
7
|
+
//
|
|
8
|
+
// Deliberately dependency-free (no eve, no npm): it is imported by server.ts, unit-tested
|
|
9
|
+
// via tsx (acl-test.ts), and runs under Node's native TS stripping in the proxy container.
|
|
10
|
+
//
|
|
11
|
+
// The policy shape mirrors eve's SandboxNetworkPolicy (Vercel NetworkPolicy) — a structural
|
|
12
|
+
// copy so this module needs no eve import. See
|
|
13
|
+
// node_modules/eve/dist/src/compiled/@vercel/sandbox/network-policy.d.ts.
|
|
14
|
+
|
|
15
|
+
export type NetworkTransformer = { headers?: Record<string, string> };
|
|
16
|
+
export type PolicyMatch = {
|
|
17
|
+
path?: { exact?: string; startsWith?: string; regex?: string };
|
|
18
|
+
method?: string[];
|
|
19
|
+
};
|
|
20
|
+
export type PolicyRule = { match?: PolicyMatch; transform?: NetworkTransformer[] };
|
|
21
|
+
export type EgressPolicy =
|
|
22
|
+
| "allow-all"
|
|
23
|
+
| "deny-all"
|
|
24
|
+
| {
|
|
25
|
+
allow?: string[] | Record<string, PolicyRule[]>;
|
|
26
|
+
subnets?: { allow?: string[]; deny?: string[] };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface AclRequest {
|
|
30
|
+
/** Destination host (SNI for Tier A peek, CONNECT target, or Host header for Tier B). */
|
|
31
|
+
host: string;
|
|
32
|
+
/** HTTP method — only known in Tier B (decrypted). Undefined ⇒ Tier A, method un-checkable. */
|
|
33
|
+
method?: string;
|
|
34
|
+
/** Request path — only known in Tier B. Undefined ⇒ Tier A, path un-checkable. */
|
|
35
|
+
path?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AclDecision {
|
|
39
|
+
readonly allow: boolean;
|
|
40
|
+
/** The allow-map key that matched (for logging). */
|
|
41
|
+
readonly matchedHost?: string;
|
|
42
|
+
/** Tier B credential-injection headers from the first matching rule's transforms. */
|
|
43
|
+
readonly headers?: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DENY: AclDecision = { allow: false };
|
|
47
|
+
|
|
48
|
+
/** Normalize a host for matching: lowercase, drop a trailing dot, strip any :port. */
|
|
49
|
+
export function normalizeHost(host: string): string {
|
|
50
|
+
let h = host.trim().toLowerCase();
|
|
51
|
+
if (h.endsWith(".")) h = h.slice(0, -1);
|
|
52
|
+
// Strip a trailing :port, but leave bare IPv6 (no port) alone. CONNECT targets are
|
|
53
|
+
// "host:port"; SNI has no port. Only strip when there's a single trailing :digits.
|
|
54
|
+
const m = h.match(/^(.*):(\d+)$/);
|
|
55
|
+
if (m && !h.includes("::")) h = m[1];
|
|
56
|
+
// Strip IPv6 brackets if present ("[::1]:443" → "::1").
|
|
57
|
+
if (h.startsWith("[") && h.includes("]")) h = h.slice(1, h.indexOf("]"));
|
|
58
|
+
return h;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Normalize a source IP key: strip IPv4-mapped-IPv6 prefix so "::ffff:172.20.0.3" == "172.20.0.3". */
|
|
62
|
+
export function normalizeSourceIp(ip: string): string {
|
|
63
|
+
const v = ip.trim().toLowerCase();
|
|
64
|
+
return v.startsWith("::ffff:") ? v.slice("::ffff:".length) : v;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Does an allow-map key match a destination host? Supports Vercel's `*` wildcard prefix
|
|
69
|
+
* (`*.npmjs.org` matches any subdomain of npmjs.org) and a bare `*` (match everything).
|
|
70
|
+
* A plain key matches its exact host only.
|
|
71
|
+
*/
|
|
72
|
+
export function hostKeyMatches(key: string, host: string): boolean {
|
|
73
|
+
const k = normalizeHost(key);
|
|
74
|
+
const h = normalizeHost(host);
|
|
75
|
+
if (k === "*") return true;
|
|
76
|
+
if (k.startsWith("*.")) {
|
|
77
|
+
const suffix = k.slice(1); // ".npmjs.org"
|
|
78
|
+
return h.endsWith(suffix) && h.length > suffix.length;
|
|
79
|
+
}
|
|
80
|
+
return k === h;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Does a single rule permit this request? method/path constraints are SKIPPED when the
|
|
84
|
+
* corresponding request field is undefined (Tier A can't see them — host granularity only). */
|
|
85
|
+
function ruleAllows(rule: PolicyRule, req: AclRequest): boolean {
|
|
86
|
+
const m = rule.match;
|
|
87
|
+
if (!m) return true;
|
|
88
|
+
if (m.method && m.method.length > 0 && req.method !== undefined) {
|
|
89
|
+
const want = m.method.map((x) => x.toUpperCase());
|
|
90
|
+
if (!want.includes(req.method.toUpperCase())) return false;
|
|
91
|
+
}
|
|
92
|
+
if (m.path && req.path !== undefined) {
|
|
93
|
+
const p = req.path;
|
|
94
|
+
if (m.path.exact !== undefined && p !== m.path.exact) return false;
|
|
95
|
+
if (m.path.startsWith !== undefined && !p.startsWith(m.path.startsWith)) return false;
|
|
96
|
+
if (m.path.regex !== undefined) {
|
|
97
|
+
try {
|
|
98
|
+
if (!new RegExp(m.path.regex).test(p)) return false;
|
|
99
|
+
} catch {
|
|
100
|
+
return false; // an un-compilable regex fails closed
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function mergeHeaders(rule: PolicyRule): Record<string, string> | undefined {
|
|
108
|
+
if (!rule.transform || rule.transform.length === 0) return undefined;
|
|
109
|
+
const out: Record<string, string> = {};
|
|
110
|
+
for (const t of rule.transform) {
|
|
111
|
+
if (t.headers) for (const [k, v] of Object.entries(t.headers)) out[k] = v;
|
|
112
|
+
}
|
|
113
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Decide allow/deny for `req` against `policy`. FAIL-CLOSED: an unknown host, a policy with
|
|
118
|
+
* no allow map, or "deny-all" all deny. "allow-all" allows. For the record form, the first
|
|
119
|
+
* rule that permits the request wins (its transform headers ride back for Tier B injection);
|
|
120
|
+
* an empty rules array is a bare allow (no headers).
|
|
121
|
+
*/
|
|
122
|
+
export function decide(policy: EgressPolicy | undefined, req: AclRequest): AclDecision {
|
|
123
|
+
if (policy === undefined) return DENY; // no policy pushed for this source yet
|
|
124
|
+
if (policy === "deny-all") return DENY;
|
|
125
|
+
if (policy === "allow-all") return { allow: true };
|
|
126
|
+
const allow = policy.allow;
|
|
127
|
+
if (!allow) return DENY;
|
|
128
|
+
|
|
129
|
+
if (Array.isArray(allow)) {
|
|
130
|
+
// Simple string list — host-level only, no credential injection.
|
|
131
|
+
for (const key of allow) {
|
|
132
|
+
if (hostKeyMatches(key, req.host)) return { allow: true, matchedHost: normalizeHost(key) };
|
|
133
|
+
}
|
|
134
|
+
return DENY;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Record form: find the matching host key, then a permitting rule under it.
|
|
138
|
+
for (const [key, rules] of Object.entries(allow)) {
|
|
139
|
+
if (!hostKeyMatches(key, req.host)) continue;
|
|
140
|
+
if (!rules || rules.length === 0) return { allow: true, matchedHost: normalizeHost(key) };
|
|
141
|
+
for (const rule of rules) {
|
|
142
|
+
if (ruleAllows(rule, req)) {
|
|
143
|
+
return { allow: true, matchedHost: normalizeHost(key), headers: mergeHeaders(rule) };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Host is listed but no rule permitted this specific method/path (Tier B narrowing).
|
|
147
|
+
return DENY;
|
|
148
|
+
}
|
|
149
|
+
return DENY;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* In-memory policy store keyed by (normalized) source IP. The control service holds one
|
|
154
|
+
* compiled policy per active sandbox source. Unknown source ⇒ deny (fail-closed) — the
|
|
155
|
+
* internal-only Docker network is the structural backstop; this is the runtime floor.
|
|
156
|
+
*/
|
|
157
|
+
export class PolicyStore {
|
|
158
|
+
private readonly byIp = new Map<string, EgressPolicy>();
|
|
159
|
+
/** The APP's own allow list (OPE-2406), kept apart from every per-source session policy. See {@link validateHostPlanePolicy}. */
|
|
160
|
+
private hostPlanePolicy: EgressPolicy | undefined;
|
|
161
|
+
|
|
162
|
+
setHostPlane(policy: EgressPolicy): void {
|
|
163
|
+
this.hostPlanePolicy = policy;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Undefined until the app pushes one, which {@link decide} reads as deny. */
|
|
167
|
+
hostPlane(): EgressPolicy | undefined {
|
|
168
|
+
return this.hostPlanePolicy;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
set(sourceIp: string, policy: EgressPolicy): void {
|
|
172
|
+
this.byIp.set(normalizeSourceIp(sourceIp), policy);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get(sourceIp: string): EgressPolicy | undefined {
|
|
176
|
+
return this.byIp.get(normalizeSourceIp(sourceIp));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
delete(sourceIp: string): boolean {
|
|
180
|
+
return this.byIp.delete(normalizeSourceIp(sourceIp));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
size(): number {
|
|
184
|
+
return this.byIp.size;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Decide for a source+request in one call (fail-closed on unknown source). */
|
|
188
|
+
decide(sourceIp: string, req: AclRequest): AclDecision {
|
|
189
|
+
return decide(this.get(sourceIp), req);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Validate a pushed policy body just enough to reject garbage before it becomes the ACL
|
|
195
|
+
* for a source. Returns the policy or throws with a reason (the server turns it into 400).
|
|
196
|
+
*/
|
|
197
|
+
export function validatePolicy(input: unknown): EgressPolicy {
|
|
198
|
+
if (input === "allow-all" || input === "deny-all") return input;
|
|
199
|
+
if (input && typeof input === "object") {
|
|
200
|
+
const obj = input as Record<string, unknown>;
|
|
201
|
+
const allow = obj.allow;
|
|
202
|
+
if (allow === undefined || Array.isArray(allow) || (allow && typeof allow === "object")) {
|
|
203
|
+
return input as EgressPolicy;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
throw new Error('policy must be "allow-all", "deny-all", or an object with an `allow` list/map');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Validate a HOST-PLANE policy (OPE-2406): the hosts the APP process itself calls, for example the
|
|
211
|
+
* model gateway. It is narrower than {@link validatePolicy} on purpose. Only a list of exact hosts
|
|
212
|
+
* passes: no "allow-all", no wildcard, no rule map (so no Tier B credential injection on this plane).
|
|
213
|
+
* The app is the only writer, because the push needs the control token. The narrow shape keeps a
|
|
214
|
+
* wrong push from opening the whole internet to every request that carries the credential.
|
|
215
|
+
*/
|
|
216
|
+
export function validateHostPlanePolicy(input: unknown): EgressPolicy {
|
|
217
|
+
const allow = input && typeof input === "object" ? (input as Record<string, unknown>).allow : undefined;
|
|
218
|
+
if (!Array.isArray(allow) || allow.length === 0) {
|
|
219
|
+
throw new Error("host-plane policy must be an object with a non-empty `allow` list of exact hosts");
|
|
220
|
+
}
|
|
221
|
+
const hosts: string[] = [];
|
|
222
|
+
for (const h of allow) {
|
|
223
|
+
if (typeof h !== "string" || h.trim() === "") throw new Error("host-plane `allow` entries must be non-empty host strings");
|
|
224
|
+
if (h.includes("*")) throw new Error(`host-plane hosts are exact hosts, never a wildcard ("${h}")`);
|
|
225
|
+
hosts.push(normalizeHost(h));
|
|
226
|
+
}
|
|
227
|
+
return { allow: hosts };
|
|
228
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Pure request handlers for the control service — the policy-push handler, the ACL query
|
|
2
|
+
// handler, and the two auth primitives (boot-time token requirement + bearer comparison),
|
|
3
|
+
// factored out of the HTTP plumbing (server.ts) so all are unit-testable without opening a
|
|
4
|
+
// socket (acl-test.ts).
|
|
5
|
+
|
|
6
|
+
import { timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { PolicyStore, decide, validatePolicy, validateHostPlanePolicy, normalizeSourceIp, type AclRequest, type AclDecision } from "./acl.ts";
|
|
8
|
+
|
|
9
|
+
export interface PushInput {
|
|
10
|
+
/** Parsed JSON body of POST /policy. */
|
|
11
|
+
readonly body: unknown;
|
|
12
|
+
/** The connection's remote address (the fallback source-IP key). */
|
|
13
|
+
readonly remoteAddr: string;
|
|
14
|
+
/** Whether the request carried the valid bearer token. Auth is never disabled — the control
|
|
15
|
+
* service refuses to boot without a token (see requireControlToken). */
|
|
16
|
+
readonly authOk: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The fatal message printed when the control service is started without a token. Kept next to
|
|
21
|
+
* requireControlToken so the test can pin the wording that operators will actually see.
|
|
22
|
+
*/
|
|
23
|
+
export const MISSING_TOKEN_FATAL =
|
|
24
|
+
"OE_EGRESS_PROXY_TOKEN is required — the control service writes the egress policy that is the " +
|
|
25
|
+
"only thing standing between the sandbox and the open internet, and it listens on the same " +
|
|
26
|
+
"network the sandbox runs on. Refusing to start without it. Generate one with " +
|
|
27
|
+
"`openssl rand -hex 32` and set it on BOTH the proxy and the app (docker-compose.yml passes " +
|
|
28
|
+
"it to both).";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the control-plane bearer token, or throw. There is deliberately NO tokenless mode and
|
|
32
|
+
* no opt-out flag: this proxy exists only for self-host deploys, where an unauthenticated
|
|
33
|
+
* `POST /policy` lets any peer on the internal network (including a compromised sandbox) install
|
|
34
|
+
* an allow-all policy for itself and defeat default-deny egress entirely. Fail loud at boot
|
|
35
|
+
* rather than silently ship an open control plane.
|
|
36
|
+
*/
|
|
37
|
+
export function requireControlToken(raw: string | undefined): string {
|
|
38
|
+
const token = raw?.trim();
|
|
39
|
+
if (!token) throw new Error(MISSING_TOKEN_FATAL);
|
|
40
|
+
return token;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Constant-time bearer-token check for the `Authorization` header. Length is compared first
|
|
45
|
+
* (timingSafeEqual throws on a length mismatch), so this leaks the token's length and nothing
|
|
46
|
+
* else. Returns false for a missing/duplicated/non-Bearer header — fail-closed.
|
|
47
|
+
*/
|
|
48
|
+
export function bearerMatches(header: string | string[] | undefined, token: string): boolean {
|
|
49
|
+
if (typeof header !== "string") return false;
|
|
50
|
+
const prefix = "Bearer ";
|
|
51
|
+
if (!header.startsWith(prefix)) return false;
|
|
52
|
+
const got = Buffer.from(header.slice(prefix.length), "utf-8");
|
|
53
|
+
const want = Buffer.from(token, "utf-8");
|
|
54
|
+
if (got.length !== want.length) return false;
|
|
55
|
+
return timingSafeEqual(got, want);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PushResult {
|
|
59
|
+
readonly status: number;
|
|
60
|
+
readonly body: unknown;
|
|
61
|
+
/** The source-IP key the policy was stored under (undefined on rejection). */
|
|
62
|
+
readonly keyed?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Apply a policy push. Keys by the body's explicit `sourceIp` when present, else the
|
|
67
|
+
* connection's remote address (correct for the justbash topology, where the app pushes
|
|
68
|
+
* from the very IP its egress uses). Fail-closed on auth/validation.
|
|
69
|
+
*
|
|
70
|
+
* `plane: "host"` writes the HOST-PLANE policy instead (OPE-2406): the app's own allow list,
|
|
71
|
+
* which only a request that carries the control credential is decided against (see
|
|
72
|
+
* {@link handleAcl}). It is never keyed by source IP, so it never reaches a session. It travels
|
|
73
|
+
* in `hostPlane`, never in `policy`: a proxy built before the host plane validates `policy`
|
|
74
|
+
* and so refuses this body (400) instead of storing the list as the app IP's session policy.
|
|
75
|
+
*/
|
|
76
|
+
export function handlePolicyPush(store: PolicyStore, input: PushInput): PushResult {
|
|
77
|
+
if (!input.authOk) return { status: 401, body: { error: "unauthorized" } };
|
|
78
|
+
const body = input.body;
|
|
79
|
+
if (!body || typeof body !== "object") {
|
|
80
|
+
return { status: 400, body: { error: "expected a JSON object body" } };
|
|
81
|
+
}
|
|
82
|
+
const obj = body as Record<string, unknown>;
|
|
83
|
+
if (obj.plane !== undefined && obj.plane !== "host" && obj.plane !== "session") {
|
|
84
|
+
return { status: 400, body: { error: 'plane must be "host" or "session"' } };
|
|
85
|
+
}
|
|
86
|
+
if (obj.plane === "host") {
|
|
87
|
+
if (obj.policy !== undefined) return { status: 400, body: { error: "a host-plane push carries `hostPlane`, never `policy`" } };
|
|
88
|
+
let hostPolicy;
|
|
89
|
+
try {
|
|
90
|
+
hostPolicy = validateHostPlanePolicy(obj.hostPlane);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return { status: 400, body: { error: String(err instanceof Error ? err.message : err) } };
|
|
93
|
+
}
|
|
94
|
+
store.setHostPlane(hostPolicy);
|
|
95
|
+
return { status: 200, body: { ok: true, plane: "host", policies: store.size() }, keyed: "host-plane" };
|
|
96
|
+
}
|
|
97
|
+
let policy;
|
|
98
|
+
try {
|
|
99
|
+
policy = validatePolicy(obj.policy);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { status: 400, body: { error: String(err instanceof Error ? err.message : err) } };
|
|
102
|
+
}
|
|
103
|
+
const explicit = typeof obj.sourceIp === "string" && obj.sourceIp.trim() ? obj.sourceIp.trim() : undefined;
|
|
104
|
+
const key = explicit ?? input.remoteAddr;
|
|
105
|
+
if (!key) return { status: 400, body: { error: "no source IP: set body.sourceIp or connect from the sandbox network" } };
|
|
106
|
+
store.set(key, policy);
|
|
107
|
+
return { status: 200, body: { ok: true, sourceIp: key, policies: store.size() }, keyed: key };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface AclQuery {
|
|
111
|
+
src?: string;
|
|
112
|
+
host?: string;
|
|
113
|
+
method?: string;
|
|
114
|
+
path?: string;
|
|
115
|
+
/** The request's `Proxy-Authorization` value, as Squid passed it. Absent ⇒ a session request. */
|
|
116
|
+
auth?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface AclResult {
|
|
120
|
+
readonly decision: AclDecision;
|
|
121
|
+
/** True iff the query was well-formed enough to evaluate. Malformed ⇒ fail-closed deny. */
|
|
122
|
+
readonly evaluated: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Evaluate an ACL query from the Squid helper. Missing src/host ⇒ fail-closed deny (a
|
|
127
|
+
* malformed callout must never open the firewall). "-" is Squid's empty-field sentinel.
|
|
128
|
+
*
|
|
129
|
+
* 🔴 TWO PRINCIPALS, ONE SOURCE IP (OPE-2406). Under the justbash backend the app process and
|
|
130
|
+
* the agent's sandboxed session send from the same address, so the address cannot tell them
|
|
131
|
+
* apart. The credential does: the app sends `Proxy-Authorization: Bearer <control token>` on its
|
|
132
|
+
* own model call. The token stays in the app's environment, outside the session's shell, and a
|
|
133
|
+
* session that obtained it could already push itself any policy, so this adds no reach. So:
|
|
134
|
+
* - no credential → the SESSION policy for the source IP, exactly as before;
|
|
135
|
+
* - the credential → the HOST-PLANE policy only, never the session policy;
|
|
136
|
+
* - any other credential → deny. A wrong token never falls back to the session policy.
|
|
137
|
+
* The host plane never widens what a session reaches: a session request carries no credential,
|
|
138
|
+
* so the host-plane list is never consulted for it.
|
|
139
|
+
*/
|
|
140
|
+
export function handleAcl(store: PolicyStore, q: AclQuery, token?: string): AclResult {
|
|
141
|
+
const src = clean(q.src);
|
|
142
|
+
const host = clean(q.host);
|
|
143
|
+
if (!src || !host) return { decision: { allow: false }, evaluated: false };
|
|
144
|
+
const req: AclRequest = { host };
|
|
145
|
+
const method = clean(q.method);
|
|
146
|
+
const path = clean(q.path);
|
|
147
|
+
if (method) req.method = method;
|
|
148
|
+
if (path) req.path = path;
|
|
149
|
+
const auth = clean(q.auth);
|
|
150
|
+
if (auth !== undefined) {
|
|
151
|
+
if (token === undefined || !bearerMatches(auth, token)) return { decision: { allow: false }, evaluated: true };
|
|
152
|
+
// Host grain only: the host plane carries no rule map, so method/path cannot narrow or inject.
|
|
153
|
+
return { decision: decide(store.hostPlane(), { host }), evaluated: true };
|
|
154
|
+
}
|
|
155
|
+
return { decision: store.decide(src, req), evaluated: true };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Is a connection's remote address loopback (127.0.0.0/8 or ::1)? Gates /acl, which returns
|
|
160
|
+
* Tier B credential-injection headers and must only ever answer the in-container Squid helper
|
|
161
|
+
* (which calls over 127.0.0.1). A cross-container peer arrives under its network IP, never
|
|
162
|
+
* loopback, so it is refused. Empty/unknown ⇒ not loopback (fail-closed).
|
|
163
|
+
*/
|
|
164
|
+
export function isLoopbackAddr(addr: string | undefined): boolean {
|
|
165
|
+
if (!addr) return false;
|
|
166
|
+
const a = normalizeSourceIp(addr); // lowercases + strips the ::ffff: ipv4-mapped prefix
|
|
167
|
+
return a === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(a);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function clean(v: string | undefined): string | undefined {
|
|
171
|
+
if (v === undefined) return undefined;
|
|
172
|
+
const t = v.trim();
|
|
173
|
+
return t === "" || t === "-" ? undefined : t;
|
|
174
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Squid external_acl_type helper for the Open Everywhere egress proxy.
|
|
2
|
+
//
|
|
3
|
+
// Squid spawns this as a long-lived child, feeds it one whitespace-separated request line
|
|
4
|
+
// per lookup on stdin, and reads one response line on stdout. We use the concurrency
|
|
5
|
+
// (channel-id) protocol: each input line is `<id> <src> <host> <method> <path> <auth>` and each
|
|
6
|
+
// output line is `<id> OK`/`<id> ERR`, optionally with a `message="..."` and, for Tier B,
|
|
7
|
+
// a `authz="<header value>"` annotation Squid can stamp onto the forwarded request.
|
|
8
|
+
//
|
|
9
|
+
// It simply forwards the decision to the local control service (server.ts) over loopback.
|
|
10
|
+
// Plain Node, zero deps (no TS) so it needs no build step.
|
|
11
|
+
//
|
|
12
|
+
// Squid config wires it as (deploy/egress-proxy/squid.conf):
|
|
13
|
+
// external_acl_type oe_egress ttl=0 negative_ttl=0 grace=0 concurrency=64 children-max=8 \
|
|
14
|
+
// children-startup=1 \
|
|
15
|
+
// %SRC %DST %METHOD %PATH %>{Proxy-Authorization} \
|
|
16
|
+
// /usr/bin/node /opt/oe-egress/control/helper.cjs
|
|
17
|
+
// The last field is the request's Proxy-Authorization ("-" when absent). Only the APP's own
|
|
18
|
+
// model call carries one (OPE-2406); the control service decides it against the host-plane
|
|
19
|
+
// list, and every request without one against the session policy for its source IP.
|
|
20
|
+
// (Tier A, no MITM: a CONNECT carries no method and no path, so only %SRC and %DST decide it —
|
|
21
|
+
// handleLine below blanks the CONNECT method and the rule matches at host grain. A plain-HTTP
|
|
22
|
+
// request on port 80 still arrives with a real method and path, and a rule's method/path
|
|
23
|
+
// narrowing does apply to that one.)
|
|
24
|
+
|
|
25
|
+
const http = require("node:http");
|
|
26
|
+
|
|
27
|
+
const CONTROL_HOST = process.env.OE_EGRESS_CONTROL_HOST_INTERNAL || "127.0.0.1";
|
|
28
|
+
const CONTROL_PORT = Number(process.env.OE_EGRESS_CONTROL_PORT || 8080);
|
|
29
|
+
|
|
30
|
+
function query(src, host, method, path, auth) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const qs = new URLSearchParams({ src: src || "", host: host || "", method: method || "", path: path || "", auth: auth || "" });
|
|
33
|
+
const req = http.request(
|
|
34
|
+
{ host: CONTROL_HOST, port: CONTROL_PORT, path: `/acl?${qs.toString()}`, method: "GET", timeout: 2000 },
|
|
35
|
+
(res) => {
|
|
36
|
+
let buf = "";
|
|
37
|
+
res.on("data", (d) => (buf += d));
|
|
38
|
+
res.on("end", () => {
|
|
39
|
+
try {
|
|
40
|
+
resolve(JSON.parse(buf));
|
|
41
|
+
} catch {
|
|
42
|
+
resolve({ allow: false }); // fail-closed on a malformed control response
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
req.on("error", () => resolve({ allow: false })); // fail-closed if control is unreachable
|
|
48
|
+
req.on("timeout", () => {
|
|
49
|
+
req.destroy();
|
|
50
|
+
resolve({ allow: false });
|
|
51
|
+
});
|
|
52
|
+
req.end();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function esc(v) {
|
|
57
|
+
return String(v).replace(/"/g, '\\"');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function handleLine(line) {
|
|
61
|
+
const trimmed = line.trim();
|
|
62
|
+
if (!trimmed) return;
|
|
63
|
+
// Concurrency protocol: first token is the channel id.
|
|
64
|
+
const parts = trimmed.split(/\s+/);
|
|
65
|
+
const id = parts.shift();
|
|
66
|
+
const [src, host, rawMethod, path, auth] = parts;
|
|
67
|
+
// A CONNECT is tunnel establishment, not the inner HTTP request — in Tier A (splice, no
|
|
68
|
+
// decryption) we only ever see CONNECT, so treat its method/path as UNKNOWN and match at
|
|
69
|
+
// host granularity. In Tier B (bump) Squid re-issues the decrypted GET/POST with a real
|
|
70
|
+
// method+path, which arrives here for narrowing + header injection.
|
|
71
|
+
const method = rawMethod && rawMethod.toUpperCase() === "CONNECT" ? "" : rawMethod;
|
|
72
|
+
const decision = await query(dec(src), dec(host), dec(method), dec(path), dec(auth));
|
|
73
|
+
if (decision && decision.allow) {
|
|
74
|
+
// Tier B: pass the Authorization header value back as an annotation Squid stamps.
|
|
75
|
+
let extra = "";
|
|
76
|
+
if (decision.headers && decision.headers.authorization) {
|
|
77
|
+
extra = ` authz="${esc(decision.headers.authorization)}"`;
|
|
78
|
+
} else if (decision.headers && decision.headers.Authorization) {
|
|
79
|
+
extra = ` authz="${esc(decision.headers.Authorization)}"`;
|
|
80
|
+
}
|
|
81
|
+
process.stdout.write(`${id} OK${extra}\n`);
|
|
82
|
+
} else {
|
|
83
|
+
process.stdout.write(`${id} ERR message="denied by open-everywhere egress policy"\n`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Squid URL-ENCODES the fields it passes (%PATH and friends can hold spaces); decode defensively.
|
|
88
|
+
function dec(v) {
|
|
89
|
+
if (v === undefined) return "";
|
|
90
|
+
try {
|
|
91
|
+
return decodeURIComponent(v);
|
|
92
|
+
} catch {
|
|
93
|
+
return v;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let backlog = "";
|
|
98
|
+
process.stdin.setEncoding("utf-8");
|
|
99
|
+
process.stdin.on("data", (chunk) => {
|
|
100
|
+
backlog += chunk;
|
|
101
|
+
let nl;
|
|
102
|
+
while ((nl = backlog.indexOf("\n")) >= 0) {
|
|
103
|
+
const line = backlog.slice(0, nl);
|
|
104
|
+
backlog = backlog.slice(nl + 1);
|
|
105
|
+
void handleLine(line);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
process.stdin.on("end", () => process.exit(0));
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Control service for the Open Everywhere egress proxy.
|
|
2
|
+
//
|
|
3
|
+
// Two clients:
|
|
4
|
+
// 1. The app (agent/access/self-host-egress.ts) POSTs the compiled SandboxNetworkPolicy
|
|
5
|
+
// to POST /policy whenever it changes (session start + mid-turn git broker).
|
|
6
|
+
// 2. The Squid `external_acl_type` helper (helper.cjs) GETs /acl per request to decide
|
|
7
|
+
// allow/deny and fetch any Tier B credential-injection headers.
|
|
8
|
+
//
|
|
9
|
+
// Dependency-free: Node's built-in http only, runs under `node --experimental-strip-types`
|
|
10
|
+
// (Node 24) so no build step and no npm install in the proxy image. All decision logic
|
|
11
|
+
// lives in the pure, unit-tested acl.ts / handlers.ts.
|
|
12
|
+
//
|
|
13
|
+
// Endpoints:
|
|
14
|
+
// POST /policy { policy, sourceIp?, sessionId? } → store policy for a source IP
|
|
15
|
+
// POST /policy { plane: "host", hostPlane } → store the app's HOST-PLANE allow list
|
|
16
|
+
// DELETE /policy ?sourceIp=IP → drop a source's policy
|
|
17
|
+
// GET /acl ?src=&host=&method=&path=&auth= → { allow, headers } (helper only)
|
|
18
|
+
// GET /healthz → 200
|
|
19
|
+
//
|
|
20
|
+
// Auth: OE_EGRESS_PROXY_TOKEN is REQUIRED — the service refuses to start without it (there is
|
|
21
|
+
// no tokenless mode and no opt-out flag). POST/DELETE /policy require `Authorization: Bearer
|
|
22
|
+
// <token>`; the listener is on the same internal network as the sandbox, so a tokenless control
|
|
23
|
+
// plane would let a compromised sandbox POST itself an allow-all policy and walk out through the
|
|
24
|
+
// proxy. /acl carries the Tier B credential-injection headers, so it is LOOPBACK-ONLY: the
|
|
25
|
+
// handler refuses any /acl request whose peer is not 127.0.0.0/8 or ::1 (403). The in-container
|
|
26
|
+
// Squid helper (helper.cjs) calls it over 127.0.0.1 and is unaffected; a sandbox on the
|
|
27
|
+
// app-facing network therefore cannot read an injected credential back out.
|
|
28
|
+
|
|
29
|
+
import http from "node:http";
|
|
30
|
+
import { PolicyStore } from "./acl.ts";
|
|
31
|
+
import { handlePolicyPush, handleAcl, isLoopbackAddr, requireControlToken, bearerMatches } from "./handlers.ts";
|
|
32
|
+
|
|
33
|
+
const store = new PolicyStore();
|
|
34
|
+
|
|
35
|
+
/** Fail loud and early: no token ⇒ no control plane. */
|
|
36
|
+
function loadToken(): string {
|
|
37
|
+
try {
|
|
38
|
+
return requireControlToken(process.env.OE_EGRESS_PROXY_TOKEN);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(`[oe-egress-control] FATAL: ${err instanceof Error ? err.message : String(err)}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const TOKEN = loadToken();
|
|
46
|
+
const PORT = Number(process.env.OE_EGRESS_CONTROL_PORT ?? 8080);
|
|
47
|
+
const HOST = process.env.OE_EGRESS_CONTROL_HOST ?? "0.0.0.0";
|
|
48
|
+
|
|
49
|
+
function authOk(req: http.IncomingMessage): boolean {
|
|
50
|
+
return bearerMatches(req.headers["authorization"], TOKEN);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function remoteAddr(req: http.IncomingMessage): string {
|
|
54
|
+
return req.socket.remoteAddress ?? "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function send(res: http.ServerResponse, status: number, body: unknown): void {
|
|
58
|
+
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
|
59
|
+
res.writeHead(status, { "content-type": typeof body === "string" ? "text/plain" : "application/json" });
|
|
60
|
+
res.end(payload);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readJson(req: http.IncomingMessage): Promise<unknown> {
|
|
64
|
+
const chunks: Buffer[] = [];
|
|
65
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
66
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
67
|
+
if (!raw) return undefined;
|
|
68
|
+
return JSON.parse(raw);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const server = http.createServer(async (req, res) => {
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
74
|
+
const path = url.pathname;
|
|
75
|
+
|
|
76
|
+
if (req.method === "GET" && path === "/healthz") return send(res, 200, { ok: true, policies: store.size(), hostPlane: store.hostPlane() !== undefined });
|
|
77
|
+
|
|
78
|
+
if (path === "/policy" && req.method === "POST") {
|
|
79
|
+
let body: unknown;
|
|
80
|
+
try {
|
|
81
|
+
body = await readJson(req);
|
|
82
|
+
} catch {
|
|
83
|
+
return send(res, 400, { error: "invalid JSON" });
|
|
84
|
+
}
|
|
85
|
+
const result = handlePolicyPush(store, { body, remoteAddr: remoteAddr(req), authOk: authOk(req) });
|
|
86
|
+
if (result.keyed) console.log(`[oe-egress-control] policy set for ${result.keyed} (${store.size()} session source(s) active)`);
|
|
87
|
+
return send(res, result.status, result.body);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (path === "/policy" && req.method === "DELETE") {
|
|
91
|
+
if (!authOk(req)) return send(res, 401, { error: "unauthorized" });
|
|
92
|
+
const src = url.searchParams.get("sourceIp") ?? remoteAddr(req);
|
|
93
|
+
const removed = store.delete(src);
|
|
94
|
+
return send(res, 200, { ok: true, removed });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (path === "/acl" && req.method === "GET") {
|
|
98
|
+
// /acl returns Tier B credential-injection headers; it is loopback-only (the in-container
|
|
99
|
+
// Squid helper calls over 127.0.0.1). Refuse any non-loopback peer so a sandbox on the
|
|
100
|
+
// app-facing network cannot read injected credentials back. See handlers.ts:isLoopbackAddr.
|
|
101
|
+
if (!isLoopbackAddr(remoteAddr(req))) return send(res, 403, { error: "forbidden" });
|
|
102
|
+
const q = {
|
|
103
|
+
src: url.searchParams.get("src") ?? undefined,
|
|
104
|
+
host: url.searchParams.get("host") ?? undefined,
|
|
105
|
+
method: url.searchParams.get("method") ?? undefined,
|
|
106
|
+
path: url.searchParams.get("path") ?? undefined,
|
|
107
|
+
auth: url.searchParams.get("auth") ?? undefined,
|
|
108
|
+
};
|
|
109
|
+
const { decision } = handleAcl(store, q, TOKEN);
|
|
110
|
+
return send(res, 200, { allow: decision.allow, headers: decision.headers ?? null, matchedHost: decision.matchedHost ?? null });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return send(res, 404, { error: "not found" });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(`[oe-egress-control] error: ${String(err)}`);
|
|
116
|
+
return send(res, 500, { error: "internal error" });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
server.listen(PORT, HOST, () => {
|
|
121
|
+
console.log(`[oe-egress-control] listening on ${HOST}:${PORT} (auth required on /policy)`);
|
|
122
|
+
});
|