@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
@@ -0,0 +1,289 @@
1
+ /* WHICH PARTS OF A COMMAND ARE TEXT RATHER THAN A PROGRAM, so the one tier that cannot be argued with stops
2
+ * firing on a mention of a dangerous verb.
3
+ *
4
+ * WHAT THIS IS FOR, precisely, because it bounds how good it has to be. The triage catalog next door
5
+ * (command-classes.ts) is deliberately over-inclusive: a match only means a judge should look, and a false
6
+ * positive there costs one model call. That economy holds for every class except the hard-ruled one, where a
7
+ * match is an interruption no policy and no verdict can waive (safety-policy.ts hardRuleClasses). So
8
+ * `echo "rm -rf /" >> notes.md` and `rg 'rm -rf /'` raised un-waivable cards over a string being written to a
9
+ * file and a search of the tree — the exact failure the judge redesign was built to end, surviving in the one
10
+ * tier the judge cannot reach.
11
+ *
12
+ * This says where a fragment sits. A fragment inside a region below is still REPORTED (the class holds, the
13
+ * judge still reads it, the card still marks it); it just does not trip the hard rule.
14
+ *
15
+ * WHICH IS WHY A REGEX-LEVEL SCANNER IS ENOUGH, and this is the design argument rather than an excuse. Both
16
+ * ways of being wrong are cheap:
17
+ *
18
+ * · MISS a region (call text a program) ⇒ one judge call. Exactly today's behaviour, which is the floor.
19
+ * · INVENT a region (call a program text) ⇒ the class is still reported and the judge still rules on it.
20
+ * Only the un-waivable tier is skipped, and the judge is the tier that reads the owner's policy.
21
+ *
22
+ * Precision has to be good enough to skip tier 1½, never good enough to skip tier 1. Nothing here is a
23
+ * boundary, for the same reason nothing in command-classes.ts is: `sh -c "$CMD"` and a path assembled from a
24
+ * variable walk past all of it. The boundaries are structural and elsewhere.
25
+ *
26
+ * THREE KINDS OF REGION, and each is a place a shell will not run what it holds:
27
+ *
28
+ * 1 A COMMENT, `#` to end of line.
29
+ * 2 A HEREDOC BODY, the usual way an agent writes a script it is not running yet.
30
+ * 3 A QUOTED ARGUMENT OF A VERB THAT CANNOT EXECUTE ONE — echo, printf, and the searchers. Plus a quoted
31
+ * commit message after -m, whatever the verb, because message text never runs.
32
+ *
33
+ * `sed`, `awk` and `perl` are deliberately NOT in that verb list, though they are the obvious next entries:
34
+ * each can run a shell out of its own quoted program (`awk 'BEGIN{system("…")}'`, `perl -e`, GNU sed's `s///e`),
35
+ * so their quoted argument is a program and calling it text would be wrong rather than merely imprecise.
36
+ */
37
+
38
+ import type { CommandSpan } from "./command-classes.js";
39
+
40
+ /* Verbs whose quoted arguments this scanner will call text. Every one of them either prints its argument or
41
+ * matches with it, and none has a documented way to execute it. Widening this list is safe in the sense the
42
+ * header sets out, but each entry should be able to answer "how would this run its argument?" with "it cannot". */
43
+ const QUOTING_VERBS: ReadonlySet<string> = new Set([
44
+ "echo",
45
+ "printf",
46
+ "rg",
47
+ "grep",
48
+ "egrep",
49
+ "fgrep",
50
+ "ack",
51
+ "ag",
52
+ "ripgrep",
53
+ ]);
54
+
55
+ /* A flag whose value is a message: git's -m, and the long spelling. A quoted string here is prose that reaches
56
+ * a commit, a tag or a PR, and `git commit -m "rm -rf the old build dir"` is one of the more ordinary ways to
57
+ * write a dangerous-looking command that is not one. Read on the WORD BEFORE a quoted argument, so it applies
58
+ * whatever the verb is. */
59
+ const MESSAGE_FLAGS: ReadonlySet<string> = new Set(["-m", "--message", "-am", "--body", "-b"]);
60
+
61
+ // Where an unquoted word ends: whitespace, a pipeline or list operator, a redirect, a subshell paren.
62
+ const WORD_END = /[\s;|&<>()]/;
63
+
64
+ // A word that is a variable assignment prefixing a command (`FOO=bar cmd …`), skipped when looking for the verb.
65
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
66
+
67
+ /* `<<EOF`, `<<-EOF`, `<<'EOF'`, `<<"EOF"`. The delimiter's quoting only decides whether the body expands, which
68
+ * changes nothing here: an expanded body is still a body being written somewhere rather than run. */
69
+ const HEREDOC_OPEN = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/g;
70
+
71
+ /* The heredoc bodies in a command, as spans. Found first and in their own pass, because a body is line-oriented
72
+ * and can hold anything at all — an odd number of quotes in it would otherwise throw the word scanner out of
73
+ * step for the rest of the command. */
74
+ const heredocBodies = (command: string): CommandSpan[] => {
75
+ const bodies: CommandSpan[] = [];
76
+ for (const open of command.matchAll(HEREDOC_OPEN)) {
77
+ const indented = command.slice(open.index, open.index + 3).startsWith("<<-");
78
+ const newline = command.indexOf("\n", open.index + open[0].length);
79
+ if (newline === -1) {
80
+ // `cat <<EOF` with nothing after it: an unterminated heredoc, so there is no body to mark.
81
+ continue;
82
+ }
83
+ const start = newline + 1;
84
+ const terminator = new RegExp(`^${indented ? "[ \\t]*" : ""}${open[2] as string}[ \\t]*$`, "m");
85
+ const rest = command.slice(start);
86
+ const end = terminator.exec(rest)?.index;
87
+ // An unterminated body runs to the end of the command, which is what the shell would read too.
88
+ bodies.push({ start, end: end === undefined ? command.length : start + end });
89
+ }
90
+ return bodies;
91
+ };
92
+
93
+ // Is this offset inside one of the spans already found? Heredoc bodies are skipped wholesale by the word scan.
94
+ const within = (spans: readonly CommandSpan[], offset: number): CommandSpan | undefined =>
95
+ spans.find((span) => offset >= span.start && offset < span.end);
96
+
97
+ // A substitution inside double quotes, the one thing that makes a quoted argument a program again.
98
+ const EXPANDS = /\$\(|`/;
99
+
100
+ /* One quoted segment inside a word: the span covers the quotes as well as what is between them, so a region
101
+ * handed back from here contains the whole `"rm -rf /"` and a match on any part of it reads as contained. */
102
+ interface QuotedSegment {
103
+ readonly span: CommandSpan;
104
+ /* Does a shell expand anything in here? `"$(rm -rf /)"` inside an echo is a real delete whose output is
105
+ * printed, so a double-quoted segment carrying a substitution is NOT text and never becomes a region. Single
106
+ * quotes expand nothing, so they never set this. */
107
+ readonly expands: boolean;
108
+ }
109
+
110
+ interface ShellWord {
111
+ readonly start: number;
112
+ // The word with its quoting removed, which is what a verb name and a flag are compared against.
113
+ readonly text: string;
114
+ readonly quoted: readonly QuotedSegment[];
115
+ // Does this word begin a simple command? True for the first word after a separator or at the start.
116
+ readonly opensCommand: boolean;
117
+ }
118
+
119
+ // A verb by its bare name: `/bin/echo` and `./echo` are echo, and the path in front of it says nothing new.
120
+ const bareVerb = (text: string): string => text.slice(Math.max(text.lastIndexOf("/"), text.lastIndexOf("\\")) + 1);
121
+
122
+ // Every list and pipeline operator: past one of these the next word is a verb again.
123
+ const SEPARATORS = new Set(["\n", ";", "|", "&", "(", ")"]);
124
+ const BLANKS = new Set([" ", "\t", "\r"]);
125
+
126
+ /* One quoted run, from its opening quote to its closing one. An unbalanced quote takes the rest of the
127
+ * command, which is what a shell waiting for more input would do and keeps the caller advancing. */
128
+ const readQuoted = (command: string, open: number): QuotedSegment & { readonly text: string; readonly end: number } => {
129
+ const quote = command[open] as string;
130
+ const close = command.indexOf(quote, open + 1);
131
+ const end = close === -1 ? command.length : close + 1;
132
+ const text = command.slice(open + 1, close === -1 ? command.length : close);
133
+ return { span: { start: open, end }, expands: quote === '"' && EXPANDS.test(text), text, end };
134
+ };
135
+
136
+ /* One word, from `start` to whatever ends it, with the quoted runs inside it kept as spans. `end === start`
137
+ * cannot happen: the caller only enters here on a character that is neither blank nor a separator. */
138
+ const readWord = (command: string, start: number): { readonly word: Omit<ShellWord, "opensCommand">; readonly end: number } => {
139
+ const quoted: QuotedSegment[] = [];
140
+ let text = "";
141
+ let index = start;
142
+ while (index < command.length && !WORD_END.test(command[index] as string)) {
143
+ const char = command[index] as string;
144
+ if (char === "'" || char === '"') {
145
+ const segment = readQuoted(command, index);
146
+ quoted.push({ span: segment.span, expands: segment.expands });
147
+ text += segment.text;
148
+ index = segment.end;
149
+ continue;
150
+ }
151
+ if (char === "\\") {
152
+ text += command[index + 1] ?? "";
153
+ index += 2;
154
+ continue;
155
+ }
156
+ text += char;
157
+ index += 1;
158
+ }
159
+ return { word: { start, text, quoted }, end: index };
160
+ };
161
+
162
+ /* The command taken apart into words, with each word's quoted segments and whether it opens a simple command.
163
+ * Deliberately a scanner rather than a parser: it tracks quoting and the separators that start a new command,
164
+ * and it knows nothing about control flow, functions or expansion. Everything it gets wrong is bounded by the
165
+ * header's argument. */
166
+ const scanWords = (command: string, skip: readonly CommandSpan[]): ShellWord[] => {
167
+ const words: ShellWord[] = [];
168
+ let index = 0;
169
+ let opensCommand = true;
170
+ while (index < command.length) {
171
+ const skipped = within(skip, index);
172
+ if (skipped !== undefined) {
173
+ index = skipped.end;
174
+ continue;
175
+ }
176
+ const char = command[index] as string;
177
+ if (SEPARATORS.has(char)) {
178
+ opensCommand = true;
179
+ index += 1;
180
+ continue;
181
+ }
182
+ if (BLANKS.has(char)) {
183
+ index += 1;
184
+ continue;
185
+ }
186
+ if (char === "#") {
187
+ // A comment runs to the end of the line. Only reached at a word boundary, so `a#b` is one word.
188
+ const newline = command.indexOf("\n", index);
189
+ index = newline === -1 ? command.length : newline;
190
+ continue;
191
+ }
192
+ const { word, end } = readWord(command, index);
193
+ // A character that is neither a separator nor part of a word (a stray redirect): step over it so the
194
+ // loop always advances.
195
+ index = end === index ? index + 1 : end;
196
+ if (end !== word.start) {
197
+ words.push({ ...word, opensCommand });
198
+ opensCommand = false;
199
+ }
200
+ }
201
+ return words;
202
+ };
203
+
204
+ /* WHERE A COMMAND HOLDS TEXT RATHER THAN A PROGRAM, as spans over the command, unsorted and possibly
205
+ * overlapping — callers ask containment questions of them rather than rendering them.
206
+ *
207
+ * Exported for the classifier, which asks it once per command and hands the answer to every table. */
208
+ export const inertRegions = (command: string): CommandSpan[] => {
209
+ const bodies = heredocBodies(command);
210
+ const regions: CommandSpan[] = [...bodies];
211
+ const words = scanWords(command, bodies);
212
+ // Comments are consumed by the scanner rather than reported, so they are found again here: the scan is
213
+ // where the quoting state lives, and a `#` inside a quoted string is not a comment.
214
+ let quotingVerb = false;
215
+ let previous: ShellWord | undefined;
216
+ for (const word of words) {
217
+ if (word.opensCommand) {
218
+ quotingVerb = QUOTING_VERBS.has(bareVerb(word.text));
219
+ previous = undefined;
220
+ }
221
+ /* An env assignment in front of the verb (`LC_ALL=C grep …`) is not the verb. Re-read the next word as
222
+ * one instead of giving up on the command. */
223
+ if (word.opensCommand && ASSIGNMENT.test(word.text) && word.quoted.length === 0) {
224
+ quotingVerb = false;
225
+ previous = undefined;
226
+ continue;
227
+ }
228
+ const afterMessageFlag = previous !== undefined && MESSAGE_FLAGS.has(previous.text);
229
+ if (quotingVerb || afterMessageFlag) {
230
+ regions.push(...word.quoted.filter((segment) => !segment.expands).map((segment) => segment.span));
231
+ }
232
+ previous = word;
233
+ }
234
+ regions.push(...commentRegions(command, bodies));
235
+ return regions;
236
+ };
237
+
238
+ /* Comments, on the same scan discipline as the words: a `#` only opens one where a word could have started, so
239
+ * `sha#1` is not a comment, and a quoted `"# …"` is not one either.
240
+ *
241
+ * Its own walk rather than a by-product of scanWords, because the two want different things from a quote — the
242
+ * word scan needs what is INSIDE one, this needs only to be past it. Sharing readQuoted keeps them agreeing on
243
+ * where one ends, which is the only fact they both depend on. */
244
+ const commentRegions = (command: string, skip: readonly CommandSpan[]): CommandSpan[] => {
245
+ const comments: CommandSpan[] = [];
246
+ let index = 0;
247
+ while (index < command.length) {
248
+ const skipped = within(skip, index);
249
+ if (skipped !== undefined) {
250
+ index = skipped.end;
251
+ continue;
252
+ }
253
+ const char = command[index] as string;
254
+ if (char === "'" || char === '"') {
255
+ index = readQuoted(command, index).end;
256
+ continue;
257
+ }
258
+ if (char === "\\") {
259
+ index += 2;
260
+ continue;
261
+ }
262
+ // A word boundary in front is what makes it a comment rather than part of a word.
263
+ if (char === "#" && (index === 0 || WORD_END.test(command[index - 1] as string))) {
264
+ const newline = command.indexOf("\n", index);
265
+ const end = newline === -1 ? command.length : newline;
266
+ comments.push({ start: index, end });
267
+ index = end;
268
+ continue;
269
+ }
270
+ index += 1;
271
+ }
272
+ return comments;
273
+ };
274
+
275
+ /* Is this fragment somewhere a shell would RUN it? The question the hard rule asks of every span triage matched.
276
+ *
277
+ * JUDGED ON WHERE THE FRAGMENT STARTS, not on whether a region contains the whole of it, and the difference is
278
+ * not a relaxation — it is the only reading that answers the question asked. Every pattern in the catalog is
279
+ * written to BEGIN at the verb (`rm`, `docker volume rm`, `mkfs`, `git push`), so the span's first character is
280
+ * where the dangerous thing was found. Whether the match then ran on past a closing quote says something about
281
+ * the regex, not about the command: `rm`'s own parser reads to the end of the invocation and `>` is not a
282
+ * terminator, so `echo "rm -rf /" >> notes.md` produces a span covering `rm -rf /" >> notes.md`. Requiring
283
+ * containment made that command live — which is exactly the card this whole change exists to stop raising.
284
+ *
285
+ * The conservative direction is preserved where it matters: a fragment whose verb sits OUTSIDE every region is
286
+ * live no matter what it runs into afterwards, so `echo "tidying" && rm -rf /` and `rm -rf "$(cat list)" # ok`
287
+ * both stay live. */
288
+ export const isLive = (span: CommandSpan, regions: readonly CommandSpan[]): boolean =>
289
+ !regions.some((region) => span.start >= region.start && span.start < region.end);
package/src/versions.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /* COMPARING THE VERSIONS THIS SYSTEM STAMPS ON WHAT IT SHIPS, the daemon, the sandbox image, and the two agents
2
- * that run on a user's own computer. One release stamps all of them to the SAME version, so "is this one behind
2
+ * that run on a user's own device. One release stamps all of them to the SAME version, so "is this one behind
3
3
  * that one" is one question with one answer, and it lives here because both ends ask it: the daemon compares its
4
- * own build against the latest published release, and the browser compares a computer's agent against the same.
4
+ * own build against the latest published release, and the browser compares a device's agent against the same.
5
5
  *
6
6
  * Shared rather than copied because the two copies would not disagree until the day it mattered: 1.9.0 against
7
7
  * 1.10.0 is where a hand-rolled comparator goes wrong, and it goes wrong by reporting "up to date". */
@@ -35,7 +35,7 @@ export const webextLendUrl = (sandboxUrl: string): string => `${sandboxUrl.repla
35
35
 
36
36
  /* ---- the pairing code: the one string that travels from the sandbox's card into the extension ----
37
37
  *
38
- * A connected computer is paired by a shell one-liner, which can carry two values in two environment variables
38
+ * A connected device is paired by a shell one-liner, which can carry two values in two environment variables
39
39
  * because a terminal is a place where long strings are normal. A browser extension's popup is not: what a
40
40
  * person will actually do there is paste ONE thing, once, and anything that asks them to copy a URL into one
41
41
  * box and a token into another is a flow that fails on the second box.
@@ -43,7 +43,7 @@ export const webextLendUrl = (sandboxUrl: string): string => `${sandboxUrl.repla
43
43
  * So both halves ride in one code. It is not encryption and does not pretend to be — base64url of two fields,
44
44
  * so that the thing on the clipboard is opaque enough not to be edited by hand, short enough to paste, and
45
45
  * carries its own sandbox address, which is the field a person could not possibly be expected to type. The
46
- * secret in it is the pairing token, which is single-use and expires in ten minutes (webext-store.ts).
46
+ * secret in it is the pairing token, which is single-use and expires in ten minutes (the daemon's peer store).
47
47
  *
48
48
  * The prefix is a version marker, and it is here so that a code from an older sandbox meets a clear "this code
49
49
  * is from a different version" in the extension rather than a JSON parse error. */
@@ -2,13 +2,13 @@ import { z } from "zod";
2
2
 
3
3
  /* The handshake on /system/webext/connect, the ONE message on that socket that is not oRPC.
4
4
  *
5
- * Same two-phase shape as a connected computer's (host-protocol.ts) and for the same reason: the daemon has
5
+ * Same two-phase shape as a connected device's (host-protocol.ts) and for the same reason: the daemon has
6
6
  * nothing to call until it knows whose socket this is, so the proof cannot itself be an oRPC call. The browser
7
7
  * extension's first frame carries its enrollment token, the daemon resolves WHICH capability it belongs to, and
8
8
  * from that message on every byte is `webextContract` with the EXTENSION serving.
9
9
  *
10
10
  * WHY A SEPARATE PROTOCOL FROM host's, when the frame is the same two fields: because the thing on the other
11
- * end is not a computer. It has no shell, no filesystem and no screen; what it has is tabs, origins the person
11
+ * end is not a device. It has no shell, no filesystem and no screen; what it has is tabs, origins the person
12
12
  * granted one at a time, and a human watching every click. Sharing the host's schema would have meant a card of
13
13
  * switches that mean nothing (`roots`, `sandboxRemove`) and an agent told about a home directory it cannot
14
14
  * reach. The two connectors are siblings, not one connector with a flag. */
@@ -6,6 +6,9 @@ import {
6
6
  isLockedWorkspacePath,
7
7
  isReportedManifest,
8
8
  isReviewableLockedPath,
9
+ LOCKED_STATE_ENTRIES,
10
+ lockedWorkspaceEntry,
11
+ PLAN_DOCUMENTS_DIR,
9
12
  REPORTED_MANIFEST_PATHS,
10
13
  SEARCHABLE_STATE_PATHS,
11
14
  SHARED_STATE_PATHS,
@@ -244,6 +247,47 @@ describe(`isLockedWorkspacePath`, () => {
244
247
  expect(isLockedWorkspacePath(`.intentic\\secrets\\auth\\codex`)).toBe(true);
245
248
  expect(isLockedWorkspacePath(`./.intentic/config/capabilities.json`)).toBe(true);
246
249
  });
250
+
251
+ it(`lets the plan documents out of the session store around them`, () => {
252
+ // The plan a card asks the reader to approve, whose full text that card already renders. Refusing the
253
+ // file left the card's one link into the workspace landing on a padlock about the document it was
254
+ // asking about; the transcripts it sits beside stay locked.
255
+ expect(isLockedWorkspacePath(`${PLAN_DOCUMENTS_DIR}/wiggly-spring.md`)).toBe(false);
256
+ expect(isLockedWorkspacePath(PLAN_DOCUMENTS_DIR)).toBe(false);
257
+ expect(isLockedWorkspacePath(`.intentic/records/sessions/claude/projects/x.jsonl`)).toBe(true);
258
+ expect(isLockedWorkspacePath(`.intentic/records/sessions`)).toBe(true);
259
+ // …and it is the directory that is exempt, not the word: a sibling store named for it is not one.
260
+ expect(isLockedWorkspacePath(`.intentic/records/sessions/claude/plans-backup/x.md`)).toBe(true);
261
+ });
262
+ });
263
+
264
+ /* WHICH entry a locked path belongs to, which is what the refusal screen says a file holds and where to manage
265
+ * it. Split out from the boolean so the browser can key its sentences on the daemon's own list instead of a
266
+ * second copy of the rule — the copy it kept drifted through the state regrouping and stranded every locked
267
+ * file on the generic sentence. */
268
+ describe(`lockedWorkspaceEntry`, () => {
269
+ it(`names the entry a path matched, not the leaf it ends at`, () => {
270
+ // A locked FOLDER is one row in the explorer and never descended, so the name worth reporting is the
271
+ // folder's: "Cookies is kept private" is true of something the reader has never heard of.
272
+ expect(lockedWorkspaceEntry(`.intentic/local/browser/Default/Cookies`)).toBe(`local/browser`);
273
+ expect(lockedWorkspaceEntry(`.intentic/secrets/auth/codex/auth.json`)).toBe(`secrets/auth`);
274
+ expect(lockedWorkspaceEntry(`.intentic/config/capabilities.json`)).toBe(`config/capabilities.json`);
275
+ expect(lockedWorkspaceEntry(`.git/config`)).toBe(`.git`);
276
+ });
277
+
278
+ it(`answers undefined for everything the lock does not hold`, () => {
279
+ expect(lockedWorkspaceEntry(`src/app.ts`)).toBeUndefined();
280
+ expect(lockedWorkspaceEntry(`.intentic/config/settings.json`)).toBeUndefined();
281
+ expect(lockedWorkspaceEntry(`${PLAN_DOCUMENTS_DIR}/wiggly-spring.md`)).toBeUndefined();
282
+ });
283
+
284
+ it(`answers for every entry the lock declares`, () => {
285
+ // The set is the daemon's; this is what makes it addressable from the browser. An entry nobody can
286
+ // resolve back out is one the refusal screen could only describe generically.
287
+ for (const entry of LOCKED_STATE_ENTRIES) {
288
+ expect([entry, lockedWorkspaceEntry(`${STATE_DIR}/${entry}`)]).toEqual([entry, entry]);
289
+ }
290
+ });
247
291
  });
248
292
 
249
293
  /* The carve-out the diff routes ask for, and the reason it is derived: a locked entry the root repo TRACKS has
@@ -330,10 +374,13 @@ describe(`VERSIONED_STATE_PATHS`, () => {
330
374
  /* The connections themselves, and the entry that took the longest to earn its place: it was classed
331
375
  * `secret` on the strength of holding each capability's credential, which stopped being true when the
332
376
  * vault took the values out and left the shape behind. Connecting a deployment orchestrator, or
333
- * granting a connected computer shell and screen control, is the largest change made to what this
377
+ * granting a connected device shell and screen control, is the largest change made to what this
334
378
  * sandbox can DO, and it used to leave no diff. */
335
379
  `${STATE_DIR}/config/capabilities.json`,
336
380
  `${STATE_DIR}/config/capability-dismissals.json`,
381
+ // The context shelves: which repositories a conversation opened on one carries. A list of names, and
382
+ // the decision about what a session may see, which is what a review is for.
383
+ `${STATE_DIR}/config/context/`,
337
384
  /* The two entries the AGENT authors on its own initiative, and the reason `versioned` is not read as
338
385
  * config-only. Both are the sandbox acting outward: a draft publishes words under the owner's name,
339
386
  * a workspace extension is code that runs in the app and can serve HTTP with the workspace under
@@ -359,6 +406,13 @@ describe(`VERSIONED_STATE_PATHS`, () => {
359
406
  // raising that limit is a decision about every session sharing the box, and `git log` is the only
360
407
  // thing that answers "since when have we been allowing four of these at a time".
361
408
  `${STATE_DIR}/config/heavy-commands.json`,
409
+ /* The scripts the rules run. Tracked because the exclude list carves entries out BY NAME, so a
410
+ * directory nobody marked is invisible both to `git add -A` and to the land: that is how 559e896
411
+ * came to repoint settings.json at two scripts it never staged, and how the pair written in a
412
+ * worktree never arrived. What the workspace held from then on was settings naming two hooks that
413
+ * did not exist, and every edit for a day firing `node` at a missing file. A rule is reviewable;
414
+ * the code it runs on every write has to be. */
415
+ `${STATE_DIR}/config/hooks/`,
362
416
  `${STATE_DIR}/config/loop-designs.json`,
363
417
  `${STATE_DIR}/config/personas.json`,
364
418
  // A persona's own kit: the prompt it runs on and the skills only its turns reach. Tracked for the
@@ -135,7 +135,7 @@ const STATE_FILES = [
135
135
  * withSecretVault keeps credential VALUES off /work entirely, the manifest holds `__intentic_vaulted__`
136
136
  * where one used to be, reads rehydrate so no caller noticed, and main.ts sweeps a hand-written value out at
137
137
  * boot. What is left is the SHAPE of a connection: a kind, a URL, a username, a purpose, which permissions a
138
- * connected computer was granted.
138
+ * connected device was granted.
139
139
  *
140
140
  * WHICH KEYS THOSE ARE IS DERIVED, not listed a second time: `echo` already answers "what of this config may
141
141
  * a browser see", and the credential keys are exactly its complement (capabilities/secret-fields.ts). A kind
@@ -149,7 +149,7 @@ const STATE_FILES = [
149
149
  * one credential apiece, the shape personas.json has had all along, for the same reason.
150
150
  *
151
151
  * `versioned`, which is the point. Connecting this sandbox to a deployment orchestrator, or granting a
152
- * connected computer shell and screen control, is the largest change anyone makes to what it can DO, and it
152
+ * connected device shell and screen control, is the largest change anyone makes to what it can DO, and it
153
153
  * left a diff nowhere. One consequence worth stating rather than discovering: an identifier that pairs with a
154
154
  * credential. Komodo's api key beside its api secret, which its own connector card calls "like a database
155
155
  * user", is echoed, and therefore lands in the diff exactly as a database username would. */
@@ -273,6 +273,34 @@ const STATE_FILES = [
273
273
  * — raising the limit is a decision about everyone's sessions on that box, and `git log` is the only thing
274
274
  * that answers "since when have we allowed four of these at once". */
275
275
  { path: ".intentic/config/heavy-commands.json", invalidates: ["settings"], portability: "carry", versioned: true },
276
+ /* THE SCRIPTS THE RULE TABLE RUNS, one file per reader, at the moments settings.json points them at: the
277
+ * per-edit linter and byte scan today (`file.edited`), whatever the owner adds beside them tomorrow.
278
+ *
279
+ * IT IS HERE BECAUSE IT WAS NOT, and the gap was silent. The root repository excludes `.intentic/config/*`
280
+ * with one carve-out per tracked entry, derived from this table's `versioned` flags (history.ts
281
+ * rootExcludes), so a directory nobody marked is ignored — and an ignored file is invisible to the land.
282
+ * The two scripts were written in an agent's worktree; the rules naming them were written into
283
+ * settings.json, which IS tracked; and only the rules arrived. What the workspace held from then on was two
284
+ * hooks pointing at files that did not exist: a per-edit gate that looked armed on the settings screen,
285
+ * spawned a process on every edit, and read nothing. The linter and the byte scan ran nowhere between an
286
+ * edit and a push for as long as that stood.
287
+ *
288
+ * `versioned` for the strongest form of the reason the config slice generally is. This is not a setting that
289
+ * decides how the sandbox behaves, it is the CODE that runs when it does, against every file an agent
290
+ * writes: a change here deserves review more than a change to the rule that calls it. `carry`, because it is
291
+ * authored text about this workspace's own conventions, holding no credential and nothing about this
292
+ * machine. Invalidating nothing is a real answer here rather than a gap: the settings screen renders the
293
+ * RULES, which live in settings.json and have their own key. */
294
+ {
295
+ path: ".intentic/config/hooks/",
296
+ invalidates: [],
297
+ why: "The settings screen renders the rules that name these scripts, out of settings.json; nothing in the browser reads the scripts themselves.",
298
+ portability: "carry",
299
+ versioned: true,
300
+ // The daemon never BUILDS this path, it only runs what a rule's command names, so there is no statePath
301
+ // call for the coverage pair to find. Whoever authors a reader is the writer here.
302
+ outsideWriter: "the owner or an agent, authoring them; the daemon only ever RUNS one, by the path a rule's command names",
303
+ },
276
304
  // The rule table's last-fired stamps, beside the rules themselves. `derived` rather than `carry`: it is a
277
305
  // record of what happened in THIS sandbox, and carrying it to a fresh one would date every rule to work
278
306
  // that machine never did.
@@ -710,6 +738,12 @@ const STATE_FILES = [
710
738
  * behaves, it holds no credential, and it belongs in a pull request, which is also what makes it
711
739
  * searchable, since every versioned entry already is. */
712
740
  { path: ".intentic/config/personas/", invalidates: ["personas"], portability: "carry", versioned: true },
741
+
742
+ /* THE CONTEXT SHELVES, one JSON file per shelf (schemas/context.ts): which part of the workspace a
743
+ * conversation opened on it carries. `versioned` and `carry` on the persona card's own argument, a shelf is
744
+ * a list of item ids and holds no credential, and which repositories a session can see is exactly the kind
745
+ * of decision that belongs in a pull request. */
746
+ { path: ".intentic/config/context/", invalidates: ["context"], portability: "carry", versioned: true },
713
747
  ] as const satisfies readonly WorkspaceStateFile[];
714
748
 
715
749
  export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = STATE_FILES;
@@ -927,7 +961,7 @@ export const isReportedManifest = (relPath: string): boolean => REPORTED_MANIFES
927
961
  * and all of `identity` and `secrets` is in. That is not an accident of the grouping, it is a different question
928
962
  *, "would showing the bytes hand someone something" rather than "what kind of thing is this", so it keeps an
929
963
  * explicit list, just one that now names the folder each entry lives in. */
930
- const LOCKED_STATE_ENTRIES: ReadonlySet<string> = new Set([
964
+ export const LOCKED_STATE_ENTRIES: ReadonlySet<string> = new Set([
931
965
  "identity/owner.json",
932
966
  "identity/members.json",
933
967
  "identity/control-tokens.json",
@@ -943,28 +977,59 @@ const LOCKED_STATE_ENTRIES: ReadonlySet<string> = new Set([
943
977
  "claude.json",
944
978
  ]);
945
979
 
946
- /* Whether a workspace-root-relative path lands in that control plane, and so is shown locked rather than
947
- * opened. Scoped deliberately tight, matching the guard: only the ROOT `.intentic` counts (a repo's own nested
948
- * one is ordinary content) and only these entries within it, subtrees included, so a new provider dropped under
949
- * `auth/` is covered without a second edit.
980
+ /* WHERE THE CLI'S PLAN FILES LAND, workspace-relative and the one thing inside a locked entry that is not
981
+ * the sandbox's own state, which is why it is declared here rather than beside the card that draws it.
982
+ *
983
+ * `~/.claude/plans` is a symlink onto this directory (sessions/session-store.ts links the SDK's conversation
984
+ * state onto the workspace volume), so a plan file has a HARNESS-OWNED address: no guessing whether prose is a
985
+ * plan, no threshold on length, the path says so.
986
+ *
987
+ * What lands there is a DOCUMENT ADDRESSED TO THE READER. `records/sessions` is locked because it holds the
988
+ * provider's own conversation state; a plan is the opposite of that — the chat renders its full text into the
989
+ * card asking the reader to approve it, so opening the file publishes nothing the transcript had not. Locked
990
+ * anyway, the one link the card offers into the workspace landed on a padlock reading "it holds something only
991
+ * the sandbox itself uses", about a document the sandbox had just asked the reader to approve. */
992
+ export const PLAN_DOCUMENTS_DIR = `${STATE_DIR}/records/sessions/claude/plans`;
993
+
994
+ /* WHICH locked entry a workspace-root-relative path belongs to, or undefined for a path that is not in the
995
+ * control plane at all. Scoped deliberately tight, matching the guard: only the ROOT `.intentic` counts (a
996
+ * repo's own nested one is ordinary content) and only these entries within it, subtrees included, so a new
997
+ * provider dropped under `auth/` is covered without a second edit.
950
998
  *
951
999
  * The ROOT's own `.git` joins them. It is the pointer to the shadow history repo kept off the workspace so the
952
1000
  * agent cannot rewrite its own past; a NESTED repo's `.git` is ordinary content and stays browsable.
953
1001
  *
1002
+ * The ENTRY rather than a boolean, because the screen that reports a refusal has to say what the file holds and
1003
+ * where the thing inside it is actually managed, and it can only do that from the name of the entry the path
1004
+ * matched — never from the leaf, which is a Chromium cookie jar or a mint-fresh session id. Keeping that lookup
1005
+ * keyed on this function's answer is what stops the browser's sentences from drifting off the daemon's list, as
1006
+ * they did through the regrouping: every one of them was still keyed on `sessions` and `auth` while the rule
1007
+ * had moved to `records/sessions` and `secrets/auth`, so every locked file in the product fell through to the
1008
+ * generic sentence and lost its way out.
1009
+ *
954
1010
  * Accepts either slash so a caller holding a platform path doesn't have to normalize first. */
955
- export const isLockedWorkspacePath = (relPath: string): boolean => {
1011
+ export const lockedWorkspaceEntry = (relPath: string): string | undefined => {
956
1012
  const segments = relPath.split(/[\\/]/).filter((segment) => segment !== "" && segment !== ".");
957
1013
  if (segments[0] === ".git") {
958
- return true;
1014
+ return ".git";
959
1015
  }
960
1016
  if (segments[0] !== STATE_DIR) {
961
- return false;
1017
+ return undefined;
1018
+ }
1019
+ const rel = segments.join("/");
1020
+ if (rel === PLAN_DOCUMENTS_DIR || rel.startsWith(`${PLAN_DOCUMENTS_DIR}/`)) {
1021
+ return undefined;
962
1022
  }
963
1023
  // Two segments, which covers both spellings in the set above: a grouped entry (`secrets/auth`) matches as
964
1024
  // written, and a bare root entry (`claude.json`) joins to itself because there is no second segment to add.
965
- return LOCKED_STATE_ENTRIES.has(segments.slice(1, 3).join("/"));
1025
+ const entry = segments.slice(1, 3).join("/");
1026
+ return LOCKED_STATE_ENTRIES.has(entry) ? entry : undefined;
966
1027
  };
967
1028
 
1029
+ // Whether a path lands in that control plane, and so is refused by the file API and shown locked rather than
1030
+ // opened. The question every guard asks; the entry above is for the one screen that has to name it.
1031
+ export const isLockedWorkspacePath = (relPath: string): boolean => lockedWorkspaceEntry(relPath) !== undefined;
1032
+
968
1033
  /* THE LOCKED ENTRIES THE ROOT REPO TRACKS, refused by the file API, and diffable anyway.
969
1034
  *
970
1035
  * `capabilities.json` is the only one today and the whole reason this exists. Both of its rules are right on