open-managed-agents 0.1.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.
Files changed (197) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +248 -0
  3. package/bin/oma.mjs +499 -0
  4. package/dist/scripts/alpha-openai-compatible-fixture.mjs +134 -0
  5. package/dist/scripts/alpha-smoke-models.mjs +92 -0
  6. package/dist/scripts/alpha-smoke.mjs +581 -0
  7. package/dist/scripts/oma-doctor.js +230 -0
  8. package/dist/scripts/oma-models.js +299 -0
  9. package/dist/scripts/oma-workspaces.js +133 -0
  10. package/dist/src/control-plane/admin/auth.js +43 -0
  11. package/dist/src/control-plane/admin/routes.js +76 -0
  12. package/dist/src/control-plane/admin/service.js +102 -0
  13. package/dist/src/control-plane/admission.js +90 -0
  14. package/dist/src/control-plane/agents/routes.js +63 -0
  15. package/dist/src/control-plane/agents/service.js +687 -0
  16. package/dist/src/control-plane/agents/store.js +391 -0
  17. package/dist/src/control-plane/agents/types.js +1 -0
  18. package/dist/src/control-plane/api-constants.js +3 -0
  19. package/dist/src/control-plane/app.js +907 -0
  20. package/dist/src/control-plane/console/auth.js +68 -0
  21. package/dist/src/control-plane/console/static.js +112 -0
  22. package/dist/src/control-plane/deployment-runtime-config.js +328 -0
  23. package/dist/src/control-plane/deployment-runtime-event-coordinator.js +25 -0
  24. package/dist/src/control-plane/deployment-runtime-turn-guard.js +14 -0
  25. package/dist/src/control-plane/deployment-session-coordinator.js +25 -0
  26. package/dist/src/control-plane/deployment-session-output-coordinator.js +27 -0
  27. package/dist/src/control-plane/deployment-storage.js +354 -0
  28. package/dist/src/control-plane/egress/image.js +48 -0
  29. package/dist/src/control-plane/egress/policy.js +571 -0
  30. package/dist/src/control-plane/egress/presets.js +77 -0
  31. package/dist/src/control-plane/egress/proxy.js +72 -0
  32. package/dist/src/control-plane/egress/ssrf.js +106 -0
  33. package/dist/src/control-plane/egress/utils/debug.js +14 -0
  34. package/dist/src/control-plane/egress/vendor/http-proxy.js +335 -0
  35. package/dist/src/control-plane/egress/vendor/mitm-ca.js +226 -0
  36. package/dist/src/control-plane/egress/vendor/mitm-leaf.js +124 -0
  37. package/dist/src/control-plane/egress/vendor/parent-proxy.js +438 -0
  38. package/dist/src/control-plane/egress/vendor/request-filter.js +105 -0
  39. package/dist/src/control-plane/egress/vendor/sandbox-config.js +1 -0
  40. package/dist/src/control-plane/egress/vendor/tls-terminate-proxy.js +269 -0
  41. package/dist/src/control-plane/environments/routes.js +54 -0
  42. package/dist/src/control-plane/environments/service.js +112 -0
  43. package/dist/src/control-plane/environments/store.js +132 -0
  44. package/dist/src/control-plane/environments/types.js +1 -0
  45. package/dist/src/control-plane/errors.js +81 -0
  46. package/dist/src/control-plane/events/broadcaster.js +217 -0
  47. package/dist/src/control-plane/events/constants.js +2 -0
  48. package/dist/src/control-plane/events/persist.js +39 -0
  49. package/dist/src/control-plane/events/request.js +190 -0
  50. package/dist/src/control-plane/events/routes.js +159 -0
  51. package/dist/src/control-plane/events/runtime-helpers.js +134 -0
  52. package/dist/src/control-plane/events/service.js +1985 -0
  53. package/dist/src/control-plane/events/session-guards.js +29 -0
  54. package/dist/src/control-plane/events/sse.js +6 -0
  55. package/dist/src/control-plane/events/store.js +772 -0
  56. package/dist/src/control-plane/events/tool-persistence.js +102 -0
  57. package/dist/src/control-plane/events/types.js +21 -0
  58. package/dist/src/control-plane/files/routes.js +121 -0
  59. package/dist/src/control-plane/files/service.js +65 -0
  60. package/dist/src/control-plane/files/store-common.js +87 -0
  61. package/dist/src/control-plane/files/store-local.js +488 -0
  62. package/dist/src/control-plane/files/store-memory.js +270 -0
  63. package/dist/src/control-plane/files/store.js +2 -0
  64. package/dist/src/control-plane/files/types.js +7 -0
  65. package/dist/src/control-plane/http.js +25 -0
  66. package/dist/src/control-plane/ids.js +43 -0
  67. package/dist/src/control-plane/logging.js +263 -0
  68. package/dist/src/control-plane/models/auth-storage-backend.js +238 -0
  69. package/dist/src/control-plane/models/catalog.js +78 -0
  70. package/dist/src/control-plane/models/config-security.js +376 -0
  71. package/dist/src/control-plane/models/deployment-config.js +93 -0
  72. package/dist/src/control-plane/models/routes.js +32 -0
  73. package/dist/src/control-plane/models/service.js +138 -0
  74. package/dist/src/control-plane/observability/instruments.js +65 -0
  75. package/dist/src/control-plane/observability/metrics.js +125 -0
  76. package/dist/src/control-plane/observability/routes.js +95 -0
  77. package/dist/src/control-plane/openapi/document.js +282 -0
  78. package/dist/src/control-plane/openapi/routes.js +78 -0
  79. package/dist/src/control-plane/request-idempotency.js +85 -0
  80. package/dist/src/control-plane/secrets/envelope.js +132 -0
  81. package/dist/src/control-plane/secrets/master-key.js +44 -0
  82. package/dist/src/control-plane/secrets/routes.js +20 -0
  83. package/dist/src/control-plane/secrets/service.js +71 -0
  84. package/dist/src/control-plane/secrets/store.js +212 -0
  85. package/dist/src/control-plane/secrets/types.js +3 -0
  86. package/dist/src/control-plane/sessions/pi/custom-tools.js +146 -0
  87. package/dist/src/control-plane/sessions/pi/mcp/bridge.js +527 -0
  88. package/dist/src/control-plane/sessions/pi/mcp/client.js +157 -0
  89. package/dist/src/control-plane/sessions/pi/mcp/credential.js +28 -0
  90. package/dist/src/control-plane/sessions/pi/mcp/fetch.js +69 -0
  91. package/dist/src/control-plane/sessions/pi/mcp/probe.js +202 -0
  92. package/dist/src/control-plane/sessions/pi/mcp/runtime.js +13 -0
  93. package/dist/src/control-plane/sessions/pi/runner.js +1104 -0
  94. package/dist/src/control-plane/sessions/pi/sandbox/cma-glob.js +388 -0
  95. package/dist/src/control-plane/sessions/pi/sandbox/cma-grep.js +124 -0
  96. package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +347 -0
  97. package/dist/src/control-plane/sessions/pi/sandbox/docker.js +1589 -0
  98. package/dist/src/control-plane/sessions/pi/sandbox/glob.js +48 -0
  99. package/dist/src/control-plane/sessions/pi/sandbox/image.js +7 -0
  100. package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +1677 -0
  101. package/dist/src/control-plane/sessions/pi/sandbox/provider.js +561 -0
  102. package/dist/src/control-plane/sessions/pi/sandbox/selection.js +168 -0
  103. package/dist/src/control-plane/sessions/pi/span-normalizer.js +82 -0
  104. package/dist/src/control-plane/sessions/pi/tool-permissions.js +392 -0
  105. package/dist/src/control-plane/sessions/pi/translator.js +100 -0
  106. package/dist/src/control-plane/sessions/request.js +164 -0
  107. package/dist/src/control-plane/sessions/resources.js +63 -0
  108. package/dist/src/control-plane/sessions/routes.js +88 -0
  109. package/dist/src/control-plane/sessions/serialize.js +24 -0
  110. package/dist/src/control-plane/sessions/service.js +733 -0
  111. package/dist/src/control-plane/sessions/store.js +538 -0
  112. package/dist/src/control-plane/sessions/types.js +1 -0
  113. package/dist/src/control-plane/skills/archive.js +169 -0
  114. package/dist/src/control-plane/skills/routes.js +41 -0
  115. package/dist/src/control-plane/skills/service.js +29 -0
  116. package/dist/src/control-plane/skills/store.js +233 -0
  117. package/dist/src/control-plane/skills/types.js +5 -0
  118. package/dist/src/control-plane/sqlite-transaction.js +15 -0
  119. package/dist/src/control-plane/vaults/mcp-oauth-validate.js +165 -0
  120. package/dist/src/control-plane/vaults/oauth-refresh-ticker.js +43 -0
  121. package/dist/src/control-plane/vaults/oauth-refresh.js +448 -0
  122. package/dist/src/control-plane/vaults/routes.js +66 -0
  123. package/dist/src/control-plane/vaults/service.js +553 -0
  124. package/dist/src/control-plane/vaults/store.js +691 -0
  125. package/dist/src/control-plane/vaults/types.js +1 -0
  126. package/dist/src/control-plane/wake-loop.js +65 -0
  127. package/dist/src/control-plane/wiring.js +92 -0
  128. package/dist/src/control-plane/workspace.js +4 -0
  129. package/dist/src/control-plane/workspaces/store.js +158 -0
  130. package/dist/src/egress-proxy-main.js +103 -0
  131. package/dist/src/main.js +124 -0
  132. package/dist/src/types/agents.js +1 -0
  133. package/dist/src/types/common.js +1 -0
  134. package/dist/src/types/environments.js +1 -0
  135. package/dist/src/types/events.js +121 -0
  136. package/dist/src/types/files.js +1 -0
  137. package/dist/src/types/json.js +19 -0
  138. package/dist/src/types/sessions.js +1 -0
  139. package/dist/ui/managed-agents-console/README.md +52 -0
  140. package/dist/ui/managed-agents-console/docs/agents.md +23 -0
  141. package/dist/ui/managed-agents-console/docs/console.md +23 -0
  142. package/dist/ui/managed-agents-console/docs/dreams.md +5 -0
  143. package/dist/ui/managed-agents-console/docs/environments.md +27 -0
  144. package/dist/ui/managed-agents-console/docs/events.md +19 -0
  145. package/dist/ui/managed-agents-console/docs/files.md +17 -0
  146. package/dist/ui/managed-agents-console/docs/github.md +11 -0
  147. package/dist/ui/managed-agents-console/docs/integrations.md +19 -0
  148. package/dist/ui/managed-agents-console/docs/memory.md +11 -0
  149. package/dist/ui/managed-agents-console/docs/migration.md +17 -0
  150. package/dist/ui/managed-agents-console/docs/multiagent.md +11 -0
  151. package/dist/ui/managed-agents-console/docs/outcomes.md +11 -0
  152. package/dist/ui/managed-agents-console/docs/overview.md +41 -0
  153. package/dist/ui/managed-agents-console/docs/permissions.md +24 -0
  154. package/dist/ui/managed-agents-console/docs/quickstart.md +51 -0
  155. package/dist/ui/managed-agents-console/docs/reference.md +27 -0
  156. package/dist/ui/managed-agents-console/docs/sandbox-reference.md +19 -0
  157. package/dist/ui/managed-agents-console/docs/sandbox-security.md +15 -0
  158. package/dist/ui/managed-agents-console/docs/scheduled-deployments.md +5 -0
  159. package/dist/ui/managed-agents-console/docs/self-hosted-sandboxes.md +15 -0
  160. package/dist/ui/managed-agents-console/docs/session-operations.md +21 -0
  161. package/dist/ui/managed-agents-console/docs/sessions.md +21 -0
  162. package/dist/ui/managed-agents-console/docs/skills.md +15 -0
  163. package/dist/ui/managed-agents-console/docs/tools.md +23 -0
  164. package/dist/ui/managed-agents-console/docs/vaults.md +19 -0
  165. package/dist/ui/managed-agents-console/docs/webhooks.md +11 -0
  166. package/dist/ui/managed-agents-console/index.html +47 -0
  167. package/dist/ui/managed-agents-console/serve.mjs +116 -0
  168. package/dist/ui/managed-agents-console/src/agents-files.jsx +273 -0
  169. package/dist/ui/managed-agents-console/src/api.js +1043 -0
  170. package/dist/ui/managed-agents-console/src/app.jsx +633 -0
  171. package/dist/ui/managed-agents-console/src/auth.jsx +215 -0
  172. package/dist/ui/managed-agents-console/src/console.css +733 -0
  173. package/dist/ui/managed-agents-console/src/data.js +188 -0
  174. package/dist/ui/managed-agents-console/src/detail.jsx +719 -0
  175. package/dist/ui/managed-agents-console/src/docs.jsx +200 -0
  176. package/dist/ui/managed-agents-console/src/environments.jsx +365 -0
  177. package/dist/ui/managed-agents-console/src/forms.jsx +494 -0
  178. package/dist/ui/managed-agents-console/src/icons.jsx +48 -0
  179. package/dist/ui/managed-agents-console/src/skills.jsx +21 -0
  180. package/dist/ui/managed-agents-console/src/sse.js +185 -0
  181. package/dist/ui/managed-agents-console/src/states.jsx +90 -0
  182. package/dist/ui/managed-agents-console/src/tweaks-panel.jsx +541 -0
  183. package/dist/ui/managed-agents-console/src/ui.jsx +190 -0
  184. package/dist/ui/managed-agents-console/src/vaults-data.js +89 -0
  185. package/dist/ui/managed-agents-console/src/vaults.jsx +171 -0
  186. package/dist/ui/managed-agents-console/vendor/babel.min.js +4 -0
  187. package/dist/ui/managed-agents-console/vendor/react-dom.production.min.js +267 -0
  188. package/dist/ui/managed-agents-console/vendor/react.production.min.js +31 -0
  189. package/dist/ui/openapi-docs/VENDOR.md +27 -0
  190. package/dist/ui/openapi-docs/index.html +16 -0
  191. package/dist/ui/openapi-docs/swagger-initializer.js +14 -0
  192. package/dist/ui/openapi-docs/vendor/LICENSE +202 -0
  193. package/dist/ui/openapi-docs/vendor/NOTICE +2 -0
  194. package/dist/ui/openapi-docs/vendor/swagger-ui-bundle.js +2 -0
  195. package/dist/ui/openapi-docs/vendor/swagger-ui-standalone-preset.js +2 -0
  196. package/dist/ui/openapi-docs/vendor/swagger-ui.css +3 -0
  197. package/package.json +53 -0
@@ -0,0 +1,571 @@
1
+ // Egress policy as data + resolution (plan 0117c; ADR 0016 §2, §3, §6).
2
+ //
3
+ // A session's egress policy is a plain serializable object parsed from the
4
+ // environment's `config.networking`, resolved into (a) per-session sentinels
5
+ // for the sandbox environment and (b) the hook set `createEgressProxy`
6
+ // consumes. Policy is testable without a running proxy.
7
+ //
8
+ // Postures locked in here:
9
+ // - default deny: no `networking` config -> no policy -> no proxy at all
10
+ // (the environment stays at --network none, ADR 0016 §2);
11
+ // - strict parse: unknown keys are rejected — a typo must not silently
12
+ // widen policy;
13
+ // - inject grants are path-scoped (pathPrefix REQUIRED) and optionally
14
+ // method-scoped (ADR 0016 §6 — reflectivity is per-endpoint);
15
+ // - secrets never enter the sandbox: the sandbox gets a random per-session
16
+ // sentinel; the proxy substitutes the real secret at the boundary, lazily
17
+ // revealed per request (rotation is picked up, no long-lived plaintext);
18
+ // - injection hooks NEVER throw: a reveal failure strips the credential
19
+ // header (fail closed for the secret) instead of crashing the proxy;
20
+ // - opaque tunnels (no inspection, no injection) are per-host opt-ins.
21
+ import { randomBytes } from "node:crypto";
22
+ import { isIP } from "node:net";
23
+ export class EgressPolicyError extends Error {
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Parsing (strict)
27
+ const ALLOW_KEYS = new Set(["host", "port", "pathPrefix", "opaqueTunnel"]);
28
+ const CREDENTIAL_KEYS = new Set([
29
+ "secret",
30
+ "env",
31
+ "host",
32
+ "port",
33
+ "pathPrefix",
34
+ "methods",
35
+ "header",
36
+ ]);
37
+ const NETWORKING_KEYS = new Set(["allow", "credentials"]);
38
+ const HOSTED_NETWORKING_KEYS = new Set([
39
+ "type",
40
+ "allowed_hosts",
41
+ "allow_package_managers",
42
+ "allow_mcp_servers",
43
+ ]);
44
+ /**
45
+ * Parse `config.networking` into OMA's internal egress policy.
46
+ *
47
+ * The public networking field has two deliberately disjoint shapes:
48
+ * OMA-native `{ allow, credentials }` and the CMA-shaped hosted
49
+ * `{ type, allowed_hosts, ... }`. Every present shape is classified and
50
+ * validated; an unknown or mixed shape is never treated as absent networking.
51
+ * Returns undefined only for absent networking or a valid hosted empty list.
52
+ */
53
+ export function parseNetworkingConfig(config) {
54
+ const networking = config["networking"];
55
+ if (networking === undefined)
56
+ return undefined;
57
+ if (!isPlainObject(networking)) {
58
+ throw new EgressPolicyError("networking must be an object");
59
+ }
60
+ if (isHostedNetworkingShape(networking)) {
61
+ return parseHostedNetworkingConfig(networking);
62
+ }
63
+ if ("allow" in networking || "credentials" in networking) {
64
+ return parseNativeNetworkingConfig(networking);
65
+ }
66
+ throw new EgressPolicyError('networking must use either the OMA-native {allow, credentials} shape or the hosted {type, allowed_hosts} shape');
67
+ }
68
+ /**
69
+ * Validate networking and return the durable canonical representation.
70
+ *
71
+ * Native OMA policies are already structured records and are preserved after
72
+ * validation. CMA-shaped host lists are normalized to lowercase using the
73
+ * exact parser that runtime enforcement uses, so the console/API never show a
74
+ * different policy from the one the proxy will enforce.
75
+ */
76
+ export function canonicalizeNetworkingConfig(config) {
77
+ const networking = config["networking"];
78
+ if (networking === undefined)
79
+ return config;
80
+ if (!isPlainObject(networking)) {
81
+ throw new EgressPolicyError("networking must be an object");
82
+ }
83
+ if (isHostedNetworkingShape(networking)) {
84
+ const policy = parseHostedNetworkingConfig(networking);
85
+ return {
86
+ ...config,
87
+ networking: {
88
+ ...networking,
89
+ allowed_hosts: policy?.allow.map((entry) => entry.host) ?? [],
90
+ },
91
+ };
92
+ }
93
+ parseNativeNetworkingConfig(networking);
94
+ return config;
95
+ }
96
+ export function canonicalizeHostedAllowedHosts(input) {
97
+ const policy = parseHostedNetworkingConfig({
98
+ type: "limited",
99
+ allowed_hosts: input,
100
+ });
101
+ return policy?.allow.map((entry) => entry.host) ?? [];
102
+ }
103
+ function isHostedNetworkingShape(networking) {
104
+ return ("type" in networking ||
105
+ "allowed_hosts" in networking ||
106
+ "allow_package_managers" in networking ||
107
+ "allow_mcp_servers" in networking);
108
+ }
109
+ function parseHostedNetworkingConfig(networking) {
110
+ rejectUnknownKeys(networking, HOSTED_NETWORKING_KEYS, "networking");
111
+ const type = networking["type"];
112
+ if (type === "unrestricted") {
113
+ throw new EgressPolicyError('networking.type "unrestricted" is not supported; use type "limited" with an allowed_hosts list');
114
+ }
115
+ if (type !== "limited") {
116
+ throw new EgressPolicyError('networking.type must be "limited" ("unrestricted" is not supported)');
117
+ }
118
+ const rawAllowedHosts = networking["allowed_hosts"];
119
+ if (!Array.isArray(rawAllowedHosts)) {
120
+ throw new EgressPolicyError("networking.allowed_hosts must be an array");
121
+ }
122
+ for (const flag of ["allow_package_managers", "allow_mcp_servers"]) {
123
+ const value = networking[flag];
124
+ if (value !== undefined && typeof value !== "boolean") {
125
+ throw new EgressPolicyError(`networking.${flag} must be a boolean`);
126
+ }
127
+ if (value === true) {
128
+ throw new EgressPolicyError(`networking.${flag}=true is not supported by this deployment`);
129
+ }
130
+ }
131
+ const seen = new Set();
132
+ const allow = [];
133
+ rawAllowedHosts.forEach((value, index) => {
134
+ const host = parseHostedHost(value, `networking.allowed_hosts[${index}]`);
135
+ if (seen.has(host)) {
136
+ throw new EgressPolicyError(`networking.allowed_hosts: duplicate entry ${host}`);
137
+ }
138
+ seen.add(host);
139
+ // Hosted limited networking is explicitly HTTPS-only. Termination is what
140
+ // lets the proxy distinguish TLS from arbitrary plaintext sent through a
141
+ // CONNECT tunnel; no credential or path grant is synthesized here.
142
+ allow.push({ host, port: 443, protocol: "https", opaqueTunnel: false });
143
+ });
144
+ // A hosted empty list is an explicit, valid default-deny request. It must
145
+ // remain distinct from native `{ allow: [] }`, whose existing OMA behavior
146
+ // returns an empty policy object and therefore still requires egress wiring.
147
+ if (allow.length === 0)
148
+ return undefined;
149
+ return { allow, credentials: [] };
150
+ }
151
+ function parseNativeNetworkingConfig(networking) {
152
+ rejectUnknownKeys(networking, NETWORKING_KEYS, "networking");
153
+ const rawAllow = networking["allow"];
154
+ if (!Array.isArray(rawAllow)) {
155
+ throw new EgressPolicyError("networking.allow must be an array");
156
+ }
157
+ const allow = rawAllow.map((entry, i) => parseAllowEntry(entry, i));
158
+ const seen = new Set();
159
+ for (const entry of allow) {
160
+ const key = `${entry.host}:${entry.port}`;
161
+ if (seen.has(key)) {
162
+ throw new EgressPolicyError(`networking.allow: duplicate entry ${key}`);
163
+ }
164
+ seen.add(key);
165
+ }
166
+ const rawCredentials = networking["credentials"] ?? [];
167
+ if (!Array.isArray(rawCredentials)) {
168
+ throw new EgressPolicyError("networking.credentials must be an array");
169
+ }
170
+ const credentials = rawCredentials.map((entry, i) => parseCredentialGrant(entry, i));
171
+ const envSeen = new Set();
172
+ for (const grant of credentials) {
173
+ // Every inject host must be an allowlisted, TERMINATED host: injection
174
+ // happens on the decrypted leg, so an opaque host can't carry a grant.
175
+ const target = allow.find((entry) => entry.host === grant.host && entry.port === grant.port);
176
+ if (!target) {
177
+ throw new EgressPolicyError(`networking.credentials: ${grant.host}:${grant.port} is not in networking.allow`);
178
+ }
179
+ if (target.opaqueTunnel) {
180
+ throw new EgressPolicyError(`networking.credentials: ${grant.host}:${grant.port} is an opaqueTunnel host — ` +
181
+ "injection requires TLS termination");
182
+ }
183
+ if (envSeen.has(grant.env)) {
184
+ throw new EgressPolicyError(`networking.credentials: duplicate env ${grant.env}`);
185
+ }
186
+ envSeen.add(grant.env);
187
+ }
188
+ return { allow, credentials };
189
+ }
190
+ function parseAllowEntry(value, index) {
191
+ const at = `networking.allow[${index}]`;
192
+ if (!isPlainObject(value)) {
193
+ throw new EgressPolicyError(`${at} must be an object`);
194
+ }
195
+ rejectUnknownKeys(value, ALLOW_KEYS, at);
196
+ const host = parseHost(value["host"], at);
197
+ const port = parsePort(value["port"], at);
198
+ const opaqueTunnel = value["opaqueTunnel"] ?? false;
199
+ if (typeof opaqueTunnel !== "boolean") {
200
+ throw new EgressPolicyError(`${at}.opaqueTunnel must be a boolean`);
201
+ }
202
+ const entry = { host, port, opaqueTunnel };
203
+ if (value["pathPrefix"] !== undefined) {
204
+ if (opaqueTunnel) {
205
+ throw new EgressPolicyError(`${at}: pathPrefix cannot apply to an opaqueTunnel host (no inspection)`);
206
+ }
207
+ entry.pathPrefix = parsePathPrefix(value["pathPrefix"], at);
208
+ }
209
+ return entry;
210
+ }
211
+ function parseCredentialGrant(value, index) {
212
+ const at = `networking.credentials[${index}]`;
213
+ if (!isPlainObject(value)) {
214
+ throw new EgressPolicyError(`${at} must be an object`);
215
+ }
216
+ rejectUnknownKeys(value, CREDENTIAL_KEYS, at);
217
+ const secret = value["secret"];
218
+ if (typeof secret !== "string" || secret === "") {
219
+ throw new EgressPolicyError(`${at}.secret must be a non-empty string`);
220
+ }
221
+ const env = value["env"];
222
+ if (typeof env !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(env)) {
223
+ throw new EgressPolicyError(`${at}.env must be an UPPER_SNAKE_CASE env var name`);
224
+ }
225
+ const grant = {
226
+ secret,
227
+ env,
228
+ host: parseHost(value["host"], at),
229
+ port: parsePort(value["port"], at),
230
+ // ADR 0016 §6: pathPrefix is mandatory for inject grants.
231
+ pathPrefix: parsePathPrefix(value["pathPrefix"], at),
232
+ header: parseHeader(value["header"], at),
233
+ };
234
+ if (value["methods"] !== undefined) {
235
+ const methods = value["methods"];
236
+ if (!Array.isArray(methods) ||
237
+ methods.length === 0 ||
238
+ !methods.every((m) => typeof m === "string" && /^[A-Za-z]+$/.test(m))) {
239
+ throw new EgressPolicyError(`${at}.methods must be a non-empty array of HTTP method names`);
240
+ }
241
+ grant.methods = methods.map((m) => m.toUpperCase());
242
+ }
243
+ return grant;
244
+ }
245
+ function parseHost(value, at) {
246
+ if (typeof value !== "string" ||
247
+ value === "" ||
248
+ value !== value.toLowerCase() ||
249
+ value.includes("*") ||
250
+ value.endsWith(".")) {
251
+ throw new EgressPolicyError(`${at}.host must be a lowercase hostname (no wildcards, no trailing dot)`);
252
+ }
253
+ return value;
254
+ }
255
+ /**
256
+ * CMA-hosted hostname grammar. This is intentionally stricter than the
257
+ * pre-existing native OMA parser: hosted values are a closed, safe subset and
258
+ * are normalized only for the internal policy, never in persisted JSON.
259
+ */
260
+ function parseHostedHost(value, at) {
261
+ if (typeof value !== "string" || value.length === 0) {
262
+ throw new EgressPolicyError(`${at} must be a hostname`);
263
+ }
264
+ const wildcard = value.startsWith("*.");
265
+ const hostname = wildcard ? value.slice(2) : value;
266
+ if (value.includes("*") && !wildcard) {
267
+ throw new EgressPolicyError(`${at} must use only a leading *. wildcard`);
268
+ }
269
+ if (value.length > 253 || hostname.length === 0 || isIP(hostname) !== 0) {
270
+ throw new EgressPolicyError(`${at} is not a valid DNS hostname`);
271
+ }
272
+ const labels = hostname.split(".");
273
+ if (labels.length < 2) {
274
+ throw new EgressPolicyError(`${at} must contain at least two hostname labels`);
275
+ }
276
+ for (const label of labels) {
277
+ if (!isHostnameLabel(label)) {
278
+ throw new EgressPolicyError(`${at} contains an invalid hostname label`);
279
+ }
280
+ }
281
+ return `${wildcard ? "*." : ""}${hostname.toLowerCase()}`;
282
+ }
283
+ function parsePort(value, at) {
284
+ if (value === undefined)
285
+ return 443;
286
+ if (!Number.isSafeInteger(value) || value < 1 || value > 65535) {
287
+ throw new EgressPolicyError(`${at}.port must be an integer in 1..65535`);
288
+ }
289
+ return value;
290
+ }
291
+ function parsePathPrefix(value, at) {
292
+ if (typeof value !== "string" || !value.startsWith("/")) {
293
+ throw new EgressPolicyError(`${at}.pathPrefix must be a string starting with "/"`);
294
+ }
295
+ return value;
296
+ }
297
+ function parseHeader(value, at) {
298
+ if (value === undefined)
299
+ return "authorization";
300
+ if (typeof value !== "string" || !/^[a-z0-9-]+$/.test(value)) {
301
+ throw new EgressPolicyError(`${at}.header must be a lowercase header name`);
302
+ }
303
+ return value;
304
+ }
305
+ function isPlainObject(value) {
306
+ return typeof value === "object" && value !== null && !Array.isArray(value);
307
+ }
308
+ function rejectUnknownKeys(value, known, at) {
309
+ for (const key of Object.keys(value)) {
310
+ if (!known.has(key)) {
311
+ throw new EgressPolicyError(`${at}: unknown key "${key}"`);
312
+ }
313
+ }
314
+ }
315
+ // ---------------------------------------------------------------------------
316
+ // Path matching
317
+ /**
318
+ * Segment-boundary prefix match with encoding hardening (probed):
319
+ * - the URL parser normalizes `..` and `%2e%2e` dot segments, but
320
+ * `..%2f` / `%2f..` survive in pathname — an upstream that decodes %2f
321
+ * would escape the prefix, so any decoded form containing a ".." path
322
+ * step is denied outright;
323
+ * - `/repos` matches `/repos` and `/repos/x`, never `/repositories`.
324
+ */
325
+ export function pathWithinPrefix(pathname, prefix) {
326
+ let decoded;
327
+ try {
328
+ decoded = decodeURIComponent(pathname);
329
+ }
330
+ catch {
331
+ return false; // malformed percent-encoding: fail closed
332
+ }
333
+ if (/(^|[/\\])\.\.([/\\]|$)/.test(decoded))
334
+ return false;
335
+ if (pathname === prefix)
336
+ return true;
337
+ const boundary = prefix.endsWith("/") ? prefix : `${prefix}/`;
338
+ return pathname.startsWith(boundary);
339
+ }
340
+ /**
341
+ * Resolve an environment's networking config into per-session egress: the
342
+ * sandbox env (sentinels) and the proxy hook set. Returns undefined when the
343
+ * environment grants no egress (no proxy is stood up; --network none).
344
+ */
345
+ export function resolveSessionEgress(opts) {
346
+ const policy = parseNetworkingConfig(opts.environmentConfig);
347
+ if (policy === undefined)
348
+ return undefined;
349
+ const { grants, sandboxEnv } = mintSentinelGrants(policy);
350
+ return {
351
+ policy,
352
+ sandboxEnv,
353
+ hooks: buildHooks(policy, grants, opts.revealSecret),
354
+ };
355
+ }
356
+ function mintSentinelGrants(policy) {
357
+ const grants = policy.credentials.map((grant) => ({
358
+ ...grant,
359
+ sentinel: `oma-sentinel-${randomBytes(16).toString("hex")}`,
360
+ }));
361
+ const sandboxEnv = {};
362
+ for (const grant of grants) {
363
+ sandboxEnv[grant.env] = grant.sentinel;
364
+ }
365
+ return { grants, sandboxEnv };
366
+ }
367
+ /**
368
+ * Control-plane side of the sidecar seam (0117d). Parses the env's networking
369
+ * config, mints per-session sentinels, and resolves the session's granted
370
+ * secrets ONCE into a serializable {@link SessionEgressBundle}. Returns
371
+ * undefined when the environment grants no egress (no sidecar; --network none).
372
+ *
373
+ * A grant whose secret cannot be revealed is simply absent from `secrets` —
374
+ * the sidecar's injection hook strips that credential header (fail closed),
375
+ * exactly as the in-process path does.
376
+ */
377
+ export function resolveSessionEgressBundle(opts) {
378
+ // The token rides in a proxy-URL userinfo slot; constrain it to a URL-safe
379
+ // charset so a delimiter can't malform the URL or alter the credential the
380
+ // sandbox client sends. OMA mints the token, so this is a contract, not a
381
+ // parser — callers should pass hex / base64url.
382
+ if (typeof opts.proxyAuthToken !== "string" ||
383
+ !/^[A-Za-z0-9._~-]+$/.test(opts.proxyAuthToken)) {
384
+ throw new EgressPolicyError("resolveSessionEgressBundle requires a non-empty URL-safe proxyAuthToken " +
385
+ "([A-Za-z0-9._~-]); use hex or base64url");
386
+ }
387
+ const policy = parseNetworkingConfig(opts.environmentConfig);
388
+ if (policy === undefined)
389
+ return undefined;
390
+ const { grants, sandboxEnv } = mintSentinelGrants(policy);
391
+ const secrets = {};
392
+ for (const grant of grants) {
393
+ if (grant.secret in secrets)
394
+ continue; // resolve each secret once
395
+ let real;
396
+ try {
397
+ real = opts.revealSecret(grant.secret);
398
+ }
399
+ catch {
400
+ real = undefined;
401
+ }
402
+ if (real !== undefined)
403
+ secrets[grant.secret] = real;
404
+ }
405
+ return {
406
+ sandboxEnv,
407
+ bundle: {
408
+ policy,
409
+ grants,
410
+ secrets,
411
+ proxyAuthToken: opts.proxyAuthToken,
412
+ listenPort: opts.listenPort,
413
+ },
414
+ };
415
+ }
416
+ /**
417
+ * Sidecar side of the seam: rebuild the proxy hook set from a serialized
418
+ * {@link SessionEgressBundle}. `revealSecret` becomes a lookup into the
419
+ * launch-time resolved map, and the SAME {@link buildHooks} runs — so the
420
+ * sidecar and the in-process path share one enforcement implementation.
421
+ */
422
+ export function buildHooksFromBundle(bundle) {
423
+ return buildHooks(bundle.policy, bundle.grants, (name) => bundle.secrets[name]);
424
+ }
425
+ /**
426
+ * Match one policy hostname against a request hostname. A hosted `*.` entry
427
+ * matches one or more labels before its suffix, never the bare suffix itself.
428
+ * Native entries never contain wildcards, but use this same matcher so every
429
+ * proxy enforcement layer shares one rule.
430
+ */
431
+ export function hostMatchesAllowPattern(pattern, host) {
432
+ const normalizedHost = host.toLowerCase();
433
+ if (!pattern.startsWith("*."))
434
+ return pattern === normalizedHost;
435
+ const suffix = pattern.slice(2);
436
+ if (!normalizedHost.endsWith(`.${suffix}`))
437
+ return false;
438
+ const prefix = normalizedHost.slice(0, -(suffix.length + 1));
439
+ return prefix.length > 0 && prefix.split(".").every(isHostnameLabel);
440
+ }
441
+ function isHostnameLabel(label) {
442
+ return (label.length >= 1 &&
443
+ label.length <= 63 &&
444
+ /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label));
445
+ }
446
+ function buildHooks(policy, grants, revealSecret) {
447
+ const findAllow = (host, port) => policy.allow.find((entry) => entry.port === port && hostMatchesAllowPattern(entry.host, host));
448
+ const grantInScope = (grant, host, port, pathname, method) => grant.host === host.toLowerCase() &&
449
+ grant.port === port &&
450
+ pathWithinPrefix(pathname, grant.pathPrefix) &&
451
+ (grant.methods === undefined || grant.methods.includes(method.toUpperCase()));
452
+ return {
453
+ filter: (port, host) => findAllow(host, port) !== undefined,
454
+ shouldTerminateTLS: (host, port) => findAllow(host, port)?.opaqueTunnel !== true,
455
+ // ADR 0016 §3: an uninspected byte tunnel is an explicit per-host grant.
456
+ allowOpaqueTunnel: (host, port) => findAllow(host, port)?.opaqueTunnel === true,
457
+ filterRequest: async (request, context) => {
458
+ const url = new URL(request.url);
459
+ const host = url.hostname.toLowerCase();
460
+ const port = url.port
461
+ ? Number.parseInt(url.port, 10)
462
+ : url.protocol === "https:"
463
+ ? 443
464
+ : 80;
465
+ const entry = findAllow(host, port);
466
+ if (!entry) {
467
+ // The connection filter already gates hosts; this fires only if the
468
+ // two ever disagree. Fail closed.
469
+ return { action: "deny", reason: `${host}:${port} is not allowlisted` };
470
+ }
471
+ if (entry.protocol !== undefined && url.protocol !== `${entry.protocol}:`) {
472
+ return {
473
+ action: "deny",
474
+ reason: `${host}:${port} requires ${entry.protocol.toUpperCase()} transport`,
475
+ };
476
+ }
477
+ if (entry.pathPrefix && !pathWithinPrefix(url.pathname, entry.pathPrefix)) {
478
+ return {
479
+ action: "deny",
480
+ reason: `path ${url.pathname} is outside the allowed prefix for ${host}`,
481
+ };
482
+ }
483
+ // Sentinel scope enforcement (ADR 0016 §6): a request carrying a
484
+ // sentinel ANYWHERE outside its grant's (host, port, path, method,
485
+ // header) scope is denied — the agent cannot steer a granted
486
+ // credential to an ungranted endpoint. The vendor leg discriminator is
487
+ // also load-bearing: only the TLS-terminated leg runs mutateHeaders, so
488
+ // a sentinel on the plain request leg must fail closed before forwarding.
489
+ for (const grant of grants) {
490
+ for (const [headerName, headerValue] of request.headers) {
491
+ if (!headerValue.includes(grant.sentinel))
492
+ continue;
493
+ if (context.leg === "plain") {
494
+ return {
495
+ action: "deny",
496
+ reason: `credential ${grant.env} cannot transit the plain proxy leg`,
497
+ };
498
+ }
499
+ if (headerName.toLowerCase() !== grant.header) {
500
+ return {
501
+ action: "deny",
502
+ reason: `credential ${grant.env} used outside its ${grant.header} header`,
503
+ };
504
+ }
505
+ if (url.protocol !== "https:" ||
506
+ !grantInScope(grant, host, port, url.pathname, request.method)) {
507
+ return {
508
+ action: "deny",
509
+ reason: `credential ${grant.env} is not granted for ` +
510
+ `${request.method} ${host}:${port}${url.pathname}`,
511
+ };
512
+ }
513
+ }
514
+ }
515
+ return { action: "allow" };
516
+ },
517
+ // Injection at the boundary (ADR 0016 §3). Runs only on the terminated
518
+ // TLS leg (we never set mutateHeadersPlaintext, so a secret cannot be
519
+ // injected into cleartext HTTP). MUST NOT throw: forwardUpstream treats
520
+ // a hook throw as a hard failure, and the secret's fail-closed shape is
521
+ // "strip the credential header", not "kill the proxy".
522
+ mutateHeaders: (headers, destHost, context) => {
523
+ if (!context)
524
+ return; // no request context -> no injection
525
+ const pathname = pathnameOf(context.path);
526
+ for (const grant of grants) {
527
+ const value = headers[grant.header];
528
+ if (value === undefined)
529
+ continue;
530
+ const values = Array.isArray(value) ? value : [value];
531
+ if (!values.some((v) => v.includes(grant.sentinel)))
532
+ continue;
533
+ if (pathname === undefined ||
534
+ !grantInScope(grant, destHost, context.port, pathname, context.method)) {
535
+ // filterRequest already denies off-scope sentinels; if a request
536
+ // reaches here anyway, never let the sentinel transit either.
537
+ delete headers[grant.header];
538
+ continue;
539
+ }
540
+ let real;
541
+ try {
542
+ real = revealSecret(grant.secret);
543
+ }
544
+ catch {
545
+ real = undefined;
546
+ }
547
+ if (real === undefined) {
548
+ // Reveal failed (missing secret, rotated key): strip rather than
549
+ // forward the sentinel — the upstream 401 tells the agent, and
550
+ // neither the sentinel nor a secret leaves the boundary.
551
+ delete headers[grant.header];
552
+ continue;
553
+ }
554
+ const replaced = values.map((v) => v.replaceAll(grant.sentinel, real));
555
+ headers[grant.header] = Array.isArray(value) ? replaced : replaced[0];
556
+ }
557
+ },
558
+ };
559
+ }
560
+ // context.path is the raw origin-form request-target; normalize through the
561
+ // URL parser exactly like filterRequest does, so both layers see the same
562
+ // pathname. Returns undefined (no injection) if the path cannot parse.
563
+ function pathnameOf(rawPath) {
564
+ try {
565
+ return new URL(`https://x${rawPath.startsWith("/") ? "" : "/"}${rawPath}`)
566
+ .pathname;
567
+ }
568
+ catch {
569
+ return undefined;
570
+ }
571
+ }
@@ -0,0 +1,77 @@
1
+ import { invalidRequest } from "../errors.js";
2
+ import { canonicalizeHostedAllowedHosts, EgressPolicyError } from "./policy.js";
3
+ const DEFINITIONS = [
4
+ {
5
+ id: "offline-v1",
6
+ version: 1,
7
+ name: "Offline",
8
+ description: "No network access. This is the default.",
9
+ allowedHosts: [],
10
+ },
11
+ {
12
+ id: "npm-pypi-v1",
13
+ version: 1,
14
+ name: "npm + PyPI",
15
+ description: "Install packages from the npm and Python package registries.",
16
+ allowedHosts: [
17
+ "registry.npmjs.org",
18
+ "pypi.org",
19
+ "files.pythonhosted.org",
20
+ ],
21
+ },
22
+ {
23
+ id: "github-packages-v1",
24
+ version: 1,
25
+ name: "GitHub + package registries",
26
+ description: "Clone and download from GitHub, npm, and PyPI.",
27
+ allowedHosts: [
28
+ "registry.npmjs.org",
29
+ "pypi.org",
30
+ "files.pythonhosted.org",
31
+ "github.com",
32
+ "api.github.com",
33
+ "codeload.github.com",
34
+ "*.githubusercontent.com",
35
+ ],
36
+ },
37
+ ];
38
+ export const ENVIRONMENT_NETWORKING_PRESETS = Object.freeze(DEFINITIONS.map((definition) => Object.freeze({
39
+ id: definition.id,
40
+ version: definition.version,
41
+ name: definition.name,
42
+ description: definition.description,
43
+ networking: Object.freeze({
44
+ type: "limited",
45
+ allowed_hosts: Object.freeze(canonicalizeHostedAllowedHosts([...definition.allowedHosts])),
46
+ }),
47
+ })));
48
+ export function environmentNetworkingPresetCatalog(deployment) {
49
+ return {
50
+ type: "environment_networking_presets",
51
+ deployment,
52
+ presets: ENVIRONMENT_NETWORKING_PRESETS,
53
+ custom: {
54
+ https_only: true,
55
+ wildcard_matches_bare_domain: false,
56
+ },
57
+ };
58
+ }
59
+ export function validateEnvironmentNetworkingHosts(input) {
60
+ if (typeof input !== "object" ||
61
+ input === null ||
62
+ Array.isArray(input) ||
63
+ Object.keys(input).some((key) => key !== "allowed_hosts")) {
64
+ throw invalidRequest("Request body must contain only `allowed_hosts`");
65
+ }
66
+ try {
67
+ return {
68
+ allowed_hosts: canonicalizeHostedAllowedHosts(input["allowed_hosts"]),
69
+ };
70
+ }
71
+ catch (error) {
72
+ if (error instanceof EgressPolicyError) {
73
+ throw invalidRequest(`Invalid environment networking config: ${error.message}`);
74
+ }
75
+ throw error;
76
+ }
77
+ }