@descryy/mcp 0.6.0 → 0.7.1

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 (264) hide show
  1. package/dist/action-handshake.d.ts +8 -75
  2. package/dist/action-handshake.d.ts.map +1 -1
  3. package/dist/action-handshake.js +9 -79
  4. package/dist/action-handshake.js.map +1 -1
  5. package/dist/bin/descry-mcp.d.ts +4 -15
  6. package/dist/bin/descry-mcp.d.ts.map +1 -1
  7. package/dist/bin/descry-mcp.js +12 -42
  8. package/dist/bin/descry-mcp.js.map +1 -1
  9. package/dist/browser/driver.d.ts +121 -224
  10. package/dist/browser/driver.d.ts.map +1 -1
  11. package/dist/browser/driver.js +9 -55
  12. package/dist/browser/driver.js.map +1 -1
  13. package/dist/browser/evidence.d.ts +10 -43
  14. package/dist/browser/evidence.d.ts.map +1 -1
  15. package/dist/browser/evidence.js +20 -54
  16. package/dist/browser/evidence.js.map +1 -1
  17. package/dist/browser/fake-driver.d.ts +12 -19
  18. package/dist/browser/fake-driver.d.ts.map +1 -1
  19. package/dist/browser/fake-driver.js +31 -26
  20. package/dist/browser/fake-driver.js.map +1 -1
  21. package/dist/browser/graph-write.d.ts +4 -45
  22. package/dist/browser/graph-write.d.ts.map +1 -1
  23. package/dist/browser/graph-write.js +8 -53
  24. package/dist/browser/graph-write.js.map +1 -1
  25. package/dist/browser/playwright-driver.d.ts +7 -91
  26. package/dist/browser/playwright-driver.d.ts.map +1 -1
  27. package/dist/browser/playwright-driver.js +59 -197
  28. package/dist/browser/playwright-driver.js.map +1 -1
  29. package/dist/browser/provider.d.ts +5 -34
  30. package/dist/browser/provider.d.ts.map +1 -1
  31. package/dist/browser/provider.js +4 -24
  32. package/dist/browser/provider.js.map +1 -1
  33. package/dist/browser/registry.d.ts +26 -106
  34. package/dist/browser/registry.d.ts.map +1 -1
  35. package/dist/browser/registry.js +20 -77
  36. package/dist/browser/registry.js.map +1 -1
  37. package/dist/browser/scenario-provenance.d.ts +23 -0
  38. package/dist/browser/scenario-provenance.d.ts.map +1 -0
  39. package/dist/browser/scenario-provenance.js +59 -0
  40. package/dist/browser/scenario-provenance.js.map +1 -0
  41. package/dist/browser/scenario-resolve.d.ts +10 -44
  42. package/dist/browser/scenario-resolve.d.ts.map +1 -1
  43. package/dist/browser/scenario-resolve.js +10 -41
  44. package/dist/browser/scenario-resolve.js.map +1 -1
  45. package/dist/browser/scenario-runner.d.ts +12 -70
  46. package/dist/browser/scenario-runner.d.ts.map +1 -1
  47. package/dist/browser/scenario-runner.js +30 -90
  48. package/dist/browser/scenario-runner.js.map +1 -1
  49. package/dist/browser/stack-parser.d.ts +4 -28
  50. package/dist/browser/stack-parser.d.ts.map +1 -1
  51. package/dist/browser/stack-parser.js +12 -39
  52. package/dist/browser/stack-parser.js.map +1 -1
  53. package/dist/browser/tool-support.d.ts +20 -67
  54. package/dist/browser/tool-support.d.ts.map +1 -1
  55. package/dist/browser/tool-support.js +21 -65
  56. package/dist/browser/tool-support.js.map +1 -1
  57. package/dist/browser/url-scheme.d.ts +14 -0
  58. package/dist/browser/url-scheme.d.ts.map +1 -0
  59. package/dist/browser/url-scheme.js +38 -0
  60. package/dist/browser/url-scheme.js.map +1 -0
  61. package/dist/cancellation.d.ts +11 -46
  62. package/dist/cancellation.d.ts.map +1 -1
  63. package/dist/cancellation.js +11 -46
  64. package/dist/cancellation.js.map +1 -1
  65. package/dist/capped.d.ts +9 -33
  66. package/dist/capped.d.ts.map +1 -1
  67. package/dist/capped.js +7 -27
  68. package/dist/capped.js.map +1 -1
  69. package/dist/disclosure-ledger.d.ts +6 -30
  70. package/dist/disclosure-ledger.d.ts.map +1 -1
  71. package/dist/disclosure-ledger.js +4 -26
  72. package/dist/disclosure-ledger.js.map +1 -1
  73. package/dist/index.d.ts +10 -26
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +7 -17
  76. package/dist/index.js.map +1 -1
  77. package/dist/module-trust.d.ts +24 -0
  78. package/dist/module-trust.d.ts.map +1 -0
  79. package/dist/module-trust.js +60 -0
  80. package/dist/module-trust.js.map +1 -0
  81. package/dist/path-confinement.d.ts +31 -0
  82. package/dist/path-confinement.d.ts.map +1 -0
  83. package/dist/path-confinement.js +44 -0
  84. package/dist/path-confinement.js.map +1 -0
  85. package/dist/protocol.d.ts +10 -53
  86. package/dist/protocol.d.ts.map +1 -1
  87. package/dist/protocol.js +14 -60
  88. package/dist/protocol.js.map +1 -1
  89. package/dist/registry.d.ts +37 -58
  90. package/dist/registry.d.ts.map +1 -1
  91. package/dist/registry.js +88 -96
  92. package/dist/registry.js.map +1 -1
  93. package/dist/render.d.ts +89 -13
  94. package/dist/render.d.ts.map +1 -1
  95. package/dist/render.js +111 -16
  96. package/dist/render.js.map +1 -1
  97. package/dist/runtime-registry.d.ts +7 -66
  98. package/dist/runtime-registry.d.ts.map +1 -1
  99. package/dist/runtime-registry.js +39 -114
  100. package/dist/runtime-registry.js.map +1 -1
  101. package/dist/scenarios/index.d.ts +1 -1
  102. package/dist/scenarios/index.d.ts.map +1 -1
  103. package/dist/scenarios/index.js +1 -1
  104. package/dist/scenarios/index.js.map +1 -1
  105. package/dist/scenarios/parse.d.ts +4 -18
  106. package/dist/scenarios/parse.d.ts.map +1 -1
  107. package/dist/scenarios/parse.js +14 -34
  108. package/dist/scenarios/parse.js.map +1 -1
  109. package/dist/scenarios/scenario.d.ts +18 -74
  110. package/dist/scenarios/scenario.d.ts.map +1 -1
  111. package/dist/scenarios/scenario.js +7 -34
  112. package/dist/scenarios/scenario.js.map +1 -1
  113. package/dist/scenarios/storage.d.ts +11 -41
  114. package/dist/scenarios/storage.d.ts.map +1 -1
  115. package/dist/scenarios/storage.js +57 -47
  116. package/dist/scenarios/storage.js.map +1 -1
  117. package/dist/server.d.ts.map +1 -1
  118. package/dist/server.js +69 -19
  119. package/dist/server.js.map +1 -1
  120. package/dist/session.d.ts +90 -259
  121. package/dist/session.d.ts.map +1 -1
  122. package/dist/session.js +136 -250
  123. package/dist/session.js.map +1 -1
  124. package/dist/tools/analyze.d.ts +32 -118
  125. package/dist/tools/analyze.d.ts.map +1 -1
  126. package/dist/tools/analyze.js +65 -186
  127. package/dist/tools/analyze.js.map +1 -1
  128. package/dist/tools/browser-click.d.ts +3 -21
  129. package/dist/tools/browser-click.d.ts.map +1 -1
  130. package/dist/tools/browser-click.js +10 -31
  131. package/dist/tools/browser-click.js.map +1 -1
  132. package/dist/tools/browser-close-session.d.ts +4 -13
  133. package/dist/tools/browser-close-session.d.ts.map +1 -1
  134. package/dist/tools/browser-close-session.js +4 -13
  135. package/dist/tools/browser-close-session.js.map +1 -1
  136. package/dist/tools/browser-fill.d.ts +5 -36
  137. package/dist/tools/browser-fill.d.ts.map +1 -1
  138. package/dist/tools/browser-fill.js +9 -44
  139. package/dist/tools/browser-fill.js.map +1 -1
  140. package/dist/tools/browser-navigate.d.ts +6 -27
  141. package/dist/tools/browser-navigate.d.ts.map +1 -1
  142. package/dist/tools/browser-navigate.js +6 -23
  143. package/dist/tools/browser-navigate.js.map +1 -1
  144. package/dist/tools/browser-run-scenario.d.ts +5 -31
  145. package/dist/tools/browser-run-scenario.d.ts.map +1 -1
  146. package/dist/tools/browser-run-scenario.js +10 -46
  147. package/dist/tools/browser-run-scenario.js.map +1 -1
  148. package/dist/tools/browser-save-scenario.d.ts +3 -28
  149. package/dist/tools/browser-save-scenario.d.ts.map +1 -1
  150. package/dist/tools/browser-save-scenario.js +47 -84
  151. package/dist/tools/browser-save-scenario.js.map +1 -1
  152. package/dist/tools/browser-snapshot.d.ts +6 -39
  153. package/dist/tools/browser-snapshot.d.ts.map +1 -1
  154. package/dist/tools/browser-snapshot.js +4 -31
  155. package/dist/tools/browser-snapshot.js.map +1 -1
  156. package/dist/tools/browser-start-session.d.ts +4 -23
  157. package/dist/tools/browser-start-session.d.ts.map +1 -1
  158. package/dist/tools/browser-start-session.js +145 -56
  159. package/dist/tools/browser-start-session.js.map +1 -1
  160. package/dist/tools/browser-type.d.ts +5 -35
  161. package/dist/tools/browser-type.d.ts.map +1 -1
  162. package/dist/tools/browser-type.js +10 -45
  163. package/dist/tools/browser-type.js.map +1 -1
  164. package/dist/tools/contracts.d.ts +9 -86
  165. package/dist/tools/contracts.d.ts.map +1 -1
  166. package/dist/tools/contracts.js +24 -80
  167. package/dist/tools/contracts.js.map +1 -1
  168. package/dist/tools/cross-pr.d.ts +42 -30
  169. package/dist/tools/cross-pr.d.ts.map +1 -1
  170. package/dist/tools/cross-pr.js +81 -62
  171. package/dist/tools/cross-pr.js.map +1 -1
  172. package/dist/tools/git-diff.d.ts +18 -2
  173. package/dist/tools/git-diff.d.ts.map +1 -1
  174. package/dist/tools/git-diff.js +118 -26
  175. package/dist/tools/git-diff.js.map +1 -1
  176. package/dist/tools/git-history.d.ts +5 -16
  177. package/dist/tools/git-history.d.ts.map +1 -1
  178. package/dist/tools/git-history.js +3 -10
  179. package/dist/tools/git-history.js.map +1 -1
  180. package/dist/tools/history.d.ts +4 -33
  181. package/dist/tools/history.d.ts.map +1 -1
  182. package/dist/tools/history.js +6 -31
  183. package/dist/tools/history.js.map +1 -1
  184. package/dist/tools/impact.d.ts +7 -51
  185. package/dist/tools/impact.d.ts.map +1 -1
  186. package/dist/tools/impact.js +15 -67
  187. package/dist/tools/impact.js.map +1 -1
  188. package/dist/tools/index.d.ts +3 -8
  189. package/dist/tools/index.d.ts.map +1 -1
  190. package/dist/tools/index.js +3 -8
  191. package/dist/tools/index.js.map +1 -1
  192. package/dist/tools/kit.d.ts +66 -113
  193. package/dist/tools/kit.d.ts.map +1 -1
  194. package/dist/tools/kit.js +60 -28
  195. package/dist/tools/kit.js.map +1 -1
  196. package/dist/tools/link-workspace.d.ts +7 -45
  197. package/dist/tools/link-workspace.d.ts.map +1 -1
  198. package/dist/tools/link-workspace.js +10 -48
  199. package/dist/tools/link-workspace.js.map +1 -1
  200. package/dist/tools/lookup.d.ts +4 -14
  201. package/dist/tools/lookup.d.ts.map +1 -1
  202. package/dist/tools/lookup.js +4 -14
  203. package/dist/tools/lookup.js.map +1 -1
  204. package/dist/tools/mark-incident.d.ts +7 -54
  205. package/dist/tools/mark-incident.d.ts.map +1 -1
  206. package/dist/tools/mark-incident.js +15 -68
  207. package/dist/tools/mark-incident.js.map +1 -1
  208. package/dist/tools/observe-runtime.d.ts +27 -210
  209. package/dist/tools/observe-runtime.d.ts.map +1 -1
  210. package/dist/tools/observe-runtime.js +273 -434
  211. package/dist/tools/observe-runtime.js.map +1 -1
  212. package/dist/tools/observe-tests.d.ts +9 -100
  213. package/dist/tools/observe-tests.d.ts.map +1 -1
  214. package/dist/tools/observe-tests.js +74 -152
  215. package/dist/tools/observe-tests.js.map +1 -1
  216. package/dist/tools/pr-analysis.d.ts +18 -2
  217. package/dist/tools/pr-analysis.d.ts.map +1 -1
  218. package/dist/tools/pr-analysis.js +73 -23
  219. package/dist/tools/pr-analysis.js.map +1 -1
  220. package/dist/tools/pre-push.d.ts +74 -16
  221. package/dist/tools/pre-push.d.ts.map +1 -1
  222. package/dist/tools/pre-push.js +152 -51
  223. package/dist/tools/pre-push.js.map +1 -1
  224. package/dist/tools/propagation.d.ts +11 -53
  225. package/dist/tools/propagation.d.ts.map +1 -1
  226. package/dist/tools/propagation.js +17 -58
  227. package/dist/tools/propagation.js.map +1 -1
  228. package/dist/tools/questions.d.ts +13 -63
  229. package/dist/tools/questions.d.ts.map +1 -1
  230. package/dist/tools/questions.js +33 -105
  231. package/dist/tools/questions.js.map +1 -1
  232. package/dist/tools/refusal-fetch.d.ts +4 -40
  233. package/dist/tools/refusal-fetch.d.ts.map +1 -1
  234. package/dist/tools/refusal-fetch.js +4 -40
  235. package/dist/tools/refusal-fetch.js.map +1 -1
  236. package/dist/tools/runtime-incident.d.ts +4 -63
  237. package/dist/tools/runtime-incident.d.ts.map +1 -1
  238. package/dist/tools/runtime-incident.js +10 -87
  239. package/dist/tools/runtime-incident.js.map +1 -1
  240. package/dist/tools/scope.d.ts +7 -25
  241. package/dist/tools/scope.d.ts.map +1 -1
  242. package/dist/tools/scope.js +10 -29
  243. package/dist/tools/scope.js.map +1 -1
  244. package/dist/tools/similar-incidents.d.ts +11 -86
  245. package/dist/tools/similar-incidents.d.ts.map +1 -1
  246. package/dist/tools/similar-incidents.js +7 -71
  247. package/dist/tools/similar-incidents.js.map +1 -1
  248. package/dist/tools/validate.d.ts +29 -70
  249. package/dist/tools/validate.d.ts.map +1 -1
  250. package/dist/tools/validate.js +141 -85
  251. package/dist/tools/validate.js.map +1 -1
  252. package/dist/tools/verification-status.d.ts +9 -64
  253. package/dist/tools/verification-status.d.ts.map +1 -1
  254. package/dist/tools/verification-status.js +9 -62
  255. package/dist/tools/verification-status.js.map +1 -1
  256. package/dist/tools/verify-claim.d.ts +5 -52
  257. package/dist/tools/verify-claim.d.ts.map +1 -1
  258. package/dist/tools/verify-claim.js +6 -56
  259. package/dist/tools/verify-claim.js.map +1 -1
  260. package/dist/transport.d.ts +15 -52
  261. package/dist/transport.d.ts.map +1 -1
  262. package/dist/transport.js +16 -60
  263. package/dist/transport.js.map +1 -1
  264. package/package.json +40 -22
@@ -1,137 +1,22 @@
1
1
  /**
2
- * `observe_runtime` — boot or attach to a real application, watch it, and write
3
- * what was witnessed into the graph as R4 facts.
4
- *
5
- * **The second tool that writes, and the first that writes something no
6
- * re-index can reproduce.** `analyze` re-derives the graph from source already
7
- * on disk, so nothing it writes is a new claim about the world. This one runs
8
- * a real process and records what it saw happen, which is the one accuracy
9
- * source architecture §11B.3 calls *"the core technical moat"* and the one a
10
- * purely static tool is structurally unable to reach: *"a purely static
11
- * code-graph tool is capped at R3 permanently. It has no runtime."*
12
- *
13
- * ## What this composes, and what it invents
14
- *
15
- * It invents no mechanism. Every stage already existed, gate-verified, in
16
- * `descry-runtime`, and every one of them was dormant — the whole point of
17
- * `DEC-NEXT-mcp-runtime-dependency-boundary-for-r4-evidence`, which measured
18
- * that `applyRuntimeObservations` had **zero production callers anywhere**,
19
- * not in `descry-desktop` and not in `descry-runtime`'s own pipeline. Four
20
- * shipped components in a row, and this tool is the wire between them:
21
- *
22
- * 1. `runInstrumentedExecution` (`@descryy/runtime-orchestrator`) spawns or
23
- * attaches the declared services, starts the adapter's collectors, drains
24
- * them for a stated window, and writes every item through `EvidenceStore`.
25
- * 2. `correlateExecution` (`@descryy/runtime-evidence-correlation`) resolves
26
- * each evidence item to the graph node it is *about* — the resolve-then-
27
- * attribute pass. This answers identity, not edges.
28
- * 3. `confirmObservedFrontendCaller` (`@descryy/runtime-graph-correlator`)
29
- * turns a captured call-site stack plus a resolved endpoint into a
30
- * `RuntimeEdgeObservation`, and
31
- * 4. calls `applyRuntimeObservations` (`@descryy/core`) with it, which
32
- * promotes, mints or contradicts the edge and writes the EARNED ledger.
33
- *
34
- * ## Why stage 3 exists rather than deriving edges from stage 2 directly
35
- *
36
- * `uat-phase-1-bug-fixes.md` Phase 2 describes step 2 as producing
37
- * `RuntimeEdgeObservation[]`. It does not, and the difference is load-bearing
38
- * rather than pedantic: `correlateExecution` returns
39
- * `CorrelationAttribution`s — *(evidenceId, graphNodeId)* pairs saying which
40
- * single node an observation is about. An edge needs **two** endpoints and a
41
- * witnessed relationship between them, and manufacturing one by pairing up
42
- * attributions that happen to co-occur in the same run would mint edges from
43
- * temporal coincidence. That is precisely the wrong-direction failure rule 2
44
- * exists to prevent, arriving through the one mechanism built to make the
45
- * graph *more* trustworthy.
46
- *
47
- * So the observation comes from the one shipped producer that can honestly
48
- * make one: a captured stack naming the caller, against an endpoint the same
49
- * observation named. Both endpoints come from the observation itself. Every
50
- * other correlated item is reported in the counts and produces no edge, which
51
- * is a disclosed gap rather than a silent one.
52
- *
53
- * ## What this closure can actually witness today — measured, not assumed
54
- *
55
- * The wire is complete, and what it can carry changed when the runtime packages
56
- * were published at 0.1.0 and pinned here — so this paragraph is the record of
57
- * a limit that was real and is now lifted, kept rather than deleted because the
58
- * shape of it recurs.
59
- *
60
- * **It used to be that no producer of the required pair — HTTP evidence
61
- * carrying a call-site stack — was in this server's closure.** The browser
62
- * network collector never has been. The outbound-fetch instrumentation in
63
- * `@descryy/runtime-external-service-observation` existed, was proven in
64
- * `descry-runtime`, and was not installed here at all. So a run resolved both
65
- * kinds of node and wrote no edge, and `STANDING_NOTES` said so on every call
66
- * because "no edge was written" and "nothing here could have written one" are
67
- * different statements.
68
- *
69
- * **That second producer is now in the closure**, transitively through
70
- * `@descryy/runtime-orchestrator`, which applies the adapter's
71
- * `outboundHttpLaunch()` between the interpreter and the script so the client
72
- * is patched before any application code can capture an unpatched one. Measured
73
- * end to end in `observe-runtime-outbound.conformance.test.ts`: two real
74
- * processes, 17 `EXTERNAL_REQUEST` items alongside 18 `BACKEND_LOG`, one
75
- * `USES_API` edge minted at R4, and the first `strongly supported` reply this
76
- * server has produced.
77
- *
78
- * **Two limits remain, and they are stated rather than inferred from a zero.**
79
- * An *attached* service is not launched by Descry, so the instrumentation
80
- * cannot be installed into it and its outbound calls carry no stack. And
81
- * browser-side traffic still needs `@descryy/runtime-browser`, which is not
82
- * here. Both are in `STANDING_NOTES`.
83
- *
84
- * The rule that outlives all of it: a disclosure about the closure is a fact
85
- * about *this build*, not about what `descry-runtime` can do. The two came
86
- * apart once already, when the cross-boundary lane landed and this file still
87
- * claimed the capability was absent. Re-check it against the installed tree
88
- * when the pins move, not against the source repository.
89
- * Descry.
90
- *
91
- * ## Why no denial is ever emitted
92
- *
93
- * `applyRuntimeObservations` accepts `held: false`. Nothing here ever sends
94
- * one, and `confirmObservedFrontendCaller`'s own header explains why: a run
95
- * establishes that a call *happened*; it cannot establish that one did not,
96
- * because a run exercises the paths it happens to take. Demoting a correct
97
- * edge on the strength of a route this run did not visit would be worse than
98
- * never running.
99
- *
100
- * ## Class and tier
101
- *
102
- * `action` — DEC-278's own test is *"can this call's effect change a later
103
- * finding without the developer having said so"*, and this one spawns
104
- * processes and writes R4 edges that cap every later reliability class
105
- * differently. It is gated by the same two-call `confirmToken` handshake
106
- * `questions` uses, and additionally by the environment profile's declared
107
- * `safetyLevel` (DEC-270): booting a service is a **write** against the
108
- * target, so a `readOnly` profile refuses. An all-attach run is genuinely
109
- * read-only — `ServiceConfiguration.attach`'s own contract is that Descry
110
- * never executes code in, or applies limits to, a process it did not spawn —
111
- * so it is allowed under `readOnly`, and that distinction is stated in the
112
- * disclosures rather than inferred silently.
113
- *
114
- * `evidence` — it reports what was witnessed and what was written. It draws no
115
- * conclusion about the user's code; nothing here reads or writes a finding, a
116
- * hypothesis or a category (RT-027).
117
- *
118
- * ## No new query tools
119
- *
120
- * None are needed and none are added. `impact`, `propagation` and the rest
121
- * already read the `resolution` field, so an edge this tool promotes to R4
122
- * becomes visible through every one of them on the next call, with no change
123
- * to any of them.
124
- */
2
+ * `observe_runtime` — boot or attach to a real application, write what was witnessed into the
3
+ * graph as R4 facts. Composes existing descry-runtime stages, no new mechanism (DEC-NEXT-mcp-
4
+ * runtime-dependency-boundary-for-r4-evidence). `action`/`evidence` (DEC-278), gated by
5
+ * confirmToken + profile safetyLevel (DEC-270). Never emits a denial. */
125
6
  import { mkdir } from "node:fs/promises";
126
7
  import { dirname, isAbsolute, join } from "node:path";
127
8
  import { independentSignalTypes } from "@descryy/ir";
9
+ import { checkNotSensitivePath, checkPathConfinement } from "../path-confinement.js";
10
+ /** A-F6 escape hatches — see path-confinement.ts. */
11
+ const ALLOW_EVIDENCE_PATH_OUTSIDE_REPO_ENV_VAR = "DESCRY_ALLOW_EVIDENCE_PATH_OUTSIDE_REPO";
12
+ const ALLOW_SENSITIVE_LOG_PATH_ENV_VAR = "DESCRY_ALLOW_SENSITIVE_LOG_PATH";
128
13
  import { buildGraph, counts, createConfirmedIncidentSource, persistGraph, } from "@descryy/core";
129
14
  import { evaluateAction, validateProfile } from "@descryy/runtime-environment-profile";
130
15
  import { correlateExecution } from "@descryy/runtime-evidence-correlation";
131
16
  import { EvidenceStore } from "@descryy/runtime-evidence-store";
132
17
  import { confirmObservedFrontendCaller } from "@descryy/runtime-graph-correlator";
133
18
  import { runInstrumentedExecution } from "@descryy/runtime-orchestrator";
134
- import { answer, optionalInteger, optionalString, ToolInputError, } from "./kit.js";
19
+ import { answer, optionalEnum, optionalInteger, optionalString, ToolInputError, } from "./kit.js";
135
20
  import { cancellationHeadline, cancellationNotes, whenAborted } from "../cancellation.js";
136
21
  import { loadRuntimeAdapter, RuntimeAdapterLoadError } from "../runtime-registry.js";
137
22
  import { writeConfirmedIncident } from "../session.js";
@@ -186,10 +71,8 @@ const SCHEMA = {
186
71
  "an \"http\" or \"tcp-port\" check it is not needed at all, because nothing is " +
187
72
  "started and Descry never executes code in a process it did not spawn.",
188
73
  },
189
- // The description below is the fix for the single most expensive
190
- // ergonomic gap this tool has: three failed runs in one real
191
- // investigation, all the same root cause wearing different clothes,
192
- // and the explanation both times living in a source comment.
74
+ // Fixes the tool's costliest ergonomic gap: 3 failed runs in one real investigation,
75
+ // same root cause, explanation previously living only in a source comment.
193
76
  port: {
194
77
  type: "integer",
195
78
  description: "The port readiness checks against, and the two modes need opposite things from you. " +
@@ -306,6 +189,51 @@ const SCHEMA = {
306
189
  type: "string",
307
190
  description: `Where the evidence database lives. Defaults to ${DEFAULT_EVIDENCE_RELATIVE_PATH} under the repository root.`,
308
191
  },
192
+ resourceLimits: {
193
+ type: "object",
194
+ description: "A-F5: caps on a spawned service, enforced by the OS (prlimit) — never applied to an attached " +
195
+ "service, since Descry did not start it. Absent means unconstrained, which every reply discloses.",
196
+ properties: {
197
+ maxMemoryBytes: { type: "integer", description: "Virtual address space cap (prlimit --as)." },
198
+ maxCpuSeconds: { type: "integer", description: "CPU time cap, in seconds (prlimit --cpu)." },
199
+ maxProcesses: { type: "integer", description: "Process count cap, per real uid (prlimit --nproc)." },
200
+ },
201
+ additionalProperties: false,
202
+ },
203
+ filesystemPolicy: {
204
+ type: "object",
205
+ description: "A-F5: confines a spawned service's filesystem view to its own cwd plus these roots — real on " +
206
+ "Linux (a bwrap mount namespace; everything else is not merely unreadable, it is not mounted at " +
207
+ "all), refused rather than silently unenforced elsewhere. Absent means unconstrained.",
208
+ properties: {
209
+ allowedRoots: {
210
+ type: "array",
211
+ items: { type: "string" },
212
+ description: "Absolute paths visible read-write in addition to the service's own cwd.",
213
+ },
214
+ },
215
+ required: ["allowedRoots"],
216
+ additionalProperties: false,
217
+ },
218
+ networkPolicy: {
219
+ type: "object",
220
+ description: 'A-F5: only { mode: "allow", hosts: [] } (full denial) is actually enforced today — a network ' +
221
+ "namespace holding nothing but an unreachable loopback. Any other shape refuses the run rather " +
222
+ "than starting unconstrained under a policy nobody enforced. Absent means unconstrained.",
223
+ properties: {
224
+ mode: { type: "string", enum: ["allow", "deny"] },
225
+ hosts: { type: "array", items: { type: "string" } },
226
+ },
227
+ required: ["mode", "hosts"],
228
+ additionalProperties: false,
229
+ },
230
+ sandboxBackend: {
231
+ type: "string",
232
+ enum: ["bwrap", "container"],
233
+ description: 'Which mechanism enforces filesystemPolicy/networkPolicy. Defaults to "bwrap" (Linux-native). ' +
234
+ '"container" routes through a real Docker container instead — the only option on macOS/Windows, ' +
235
+ "and it does not compose with resourceLimits (disclosed on the reply when both are declared).",
236
+ },
309
237
  confirmToken: {
310
238
  type: "string",
311
239
  description: "The token returned by an unconfirmed call. This tool performs nothing without it: the first " +
@@ -317,9 +245,6 @@ const SCHEMA = {
317
245
  required: ["profile", "services", "adapter"],
318
246
  additionalProperties: false,
319
247
  };
320
- // ---------------------------------------------------------------------------
321
- // Argument reading. Hand-written, same reasoning as `kit.ts`'s own readers.
322
- // ---------------------------------------------------------------------------
323
248
  function asRecord(value, what) {
324
249
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
325
250
  throw new ToolInputError(`"${what}" must be an object`);
@@ -343,8 +268,7 @@ function readProfile(args) {
343
268
  };
344
269
  const errors = validateProfile(candidate);
345
270
  if (errors.length > 0) {
346
- // The profile package's own error codes, verbatim — this tool adds no
347
- // interpretation to a validation it did not perform.
271
+ // The profile package's own error codes, verbatim — no interpretation added here.
348
272
  throw new ToolInputError(`"profile" is not valid: ${errors.join(", ")}`);
349
273
  }
350
274
  return candidate;
@@ -385,25 +309,13 @@ function readServices(args, repoPath) {
385
309
  if (command !== undefined && typeof command !== "string") {
386
310
  throw new ToolInputError(`"services.${name}.command" must be a string`);
387
311
  }
388
- // Read once, here, because two separate rules below need it: whether a
389
- // `cwd` is required at all, and whether an attached service must state its
390
- // port.
312
+ // Read once: two rules below need it (whether cwd is required, whether attach needs a port).
391
313
  const readinessRaw = entry["readiness"];
392
314
  const readinessKind = typeof readinessRaw === "object" && readinessRaw !== null && !Array.isArray(readinessRaw)
393
315
  ? readinessRaw["kind"]
394
316
  : undefined;
395
- // `cwd` is required exactly where something will be executed in it, and
396
- // nowhere else. Descry spawns nothing for an attached service and never
397
- // executes code in a process it did not spawn, so for an attached service
398
- // with an `http` or `tcp-port` check the value is inert — demanding it
399
- // makes the caller invent a path that changes nothing. A `command` check
400
- // *is* executed, in this directory, so it still needs one.
401
- //
402
- // The runtime contract's `ServiceConfiguration.cwd` is non-optional, so
403
- // something must be supplied downstream either way; the repository root is
404
- // the inert choice, and it is inert precisely because nothing runs there
405
- // on this path.
406
- // (`DEC-388`.)
317
+ // cwd required only where something executes in it: attach never runs code Descry didn't
318
+ // spawn, so http/tcp-port checks leave it inert; a "command" check does execute (DEC-388).
407
319
  const cwdRaw = entry["cwd"];
408
320
  if (cwdRaw !== undefined && (typeof cwdRaw !== "string" || cwdRaw === "")) {
409
321
  throw new ToolInputError(`"services.${name}.cwd" must be a non-empty string`);
@@ -447,20 +359,17 @@ function readServices(args, repoPath) {
447
359
  if (typeof logFilePath !== "string" || logFilePath === "") {
448
360
  throw new ToolInputError(`"services.${name}.attach.logFilePath" is required`);
449
361
  }
362
+ // A-F6: logFilePath is read in full on every poll and can't be root-confined (a real
363
+ // log lives anywhere) — narrower, disclosed name-based check instead (path-confinement.ts).
364
+ const sensitivity = checkNotSensitivePath(logFilePath, ALLOW_SENSITIVE_LOG_PATH_ENV_VAR);
365
+ if (sensitivity.sensitive) {
366
+ throw new ToolInputError(`"services.${name}.attach.logFilePath": ${sensitivity.reason}`);
367
+ }
450
368
  attach = { pid, logFilePath };
451
- // A spawned service gets an ephemeral port allocated for it; an attached
452
- // one cannot, because the target chose its own port before Descry
453
- // existed and nothing in a pid or a log path reveals which. The
454
- // controller's refusal to guess is deliberate and right — but expressed
455
- // as `port ?? 0`, it surfaces to a caller as a readiness check timing
456
- // out against port 0 some seconds later, with the actual explanation
457
- // living in a source comment they cannot see. Twice in one real
458
- // investigation that cost a full failed run to rediscover. So it is
459
- // refused here, by name, before anything starts.
460
- //
461
- // Only for the two checks that resolve a port. A "command" check runs an
462
- // executable and never asks where the service listens, so demanding a
463
- // port for it would be a second wrong answer in the other direction.
369
+ // An attached service's port can't be allocated or inferred — nothing in a pid or log
370
+ // path reveals it. Left as `port ?? 0` it silently times out against port 0; refused
371
+ // here by name instead (cost a full failed run to rediscover, twice). Not for "command"
372
+ // checks, which never ask where the service listens.
464
373
  if ((readinessKind === "http" || readinessKind === "tcp-port") && port === undefined) {
465
374
  throw new ToolInputError(`"services.${name}.port" is required when "${name}" uses "attach" with a ` +
466
375
  `"${readinessKind}" readiness check: the check needs a port and an attached target's ` +
@@ -484,16 +393,9 @@ function readServices(args, repoPath) {
484
393
  };
485
394
  });
486
395
  }
487
- /**
488
- * The JSON→`ReadinessCheck` mapping, and the two mechanisms it cannot express.
489
- *
490
- * `log-pattern` needs a `read()` closing over the `ManagedProcess` the
491
- * controller owns, and `custom-hook` is a function outright. Neither survives a
492
- * JSON boundary, and inventing a string-shaped stand-in for either would offer
493
- * a mechanism that silently is not the one named. They are absent from the
494
- * schema's enum and stated in the disclosures instead — rule 7, honest
495
- * degradation, applied to a capability rather than to a result.
496
- */
396
+ /** JSON→ReadinessCheck mapping. `log-pattern`/`custom-hook` can't survive a JSON boundary
397
+ * (one closes over a live process, the other is a function) — absent from the enum and
398
+ * stated in disclosures instead (rule 7, applied to a capability, not just a result). */
497
399
  function readReadiness(raw, service, cwd) {
498
400
  const entry = asRecord(raw, `services.${service}.readiness`);
499
401
  const kind = entry["kind"];
@@ -505,12 +407,8 @@ function readReadiness(raw, service, cwd) {
505
407
  (typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs) || timeoutMs < 1)) {
506
408
  throw new ToolInputError(`"services.${service}.readiness.timeoutMs" must be a positive integer`);
507
409
  }
508
- // Every field is validated **here**, not inside `checks`. The controller does
509
- // not call `checks()` until the service has already spawned, so a bad
510
- // argument validated lazily would surface as a failed run with a live process
511
- // to clean up rather than as a rejected call that started nothing — and
512
- // `ToolInputError`'s whole contract is that it is something the caller can
513
- // fix before anything happens.
410
+ // Validated here, not lazily inside checks() — the controller only calls checks() after
411
+ // spawning, so a bad argument caught late means a live process to clean up.
514
412
  const path = typeof entry["path"] === "string" ? entry["path"] : "/";
515
413
  const expectedStatus = entry["expectedStatus"];
516
414
  if (expectedStatus !== undefined && typeof expectedStatus !== "number") {
@@ -584,9 +482,6 @@ function readScopes(args, declared, fallback) {
584
482
  }
585
483
  return scopes;
586
484
  }
587
- // ---------------------------------------------------------------------------
588
- // The run
589
- // ---------------------------------------------------------------------------
590
485
  const EMPTY_WRITE = {
591
486
  promoted: [],
592
487
  created: [],
@@ -595,14 +490,9 @@ const EMPTY_WRITE = {
595
490
  contradictions: [],
596
491
  staleR4: [],
597
492
  };
598
- /**
599
- * The disclosure every call carries, whatever it did.
600
- *
601
- * Stated unconditionally rather than only when it bites: a caller who does not
602
- * know that `log-pattern` readiness is unreachable here will write a
603
- * `tcp-port` check that passes the instant the socket binds and read the
604
- * resulting empty evidence as "the service produced nothing".
605
- */
493
+ /** Stated unconditionally on every call, not only when it bites: a caller unaware
494
+ * log-pattern readiness is unreachable will misread a tcp-port check's empty evidence
495
+ * as "the service produced nothing". */
606
496
  const STANDING_NOTES = [
607
497
  "Readiness here offers only the three mechanisms JSON can state — http, tcp-port and command. " +
608
498
  "log-pattern and custom-hook need a function and are unreachable through this tool; a run that " +
@@ -625,30 +515,40 @@ const STANDING_NOTES = [
625
515
  "No denial is ever recorded. A run establishes that a call happened; it cannot establish that one " +
626
516
  "did not, because it exercises only the paths it took. Nothing here demotes an edge.",
627
517
  ];
628
- /**
629
- * Said on every run that attaches, because it explains an absence that
630
- * otherwise reads as a Descry defect — and did, in a real investigation, until
631
- * it was measured.
632
- *
633
- * A request fired partway through an observation window did not appear in the
634
- * evidence, and appeared in the log file afterwards. The obvious reading is
635
- * that observation stopped early. It did not: reproduced end to end against a
636
- * real attached service, a request fired at t≈5s of a 12s window landed in the
637
- * evidence store, timestamped correctly. What actually happens is one layer
638
- * out — a process whose stdout is redirected to a file is block-buffered, not
639
- * line-buffered, because the descriptor is not a terminal. Measured directly:
640
- * three lines written over 0.6s were still entirely absent from the file three
641
- * seconds later, and arrived only when the process flushed.
642
- *
643
- * Nothing in Descry can see those bytes; they are in the target's own
644
- * userspace buffer. So this is not a gap to close, it is a boundary to state —
645
- * and stating it is what separates "we did not see it" from "it was not
646
- * there", which is the whole difference this tool exists to preserve.
647
- *
648
- * Written for any redirected process, naming no language or framework: the
649
- * behaviour is the C standard library's, and a note scoped to one ecosystem
650
- * would invite one more such note per ecosystem.
651
- */
518
+ /** A-F5: honest degradation about real isolation, said on every reply rather than left for
519
+ * the caller to discover. applySandbox enforces resourceLimits/filesystemPolicy/networkPolicy
520
+ * for real on Linux when declared; never applied to an attached service (Descry didn't spawn it). */
521
+ function sandboxDisclosure(options) {
522
+ const { resourceLimits, filesystemPolicy, networkPolicy, sandboxBackend } = options;
523
+ // True regardless of policy: spawnProcess merges the full ambient env into every spawned
524
+ // process and bwrap doesn't clear it — out of this repo's reach (DEC-NEXT-runtime-sandbox-residual-gaps).
525
+ const envCaveat = "This is unaffected by any policy above: the spawned process still receives this operator's full " +
526
+ "environment, secrets included — the merge happens in @descryy/runtime-controller's spawnProcess and " +
527
+ "cannot be narrowed from this server.";
528
+ if (resourceLimits === undefined && filesystemPolicy === undefined && networkPolicy === undefined) {
529
+ return ("No resourceLimits, filesystemPolicy or networkPolicy was declared for this run. Every spawned " +
530
+ "service therefore ran with no OS-enforced isolation: it can read and write anything this operator's " +
531
+ "account can, use as much memory/CPU/process count as the host allows, and reach any network this " +
532
+ "operator's account can reach, and it received this operator's full environment. Attached services " +
533
+ "are never sandboxed regardless — Descry did not spawn them. Declare resourceLimits/filesystemPolicy/" +
534
+ "networkPolicy to change the filesystem/network/resource part of this for a spawned service.");
535
+ }
536
+ const applied = [];
537
+ if (resourceLimits !== undefined)
538
+ applied.push("resourceLimits");
539
+ if (filesystemPolicy !== undefined)
540
+ applied.push(`filesystemPolicy (allowedRoots: ${filesystemPolicy.allowedRoots.join(", ") || "none beyond cwd"})`);
541
+ if (networkPolicy !== undefined)
542
+ applied.push(`networkPolicy (${networkPolicy.mode}: ${networkPolicy.hosts.join(", ") || "none"})`);
543
+ const backend = sandboxBackend ?? "bwrap";
544
+ return (`Declared for this run, applied to every spawned (never attached) service via the "${backend}" ` +
545
+ `backend: ${applied.join(", ")}. If any of these could not actually be enforced (wrong platform, an ` +
546
+ "unsupported networkPolicy shape, bwrap/Docker unavailable), the run refused to start rather than " +
547
+ `running unconstrained under a policy nobody enforced — see the headline if this reply is a refusal. ${envCaveat}`);
548
+ }
549
+ /** Said on every attaching run. Measured: a redirected process's stdout is block-buffered,
550
+ * not line-buffered — 3 lines written over 0.6s were still absent from the file 3s later.
551
+ * Nothing in Descry can see those bytes; a boundary to state, not a gap to close. */
652
552
  const ATTACH_BUFFERING_NOTE = "Attaching reads a file the target writes; it can only see what the target has already flushed " +
653
553
  "there. A process whose output is redirected to a file is usually block-buffered rather than " +
654
554
  "line-buffered — its own runtime holds whole lines in a userspace buffer, invisible from outside, " +
@@ -657,25 +557,73 @@ const ATTACH_BUFFERING_NOTE = "Attaching reads a file the target writes; it can
657
557
  "target's buffering and not evidence that it did nothing. Run the target with its output " +
658
558
  "unbuffered or line-buffered if the timing matters.";
659
559
  /**
660
- * Every argument this tool takes, read and validated in one place.
661
- *
662
- * Extracted from `run` so that `describeAction` can call it too. That is the
663
- * whole point of the extraction: an `action` tool's first call is the one a
664
- * caller makes *before* it has a token, and therefore the only cheap place to
665
- * learn its arguments are wrong. Minting a token, and a paragraph describing
666
- * an action, for a declaration that cannot possibly run asks a developer to
667
- * confirm something that was never going to happen — and delivers the real
668
- * refusal on the second call, after the confirmation.
669
- *
670
- * Nothing here touches the filesystem, spawns anything or reads the graph: it
671
- * is argument reading and nothing else, which is what makes it safe to run at
672
- * mint time, outside the call budget.
560
+ * RT-024, restated where a caller can actually see it.
673
561
  *
674
- * Ruled in `documents/decisions-inbox/DEC-387.md`.
675
- *
676
- * Exported for its own test: several of the argument rules are conditional and
677
- * cheaper to assert directly than through a full run.
562
+ * `attachToRunningProcess` (descry-runtime) already discloses this in its own
563
+ * source comment — `isProcessAlive` confirms *a* process with this pid
564
+ * exists, not that it is the one that wrote `logFilePath`, because pids are
565
+ * reused by the OS and nothing in a bare pid or path proves provenance. That
566
+ * comment reaches nobody calling this tool. A real run hit exactly the gap it
567
+ * describes: an unrelated orphaned process from an earlier failed boot wrote
568
+ * a crash trace into the same log path a live attach target used, and it was
569
+ * attributed to the live run with no indication anything could be wrong.
678
570
  */
571
+ const ATTACH_IDENTITY_NOTE = "Attaching verifies the declared pid is alive and reads the declared log file; it does not verify " +
572
+ "the lines in that file actually came from that pid. A pid can be reused by the OS, and nothing in " +
573
+ "a bare pid or file path proves which process is writing to it — an unrelated process writing to " +
574
+ "the same path would be attributed to this run with no way to tell the two apart from here.";
575
+ /** Every argument this tool takes (readArguments, below), extracted so describeAction can
576
+ * call it too — catches a bad argument before minting a token for a run that could never
577
+ * happen (DEC-387). Touches nothing; exported for its own test. */
578
+ /** A-F5: ResourceLimits, straight through to ExecutionConfiguration. applyResourceLimits
579
+ * already refuses rather than silently running unconstrained; this only reads the shape. */
580
+ function readResourceLimits(args) {
581
+ const raw = args["resourceLimits"];
582
+ if (raw === undefined || raw === null)
583
+ return undefined;
584
+ const record = asRecord(raw, "resourceLimits");
585
+ const maxMemoryBytes = optionalInteger(record, "maxMemoryBytes", 1);
586
+ const maxCpuSeconds = optionalInteger(record, "maxCpuSeconds", 1);
587
+ const maxProcesses = optionalInteger(record, "maxProcesses", 1);
588
+ return {
589
+ ...(maxMemoryBytes === undefined ? {} : { maxMemoryBytes }),
590
+ ...(maxCpuSeconds === undefined ? {} : { maxCpuSeconds }),
591
+ ...(maxProcesses === undefined ? {} : { maxProcesses }),
592
+ };
593
+ }
594
+ /** A-F5: FilesystemPolicy. allowedRoots is required whenever filesystemPolicy is present —
595
+ * an empty-roots policy silently means "cwd only", and that must be stated, not defaulted into. */
596
+ function readFilesystemPolicy(args) {
597
+ const raw = args["filesystemPolicy"];
598
+ if (raw === undefined || raw === null)
599
+ return undefined;
600
+ const record = asRecord(raw, "filesystemPolicy");
601
+ const allowedRoots = record["allowedRoots"];
602
+ if (!Array.isArray(allowedRoots) || allowedRoots.some((r) => typeof r !== "string")) {
603
+ throw new ToolInputError('"filesystemPolicy.allowedRoots" must be an array of strings');
604
+ }
605
+ return { allowedRoots: allowedRoots };
606
+ }
607
+ /** A-F5: NetworkPolicy. Only `{mode:"allow", hosts:[]}` (full denial) is actually enforced
608
+ * by applySandbox today; any other shape is accepted here and refused downstream by the
609
+ * mechanism itself (execution.validationError), never pre-judged here (rule 1). */
610
+ function readNetworkPolicy(args) {
611
+ const raw = args["networkPolicy"];
612
+ if (raw === undefined || raw === null)
613
+ return undefined;
614
+ const record = asRecord(raw, "networkPolicy");
615
+ const mode = optionalEnum(record, "mode", ["allow", "deny"]);
616
+ if (mode === undefined)
617
+ throw new ToolInputError('"networkPolicy.mode" is required');
618
+ const hosts = record["hosts"];
619
+ if (!Array.isArray(hosts) || hosts.some((h) => typeof h !== "string")) {
620
+ throw new ToolInputError('"networkPolicy.hosts" must be an array of strings');
621
+ }
622
+ return { mode, hosts: hosts };
623
+ }
624
+ function readSandboxBackend(args) {
625
+ return optionalEnum(args, "sandboxBackend", ["bwrap", "container"]);
626
+ }
679
627
  export function readArguments(args, repoPath) {
680
628
  const profile = readProfile(args);
681
629
  const adapterSpec = readAdapterSpec(args);
@@ -687,31 +635,55 @@ export function readArguments(args, repoPath) {
687
635
  throw new ToolInputError('"fidelityLevel" must be 1, 2, 3 or 4');
688
636
  const environmentTier = optionalString(args, "environmentTier") ?? "tier-2-container";
689
637
  const evidenceArg = optionalString(args, "evidencePath");
690
- const evidencePath = evidenceArg === undefined
638
+ const evidenceCandidate = evidenceArg === undefined
691
639
  ? join(repoPath, DEFAULT_EVIDENCE_RELATIVE_PATH)
692
640
  : isAbsolute(evidenceArg)
693
641
  ? evidenceArg
694
642
  : join(repoPath, evidenceArg);
695
- return { profile, adapterSpec, declared, observeForMs, timeoutMs, fidelityRaw, environmentTier, evidencePath };
643
+ // A-F6: join() doesn't stop ".." escaping repoPath — confined by default (path-confinement.ts).
644
+ const confinement = checkPathConfinement({
645
+ candidate: evidenceCandidate,
646
+ allowedRoots: [repoPath],
647
+ envVar: ALLOW_EVIDENCE_PATH_OUTSIDE_REPO_ENV_VAR,
648
+ what: "evidencePath",
649
+ });
650
+ if (!confinement.allowed)
651
+ throw new ToolInputError(confinement.reason);
652
+ const evidencePath = confinement.resolved;
653
+ const resourceLimits = readResourceLimits(args);
654
+ const filesystemPolicy = readFilesystemPolicy(args);
655
+ const networkPolicy = readNetworkPolicy(args);
656
+ const sandboxBackend = readSandboxBackend(args);
657
+ return {
658
+ profile,
659
+ adapterSpec,
660
+ declared,
661
+ observeForMs,
662
+ timeoutMs,
663
+ fidelityRaw,
664
+ environmentTier,
665
+ evidencePath,
666
+ resourceLimits,
667
+ filesystemPolicy,
668
+ networkPolicy,
669
+ sandboxBackend,
670
+ };
696
671
  }
697
672
  async function run(args, ctx) {
698
673
  const session = ctx.session;
699
- const { profile, adapterSpec, declared, observeForMs, timeoutMs, fidelityRaw, environmentTier, evidencePath } = readArguments(args, session.repoPath);
700
- const notes = [...STANDING_NOTES];
701
- // Added on the refusal paths too, deliberately. A caller who attaches and is
702
- // then refused for some unrelated reason will fix that reason and attach
703
- // again; telling them about the buffering only on the success path means
704
- // telling them after the run whose result it would have explained.
705
- if (declared.some((service) => service.attached))
706
- notes.push(ATTACH_BUFFERING_NOTE);
674
+ const { profile, adapterSpec, declared, observeForMs, timeoutMs, fidelityRaw, environmentTier, evidencePath, resourceLimits, filesystemPolicy, networkPolicy, sandboxBackend, } = readArguments(args, session.repoPath);
675
+ const notes = [...STANDING_NOTES, sandboxDisclosure({ resourceLimits, filesystemPolicy, networkPolicy, sandboxBackend })];
676
+ // Added on refusal paths too — telling the caller only on success means telling them
677
+ // after the run whose result it would have explained.
678
+ if (declared.some((service) => service.attached)) {
679
+ notes.push(ATTACH_BUFFERING_NOTE, ATTACH_IDENTITY_NOTE);
680
+ }
707
681
  const base = session.provider().baseStamp();
708
682
  const refuse = (headline, data = {}, extraNotes = []) => answer({
709
683
  headline,
710
684
  state: "refused",
711
685
  nameLevel: true,
712
- // Nothing ran, so nothing was resolved. A refusal reporting the tier its
713
- // successful path would have reached is the leaked-default this repo's
714
- // own UAT already caught once elsewhere.
686
+ // Nothing ran, so nothing was resolved — never leak the tier a successful path would reach.
715
687
  resolutionFloor: 0,
716
688
  commitSha: base.commitSha,
717
689
  graphBuiltAt: base.graphBuiltAt,
@@ -730,11 +702,8 @@ async function run(args, ctx) {
730
702
  ...data,
731
703
  },
732
704
  });
733
- // --- the safety gate ------------------------------------------------------
734
- // Spawning is a write against the target; attaching is not (Descry never
735
- // executes code in, signals, or applies limits to a process it did not
736
- // spawn). So the action's shape depends on what was declared, and the
737
- // profile's declared level decides — never the profile's name.
705
+ // Spawning is a write against the target; attaching is not (Descry never executes code in,
706
+ // signals, or limits a process it didn't spawn). The profile's declared level decides, never its name.
738
707
  const spawns = declared.some((service) => !service.attached);
739
708
  const action = { write: spawns, destructive: false };
740
709
  const decision = evaluateAction(profile, action);
@@ -750,10 +719,8 @@ async function run(args, ctx) {
750
719
  "applied no resource, filesystem or network policy to any process — Descry does not constrain " +
751
720
  "a process it did not spawn.");
752
721
  }
753
- // --- the graph must exist -------------------------------------------------
754
- // Correlation resolves evidence against this graph. Against an empty one it
755
- // resolves nothing, and reporting that as a clean run with no findings would
756
- // be the exact "empty means broken" collapse the five states exist to stop.
722
+ // Correlation resolves evidence against this graph; an empty graph resolving nothing must
723
+ // not be reported as a clean run with no findings — the "empty means broken" collapse.
757
724
  const driver = session.store().driver;
758
725
  const stored = counts(driver);
759
726
  if (stored.nodes === 0) {
@@ -791,25 +758,23 @@ async function run(args, ctx) {
791
758
  fidelityLevel: fidelityRaw,
792
759
  timeoutMs,
793
760
  services,
761
+ // A-F5: wired through to the controller's real bwrap/container isolation
762
+ // — see sandboxDisclosure() above for what this reply says about it.
763
+ ...(resourceLimits === undefined ? {} : { resourceLimits }),
764
+ ...(filesystemPolicy === undefined ? {} : { filesystemPolicy }),
765
+ ...(networkPolicy === undefined ? {} : { networkPolicy }),
766
+ ...(sandboxBackend === undefined ? {} : { sandboxBackend }),
794
767
  };
795
- // Before anything is spawned. This tool boots somebody's application; doing
796
- // that for a caller who has already gone leaves a real process running with
797
- // nothing left that would ever stop it.
768
+ // Before anything is spawned — a caller who's already gone would leave a real process
769
+ // running with nothing left to ever stop it.
798
770
  if (ctx.signal.aborted) {
799
771
  return refuse(cancellationHeadline("no service was started") + " " + cancellationNotes("Nothing was spawned.")[0]);
800
772
  }
801
773
  await mkdir(dirname(evidencePath), { recursive: true });
802
774
  const evidenceStore = new EvidenceStore({ path: evidencePath });
803
- /**
804
- * The processes **this run started**, and only those.
805
- *
806
- * An `attach`-mode service was already running before this tool was called —
807
- * it is the developer's own process, borrowed for the length of an
808
- * observation. Killing it on a cancellation would destroy something this
809
- * call never created, which is a worse failure than the leak being fixed.
810
- * The spawn path is the one that is cleaned up, because it is the one this
811
- * call is responsible for.
812
- */
775
+ /** Processes **this run started**, and only those — an attach-mode service is the
776
+ * developer's own process; killing it on cancellation would destroy something this
777
+ * call never created. Only the spawn path is cleaned up. */
813
778
  const attached = new Set(declared.filter((service) => service.attached).map((service) => service.name));
814
779
  const spawnedPids = new Set();
815
780
  const abort = whenAborted(ctx.signal);
@@ -828,10 +793,8 @@ async function run(args, ctx) {
828
793
  },
829
794
  runOptions: {
830
795
  readiness,
831
- // The only channel that names a process while it is still alive.
832
- // `Execution.processes` is complete when `runInstrumentedExecution`
833
- // resolves, and by then the whole observation window has already been
834
- // slept — which is exactly the stretch a cancellation lands in.
796
+ // The only channel naming a process while still alive — Execution.processes is
797
+ // complete only once the whole window has already slept, past where a cancel lands.
835
798
  onProcessLifecycleEvent: (event) => {
836
799
  if (event.kind !== "process-started" || attached.has(event.serviceName))
837
800
  return;
@@ -843,12 +806,9 @@ async function run(args, ctx) {
843
806
  store: evidenceStore,
844
807
  observeForMs,
845
808
  });
846
- // The window is not shortened by the kill above — the orchestrator sleeps
847
- // it out and returns as it always does — so this is where a cancelled run
848
- // stops. Nothing is correlated and nothing is written: an R4 edge is the
849
- // one claim in this system that cannot be re-derived from source and
850
- // checked later, and minting one from a run nobody watched to the end
851
- // would put a fact into the graph that no later pass could question.
809
+ // The kill above doesn't shorten the window — this is where a cancelled run actually
810
+ // stops. Nothing is correlated or written: an R4 edge can't be re-derived and checked
811
+ // later, so minting one from a run nobody watched to the end would be unquestionable.
852
812
  if (ctx.signal.aborted) {
853
813
  return refuse(cancellationHeadline("the observed application was stopped"), {
854
814
  executionId: execution.execution.executionId,
@@ -863,9 +823,8 @@ async function run(args, ctx) {
863
823
  const observed = describeServices(execution.execution.processes, declared);
864
824
  const evidenceByType = tally(execution.evidence);
865
825
  if (execution.validationError !== null) {
866
- // The controller refused before spawning anything. That is a fact about
867
- // the declaration, not about the application — no evidence, no
868
- // correlation, and emphatically not "the service is clean".
826
+ // Refused before spawning — a fact about the declaration, not the application;
827
+ // emphatically not "the service is clean".
869
828
  return refuse(`The execution refused to start: ${execution.validationError}. Nothing was spawned, no ` +
870
829
  "evidence was collected, and no graph edge was written.", {
871
830
  executionState: execution.execution.state,
@@ -874,24 +833,9 @@ async function run(args, ctx) {
874
833
  evidenceByType,
875
834
  });
876
835
  }
877
- // **The declared budget expired before the observation window closed.**
878
- // `timeoutMs` is published in this tool's schema as the whole-execution
879
- // budget, and it reaches `ExecutionConfiguration` where the controller
880
- // arms a timer for it — that timer kills the services and nothing else.
881
- // Measured: an 8s budget on a live attach returned after ~90s, and a
882
- // 1.5s budget over a 12s window returned after 12.3s with zero evidence,
883
- // because the processes had been dead for the last 10.8s of a window
884
- // nothing shortened (`DEC-NEXT-observe-runtime-timeout-budget-is-not-
885
- // enforced.md`). The window is now clamped in the orchestrator; this is
886
- // the other half — saying so.
887
- //
888
- // Reported as `timed_out` rather than `ok`. §6 calls that state "not a
889
- // silent truncation and never a `failed`", which is exactly this run: the
890
- // evidence below is real and the R4 writes below it are honest, there is
891
- // simply less of both than was asked for. `ok` would make "we watched the
892
- // whole window and it was quiet" indistinguishable from "we stopped
893
- // watching a tenth of the way in", and a caller acting on the first
894
- // concludes the application is fine.
836
+ // Budget expired before the window closed. Measured pre-clamp: a 1.5s budget over a 12s
837
+ // window returned at 12.3s with zero evidence (DEC-NEXT-observe-runtime-timeout-budget-
838
+ // is-not-enforced.md). Reported `timed_out`, not `ok` (§6) — real evidence, just less of it.
895
839
  const timedOut = execution.execution.state === "TIMED_OUT";
896
840
  if (timedOut) {
897
841
  notes.push(`This run hit its ${String(timeoutMs)}ms whole-execution budget before the ` +
@@ -916,16 +860,8 @@ async function run(args, ctx) {
916
860
  harnessErrors: pass.harnessErrors.map((e) => `${e.detail} (${String(e.occurrences)}×)`),
917
861
  };
918
862
  if (pass.unscopedServices.length > 0) {
919
- // Two different sentences, because they call for two different actions
920
- // and the first one used to be printed for both. A named service with no
921
- // scope is something the caller can fix by supplying one. The `(no
922
- // service)` sentinel is not: `correlateExecution` looks a scope up by
923
- // `evidence.service`, and no collector in this dependency closure stamps
924
- // one — measured by the conformance run, which prints a real V8 stack
925
- // resolving to a real graph node and watches it go unasked. Telling a
926
- // caller to name a scope they have no key for would send them after a
927
- // fix that does not exist, which is the honest-degradation rule failing
928
- // in the one place it acts.
863
+ // Two sentences, not one: a named service with no scope is fixable by the caller; the
864
+ // "(no service)" sentinel is not — no collector stamps one, so there's no key to supply.
929
865
  const named = pass.unscopedServices.filter((s) => s !== "(no service)");
930
866
  const anonymous = pass.unscopedServices.length - named.length;
931
867
  if (named.length > 0) {
@@ -948,10 +884,8 @@ async function run(args, ctx) {
948
884
  "than a resolver honestly declining — each is written into the evidence stream as a " +
949
885
  "COLLECTOR_ERROR, and the counts below are correspondingly incomplete.");
950
886
  }
951
- // A service that died is a witnessed failure, and this run is the only
952
- // thing that will ever have seen it. Recorded before the edge write so a
953
- // failure in one does not silently cost the other.
954
- // See `runtime-incident.ts` for why an EXCEPTION alone is not an incident.
887
+ // Recorded before the edge write so a failure in one doesn't silently cost the other.
888
+ // See runtime-incident.ts for why an EXCEPTION alone is not an incident.
955
889
  const incident = runtimeObservedIncident({
956
890
  repo: root.repo,
957
891
  repoRoot: root.absolutePath,
@@ -1004,33 +938,17 @@ async function run(args, ctx) {
1004
938
  `${String(wrote.promoted.length)} edge(s) promoted to R4 and ${String(wrote.created.length)} minted at R4.`;
1005
939
  return answer({
1006
940
  headline,
1007
- // Deliberately not `empty` when nothing was witnessed: `empty` is a claim
1008
- // about the population, and "this run took no path that exercised the
1009
- // code" is not "this code does nothing". The counts say what happened.
1010
- //
1011
- // `timed_out` when the budget bound this run — see the block above. It
1012
- // is the same distinction one step further out: `empty` and `ok` both
1013
- // claim the window was watched to its end.
941
+ // Not `empty` when nothing was witnessed — `empty` claims the population, and "this run
942
+ // took no path exercising the code" isn't "this code does nothing". `timed_out` when the
943
+ // budget bound this run (see above): `empty`/`ok` both claim the window ran to its end.
1014
944
  state: timedOut ? "timed_out" : "ok",
1015
945
  nameLevel: true,
1016
- // R4 unconditionally, and honestly so — every fact in `wrote` was
1017
- // witnessed at runtime, which is the one resolution level that does not
1018
- // rest on inference (DEC-115). The correlated-but-unwritten items are
1019
- // not claimed here at all; they resolved a node and asserted nothing, so
1020
- // a run that wrote nothing reports R0 rather than borrowing the tier its
1021
- // successful path would have reached.
946
+ // R4 unconditionally: every `wrote` fact was witnessed at runtime (DEC-115, no inference).
947
+ // A run that wrote nothing reports R0 rather than borrowing the tier a success would reach.
1022
948
  resolutionFloor: (written > 0 ? 4 : 0),
1023
- // G3's precondition and G4's `E`, declared because this run actually
1024
- // watched something. Only on a path where evidence really came back: a
1025
- // completed run that collected nothing witnessed nothing, and saying
1026
- // otherwise would assert a precondition on an empty array.
1027
- //
1028
- // Note this is deliberately *not* gated on `written > 0`. Whether an
1029
- // edge could be written is a fact about the graph path, and rule 3
1030
- // already caps the category through `resolutionFloor` just above — a
1031
- // run that saw three channels and wrote no edge is reported
1032
- // `unconfirmed` by the cap, not by pretending it saw nothing. Two
1033
- // separate facts, each stated once.
949
+ // G3/G4's E, declared only when evidence actually came back — not gated on `written > 0`:
950
+ // rule 3 already caps the category via resolutionFloor above, so a run that saw channels
951
+ // but wrote no edge is `unconfirmed` by that cap, not by pretending it saw nothing.
1034
952
  ...(execution.evidence.length === 0
1035
953
  ? {}
1036
954
  : { runtimeEvidence: { independentSignalTypes: witnessedSignalTypes(execution.evidence) } }),
@@ -1056,16 +974,8 @@ async function run(args, ctx) {
1056
974
  evidenceStore.close();
1057
975
  }
1058
976
  }
1059
- /**
1060
- * Stop a process this run spawned, and the group it leads.
1061
- *
1062
- * The controller spawns detached specifically so a service that forks — a dev
1063
- * server that runs a compiler, a runtime that supervises a worker — can be
1064
- * stopped whole. Signalling the leader alone would reap the parent and leave
1065
- * its children holding the port, which reads as a successful cleanup and is
1066
- * not one. `ESRCH` is the ordinary case, not an error: the process may have
1067
- * exited on its own between the abort and this call.
1068
- */
977
+ /** Stop a process this run spawned, and the group it leads — signalling the leader alone
978
+ * would leave forked children holding the port. ESRCH is ordinary: it may have already exited. */
1069
979
  function terminateSpawnedProcess(pid) {
1070
980
  try {
1071
981
  process.kill(-pid, "SIGTERM");
@@ -1082,12 +992,8 @@ function terminateSpawnedProcess(pid) {
1082
992
  // Already gone. Nothing to report and nothing to do.
1083
993
  }
1084
994
  }
1085
- /**
1086
- * One row per **declared** service, not one per spawned process — a service
1087
- * that never started must appear with `started: false` rather than vanish from
1088
- * the list, which is the difference between "it ran and did nothing" and "it
1089
- * never ran".
1090
- */
995
+ /** One row per **declared** service, not per spawned process — a never-started service
996
+ * appears with `started: false` rather than vanishing ("it did nothing" vs "it never ran"). */
1091
997
  function describeServices(processes, declared) {
1092
998
  const byService = new Map();
1093
999
  for (const handle of processes) {
@@ -1113,32 +1019,9 @@ function tally(evidence) {
1113
1019
  byType[item.eventType] = (byType[item.eventType] ?? 0) + 1;
1114
1020
  return byType;
1115
1021
  }
1116
- /**
1117
- * One evidence row onto one of `@descryy/ir`'s six `RUNTIME_SIGNAL_TYPES`, or
1118
- * `null`.
1119
- *
1120
- * **Decided by `eventType`, with `source` consulted only where the event type
1121
- * is genuinely ambiguous** — an exception can come off a browser console or a
1122
- * backend process, and nothing but the collector says which. The obvious
1123
- * alternative, reading `source` alone, is wrong and was measured to be wrong
1124
- * rather than reasoned about: every row this repository's own conformance run
1125
- * produces carries `source: "backend-process"`, and `EVIDENCE_SOURCES` also
1126
- * has a `backend-log` value that nothing in the shipped dependency closure
1127
- * emits. A source-driven table would have counted zero channels on every real
1128
- * run while passing a hand-built test — the exact shape of failure that gets
1129
- * caught by running the thing.
1130
- *
1131
- * **Everything not listed returns `null` and is counted as nothing.** That is
1132
- * `RuntimeEvidence`'s own rule, not caution added here: `TEST_*` is excluded
1133
- * by design (its evidentiary weight is `M`'s, never double-counted as a
1134
- * channel too), harness actions are Descry driving the application rather than
1135
- * observing it, process lifecycle is a fact about the process rather than
1136
- * about its behaviour, and a collector or version-mismatch error is a fact
1137
- * about the run. A kind this table has not ruled on must fail closed, because
1138
- * the alternative — mapping it to the nearest-looking channel — raises `E`,
1139
- * and therefore the reported category, with nobody having decided that it
1140
- * should.
1141
- */
1022
+ /** One evidence row onto one of @descryy/ir's six RUNTIME_SIGNAL_TYPES, or null. Decided by
1023
+ * `eventType`; `source` consulted only for the ambiguous EXCEPTION/STACK_TRACE pair (reading
1024
+ * `source` alone was measured wrong). Unlisted kinds fail closed to null, never guess-mapped. */
1142
1025
  function signalOf(item) {
1143
1026
  switch (item.eventType) {
1144
1027
  case "CONSOLE_MESSAGE":
@@ -1157,9 +1040,7 @@ function signalOf(item) {
1157
1040
  return "database";
1158
1041
  case "EXTERNAL_REQUEST":
1159
1042
  return "external-service";
1160
- // The ambiguous pair, and the only place `source` decides: a thrown error
1161
- // reaches Descry through whichever collector saw it, and that collector is
1162
- // the channel.
1043
+ // The ambiguous pair: a thrown error reaches Descry through whichever collector saw it.
1163
1044
  case "EXCEPTION":
1164
1045
  case "STACK_TRACE":
1165
1046
  return item.source === "browser-console" ? "browser-console" : "backend-log";
@@ -1167,31 +1048,15 @@ function signalOf(item) {
1167
1048
  return null;
1168
1049
  }
1169
1050
  }
1170
- /**
1171
- * `E` for this run — how many of the six channels it actually saw.
1172
- *
1173
- * The count itself is `@descryy/ir`'s `independentSignalTypes`, deliberately:
1174
- * the clamp to six and the drop of `null` signals are that function's rules,
1175
- * and a second implementation of them here is a second place for the
1176
- * vocabulary to drift. This function's only job is the translation above.
1177
- *
1178
- * Exported for its own test — the mapping decides whether a witnessed answer
1179
- * reads `strongly supported` or `unconfirmed`, which is too load-bearing to be
1180
- * asserted only through a category two layers downstream.
1181
- */
1051
+ /** `E` for this run — channels actually seen, via @descryy/ir's independentSignalTypes
1052
+ * (clamp-to-six and null-drop are that function's rules, not duplicated here). Exported for
1053
+ * its own test — this mapping decides `strongly supported` vs `unconfirmed`. */
1182
1054
  export function witnessedSignalTypes(evidence) {
1183
1055
  return independentSignalTypes(evidence.map((item) => ({ signal: signalOf(item), detail: item.eventType })));
1184
1056
  }
1185
- /**
1186
- * The R4 write, and the one join this tool performs itself.
1187
- *
1188
- * For every evidence item the correlation pass resolved to an endpoint, if that
1189
- * same item also carried a call-site stack, ask `confirmObservedFrontendCaller`
1190
- * whether the stack names a function — and when it does, it writes. Both
1191
- * endpoints of the resulting edge come from the one observation; nothing here
1192
- * pairs two separate items together. See the module header for why that
1193
- * restraint is the whole design rather than a limitation of it.
1194
- */
1057
+ /** The R4 write, and the one join this tool performs: for every evidence item resolved to an
1058
+ * endpoint that also carries a call-site stack, ask confirmObservedFrontendCaller. Both
1059
+ * endpoints come from one observation — nothing here pairs two separate items (see header). */
1195
1060
  function writeObservations(input) {
1196
1061
  const promoted = [];
1197
1062
  const created = [];
@@ -1199,19 +1064,13 @@ function writeObservations(input) {
1199
1064
  const refused = [];
1200
1065
  const contradictions = [];
1201
1066
  const staleR4 = [];
1202
- // One evidence item can be attributed twice (an endpoint and a second naming
1203
- // the same line carries). Keyed on both so the same (evidence, endpoint) pair
1204
- // is never confirmed twice within one run.
1067
+ // One evidence item can be attributed twice; keyed on both so the same (evidence,
1068
+ // endpoint) pair is never confirmed twice within one run.
1205
1069
  const seen = new Set();
1206
1070
  for (const attribution of input.pass.attributed) {
1207
- // `endpoint` only, and `log-text-endpoint` deliberately excluded. That
1208
- // family fires when a backend log LINE mentions a route and carries its
1209
- // own stack frame — which establishes that the function logged about the
1210
- // endpoint, not that it called it. The commonest real shape is a handler
1211
- // logging "GET /invoices -> 500", and that function SERVES the endpoint
1212
- // rather than USING it, so an edge minted from it could point the wrong
1213
- // way. Rule 2: a wrong edge corrupts diff scoping, impact scores and
1214
- // root-cause traversal; a missing one is a disclosed gap. Omitted.
1071
+ // "endpoint" only — "log-text-endpoint" fires when a log line mentions a route, which
1072
+ // means the function SERVES it, not calls it; minting from that could point the wrong
1073
+ // way. Rule 2: a wrong edge is worse than a missing (disclosed) one. Omitted.
1215
1074
  if (attribution.family !== "endpoint")
1216
1075
  continue;
1217
1076
  const key = `${attribution.evidenceId}::${attribution.graphNodeId}`;
@@ -1238,29 +1097,9 @@ function writeObservations(input) {
1238
1097
  }
1239
1098
  return { promoted, created, confirmed, refused, contradictions, staleR4 };
1240
1099
  }
1241
- /**
1242
- * What confirming this call would do, in the caller's own terms.
1243
- *
1244
- * Always returns a sentence — unlike `questions`, whose unconfirmed shape is a
1245
- * genuine pure read of the question queue, there is no argument to this tool
1246
- * that makes it not run anything. Every valid call spawns or attaches, and
1247
- * every one of them can write.
1248
- */
1249
- /**
1250
- * §7's `willDo`, and — since `readArguments` is the first thing it does — the
1251
- * point where an invalid call is refused.
1252
- *
1253
- * Returns a string on every valid call rather than ever returning `undefined`:
1254
- * `undefined` means "this particular call has nothing to confirm", and there
1255
- * is no such call here. Every accepted declaration boots or attaches to
1256
- * something and may write durable R4 facts.
1257
- *
1258
- * Reads the *parsed* services rather than the raw object, so the sentence a
1259
- * developer confirms is built from the same values the run will use — which
1260
- * is also what makes "spawn" and "attach" here mean exactly what
1261
- * `readServices` decided they mean, rather than a second, looser guess at the
1262
- * same distinction.
1263
- */
1100
+ /** §7's `willDo` — and, since readArguments runs first, where an invalid call is refused.
1101
+ * Always returns a string, never undefined: unlike `questions`, no call here has nothing
1102
+ * to confirm. Reads the *parsed* services so the confirmation sentence matches the run exactly. */
1264
1103
  function describeAction(args, ctx) {
1265
1104
  const { declared } = readArguments(args, ctx.session.repoPath);
1266
1105
  const spawned = declared.filter((service) => !service.attached).map((service) => service.name);