@inhuman.tools/turn 0.0.0-stage → 0.2.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 (346) hide show
  1. package/LICENSE.md +7 -0
  2. package/dist/agent-calls/acceptance.d.ts +12 -0
  3. package/dist/agent-calls/acceptance.d.ts.map +1 -0
  4. package/dist/agent-calls/acceptance.js +64 -0
  5. package/dist/agent-calls/acceptance.js.map +1 -0
  6. package/dist/agent-calls/callbacks.d.ts +14 -0
  7. package/dist/agent-calls/callbacks.d.ts.map +1 -0
  8. package/dist/agent-calls/callbacks.js +59 -0
  9. package/dist/agent-calls/callbacks.js.map +1 -0
  10. package/dist/agent-calls/delivery.d.ts +7 -0
  11. package/dist/agent-calls/delivery.d.ts.map +1 -0
  12. package/dist/agent-calls/delivery.js +16 -0
  13. package/dist/agent-calls/delivery.js.map +1 -0
  14. package/dist/agent-calls/index.d.ts +7 -0
  15. package/dist/agent-calls/index.d.ts.map +1 -0
  16. package/dist/agent-calls/index.js +6 -0
  17. package/dist/agent-calls/index.js.map +1 -0
  18. package/dist/agent-calls/operator.d.ts +21 -0
  19. package/dist/agent-calls/operator.d.ts.map +1 -0
  20. package/dist/agent-calls/operator.js +78 -0
  21. package/dist/agent-calls/operator.js.map +1 -0
  22. package/dist/agent-calls/runtime.d.ts +99 -0
  23. package/dist/agent-calls/runtime.d.ts.map +1 -0
  24. package/dist/agent-calls/runtime.js +245 -0
  25. package/dist/agent-calls/runtime.js.map +1 -0
  26. package/dist/agent-calls/types.d.ts +31 -0
  27. package/dist/agent-calls/types.d.ts.map +1 -0
  28. package/dist/agent-calls/types.js +2 -0
  29. package/dist/agent-calls/types.js.map +1 -0
  30. package/dist/context/compact-floors.d.ts +43 -0
  31. package/dist/context/compact-floors.d.ts.map +1 -0
  32. package/dist/context/compact-floors.js +52 -0
  33. package/dist/context/compact-floors.js.map +1 -0
  34. package/dist/context/compact.d.ts +42 -0
  35. package/dist/context/compact.d.ts.map +1 -0
  36. package/dist/context/compact.js +155 -0
  37. package/dist/context/compact.js.map +1 -0
  38. package/dist/context/delivery.d.ts +13 -0
  39. package/dist/context/delivery.d.ts.map +1 -0
  40. package/dist/context/delivery.js +6 -0
  41. package/dist/context/delivery.js.map +1 -0
  42. package/dist/context/history.d.ts +66 -0
  43. package/dist/context/history.d.ts.map +1 -0
  44. package/dist/context/history.js +573 -0
  45. package/dist/context/history.js.map +1 -0
  46. package/dist/context/index.d.ts +10 -0
  47. package/dist/context/index.d.ts.map +1 -0
  48. package/dist/context/index.js +10 -0
  49. package/dist/context/index.js.map +1 -0
  50. package/dist/context/observations.d.ts +7 -0
  51. package/dist/context/observations.d.ts.map +1 -0
  52. package/dist/context/observations.js +11 -0
  53. package/dist/context/observations.js.map +1 -0
  54. package/dist/context/retention/defaults.d.ts +10 -0
  55. package/dist/context/retention/defaults.d.ts.map +1 -0
  56. package/dist/context/retention/defaults.js +47 -0
  57. package/dist/context/retention/defaults.js.map +1 -0
  58. package/dist/context/retention/index.d.ts +14 -0
  59. package/dist/context/retention/index.d.ts.map +1 -0
  60. package/dist/context/retention/index.js +21 -0
  61. package/dist/context/retention/index.js.map +1 -0
  62. package/dist/context/retention/narrow.d.ts +22 -0
  63. package/dist/context/retention/narrow.d.ts.map +1 -0
  64. package/dist/context/retention/narrow.js +140 -0
  65. package/dist/context/retention/narrow.js.map +1 -0
  66. package/dist/context/retention/render.d.ts +37 -0
  67. package/dist/context/retention/render.d.ts.map +1 -0
  68. package/dist/context/retention/render.js +149 -0
  69. package/dist/context/retention/render.js.map +1 -0
  70. package/dist/context/retention/resolve.d.ts +51 -0
  71. package/dist/context/retention/resolve.d.ts.map +1 -0
  72. package/dist/context/retention/resolve.js +254 -0
  73. package/dist/context/retention/resolve.js.map +1 -0
  74. package/dist/context/retention/schema.d.ts +345 -0
  75. package/dist/context/retention/schema.d.ts.map +1 -0
  76. package/dist/context/retention/schema.js +149 -0
  77. package/dist/context/retention/schema.js.map +1 -0
  78. package/dist/context/retention/stamp.d.ts +25 -0
  79. package/dist/context/retention/stamp.d.ts.map +1 -0
  80. package/dist/context/retention/stamp.js +103 -0
  81. package/dist/context/retention/stamp.js.map +1 -0
  82. package/dist/context/retention/step.d.ts +33 -0
  83. package/dist/context/retention/step.d.ts.map +1 -0
  84. package/dist/context/retention/step.js +74 -0
  85. package/dist/context/retention/step.js.map +1 -0
  86. package/dist/context/retention/view.d.ts +57 -0
  87. package/dist/context/retention/view.d.ts.map +1 -0
  88. package/dist/context/retention/view.js +121 -0
  89. package/dist/context/retention/view.js.map +1 -0
  90. package/dist/context/squeeze.d.ts +45 -0
  91. package/dist/context/squeeze.d.ts.map +1 -0
  92. package/dist/context/squeeze.js +182 -0
  93. package/dist/context/squeeze.js.map +1 -0
  94. package/dist/context/tools.d.ts +16 -0
  95. package/dist/context/tools.d.ts.map +1 -0
  96. package/dist/context/tools.js +73 -0
  97. package/dist/context/tools.js.map +1 -0
  98. package/dist/context/transcript.d.ts +151 -0
  99. package/dist/context/transcript.d.ts.map +1 -0
  100. package/dist/context/transcript.js +659 -0
  101. package/dist/context/transcript.js.map +1 -0
  102. package/dist/policy-processor/bypass.d.ts +95 -0
  103. package/dist/policy-processor/bypass.d.ts.map +1 -0
  104. package/dist/policy-processor/bypass.js +188 -0
  105. package/dist/policy-processor/bypass.js.map +1 -0
  106. package/dist/policy-processor/classifier.d.ts +47 -0
  107. package/dist/policy-processor/classifier.d.ts.map +1 -0
  108. package/dist/policy-processor/classifier.js +671 -0
  109. package/dist/policy-processor/classifier.js.map +1 -0
  110. package/dist/policy-processor/cluster.d.ts +7 -0
  111. package/dist/policy-processor/cluster.d.ts.map +1 -0
  112. package/dist/policy-processor/cluster.js +32 -0
  113. package/dist/policy-processor/cluster.js.map +1 -0
  114. package/dist/policy-processor/evidence.d.ts +26 -0
  115. package/dist/policy-processor/evidence.d.ts.map +1 -0
  116. package/dist/policy-processor/evidence.js +204 -0
  117. package/dist/policy-processor/evidence.js.map +1 -0
  118. package/dist/policy-processor/explain.d.ts +59 -0
  119. package/dist/policy-processor/explain.d.ts.map +1 -0
  120. package/dist/policy-processor/explain.js +224 -0
  121. package/dist/policy-processor/explain.js.map +1 -0
  122. package/dist/policy-processor/index.d.ts +6 -0
  123. package/dist/policy-processor/index.d.ts.map +1 -0
  124. package/dist/policy-processor/index.js +6 -0
  125. package/dist/policy-processor/index.js.map +1 -0
  126. package/dist/policy-processor/license.d.ts +23 -0
  127. package/dist/policy-processor/license.d.ts.map +1 -0
  128. package/dist/policy-processor/license.js +47 -0
  129. package/dist/policy-processor/license.js.map +1 -0
  130. package/dist/policy-processor/peel-cwd.d.ts +2 -0
  131. package/dist/policy-processor/peel-cwd.d.ts.map +1 -0
  132. package/dist/policy-processor/peel-cwd.js +2 -0
  133. package/dist/policy-processor/peel-cwd.js.map +1 -0
  134. package/dist/policy-processor/real-path.d.ts +7 -0
  135. package/dist/policy-processor/real-path.d.ts.map +1 -0
  136. package/dist/policy-processor/real-path.js +42 -0
  137. package/dist/policy-processor/real-path.js.map +1 -0
  138. package/dist/policy-processor/scope.d.ts +111 -0
  139. package/dist/policy-processor/scope.d.ts.map +1 -0
  140. package/dist/policy-processor/scope.js +629 -0
  141. package/dist/policy-processor/scope.js.map +1 -0
  142. package/dist/policy-processor/self-protect.d.ts +10 -0
  143. package/dist/policy-processor/self-protect.d.ts.map +1 -0
  144. package/dist/policy-processor/self-protect.js +48 -0
  145. package/dist/policy-processor/self-protect.js.map +1 -0
  146. package/dist/policy-processor/soft-deny.d.ts +75 -0
  147. package/dist/policy-processor/soft-deny.d.ts.map +1 -0
  148. package/dist/policy-processor/soft-deny.js +185 -0
  149. package/dist/policy-processor/soft-deny.js.map +1 -0
  150. package/dist/policy-processor/unattended-guard.d.ts +28 -0
  151. package/dist/policy-processor/unattended-guard.d.ts.map +1 -0
  152. package/dist/policy-processor/unattended-guard.js +145 -0
  153. package/dist/policy-processor/unattended-guard.js.map +1 -0
  154. package/dist/session/cancel.d.ts +14 -0
  155. package/dist/session/cancel.d.ts.map +1 -0
  156. package/dist/session/cancel.js +32 -0
  157. package/dist/session/cancel.js.map +1 -0
  158. package/dist/session/commands.d.ts +41 -0
  159. package/dist/session/commands.d.ts.map +1 -0
  160. package/dist/session/commands.js +241 -0
  161. package/dist/session/commands.js.map +1 -0
  162. package/dist/session/compact-actor.d.ts +23 -0
  163. package/dist/session/compact-actor.d.ts.map +1 -0
  164. package/dist/session/compact-actor.js +91 -0
  165. package/dist/session/compact-actor.js.map +1 -0
  166. package/dist/session/compact-env.d.ts +54 -0
  167. package/dist/session/compact-env.d.ts.map +1 -0
  168. package/dist/session/compact-env.js +345 -0
  169. package/dist/session/compact-env.js.map +1 -0
  170. package/dist/session/compact.d.ts +42 -0
  171. package/dist/session/compact.d.ts.map +1 -0
  172. package/dist/session/compact.js +68 -0
  173. package/dist/session/compact.js.map +1 -0
  174. package/dist/session/index.d.ts +9 -0
  175. package/dist/session/index.d.ts.map +1 -0
  176. package/dist/session/index.js +8 -0
  177. package/dist/session/index.js.map +1 -0
  178. package/dist/session/park.d.ts +16 -0
  179. package/dist/session/park.d.ts.map +1 -0
  180. package/dist/session/park.js +73 -0
  181. package/dist/session/park.js.map +1 -0
  182. package/dist/session/side-channel-reminder.d.ts +41 -0
  183. package/dist/session/side-channel-reminder.d.ts.map +1 -0
  184. package/dist/session/side-channel-reminder.js +132 -0
  185. package/dist/session/side-channel-reminder.js.map +1 -0
  186. package/dist/session/types.d.ts +44 -0
  187. package/dist/session/types.d.ts.map +1 -0
  188. package/dist/session/types.js +2 -0
  189. package/dist/session/types.js.map +1 -0
  190. package/dist/tool-catalog/catalog.d.ts +77 -0
  191. package/dist/tool-catalog/catalog.d.ts.map +1 -0
  192. package/dist/tool-catalog/catalog.js +380 -0
  193. package/dist/tool-catalog/catalog.js.map +1 -0
  194. package/dist/tool-catalog/index.d.ts +4 -0
  195. package/dist/tool-catalog/index.d.ts.map +1 -0
  196. package/dist/tool-catalog/index.js +3 -0
  197. package/dist/tool-catalog/index.js.map +1 -0
  198. package/dist/tool-catalog/recipes.d.ts +36 -0
  199. package/dist/tool-catalog/recipes.d.ts.map +1 -0
  200. package/dist/tool-catalog/recipes.js +121 -0
  201. package/dist/tool-catalog/recipes.js.map +1 -0
  202. package/dist/tool-catalog/types.d.ts +103 -0
  203. package/dist/tool-catalog/types.d.ts.map +1 -0
  204. package/dist/tool-catalog/types.js +5 -0
  205. package/dist/tool-catalog/types.js.map +1 -0
  206. package/dist/tool-runtime/approval/approved.d.ts +13 -0
  207. package/dist/tool-runtime/approval/approved.d.ts.map +1 -0
  208. package/dist/tool-runtime/approval/approved.js +20 -0
  209. package/dist/tool-runtime/approval/approved.js.map +1 -0
  210. package/dist/tool-runtime/approval/dist.d.ts +6 -0
  211. package/dist/tool-runtime/approval/dist.d.ts.map +1 -0
  212. package/dist/tool-runtime/approval/dist.js +28 -0
  213. package/dist/tool-runtime/approval/dist.js.map +1 -0
  214. package/dist/tool-runtime/approval/inbound.d.ts +7 -0
  215. package/dist/tool-runtime/approval/inbound.d.ts.map +1 -0
  216. package/dist/tool-runtime/approval/inbound.js +48 -0
  217. package/dist/tool-runtime/approval/inbound.js.map +1 -0
  218. package/dist/tool-runtime/approval/unattended.d.ts +8 -0
  219. package/dist/tool-runtime/approval/unattended.d.ts.map +1 -0
  220. package/dist/tool-runtime/approval/unattended.js +23 -0
  221. package/dist/tool-runtime/approval/unattended.js.map +1 -0
  222. package/dist/tool-runtime/blob-port.d.ts +46 -0
  223. package/dist/tool-runtime/blob-port.d.ts.map +1 -0
  224. package/dist/tool-runtime/blob-port.js +47 -0
  225. package/dist/tool-runtime/blob-port.js.map +1 -0
  226. package/dist/tool-runtime/extra-root.d.ts +3 -0
  227. package/dist/tool-runtime/extra-root.d.ts.map +1 -0
  228. package/dist/tool-runtime/extra-root.js +14 -0
  229. package/dist/tool-runtime/extra-root.js.map +1 -0
  230. package/dist/tool-runtime/handler.d.ts +29 -0
  231. package/dist/tool-runtime/handler.d.ts.map +1 -0
  232. package/dist/tool-runtime/handler.js +460 -0
  233. package/dist/tool-runtime/handler.js.map +1 -0
  234. package/dist/tool-runtime/index.d.ts +14 -0
  235. package/dist/tool-runtime/index.d.ts.map +1 -0
  236. package/dist/tool-runtime/index.js +14 -0
  237. package/dist/tool-runtime/index.js.map +1 -0
  238. package/dist/tool-runtime/peel.d.ts +19 -0
  239. package/dist/tool-runtime/peel.d.ts.map +1 -0
  240. package/dist/tool-runtime/peel.js +74 -0
  241. package/dist/tool-runtime/peel.js.map +1 -0
  242. package/dist/tool-runtime/plugin-context.d.ts +13 -0
  243. package/dist/tool-runtime/plugin-context.d.ts.map +1 -0
  244. package/dist/tool-runtime/plugin-context.js +55 -0
  245. package/dist/tool-runtime/plugin-context.js.map +1 -0
  246. package/dist/tool-runtime/recall.d.ts +18 -0
  247. package/dist/tool-runtime/recall.d.ts.map +1 -0
  248. package/dist/tool-runtime/recall.js +100 -0
  249. package/dist/tool-runtime/recall.js.map +1 -0
  250. package/dist/tool-runtime/run-system-tool.d.ts +13 -0
  251. package/dist/tool-runtime/run-system-tool.d.ts.map +1 -0
  252. package/dist/tool-runtime/run-system-tool.js +230 -0
  253. package/dist/tool-runtime/run-system-tool.js.map +1 -0
  254. package/dist/tool-runtime/tool-error.d.ts +6 -0
  255. package/dist/tool-runtime/tool-error.d.ts.map +1 -0
  256. package/dist/tool-runtime/tool-error.js +6 -0
  257. package/dist/tool-runtime/tool-error.js.map +1 -0
  258. package/dist/tool-runtime/worker-tools.d.ts +8 -0
  259. package/dist/tool-runtime/worker-tools.d.ts.map +1 -0
  260. package/dist/tool-runtime/worker-tools.js +65 -0
  261. package/dist/tool-runtime/worker-tools.js.map +1 -0
  262. package/dist/tool-runtime/write-admit.d.ts +21 -0
  263. package/dist/tool-runtime/write-admit.d.ts.map +1 -0
  264. package/dist/tool-runtime/write-admit.js +89 -0
  265. package/dist/tool-runtime/write-admit.js.map +1 -0
  266. package/package.json +114 -3
  267. package/policy/auto-mode-ask.scm +201 -0
  268. package/policy/bypass-detect.prompt +55 -0
  269. package/policy/classifier-bash-incomplete.prompt +46 -0
  270. package/policy/classifier-bash.prompt +45 -0
  271. package/policy/classifier-mcp.prompt +45 -0
  272. package/policy/classifier-read.prompt +44 -0
  273. package/policy/classifier-write-outside.prompt +46 -0
  274. package/policy/classifier-write-risky.prompt +45 -0
  275. package/policy/classifier-write-workspace.prompt +46 -0
  276. package/policy/classifier-write.prompt +45 -0
  277. package/policy/soft-deny-round1.prompt +51 -0
  278. package/policy/soft-deny-round2.prompt +60 -0
  279. package/src/agent-calls/acceptance.ts +71 -0
  280. package/src/agent-calls/callbacks.ts +75 -0
  281. package/src/agent-calls/delivery.ts +15 -0
  282. package/src/agent-calls/index.ts +16 -0
  283. package/src/agent-calls/operator.ts +99 -0
  284. package/src/agent-calls/runtime.ts +310 -0
  285. package/src/agent-calls/types.ts +33 -0
  286. package/src/context/compact-floors.ts +76 -0
  287. package/src/context/compact.ts +176 -0
  288. package/src/context/delivery.ts +15 -0
  289. package/src/context/history.ts +725 -0
  290. package/src/context/index.ts +35 -0
  291. package/src/context/observations.ts +13 -0
  292. package/src/context/retention/defaults.ts +58 -0
  293. package/src/context/retention/index.ts +45 -0
  294. package/src/context/retention/narrow.ts +123 -0
  295. package/src/context/retention/render.ts +177 -0
  296. package/src/context/retention/resolve.ts +306 -0
  297. package/src/context/retention/schema.ts +173 -0
  298. package/src/context/retention/stamp.ts +121 -0
  299. package/src/context/retention/step.ts +85 -0
  300. package/src/context/retention/view.ts +161 -0
  301. package/src/context/squeeze.ts +218 -0
  302. package/src/context/tools.ts +92 -0
  303. package/src/context/transcript.ts +705 -0
  304. package/src/policy-processor/README.md +43 -0
  305. package/src/policy-processor/bypass.ts +246 -0
  306. package/src/policy-processor/classifier.ts +711 -0
  307. package/src/policy-processor/cluster.ts +31 -0
  308. package/src/policy-processor/evidence.ts +214 -0
  309. package/src/policy-processor/explain.ts +310 -0
  310. package/src/policy-processor/index.ts +6 -0
  311. package/src/policy-processor/license.ts +45 -0
  312. package/src/policy-processor/peel-cwd.ts +1 -0
  313. package/src/policy-processor/real-path.ts +41 -0
  314. package/src/policy-processor/scope.ts +655 -0
  315. package/src/policy-processor/self-protect.ts +46 -0
  316. package/src/policy-processor/soft-deny.ts +225 -0
  317. package/src/policy-processor/unattended-guard.ts +158 -0
  318. package/src/session/cancel.ts +34 -0
  319. package/src/session/commands.ts +292 -0
  320. package/src/session/compact-actor.ts +132 -0
  321. package/src/session/compact-env.ts +539 -0
  322. package/src/session/compact.ts +95 -0
  323. package/src/session/index.ts +18 -0
  324. package/src/session/park.ts +73 -0
  325. package/src/session/side-channel-reminder.ts +148 -0
  326. package/src/session/types.ts +50 -0
  327. package/src/tool-catalog/catalog.ts +282 -0
  328. package/src/tool-catalog/index.ts +26 -0
  329. package/src/tool-catalog/recipes.ts +154 -0
  330. package/src/tool-catalog/types.ts +110 -0
  331. package/src/tool-runtime/approval/approved.ts +20 -0
  332. package/src/tool-runtime/approval/dist.ts +29 -0
  333. package/src/tool-runtime/approval/inbound.ts +50 -0
  334. package/src/tool-runtime/approval/unattended.ts +24 -0
  335. package/src/tool-runtime/blob-port.ts +64 -0
  336. package/src/tool-runtime/extra-root.ts +16 -0
  337. package/src/tool-runtime/handler.ts +576 -0
  338. package/src/tool-runtime/index.ts +21 -0
  339. package/src/tool-runtime/peel.ts +91 -0
  340. package/src/tool-runtime/plugin-context.ts +68 -0
  341. package/src/tool-runtime/recall.ts +104 -0
  342. package/src/tool-runtime/run-system-tool.ts +273 -0
  343. package/src/tool-runtime/tool-error.ts +6 -0
  344. package/src/tool-runtime/worker-tools.ts +76 -0
  345. package/src/tool-runtime/write-admit.ts +90 -0
  346. package/README.md +0 -3
@@ -0,0 +1,655 @@
1
+ /**
2
+ * Session-global license intern. Cap, compiled Policy, and handles are
3
+ * not per-call. Eligibility computeds name the live key sets; kick
4
+ * reactions `get`+`start` those handles. `license` only waits LICENSE WHEN.
5
+ *
6
+ * The floor stamps a tier, the classes that voted, and the votes. What licenses a soft-deny or an
7
+ * ask is the resolution table (harness-model authorization/resolve.ts), read by the job. The grant
8
+ * check (a stored human "yes" to an ask, by family) runs here at floor time, because it needs a
9
+ * clock and the floor votes. A grant never covers a soft-deny, a block, a hatch or a lifted ask.
10
+ */
11
+
12
+ import nodePath from "node:path";
13
+
14
+ import { DefaultedMap, DefaultedWeakMap } from "@here.build/collections";
15
+ import type { PromptComplete } from "@inhuman.tools/env-capability-module-prompt";
16
+ import {
17
+ type AckCall,
18
+ type AckState,
19
+ ACKED_ONCE,
20
+ activeAcks,
21
+ classifyMcpToolName,
22
+ clusterOf,
23
+ collectAckFlags,
24
+ coveringGrants,
25
+ fileQuestion,
26
+ type GrantRow,
27
+ MCPToolJob,
28
+ matchAckFlags,
29
+ owningTurnId,
30
+ parseGrantRows,
31
+ readRootList,
32
+ resolveAcks,
33
+ type RuleStamp,
34
+ ruleStampFor,
35
+ rowKind,
36
+ type SessionRoot,
37
+ type SessionTurn,
38
+ type ToolCallJob,
39
+ userEpoch,
40
+ visibleRows,
41
+ workspacePath,
42
+ } from "@inhuman.tools/harness-model";
43
+ import {
44
+ APPROVE,
45
+ bindPolicy,
46
+ cast,
47
+ classesOf,
48
+ join,
49
+ reasonableDefaultsSource,
50
+ whichOnPath,
51
+ type ClassId,
52
+ type FloorVerdict,
53
+ type Policy,
54
+ type Which,
55
+ } from "@inhuman.tools/policy/floor";
56
+ import {
57
+ checkTirith,
58
+ tirithClass,
59
+ tirithDisposition,
60
+ tirithFailure,
61
+ tirithMessage,
62
+ type ShellChecker,
63
+ type TirithReport,
64
+ } from "@inhuman.tools/policy-tirith";
65
+ import { action, comparer, computed, type IReactionDisposer, observable, reaction, runInAction } from "mobx";
66
+
67
+ import type { RunLedger } from "./bypass.js";
68
+ import { FIRST_ATTEMPT_MS, ClassifierHandle, RETRY_ATTEMPT_MS } from "./classifier.js";
69
+ import { evidencePayloadKey } from "./evidence.js";
70
+ import { realpathDeepest, resolvedWorkspacePath } from "./real-path.js";
71
+ import { withReadRoots, withSelfProtection } from "./self-protect.js";
72
+ import type { RunStop } from "./unattended-guard.js";
73
+
74
+ /**
75
+ * Lexical verdict joined with the verdict for where the path really leads (symlinks), stricter wins:
76
+ * a link inside the root to a blocked file is judged as the blocked file.
77
+ */
78
+ async function fileVerdict(judge: Policy["explainRead"], rootPath: string, raw: string): Promise<FloorVerdict> {
79
+ const lexical = judge(workspacePath(rootPath, raw));
80
+ const resolved = await resolvedWorkspacePath(rootPath, raw).catch(() => undefined);
81
+ return resolved === undefined ? lexical : join([lexical, judge(resolved)]);
82
+ }
83
+
84
+ /**
85
+ * A root the human named, and the folder it really is: the floor judges both spellings of a path
86
+ * (a link may lead out of the workspace), so both must be roots or `/tmp/x` would never match
87
+ * `/private/tmp/x`.
88
+ */
89
+ export async function withRealRoots(roots: readonly string[]): Promise<string[]> {
90
+ const out = new Set<string>(roots);
91
+ for (const root of roots) {
92
+ const real = await realpathDeepest(root).catch(() => undefined);
93
+ if (real !== undefined) out.add(real);
94
+ }
95
+ return [...out].toSorted();
96
+ }
97
+
98
+ /** The class of a verdict the host stamps itself (no rule voted): MCP and unknown tools, by name. */
99
+ function hostClass(toolName: string): ClassId {
100
+ return classifyMcpToolName(toolName).verdict === "stem" ? "mcp-lookup" : "mcp-action";
101
+ }
102
+
103
+ /**
104
+ * The tier a rule of the owner gives an MCP call. The class stays the name-based look-up or action, so
105
+ * the resolution table and the grant rules read it as before; only the tier moves. `ask` is the default.
106
+ */
107
+ function ruleVerdict(rule: RuleStamp | undefined, mcpName: string): FloorVerdict | undefined {
108
+ const id = `host:mcp-rule:${rule?.tier}`;
109
+ switch (rule?.tier) {
110
+ case undefined:
111
+ case "ask":
112
+ return undefined;
113
+ case "allow":
114
+ return APPROVE;
115
+ case "soft-deny":
116
+ return cast("deny", hostClass(mcpName), id);
117
+ case "block":
118
+ return cast("block", hostClass(mcpName), id);
119
+ }
120
+ }
121
+
122
+ function mcpNameOf(job: ToolCallJob): string {
123
+ return job instanceof MCPToolJob ? job.serverToolName : job.name;
124
+ }
125
+
126
+ /**
127
+ * The floor's verdict for one call: the compiled policy for shell and file tools, the host's own
128
+ * stamps for the rest (web_search approves, web_extract asks as network egress, MCP and unknown
129
+ * tools ask as a look-up or an action by name). The same function answers `policy/explain`.
130
+ */
131
+ export async function floorVerdict(
132
+ policy: Policy,
133
+ name: string,
134
+ args: Readonly<Record<string, unknown>> | null,
135
+ rootPath: string,
136
+ mcpName: string,
137
+ rule?: RuleStamp,
138
+ ): Promise<FloorVerdict> {
139
+ let verdict: FloorVerdict | undefined;
140
+ if (args != null) {
141
+ switch (clusterOf(name)) {
142
+ case "read":
143
+ verdict = await fileVerdict(policy.explainRead, rootPath, fileQuestion(name, args) ?? ".");
144
+ break;
145
+ case "write": {
146
+ const question = fileQuestion(name, args);
147
+ if (question != null) verdict = await fileVerdict(policy.explainWrite, rootPath, question);
148
+ break;
149
+ }
150
+ case "bash":
151
+ if (typeof args.command === "string" && args.command !== "") {
152
+ const cwd = typeof args.cwd === "string" && args.cwd !== "" ? workspacePath(rootPath, args.cwd) : undefined;
153
+ verdict = policy.explainBash(args.command, cwd);
154
+ }
155
+ // Extra-root cwd is remainder (read miss).
156
+ if (typeof args.cwd === "string" && args.cwd !== "" && args.cwd !== ".") {
157
+ verdict = join([
158
+ verdict ?? cast("ask", "unmodelled", "host:no-command"),
159
+ await fileVerdict(policy.explainRead, rootPath, args.cwd),
160
+ ]);
161
+ }
162
+ break;
163
+ case "mcp":
164
+ // web_search sends a query; web_extract fetches an arbitrary URL, which is an exfiltration channel.
165
+ if (name === "web_search") verdict = APPROVE;
166
+ else if (name === "web_extract") verdict = cast("ask", "network-egress", "host:web_extract");
167
+ break;
168
+ }
169
+ }
170
+ if (verdict !== undefined) return verdict;
171
+ if (clusterOf(name) !== "mcp") return cast("ask", "unmodelled", "host:no-question");
172
+ return (
173
+ ruleVerdict(rule, mcpName) ??
174
+ cast("ask", hostClass(mcpName), `host:mcp-name:${classifyMcpToolName(mcpName).verdict}`)
175
+ );
176
+ }
177
+
178
+ class FloorHandle {
179
+ #gen = 0;
180
+ #stop: IReactionDisposer | undefined;
181
+ #abort: AbortController | undefined;
182
+
183
+ constructor(
184
+ readonly job: ToolCallJob,
185
+ readonly scope: LicenseScope,
186
+ ) {}
187
+
188
+ @action
189
+ start(): void {
190
+ if (this.#stop !== undefined || this.job.policyResult !== null || this.scope.closed) return;
191
+ this.#stop = reaction(
192
+ () => ({
193
+ source: this.scope.root.policyProgram,
194
+ args: this.job.args,
195
+ name: this.job.name,
196
+ rootPath: this.scope.root.path,
197
+ roots: readRootList(this.scope.root.readRoots).toSorted(),
198
+ // The owner's rule for this MCP call; a changed rule re-votes the floor.
199
+ rule: this.job.authCluster === "mcp" ? ruleStampFor(this.job.authorizationLayers, this.job.callSubject) : {},
200
+ }),
201
+ (input) => {
202
+ this.#abort?.abort();
203
+ const gen = ++this.#gen;
204
+ const abort = new AbortController();
205
+ this.#abort = abort;
206
+ this.scope.beginFloor(this.job);
207
+ void this.#produce(gen, input, abort.signal);
208
+ },
209
+ { equals: comparer.structural, fireImmediately: true },
210
+ );
211
+ }
212
+
213
+ dispose(): void {
214
+ this.#gen += 1;
215
+ this.#stop?.();
216
+ this.#stop = undefined;
217
+ this.#abort?.abort();
218
+ this.scope.endFloor(this.job);
219
+ }
220
+
221
+ /** Tier, classes and votes land in one action, so no peer sees a vote without its class. */
222
+ #stamp(verdict: FloorVerdict, message: string | null, evidenceJson: string | null): void {
223
+ const classes = classesOf(verdict);
224
+ const votes = verdict.votes.filter((vote) => vote.disposition === verdict.disposition);
225
+ this.job.stampPolicy(verdict.disposition, message, evidenceJson, {
226
+ classesJson: JSON.stringify(classes),
227
+ votesJson: JSON.stringify(votes),
228
+ grantedBy: this.scope.grantsFor(this.job, verdict),
229
+ ack: this.scope.acksFor(this.job, verdict),
230
+ });
231
+ }
232
+
233
+ async #produce(
234
+ gen: number,
235
+ input: {
236
+ source: string;
237
+ args: Readonly<Record<string, unknown>> | null;
238
+ name: string;
239
+ rootPath: string;
240
+ roots: readonly string[];
241
+ rule: RuleStamp;
242
+ },
243
+ signal: AbortSignal,
244
+ ): Promise<void> {
245
+ const { source } = input;
246
+ const current = () =>
247
+ this.#gen === gen &&
248
+ !signal.aborted &&
249
+ !this.scope.closed &&
250
+ !this.job.cancelled &&
251
+ !this.job.completed &&
252
+ this.scope.waiting.has(this.job) &&
253
+ this.scope.root.policyProgram === source &&
254
+ comparer.structural(this.job.args, input.args) &&
255
+ this.job.name === input.name &&
256
+ this.scope.root.path === input.rootPath;
257
+ try {
258
+ if (source.trim() === "") return;
259
+ let policy: Policy;
260
+ try {
261
+ policy = await this.scope.compiledPolicy(source, await withRealRoots(input.roots));
262
+ } catch (error_) {
263
+ // An operator policy that does not compile must not quietly demote the session to
264
+ // the (possibly looser) dist floor, and "ask" would let a stored grant run
265
+ // everything. Fail closed (block) and say why.
266
+ if (!current()) return;
267
+ const reason = error_ instanceof Error ? error_.message : String(error_);
268
+ const text = `policy program is invalid; every tool call is blocked until it is fixed: ${reason}`;
269
+ this.#stamp(cast("block", "policy-fault", "host:policy-invalid"), text, null);
270
+ this.#report(text);
271
+ return;
272
+ }
273
+ if (!current()) return;
274
+ const args = this.job.args;
275
+ let verdict = await floorVerdict(policy, input.name, args, input.rootPath, mcpNameOf(this.job), input.rule);
276
+ let tirith: TirithReport | undefined;
277
+ if (this.scope.mcpPersistDenies?.(this.job) === true) {
278
+ verdict = cast(
279
+ "deny",
280
+ clusterOf(input.name) === "mcp" ? hostClass(mcpNameOf(this.job)) : "mcp-action",
281
+ "host:tool-blocklist",
282
+ );
283
+ } else if (verdict.disposition === "ask" && this.scope.mcpPersistAllows?.(this.job) === true) {
284
+ verdict = APPROVE;
285
+ }
286
+ if (clusterOf(input.name) === "bash" && typeof args?.command === "string" && args.command !== "") {
287
+ try {
288
+ tirith = await this.scope.checkShell(args.command, {
289
+ cwd: nodePath.resolve(input.rootPath, typeof args.cwd === "string" ? args.cwd : "."),
290
+ signal,
291
+ });
292
+ } catch (error_) {
293
+ tirith = tirithFailure(error_);
294
+ }
295
+ const rules = tirith.findings.map((finding) => finding.rule_id).join("+");
296
+ verdict = join([
297
+ verdict,
298
+ cast(tirithDisposition(tirith), tirithClass(tirith), `tirith:${tirith.action === "error" ? "error" : rules}`),
299
+ ]);
300
+ }
301
+ if (!current() || this.job.policyResult !== null) return;
302
+ this.#stamp(
303
+ verdict,
304
+ tirith === undefined ? null : tirithMessage(tirith),
305
+ tirith === undefined ? null : JSON.stringify({ tirith }),
306
+ );
307
+ this.#report(null);
308
+ } catch (error) {
309
+ if (current()) {
310
+ const message = error instanceof Error ? error.message : String(error);
311
+ // Fail closed and visible: a thrown check is a block, not an ask (a stored grant
312
+ // would run an ask) and not a call parked forever with no prompt.
313
+ if (this.job.policyResult === null) {
314
+ this.#stamp(cast("block", "policy-fault", "host:check-threw"), `policy check failed: ${message}`, null);
315
+ }
316
+ this.#report(message);
317
+ }
318
+ } finally {
319
+ if (this.#gen === gen) {
320
+ runInAction(() => {
321
+ this.scope.endFloor(this.job);
322
+ if (this.job.policyResult === null && source.trim() !== "") {
323
+ this.scope.blockFloor(this.job, source);
324
+ }
325
+ });
326
+ }
327
+ }
328
+ }
329
+
330
+ #report(text: string | null): void {
331
+ try {
332
+ this.scope.onPolicyError?.(text);
333
+ } catch {
334
+ /* awareness is presence, not the vote */
335
+ }
336
+ }
337
+ }
338
+
339
+ export class LicenseScope {
340
+ @observable accessor closed = false;
341
+ @observable accessor cap = 2;
342
+ backoffMs = function (n: number): number {
343
+ const seconds = Math.min(5 * 60, Math.max(1, n * Math.log(n)));
344
+ return seconds * 1000;
345
+ };
346
+ /** Per-attempt classifier deadline. Attempt 0 is short — the first vote is what the
347
+ * human is waiting on. Retries get longer rope, but never unbounded. */
348
+ judgeDeadlineMs = function (attempt: number): number {
349
+ return attempt === 0 ? FIRST_ATTEMPT_MS : RETRY_ATTEMPT_MS;
350
+ };
351
+ onPolicyError: ((text: string | null) => void) | undefined;
352
+ /** Session intern port. Remainder `dotprompt/run` calls this with a homed round. */
353
+ complete: PromptComplete | undefined;
354
+ /** Kernel `kv.json` last-choice. Session mint does not re-seed these. */
355
+ judgeFallbackKvs: Readonly<Record<string, string>> = {};
356
+ /** Connection `settings.model` for a named judge wire. */
357
+ judgeConfiguredModel: ((connectionId: string) => string) | undefined;
358
+ /** Host persist hit. May turn MCP `ask` into allow; never lifts deny/block. */
359
+ mcpPersistAllows: ((job: ToolCallJob) => boolean) | undefined;
360
+ /** Host hide hit. Turns MCP remainder into deny. Does not lift deny/block. */
361
+ mcpPersistDenies: ((job: ToolCallJob) => boolean) | undefined;
362
+ /** Stored grants of project, bot and global scope (the kernel's `grants.json`). Session rows ride the root. */
363
+ persistentGrants: () => readonly GrantRow[] = () => [];
364
+ /** The bot this session is, for bot-scope grants. */
365
+ botName: () => string | undefined = () => undefined;
366
+ now: () => number = () => Date.now();
367
+ /** Unattended-run denial ledgers, one per eval turn. */
368
+ readonly ledgers = new WeakMap<SessionTurn, RunLedger>();
369
+ /** The host stops the run's UI side: notify a person. The turn is already sealed and cancelling. */
370
+ onRunStopped: ((stop: RunStop) => void) | undefined;
371
+ /** A non-stopping notice about an unattended run ("hit repeated denials"). */
372
+ onRunNotice: ((notice: { readonly turn: SessionTurn; readonly text: string }) => void) | undefined;
373
+ which: Which = whichOnPath;
374
+ checkShell: ShellChecker = checkTirith;
375
+ /** Last `judgeAskProgram` seen; mismatch drops `#remainderVotes`. */
376
+ #remainderProgram = "";
377
+ /** Process-local remainder skip. Dropped when `judgeAskProgram` changes. */
378
+ readonly #remainderVotes = new Map<
379
+ string,
380
+ { identity: string; decision: "allow" | "deny"; epoch?: number; turn?: string }
381
+ >();
382
+ /** Connection+model pairs that cannot send json_schema. Cleared only by using a different pair. */
383
+ readonly schemaHaltKeys = observable.set<string>();
384
+ readonly waiting = observable.set<ToolCallJob>();
385
+ readonly inFlight = observable.set<ToolCallJob>();
386
+ readonly blockedFloor = observable.map<ToolCallJob, string>();
387
+
388
+ readonly #policies = new DefaultedMap((key: string): Promise<Policy> => {
389
+ const [source, ...roots] = JSON.parse(key) as string[];
390
+ const hit = Promise.resolve().then(() =>
391
+ bindPolicy(withReadRoots(withSelfProtection(JSON.parse(source!)), roots), this.which),
392
+ );
393
+ void hit.catch(() => {
394
+ this.#policies.delete(key);
395
+ });
396
+ return hit;
397
+ });
398
+ readonly #floors = new DefaultedWeakMap((job: ToolCallJob) => new FloorHandle(job, this));
399
+ readonly #judges = new DefaultedWeakMap((job: ToolCallJob) => new ClassifierHandle(job, this));
400
+ readonly #floorJobs = new Set<ToolCallJob>();
401
+ readonly #judgeJobs = new Set<ToolCallJob>();
402
+
403
+ readonly #disposers: IReactionDisposer[] = [];
404
+
405
+ constructor(readonly root: SessionRoot) {
406
+ this.#disposers.push(
407
+ reaction(
408
+ () => {
409
+ const keys = this.liveFloorKeys;
410
+ const toStart = keys
411
+ .filter((job) => !this.inFlight.has(job))
412
+ .slice(0, Math.max(0, this.cap - this.inFlight.size));
413
+ return { keys, toStart };
414
+ },
415
+ ({ keys, toStart }, prev) => {
416
+ const live = new Set(keys);
417
+ for (const job of prev?.keys ?? []) {
418
+ if (live.has(job) || !this.#floors.has(job)) continue;
419
+ this.#floors.get(job).dispose();
420
+ this.#floors.delete(job);
421
+ this.#floorJobs.delete(job);
422
+ }
423
+ for (const job of toStart) {
424
+ this.#floorJobs.add(job);
425
+ this.#floors.get(job).start();
426
+ }
427
+ },
428
+ { equals: comparer.structural, fireImmediately: true },
429
+ ),
430
+ // A mode change or a session-scope store/revoke changes what an acknowledgement still needs.
431
+ reaction(
432
+ () => [this.root.permissionMode, this.root.grantsJson, this.root.authorizationUserJson] as const,
433
+ () => this.reconcileGrants(),
434
+ { equals: comparer.structural },
435
+ ),
436
+ reaction(
437
+ () => this.pending,
438
+ (keys, prev) => {
439
+ const live = new Set(keys);
440
+ for (const job of prev ?? []) {
441
+ if (live.has(job) || !this.#judges.has(job)) continue;
442
+ this.#judges.get(job).dispose();
443
+ this.#judges.delete(job);
444
+ this.#judgeJobs.delete(job);
445
+ }
446
+ for (const job of keys) {
447
+ this.#judgeJobs.add(job);
448
+ this.#judges.get(job).start();
449
+ }
450
+ },
451
+ { equals: comparer.shallow, fireImmediately: true },
452
+ ),
453
+ );
454
+ }
455
+
456
+ @computed
457
+ get liveFloorKeys(): ToolCallJob[] {
458
+ if (this.closed) return [];
459
+ const source = this.root.policyProgram;
460
+ if (source.trim() === "") return [];
461
+ return [...this.waiting].filter(
462
+ (job) => !job.cancelled && !job.completed && job.policyResult === null && this.blockedFloor.get(job) !== source,
463
+ );
464
+ }
465
+
466
+ /** Leaves whose resolver names a judge and nothing has decided yet. Any mode: the table says. */
467
+ @computed
468
+ get pending(): ToolCallJob[] {
469
+ if (this.closed) return [];
470
+ return [...this.waiting].filter((job) => !job.orchestrated && job.autoClassifierPending);
471
+ }
472
+
473
+ distSource(): string {
474
+ return reasonableDefaultsSource;
475
+ }
476
+
477
+ compiledPolicy(source: string, roots: readonly string[] = []): Promise<Policy> {
478
+ return this.#policies.get(JSON.stringify([source, ...roots]));
479
+ }
480
+
481
+ #visible(): GrantRow[] {
482
+ const rows = [...parseGrantRows(this.root.grantsJson), ...this.persistentGrants()];
483
+ return visibleRows(
484
+ rows,
485
+ { sessionId: this.root.sessionId, projectPath: this.root.path, bot: this.botName() },
486
+ this.now(),
487
+ );
488
+ }
489
+
490
+ /** The grant rows this session may use right now: its own, its project's, its bot's, the global ones. */
491
+ visibleGrants(): GrantRow[] {
492
+ return this.#visible().filter((row) => rowKind(row) === "grant");
493
+ }
494
+
495
+ /** The stored acknowledgements this session may use right now (same scopes and TTL as grants). */
496
+ visibleAcks(): GrantRow[] {
497
+ return this.#visible().filter((row) => rowKind(row) === "ack");
498
+ }
499
+
500
+ /**
501
+ * Which acknowledgement flags cover this call and whether a stored ack satisfies them. The flags are
502
+ * a union of every layer; `block` is never acknowledged (it never runs). Nothing here licenses a call.
503
+ */
504
+ acksFor(
505
+ job: ToolCallJob,
506
+ verdict: FloorVerdict,
507
+ ): { state: AckState; flagsJson: string | null; ackedBy: string | null } {
508
+ if (verdict.disposition === "block") return { state: "none", flagsJson: null, ackedBy: null };
509
+ const units = new Map<string, NonNullable<AckCall["units"][number]>>();
510
+ for (const unit of verdict.seen ?? []) units.set(JSON.stringify(unit), unit);
511
+ for (const vote of verdict.votes) if (vote.unit !== undefined) units.set(JSON.stringify(vote.unit), vote.unit);
512
+ const call: AckCall = {
513
+ classes: classesOf(verdict),
514
+ mcp: job.grantMcp,
515
+ tool: job.grantToolName,
516
+ units: [...units.values()],
517
+ };
518
+ const matched = matchAckFlags(collectAckFlags(job.authorizationLayers), call);
519
+ if (matched.length === 0) return { state: "none", flagsJson: null, ackedBy: null };
520
+ const resolution = resolveAcks(activeAcks(matched, job.permissionMode), this.visibleAcks(), call);
521
+ return {
522
+ state: resolution.state,
523
+ flagsJson: JSON.stringify(matched),
524
+ ackedBy: resolution.ackedBy.length === 0 ? null : resolution.ackedBy.join(","),
525
+ };
526
+ }
527
+
528
+ /** Ids (comma-joined) of the stored grants that cover this ask, or null. Only an ask is ever covered. */
529
+ grantsFor(job: ToolCallJob, verdict: FloorVerdict): string | null {
530
+ if (verdict.disposition !== "ask") return null;
531
+ const covering = coveringGrants(this.visibleGrants(), {
532
+ tier: verdict.disposition,
533
+ cluster: job.authCluster,
534
+ hatch: job.hatch,
535
+ votes: verdict.votes,
536
+ mcp: job.grantMcp,
537
+ toolName: job.grantToolName,
538
+ });
539
+ return covering === null ? null : covering.map((row) => row.id).join(",");
540
+ }
541
+
542
+ /**
543
+ * Recompute which grant covers each leaf that is still waiting for a license. A revoked or expired
544
+ * grant stops covering an unlicensed ask; a new grant starts to. A leaf that already ran is left alone.
545
+ */
546
+ @action
547
+ reconcileGrants(): void {
548
+ for (const job of this.waiting) {
549
+ if (job.completed || job.cancelled || job.humanApprove !== null) continue;
550
+ if (job.policyResult === "ask") {
551
+ const verdict: FloorVerdict = {
552
+ disposition: "ask",
553
+ votes: job.policyVotes as unknown as FloorVerdict["votes"],
554
+ };
555
+ const next = this.grantsFor(job, verdict);
556
+ if (next !== job.grantedBy) job.grantedBy = next;
557
+ }
558
+ this.#reconcileAck(job);
559
+ }
560
+ }
561
+
562
+ /** A stored ack came or went, or the mode changed: where does the call stand now? */
563
+ #reconcileAck(job: ToolCallJob): void {
564
+ if (job.policyResult === null || job.policyResult === "block") return;
565
+ if (job.ackFlagsJson === null || job.ackState === "refused" || job.ackedBy === ACKED_ONCE) return;
566
+ const call: AckCall = { classes: job.policyClasses, mcp: job.grantMcp, tool: job.grantToolName, units: [] };
567
+ const resolution = resolveAcks(activeAcks(job.ackFlags, job.permissionMode), this.visibleAcks(), call);
568
+ job.stampAck(resolution.state, resolution.ackedBy.length === 0 ? null : resolution.ackedBy.join(","));
569
+ }
570
+
571
+ #syncRemainderProgram(): void {
572
+ const source = this.root.judgeAskProgram;
573
+ if (source === this.#remainderProgram) return;
574
+ this.#remainderVotes.clear();
575
+ this.#remainderProgram = source;
576
+ }
577
+
578
+ remainderHit(job: ToolCallJob, identity: string): "allow" | "deny" | null {
579
+ this.#syncRemainderProgram();
580
+ const key = evidencePayloadKey(job);
581
+ if (key == null) return null;
582
+ const cached = this.#remainderVotes.get(key);
583
+ if (cached === undefined || cached.identity !== identity) return null;
584
+ if (cached.decision === "allow") {
585
+ return cached.epoch === userEpoch(this.root) ? "allow" : null;
586
+ }
587
+ const turnId = owningTurnId(job);
588
+ return turnId != null && cached.turn === turnId ? "deny" : null;
589
+ }
590
+
591
+ /**
592
+ * A soft-deny allow is licensed for exactly one call and is never stored. A deny may be reused for
593
+ * the same payload, tier, classes and evidence within the turn (a looping agent cannot re-buy it).
594
+ */
595
+ remainderStore(job: ToolCallJob, identity: string, decision: "allow" | "deny"): void {
596
+ if (decision === "allow" && job.policyResult === "deny") return;
597
+ this.#syncRemainderProgram();
598
+ const key = evidencePayloadKey(job);
599
+ if (key == null) return;
600
+ const turnId = owningTurnId(job);
601
+ if (turnId == null) return;
602
+ this.#remainderVotes.set(
603
+ key,
604
+ decision === "allow" ? { identity, decision, epoch: userEpoch(this.root) } : { identity, decision, turn: turnId },
605
+ );
606
+ }
607
+
608
+ @action
609
+ admit(job: ToolCallJob): void {
610
+ this.waiting.add(job);
611
+ }
612
+
613
+ @action
614
+ release(job: ToolCallJob): void {
615
+ this.waiting.delete(job);
616
+ this.blockedFloor.delete(job);
617
+ }
618
+
619
+ @action
620
+ beginFloor(job: ToolCallJob): void {
621
+ this.inFlight.add(job);
622
+ }
623
+
624
+ @action
625
+ endFloor(job: ToolCallJob): void {
626
+ this.inFlight.delete(job);
627
+ }
628
+
629
+ @action
630
+ blockFloor(job: ToolCallJob, source: string): void {
631
+ this.blockedFloor.set(job, source);
632
+ }
633
+
634
+ @action
635
+ close(): void {
636
+ this.closed = true;
637
+ for (const stop of this.#disposers) stop();
638
+ this.#disposers.length = 0;
639
+ for (const job of this.#judgeJobs) {
640
+ if (!this.#judges.has(job)) continue;
641
+ this.#judges.get(job).dispose();
642
+ this.#judges.delete(job);
643
+ }
644
+ this.#judgeJobs.clear();
645
+ for (const job of this.#floorJobs) {
646
+ if (!this.#floors.has(job)) continue;
647
+ this.#floors.get(job).dispose();
648
+ this.#floors.delete(job);
649
+ }
650
+ this.#floorJobs.clear();
651
+ scopes.delete(this.root);
652
+ }
653
+ }
654
+
655
+ export const scopes = new DefaultedWeakMap<SessionRoot, LicenseScope>((root) => new LicenseScope(root));
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Self-protection: the agent must not rewrite the gate that judges it. The floor is pure and does
3
+ * not know the home directory, so the host adds the absolute locations the gates load from to
4
+ * `write_block`. Only those locations: a repo file that merely shares a name (`authorize.scm`,
5
+ * `policy.json`, a source dir called `.harness`) stays an ordinary project file.
6
+ *
7
+ * - `<home>/.harness`: kernel root (`policy.json`, `kv.json`, classifier `.scm` programs).
8
+ * - `<home>/.inhuman` (or `$INHUMAN_HOME`): home config and bots.
9
+ * - `$PI_CODING_AGENT_DIR` or `<home>/.pi/agent`: pi's `policy-pi.json` and `policy-pi-authorize.scm`.
10
+ * `<root>/.pi/policy-pi.json` is project-relative and lives in the static floor.
11
+ */
12
+ import { homedir } from "node:os";
13
+ import nodePath from "node:path";
14
+
15
+ export function selfProtectedRoots(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string[] {
16
+ const roots = [nodePath.join(home, ".harness"), nodePath.join(home, ".inhuman")];
17
+ const inhuman = env.INHUMAN_HOME?.trim();
18
+ if (inhuman !== undefined && inhuman !== "") roots.push(inhuman);
19
+ const pi = env.PI_CODING_AGENT_DIR?.trim();
20
+ roots.push(pi !== undefined && pi !== "" ? pi : nodePath.join(home, ".pi", "agent"));
21
+ return roots.filter((root) => nodePath.isAbsolute(root));
22
+ }
23
+
24
+ /**
25
+ * Document with the session's read roots ("Treat <folder> as part of this session?" answered yes)
26
+ * appended to `read_file`, so reads under a root follow the in-workspace rules. Block rules still
27
+ * block. Writes are never a root. A document with no read cluster passes through.
28
+ */
29
+ export function withReadRoots(document: unknown, roots: readonly string[]): unknown {
30
+ if (roots.length === 0 || document === null || typeof document !== "object" || Array.isArray(document)) {
31
+ return document;
32
+ }
33
+ const doc = document as { read_file?: unknown };
34
+ if (!Array.isArray(doc.read_file)) return document;
35
+ return { ...doc, read_file: [...(doc.read_file as unknown[]), ...roots] };
36
+ }
37
+
38
+ /** Document with the absolute gate locations appended to `write_block`. Non-objects pass through. */
39
+ export function withSelfProtection(document: unknown, roots: readonly string[] = selfProtectedRoots()): unknown {
40
+ if (document === null || typeof document !== "object" || Array.isArray(document)) return document;
41
+ const doc = document as { write_block?: unknown; write_file?: unknown };
42
+ // A document with no write cluster asks for every write; adding a block list would turn that into deny-by-miss.
43
+ if (doc.write_block === undefined && doc.write_file === undefined) return document;
44
+ const existing = Array.isArray(doc.write_block) ? (doc.write_block as unknown[]) : [];
45
+ return { ...doc, write_block: [...existing, ...roots] };
46
+ }