@intentic/sandbox-contract 1.244.0 → 1.246.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 (211) hide show
  1. package/README.md +17 -1
  2. package/dist/batch-runs.d.ts +32 -0
  3. package/dist/batch-runs.d.ts.map +1 -0
  4. package/dist/batch-runs.js +43 -0
  5. package/dist/batch-runs.js.map +1 -0
  6. package/dist/command-classes.d.ts +6 -3
  7. package/dist/command-classes.d.ts.map +1 -1
  8. package/dist/command-classes.js +43 -18
  9. package/dist/command-classes.js.map +1 -1
  10. package/dist/contracts/{cursor.contract.d.ts → accounts.contract.d.ts} +102 -3
  11. package/dist/contracts/accounts.contract.d.ts.map +1 -0
  12. package/dist/contracts/accounts.contract.js +61 -0
  13. package/dist/contracts/accounts.contract.js.map +1 -0
  14. package/dist/contracts/agents.contract.d.ts +121 -0
  15. package/dist/contracts/agents.contract.d.ts.map +1 -1
  16. package/dist/contracts/agents.contract.js +4 -4
  17. package/dist/contracts/agents.contract.js.map +1 -1
  18. package/dist/contracts/ci.contract.d.ts +2 -0
  19. package/dist/contracts/ci.contract.d.ts.map +1 -1
  20. package/dist/contracts/host.contract.d.ts +35 -0
  21. package/dist/contracts/host.contract.d.ts.map +1 -1
  22. package/dist/contracts/host.contract.js +3 -2
  23. package/dist/contracts/host.contract.js.map +1 -1
  24. package/dist/contracts/personas.contract.d.ts +4 -2
  25. package/dist/contracts/personas.contract.d.ts.map +1 -1
  26. package/dist/contracts/runner.contract.d.ts +2 -2
  27. package/dist/contracts/settings.contract.d.ts +2 -51
  28. package/dist/contracts/settings.contract.d.ts.map +1 -1
  29. package/dist/contracts/system.contract.d.ts +52 -2
  30. package/dist/contracts/system.contract.d.ts.map +1 -1
  31. package/dist/contracts/system.contract.js +26 -17
  32. package/dist/contracts/system.contract.js.map +1 -1
  33. package/dist/definition.d.ts +4 -8
  34. package/dist/definition.d.ts.map +1 -1
  35. package/dist/documents.d.ts +0 -1
  36. package/dist/documents.d.ts.map +1 -1
  37. package/dist/documents.js +1 -2
  38. package/dist/documents.js.map +1 -1
  39. package/dist/embed.d.ts +23 -0
  40. package/dist/embed.d.ts.map +1 -0
  41. package/dist/embed.js +84 -0
  42. package/dist/embed.js.map +1 -0
  43. package/dist/events.d.ts +21 -0
  44. package/dist/events.d.ts.map +1 -1
  45. package/dist/events.js +5 -2
  46. package/dist/events.js.map +1 -1
  47. package/dist/history-state.d.ts.map +1 -1
  48. package/dist/history-state.js +2 -0
  49. package/dist/history-state.js.map +1 -1
  50. package/dist/index.d.ts +354 -335
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +5 -13
  53. package/dist/index.js.map +1 -1
  54. package/dist/peer-dial.d.ts +33 -0
  55. package/dist/peer-dial.d.ts.map +1 -0
  56. package/dist/peer-dial.js +79 -0
  57. package/dist/peer-dial.js.map +1 -0
  58. package/dist/peer-mcp-server.d.ts +36 -0
  59. package/dist/peer-mcp-server.d.ts.map +1 -0
  60. package/dist/peer-mcp-server.js +71 -0
  61. package/dist/peer-mcp-server.js.map +1 -0
  62. package/dist/provider-specs.d.ts +38 -20
  63. package/dist/provider-specs.d.ts.map +1 -1
  64. package/dist/provider-specs.js +39 -13
  65. package/dist/provider-specs.js.map +1 -1
  66. package/dist/quick-model.d.ts.map +1 -1
  67. package/dist/quick-model.js +2 -1
  68. package/dist/quick-model.js.map +1 -1
  69. package/dist/runtime-state.d.ts +1 -1
  70. package/dist/runtime-state.js +1 -1
  71. package/dist/runtime-state.js.map +1 -1
  72. package/dist/safety-policy.d.ts +12 -3
  73. package/dist/safety-policy.d.ts.map +1 -1
  74. package/dist/safety-policy.js +30 -5
  75. package/dist/safety-policy.js.map +1 -1
  76. package/dist/schemas/agent.d.ts +6 -6
  77. package/dist/schemas/agent.d.ts.map +1 -1
  78. package/dist/schemas/agent.js +6 -2
  79. package/dist/schemas/agent.js.map +1 -1
  80. package/dist/schemas/agents.d.ts +42 -0
  81. package/dist/schemas/agents.d.ts.map +1 -1
  82. package/dist/schemas/agents.js +25 -4
  83. package/dist/schemas/agents.js.map +1 -1
  84. package/dist/schemas/automations.d.ts +11 -2
  85. package/dist/schemas/automations.d.ts.map +1 -1
  86. package/dist/schemas/automations.js +1 -1
  87. package/dist/schemas/automations.js.map +1 -1
  88. package/dist/schemas/ci.d.ts +6 -0
  89. package/dist/schemas/ci.d.ts.map +1 -1
  90. package/dist/schemas/ci.js +3 -2
  91. package/dist/schemas/ci.js.map +1 -1
  92. package/dist/schemas/context.d.ts +30 -0
  93. package/dist/schemas/context.d.ts.map +1 -0
  94. package/dist/schemas/context.js +34 -0
  95. package/dist/schemas/context.js.map +1 -0
  96. package/dist/schemas/{computers.d.ts → devices.d.ts} +155 -61
  97. package/dist/schemas/devices.d.ts.map +1 -0
  98. package/dist/schemas/devices.js +157 -0
  99. package/dist/schemas/devices.js.map +1 -0
  100. package/dist/schemas/hosts.d.ts +12 -0
  101. package/dist/schemas/hosts.d.ts.map +1 -1
  102. package/dist/schemas/hosts.js +1 -0
  103. package/dist/schemas/hosts.js.map +1 -1
  104. package/dist/schemas/issues.d.ts +0 -5
  105. package/dist/schemas/issues.d.ts.map +1 -1
  106. package/dist/schemas/issues.js +0 -1
  107. package/dist/schemas/issues.js.map +1 -1
  108. package/dist/schemas/personas.d.ts +5 -3
  109. package/dist/schemas/personas.d.ts.map +1 -1
  110. package/dist/schemas/personas.js +3 -2
  111. package/dist/schemas/personas.js.map +1 -1
  112. package/dist/schemas/provider-oauth.d.ts +48 -16
  113. package/dist/schemas/provider-oauth.d.ts.map +1 -1
  114. package/dist/schemas/provider-oauth.js +22 -20
  115. package/dist/schemas/provider-oauth.js.map +1 -1
  116. package/dist/schemas/settings.d.ts +1 -52
  117. package/dist/schemas/settings.d.ts.map +1 -1
  118. package/dist/schemas/settings.js +8 -11
  119. package/dist/schemas/settings.js.map +1 -1
  120. package/dist/schemas/terminal.js +9 -9
  121. package/dist/schemas/terminal.js.map +1 -1
  122. package/dist/schemas/usage.d.ts +0 -2
  123. package/dist/schemas/usage.d.ts.map +1 -1
  124. package/dist/schemas/usage.js +0 -2
  125. package/dist/schemas/usage.js.map +1 -1
  126. package/dist/shell-regions.d.ts +4 -0
  127. package/dist/shell-regions.d.ts.map +1 -0
  128. package/dist/shell-regions.js +156 -0
  129. package/dist/shell-regions.js.map +1 -0
  130. package/dist/workspace-state.d.ts +15 -0
  131. package/dist/workspace-state.d.ts.map +1 -1
  132. package/dist/workspace-state.js +21 -5
  133. package/dist/workspace-state.js.map +1 -1
  134. package/package.json +48 -4
  135. package/src/agent-catalog.ts +1 -1
  136. package/src/arrival.ts +3 -3
  137. package/src/batch-runs.test.ts +167 -0
  138. package/src/batch-runs.ts +181 -0
  139. package/src/command-classes.test.ts +195 -71
  140. package/src/command-classes.ts +148 -46
  141. package/src/contracts/accounts.contract.ts +94 -0
  142. package/src/contracts/agents.contract.ts +4 -3
  143. package/src/contracts/exit.contract.ts +2 -2
  144. package/src/contracts/host.contract.ts +17 -5
  145. package/src/contracts/settings.contract.ts +1 -1
  146. package/src/contracts/system.contract.ts +43 -24
  147. package/src/contracts/vpn.contract.ts +2 -2
  148. package/src/documents.test.ts +2 -1
  149. package/src/documents.ts +7 -11
  150. package/src/embed.test.ts +68 -0
  151. package/src/embed.ts +164 -0
  152. package/src/events.ts +30 -3
  153. package/src/history-state.ts +12 -3
  154. package/src/host-protocol.ts +2 -2
  155. package/src/index.ts +6 -14
  156. package/src/model-order.ts +1 -1
  157. package/src/peer-dial.test.ts +203 -0
  158. package/src/peer-dial.ts +163 -0
  159. package/src/peer-mcp-server.test.ts +104 -0
  160. package/src/peer-mcp-server.ts +144 -0
  161. package/src/provider-specs.test.ts +44 -17
  162. package/src/provider-specs.ts +147 -67
  163. package/src/quick-model.ts +10 -3
  164. package/src/routes.test.ts +6 -3
  165. package/src/runner-protocol.ts +1 -1
  166. package/src/runtime-state.ts +2 -2
  167. package/src/safety-policy.test.ts +88 -0
  168. package/src/safety-policy.ts +84 -14
  169. package/src/schemas/agent.ts +41 -13
  170. package/src/schemas/agents.ts +65 -4
  171. package/src/schemas/automations.ts +6 -4
  172. package/src/schemas/capabilities.ts +4 -4
  173. package/src/schemas/ci.ts +23 -6
  174. package/src/schemas/context.ts +87 -0
  175. package/src/schemas/{computers.ts → devices.ts} +201 -109
  176. package/src/schemas/hosts.ts +5 -1
  177. package/src/schemas/issues.ts +0 -4
  178. package/src/schemas/personas.ts +8 -3
  179. package/src/schemas/provider-oauth.ts +49 -52
  180. package/src/schemas/settings.ts +30 -54
  181. package/src/schemas/terminal.ts +12 -12
  182. package/src/schemas/usage.ts +3 -27
  183. package/src/schemas/version-seam.test.ts +0 -1
  184. package/src/shell-regions.ts +289 -0
  185. package/src/versions.ts +2 -2
  186. package/src/webext-links.ts +2 -2
  187. package/src/webext-protocol.ts +2 -2
  188. package/src/workspace-state.test.ts +55 -1
  189. package/src/workspace-state.ts +76 -11
  190. package/dist/contracts/claude.contract.d.ts +0 -91
  191. package/dist/contracts/claude.contract.d.ts.map +0 -1
  192. package/dist/contracts/claude.contract.js +0 -50
  193. package/dist/contracts/claude.contract.js.map +0 -1
  194. package/dist/contracts/cursor.contract.d.ts.map +0 -1
  195. package/dist/contracts/cursor.contract.js +0 -50
  196. package/dist/contracts/cursor.contract.js.map +0 -1
  197. package/dist/contracts/grok.contract.d.ts +0 -36
  198. package/dist/contracts/grok.contract.d.ts.map +0 -1
  199. package/dist/contracts/grok.contract.js +0 -31
  200. package/dist/contracts/grok.contract.js.map +0 -1
  201. package/dist/contracts/keys.contract.d.ts +0 -81
  202. package/dist/contracts/keys.contract.d.ts.map +0 -1
  203. package/dist/contracts/keys.contract.js +0 -51
  204. package/dist/contracts/keys.contract.js.map +0 -1
  205. package/dist/schemas/computers.d.ts.map +0 -1
  206. package/dist/schemas/computers.js +0 -134
  207. package/dist/schemas/computers.js.map +0 -1
  208. package/src/contracts/claude.contract.ts +0 -71
  209. package/src/contracts/cursor.contract.ts +0 -74
  210. package/src/contracts/grok.contract.ts +0 -41
  211. package/src/contracts/keys.contract.ts +0 -79
@@ -1,10 +1,12 @@
1
- import { type CommandClass, CommandClassSchema } from "./schemas/agent.js";
1
+ import { HISTORY_ROOT, WORKSPACE_ROOT } from "@intentic/constants";
2
+ import { type CommandClass, CommandClassSchema, type CommandLocus } from "./schemas/agent.js";
3
+ import { inertRegions, isLive } from "./shell-regions.js";
2
4
 
3
5
  /* WHICH CLASSES A SHELL COMMAND FALLS IN, the classifier behind every command gate, read before the command runs.
4
6
  *
5
7
  * IT LIVES IN THE CONTRACT PACKAGE because there are TWO enforcement points and they must not drift. The
6
8
  * sandbox's own gate (sandbox/src/guard/command-gate.ts) judges what the agent types here; the machine agent's
7
- * shell tool (_computers/machine/src/computer/tools/shell.ts) judges what it sends to somebody's laptop. Those answer to
9
+ * shell tool (_devices/machine/src/device/tools/shell.ts) judges what it sends to somebody's laptop. Those answer to
8
10
  * different policy (a rulebook with a permission card here, a scope switch on the card there) but they must
9
11
  * agree on WHAT A COMMAND IS, or a class the owner thought they had covered turns out to mean something else on
10
12
  * the surface where it mattered most. One table, two consults.
@@ -18,7 +20,7 @@ import { type CommandClass, CommandClassSchema } from "./schemas/agent.js";
18
20
  * A creatively quoted command, a path assembled from a variable, or a script written in one call and run in the
19
21
  * next goes past it untouched. So a gate built on this is friction and a prompt for well-behaved work, never a
20
22
  * boundary, the boundaries are structural and elsewhere: the container, the isolated worktree, the land gate,
21
- * an automation's tool allowlist, and, on somebody's own computer, the scope switches enforced there.
23
+ * an automation's tool allowlist, and, on somebody's own device, the scope switches enforced there.
22
24
  *
23
25
  * WHICH IS WHY THIS NO LONGER DECIDES ANYTHING. A match used to BE the verdict: whatever fired here became the
24
26
  * permission card, so `echo "rm -rf /"`, `rg 'rm -rf'` and an actual delete were one question with one answer.
@@ -28,6 +30,18 @@ import { type CommandClass, CommandClassSchema } from "./schemas/agent.js";
28
30
  * positive now costs one model call rather than one interruption, and a miss still costs everything. Anyone
29
31
  * tuning a pattern below should widen rather than narrow it.
30
32
  *
33
+ * WITH ONE EXCEPTION, AND IT IS WHY `live` EXISTS. The hard rule (safety-policy.ts hardRuleClasses) still turns
34
+ * a match straight into an interruption nothing can waive, so for the classes it covers a false positive costs
35
+ * exactly what it always did. That left the old failure standing in the one place it could not be argued out
36
+ * of: `echo "rm -rf /" >> notes.md` and `rg 'rm -rf /'` were un-waivable cards over a string. So every match
37
+ * carries whether a shell would RUN the fragment or merely print, search or write it (shell-regions.ts), and
38
+ * only the hard rule reads that bit. The tables stay over-inclusive; the un-waivable tier stops firing on text.
39
+ *
40
+ * AND IT IS ASKED OF A PLACE. `rm -rf /usr` ends a laptop and costs a container nothing, because a container is
41
+ * rebuilt from an image; a Docker volume here is a dev database the agent made and there is the owner's data.
42
+ * Half the tables below therefore have two answers, chosen by CommandContext.locus, and the caller must say
43
+ * which machine it is asking about. schemas/agent.ts CommandLocus argues the split.
44
+ *
31
45
  * Matching is deliberately UNANCHORED, substrings, not line starts. Another PreToolUse hook may have rewrapped
32
46
  * the command by the time this reads it (agent-terminals.ts wraps every Bash call in bin/tmux-run), and the
33
47
  * agent's own line survives verbatim inside that wrapper. Nothing the wrapper adds is in any class below.
@@ -58,16 +72,30 @@ export interface CommandSpan {
58
72
  readonly end: number;
59
73
  }
60
74
 
61
- // One class the command fell in, and the fragments that put it there. `spans` is never empty: a class with
62
- // nothing to point at is a class this walk does not report.
75
+ /* One class the command fell in, and the fragments that put it there. `spans` is never empty: a class with
76
+ * nothing to point at is a class this walk does not report. */
63
77
  export interface CommandMatch {
64
78
  readonly commandClass: CommandClass;
65
79
  readonly spans: readonly CommandSpan[];
80
+ /* Would a shell RUN any of those fragments, or are they all text — a heredoc body, a comment, a quoted
81
+ * argument to echo or a grep (shell-regions.ts says how that is decided and how wrong it is allowed to be)?
82
+ *
83
+ * ONLY THE HARD RULE READS THIS, and that is the whole point of it being a flag on the match rather than a
84
+ * filter over the tables. A mention still puts the command in the class, still reaches the judge, and is
85
+ * still marked on the card: the judge is the tier that can tell a README from a delete, and taking the
86
+ * class away would take the question away from it. What a mention must NOT do is trip the one tier that
87
+ * cannot be argued with, which is what `echo "rm -rf /" >> notes.md` used to do. */
88
+ readonly live: boolean;
66
89
  }
67
90
 
68
91
  /* WHAT THE CALLER CAN CHECK THAT THE PATTERNS CANNOT. Optional everywhere: absent ⇒ every table answers from
69
92
  * the command text alone, which is what the browser, the machine agent and every test that does not care get. */
70
93
  export interface CommandContext {
94
+ /* WHERE THIS COMMAND WOULD RUN, and it is the one field with no default. Half this catalog means something
95
+ * different on a disposable container than on somebody's laptop — which directories are roots, whether a
96
+ * Docker volume is a dev database or their data — and a default would be one of those two answers applied
97
+ * silently to the other machine. Callers state it; schemas/agent.ts CommandLocus argues the split. */
98
+ readonly locus: CommandLocus;
71
99
  /* Does the file at this path — as the command spells it, `~/.npmrc`, `.env`, `/work/app/.env.local` — hold
72
100
  * credential material? (credential-material.ts says what that means; the caller says how to read a file.)
73
101
  *
@@ -251,16 +279,38 @@ const parseRm = (command: string): RmInvocation[] => {
251
279
  };
252
280
 
253
281
  /* A TARGET THAT IS A ROOT RATHER THAN SOMETHING INSIDE ONE. This is the whole difference between the two
254
- * deletion classes, so it is deliberately a short, closed list of whole names rather than a clever heuristic:
255
- * the filesystem root and the top-level directories an OS keeps, the two trees this product keeps state in, a
256
- * home directory however it is spelled, and a Windows drive.
282
+ * deletion classes, so it is deliberately a short, closed list of whole names rather than a clever heuristic.
257
283
  *
258
- * `/tmp` is deliberately absent. It is scratch by definition and emptying it is a chore, not an incident. */
259
- const ROOT_DIRECTORIES = new Set([
260
- // The empty string is what the filesystem root normalizes to, see trimTarget.
284
+ * AND IT IS A DIFFERENT LIST PER LOCUS, because "root" means "the thing nothing here brings back" and the two
285
+ * machines answer that very differently. Everything below is measured against one question: after this delete,
286
+ * what restores it?
287
+ *
288
+ * `/tmp` is deliberately absent from both. It is scratch by definition and emptying it is a chore, not an
289
+ * incident. */
290
+
291
+ /* IN THIS SANDBOX: two entries, and the shortness is the point rather than an oversight.
292
+ *
293
+ * `` the filesystem root itself (what trimTarget normalizes `/` to). Nothing restores it.
294
+ * /history every OTHER conversation's worktrees and logs. THIS turn cannot recreate them at any price,
295
+ * which is exactly what makes it a root here and the reason it outranks anything in /work.
296
+ *
297
+ * WHAT IS DELIBERATELY NOT HERE, and each was hard-ruled before this: `/usr`, `/etc`, `/bin`, `/var`, `/opt`
298
+ * and the rest of the container's OS come back with the image — recreating this sandbox is a documented
299
+ * operation, not a catastrophe. `/work` is a git worktree whose delta lands as uncommitted changes, and it is
300
+ * also the directory an agent has the most legitimate reason to clear. `/Users`, `/Applications`, `/System`,
301
+ * `/Library` and a Windows drive do not exist in this container at all, so holding them here bought nothing and
302
+ * cost a card. All of them are still files.destructive, still triaged, still judged — they have simply stopped
303
+ * being un-waivable. */
304
+ const SANDBOX_ROOTS = new Set(["", HISTORY_ROOT]);
305
+
306
+ /* ON THE OWNER'S OWN COMPUTER: the full list, because nothing there is rebuilt from an image and there is no
307
+ * checkpoint under any of it. The filesystem root, the top-level directories an OS keeps, both of this
308
+ * product's own trees (a device may be running one), a home directory however it is spelled, and a Windows
309
+ * drive. Unchanged from what this catalog held before the split. */
310
+ const DEVICE_ROOTS = new Set([
261
311
  "",
262
- "/work",
263
- "/history",
312
+ WORKSPACE_ROOT,
313
+ HISTORY_ROOT,
264
314
  "/home",
265
315
  "/root",
266
316
  "/etc",
@@ -284,6 +334,8 @@ const ROOT_DIRECTORIES = new Set([
284
334
  "/Library",
285
335
  ]);
286
336
 
337
+ const rootsAt = (locus: CommandLocus): ReadonlySet<string> => (locus === "sandbox" ? SANDBOX_ROOTS : DEVICE_ROOTS);
338
+
287
339
  const HOME_ALIAS = /^(?:~|\$HOME|\$\{HOME\}|%USERPROFILE%)$/;
288
340
  const WINDOWS_DRIVE = /^[A-Za-z]:$/;
289
341
 
@@ -293,14 +345,16 @@ const WINDOWS_DRIVE = /^[A-Za-z]:$/;
293
345
  * case this class exists to stay out of the way of. */
294
346
  const trimTarget = (operand: string): string => operand.replace(/[/\\]\*+$/, "").replace(/[/\\]+$/, "");
295
347
 
296
- const isRootTarget = (operand: string): boolean => {
348
+ const isRootTarget = (operand: string, locus: CommandLocus): boolean => {
297
349
  const target = trimTarget(operand);
350
+ /* A home directory and a Windows drive are roots on a DEVICE only. In the container `~` is the agent's own
351
+ * scratch home, rebuilt with the image, and `C:` is not a path that exists. */
298
352
  if (HOME_ALIAS.test(target) || WINDOWS_DRIVE.test(target)) {
299
- return true;
353
+ return locus === "device";
300
354
  }
301
355
  // Only an absolute path can name a root, and `""` is the root itself. A relative path is inside whatever
302
356
  // the shell is standing in, which this cannot know and must not guess about.
303
- return target === "" ? operand.startsWith("/") || operand.startsWith("\\") : ROOT_DIRECTORIES.has(target);
357
+ return target === "" ? operand.startsWith("/") || operand.startsWith("\\") : rootsAt(locus).has(target);
304
358
  };
305
359
 
306
360
  /* --- the JS execution backend's own deletes -------------------------------------------------------------
@@ -355,9 +409,13 @@ const nodeDeleteTargets = (program: string): { readonly target: string; readonly
355
409
  * it), `docker image prune` (pull it again), `git reset --hard` (that is git.destructive, and the reflog has
356
410
  * it), `rm -rf node_modules` (install it again). The point of a floor is that it is rare enough to be worth
357
411
  * stopping for; a floor that fires on ordinary work is one people learn to click through. */
358
- const SYSTEM_DESTRUCTIVE = [
359
- // Format, wipe or overwrite a block device. `dd` only counts when it is pointed AT a device: reading one
360
- // into a file is how an image is taken, and holding a backup would be exactly the wrong lesson.
412
+ /* A BLOCK DEVICE, FORMATTED, WIPED OR OVERWRITTEN. The only membership of system.destructive that does not
413
+ * depend on where the command runs: there is no image, checkpoint or worktree behind a disk at either locus,
414
+ * and this is what the shipped safety policy has always told the owner it holds unconditionally.
415
+ *
416
+ * `dd` only counts when it is pointed AT a device: reading one into a file is how an image is taken, and
417
+ * holding a backup would be exactly the wrong lesson. */
418
+ const BLOCK_DEVICE = [
361
419
  /\bmkfs(?:\.\w+)?\b/,
362
420
  /\bwipefs\b/,
363
421
  /\bblkdiscard\b/,
@@ -366,11 +424,22 @@ const SYSTEM_DESTRUCTIVE = [
366
424
  /\bshred\b[^|;&]*\s\/dev\//,
367
425
  // A redirect straight onto a disk device, which is the same wipe without the ceremony.
368
426
  />\s*\/dev\/(?:[shv]d[a-z]|nvme\d|disk\d|mmcblk\d)/,
369
- /* Docker state that is data rather than image. A named volume IS the database; `system prune` takes every
370
- * unused one with it, and `compose down -v` is the spelling people reach for without reading the flag.
371
- * In this sandbox these hit the nested engine (the host's socket is never mounted, see
372
- * capabilities/handlers/docker.ts), so the blast radius is the dev databases the agent has been working
373
- * against. Sent to somebody's own computer through the host agent, it is whatever they run on it. */
427
+ ];
428
+
429
+ /* CONTAINER STATE THAT IS DATA RATHER THAN IMAGE, its own class (container.state) because the two loci
430
+ * disagree about it more sharply than about anything else in this catalog.
431
+ *
432
+ * IN THIS SANDBOX these reach the NESTED engine — the host's Docker socket is never mounted, see
433
+ * capabilities/handlers/docker.ts — so the volumes in reach are the ones the agent itself created, and tearing
434
+ * down a smoke-test stack it just brought up is ordinary work. Holding it as an un-waivable card was the
435
+ * concrete complaint that produced this split: `docker volume rm` on a throwaway test container is not the
436
+ * class of thing a person needs woken for.
437
+ *
438
+ * ON SOMEBODY'S OWN COMPUTER a named volume IS the database, and the owner's policy says never. It stays
439
+ * hard-ruled there (safety-policy.ts hardRuleClasses), and the machine's own `destructive` scope sits under
440
+ * that (machine/src/device/tools/shell.ts GATED_CLASSES), which is the part that is a boundary rather than
441
+ * friction. */
442
+ const CONTAINER_STATE = [
374
443
  /\b(?:docker|podman)\s+volume\s+(?:rm|remove|prune)\b/,
375
444
  /\b(?:docker|podman)\s+system\s+prune\b/,
376
445
  /\b(?:docker(?:\s+compose|-compose)?|podman-compose)\s+down\b[^|;&]*\s(?:-v\b|--volumes\b)/,
@@ -381,25 +450,27 @@ const recursiveForceRms = (command: string): CommandSpan[] =>
381
450
  .filter((invocation) => invocation.recursive && invocation.force)
382
451
  .map((invocation) => invocation.span);
383
452
 
384
- // A recursive delete aimed at a root, in either spelling the gate can be handed: the shell's `rm -rf /` and
385
- // the script's `fs.rmSync("/", { recursive: true })`.
386
- const rootDeletes = (program: string): CommandSpan[] => [
453
+ /* A recursive delete aimed at a root, in either spelling the gate can be handed: the shell's `rm -rf /` and
454
+ * the script's `fs.rmSync("/", { recursive: true })`. Which targets count as roots is the locus's answer, so
455
+ * `rm -rf /usr` is this class on a laptop and merely files.destructive in a container built from an image. */
456
+ const rootDeletes = (program: string, locus: CommandLocus): CommandSpan[] => [
387
457
  ...parseRm(program)
388
- .filter((invocation) => invocation.recursive && invocation.force && invocation.operands.some(isRootTarget))
458
+ .filter((invocation) => invocation.recursive && invocation.force && invocation.operands.some((operand) => isRootTarget(operand, locus)))
389
459
  .map((invocation) => invocation.span),
390
460
  ...nodeDeleteTargets(program)
391
- .filter((delete_) => isRootTarget(delete_.target))
461
+ .filter((delete_) => isRootTarget(delete_.target, locus))
392
462
  .map((delete_) => delete_.span),
393
463
  ];
394
464
 
395
465
  // The `g` twins, built once at load rather than per call: a card is minted per held command and a classify runs
396
- // per command the agent types, so recompiling six tables of patterns each time is work with no reader.
466
+ // per command the agent types, so recompiling seven tables of patterns each time is work with no reader.
397
467
  const GIT_DESTRUCTIVE_G = globally(GIT_DESTRUCTIVE);
398
468
  const SECRET_REFERENCES_G = globally(SECRET_REFERENCES);
399
469
  const CREDENTIAL_PATHS_G = globally(CREDENTIAL_PATHS);
400
470
  const PACKAGE_PUBLISH_G = globally(PACKAGE_PUBLISH);
401
471
  const NETWORK_OUTBOUND_G = globally(NETWORK_OUTBOUND);
402
- const SYSTEM_DESTRUCTIVE_G = globally(SYSTEM_DESTRUCTIVE);
472
+ const BLOCK_DEVICE_G = globally(BLOCK_DEVICE);
473
+ const CONTAINER_STATE_G = globally(CONTAINER_STATE);
403
474
 
404
475
  /* THE PATH A MATCHED FRAGMENT SITS IN, so the oracle is asked about the file the command would actually open
405
476
  * rather than about the suffix that fired: `sed 's/…/' ~/.npmrc` fires on `.npmrc` and must ask about
@@ -463,20 +534,21 @@ const namesAPattern = (word: string): boolean =>
463
534
  * `!== false` is the whole fact-check, and the comparison is written against `false` rather than for `true` on
464
535
  * purpose: `undefined` (nobody could look) has to behave like `true` (there is a credential in there), or the
465
536
  * class would evaporate on every caller without a filesystem. */
466
- const credentialReads = (command: string, context: CommandContext | undefined): CommandSpan[] => [
537
+ const credentialReads = (command: string, context: CommandContext): CommandSpan[] => [
467
538
  ...spansOf(SECRET_REFERENCES_G, command),
468
539
  ...spansOf(CREDENTIAL_PATHS_G, command).filter((span) => {
469
540
  const word = enclosingPath(command, span);
470
- return !namesAPattern(word) && context?.holdsSecret?.(word) !== false;
541
+ return !namesAPattern(word) && context.holdsSecret?.(word) !== false;
471
542
  }),
472
543
  ];
473
544
 
474
545
  // WHERE each class fires, one entry per class. Empty ⇒ the command is not in it, so membership and evidence are
475
546
  // the same walk and cannot disagree: there is no way to be held for a class with nothing to show for it.
476
- const MATCHES: Readonly<Record<CommandClass, (command: string, context: CommandContext | undefined) => CommandSpan[]>> = {
547
+ const MATCHES: Readonly<Record<CommandClass, (command: string, context: CommandContext) => CommandSpan[]>> = {
477
548
  "git.destructive": (command) => spansOf(GIT_DESTRUCTIVE_G, command),
478
549
  "files.destructive": (command) => [...recursiveForceRms(command), ...recursiveDeletes(command)],
479
- "system.destructive": (command) => [...spansOf(SYSTEM_DESTRUCTIVE_G, command), ...rootDeletes(command)],
550
+ "system.destructive": (command, context) => [...spansOf(BLOCK_DEVICE_G, command), ...rootDeletes(command, context.locus)],
551
+ "container.state": (command) => spansOf(CONTAINER_STATE_G, command),
480
552
  "secrets.access": credentialReads,
481
553
  "package.publish": (command) => spansOf(PACKAGE_PUBLISH_G, command),
482
554
  "network.outbound": (command) => spansOf(NETWORK_OUTBOUND_G, command),
@@ -485,31 +557,61 @@ const MATCHES: Readonly<Record<CommandClass, (command: string, context: CommandC
485
557
  /* Every class the command falls in AND the fragments that put it there, in the catalog's own order so a card and
486
558
  * a log name them the same way twice. The primitive; classifyCommand is this with the offsets dropped.
487
559
  *
488
- * `context` is what a caller that can check a fact hands in (CommandContext); omitting it classifies from the
489
- * command text alone, which is every caller that has no filesystem to consult. */
490
- export const matchCommand = (command: string, context?: CommandContext): CommandMatch[] =>
491
- CommandClassSchema.options.flatMap((commandClass) => {
560
+ * `context` is REQUIRED, unlike before: its `locus` decides what half of this catalog means (see
561
+ * CommandContext), and the fact-check is the optional part of it.
562
+ *
563
+ * THE INERT SCAN RUNS ONCE, here, and is handed to every class rather than being redone per table: it walks the
564
+ * whole command, and seven walks would be six more than the answer needs. A class with no live span is still
565
+ * reported — `live` rides on the match and only the hard rule reads it (shell-regions.ts argues why). */
566
+ export const matchCommand = (command: string, context: CommandContext): CommandMatch[] => {
567
+ const regions = inertRegions(command);
568
+ return CommandClassSchema.options.flatMap((commandClass) => {
492
569
  const spans = mergeSpans(MATCHES[commandClass](command, context));
493
- return spans.length === 0 ? [] : [{ commandClass, spans }];
570
+ return spans.length === 0 ? [] : [{ commandClass, spans, live: spans.some((span) => isLive(span, regions)) }];
494
571
  });
572
+ };
495
573
 
496
574
  // Every class the command falls in, for the callers that only take a verdict from it (the gate's rulebook
497
575
  // consult, the machine agent's scope switch).
498
- export const classifyCommand = (command: string, context?: CommandContext): CommandClass[] =>
576
+ export const classifyCommand = (command: string, context: CommandContext): CommandClass[] =>
499
577
  matchCommand(command, context).map((match) => match.commandClass);
500
578
 
501
579
  // What the card says the command would DO. The class name is a settings key, not a sentence to show a person.
502
580
  export const COMMAND_CLASS_LABELS: Readonly<Record<CommandClass, string>> = {
503
581
  "git.destructive": "rewrite or discard git history",
504
582
  "files.destructive": "delete files recursively",
505
- "system.destructive": "wipe a disk, a container volume, or a whole home or root directory",
583
+ "system.destructive": "wipe a disk, or delete a whole root directory",
584
+ "container.state": "delete a container volume or the data in it",
506
585
  "secrets.access": "read credential material",
507
586
  "package.publish": "publish or release a package",
508
587
  "network.outbound": "send a request out to the internet",
509
588
  };
510
589
 
590
+ /* THE PATTERNS BEHIND EACH CLASS, IN WORDS, for the one reader that is a person rather than a gate: the Safety
591
+ * page's "What gets stopped" panel (editor/web/.../AgentSafetyRules.vue). A card says which class fired; this
592
+ * says what the class is, so an owner can see the whole catalog without reading this file.
593
+ *
594
+ * PROSE RATHER THAN THE REGEXES THEMSELVES, deliberately. A `/\b(?:docker|podman)\s+volume\s+(?:rm|remove|prune)\b/`
595
+ * on a settings page is a worse answer to "what stops my commands" than "docker volume rm, remove, prune" is,
596
+ * and rendering source at somebody implies they can edit it. Pinned to the tables by the conformance test in
597
+ * command-classes.test.ts, so a pattern added without a line here fails the suite rather than going unlisted. */
598
+ export const COMMAND_CLASS_PATTERNS: Readonly<Record<CommandClass, readonly string[]>> = {
599
+ "git.destructive": ["git push --force / -f / --force-with-lease / --delete", "git reset --hard", "git clean -f", "git branch -D", "git filter-branch"],
600
+ "files.destructive": ["rm -rf <path>", "fs.rm / rmSync / rmdir with recursive: true", "rimraf(<path>)"],
601
+ "system.destructive": [
602
+ "mkfs, wipefs, blkdiscard, sgdisk --zap-all",
603
+ "dd of=/dev/…, shred /dev/…, > /dev/sda",
604
+ "rm -rf aimed at a root directory",
605
+ ],
606
+ "container.state": ["docker / podman volume rm, remove, prune", "docker / podman system prune", "docker compose down -v"],
607
+ "secrets.access": ["a {{secret:NAME}} reference in the command", ".env, .ssh/*, id_rsa, .aws/credentials, .npmrc, .git-credentials"],
608
+ "package.publish": ["npm / pnpm / yarn / bun publish", "cargo publish", "gh release create", "docker push", "twine upload"],
609
+ "network.outbound": ["curl / wget to a non-loopback https:// host", 'fetch("https://…") in a script'],
610
+ };
611
+
511
612
  /* No verdict set lives here any more. Which classes are worth stopping for is a POLICY question now, and it is
512
- * answered in two places that are honest about being different: safety-policy.ts's HARD_RULE_CLASSES for the
513
- * one thing nothing recovers, and the owner's own written policy for everything else. The machine agent keeps
514
- * its own set beside its scope switches (machine/src/computer/tools/shell.ts), because "which commands need
515
- * the destructive switch" is a question about that capability card rather than about this catalog. */
613
+ * answered in two places that are honest about being different: safety-policy.ts's hardRuleClasses for the
614
+ * things nothing recovers at a given locus, and the owner's own written policy for everything else. The machine
615
+ * agent keeps its own set beside its scope switches (machine/src/device/tools/shell.ts), because "which
616
+ * commands need the destructive switch" is a question about that capability card rather than about this
617
+ * catalog. */
@@ -0,0 +1,94 @@
1
+ import { oc } from "@orpc/contract";
2
+ import { NativeProviderParamSchema } from "../schemas/agent.js";
3
+ import {
4
+ AccountIdSchema,
5
+ AccountListQuerySchema,
6
+ AccountRenameSchema,
7
+ LoginCancelSchema,
8
+ LoginCompletedSchema,
9
+ LoginCompleteSchema,
10
+ LoginRequestSchema,
11
+ LoginStartSchema,
12
+ OauthAccountListSchema,
13
+ OauthAccountSchema,
14
+ } from "../schemas/provider-oauth.js";
15
+ import { OkSchema } from "../schemas/shared.js";
16
+
17
+ /* THE ACCOUNTS THIS SANDBOX HOLDS ITSELF, one route family with the provider as a PARAMETER.
18
+ *
19
+ * Four families used to serve this (Claude's, Cursor's, Grok's, and one for every provider whose sign-in mints
20
+ * its own key), each "the previous one's shape" with a verb renamed: `oauth/start` here, `login/start` there,
21
+ * an `exchange` for the flow whose proof travelled and a `complete` for the one whose proof did not. The
22
+ * lesson providers.contract.ts learned about model catalogs holds for accounts too: the operations are the
23
+ * same six for all of them, so the id is a parameter and a provider's own mechanism is its module's to declare
24
+ * (the daemon's ProviderModule.accounts), not a vertical slice through the contract, the router and every test
25
+ * double.
26
+ *
27
+ * WHY NOT THE TRANSLATOR ROUTES. Those address subscriptions CLIProxyAPI holds and re-serves behind an Anthropic
28
+ * endpoint; these credentials live in this daemon's own auth tree (or, for Grok, in OpenCode's), and a
29
+ * disconnect here removes a file rather than asking a proxy to forget one. A provider served both ways (Grok)
30
+ * has a row in each.
31
+ *
32
+ * Nothing redeemable is on any answer here: `accounts` is an OauthAccount list, whose shape has no field a
33
+ * credential could ride in, and a sign-in's proof never leaves the sandbox (provider-oauth.ts, LoginStart). */
34
+ export const accountsContract = {
35
+ start: oc
36
+ .route({
37
+ method: "POST",
38
+ path: "/accounts/{provider}/login/start",
39
+ summary: "Begin connecting an account",
40
+ description:
41
+ "Hands back the page to sign in on, and the code it will ask for where there is one. The sandbox holds the proof and finishes what it can itself: a device sign-in lands in the account list on its own, a paste or a redirect needs one thing brought back to the finishing call.",
42
+ })
43
+ .input(NativeProviderParamSchema.extend(LoginRequestSchema.shape))
44
+ .output(LoginStartSchema),
45
+ complete: oc
46
+ .route({
47
+ method: "POST",
48
+ path: "/accounts/{provider}/login/complete",
49
+ summary: "Finish a sign-in with what the page handed back",
50
+ description:
51
+ "Takes the code the page showed, or the address a redirect landed on, and finishes the attempt. Answers with the account where the exchange ends here; otherwise the sandbox still has a mint to do and the row appears in the account list.",
52
+ })
53
+ .input(NativeProviderParamSchema.extend(LoginCompleteSchema.shape))
54
+ .output(LoginCompletedSchema),
55
+ cancel: oc
56
+ .route({
57
+ method: "POST",
58
+ path: "/accounts/{provider}/login/cancel",
59
+ summary: "Abandon a sign-in",
60
+ description: "Stops waiting on a sign-in nobody completed. An abandoned attempt also expires on its own.",
61
+ })
62
+ .input(NativeProviderParamSchema.extend(LoginCancelSchema.shape))
63
+ .output(OkSchema),
64
+ // Each account carries its plan-limit reading where the provider publishes one. `force` re-measures before
65
+ // answering (AccountListQuerySchema); a provider with nothing to measure accepts it and answers at once.
66
+ accounts: oc
67
+ .route({
68
+ method: "GET",
69
+ path: "/accounts/{provider}",
70
+ summary: "Connected accounts of a provider",
71
+ description:
72
+ "Each connected account with how full its plan limits were when last measured, where the provider publishes any. Ask for a fresh measurement and it takes one before answering, which is slower. The credentials themselves never travel: being in this list is what connected means.",
73
+ })
74
+ .input(NativeProviderParamSchema.extend(AccountListQuerySchema.shape))
75
+ .output(OauthAccountListSchema),
76
+ rename: oc
77
+ .route({
78
+ method: "POST",
79
+ path: "/accounts/{provider}/rename",
80
+ summary: "Rename an account",
81
+ description: "Changes the label one account shows under, so several are tellable apart. Blank restores the one derived from the sign-in.",
82
+ })
83
+ .input(NativeProviderParamSchema.extend(AccountRenameSchema.shape))
84
+ .output(OauthAccountSchema),
85
+ disconnect: oc
86
+ .route({
87
+ method: "POST",
88
+ path: "/accounts/{provider}/disconnect",
89
+ summary: "Disconnect an account",
90
+ description: "Clears one stored credential, and stops any sign-in still in flight for this provider. The others stay connected.",
91
+ })
92
+ .input(NativeProviderParamSchema.extend(AccountIdSchema.shape))
93
+ .output(OkSchema),
94
+ };
@@ -5,6 +5,7 @@ import {
5
5
  AgentAutoLandSchema,
6
6
  AgentFileDiffQuerySchema,
7
7
  AgentIdSchema,
8
+ AgentTranscriptQuerySchema,
8
9
  AgentIdsSchema,
9
10
  AgentLandSchema,
10
11
  AgentPlaceSchema,
@@ -94,11 +95,11 @@ export const agentsContract = {
94
95
  .route({
95
96
  method: "GET",
96
97
  path: "/agents/{id}/transcript",
97
- summary: "The full conversation record",
98
+ summary: "One page of a conversation",
98
99
  description:
99
- "Every message in one conversation, in order, including the tool calls and their results. This is the record the chat replays and the next turn is seeded from.",
100
+ "The most recent turns of one conversation, in order, including the tool calls and their results: what the chat replays and the next turn is seeded from. A page, not the whole record — pass the answer's `from` back as `before` to walk further back, until `more` reads false.",
100
101
  })
101
- .input(AgentIdSchema)
102
+ .input(AgentTranscriptQuerySchema)
102
103
  .output(AgentTranscriptSchema),
103
104
  /* SPEAK AS THE AGENT, append the user's words to the conversation's record as an assistant row, with no
104
105
  * turn behind them and no reply. The row is marked `placed` for human readers (TranscriptRowSchema); the
@@ -6,7 +6,7 @@ import { OkSchema } from "../schemas/shared.js";
6
6
  // The live GEO EXIT surface. An exit is ADDED as an `exit` capability (which provider, a resting country,
7
7
  // whether it comes up on boot); it is STARTED, MOVED and ROTATED here. Same split as the vpn contract and for
8
8
  // the same reason: switching country is a runtime operation performed many times over one stored pool, by the
9
- // operator from the Status card and by the agent through `exit` on its PATH, which calls these very routes.
9
+ // operator from the capability card and by the agent through `exit` on its PATH, which calls these very routes.
10
10
  //
11
11
  // The difference from vpn is what "success" means. A dial succeeds when the tunnel is up; a country switch
12
12
  // succeeds only when the egress ADDRESS has moved, which is why `use` and `rotate` end by fetching an
@@ -17,7 +17,7 @@ import { OkSchema } from "../schemas/shared.js";
17
17
  // the plain uplink no matter what is up.
18
18
  export const exitContract = {
19
19
  // Every configured exit with its live state, where it was asked to come out and where it actually does.
20
- // Drives the Status card, the browser account picker and `geo list` (the CLI is `geo`: `exit` is a shell
20
+ // Drives the capability card, the browser account picker and `geo list` (the CLI is `geo`: `exit` is a shell
21
21
  // builtin, so a binary of that name is unreachable from a command line).
22
22
  list: oc
23
23
  .route({
@@ -1,14 +1,14 @@
1
1
  import { eventIterator, oc } from "@orpc/contract";
2
2
  import { z } from "zod";
3
- import { MachineFlowLineSchema, MachineSandboxFlowSchema } from "../schemas/computers.js";
3
+ import { DeviceAgentFlowSchema, DeviceFlowLineSchema, DeviceSandboxFlowSchema } from "../schemas/devices.js";
4
4
  import { HostScopesSchema } from "../schemas/capabilities.js";
5
5
  import { HostFactsSchema } from "../schemas/hosts.js";
6
6
  import { OkSchema } from "../schemas/shared.js";
7
7
 
8
- /* What a connected computer can be ASKED, over the socket it opened to this sandbox.
8
+ /* What a connected device can be ASKED, over the socket it opened to this sandbox.
9
9
  *
10
10
  * The direction is the unusual part: the machine is the oRPC SERVER and the daemon holds the client, even though
11
- * the machine is the side that dialled. A personal computer sits behind NAT with a closing lid, so it can only
11
+ * the machine is the side that dialled. A personal device sits behind NAT with a closing lid, so it can only
12
12
  * ever be the one that connects, but everything is asked OF it. oRPC's websocket adapter takes any socket-like
13
13
  * object on either side, so the roles are free to be the opposite of who placed the call.
14
14
  *
@@ -22,7 +22,7 @@ import { OkSchema } from "../schemas/shared.js";
22
22
  * release cycle. The payload is still validated where it is understood: on the machine, against the tool's own
23
23
  * schema, and by the agent's MCP client on the way back. */
24
24
  export const hostContract = {
25
- // What this computer is, pulled right after the socket authenticates, and again whenever the sandbox wants
25
+ // What this device is, pulled right after the socket authenticates, and again whenever the sandbox wants
26
26
  // it fresh. The card shows it, and the agent's skill pack is written against it.
27
27
  describe: oc.output(HostFactsSchema),
28
28
  // The grant, pushed down on every connect and again whenever the owner edits the card. The machine ENFORCES
@@ -43,5 +43,17 @@ export const hostContract = {
43
43
  *
44
44
  * The scope is still checked here, on the machine, by the same functions the MCP tools call, this adds a
45
45
  * way of WATCHING an operation, never a way of skipping the switch that permits it. */
46
- runSandboxFlow: oc.input(MachineSandboxFlowSchema).output(eventIterator(MachineFlowLineSchema)),
46
+ runSandboxFlow: oc.input(DeviceSandboxFlowSchema).output(eventIterator(DeviceFlowLineSchema)),
47
+ /* Update or restart the agent on this device, narrated the same way — and the one call that expects to be
48
+ * cut off, because the work it starts stops the process serving it (see DeviceAgentFlowSchema).
49
+ *
50
+ * A TYPED PROCEDURE rather than another `run_command` line, and the reason is the failure mode rather than
51
+ * the typing: run through `run_command` this is a child of a process about to be SIGTERMed, and a swap
52
+ * interrupted between its two renames leaves the device with no agent binary at all. Here the agent spawns
53
+ * the work detached from the socket first, so nothing the daemon does to this stream can brick it.
54
+ *
55
+ * It takes "Run commands" — the same grant `run_command` takes, since this is literally a command its owner
56
+ * could type — rather than the sandbox switches, which are about containers. An agent too old to serve this
57
+ * procedure is a real answer too: the view falls back to naming the command, as it did before. */
58
+ runAgentFlow: oc.input(DeviceAgentFlowSchema).output(eventIterator(DeviceFlowLineSchema)),
47
59
  };
@@ -5,7 +5,7 @@ import { DayWindowQuerySchema } from "../schemas/usage.js";
5
5
 
6
6
  // Per-sandbox agent settings (.intentic/config/settings.json). `get` returns the current flags with defaults applied
7
7
  // when the file is absent; `set` overwrites them. `savings` reports what each token-reduction mechanism was
8
- // worth, the cleaners' realized per-command savings and the terse steer's measured A/B, over an inclusive
8
+ // worth, the cleaners' realized per-command savings over an inclusive
9
9
  // UTC day window, the same one the spend ledger takes, so a screen can filter both with one calendar.
10
10
  // `builtinPrompt` returns one of the two built-in system prompts as text. Intentic's own, or Claude Code's
11
11
  // read out of the installed CLI, so the settings page can SHOW the prompt behind a mode instead of asking the