@markii/host 0.0.0-stage → 0.17.0-next.2

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 (193) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -2
  3. package/dist/browser-worker.js +639 -0
  4. package/dist/browser.d.ts +57 -0
  5. package/dist/browser.js +167 -0
  6. package/dist/conformance/host/01-plain-open/expected.json +5 -0
  7. package/dist/conformance/host/01-plain-open/note.mk.md +8 -0
  8. package/dist/conformance/host/01-plain-open/scenario.json +6 -0
  9. package/dist/conformance/host/02-run-manual-grant-accepted/expected.json +16 -0
  10. package/dist/conformance/host/02-run-manual-grant-accepted/note.mk.md +5 -0
  11. package/dist/conformance/host/02-run-manual-grant-accepted/scenario.json +14 -0
  12. package/dist/conformance/host/03-run-manual-grant-refused/expected.json +18 -0
  13. package/dist/conformance/host/03-run-manual-grant-refused/note.mk.md +5 -0
  14. package/dist/conformance/host/03-run-manual-grant-refused/scenario.json +14 -0
  15. package/dist/conformance/host/04-run-auto-write-denied/expected.json +16 -0
  16. package/dist/conformance/host/04-run-auto-write-denied/note.mk.md +10 -0
  17. package/dist/conformance/host/04-run-auto-write-denied/scenario.json +13 -0
  18. package/dist/conformance/host/05-export-html-pack-directive/expected.json +14 -0
  19. package/dist/conformance/host/05-export-html-pack-directive/note.mk.md +8 -0
  20. package/dist/conformance/host/05-export-html-pack-directive/scenario.json +6 -0
  21. package/dist/conformance/host/06-install-pack-then-render/expected.json +18 -0
  22. package/dist/conformance/host/06-install-pack-then-render/files/ana-1.0.0/pack.json +13 -0
  23. package/dist/conformance/host/06-install-pack-then-render/files/ana-1.0.0/webview.js +1 -0
  24. package/dist/conformance/host/06-install-pack-then-render/note.mk.md +5 -0
  25. package/dist/conformance/host/06-install-pack-then-render/scenario.json +15 -0
  26. package/dist/conformance/host/07-complete-in-directive/expected.json +14 -0
  27. package/dist/conformance/host/07-complete-in-directive/note.mk.md +3 -0
  28. package/dist/conformance/host/07-complete-in-directive/scenario.json +6 -0
  29. package/dist/conformance/host/08-hover-standard-component/expected.json +5 -0
  30. package/dist/conformance/host/08-hover-standard-component/note.mk.md +5 -0
  31. package/dist/conformance/host/08-hover-standard-component/scenario.json +6 -0
  32. package/dist/conformance/host/09-insert-skeleton/expected.json +5 -0
  33. package/dist/conformance/host/09-insert-skeleton/note.mk.md +4 -0
  34. package/dist/conformance/host/09-insert-skeleton/scenario.json +6 -0
  35. package/dist/conformance/host/10-scripts-disabled/expected.json +11 -0
  36. package/dist/conformance/host/10-scripts-disabled/note.mk.md +5 -0
  37. package/dist/conformance/host/10-scripts-disabled/scenario.json +9 -0
  38. package/dist/conformance/host/11-complete-closing-fence/expected.json +5 -0
  39. package/dist/conformance/host/11-complete-closing-fence/note.mk.md +5 -0
  40. package/dist/conformance/host/11-complete-closing-fence/scenario.json +12 -0
  41. package/dist/conformance/host/12-complete-nested-container/expected.json +5 -0
  42. package/dist/conformance/host/12-complete-nested-container/note.mk.md +5 -0
  43. package/dist/conformance/host/12-complete-nested-container/scenario.json +12 -0
  44. package/dist/conformance/host/13-install-pack-declined/expected.json +12 -0
  45. package/dist/conformance/host/13-install-pack-declined/files/ana-1.0.0/pack.json +13 -0
  46. package/dist/conformance/host/13-install-pack-declined/files/ana-1.0.0/webview.js +1 -0
  47. package/dist/conformance/host/13-install-pack-declined/note.mk.md +5 -0
  48. package/dist/conformance/host/13-install-pack-declined/scenario.json +10 -0
  49. package/dist/conformance/host/14-remove-pack/expected.json +14 -0
  50. package/dist/conformance/host/14-remove-pack/files/ana-1.0.0/pack.json +13 -0
  51. package/dist/conformance/host/14-remove-pack/files/ana-1.0.0/webview.js +1 -0
  52. package/dist/conformance/host/14-remove-pack/note.mk.md +5 -0
  53. package/dist/conformance/host/14-remove-pack/scenario.json +11 -0
  54. package/dist/conformance/host/15-load-packs-engine-views/expected.json +19 -0
  55. package/dist/conformance/host/15-load-packs-engine-views/files/duo-1.0.0/pack.json +9 -0
  56. package/dist/conformance/host/15-load-packs-engine-views/files/duo-1.0.0/webview.js +1 -0
  57. package/dist/conformance/host/15-load-packs-engine-views/note.mk.md +9 -0
  58. package/dist/conformance/host/15-load-packs-engine-views/scenario.json +10 -0
  59. package/dist/conformance/host/16-pack-trust/expected.json +37 -0
  60. package/dist/conformance/host/16-pack-trust/files/ana-1.0.0/pack.json +12 -0
  61. package/dist/conformance/host/16-pack-trust/files/ana-1.0.0/webview.js +1 -0
  62. package/dist/conformance/host/16-pack-trust/files/sneaky-1.0.0/pack.json +12 -0
  63. package/dist/conformance/host/16-pack-trust/files/sneaky-1.0.0/webview.js +1 -0
  64. package/dist/conformance/host/16-pack-trust/note.mk.md +5 -0
  65. package/dist/conformance/host/16-pack-trust/scenario.json +26 -0
  66. package/dist/conformance/host/README.md +221 -0
  67. package/dist/conformance.d.ts +2 -0
  68. package/dist/conformance.js +5 -0
  69. package/dist/diagnostics/render-diagnostics.d.ts +41 -0
  70. package/dist/diagnostics/render-diagnostics.js +46 -0
  71. package/dist/export/cascade.d.ts +98 -0
  72. package/dist/export/cascade.js +159 -0
  73. package/dist/export/export-zip.d.ts +19 -0
  74. package/dist/export/export-zip.js +38 -0
  75. package/dist/export/image-embed.d.ts +126 -0
  76. package/dist/export/image-embed.js +274 -0
  77. package/dist/export/note-export.d.ts +332 -0
  78. package/dist/export/note-export.js +366 -0
  79. package/dist/export/note-links.d.ts +83 -0
  80. package/dist/export/note-links.js +241 -0
  81. package/dist/glue.wasm +0 -0
  82. package/dist/host/adapter.d.ts +223 -0
  83. package/dist/host/adapter.js +9 -0
  84. package/dist/host/cascade-export-behavior.d.ts +120 -0
  85. package/dist/host/cascade-export-behavior.js +151 -0
  86. package/dist/host/conformance-runner.d.ts +156 -0
  87. package/dist/host/conformance-runner.js +634 -0
  88. package/dist/host/create-host.d.ts +253 -0
  89. package/dist/host/create-host.js +329 -0
  90. package/dist/host/editor-behavior.d.ts +157 -0
  91. package/dist/host/editor-behavior.js +227 -0
  92. package/dist/host/export/md-plain.d.ts +15 -0
  93. package/dist/host/export/md-plain.js +125 -0
  94. package/dist/host/export-behavior.d.ts +113 -0
  95. package/dist/host/export-behavior.js +257 -0
  96. package/dist/host/labels.d.ts +24 -0
  97. package/dist/host/labels.js +32 -0
  98. package/dist/host/pack-archive.d.ts +30 -0
  99. package/dist/host/pack-archive.js +68 -0
  100. package/dist/host/pack-install.d.ts +88 -0
  101. package/dist/host/pack-install.js +189 -0
  102. package/dist/host/pack-load.d.ts +72 -0
  103. package/dist/host/pack-load.js +57 -0
  104. package/dist/host/pack-remove.d.ts +21 -0
  105. package/dist/host/pack-remove.js +65 -0
  106. package/dist/host/pack-trust-behavior.d.ts +79 -0
  107. package/dist/host/pack-trust-behavior.js +262 -0
  108. package/dist/host/refresh-interval.d.ts +39 -0
  109. package/dist/host/refresh-interval.js +61 -0
  110. package/dist/host/run-behavior.d.ts +89 -0
  111. package/dist/host/run-behavior.js +168 -0
  112. package/dist/host/run-diagnostics.d.ts +26 -0
  113. package/dist/host/run-diagnostics.js +43 -0
  114. package/dist/host/script-execution.d.ts +72 -0
  115. package/dist/host/script-execution.js +68 -0
  116. package/dist/host/terminal-packs.d.ts +39 -0
  117. package/dist/host/terminal-packs.js +51 -0
  118. package/dist/html-engine.d.ts +2 -0
  119. package/dist/html-engine.js +29 -0
  120. package/dist/index.d.ts +95 -0
  121. package/dist/index.js +70 -0
  122. package/dist/insert/component-catalog.d.ts +23 -0
  123. package/dist/insert/component-catalog.js +96 -0
  124. package/dist/node-fs.d.ts +14 -0
  125. package/dist/node-fs.js +70 -0
  126. package/dist/node.d.ts +5 -0
  127. package/dist/node.js +13 -0
  128. package/dist/package.json +3 -0
  129. package/dist/packs/discover.d.ts +119 -0
  130. package/dist/packs/discover.js +266 -0
  131. package/dist/packs/pack-diagnostics.d.ts +81 -0
  132. package/dist/packs/pack-diagnostics.js +105 -0
  133. package/dist/packs/pack-paths.d.ts +53 -0
  134. package/dist/packs/pack-paths.js +106 -0
  135. package/dist/packs/pack-render-registry.d.ts +83 -0
  136. package/dist/packs/pack-render-registry.js +182 -0
  137. package/dist/packs/pack-scripts.d.ts +20 -0
  138. package/dist/packs/pack-scripts.js +96 -0
  139. package/dist/packs/pack-trust.d.ts +57 -0
  140. package/dist/packs/pack-trust.js +111 -0
  141. package/dist/packs/prebuilt.d.ts +49 -0
  142. package/dist/packs/prebuilt.js +97 -0
  143. package/dist/path-util.d.ts +24 -0
  144. package/dist/path-util.js +82 -0
  145. package/dist/run/browser-isolate.d.ts +45 -0
  146. package/dist/run/browser-isolate.js +81 -0
  147. package/dist/run/bundle-document.d.ts +29 -0
  148. package/dist/run/bundle-document.js +40 -0
  149. package/dist/run/bundle-run.d.ts +188 -0
  150. package/dist/run/bundle-run.js +232 -0
  151. package/dist/run/grant-flow.d.ts +232 -0
  152. package/dist/run/grant-flow.js +390 -0
  153. package/dist/run/ip-address.d.ts +41 -0
  154. package/dist/run/ip-address.js +255 -0
  155. package/dist/run/isolate.d.ts +57 -0
  156. package/dist/run/isolate.js +22 -0
  157. package/dist/run/lua-resolver.d.ts +21 -0
  158. package/dist/run/lua-resolver.js +63 -0
  159. package/dist/run/net-bridge-worker.d.ts +26 -0
  160. package/dist/run/net-bridge-worker.js +66 -0
  161. package/dist/run/net-bridge.d.ts +56 -0
  162. package/dist/run/net-bridge.js +59 -0
  163. package/dist/run/net-pinning.d.ts +82 -0
  164. package/dist/run/net-pinning.js +137 -0
  165. package/dist/run/net-provider.d.ts +70 -0
  166. package/dist/run/net-provider.js +289 -0
  167. package/dist/run/node-isolate.d.ts +30 -0
  168. package/dist/run/node-isolate.js +117 -0
  169. package/dist/run/run-flow.d.ts +187 -0
  170. package/dist/run/run-flow.js +323 -0
  171. package/dist/run/run-host.d.ts +107 -0
  172. package/dist/run/run-host.js +241 -0
  173. package/dist/run/run-job.d.ts +130 -0
  174. package/dist/run/run-job.js +279 -0
  175. package/dist/run/run-progress.d.ts +55 -0
  176. package/dist/run/run-progress.js +42 -0
  177. package/dist/run/run-trace.d.ts +44 -0
  178. package/dist/run/run-trace.js +36 -0
  179. package/dist/run/script-requirements.d.ts +98 -0
  180. package/dist/run/script-requirements.js +212 -0
  181. package/dist/run/snapshot-storage.d.ts +25 -0
  182. package/dist/run/snapshot-storage.js +31 -0
  183. package/dist/run/stale-values.d.ts +25 -0
  184. package/dist/run/stale-values.js +32 -0
  185. package/dist/run/worker-entry-browser.d.ts +42 -0
  186. package/dist/run/worker-entry-browser.js +77 -0
  187. package/dist/run/worker-entry.d.ts +1 -0
  188. package/dist/run/worker-entry.js +96 -0
  189. package/dist/values-failure.d.ts +17 -0
  190. package/dist/values-failure.js +1 -0
  191. package/dist/values-merge.d.ts +39 -0
  192. package/dist/values-merge.js +33 -0
  193. package/package.json +84 -4
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Slice 2's grant/prompt flow (docs: GitHub issue #1's locked design
3
+ * comment) — vscode-free by design, same split as `./run-host.ts` and
4
+ * `./script-requirements.ts`: this module decides WHAT to prompt and WHAT
5
+ * to persist, but never calls `vscode.window.showInformationMessage` or
6
+ * `context.workspaceState` directly. The extension-host adapter
7
+ * (`extension.ts`/`preview-panel.ts` — the only files allowed to import
8
+ * `vscode`) supplies the actual prompt UI and Memento, both behind the thin
9
+ * interfaces below, so this file stays unit-testable with plain fakes.
10
+ *
11
+ * ## Keying
12
+ *
13
+ * `computeGrantKey` (`@markii/runtime`) hashes the note's executable
14
+ * closure — that closure is its own script blocks
15
+ * (`RunRequirements.grantScripts`) plus, for a bundle-backed run, the
16
+ * resolved source text of every `src=`-referenced script file
17
+ * (`GrantFlowRequirements.bundleModules`, F-1 fix), with `vaultModules`/
18
+ * `packs` left empty: `require` (bundle-local or pack) stays unwired for
19
+ * the whole `.mkz` Run-path arc (GitHub issue #9's design comment), so a
20
+ * bundle-backed run's closure has no OTHER bundle-local code to fold in
21
+ * yet beyond its `src=` targets. Any edit to any script's code — inline or
22
+ * `src=`-referenced — changes the hash, which is what makes "any code
23
+ * change re-prompts" fall out of the keying scheme for free rather than
24
+ * needing its own diffing logic here.
25
+ *
26
+ * The bundle-fs capability (`permissions.bundle`) is out of this module
27
+ * entirely (consent unification, SECURITY-RELEVANT): it needs no
28
+ * user-facing prompt, so it is never part of `GrantFlowRequirements`, the
29
+ * stored grant, or the grant key. `./run-flow.ts` passes the manifest's
30
+ * declared bundle-fs grants straight through to `spawnRun` — see its own
31
+ * comment on why that is still safe (path-jail + the read-only tier for
32
+ * auto/scheduled, both unaffected by this module).
33
+ *
34
+ * ## Storage
35
+ *
36
+ * One `{key, allowedHosts}` record per document, keyed by the document's
37
+ * URI string, all held under one Memento key (`GRANTS_STORAGE_KEY`) as a
38
+ * plain map. A stored record whose `key` no longer matches the freshly
39
+ * computed one is a MISS — this module never trusts a stale record's
40
+ * `allowedHosts`, even partially; the whole grant re-derives from scratch.
41
+ * A record is only ever WRITTEN when `allowedHosts` ends up non-empty — a
42
+ * full decline is never persisted, so it simply re-prompts next time (see
43
+ * `runGrantFlow`'s own comment on this, C-3) — and `clearGrantForDocument`
44
+ * removes a document's record outright, for the `markii.resetScriptGrants`
45
+ * command.
46
+ *
47
+ * ## The prompt shapes
48
+ *
49
+ * - One prompt per statically-known host (`promptHost`), worded exactly
50
+ * "This note's scripts can send data to <host>. Allow?" — see
51
+ * `hostPromptMessage`. Used only while the distinct safe host count is at
52
+ * or under `MAX_HOST_PROMPTS`; above that, `promptManyHosts` (below) takes
53
+ * over instead so a note can never turn one Run press into a wall of
54
+ * modals (the PROMPT-STORM fix — see `MAX_HOST_PROMPTS`'s doc comment).
55
+ * - `promptManyHosts`, exactly once, in place of the whole per-host loop
56
+ * when there are more than `MAX_HOST_PROMPTS` distinct safe hosts: one
57
+ * all-or-nothing gate ("This note requests network access to many hosts
58
+ * (N). Allow all or deny all?", `manyHostsPromptMessage`) rather than N
59
+ * separate dialogs.
60
+ * - Exactly one extra prompt (`promptUnknownHosts`,
61
+ * `UNKNOWN_HOSTS_PROMPT_MESSAGE`) when the closure could reach hosts that
62
+ * cannot be listed in advance (`RunRequirements.hasUnknownHosts`, or a
63
+ * host string this module refuses to render — see `isSafeHostForPrompt`).
64
+ * Accepting it does NOT add anything to `allowedHosts` — there is no host
65
+ * name to grant — it is a consent gate: declining it clears every
66
+ * already-collected per-host answer too, since a script that can reach
67
+ * addresses it never named cannot be trusted to only use the hosts it DID
68
+ * name. A dynamically-built address a script hits at RUN TIME, whether or
69
+ * not this prompt was accepted, is never added to `allowedHosts` here (it
70
+ * is not known in advance); it fails at execution with the ordinary
71
+ * `'capability-denied'` kind, same as any other ungranted host.
72
+ */
73
+ import { computeGrantKey, } from '@markii/runtime';
74
+ /**
75
+ * Exact prompt wording — normative per the locked design comment. Exported
76
+ * so a test (and the adapter that renders it) both anchor on the same
77
+ * string.
78
+ *
79
+ * `declaredHosts` is what the note's own scripts declare through their
80
+ * `permissions` fence attribute (`./script-requirements.ts`'s
81
+ * `scriptDeclaredHosts`). It is DISPLAY-ONLY: it never changes what is
82
+ * prompted for or granted, only what the user reads before answering, so
83
+ * a note cannot talk its way into a host the literal-URL scan did not
84
+ * find. `host` is always the one host this prompt asks about. With no
85
+ * declaration the wording is unchanged, so a host that does not pass the
86
+ * list sees exactly the message it saw before.
87
+ */
88
+ export function hostPromptMessage(host, declaredHosts) {
89
+ const ask = `This note's scripts can send data to ${host}. Allow?`;
90
+ if (!declaredHosts || declaredHosts.length === 0)
91
+ return ask;
92
+ return `This note's scripts can send data to ${host}. The note declares ${declaredHosts.join(', ')}. Allow?`;
93
+ }
94
+ /** Exact wording of the one extra "hosts can't be listed in advance" prompt. */
95
+ export const UNKNOWN_HOSTS_PROMPT_MESSAGE = "This note builds network addresses at run time, which can't be listed in advance and will be denied; only the hosts written directly in the note can be granted. Allow those?";
96
+ /** Button labels every prompt in this flow uses — both a plain "Allow" and a "Don't allow" refusal are always offered, never a bare dismiss-only message. */
97
+ export const ALLOW_LABEL = 'Allow';
98
+ export const DONT_ALLOW_LABEL = "Don't allow";
99
+ /**
100
+ * PROMPT-STORM fix (report section 8, item 6, docs/archive/PENTEST-REPORT-2026-08-23.md):
101
+ * `runGrantFlow`'s per-host loop below is sequential and modal, one dialog
102
+ * per statically-extracted host. A hostile note with hundreds of literal
103
+ * `net.fetch_json("https://hostN/")` calls would otherwise re-open hundreds
104
+ * of modals on EVERY `markii.runScripts` press — and because a FULL decline
105
+ * is deliberately never persisted (C-3, so one mis-click can't permanently
106
+ * lock out network access), nothing about answering them once makes them
107
+ * stop recurring on the next Run press either. That makes the per-host loop
108
+ * itself a denial-of-service surface against the user's own attention, not
109
+ * just an annoyance.
110
+ *
111
+ * The guard: cap how many per-host prompts one run will ever show. When the
112
+ * distinct SAFE static host count is at or under this cap, the ordinary
113
+ * per-host flow runs exactly as before (this is overwhelmingly the common
114
+ * case — most notes touch a small, fixed set of APIs). Above the cap, the
115
+ * per-host loop is skipped entirely in favor of ONE consolidated prompt
116
+ * (`promptManyHosts`) that grants either every one of those hosts or none of
117
+ * them — still a single, honest, all-or-nothing consent decision, just sized
118
+ * to the actual scale of what's being asked, rather than a wall of modals a
119
+ * user would rationally start rubber-stamping. 10 is comfortably above what
120
+ * a legitimate dashboard-style note (docs/scripting.md's callout on
121
+ * dashboards being a first-class use case) is expected to reach while still
122
+ * catching a hostile host list before it becomes hundreds of dialogs.
123
+ */
124
+ export const MAX_HOST_PROMPTS = 10;
125
+ /** Exact wording of the consolidated "many hosts" gate — normative, mirroring `hostPromptMessage`/`UNKNOWN_HOSTS_PROMPT_MESSAGE`'s pattern of an exported, test-anchored string. */
126
+ export function manyHostsPromptMessage(hostCount) {
127
+ return `This note requests network access to many hosts (${hostCount}). Allow all or deny all?`;
128
+ }
129
+ const GRANTS_STORAGE_KEY = 'markii.netGrants';
130
+ function hasOwn(value, key) {
131
+ return Object.prototype.hasOwnProperty.call(value, key);
132
+ }
133
+ function isPlainObject(value) {
134
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
135
+ }
136
+ /** Never trusts a Memento's stored shape — a corrupt/foreign/hand-edited value degrades to "no stored grant" rather than throwing or partially applying. */
137
+ function isStoredGrant(value) {
138
+ if (!isPlainObject(value))
139
+ return false;
140
+ if (!hasOwn(value, 'key') || typeof value.key !== 'string')
141
+ return false;
142
+ if (!hasOwn(value, 'allowedHosts') ||
143
+ !Array.isArray(value.allowedHosts) ||
144
+ !value.allowedHosts.every((host) => typeof host === 'string')) {
145
+ return false;
146
+ }
147
+ return true;
148
+ }
149
+ function readAllGrants(memento) {
150
+ const raw = memento.get(GRANTS_STORAGE_KEY, {});
151
+ return isPlainObject(raw) ? raw : {};
152
+ }
153
+ /**
154
+ * Reads the stored grant for `documentKey`, if any, and re-runs every one of
155
+ * its `allowedHosts` through `isSafeHostForPrompt` before returning it (N-6):
156
+ * a stored record's hosts were only ever validated at COLLECTION time,
157
+ * possibly under an older/buggier version of this module, or (with direct
158
+ * `workspaceState` write access) never validated at all. Trusting the shape
159
+ * check alone and not re-deriving the safety property at read time would let
160
+ * a planted or stale record become the effective allowlist verbatim. Any
161
+ * host that fails re-validation is dropped from the returned grant, and
162
+ * `droppedUnsafeHost` is set so `runGrantFlow` know it must not treat this as
163
+ * a clean cache hit — see its own comment at the call site.
164
+ */
165
+ function readGrant(memento, documentKey) {
166
+ const all = readAllGrants(memento);
167
+ if (!hasOwn(all, documentKey))
168
+ return undefined;
169
+ const candidate = all[documentKey];
170
+ if (!isStoredGrant(candidate))
171
+ return undefined;
172
+ const safeHosts = candidate.allowedHosts.filter(isSafeHostForPrompt);
173
+ return {
174
+ grant: {
175
+ key: candidate.key,
176
+ allowedHosts: safeHosts,
177
+ },
178
+ droppedUnsafeHost: safeHosts.length !== candidate.allowedHosts.length,
179
+ };
180
+ }
181
+ async function writeGrant(memento, documentKey, grant) {
182
+ const all = readAllGrants(memento);
183
+ const next = { ...all, [documentKey]: grant };
184
+ await memento.update(GRANTS_STORAGE_KEY, next);
185
+ }
186
+ /**
187
+ * A conservative hostname-shape check for what this module is willing to
188
+ * interpolate into a prompt string. `extractRunRequirements`
189
+ * (`./script-requirements.ts`) already derives every host in
190
+ * `RunRequirements.hosts` from `new URL(literal).hostname` — which can
191
+ * never itself contain whitespace or control characters — but this is a
192
+ * second, independent gate at the point of DISPLAY: defense in depth
193
+ * against a future host-extraction bug (or a deliberately different caller
194
+ * of this module) ever interpolating raw, unvalidated note text into a
195
+ * modal dialog. Accepts ordinary DNS names, bracketed IPv6 literals, and
196
+ * plain IPv4 literals; rejects everything else, including any control or
197
+ * whitespace character.
198
+ */
199
+ const SAFE_HOSTNAME_PATTERN = /^(?:[a-z0-9]([a-z0-9-]*[a-z0-9])?)(?:\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$|^\[[0-9a-f:]+\]$|^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/i;
200
+ export function isSafeHostForPrompt(host) {
201
+ // SAFE_HOSTNAME_PATTERN is a full, anchored (^...$) match against a
202
+ // closed character set, so no whitespace or control character can ever
203
+ // pass it -- no separate check is needed.
204
+ return (typeof host === 'string' &&
205
+ host.length > 0 &&
206
+ host.length <= 253 &&
207
+ SAFE_HOSTNAME_PATTERN.test(host));
208
+ }
209
+ /**
210
+ * Splits `hosts` into the ones safe to render in a per-host prompt, and
211
+ * whether the "can't be listed in advance" prompt is needed — either
212
+ * because the static scan already said so (`hasUnknownHosts`), or because
213
+ * at least one extracted host failed `isSafeHostForPrompt` and must never
214
+ * be shown verbatim. Failing toward the broader prompt (never toward
215
+ * silently dropping a host with no prompt at all) mirrors
216
+ * `extractRunRequirements`'s own philosophy.
217
+ */
218
+ function partitionHosts(hosts, hasUnknownHosts) {
219
+ const safeHosts = [];
220
+ let needsUnknownPrompt = hasUnknownHosts;
221
+ for (const host of hosts) {
222
+ if (isSafeHostForPrompt(host)) {
223
+ safeHosts.push(host);
224
+ }
225
+ else {
226
+ needsUnknownPrompt = true;
227
+ }
228
+ }
229
+ return { safeHosts, needsUnknownPrompt };
230
+ }
231
+ function closureFrom(requirements) {
232
+ return {
233
+ scripts: [...requirements.grantScripts],
234
+ // F-1 fix: was unconditionally `{}` — see `GrantFlowRequirements.bundleModules`'s
235
+ // doc comment for why that let a bundle script's contents change under an
236
+ // unchanged grant key.
237
+ bundleModules: { ...(requirements.bundleModules ?? {}) },
238
+ vaultModules: {},
239
+ packs: [],
240
+ };
241
+ }
242
+ /**
243
+ * Runs the whole grant flow for one Run press: computes the closure's grant
244
+ * key, reuses a matching stored grant with NO prompting at all, or (on a
245
+ * miss — first run or any code change) prompts fresh for every host and the
246
+ * unknown-hosts gate, persists the result, and returns the allowlist
247
+ * `spawnRun` should use.
248
+ */
249
+ export async function runGrantFlow(options) {
250
+ const { documentKey, requirements, memento, promptHost, promptUnknownHosts, promptManyHosts, } = options;
251
+ const declaredHosts = options.declaredHosts ?? [];
252
+ const key = await computeGrantKey(closureFrom(requirements));
253
+ const stored = readGrant(memento, documentKey);
254
+ // N-6: a matching key alone is not enough to reuse a stored grant with no
255
+ // prompting -- only take the fast path when EVERY stored host still
256
+ // passes today's safety re-check. If `readGrant` had to drop anything,
257
+ // the record is not trusted at all here; fall through to the ordinary
258
+ // prompt flow below, which re-derives from the note's CURRENT
259
+ // requirements and, once answered, overwrites the untrusted record via
260
+ // the normal `writeGrant` call at the end of this function.
261
+ if (stored && stored.grant.key === key && !stored.droppedUnsafeHost) {
262
+ return { allowedHosts: [...stored.grant.allowedHosts] };
263
+ }
264
+ const { safeHosts, needsUnknownPrompt } = partitionHosts(requirements.hosts, requirements.hasUnknownHosts);
265
+ let allowedHosts;
266
+ if (safeHosts.length > MAX_HOST_PROMPTS) {
267
+ // PROMPT-STORM guard: too many distinct hosts for the per-host loop --
268
+ // fold the whole set into one consolidated all-or-nothing gate instead
269
+ // of opening `safeHosts.length` modals in a row. See `MAX_HOST_PROMPTS`'s
270
+ // doc comment.
271
+ const allowAll = await promptManyHosts(safeHosts.length);
272
+ allowedHosts = allowAll ? [...safeHosts] : [];
273
+ }
274
+ else {
275
+ allowedHosts = [];
276
+ for (const host of safeHosts) {
277
+ // Each prompt is modal; they must appear one at a time, never all at
278
+ // once, so this loop is deliberately sequential rather than
279
+ // `Promise.all`-ed.
280
+ const allowed = await promptHost(host, declaredHosts);
281
+ if (allowed)
282
+ allowedHosts.push(host);
283
+ }
284
+ }
285
+ let finalAllowedHosts = allowedHosts;
286
+ // The gate governs the per-host answers just collected: declining
287
+ // withdraws them. With NO grantable host it governs nothing, and asking
288
+ // anyway produces a dialog the user cannot satisfy — Allow grants no
289
+ // host, Deny withdraws no host, and because a run that ends with an
290
+ // empty allowlist is deliberately never persisted (C-3 below), the same
291
+ // unanswerable dialog reopens on every single Run press. A real note hit
292
+ // this: every `net.*` call site built its URL from a variable, so the
293
+ // static scan resolved zero hosts, and the gate became an infinite loop
294
+ // around a decision with no effect.
295
+ //
296
+ // Skipping it here cannot widen anything, and the equivalence is exact
297
+ // rather than a judgement call: with `safeHosts` empty, `allowedHosts`
298
+ // is already `[]` at this point, so BOTH branches of the gate produce
299
+ // `[]`. The outcome is identical with the prompt and without it.
300
+ //
301
+ // The user is not left uninformed, per AGENTS.md's "clean is not
302
+ // silent": the request is still denied at execution time with the
303
+ // ordinary `'capability-denied'` kind, which shows the component's quiet
304
+ // failure marker and writes the full reason to the host's diagnostics
305
+ // surface — naming the host actually attempted, which is strictly more
306
+ // useful than this gate's "some address, somewhere" wording could be.
307
+ if (needsUnknownPrompt && safeHosts.length > 0) {
308
+ const allowed = await promptUnknownHosts();
309
+ if (!allowed) {
310
+ // Declining the "can't be listed in advance" gate withdraws every
311
+ // per-host answer already collected too — see this module's top doc
312
+ // comment.
313
+ finalAllowedHosts = [];
314
+ }
315
+ }
316
+ // C-3: a FULL decline (no host ended up allowed) is deliberately NOT
317
+ // persisted. Persisting `{key, allowedHosts: []}` would otherwise disable
318
+ // network for this note's exact current code FOREVER — a single
319
+ // mis-click on "Don't allow" (or declining the unknown-hosts gate) would
320
+ // silently and permanently suppress every future prompt, with no stored
321
+ // record even hinting that happened and no way to revoke it short of
322
+ // editing the code (which changes the grant key anyway). Only ever
323
+ // writing a grant with at least one allowed host means a full decline
324
+ // simply re-prompts on the next Run press, exactly like a first run — the
325
+ // ordinary, recoverable outcome. A PARTIAL grant (at least one host
326
+ // allowed) is still persisted normally, and `clearGrantForDocument` below
327
+ // gives an explicit way to revoke a persisted grant when the user wants a
328
+ // fresh prompt without changing the note's code.
329
+ if (finalAllowedHosts.length > 0) {
330
+ await writeGrant(memento, documentKey, {
331
+ key,
332
+ allowedHosts: finalAllowedHosts,
333
+ });
334
+ }
335
+ return { allowedHosts: finalAllowedHosts };
336
+ }
337
+ /**
338
+ * The NON-INTERACTIVE half of `runGrantFlow`, for auto/scheduled runs
339
+ * (GitHub issue #11): resolves this note's effective net + bundle-fs
340
+ * allowlist from ONLY what is already persisted, and NEVER prompts, NEVER
341
+ * writes. It is exactly `runGrantFlow`'s stored-grant fast path in
342
+ * isolation.
343
+ *
344
+ * This is the code half of the security promise auto/scheduled execution
345
+ * makes (docs/security.md, docs/scripting.md): a run that was not triggered
346
+ * by a click must never open a modal on a timer, and must never widen what
347
+ * the note can reach beyond a grant the user already made by hand under the
348
+ * exact same executable closure. On ANY miss — no stored grant, a grant for
349
+ * different code (the key no longer matches), or a stored host that fails
350
+ * today's `isSafeHostForPrompt` re-check (N-6) — this returns an EMPTY
351
+ * allowlist rather than falling back to prompting. An ungranted host then
352
+ * fails at execution with the ordinary `'capability-denied'` kind, exactly
353
+ * as it would on a manual run the user declined, and the consuming
354
+ * component shows its quiet stale/empty state.
355
+ *
356
+ * The read-only tier (`tierForTrigger('auto'|'scheduled')` in
357
+ * `@markii/runtime`) is a SEPARATE, independent gate applied in the worker;
358
+ * this function governs only which hosts are reachable, that one governs
359
+ * which capabilities (no POST/PATCH/bundle-write) may run at all.
360
+ */
361
+ export async function resolveStoredGrant(options) {
362
+ const empty = { allowedHosts: [] };
363
+ const stored = readGrant(options.memento, options.documentKey);
364
+ // N-6: a stored host that no longer passes today's safety re-check makes
365
+ // the whole record untrusted here, exactly as it does in `runGrantFlow` —
366
+ // but with no prompt to fall through to, an untrusted record simply yields
367
+ // the empty allowlist.
368
+ if (!stored || stored.droppedUnsafeHost)
369
+ return empty;
370
+ const key = await computeGrantKey(closureFrom(options.requirements));
371
+ if (stored.grant.key !== key)
372
+ return empty;
373
+ return { allowedHosts: [...stored.grant.allowedHosts] };
374
+ }
375
+ /**
376
+ * Clears the persisted grant (if any) for `documentKey` — the
377
+ * `markii.resetScriptGrants` command's whole implementation (C-3): with no
378
+ * stored record, `runGrantFlow`'s next call for this document is
379
+ * indistinguishable from a first run, so it prompts fresh for every host
380
+ * again. A no-op, not an error, when there is nothing stored for this
381
+ * document.
382
+ */
383
+ export async function clearGrantForDocument(memento, documentKey) {
384
+ const all = readAllGrants(memento);
385
+ if (!hasOwn(all, documentKey))
386
+ return;
387
+ const next = { ...all };
388
+ delete next[documentKey];
389
+ await memento.update(GRANTS_STORAGE_KEY, next);
390
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Address classification for the run path's network jail (GitHub issue #10).
3
+ *
4
+ * A network grant is a hostname string, so the host the user consented to and
5
+ * the address the request actually reaches are two different things. This
6
+ * module answers the second half: given an address, is it somewhere a note's
7
+ * script has any business reaching?
8
+ *
9
+ * Pure and dependency-free (the address validators are local), so it
10
+ * is exhaustively testable without a socket, a resolver, or a worker.
11
+ */
12
+ /**
13
+ * True for a dotted-quad IPv4 literal: four decimal octets, 0 to 255, with
14
+ * no leading zeros (`010.0.0.1` is refused, since some SSRF filters
15
+ * mis-parse it as decimal) and no extra characters.
16
+ */
17
+ export declare function isIPv4(value: string): boolean;
18
+ /**
19
+ * True for an IPv6 literal without a zone index: up to eight groups of one
20
+ * to four hex digits, at most one `::` run, and an optional trailing
21
+ * dotted-quad standing for the last two groups.
22
+ */
23
+ export declare function isIPv6(value: string): boolean;
24
+ /**
25
+ * Where an address lives. Everything that is not `'public'` is somewhere a
26
+ * script reaching it would be a surprise to the person who granted a
27
+ * hostname: their own machine, their own LAN, or a range that is not
28
+ * globally routable at all.
29
+ */
30
+ export type AddressScope = 'public' | 'loopback' | 'private' | 'link-local' | 'unspecified' | 'multicast' | 'reserved';
31
+ /** Every scope except `'public'`: not reachable by default from a note's script. */
32
+ export declare function isRestrictedScope(scope: AddressScope): boolean;
33
+ /** Human wording for a denial or a grant prompt. Kept here so one vocabulary describes a scope everywhere. */
34
+ export declare function describeScope(scope: AddressScope): string;
35
+ /**
36
+ * Classifies `address`, or returns `undefined` when it is not an IP literal
37
+ * at all (a hostname, or anything malformed). The local `isIPv4`/`isIPv6`
38
+ * validators decide what counts as an IP; they reject the leading-zero octet forms
39
+ * (`010.0.0.1`) that some SSRF filters mis-parse as decimal.
40
+ */
41
+ export declare function classifyIpAddress(address: string): AddressScope | undefined;