hullproof 0.0.0-stage → 0.5.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 (363) hide show
  1. package/LICENSE +222 -0
  2. package/NOTICE +32 -0
  3. package/README.md +313 -2
  4. package/checks/db.firebase-rules-not-open.json +24 -0
  5. package/checks/db.rls-every-table.json +25 -0
  6. package/checks/log.no-credential-in-logs.json +30 -0
  7. package/checks/secrets.no-env-file-committed.json +31 -0
  8. package/checks/secrets.no-secret-in-public-env.json +37 -0
  9. package/checks/storage.firebase-rules-not-open.json +22 -0
  10. package/checks/supply.no-untrusted-event-in-run.json +23 -0
  11. package/checks/web.cors-no-reflected-origin.json +31 -0
  12. package/detectors/ai.json +25 -0
  13. package/detectors/auth.json +27 -0
  14. package/detectors/ci.json +19 -0
  15. package/detectors/containers.json +8 -0
  16. package/detectors/data_stores.json +38 -0
  17. package/detectors/frameworks.json +94 -0
  18. package/detectors/hosting.json +44 -0
  19. package/detectors/iac.json +43 -0
  20. package/detectors/languages.json +48 -0
  21. package/detectors/mobile.json +22 -0
  22. package/detectors/package_managers.json +39 -0
  23. package/detectors/payments.json +19 -0
  24. package/detectors/runtimes.json +17 -0
  25. package/dist/checks/code/admin-role.js +46 -0
  26. package/dist/checks/code/owner-filter.js +91 -0
  27. package/dist/checks/code/shared.js +84 -0
  28. package/dist/checks/code/webhook-verify.js +77 -0
  29. package/dist/checks/context.js +64 -0
  30. package/dist/checks/map/config-checks.js +74 -0
  31. package/dist/checks/map/entry-checks.js +97 -0
  32. package/dist/checks/map/shared.js +44 -0
  33. package/dist/checks/ops.js +307 -0
  34. package/dist/checks/pro-map.js +1 -0
  35. package/dist/checks/run.js +92 -0
  36. package/dist/checks/types.js +1 -0
  37. package/dist/ci/comment.js +44 -0
  38. package/dist/ci/sarif-check.js +157 -0
  39. package/dist/ci/sarif.js +61 -0
  40. package/dist/cli.js +358 -0
  41. package/dist/commands/ci.js +115 -0
  42. package/dist/commands/git.js +58 -0
  43. package/dist/commands/hooks.js +30 -0
  44. package/dist/commands/hunt-pro.js +1 -0
  45. package/dist/commands/hunt.js +86 -0
  46. package/dist/commands/map.js +33 -0
  47. package/dist/commands/mcp.js +17 -0
  48. package/dist/commands/pack.js +48 -0
  49. package/dist/commands/prove-pro.js +1 -0
  50. package/dist/commands/prove.js +173 -0
  51. package/dist/commands/report-gate.js +92 -0
  52. package/dist/commands/scan.js +158 -0
  53. package/dist/confirm/agreement.js +18 -0
  54. package/dist/confirm/chain.js +84 -0
  55. package/dist/confirm/confirm.js +112 -0
  56. package/dist/confirm/honesty.js +75 -0
  57. package/dist/confirm/lead.js +92 -0
  58. package/dist/confirm/merge.js +88 -0
  59. package/dist/confirm/query.js +145 -0
  60. package/dist/confirm/rule.js +86 -0
  61. package/dist/confirm/store.js +42 -0
  62. package/dist/confirm/summary.js +6 -0
  63. package/dist/confirm/synthesis.js +28 -0
  64. package/dist/discover/detectors.js +50 -0
  65. package/dist/discover/discover.js +270 -0
  66. package/dist/discover/extract.js +282 -0
  67. package/dist/discover/git.js +40 -0
  68. package/dist/discover/kit-files.js +47 -0
  69. package/dist/discover/walk.js +110 -0
  70. package/dist/env.js +57 -0
  71. package/dist/gate/gate.js +173 -0
  72. package/dist/hooks/install.js +153 -0
  73. package/dist/hooks/run.js +112 -0
  74. package/dist/map/analyze.js +201 -0
  75. package/dist/map/build.js +253 -0
  76. package/dist/map/candidates.js +49 -0
  77. package/dist/map/cblocks.js +77 -0
  78. package/dist/map/config.js +291 -0
  79. package/dist/map/dotnet.js +242 -0
  80. package/dist/map/entry.js +78 -0
  81. package/dist/map/expo.js +190 -0
  82. package/dist/map/go.js +292 -0
  83. package/dist/map/js-express.js +143 -0
  84. package/dist/map/js-misc.js +295 -0
  85. package/dist/map/js-next.js +292 -0
  86. package/dist/map/laravel.js +389 -0
  87. package/dist/map/lex-more.js +305 -0
  88. package/dist/map/lex.js +425 -0
  89. package/dist/map/light-index.js +191 -0
  90. package/dist/map/light-profiles.js +324 -0
  91. package/dist/map/light.js +574 -0
  92. package/dist/map/local-vocab.js +27 -0
  93. package/dist/map/mobile.js +326 -0
  94. package/dist/map/py-django.js +281 -0
  95. package/dist/map/py.js +391 -0
  96. package/dist/map/rails.js +391 -0
  97. package/dist/map/repo.js +178 -0
  98. package/dist/map/rust.js +312 -0
  99. package/dist/map/spring.js +207 -0
  100. package/dist/map/taint.js +96 -0
  101. package/dist/map/touches.js +355 -0
  102. package/dist/map/vocab.js +88 -0
  103. package/dist/mapping/controls.js +34 -0
  104. package/dist/mapping/dependency-policy.js +44 -0
  105. package/dist/mapping/map.js +44 -0
  106. package/dist/mapping/maps/gitleaks.json +12 -0
  107. package/dist/mapping/maps/osv-scanner.json +7 -0
  108. package/dist/mapping/maps/trivy.json +13 -0
  109. package/dist/mcp/confine.js +59 -0
  110. package/dist/mcp/protocol.js +140 -0
  111. package/dist/mcp/schema.js +43 -0
  112. package/dist/mcp/server.js +37 -0
  113. package/dist/mcp/tools.js +238 -0
  114. package/dist/packs/coverage.js +79 -0
  115. package/dist/packs/manifest.js +36 -0
  116. package/dist/packs/validate.js +271 -0
  117. package/dist/prove/container.js +276 -0
  118. package/dist/prove/exec-main.js +20 -0
  119. package/dist/prove/executor.js +149 -0
  120. package/dist/prove/local.js +131 -0
  121. package/dist/prove/probes.js +65 -0
  122. package/dist/prove/recipe.js +278 -0
  123. package/dist/prove/run.js +136 -0
  124. package/dist/prove/runner.js +1 -0
  125. package/dist/prove/safety.js +126 -0
  126. package/dist/prove/spec.js +357 -0
  127. package/dist/prove/store.js +65 -0
  128. package/dist/report/build.js +297 -0
  129. package/dist/report/entry-points.js +75 -0
  130. package/dist/report/hunt-section.js +71 -0
  131. package/dist/report/kit.js +41 -0
  132. package/dist/report/load.js +35 -0
  133. package/dist/report/messages.js +273 -0
  134. package/dist/report/prove-section.js +65 -0
  135. package/dist/report/report.js +156 -0
  136. package/dist/report/surface.js +21 -0
  137. package/dist/report/test-code.js +51 -0
  138. package/dist/report/tiers.js +44 -0
  139. package/dist/scanners/common.js +162 -0
  140. package/dist/scanners/gitleaks.js +90 -0
  141. package/dist/scanners/index.js +113 -0
  142. package/dist/scanners/osv-scanner.js +77 -0
  143. package/dist/scanners/run-targets.js +34 -0
  144. package/dist/scanners/secret-noise.js +89 -0
  145. package/dist/scanners/semgrep.js +142 -0
  146. package/dist/scanners/trivy.js +65 -0
  147. package/dist/scanners/types.js +1 -0
  148. package/dist/types/check.js +1 -0
  149. package/dist/types/codemap.js +1 -0
  150. package/dist/types/findings.js +5 -0
  151. package/dist/types/hunt.js +1 -0
  152. package/dist/types/index.js +1 -0
  153. package/dist/types/prove.js +1 -0
  154. package/dist/types/results.js +2 -0
  155. package/dist/types/scan.js +1 -0
  156. package/dist/types/stack.js +5 -0
  157. package/dist/util.js +71 -0
  158. package/dist/version.js +2 -0
  159. package/package.json +46 -4
  160. package/packs/android/fixture-result.json +43 -0
  161. package/packs/android/pack.json +56 -0
  162. package/packs/dotnet/fixture-result.json +62 -0
  163. package/packs/dotnet/pack.json +88 -0
  164. package/packs/expo/answer-key.json +51 -0
  165. package/packs/expo/fixture-result.json +62 -0
  166. package/packs/expo/pack.json +79 -0
  167. package/packs/firebase/answer-key.json +27 -0
  168. package/packs/firebase/fixture-result.json +43 -0
  169. package/packs/firebase/pack.json +58 -0
  170. package/packs/go/answer-key.json +101 -0
  171. package/packs/go/fixture-result.json +64 -0
  172. package/packs/go/pack.json +99 -0
  173. package/packs/ios/fixture-result.json +43 -0
  174. package/packs/ios/pack.json +55 -0
  175. package/packs/laravel/answer-key.json +84 -0
  176. package/packs/laravel/fixture-result.json +60 -0
  177. package/packs/laravel/pack.json +91 -0
  178. package/packs/node-ts/answer-key.json +220 -0
  179. package/packs/node-ts/fixture-result.json +114 -0
  180. package/packs/node-ts/pack.json +134 -0
  181. package/packs/python/answer-key.json +195 -0
  182. package/packs/python/fixture-result.json +119 -0
  183. package/packs/python/pack.json +121 -0
  184. package/packs/rails/answer-key.json +84 -0
  185. package/packs/rails/fixture-result.json +60 -0
  186. package/packs/rails/pack.json +78 -0
  187. package/packs/rust/fixture-result.json +43 -0
  188. package/packs/rust/pack.json +69 -0
  189. package/packs/spring/fixture-result.json +63 -0
  190. package/packs/spring/pack.json +103 -0
  191. package/packs/supabase/answer-key.json +52 -0
  192. package/packs/supabase/fixture-result.json +58 -0
  193. package/packs/supabase/pack.json +60 -0
  194. package/rules/gitleaks.toml +256 -0
  195. package/rules/semgrep/api-route-no-auth.yaml +31 -0
  196. package/rules/semgrep/cors-reflect-origin.yaml +48 -0
  197. package/rules/semgrep/eval-or-new-function.yaml +185 -0
  198. package/rules/semgrep/log-session-token.yaml +48 -0
  199. package/rules/semgrep/math-random-tokens.yaml +24 -0
  200. package/rules/semgrep/next-public-secret-name.yaml +32 -0
  201. package/rules/semgrep/postgrest-filter-injection.yaml +78 -0
  202. package/rules/semgrep/raw-sql-concat.yaml +42 -0
  203. package/rules/semgrep/read-by-id-no-owner.yaml +49 -0
  204. package/rules/semgrep/server-action-no-auth.yaml +26 -0
  205. package/rules/semgrep/sql-variants.yaml +99 -0
  206. package/rules/semgrep/ssrf-unguarded-url.yaml +137 -0
  207. package/rules/semgrep/subprocess-injection.yaml +163 -0
  208. package/rules/semgrep/taint-go.yaml +333 -0
  209. package/rules/semgrep/taint-java.yaml +317 -0
  210. package/rules/semgrep/taint-javascript.yaml +412 -0
  211. package/rules/semgrep/taint-php.yaml +361 -0
  212. package/rules/semgrep/taint-python.yaml +414 -0
  213. package/rules/semgrep/tool-endpoint-admin-client.yaml +86 -0
  214. package/rules/semgrep/user-metadata-authz.yaml +22 -0
  215. package/rules/semgrep/verify-result-unused.yaml +62 -0
  216. package/rules/semgrep/webhook-parse-before-verify.yaml +29 -0
  217. package/rules/test-artifact-ids.json +4 -0
  218. package/rules/tiers.json +427 -0
  219. package/schemas/check-results.schema.json +55 -0
  220. package/schemas/check.schema.json +41 -0
  221. package/schemas/codemap.schema.json +216 -0
  222. package/schemas/findings.schema.json +1048 -0
  223. package/schemas/hunt-output.schema.json +46 -0
  224. package/schemas/hunt-result.schema.json +58 -0
  225. package/schemas/results.schema.json +17 -0
  226. package/schemas/scan-hits.schema.json +32 -0
  227. package/schemas/scan-meta.schema.json +25 -0
  228. package/schemas/stack.schema.json +93 -0
  229. package/src/checks/README.md +32 -0
  230. package/src/checks/code/admin-role.ts +49 -0
  231. package/src/checks/code/owner-filter.ts +95 -0
  232. package/src/checks/code/shared.ts +66 -0
  233. package/src/checks/code/webhook-verify.ts +75 -0
  234. package/src/checks/context.ts +72 -0
  235. package/src/checks/map/config-checks.ts +79 -0
  236. package/src/checks/map/entry-checks.ts +116 -0
  237. package/src/checks/map/shared.ts +63 -0
  238. package/src/checks/ops.ts +280 -0
  239. package/src/checks/pro-map.ts +4 -0
  240. package/src/checks/run.ts +80 -0
  241. package/src/checks/types.ts +12 -0
  242. package/src/ci/comment.ts +52 -0
  243. package/src/ci/sarif-check.ts +100 -0
  244. package/src/ci/sarif.ts +72 -0
  245. package/src/cli.ts +273 -0
  246. package/src/commands/ci.ts +104 -0
  247. package/src/commands/git.ts +59 -0
  248. package/src/commands/hooks.ts +27 -0
  249. package/src/commands/hunt-pro.ts +4 -0
  250. package/src/commands/hunt.ts +68 -0
  251. package/src/commands/map.ts +27 -0
  252. package/src/commands/mcp.ts +18 -0
  253. package/src/commands/pack.ts +34 -0
  254. package/src/commands/prove-pro.ts +4 -0
  255. package/src/commands/prove.ts +126 -0
  256. package/src/commands/report-gate.ts +75 -0
  257. package/src/commands/scan.ts +156 -0
  258. package/src/confirm/agreement.ts +23 -0
  259. package/src/confirm/chain.ts +67 -0
  260. package/src/confirm/confirm.ts +107 -0
  261. package/src/confirm/honesty.ts +65 -0
  262. package/src/confirm/lead.ts +76 -0
  263. package/src/confirm/merge.ts +89 -0
  264. package/src/confirm/query.ts +126 -0
  265. package/src/confirm/rule.ts +76 -0
  266. package/src/confirm/store.ts +42 -0
  267. package/src/confirm/summary.ts +9 -0
  268. package/src/confirm/synthesis.ts +33 -0
  269. package/src/discover/detectors.ts +71 -0
  270. package/src/discover/discover.ts +253 -0
  271. package/src/discover/extract.ts +235 -0
  272. package/src/discover/git.ts +31 -0
  273. package/src/discover/kit-files.ts +37 -0
  274. package/src/discover/walk.ts +88 -0
  275. package/src/env.ts +51 -0
  276. package/src/gate/gate.ts +169 -0
  277. package/src/hooks/install.ts +142 -0
  278. package/src/hooks/run.ts +121 -0
  279. package/src/map/analyze.ts +219 -0
  280. package/src/map/build.ts +206 -0
  281. package/src/map/candidates.ts +47 -0
  282. package/src/map/cblocks.ts +55 -0
  283. package/src/map/config.ts +207 -0
  284. package/src/map/dotnet.ts +205 -0
  285. package/src/map/entry.ts +80 -0
  286. package/src/map/expo.ts +171 -0
  287. package/src/map/go.ts +271 -0
  288. package/src/map/js-express.ts +135 -0
  289. package/src/map/js-misc.ts +221 -0
  290. package/src/map/js-next.ts +282 -0
  291. package/src/map/laravel.ts +327 -0
  292. package/src/map/lex-more.ts +152 -0
  293. package/src/map/lex.ts +274 -0
  294. package/src/map/light-index.ts +134 -0
  295. package/src/map/light-profiles.ts +363 -0
  296. package/src/map/light.ts +484 -0
  297. package/src/map/local-vocab.ts +30 -0
  298. package/src/map/mobile.ts +263 -0
  299. package/src/map/py-django.ts +239 -0
  300. package/src/map/py.ts +329 -0
  301. package/src/map/rails.ts +291 -0
  302. package/src/map/repo.ts +150 -0
  303. package/src/map/rust.ts +256 -0
  304. package/src/map/spring.ts +185 -0
  305. package/src/map/taint.ts +86 -0
  306. package/src/map/touches.ts +296 -0
  307. package/src/map/vocab.ts +106 -0
  308. package/src/mapping/controls.ts +47 -0
  309. package/src/mapping/dependency-policy.ts +55 -0
  310. package/src/mapping/map.ts +59 -0
  311. package/src/mapping/maps/gitleaks.json +12 -0
  312. package/src/mapping/maps/osv-scanner.json +7 -0
  313. package/src/mapping/maps/trivy.json +13 -0
  314. package/src/mcp/confine.ts +44 -0
  315. package/src/mcp/protocol.ts +144 -0
  316. package/src/mcp/schema.ts +35 -0
  317. package/src/mcp/server.ts +32 -0
  318. package/src/mcp/tools.ts +235 -0
  319. package/src/packs/coverage.ts +98 -0
  320. package/src/packs/manifest.ts +97 -0
  321. package/src/packs/validate.ts +204 -0
  322. package/src/prove/container.ts +237 -0
  323. package/src/prove/exec-main.ts +16 -0
  324. package/src/prove/executor.ts +130 -0
  325. package/src/prove/local.ts +100 -0
  326. package/src/prove/probes.ts +68 -0
  327. package/src/prove/recipe.ts +215 -0
  328. package/src/prove/run.ts +124 -0
  329. package/src/prove/runner.ts +28 -0
  330. package/src/prove/safety.ts +98 -0
  331. package/src/prove/spec.ts +256 -0
  332. package/src/prove/store.ts +48 -0
  333. package/src/report/build.ts +304 -0
  334. package/src/report/entry-points.ts +78 -0
  335. package/src/report/hunt-section.ts +67 -0
  336. package/src/report/kit.ts +59 -0
  337. package/src/report/load.ts +49 -0
  338. package/src/report/messages.ts +270 -0
  339. package/src/report/prove-section.ts +61 -0
  340. package/src/report/report.ts +160 -0
  341. package/src/report/surface.ts +32 -0
  342. package/src/report/test-code.ts +56 -0
  343. package/src/report/tiers.ts +50 -0
  344. package/src/scanners/common.ts +149 -0
  345. package/src/scanners/gitleaks.ts +89 -0
  346. package/src/scanners/index.ts +103 -0
  347. package/src/scanners/osv-scanner.ts +90 -0
  348. package/src/scanners/run-targets.ts +35 -0
  349. package/src/scanners/secret-noise.ts +80 -0
  350. package/src/scanners/semgrep.ts +139 -0
  351. package/src/scanners/trivy.ts +66 -0
  352. package/src/scanners/types.ts +98 -0
  353. package/src/types/check.ts +52 -0
  354. package/src/types/codemap.ts +142 -0
  355. package/src/types/findings.ts +107 -0
  356. package/src/types/hunt.ts +97 -0
  357. package/src/types/index.ts +10 -0
  358. package/src/types/prove.ts +83 -0
  359. package/src/types/results.ts +15 -0
  360. package/src/types/scan.ts +63 -0
  361. package/src/types/stack.ts +61 -0
  362. package/src/util.ts +59 -0
  363. package/src/version.ts +2 -0
@@ -0,0 +1,215 @@
1
+ // Start recipes: how to build and start an app in the sandbox, read from stack.json and the code map and from the repository itself.
2
+ // Two recipes: a Node app on Express with a local SQLite file or an ephemeral Postgres, and (Phase 6d) a Python app on Flask with a
3
+ // local SQLite file. Anything else, or anything that needs a hosted service, comes back as "not provable here" with the reason. A
4
+ // recipe is never guessed.
5
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import type { CodeMap, Recipe, RecipeResult, Stack } from '../types/index.ts';
8
+ import { cmp } from '../util.ts';
9
+ import { dummyValue, envNames } from './safety.ts';
10
+ import { canon, sha256 } from './spec.ts';
11
+
12
+ export const BASE_IMAGE = 'node:22-slim';
13
+ export const PYTHON_IMAGE = 'python:3.12-slim';
14
+ const HOSTED_DATA = new Set(['supabase', 'firebase-realtime-database', 'firestore', 'dynamodb', 'azure-cosmos-db', 'neon', 'planetscale', 'turso', 'aws-rds', 'google-cloud-sql', 'azure-database-mysql', 'azure-database-postgres', 'azure-sql']);
15
+ const HOSTED_AUTH = new Set(['auth0', 'clerk', 'firebase-auth', 'supabase-auth', 'aws-cognito', 'kinde', 'workos', 'stytch', 'okta', 'microsoft-entra']);
16
+ const LOCAL_DATA = new Set(['sqlite', 'postgres']);
17
+ const NATIVE = ['better-sqlite3', 'sqlite3', 'bcrypt', 'sharp', 'canvas', 'argon2', 'node-sass', 'grpc', 'libsql'];
18
+ const DB_VARS = /^(?:DATABASE_URL|DB_PATH|DATABASE_PATH|SQLITE_PATH|SQLITE_FILE|DB_FILE|DATABASE_FILE)$/;
19
+ const HEALTH = ['/health', '/healthz', '/api/health', '/ping', '/status', '/'];
20
+ const nice = (id: string): string => id.replace(/-/g, ' ');
21
+
22
+ const no = (code: Extract<RecipeResult, { ok: false }>['code'], reason: string): RecipeResult => ({ ok: false, code, reason });
23
+ const ids = (xs: { id: string }[]): string[] => xs.map((x) => x.id);
24
+
25
+ function sourceFiles(repo: string, depth = 0, out: string[] = []): string[] {
26
+ if (depth > 6) return out;
27
+ for (const n of readdirSync(repo).sort(cmp)) {
28
+ if (n === 'node_modules' || n === '.git' || n === '.hullproof') continue;
29
+ const p = join(repo, n);
30
+ const st = lstatSync(p);
31
+ if (st.isSymbolicLink()) continue;
32
+ if (st.isDirectory()) sourceFiles(p, depth + 1, out);
33
+ else if (/\.(?:[cm]?js|ts)$/.test(n) && st.size < 1_000_000) out.push(p);
34
+ }
35
+ return out;
36
+ }
37
+
38
+ // The port the app listens on: a number in listen(), or the number after process.env.PORT. Returns null when no listen() call is found.
39
+ export function detectPort(text: string): { port: number; fromEnv: boolean } | null {
40
+ const m = /\.listen\(\s*([^,)\n]+)/.exec(text);
41
+ if (!m) return null;
42
+ let expr = m[1].trim();
43
+ if (/^[A-Za-z_$][\w$]*$/.test(expr)) {
44
+ const d = new RegExp(`(?:const|let|var)\\s+${expr}\\s*=\\s*([^;\\n]+)`).exec(text);
45
+ if (d) expr = d[1];
46
+ }
47
+ const fromEnv = /process\.env\.PORT|process\.env\[\s*['"]PORT['"]\s*\]/.test(expr);
48
+ const n = /\b(\d{2,5})\b/.exec(expr.replace(/process\.env\.PORT/g, ''));
49
+ if (n && Number(n[1]) <= 65535) return { port: Number(n[1]), fromEnv };
50
+ return fromEnv ? { port: 3000, fromEnv } : null;
51
+ }
52
+
53
+ export function deriveRecipe(repo: string, stack: Stack, map: CodeMap, opts: { allowInstallScripts?: boolean } = {}): RecipeResult {
54
+ const hosted = [...stack.data_stores.filter((i) => HOSTED_DATA.has(i.id)), ...stack.auth.filter((i) => HOSTED_AUTH.has(i.id))];
55
+ if (hosted.length) return no('hosted_service', `the app uses ${hosted.map((i) => nice(i.id)).join(' and ')}, a hosted service that cannot run locally and has no local stand in in this version. It is not faked.`);
56
+ const otherData = stack.data_stores.filter((i) => !LOCAL_DATA.has(i.id));
57
+ if (otherData.length) return no('unsupported_datastore', `the app uses ${otherData.map((i) => nice(i.id)).join(' and ')}. Only a local SQLite file and an ephemeral Postgres are supported.`);
58
+ const fw = ids(stack.frameworks);
59
+ if (!fw.includes('express') && fw.includes('flask')) return deriveFlask(repo, stack, map, opts);
60
+ if (!fw.includes('express')) return no('unsupported_stack', `the proof sandbox has a recipe for Node on Express and for Python on Flask in this version${fw.length ? ` (found ${fw.slice(0, 4).join(', ')})` : ''}.`);
61
+ if (fw.includes('nextjs')) return no('unsupported_stack', 'the app also uses Next.js, which needs a production build step that this version has no recipe for.');
62
+ const pjPath = join(repo, 'package.json');
63
+ if (!existsSync(pjPath)) return no('no_package_json', 'there is no package.json at the repository root.');
64
+ let pj: { main?: string; scripts?: Record<string, string>; dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
65
+ try { pj = JSON.parse(readFileSync(pjPath, 'utf8')); } catch { return no('no_package_json', 'package.json cannot be read as JSON.'); }
66
+ const deps = { ...(pj.dependencies ?? {}), ...(pj.devDependencies ?? {}) };
67
+ const notes: string[] = [];
68
+
69
+ // Build phase: a lockfile install without install scripts, or nothing at all when the repository carries its own modules.
70
+ const hasDeps = Object.keys(deps).length > 0;
71
+ const native = NATIVE.filter((n) => n in deps);
72
+ let install: string[] | null = null;
73
+ let vendored = false;
74
+ if (hasDeps) {
75
+ if (native.length && !opts.allowInstallScripts) return no('needs_install_scripts', `${native.join(', ')} needs an install script to build or download a native part, and the sandbox installs without install scripts so none can run. Pass --allow-install-scripts to let them run in the build container.`);
76
+ if (!existsSync(join(repo, 'package-lock.json'))) return no('no_lockfile', `there is no package-lock.json${existsSync(join(repo, 'yarn.lock')) || existsSync(join(repo, 'pnpm-lock.yaml')) ? ' (only npm lockfiles are installed in this version)' : ''}, and the sandbox installs from a lockfile only.`);
77
+ install = ['npm', 'ci', ...(opts.allowInstallScripts ? [] : ['--ignore-scripts']), '--no-audit', '--no-fund', '--registry=https://registry.npmjs.org/'];
78
+ if (opts.allowInstallScripts) notes.push('install scripts run inside the build container, which has network access to the package registry');
79
+ } else if (existsSync(join(repo, 'node_modules'))) { vendored = true; notes.push('the repository carries its own node_modules and has no dependencies to install, so the build phase needs no network'); }
80
+
81
+ const start = pj.scripts?.start ? 'npm start' : pj.main && existsSync(join(repo, pj.main)) ? `node ${pj.main}` : ['server.js', 'index.js', 'app.js', 'src/server.js', 'src/index.js'].find((f) => existsSync(join(repo, f))) ? `node ${['server.js', 'index.js', 'app.js', 'src/server.js', 'src/index.js'].find((f) => existsSync(join(repo, f)))}` : null;
82
+ if (!start) return no('no_start_command', 'there is no start script, no main file and no server.js, index.js or app.js.');
83
+
84
+ let port: { port: number; fromEnv: boolean } | null = null;
85
+ for (const f of sourceFiles(repo)) { port = detectPort(readFileSync(f, 'utf8')); if (port) break; }
86
+ if (!port) return no('port_unknown', 'no listen() call with a port was found in the source.');
87
+
88
+ const names = envNames(repo);
89
+ const env: Record<string, string> = {};
90
+ if (port.fromEnv) env.PORT = String(port.port);
91
+ const sqliteUsed = stack.data_stores.some((i) => i.id === 'sqlite') || sourceFiles(repo).some((f) => /node:sqlite|better-sqlite3|require\(['"]sqlite3['"]\)/.test(readFileSync(f, 'utf8')));
92
+ const pgUsed = stack.data_stores.some((i) => i.id === 'postgres');
93
+ let data: Recipe['data_store'] = { kind: 'none' };
94
+ if (pgUsed && !sqliteUsed) {
95
+ const url = names.find((n) => n === 'DATABASE_URL' || n === 'POSTGRES_URL');
96
+ if (!url) return no('unsupported_datastore', 'the app uses Postgres but does not read DATABASE_URL or POSTGRES_URL, so the sandbox cannot point it at its own database.');
97
+ const sqlIn = (d: string): string[] => (existsSync(join(repo, d)) ? readdirSync(join(repo, d)).filter((f) => f.endsWith('.sql')).sort(cmp).map((f) => `${d}/${f}`) : []);
98
+ const migrations = [...sqlIn('migrations'), ...sqlIn('db/migrations')];
99
+ const seedSql = ['seed.sql', 'db/seed.sql'].find((f) => existsSync(join(repo, f))) ?? null;
100
+ if (!migrations.length && !seedSql) return no('no_seed', 'the app uses Postgres and the repository has no migrations folder or seed.sql to build the schema and data from.');
101
+ data = { kind: 'postgres', migrations, seed_sql: seedSql };
102
+ env[url] = '{PGURL}';
103
+ notes.push('Postgres runs as a second container on an internal network with no route out');
104
+ } else if (sqliteUsed) {
105
+ const dbVars = names.filter((n) => DB_VARS.test(n));
106
+ if (!dbVars.length) return no('db_path_hardcoded', 'the app uses SQLite but does not read its file path from an environment variable (DATABASE_URL, DB_PATH, SQLITE_PATH and similar). The sandbox root filesystem is read only, so a path written into the code cannot be redirected to a writable place.');
107
+ for (const v of dbVars) env[v] = v.endsWith('_URL') ? 'file:{DATA}/app.db' : '{DATA}/app.db';
108
+ data = { kind: 'sqlite', env: dbVars };
109
+ }
110
+ for (const n of names) if (!(n in env) && n !== 'PORT') env[n] = dummyValue(n);
111
+
112
+ let seed: string | null = null;
113
+ if (pj.scripts?.seed) seed = 'npm run seed';
114
+ else {
115
+ const f = ['seed.js', 'db/seed.js', 'scripts/seed.js', 'seed.mjs'].find((x) => existsSync(join(repo, x)));
116
+ if (f) seed = `node ${f}`;
117
+ else notes.push('no seed step found; the specs rely on data the app creates itself');
118
+ }
119
+ const gets = new Set(map.entry_points.filter((e) => e.kind === 'http_route' && e.method === 'GET' && e.path).map((e) => e.path!));
120
+ const health = HEALTH.find((h) => gets.has(h)) ?? '/';
121
+ if (health === '/' && !gets.has('/')) notes.push('no health route found; the app counts as up when it answers any request with a status below 500');
122
+
123
+ return { ok: true, recipe: { schema_version: '1.0.0', stack: 'node-express', base_image: BASE_IMAGE, ...(opts.allowInstallScripts && install ? { install_scripts: true } : {}), install, vendored_modules: vendored, seed, start, port: port.port, port_from_env: port.fromEnv, health_path: health, data_store: data, env: Object.fromEntries(Object.entries(env).sort(([a], [b]) => cmp(a, b))), notes } };
124
+ }
125
+
126
+ // ---- Python on Flask (Phase 6d) ---------------------------------------------------------------------------------------------------------
127
+ function pyFiles(repo: string, depth = 0, out: string[] = []): string[] {
128
+ if (depth > 6) return out;
129
+ for (const n of readdirSync(repo).sort(cmp)) {
130
+ if (['node_modules', '.git', '.hullproof', '__pycache__', '.venv', 'venv'].includes(n)) continue;
131
+ const p = join(repo, n);
132
+ const st = lstatSync(p);
133
+ if (st.isSymbolicLink()) continue;
134
+ if (st.isDirectory()) pyFiles(p, depth + 1, out);
135
+ else if (n.endsWith('.py') && st.size < 1_000_000) out.push(p);
136
+ }
137
+ return out;
138
+ }
139
+
140
+ // The port a Flask app listens on: app.run(port=...) with a number, or int(os.environ.get("PORT", "5000")). The flask command defaults to 5000.
141
+ export function detectPyPort(text: string): { port: number; fromEnv: boolean } | null {
142
+ const run = /\.run\s*\(/.exec(text);
143
+ if (!run) return null;
144
+ // The text of the argument list, up to the bracket that closes it.
145
+ let depth = 1;
146
+ let i = run.index + run[0].length;
147
+ const from = i;
148
+ for (; i < text.length && depth > 0; i++) { if (text[i] === '(') depth++; else if (text[i] === ')') depth--; }
149
+ const args = text.slice(from, i - 1);
150
+ const at = /\bport\s*=\s*/.exec(args);
151
+ if (!at) return { port: 5000, fromEnv: false };
152
+ let d = 0;
153
+ let k = at.index + at[0].length;
154
+ const start = k;
155
+ for (; k < args.length; k++) { if (args[k] === '(') d++; else if (args[k] === ')') d--; else if (args[k] === ',' && d === 0) break; }
156
+ const expr = args.slice(start, k);
157
+ const fromEnv = /os\.environ|getenv/.test(expr) && /PORT/.test(expr);
158
+ const n = /(\d{2,5})/.exec(expr.replace(/PORT/g, ''));
159
+ if (n && Number(n[1]) <= 65535) return { port: Number(n[1]), fromEnv };
160
+ return fromEnv ? { port: 5000, fromEnv } : null;
161
+ }
162
+
163
+ function deriveFlask(repo: string, stack: Stack, map: CodeMap, opts: { allowInstallScripts?: boolean }): RecipeResult {
164
+ const files = pyFiles(repo);
165
+ const text = new Map(files.map((f) => [f, readFileSync(f, 'utf8')]));
166
+ const notes: string[] = [];
167
+ const reqPath = join(repo, 'requirements.txt');
168
+ if (!existsSync(reqPath)) return no('no_lockfile', 'there is no requirements.txt, and the sandbox installs from pinned requirements only (Pipfile, Poetry and uv are not read in this version).');
169
+ const lines = readFileSync(reqPath, 'utf8').split(/\r?\n/).map((l) => l.replace(/\s+#.*$/, '').trim()).filter((l) => l && !l.startsWith('#'));
170
+ const loose = lines.filter((l) => !/^[A-Za-z0-9_.\[\]-]+\s*==\s*[\w.!+*-]+(?:\s*;.*)?(?:\s+--hash=.*)?$/.test(l) && !/^--hash=/.test(l));
171
+ if (loose.length) return no('no_lockfile', `requirements.txt has lines that are not pinned with == (${loose.slice(0, 3).join(', ')}), and the sandbox installs from pinned requirements only.`);
172
+ if (lines.some((l) => /^(?:-e|git\+|https?:|\.\/|\/|--(?:index-url|extra-index-url|find-links))/.test(l))) return no('no_lockfile', 'requirements.txt points at a source other than the package index, and the sandbox installs from the index only.');
173
+
174
+ const sqlite = stack.data_stores.some((i) => i.id === 'sqlite') || [...text.values()].some((t) => /\bimport\s+(?:[\w.]+\s*,\s*)*sqlite3\b|\bfrom\s+sqlite3\b/.test(t));
175
+ const otherDb = lines.some((l) => /^(?:psycopg2?|psycopg|pymysql|mysqlclient|pymongo|redis|sqlalchemy|django)\b/i.test(l));
176
+ if (otherDb && !sqlite) return no('unsupported_datastore', 'the app uses a database package other than the built in sqlite3. Only a local SQLite file is supported for Python in this version.');
177
+ const names = envNames(repo);
178
+ const dbVars = names.filter((n) => DB_VARS.test(n));
179
+ if (sqlite && !dbVars.length) return no('db_path_hardcoded', 'the app uses SQLite but does not read its file path from an environment variable (DATABASE_URL, DB_PATH, SQLITE_PATH and similar). The sandbox root filesystem is read only, so a path written into the code cannot be redirected to a writable place.');
180
+ if (!sqlite) notes.push('no data store was found; the app starts with no database');
181
+
182
+ const appFile = files.find((f) => /\bFlask\s*\(\s*__name__/.test(text.get(f)!));
183
+ if (!appFile) return no('no_start_command', 'no file creates the app with Flask(__name__).');
184
+ const rel = (f: string): string => f.slice(repo.length + 1);
185
+ const runner = files.find((f) => /if\s+__name__\s*==\s*['"]__main__['"]/.test(text.get(f)!) && /\.run\s*\(/.test(text.get(f)!) && (f === appFile || /from\s+\w+\s+import|import\s+\w+/.test(text.get(f)!)));
186
+ let start: string;
187
+ let port: { port: number; fromEnv: boolean };
188
+ if (runner) {
189
+ const pt = detectPyPort(text.get(runner)!);
190
+ if (!pt) return no('port_unknown', 'app.run() is called but its port could not be read.');
191
+ port = pt;
192
+ start = `python ${rel(runner)}`;
193
+ } else {
194
+ const mod = rel(appFile).replace(/\.py$/, '').replace(/\//g, '.');
195
+ port = { port: 5000, fromEnv: false };
196
+ start = `python -m flask --app ${mod} run --host 127.0.0.1 --port 5000`;
197
+ notes.push('the app has no app.run() under __main__, so it starts with the flask command on port 5000');
198
+ }
199
+ const env: Record<string, string> = {};
200
+ if (port.fromEnv) env.PORT = String(port.port);
201
+ for (const v of dbVars) env[v] = v.endsWith('_URL') ? 'file:{DATA}/app.db' : '{DATA}/app.db';
202
+ for (const n of names) if (!(n in env) && n !== 'PORT') env[n] = dummyValue(n);
203
+ const seedFile = ['seed.py', 'db/seed.py', 'scripts/seed.py', 'init_db.py', 'db/init_db.py'].find((x) => existsSync(join(repo, x)));
204
+ if (!seedFile) notes.push('no seed step found; the specs rely on data the app creates itself');
205
+ const gets = new Set(map.entry_points.filter((e) => e.kind === 'http_route' && e.method === 'GET' && e.path).map((e) => e.path!));
206
+ const health = HEALTH.find((h) => gets.has(h)) ?? '/';
207
+ if (health === '/' && !gets.has('/')) notes.push('no health route found; the app counts as up when it answers any request with a status below 500');
208
+ const install = ['pip', 'install', '--no-cache-dir', ...(opts.allowInstallScripts ? [] : ['--only-binary=:all:']), '--disable-pip-version-check', '-r', 'requirements.txt'];
209
+ if (opts.allowInstallScripts) notes.push('a package with no wheel builds from source inside the build container, which has network access to the package index');
210
+ else notes.push('packages install from wheels only, so no package build script runs; a package that has no wheel makes the app not provable here');
211
+ notes.push('requirements are pinned by version; the sandbox does not check package hashes');
212
+ return { ok: true, recipe: { schema_version: '1.0.0', stack: 'python-flask', base_image: PYTHON_IMAGE, ...(opts.allowInstallScripts ? { install_scripts: true } : {}), install, vendored_modules: false, seed: seedFile ? `python ${seedFile}` : null, start, port: port.port, port_from_env: port.fromEnv, health_path: health, data_store: sqlite ? { kind: 'sqlite', env: dbVars } : { kind: 'none' }, env: Object.fromEntries(Object.entries(env).sort(([a], [b]) => cmp(a, b))), notes } };
213
+ }
214
+
215
+ export const recipeSha256 = (r: Recipe): string => sha256(canon(r));
@@ -0,0 +1,124 @@
1
+ // The proof run: for each spec, start the app in a fresh sandbox, run the spec, tear down, three times; then build result.json from
2
+ // the saved run files. Building the result is one pure function, used by the live run and by the replay, so a replay of the saved
3
+ // transcripts gives the same bytes with no sandbox. Nothing here decides a verdict by itself: the verdict function is judge()
4
+ // in executor.ts and it reads only the stored transcript.
5
+ import type { CodeMap, NotProvable, ProveMeta, ProveResult, ProveRunSummary, ProveSpecResult, Recipe, RunOutcome, RunRecord, Spec, SpecOutcome, Stack } from '../types/index.ts';
6
+ import { ENGINE_VERSION } from '../version.ts';
7
+ import { cmp } from '../util.ts';
8
+ import { judge, runRecord } from './executor.ts';
9
+ import { deriveRecipe, recipeSha256 } from './recipe.ts';
10
+ import { makeSandboxCopy } from './safety.ts';
11
+ import { DEFAULT_LIMITS, type Limits, type Runner } from './runner.ts';
12
+ import { canon, sha256, specSha256 } from './spec.ts';
13
+ import { clearRuns, loadSpecs, readMeta, readRuns, writeMeta, writeProveResult, writeRecipe, writeRun } from './store.ts';
14
+
15
+ export const RUNS_REQUIRED = 3;
16
+
17
+ function verifyRun(spec: Spec, specSha: string, rr: RunRecord): ProveRunSummary {
18
+ const bad = (detail: string): ProveRunSummary => ({ run: rr.run, outcome: 'error', transcript_sha256: rr.transcript_sha256, detail });
19
+ if (rr.spec_sha256 !== specSha) return bad('the spec changed after this run, so its transcript no longer belongs to it');
20
+ const { transcript_sha256: _h, ...body } = rr;
21
+ if (sha256(canon(body)) !== rr.transcript_sha256) return bad('the stored transcript does not match its hash; it was changed after the run');
22
+ if (!rr.steps.length) return rr.outcome === 'error' ? { run: rr.run, outcome: 'error', transcript_sha256: rr.transcript_sha256, detail: rr.detail } : bad('a run with no steps can only be an error');
23
+ const j = judge(spec, rr.steps);
24
+ if (!j.done || !j.outcome) return bad('the stored transcript stops before the spec reaches a verdict');
25
+ if (j.outcome !== rr.outcome) return bad(`the stored outcome (${rr.outcome}) does not match what the transcript shows (${j.outcome})`);
26
+ return { run: rr.run, outcome: j.outcome, transcript_sha256: rr.transcript_sha256, detail: j.detail };
27
+ }
28
+
29
+ function combine(runs: ProveRunSummary[], required: number): { outcome: SpecOutcome; reason: string } {
30
+ if (runs.length < required) return { outcome: 'error', reason: `${runs.length} of ${required} runs finished, so nothing is proven` };
31
+ const set = new Set<RunOutcome>(runs.map((r) => r.outcome));
32
+ if (set.size > 1) return { outcome: 'unstable', reason: `the runs disagreed (${runs.map((r) => r.outcome).join(', ')}), so the lead stays a lead` };
33
+ const o = runs[0].outcome;
34
+ return { outcome: o, reason: o === 'proven' ? `${required} of ${required} runs: the positive control passed and the flaw showed` : runs[0].detail };
35
+ }
36
+
37
+ export function buildResult(meta: ProveMeta, specs: Spec[], dropped: { file: string; reason: string }[], runsOf: (id: string) => RunRecord[]): ProveResult {
38
+ const out: ProveSpecResult[] = [];
39
+ for (const s of specs.slice().sort((a, b) => cmp(a.spec_id, b.spec_id))) {
40
+ const sha = specSha256(s);
41
+ const base = { spec_id: s.spec_id, spec_sha256: sha, lead_key: s.lead_key, title: s.title, claim: s.claim, entry_point: s.target.entry_point, requirement_ids: s.requirement_ids, sink: { file: s.target.file, line: s.target.line } };
42
+ if (meta.not_provable) { out.push({ ...base, outcome: 'not_provable', reason: `not provable here (${meta.not_provable.code}): ${meta.not_provable.reason}`, runs: [] }); continue; }
43
+ const runs = runsOf(s.spec_id).map((r) => verifyRun(s, sha, r));
44
+ out.push({ ...base, ...combine(runs, meta.runs_required), runs });
45
+ }
46
+ const n = (o: SpecOutcome): number => out.filter((x) => x.outcome === o).length;
47
+ return {
48
+ schema_version: '1.0.0', engine_version: ENGINE_VERSION, commit: meta.commit, runner: meta.runner, runs_required: meta.runs_required,
49
+ recipe_sha256: meta.recipe_sha256, not_provable: meta.not_provable, specs: out, dropped, left_behind: meta.left_behind, copy_excluded: meta.copy_excluded,
50
+ counts: { specs: out.length, proven: n('proven'), not_proven: n('not_proven'), invalid: n('invalid'), unstable: n('unstable'), error: n('error'), not_provable: n('not_provable'), dropped: dropped.length },
51
+ };
52
+ }
53
+
54
+ export interface ProveInput {
55
+ repo: string; proveDir: string; commit: string | null; stack: Stack; map: CodeMap; runner: Runner;
56
+ runs?: number; limits?: Limits; allowInstallScripts?: boolean; log: (s: string) => void;
57
+ }
58
+
59
+ const finish = (i: ProveInput, meta: ProveMeta, specs: Spec[], dropped: { file: string; reason: string }[]): ProveResult => {
60
+ writeMeta(i.proveDir, meta);
61
+ const r = buildResult(meta, specs, dropped, (id) => readRuns(i.proveDir, id));
62
+ writeProveResult(i.proveDir, r);
63
+ return r;
64
+ };
65
+
66
+ export async function runProve(i: ProveInput): Promise<ProveResult> {
67
+ const limits = i.limits ?? DEFAULT_LIMITS;
68
+ const required = i.runs ?? RUNS_REQUIRED;
69
+ const { specs, dropped } = loadSpecs(i.proveDir);
70
+ clearRuns(i.proveDir);
71
+ const meta: ProveMeta = { schema_version: '1.0.0', commit: i.commit, runner: i.runner.name, runs_required: required, recipe_sha256: null, not_provable: null, left_behind: 0, copy_excluded: [] };
72
+ const stop = (np: NotProvable): ProveResult => finish(i, { ...meta, not_provable: np }, specs, dropped);
73
+ if (!specs.length) { i.log('hullproof prove: no valid specs to run'); return finish(i, meta, specs, dropped); }
74
+
75
+ const rec = deriveRecipe(i.repo, i.stack, i.map, { allowInstallScripts: i.allowInstallScripts });
76
+ if (!rec.ok) return stop({ code: rec.code, reason: rec.reason });
77
+ const recipe: Recipe = rec.recipe;
78
+ meta.recipe_sha256 = recipeSha256(recipe);
79
+ writeRecipe(i.proveDir, recipe);
80
+ const av = await i.runner.available();
81
+ if (!av.ok) return stop({ code: 'no_runtime', reason: av.reason });
82
+ const copy = makeSandboxCopy(i.repo, { keepNodeModules: recipe.vendored_modules });
83
+ if ('error' in copy) return stop({ code: 'too_large', reason: copy.error });
84
+ meta.copy_excluded = copy.excluded;
85
+ let notProvable: NotProvable | null = null;
86
+ let leftBehind = 0;
87
+ try {
88
+ i.log(`hullproof prove: ${i.runner.name}, ${copy.files} files copied, ${copy.excluded.length} left out, preparing the image`);
89
+ const prep = await i.runner.prepare({ recipe, copyDir: copy.dir, sourceDir: i.repo, limits });
90
+ if (!prep.ok) { notProvable = { code: prep.code, reason: prep.reason }; }
91
+ else {
92
+ try {
93
+ outer: for (const spec of specs) {
94
+ const sha = specSha256(spec);
95
+ for (let run = 1; run <= required; run++) {
96
+ const st = await prep.prepared.start();
97
+ if (!st.ok) { notProvable = { code: st.code, reason: st.reason }; break outer; }
98
+ try {
99
+ const r = await st.instance.runSpec(spec);
100
+ const body = r.ok ? { outcome: r.outcome, detail: r.detail, steps: r.steps } : { outcome: 'error' as const, detail: r.reason, steps: [] };
101
+ writeRun(i.proveDir, runRecord(spec, sha, run, body));
102
+ i.log(`hullproof prove: ${spec.spec_id} run ${run}/${required}: ${body.outcome}`);
103
+ } finally { leftBehind += (await st.instance.stop()).left_behind.length; }
104
+ }
105
+ }
106
+ } finally {
107
+ const t = await prep.prepared.dispose();
108
+ leftBehind += t.left_behind.length;
109
+ if (t.left_behind.length) i.log(`hullproof prove: WARNING: left behind after teardown: ${t.left_behind.join(', ')}`);
110
+ }
111
+ }
112
+ } finally { copy.cleanup(); }
113
+ return finish(i, { ...meta, not_provable: notProvable, left_behind: leftBehind }, specs, dropped);
114
+ }
115
+
116
+ // Rebuilds result.json from the saved specs and run files. No sandbox, no app, no model.
117
+ export function replayProve(proveDir: string): ProveResult | string {
118
+ const meta = readMeta(proveDir);
119
+ if (!meta) return `no saved proof run in ${proveDir}`;
120
+ const { specs, dropped } = loadSpecs(proveDir);
121
+ const r = buildResult(meta, specs, dropped, (id) => readRuns(proveDir, id));
122
+ writeProveResult(proveDir, r);
123
+ return r;
124
+ }
@@ -0,0 +1,28 @@
1
+ // The runner interface of the proof sandbox. One backend is a container runtime (container.ts). The other, a plain local process
2
+ // (local.ts), is labelled unsafe and exists only for the engine's own fixtures. The prove step knows only this interface.
3
+ import type { NotProvableCode, Recipe, Spec, StepRecord } from '../types/index.ts';
4
+ import type { RunOutcome } from '../types/prove.ts';
5
+
6
+ export interface Limits { memory: string; cpus: string; pids: number; instanceMs: number; execMs: number; healthMs: number }
7
+ export const DEFAULT_LIMITS: Limits = { memory: '512m', cpus: '1', pids: 128, instanceMs: 180_000, execMs: 60_000, healthMs: 30_000 };
8
+
9
+ export interface Exec { code: number | null; stdout: string; stderr: string; timedOut: boolean }
10
+ export interface Teardown { left_behind: string[] }
11
+ export type SpecRun = { ok: true; outcome: RunOutcome; detail: string; steps: StepRecord[] } | { ok: false; reason: string };
12
+
13
+ export interface Instance {
14
+ readonly baseUrl: string;
15
+ exec(cmd: string[], o?: { input?: string; timeoutMs?: number }): Promise<Exec>; // inside the sandbox, with the same limits as the app
16
+ runSpec(spec: Spec): Promise<SpecRun>; // the step executor, inside the sandbox
17
+ stop(): Promise<Teardown>;
18
+ }
19
+ export type StartResult = { ok: true; instance: Instance } | { ok: false; code: NotProvableCode; reason: string };
20
+ export interface Prepared { start(): Promise<StartResult>; dispose(): Promise<Teardown> }
21
+ export type PrepareResult = { ok: true; prepared: Prepared } | { ok: false; code: NotProvableCode; reason: string };
22
+
23
+ export interface PrepareRequest { recipe: Recipe; copyDir: string; sourceDir: string; limits: Limits }
24
+ export interface Runner {
25
+ readonly name: string;
26
+ available(): Promise<{ ok: true } | { ok: false; reason: string }>;
27
+ prepare(r: PrepareRequest): Promise<PrepareResult>;
28
+ }
@@ -0,0 +1,98 @@
1
+ // The safety rules of the proof sandbox that do not depend on a container runtime: the typed confirmation, the loopback only
2
+ // target rule, the copy of the repository without secrets, and the dummy environment. Design: docs/PHASE-4-DESIGN.md, safety rules 1 to 3.
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, copyFileSync, realpathSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, relative, sep } from 'node:path';
7
+ import { loopbackBase } from './executor.ts';
8
+
9
+ export const CONFIRM_PHRASE = 'I own this app and approve running it';
10
+
11
+ // Rule 1: an explicit flag and the exact typed phrase. Nothing else counts: no environment variable, no default, no CI setting.
12
+ export function checkConfirmation(allowRun: boolean, typed: string | null): { ok: true } | { ok: false; reason: string } {
13
+ if (!allowRun) return { ok: false, reason: 'hullproof prove runs the app, so it needs --allow-run' };
14
+ if (typed === null) return { ok: false, reason: `hullproof prove needs the owner's typed confirmation: type "${CONFIRM_PHRASE}"` };
15
+ if (typed.trim() !== CONFIRM_PHRASE) return { ok: false, reason: `the confirmation does not match. Type exactly: ${CONFIRM_PHRASE}` };
16
+ return { ok: true };
17
+ }
18
+
19
+ // Rule 2: the only address a request may go to is the sandbox's own loopback on the recipe's port.
20
+ export function assertSandboxTarget(url: string, port: number): void {
21
+ const u = loopbackBase(url);
22
+ if (Number(u.port) !== port) throw new Error(`refused: ${u.origin} is not the sandbox app port ${port}`);
23
+ }
24
+
25
+ // Rule 3: names that never go into the copy. Environment files, keys, credentials, tool folders, git history.
26
+ const SECRET_FILE = /^(?:\.env(?:\..*)?|\.npmrc|\.netrc|\.pypirc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|credentials(?:\..*)?|.*\.(?:pem|key|p12|pfx|jks|keystore|kdbx)|service-account.*\.json|.*secrets?\.(?:json|ya?ml|toml))$/i;
27
+ const SKIP_DIR = new Set(['.git', '.hullproof', '.DS_Store', '.next', '.cache', 'coverage']);
28
+ export const isSecretFile = (name: string): boolean => SECRET_FILE.test(name);
29
+ const MAX_FILE = 10 * 1024 * 1024;
30
+ const MAX_TOTAL = 200 * 1024 * 1024;
31
+
32
+ export interface SandboxCopy { dir: string; files: number; excluded: string[]; cleanup(): void }
33
+
34
+ // Copies the repository to a fresh temporary folder, leaving out secrets and (unless the repository vendors its modules) node_modules.
35
+ // Symbolic links are never followed or copied. Returns what was left out so the report can say so.
36
+ export function makeSandboxCopy(repo: string, o: { keepNodeModules: boolean }): SandboxCopy | { error: string } {
37
+ const root = realpathSync(repo);
38
+ const dir = mkdtempSync(join(tmpdir(), 'hp-prove-'));
39
+ const excluded: string[] = [];
40
+ let files = 0;
41
+ let total = 0;
42
+ const walk = (from: string): string | null => {
43
+ for (const name of readdirSync(from).sort()) {
44
+ const p = join(from, name);
45
+ const rel = relative(root, p).split(sep).join('/');
46
+ const st = lstatSync(p);
47
+ if (st.isSymbolicLink()) { excluded.push(`${rel} (symbolic link)`); continue; }
48
+ if (st.isDirectory()) {
49
+ if (SKIP_DIR.has(name)) continue;
50
+ if (name === 'node_modules' && !o.keepNodeModules) { excluded.push(`${rel}/ (dependencies are installed in the build phase)`); continue; }
51
+ mkdirSync(join(dir, rel), { recursive: true });
52
+ const e = walk(p);
53
+ if (e) return e;
54
+ continue;
55
+ }
56
+ if (!st.isFile()) continue;
57
+ if (isSecretFile(name)) { excluded.push(`${rel} (secret file)`); continue; }
58
+ if (st.size > MAX_FILE) { excluded.push(`${rel} (over 10 MB)`); continue; }
59
+ total += st.size;
60
+ if (total > MAX_TOTAL) return 'the repository is over 200 MB, which is too large to copy into a sandbox';
61
+ mkdirSync(join(dir, rel, '..'), { recursive: true });
62
+ copyFileSync(p, join(dir, rel));
63
+ files++;
64
+ }
65
+ return null;
66
+ };
67
+ const err = walk(root);
68
+ if (err) { rmSync(dir, { recursive: true, force: true }); return { error: err }; }
69
+ return { dir, files, excluded: excluded.sort(), cleanup: () => rmSync(dir, { recursive: true, force: true }) };
70
+ }
71
+
72
+ // The environment variable names the repository reads: process.env.NAME and process.env['NAME'] in source files, and the names
73
+ // (not the values) in .env.example. A real .env file is never opened.
74
+ const SRC = /\.(?:[cm]?[jt]sx?)$/;
75
+ const PY_SRC = /\.py$/;
76
+ export function envNames(repo: string): string[] {
77
+ const names = new Set<string>();
78
+ const walk = (d: string, depth: number): void => {
79
+ if (depth > 8) return;
80
+ for (const n of readdirSync(d)) {
81
+ if (n === 'node_modules' || n === '.git' || n === '.hullproof' || n === '.next' || n === '__pycache__' || n === '.venv' || n === 'venv') continue;
82
+ const p = join(d, n);
83
+ const st = lstatSync(p);
84
+ if (st.isSymbolicLink()) continue;
85
+ if (st.isDirectory()) walk(p, depth + 1);
86
+ else if (st.isFile() && st.size < 1_000_000) {
87
+ if (SRC.test(n)) for (const m of readFileSync(p, 'utf8').matchAll(/process\.env(?:\.([A-Z][A-Z0-9_]*)|\[\s*['"]([A-Z][A-Z0-9_]*)['"]\s*\])/g)) names.add(m[1] ?? m[2]);
88
+ else if (PY_SRC.test(n)) for (const m of readFileSync(p, 'utf8').matchAll(/os\.(?:environ(?:\.get)?\s*[[(]\s*|getenv\s*\(\s*)['"]([A-Z][A-Z0-9_]*)['"]/g)) names.add(m[1]);
89
+ else if (n === '.env.example') for (const l of readFileSync(p, 'utf8').split(/\r?\n/)) { const m = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/.exec(l); if (m) names.add(m[1]); }
90
+ }
91
+ }
92
+ };
93
+ if (existsSync(repo)) walk(repo, 0);
94
+ return [...names].sort();
95
+ }
96
+
97
+ // A fixed dummy value per name, so two runs get the same environment. It looks like what it stands for and opens nothing.
98
+ export const dummyValue = (name: string): string => `dummy-${name.toLowerCase().replace(/_/g, '-')}-${createHash('sha256').update(name).digest('hex').slice(0, 8)}`;