@guilz-dev/belay 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 (266) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +268 -0
  3. package/agent-belay-logo.png +0 -0
  4. package/dist/adapters/claude/adapter.d.ts +7 -0
  5. package/dist/adapters/claude/adapter.js +114 -0
  6. package/dist/adapters/claude/hooks.d.ts +13 -0
  7. package/dist/adapters/claude/hooks.js +49 -0
  8. package/dist/adapters/claude/runtime-entry.d.ts +4 -0
  9. package/dist/adapters/claude/runtime-entry.js +260 -0
  10. package/dist/adapters/codex/adapter.d.ts +7 -0
  11. package/dist/adapters/codex/adapter.js +73 -0
  12. package/dist/adapters/codex/hooks.d.ts +21 -0
  13. package/dist/adapters/codex/hooks.js +78 -0
  14. package/dist/adapters/codex/runtime-entry.d.ts +4 -0
  15. package/dist/adapters/codex/runtime-entry.js +237 -0
  16. package/dist/adapters/cursor/adapter.d.ts +7 -0
  17. package/dist/adapters/cursor/adapter.js +29 -0
  18. package/dist/adapters/cursor/hooks.d.ts +2 -0
  19. package/dist/adapters/cursor/hooks.js +26 -0
  20. package/dist/adapters/cursor/runtime-entry.d.ts +4 -0
  21. package/dist/adapters/cursor/runtime-entry.js +143 -0
  22. package/dist/adapters/layouts/claude.d.ts +2 -0
  23. package/dist/adapters/layouts/claude.js +40 -0
  24. package/dist/adapters/layouts/codex.d.ts +2 -0
  25. package/dist/adapters/layouts/codex.js +43 -0
  26. package/dist/adapters/layouts/cursor.d.ts +2 -0
  27. package/dist/adapters/layouts/cursor.js +40 -0
  28. package/dist/adapters/layouts/index.d.ts +7 -0
  29. package/dist/adapters/layouts/index.js +23 -0
  30. package/dist/adapters/layouts/protected-paths.d.ts +3 -0
  31. package/dist/adapters/layouts/protected-paths.js +15 -0
  32. package/dist/adapters/layouts/scope.d.ts +19 -0
  33. package/dist/adapters/layouts/scope.js +86 -0
  34. package/dist/adapters/layouts/types.d.ts +14 -0
  35. package/dist/adapters/layouts/types.js +1 -0
  36. package/dist/adapters/registry.d.ts +4 -0
  37. package/dist/adapters/registry.js +14 -0
  38. package/dist/adapters/shared/gate-runtime.d.ts +51 -0
  39. package/dist/adapters/shared/gate-runtime.js +518 -0
  40. package/dist/adapters/shared/repo-root.d.ts +2 -0
  41. package/dist/adapters/shared/repo-root.js +17 -0
  42. package/dist/adapters/types.d.ts +19 -0
  43. package/dist/adapters/types.js +1 -0
  44. package/dist/branding.d.ts +3 -0
  45. package/dist/branding.js +3 -0
  46. package/dist/bundle/claude-runtime.mjs +5323 -0
  47. package/dist/bundle/codex-runtime.mjs +5310 -0
  48. package/dist/bundle/cursor-runtime.mjs +5208 -0
  49. package/dist/cleanup-orphans.d.ts +7 -0
  50. package/dist/cleanup-orphans.js +59 -0
  51. package/dist/cli.d.ts +2 -0
  52. package/dist/cli.js +631 -0
  53. package/dist/commands/approve.d.ts +14 -0
  54. package/dist/commands/approve.js +65 -0
  55. package/dist/commands/audit.d.ts +59 -0
  56. package/dist/commands/audit.js +132 -0
  57. package/dist/commands/classify-for-report.d.ts +9 -0
  58. package/dist/commands/classify-for-report.js +85 -0
  59. package/dist/commands/doctor.d.ts +3 -0
  60. package/dist/commands/doctor.js +366 -0
  61. package/dist/commands/dogfood.d.ts +5 -0
  62. package/dist/commands/dogfood.js +71 -0
  63. package/dist/commands/explain.d.ts +3 -0
  64. package/dist/commands/explain.js +133 -0
  65. package/dist/commands/health-snapshot.d.ts +2 -0
  66. package/dist/commands/health-snapshot.js +166 -0
  67. package/dist/commands/init-wizard.d.ts +16 -0
  68. package/dist/commands/init-wizard.js +50 -0
  69. package/dist/commands/metrics.d.ts +7 -0
  70. package/dist/commands/metrics.js +89 -0
  71. package/dist/commands/recover.d.ts +3 -0
  72. package/dist/commands/recover.js +105 -0
  73. package/dist/commands/report.d.ts +3 -0
  74. package/dist/commands/report.js +65 -0
  75. package/dist/commands/revoke.d.ts +5 -0
  76. package/dist/commands/revoke.js +22 -0
  77. package/dist/commands/simulate.d.ts +14 -0
  78. package/dist/commands/simulate.js +55 -0
  79. package/dist/commands/status.d.ts +5 -0
  80. package/dist/commands/status.js +107 -0
  81. package/dist/config-io.d.ts +23 -0
  82. package/dist/config-io.js +180 -0
  83. package/dist/conformance/guarantee-table.d.ts +14 -0
  84. package/dist/conformance/guarantee-table.js +95 -0
  85. package/dist/conformance/types.d.ts +6 -0
  86. package/dist/conformance/types.js +1 -0
  87. package/dist/core/approval-service.d.ts +26 -0
  88. package/dist/core/approval-service.js +41 -0
  89. package/dist/core/approval-token.d.ts +11 -0
  90. package/dist/core/approval-token.js +61 -0
  91. package/dist/core/approval.d.ts +19 -0
  92. package/dist/core/approval.js +58 -0
  93. package/dist/core/audit-analysis.d.ts +10 -0
  94. package/dist/core/audit-analysis.js +147 -0
  95. package/dist/core/audit-metrics.d.ts +51 -0
  96. package/dist/core/audit-metrics.js +155 -0
  97. package/dist/core/audit-query.d.ts +11 -0
  98. package/dist/core/audit-query.js +142 -0
  99. package/dist/core/audit-summary.d.ts +33 -0
  100. package/dist/core/audit-summary.js +111 -0
  101. package/dist/core/audit-types.d.ts +65 -0
  102. package/dist/core/audit-types.js +2 -0
  103. package/dist/core/capability/allowlist.d.ts +10 -0
  104. package/dist/core/capability/allowlist.js +53 -0
  105. package/dist/core/capability/broker.d.ts +17 -0
  106. package/dist/core/capability/broker.js +29 -0
  107. package/dist/core/capability/index.d.ts +5 -0
  108. package/dist/core/capability/index.js +4 -0
  109. package/dist/core/capability/paths.d.ts +1 -0
  110. package/dist/core/capability/paths.js +20 -0
  111. package/dist/core/capability/reasons.d.ts +2 -0
  112. package/dist/core/capability/reasons.js +4 -0
  113. package/dist/core/capability/types.d.ts +10 -0
  114. package/dist/core/capability/types.js +1 -0
  115. package/dist/core/capability-approval.d.ts +28 -0
  116. package/dist/core/capability-approval.js +43 -0
  117. package/dist/core/classify-subagent.d.ts +2 -0
  118. package/dist/core/classify-subagent.js +69 -0
  119. package/dist/core/classify-tool.d.ts +3 -0
  120. package/dist/core/classify-tool.js +311 -0
  121. package/dist/core/config-layers.d.ts +23 -0
  122. package/dist/core/config-layers.js +59 -0
  123. package/dist/core/config.d.ts +219 -0
  124. package/dist/core/config.js +720 -0
  125. package/dist/core/control-plane-isolation.d.ts +10 -0
  126. package/dist/core/control-plane-isolation.js +83 -0
  127. package/dist/core/custom-command-match.d.ts +2 -0
  128. package/dist/core/custom-command-match.js +8 -0
  129. package/dist/core/egress/allowlist.d.ts +7 -0
  130. package/dist/core/egress/allowlist.js +33 -0
  131. package/dist/core/egress/env.d.ts +3 -0
  132. package/dist/core/egress/env.js +17 -0
  133. package/dist/core/egress/fingerprint.d.ts +3 -0
  134. package/dist/core/egress/fingerprint.js +35 -0
  135. package/dist/core/egress/policy.d.ts +8 -0
  136. package/dist/core/egress/policy.js +47 -0
  137. package/dist/core/egress/proxy-server.d.ts +21 -0
  138. package/dist/core/egress/proxy-server.js +263 -0
  139. package/dist/core/egress/types.d.ts +25 -0
  140. package/dist/core/egress/types.js +1 -0
  141. package/dist/core/egress-approval.d.ts +48 -0
  142. package/dist/core/egress-approval.js +129 -0
  143. package/dist/core/fingerprint.d.ts +6 -0
  144. package/dist/core/fingerprint.js +24 -0
  145. package/dist/core/gate-contract.d.ts +48 -0
  146. package/dist/core/gate-contract.js +50 -0
  147. package/dist/core/gate-engine.d.ts +20 -0
  148. package/dist/core/gate-engine.js +172 -0
  149. package/dist/core/glob.d.ts +1 -0
  150. package/dist/core/glob.js +39 -0
  151. package/dist/core/index.d.ts +19 -0
  152. package/dist/core/index.js +15 -0
  153. package/dist/core/integrity.d.ts +15 -0
  154. package/dist/core/integrity.js +68 -0
  155. package/dist/core/judge-api-key.d.ts +4 -0
  156. package/dist/core/judge-api-key.js +11 -0
  157. package/dist/core/judge-config.d.ts +29 -0
  158. package/dist/core/judge-config.js +85 -0
  159. package/dist/core/judge-doctor.d.ts +7 -0
  160. package/dist/core/judge-doctor.js +124 -0
  161. package/dist/core/judgment.d.ts +6 -0
  162. package/dist/core/judgment.js +38 -0
  163. package/dist/core/notify.d.ts +13 -0
  164. package/dist/core/notify.js +44 -0
  165. package/dist/core/path-utils.d.ts +12 -0
  166. package/dist/core/path-utils.js +107 -0
  167. package/dist/core/reclassify.d.ts +15 -0
  168. package/dist/core/reclassify.js +82 -0
  169. package/dist/core/recover-advice.d.ts +30 -0
  170. package/dist/core/recover-advice.js +177 -0
  171. package/dist/core/recover-git-probe.d.ts +8 -0
  172. package/dist/core/recover-git-probe.js +50 -0
  173. package/dist/core/recover-select.d.ts +10 -0
  174. package/dist/core/recover-select.js +60 -0
  175. package/dist/core/scrub.d.ts +3 -0
  176. package/dist/core/scrub.js +87 -0
  177. package/dist/core/shell-substitution.d.ts +6 -0
  178. package/dist/core/shell-substitution.js +130 -0
  179. package/dist/core/shell-tokenizer.d.ts +5 -0
  180. package/dist/core/shell-tokenizer.js +129 -0
  181. package/dist/core/shell-unparseable.d.ts +4 -0
  182. package/dist/core/shell-unparseable.js +96 -0
  183. package/dist/core/transactional/diff-evaluator.d.ts +2 -0
  184. package/dist/core/transactional/diff-evaluator.js +84 -0
  185. package/dist/core/transactional/eligibility.d.ts +4 -0
  186. package/dist/core/transactional/eligibility.js +44 -0
  187. package/dist/core/transactional/git-worktree.d.ts +13 -0
  188. package/dist/core/transactional/git-worktree.js +189 -0
  189. package/dist/core/transactional/index.d.ts +5 -0
  190. package/dist/core/transactional/index.js +4 -0
  191. package/dist/core/transactional/reasons.d.ts +4 -0
  192. package/dist/core/transactional/reasons.js +8 -0
  193. package/dist/core/transactional/runner.d.ts +2 -0
  194. package/dist/core/transactional/runner.js +113 -0
  195. package/dist/core/transactional/types.d.ts +46 -0
  196. package/dist/core/transactional/types.js +1 -0
  197. package/dist/core/types.d.ts +90 -0
  198. package/dist/core/types.js +1 -0
  199. package/dist/core/v2/adapter.d.ts +14 -0
  200. package/dist/core/v2/adapter.js +118 -0
  201. package/dist/core/v2/containment.d.ts +19 -0
  202. package/dist/core/v2/containment.js +91 -0
  203. package/dist/core/v2/egress-classify.d.ts +7 -0
  204. package/dist/core/v2/egress-classify.js +216 -0
  205. package/dist/core/v2/fingerprint.d.ts +1 -0
  206. package/dist/core/v2/fingerprint.js +4 -0
  207. package/dist/core/v2/index.d.ts +12 -0
  208. package/dist/core/v2/index.js +10 -0
  209. package/dist/core/v2/judge-audit.d.ts +2 -0
  210. package/dist/core/v2/judge-audit.js +15 -0
  211. package/dist/core/v2/judge-factory.d.ts +25 -0
  212. package/dist/core/v2/judge-factory.js +75 -0
  213. package/dist/core/v2/judge-outbound.d.ts +12 -0
  214. package/dist/core/v2/judge-outbound.js +73 -0
  215. package/dist/core/v2/judge.d.ts +47 -0
  216. package/dist/core/v2/judge.js +264 -0
  217. package/dist/core/v2/launcher-resolve.d.ts +12 -0
  218. package/dist/core/v2/launcher-resolve.js +190 -0
  219. package/dist/core/v2/overrides.d.ts +7 -0
  220. package/dist/core/v2/overrides.js +37 -0
  221. package/dist/core/v2/parser.d.ts +21 -0
  222. package/dist/core/v2/parser.js +213 -0
  223. package/dist/core/v2/types.d.ts +67 -0
  224. package/dist/core/v2/types.js +1 -0
  225. package/dist/core/v2/verdict.d.ts +2 -0
  226. package/dist/core/v2/verdict.js +699 -0
  227. package/dist/corpus/evaluate.d.ts +24 -0
  228. package/dist/corpus/evaluate.js +69 -0
  229. package/dist/defaults.d.ts +18 -0
  230. package/dist/defaults.js +155 -0
  231. package/dist/egress-daemon.d.ts +1 -0
  232. package/dist/egress-daemon.js +52 -0
  233. package/dist/index.d.ts +17 -0
  234. package/dist/index.js +15 -0
  235. package/dist/installer/bootstrap.d.ts +5 -0
  236. package/dist/installer/bootstrap.js +61 -0
  237. package/dist/installer/runtime-artifacts.d.ts +3 -0
  238. package/dist/installer/runtime-artifacts.js +23 -0
  239. package/dist/installer/scope-config.d.ts +8 -0
  240. package/dist/installer/scope-config.js +25 -0
  241. package/dist/installer.d.ts +22 -0
  242. package/dist/installer.js +169 -0
  243. package/dist/node-resolution.d.ts +8 -0
  244. package/dist/node-resolution.js +237 -0
  245. package/dist/operational-insights.d.ts +19 -0
  246. package/dist/operational-insights.js +24 -0
  247. package/dist/presets.d.ts +4 -0
  248. package/dist/presets.js +95 -0
  249. package/dist/services/egress-service.d.ts +57 -0
  250. package/dist/services/egress-service.js +334 -0
  251. package/dist/services/sandbox-service.d.ts +38 -0
  252. package/dist/services/sandbox-service.js +95 -0
  253. package/dist/templates.d.ts +7 -0
  254. package/dist/templates.js +56 -0
  255. package/dist/types.d.ts +230 -0
  256. package/dist/types.js +1 -0
  257. package/dist/version.d.ts +1 -0
  258. package/dist/version.js +1 -0
  259. package/package.json +65 -0
  260. package/skills/belay/SKILL.md +52 -0
  261. package/skills/belay/belay-approve.md +7 -0
  262. package/skills/belay/belay-explain.md +11 -0
  263. package/skills/belay/belay-recover.md +13 -0
  264. package/skills/belay/belay-report.md +7 -0
  265. package/skills/belay/belay-status.md +9 -0
  266. package/skills/belay/belay-why.md +11 -0
@@ -0,0 +1,10 @@
1
+ import type { BelayControlPlaneIsolationConfig } from './config.js';
2
+ export interface ControlPlaneIsolationReport {
3
+ ok: boolean;
4
+ mode: BelayControlPlaneIsolationConfig['mode'];
5
+ controlPlaneDir: string;
6
+ issues: string[];
7
+ agentWritable: boolean;
8
+ observedOwnerUid: number | null;
9
+ }
10
+ export declare function verifyControlPlaneIsolation(controlPlaneDir: string, isolation: BelayControlPlaneIsolationConfig): ControlPlaneIsolationReport;
@@ -0,0 +1,83 @@
1
+ import { accessSync, constants, existsSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { mkdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ function currentUid() {
5
+ if (typeof process.getuid === 'function') {
6
+ return process.getuid();
7
+ }
8
+ return null;
9
+ }
10
+ export function verifyControlPlaneIsolation(controlPlaneDir, isolation) {
11
+ const issues = [];
12
+ let agentWritable = false;
13
+ let observedOwnerUid = null;
14
+ if (isolation.mode === 'none') {
15
+ return {
16
+ ok: true,
17
+ mode: isolation.mode,
18
+ controlPlaneDir,
19
+ issues,
20
+ agentWritable: false,
21
+ observedOwnerUid: null,
22
+ };
23
+ }
24
+ if (!existsSync(controlPlaneDir)) {
25
+ issues.push(`control plane directory does not exist: ${controlPlaneDir}`);
26
+ return {
27
+ ok: false,
28
+ mode: isolation.mode,
29
+ controlPlaneDir,
30
+ issues,
31
+ agentWritable: false,
32
+ observedOwnerUid: null,
33
+ };
34
+ }
35
+ try {
36
+ const stats = statSync(controlPlaneDir);
37
+ observedOwnerUid = stats.uid;
38
+ const uid = currentUid();
39
+ if (isolation.mode === 'separate-user' && uid !== null && stats.uid === uid) {
40
+ issues.push('control plane directory is owned by the current agent uid');
41
+ }
42
+ if (isolation.expectedOwnerUid !== undefined && stats.uid !== isolation.expectedOwnerUid) {
43
+ issues.push(`control plane owner uid ${stats.uid} does not match expected ${isolation.expectedOwnerUid}`);
44
+ }
45
+ }
46
+ catch (error) {
47
+ issues.push(error instanceof Error ? error.message : 'failed to stat control plane directory');
48
+ }
49
+ if (isolation.verifyAgentWritable) {
50
+ const probePath = path.join(controlPlaneDir, '.belay-isolation-probe');
51
+ try {
52
+ mkdir(controlPlaneDir, { recursive: true });
53
+ writeFileSync(probePath, 'probe\n', 'utf8');
54
+ agentWritable = true;
55
+ try {
56
+ unlinkSync(probePath);
57
+ }
58
+ catch {
59
+ // best effort
60
+ }
61
+ issues.push('agent process can write to the control plane directory');
62
+ }
63
+ catch {
64
+ agentWritable = false;
65
+ try {
66
+ accessSync(controlPlaneDir, constants.W_OK);
67
+ agentWritable = true;
68
+ issues.push('agent process has write access to the control plane directory');
69
+ }
70
+ catch {
71
+ agentWritable = false;
72
+ }
73
+ }
74
+ }
75
+ return {
76
+ ok: issues.length === 0,
77
+ mode: isolation.mode,
78
+ controlPlaneDir,
79
+ issues,
80
+ agentWritable,
81
+ observedOwnerUid,
82
+ };
83
+ }
@@ -0,0 +1,2 @@
1
+ /** Match config allow/deny patterns against a normalized command or segment key (exact only). */
2
+ export declare function matchesCustomCommand(normalizedCommand: string, key: string, pattern: string): boolean;
@@ -0,0 +1,8 @@
1
+ /** Match config allow/deny patterns against a normalized command or segment key (exact only). */
2
+ export function matchesCustomCommand(normalizedCommand, key, pattern) {
3
+ const trimmed = pattern.trim();
4
+ if (!trimmed) {
5
+ return false;
6
+ }
7
+ return normalizedCommand === trimmed || key === trimmed;
8
+ }
@@ -0,0 +1,7 @@
1
+ import type { BelayConfigV3 } from '../config.js';
2
+ import type { EgressAllowlistEntry, EgressAllowlistFile } from './types.js';
3
+ export declare function egressAllowlistPath(config: BelayConfigV3, repoLocalStateDir: string): string;
4
+ export declare function loadEgressAllowlist(filePath: string): Promise<EgressAllowlistFile>;
5
+ export declare function saveEgressAllowlist(filePath: string, state: EgressAllowlistFile): Promise<void>;
6
+ export declare function isHostAllowlisted(host: string, allowlist: EgressAllowlistFile): boolean;
7
+ export declare function addDomainToAllowlist(allowlist: EgressAllowlistFile, entry: EgressAllowlistEntry): EgressAllowlistFile;
@@ -0,0 +1,33 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { belayStateDir } from '../config.js';
5
+ export function egressAllowlistPath(config, repoLocalStateDir) {
6
+ return path.join(belayStateDir(config, repoLocalStateDir), 'egress-allowlist.json');
7
+ }
8
+ export async function loadEgressAllowlist(filePath) {
9
+ if (!existsSync(filePath)) {
10
+ return { version: 1, domains: [] };
11
+ }
12
+ const raw = JSON.parse(await readFile(filePath, 'utf8'));
13
+ return {
14
+ version: 1,
15
+ domains: Array.isArray(raw.domains) ? raw.domains : [],
16
+ };
17
+ }
18
+ export async function saveEgressAllowlist(filePath, state) {
19
+ await mkdir(path.dirname(filePath), { recursive: true });
20
+ await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
21
+ }
22
+ export function isHostAllowlisted(host, allowlist) {
23
+ const normalized = host.toLowerCase();
24
+ return allowlist.domains.some((entry) => entry.host.toLowerCase() === normalized);
25
+ }
26
+ export function addDomainToAllowlist(allowlist, entry) {
27
+ const normalized = entry.host.toLowerCase();
28
+ const filtered = allowlist.domains.filter((domain) => domain.host.toLowerCase() !== normalized);
29
+ return {
30
+ version: 1,
31
+ domains: [...filtered, { ...entry, host: normalized }],
32
+ };
33
+ }
@@ -0,0 +1,3 @@
1
+ import type { BelayEgressConfig } from '../config.js';
2
+ export declare function recommendedProxyEnv(egress: BelayEgressConfig): Record<string, string>;
3
+ export declare function formatProxyEnv(egress: BelayEgressConfig): string;
@@ -0,0 +1,17 @@
1
+ export function recommendedProxyEnv(egress) {
2
+ const proxyUrl = `http://${egress.listenHost}:${egress.listenPort}`;
3
+ return {
4
+ HTTP_PROXY: proxyUrl,
5
+ HTTPS_PROXY: proxyUrl,
6
+ http_proxy: proxyUrl,
7
+ https_proxy: proxyUrl,
8
+ NO_PROXY: '127.0.0.1,localhost',
9
+ no_proxy: '127.0.0.1,localhost',
10
+ };
11
+ }
12
+ export function formatProxyEnv(egress) {
13
+ const vars = recommendedProxyEnv(egress);
14
+ return Object.entries(vars)
15
+ .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
16
+ .join('\n');
17
+ }
@@ -0,0 +1,3 @@
1
+ export declare function egressFingerprint(repoRoot: string, host: string, port: number, method: string, hasPayload?: boolean): string;
2
+ export declare function egressSummary(host: string, port: number, method?: string, hasPayload?: boolean): string;
3
+ export declare function parseHostFromSummary(summary: string): string | null;
@@ -0,0 +1,35 @@
1
+ import { hashValue } from '../fingerprint.js';
2
+ export function egressFingerprint(repoRoot, host, port, method, hasPayload = false) {
3
+ const payloadTag = hasPayload ? ':payload' : '';
4
+ return hashValue(`egress:${repoRoot}:${host.toLowerCase()}:${port}:${method.toUpperCase()}${payloadTag}`);
5
+ }
6
+ function formatHostPort(host, port) {
7
+ const normalized = host.toLowerCase();
8
+ const hostLabel = normalized.includes(':') ? `[${normalized}]` : normalized;
9
+ return `${hostLabel}:${port}`;
10
+ }
11
+ export function egressSummary(host, port, method = 'CONNECT', hasPayload = false) {
12
+ return `${method} ${formatHostPort(host, port)}${hasPayload ? ' (payload)' : ''}`;
13
+ }
14
+ export function parseHostFromSummary(summary) {
15
+ const trimmed = summary.trim();
16
+ const methodMatch = trimmed.match(/^(CONNECT|GET|POST|PUT|DELETE|HEAD|PATCH|OPTIONS)\s+(.+?)(?:\s+\(payload\))?$/i);
17
+ if (!methodMatch?.[2]) {
18
+ try {
19
+ return new URL(trimmed).hostname.toLowerCase();
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ const target = methodMatch[2].trim();
26
+ const bracketMatch = target.match(/^\[([^\]]+)\]:(\d+)$/);
27
+ if (bracketMatch?.[1]) {
28
+ return bracketMatch[1].toLowerCase();
29
+ }
30
+ const colonIndex = target.lastIndexOf(':');
31
+ if (colonIndex > 0) {
32
+ return target.slice(0, colonIndex).toLowerCase();
33
+ }
34
+ return target.toLowerCase();
35
+ }
@@ -0,0 +1,8 @@
1
+ import type { ApprovalStateFile } from '../types.js';
2
+ import type { EgressAllowlistFile, EgressConnectRequest, EgressPolicyResult } from './types.js';
3
+ export declare function evaluateEgressConnect(params: {
4
+ request: EgressConnectRequest;
5
+ allowlist: EgressAllowlistFile;
6
+ approved: ApprovalStateFile;
7
+ pendingApprovalId?: string;
8
+ }): EgressPolicyResult;
@@ -0,0 +1,47 @@
1
+ import { isHostAllowlisted } from './allowlist.js';
2
+ import { egressFingerprint, egressSummary } from './fingerprint.js';
3
+ const SAFE_READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
4
+ export function evaluateEgressConnect(params) {
5
+ const { request, allowlist, approved } = params;
6
+ const host = request.host.toLowerCase();
7
+ const fingerprint = egressFingerprint(request.repoRoot, host, request.port, request.method, request.hasPayload === true);
8
+ const summary = egressSummary(host, request.port, request.method, request.hasPayload === true);
9
+ if (SAFE_READ_METHODS.has(request.method) && request.hasPayload !== true) {
10
+ return {
11
+ decision: 'allow',
12
+ fingerprint,
13
+ summary,
14
+ reason: 'egress_read',
15
+ };
16
+ }
17
+ if (isHostAllowlisted(host, allowlist)) {
18
+ return {
19
+ decision: 'allow',
20
+ fingerprint,
21
+ summary,
22
+ reason: 'egress_allowlist',
23
+ };
24
+ }
25
+ const approvedMatch = approved.approvals.find((approval) => approval.kind === 'egress' &&
26
+ approval.fingerprint === fingerprint &&
27
+ approval.repoRoot === request.repoRoot);
28
+ if (approvedMatch) {
29
+ return {
30
+ decision: 'allow',
31
+ fingerprint,
32
+ summary,
33
+ reason: 'approved_once',
34
+ };
35
+ }
36
+ return {
37
+ decision: 'deny_pending',
38
+ fingerprint,
39
+ summary,
40
+ reason: request.method === 'CONNECT'
41
+ ? 'egress_connect_requires_approval'
42
+ : SAFE_READ_METHODS.has(request.method) && request.hasPayload === true
43
+ ? 'egress_read_with_payload_requires_approval'
44
+ : 'egress_requires_approval',
45
+ approvalId: params.pendingApprovalId,
46
+ };
47
+ }
@@ -0,0 +1,21 @@
1
+ import http from 'node:http';
2
+ import type { BelayConfigV3 } from '../config.js';
3
+ import { type EgressApprovalStore } from '../egress-approval.js';
4
+ import type { ApprovalStateFile } from '../types.js';
5
+ export interface EgressProxyContext {
6
+ config: BelayConfigV3;
7
+ repoRoot: string;
8
+ store: EgressApprovalStore;
9
+ onAudit?: (event: Record<string, unknown>) => Promise<void>;
10
+ loadApproved: () => Promise<ApprovalStateFile>;
11
+ }
12
+ export declare function parseConnectTarget(url: string): {
13
+ host: string;
14
+ port: number;
15
+ } | null;
16
+ export declare function createEgressProxy(ctx: EgressProxyContext): http.Server;
17
+ export declare function startEgressProxy(ctx: EgressProxyContext): Promise<{
18
+ server: http.Server;
19
+ port: number;
20
+ host: string;
21
+ }>;
@@ -0,0 +1,263 @@
1
+ import http from 'node:http';
2
+ import net from 'node:net';
3
+ import { URL } from 'node:url';
4
+ import { consumeApprovedEgress, ensurePendingEgressApproval, notifyEgressDeny, } from '../egress-approval.js';
5
+ import { loadEgressAllowlist } from './allowlist.js';
6
+ import { evaluateEgressConnect } from './policy.js';
7
+ function parseHttpTarget(req) {
8
+ if (!req.url) {
9
+ return null;
10
+ }
11
+ try {
12
+ const url = new URL(req.url);
13
+ return {
14
+ host: url.hostname,
15
+ port: url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80,
16
+ url,
17
+ };
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function parseConnectTarget(url) {
24
+ if (!url) {
25
+ return null;
26
+ }
27
+ const bracketMatch = url.match(/^\[([^\]]+)\]:(\d+)$/);
28
+ if (bracketMatch?.[1]) {
29
+ const port = parseConnectPort(bracketMatch[2]);
30
+ return port ? { host: bracketMatch[1], port } : null;
31
+ }
32
+ const colonIndex = url.lastIndexOf(':');
33
+ if (colonIndex === -1) {
34
+ return { host: url, port: 443 };
35
+ }
36
+ const host = url.slice(0, colonIndex);
37
+ const portValue = url.slice(colonIndex + 1);
38
+ if (!host) {
39
+ return null;
40
+ }
41
+ const port = portValue ? parseConnectPort(portValue) : 443;
42
+ return port ? { host, port } : null;
43
+ }
44
+ function parseConnectPort(value) {
45
+ const port = Number(value);
46
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
47
+ return null;
48
+ }
49
+ return port;
50
+ }
51
+ function headerValue(headers, name) {
52
+ const value = headers[name];
53
+ if (Array.isArray(value)) {
54
+ return value[0];
55
+ }
56
+ return value;
57
+ }
58
+ function requestHasPayload(req) {
59
+ const contentLength = headerValue(req.headers, 'content-length');
60
+ if (contentLength) {
61
+ const parsed = Number(contentLength);
62
+ if (Number.isFinite(parsed) && parsed > 0) {
63
+ return true;
64
+ }
65
+ }
66
+ const transferEncoding = headerValue(req.headers, 'transfer-encoding');
67
+ return typeof transferEncoding === 'string' && transferEncoding.trim().length > 0;
68
+ }
69
+ async function evaluateRequest(ctx, request) {
70
+ const allowlist = await loadEgressAllowlist(ctx.store.allowlistPath);
71
+ const approved = await ctx.loadApproved();
72
+ const result = evaluateEgressConnect({ request, allowlist, approved });
73
+ if (result.decision === 'allow') {
74
+ if (result.reason === 'approved_once') {
75
+ const consumed = await consumeApprovedEgress({
76
+ repoRoot: ctx.repoRoot,
77
+ fingerprint: result.fingerprint,
78
+ store: ctx.store,
79
+ });
80
+ if (!consumed) {
81
+ const freshApproved = await ctx.loadApproved();
82
+ const retry = evaluateEgressConnect({ request, allowlist, approved: freshApproved });
83
+ if (retry.decision !== 'allow') {
84
+ return denyEgressRequest(ctx, request, allowlist);
85
+ }
86
+ await ctx.onAudit?.({
87
+ event: 'egressConnect',
88
+ kind: 'egress',
89
+ verdict: 'allow',
90
+ reason: retry.reason,
91
+ fingerprint: retry.fingerprint,
92
+ summary: retry.summary,
93
+ repoRoot: ctx.repoRoot,
94
+ permission: 'allow',
95
+ wouldBlock: false,
96
+ });
97
+ return { allowed: true, result: retry };
98
+ }
99
+ await ctx.onAudit?.({
100
+ event: 'egressConnect',
101
+ kind: 'egress',
102
+ verdict: 'allow',
103
+ reason: 'approved_once',
104
+ fingerprint: result.fingerprint,
105
+ summary: result.summary,
106
+ repoRoot: ctx.repoRoot,
107
+ approvalId: consumed.approvalId,
108
+ permission: 'allow',
109
+ wouldBlock: false,
110
+ });
111
+ return { allowed: true, result };
112
+ }
113
+ await ctx.onAudit?.({
114
+ event: 'egressConnect',
115
+ kind: 'egress',
116
+ verdict: 'allow',
117
+ reason: result.reason,
118
+ fingerprint: result.fingerprint,
119
+ summary: result.summary,
120
+ repoRoot: ctx.repoRoot,
121
+ permission: 'allow',
122
+ wouldBlock: false,
123
+ });
124
+ return { allowed: true, result };
125
+ }
126
+ return denyEgressRequest(ctx, request, allowlist, result);
127
+ }
128
+ async function denyEgressRequest(ctx, request, allowlist, result) {
129
+ const approved = await ctx.loadApproved();
130
+ const policyResult = result ??
131
+ evaluateEgressConnect({
132
+ request,
133
+ allowlist,
134
+ approved,
135
+ });
136
+ const { approvalId, approval, created } = await ensurePendingEgressApproval({
137
+ config: ctx.config,
138
+ repoRoot: ctx.repoRoot,
139
+ policyResult,
140
+ store: ctx.store,
141
+ });
142
+ if (created) {
143
+ await notifyEgressDeny({
144
+ config: ctx.config,
145
+ repoRoot: ctx.repoRoot,
146
+ policyResult,
147
+ approval,
148
+ });
149
+ }
150
+ await ctx.onAudit?.({
151
+ event: 'egressConnect',
152
+ kind: 'egress',
153
+ verdict: 'deny_pending_approval',
154
+ reason: policyResult.reason,
155
+ fingerprint: policyResult.fingerprint,
156
+ summary: policyResult.summary,
157
+ repoRoot: ctx.repoRoot,
158
+ approvalId,
159
+ permission: 'deny',
160
+ wouldBlock: true,
161
+ });
162
+ return { allowed: false, approvalId, result: policyResult };
163
+ }
164
+ function denyResponse(res, approvalId, summary) {
165
+ const body = JSON.stringify({
166
+ error: 'egress_requires_approval',
167
+ message: `Belay blocked egress to ${summary}. Approval ID: ${approvalId}.`,
168
+ approvalId,
169
+ });
170
+ res.writeHead(403, {
171
+ 'content-type': 'application/json',
172
+ 'content-length': Buffer.byteLength(body),
173
+ });
174
+ res.end(body);
175
+ }
176
+ function forwardHttp(req, res, target) {
177
+ const upstreamPath = `${target.url.pathname}${target.url.search}`;
178
+ const headers = { ...req.headers, host: `${target.host}:${target.port}` };
179
+ const proxyReq = http.request({
180
+ host: target.host,
181
+ port: target.port,
182
+ method: req.method,
183
+ path: upstreamPath,
184
+ headers,
185
+ }, (proxyRes) => {
186
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
187
+ proxyRes.pipe(res);
188
+ });
189
+ proxyReq.on('error', () => {
190
+ res.writeHead(502);
191
+ res.end('Bad gateway');
192
+ });
193
+ req.pipe(proxyReq);
194
+ }
195
+ export function createEgressProxy(ctx) {
196
+ const server = http.createServer(async (req, res) => {
197
+ const target = parseHttpTarget(req);
198
+ if (!target) {
199
+ res.writeHead(400);
200
+ res.end('Bad request');
201
+ return;
202
+ }
203
+ const evaluation = await evaluateRequest(ctx, {
204
+ host: target.host,
205
+ port: target.port,
206
+ method: (req.method ?? 'GET'),
207
+ hasPayload: requestHasPayload(req),
208
+ repoRoot: ctx.repoRoot,
209
+ });
210
+ if (!evaluation.allowed) {
211
+ denyResponse(res, evaluation.approvalId ?? '', evaluation.result.summary);
212
+ return;
213
+ }
214
+ forwardHttp(req, res, target);
215
+ });
216
+ server.on('connect', (req, clientSocket, head) => {
217
+ void (async () => {
218
+ const target = parseConnectTarget(req.url ?? '');
219
+ if (!target) {
220
+ clientSocket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
221
+ clientSocket.destroy();
222
+ return;
223
+ }
224
+ const evaluation = await evaluateRequest(ctx, {
225
+ host: target.host,
226
+ port: target.port,
227
+ method: 'CONNECT',
228
+ repoRoot: ctx.repoRoot,
229
+ });
230
+ if (!evaluation.allowed) {
231
+ const body = `Belay blocked egress. Approval ID: ${evaluation.approvalId ?? 'unknown'}`;
232
+ clientSocket.write(`HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
233
+ clientSocket.destroy();
234
+ return;
235
+ }
236
+ const serverSocket = net.connect(target.port, target.host, () => {
237
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
238
+ if (head.length > 0) {
239
+ serverSocket.write(head);
240
+ }
241
+ serverSocket.pipe(clientSocket);
242
+ clientSocket.pipe(serverSocket);
243
+ });
244
+ serverSocket.on('error', () => {
245
+ clientSocket.destroy();
246
+ });
247
+ clientSocket.on('error', () => {
248
+ serverSocket.destroy();
249
+ });
250
+ })();
251
+ });
252
+ return server;
253
+ }
254
+ export async function startEgressProxy(ctx) {
255
+ const server = createEgressProxy(ctx);
256
+ const host = ctx.config.egress.listenHost;
257
+ const port = ctx.config.egress.listenPort;
258
+ await new Promise((resolve, reject) => {
259
+ server.once('error', reject);
260
+ server.listen(port, host, () => resolve());
261
+ });
262
+ return { server, port, host };
263
+ }
@@ -0,0 +1,25 @@
1
+ export interface EgressConnectRequest {
2
+ host: string;
3
+ port: number;
4
+ method: 'CONNECT' | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'PATCH' | 'OPTIONS';
5
+ hasPayload?: boolean;
6
+ repoRoot: string;
7
+ }
8
+ export type EgressDecision = 'allow' | 'deny_pending';
9
+ export interface EgressPolicyResult {
10
+ decision: EgressDecision;
11
+ fingerprint: string;
12
+ summary: string;
13
+ reason: string;
14
+ approvalId?: string;
15
+ }
16
+ export interface EgressAllowlistEntry {
17
+ host: string;
18
+ approvedAt: string;
19
+ approvalId?: string;
20
+ }
21
+ export interface EgressAllowlistFile {
22
+ version: 1;
23
+ domains: EgressAllowlistEntry[];
24
+ }
25
+ export type EgressApprovalScope = 'once' | 'domain';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ import type { BelayConfigV3 } from './config.js';
2
+ import type { EgressApprovalScope, EgressPolicyResult } from './egress/types.js';
3
+ import type { ApprovalRecord, ApprovalStateFile } from './types.js';
4
+ export interface EgressApprovalStore {
5
+ loadPending: () => Promise<{
6
+ filePath: string;
7
+ state: ApprovalStateFile;
8
+ }>;
9
+ loadApproved: () => Promise<{
10
+ filePath: string;
11
+ state: ApprovalStateFile;
12
+ }>;
13
+ writePending: (filePath: string, state: ApprovalStateFile) => Promise<void>;
14
+ writeApproved: (filePath: string, state: ApprovalStateFile) => Promise<void>;
15
+ allowlistPath: string;
16
+ }
17
+ export declare function ensurePendingEgressApproval(params: {
18
+ config: BelayConfigV3;
19
+ repoRoot: string;
20
+ policyResult: EgressPolicyResult;
21
+ store: EgressApprovalStore;
22
+ }): Promise<{
23
+ approvalId: string;
24
+ approval: ApprovalRecord;
25
+ created: boolean;
26
+ }>;
27
+ export declare function consumeApprovedEgress(params: {
28
+ repoRoot: string;
29
+ fingerprint: string;
30
+ store: EgressApprovalStore;
31
+ }): Promise<ApprovalRecord | null>;
32
+ export declare function notifyEgressDeny(params: {
33
+ config: BelayConfigV3;
34
+ repoRoot: string;
35
+ policyResult: EgressPolicyResult;
36
+ approval: ApprovalRecord;
37
+ }): Promise<void>;
38
+ export declare function recordEgressApproval(params: {
39
+ approvalId: string;
40
+ config: BelayConfigV3;
41
+ store: EgressApprovalStore;
42
+ scope?: EgressApprovalScope;
43
+ token?: string;
44
+ requireSignedToken?: boolean;
45
+ }): Promise<{
46
+ ok: boolean;
47
+ message: string;
48
+ }>;