@intentic/sandbox-contract 1.332.0 → 1.333.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 (165) hide show
  1. package/README.md +8 -1
  2. package/dist/contracts/agent.contract.d.ts +1 -0
  3. package/dist/contracts/agent.contract.d.ts.map +1 -1
  4. package/dist/contracts/automations.contract.d.ts +1 -0
  5. package/dist/contracts/automations.contract.d.ts.map +1 -1
  6. package/dist/contracts/needs.contract.d.ts +29 -1
  7. package/dist/contracts/needs.contract.d.ts.map +1 -1
  8. package/dist/contracts/needs.contract.js +2 -2
  9. package/dist/contracts/needs.contract.js.map +1 -1
  10. package/dist/contracts/runner.contract.d.ts.map +1 -1
  11. package/dist/contracts/runner.contract.js +2 -2
  12. package/dist/contracts/runner.contract.js.map +1 -1
  13. package/dist/contracts/settings.contract.d.ts +34 -0
  14. package/dist/contracts/settings.contract.d.ts.map +1 -1
  15. package/dist/ids/project-dir.d.ts +1 -0
  16. package/dist/ids/project-dir.d.ts.map +1 -1
  17. package/dist/ids/project-dir.js +7 -0
  18. package/dist/ids/project-dir.js.map +1 -1
  19. package/dist/index.d.ts +114 -43
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +7 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/models/model-roles.d.ts +8 -0
  24. package/dist/models/model-roles.d.ts.map +1 -1
  25. package/dist/models/model-roles.js +8 -0
  26. package/dist/models/model-roles.js.map +1 -1
  27. package/dist/policy/control-scopes.d.ts +47 -0
  28. package/dist/policy/control-scopes.d.ts.map +1 -1
  29. package/dist/policy/control-scopes.js +16 -0
  30. package/dist/policy/control-scopes.js.map +1 -1
  31. package/dist/policy/device-programs.d.ts +28 -0
  32. package/dist/policy/device-programs.d.ts.map +1 -0
  33. package/dist/policy/device-programs.js +79 -0
  34. package/dist/policy/device-programs.js.map +1 -0
  35. package/dist/policy/identity-assertion.d.ts +27 -0
  36. package/dist/policy/identity-assertion.d.ts.map +1 -0
  37. package/dist/policy/identity-assertion.js +77 -0
  38. package/dist/policy/identity-assertion.js.map +1 -0
  39. package/dist/policy/sandbox-power.d.ts +34 -0
  40. package/dist/policy/sandbox-power.d.ts.map +1 -0
  41. package/dist/policy/sandbox-power.js +146 -0
  42. package/dist/policy/sandbox-power.js.map +1 -0
  43. package/dist/protocol/doors/runner-protocol.d.ts +36 -0
  44. package/dist/protocol/doors/runner-protocol.d.ts.map +1 -1
  45. package/dist/protocol/doors/runner-protocol.js +2 -0
  46. package/dist/protocol/doors/runner-protocol.js.map +1 -1
  47. package/dist/protocol/raw/raw-json-routes.d.ts +839 -1
  48. package/dist/protocol/raw/raw-json-routes.d.ts.map +1 -1
  49. package/dist/protocol/raw/raw-json-routes.js +60 -2
  50. package/dist/protocol/raw/raw-json-routes.js.map +1 -1
  51. package/dist/protocol/raw/raw-routes.d.ts +9 -0
  52. package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
  53. package/dist/protocol/raw/raw-routes.js +3 -0
  54. package/dist/protocol/raw/raw-routes.js.map +1 -1
  55. package/dist/schemas/agent.d.ts +3 -0
  56. package/dist/schemas/agent.d.ts.map +1 -1
  57. package/dist/schemas/automations.d.ts +1 -0
  58. package/dist/schemas/automations.d.ts.map +1 -1
  59. package/dist/schemas/automations.js +8 -2
  60. package/dist/schemas/automations.js.map +1 -1
  61. package/dist/schemas/device-swaps.d.ts +19 -0
  62. package/dist/schemas/device-swaps.d.ts.map +1 -0
  63. package/dist/schemas/device-swaps.js +11 -0
  64. package/dist/schemas/device-swaps.js.map +1 -0
  65. package/dist/schemas/environment.d.ts +7 -0
  66. package/dist/schemas/environment.d.ts.map +1 -1
  67. package/dist/schemas/environment.js +2 -0
  68. package/dist/schemas/environment.js.map +1 -1
  69. package/dist/schemas/members.d.ts +4 -0
  70. package/dist/schemas/members.d.ts.map +1 -1
  71. package/dist/schemas/members.js +5 -0
  72. package/dist/schemas/members.js.map +1 -1
  73. package/dist/schemas/needs.d.ts +78 -1
  74. package/dist/schemas/needs.d.ts.map +1 -1
  75. package/dist/schemas/needs.js +36 -1
  76. package/dist/schemas/needs.js.map +1 -1
  77. package/dist/schemas/restart-intent.d.ts +47 -0
  78. package/dist/schemas/restart-intent.d.ts.map +1 -0
  79. package/dist/schemas/restart-intent.js +19 -0
  80. package/dist/schemas/restart-intent.js.map +1 -0
  81. package/dist/schemas/settings.d.ts +34 -0
  82. package/dist/schemas/settings.d.ts.map +1 -1
  83. package/dist/schemas/settings.js +2 -0
  84. package/dist/schemas/settings.js.map +1 -1
  85. package/dist/schemas/short-codes.d.ts +16 -0
  86. package/dist/schemas/short-codes.d.ts.map +1 -1
  87. package/dist/schemas/short-codes.js +2 -0
  88. package/dist/schemas/short-codes.js.map +1 -1
  89. package/dist/schemas/speech.d.ts +4 -0
  90. package/dist/schemas/speech.d.ts.map +1 -1
  91. package/dist/schemas/speech.js +1 -0
  92. package/dist/schemas/speech.js.map +1 -1
  93. package/dist/schemas/system.d.ts +46 -0
  94. package/dist/schemas/system.d.ts.map +1 -1
  95. package/dist/schemas/system.js +18 -1
  96. package/dist/schemas/system.js.map +1 -1
  97. package/dist/schemas/voice.d.ts +171 -0
  98. package/dist/schemas/voice.d.ts.map +1 -0
  99. package/dist/schemas/voice.js +99 -0
  100. package/dist/schemas/voice.js.map +1 -0
  101. package/dist/schemas/workspace/workspace-upload.d.ts +14 -0
  102. package/dist/schemas/workspace/workspace-upload.d.ts.map +1 -0
  103. package/dist/schemas/workspace/workspace-upload.js +6 -0
  104. package/dist/schemas/workspace/workspace-upload.js.map +1 -0
  105. package/dist/state/arrival.d.ts +3 -0
  106. package/dist/state/arrival.d.ts.map +1 -1
  107. package/dist/state/arrival.js +1 -0
  108. package/dist/state/arrival.js.map +1 -1
  109. package/dist/state/definition.d.ts +52 -4
  110. package/dist/state/definition.d.ts.map +1 -1
  111. package/dist/state/history-state.d.ts.map +1 -1
  112. package/dist/state/history-state.js +2 -0
  113. package/dist/state/history-state.js.map +1 -1
  114. package/dist/state/runtime-state.d.ts +2 -2
  115. package/dist/state/runtime-state.d.ts.map +1 -1
  116. package/dist/state/runtime-state.js +2 -2
  117. package/dist/state/runtime-state.js.map +1 -1
  118. package/dist/state/update-probation.d.ts +50 -0
  119. package/dist/state/update-probation.d.ts.map +1 -0
  120. package/dist/state/update-probation.js +81 -0
  121. package/dist/state/update-probation.js.map +1 -0
  122. package/dist/state/workspace-state.d.ts +1 -1
  123. package/dist/state/workspace-state.d.ts.map +1 -1
  124. package/dist/state/workspace-state.js +1 -1
  125. package/dist/state/workspace-state.js.map +1 -1
  126. package/package.json +16 -5
  127. package/src/contracts/needs.contract.ts +2 -2
  128. package/src/contracts/runner.contract.ts +10 -2
  129. package/src/ids/project-dir.test.ts +19 -1
  130. package/src/ids/project-dir.ts +14 -0
  131. package/src/ids/tunnel-ids.test.ts +30 -0
  132. package/src/index.ts +7 -0
  133. package/src/models/model-roles.ts +10 -0
  134. package/src/policy/control-scopes.ts +27 -0
  135. package/src/policy/device-programs.ts +137 -0
  136. package/src/policy/identity-assertion.ts +130 -0
  137. package/src/policy/sandbox-power.ts +247 -0
  138. package/src/policy/tests/device-programs.test.ts +107 -0
  139. package/src/policy/tests/identity-assertion.test.ts +79 -0
  140. package/src/policy/tests/sandbox-power.test.ts +181 -0
  141. package/src/protocol/doors/runner-protocol.ts +7 -0
  142. package/src/protocol/raw/raw-json-routes.test.ts +87 -0
  143. package/src/protocol/raw/raw-json-routes.ts +125 -6
  144. package/src/protocol/raw/raw-routes.ts +6 -0
  145. package/src/schemas/automations.ts +10 -3
  146. package/src/schemas/device-swaps.test.ts +39 -0
  147. package/src/schemas/device-swaps.ts +26 -0
  148. package/src/schemas/environment.ts +4 -0
  149. package/src/schemas/members.ts +8 -0
  150. package/src/schemas/needs.ts +51 -1
  151. package/src/schemas/restart-intent.test.ts +45 -0
  152. package/src/schemas/restart-intent.ts +47 -0
  153. package/src/schemas/settings.ts +6 -0
  154. package/src/schemas/short-codes.ts +11 -0
  155. package/src/schemas/speech.ts +4 -0
  156. package/src/schemas/system.ts +43 -4
  157. package/src/schemas/voice.ts +161 -0
  158. package/src/schemas/workspace/workspace-upload.ts +11 -0
  159. package/src/state/arrival.ts +3 -0
  160. package/src/state/history-state.ts +5 -0
  161. package/src/state/runtime-state.test.ts +9 -0
  162. package/src/state/runtime-state.ts +7 -5
  163. package/src/state/update-probation.test.ts +45 -0
  164. package/src/state/update-probation.ts +169 -0
  165. package/src/state/workspace-state.ts +4 -1
@@ -56,6 +56,20 @@ export const projectDirNameFor = (folderBasename: string): string => {
56
56
  return RESERVED_PROJECT_DIR_NAMES.includes(name) ? `${name}-project` : name;
57
57
  };
58
58
 
59
+ // The names a folder may take in this computer's sandbox, best first: its own name, then its parent's name in front of
60
+ // it, then its grandparent's too (`app`, `code-app`, `radar-code-app`), each one `isProjectDirName` accepts and none
61
+ // twice. The desktop app takes the first one free (machine_sandbox.rs `unique_name`) and numbers only past all of them,
62
+ // so a second folder called `app` reads as `code-app`, which says which one it is, rather than `app-2` (2026-10-12). A
63
+ // drive (`C:`) and a network share's server and share (`\\server\share`, `\\wsl.localhost\<distro>`) are where a folder
64
+ // is, not what it is called, so they never lend a name.
65
+ export const projectDirCandidates = (hostPath: string): string[] => {
66
+ const unc = /^[\\/]{2}[^\\/]/.test(hostPath);
67
+ const segments = hostPath.split(/[\\/]+/).filter((segment) => segment !== "");
68
+ const named = (unc ? segments.slice(2) : segments).filter((segment) => !/^[A-Za-z]:$/.test(segment));
69
+ const candidates = [1, 2, 3].flatMap((depth) => (named.length < depth ? [] : [projectDirNameFor(named.slice(-depth).join("-"))]));
70
+ return [...new Set(candidates)];
71
+ };
72
+
59
73
  // Where a project folder lands in the sandbox, and the name back out of such a path: exactly `<root>/<name>`, anything
60
74
  // else (a deeper path, a trailing slash, a reserved name) is not a project dir.
61
75
  export const projectRemoteDir = (name: string): string => `${WORKSPACE_ROOT}/${name}`;
@@ -1,5 +1,16 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { repoRoot } from "@intentic/constants/node";
1
4
  import { hostSshIdFromToken, PORT_SLOT_COUNT, portSlotsFromToken, sandboxIdFromToken } from "./tunnel-ids.js";
2
5
 
6
+ // The digests every language spells again (ic's util.rs, connect-host.ps1, sandbox-run's Web Crypto twin) are held to the
7
+ // run contract's names.fixture.json, which each of them is tested against. SAFETY: the fixture is this repository's own
8
+ // file; the assertions below fail on any field it lacks.
9
+ const SHARED = JSON.parse(readFileSync(join(repoRoot(import.meta.url), "_shared/sandbox-run/src/names.fixture.json"), "utf8")) as {
10
+ readonly sandboxIds: { readonly vectors: { readonly token: string; readonly id: string }[] };
11
+ readonly hostSshIds: { readonly vectors: { readonly token: string; readonly hostName: string; readonly id: string }[] };
12
+ };
13
+
3
14
  const TOKEN = "connect-token-one";
4
15
  const OTHER = "connect-token-two";
5
16
 
@@ -35,3 +46,22 @@ test("slots are not derivable from the sandbox id: only from the token behind it
35
46
  expect(portSlotsFromToken(TOKEN)).not.toContain(id);
36
47
  expect(portSlotsFromToken(TOKEN).some((slot) => slot.length === 1)).toBe(false);
37
48
  });
49
+
50
+ test.each(SHARED.sandboxIds.vectors)("the sandbox id of $token is $id, the digest ic and the browser derive too", ({ token, id }) => {
51
+ expect(sandboxIdFromToken(token)).toBe(id);
52
+ });
53
+
54
+ test.each(SHARED.hostSshIds.vectors)("the host ssh id of $token on $hostName is $id", ({ token, hostName, id }) => {
55
+ expect(hostSshIdFromToken(token, hostName)).toBe(id);
56
+ });
57
+
58
+ // connect-host.ps1 digests a server's id itself when a code's answer carries no HOST_ID, and no PowerShell runs in the
59
+ // suites. What can be held is its source: the same salted input (`<token>:<host name>`), the same hash, the same cut, over
60
+ // UTF-8 bytes, so a change to one half reads as a change to the other here.
61
+ test("connect-host.ps1 digests the host ssh id the way hostSshIdFromToken does", () => {
62
+ const script = readFileSync(join(repoRoot(import.meta.url), "_site/site/public/scripts/connect-host.ps1"), "utf8");
63
+ expect(script).toContain("$slugInput = '{0}:{1}' -f $ConnectToken, $HostName");
64
+ expect(script).toContain("[System.Security.Cryptography.SHA256]::Create()");
65
+ expect(script).toContain("[System.Text.Encoding]::UTF8.GetBytes($slugInput)");
66
+ expect(script).toContain("-replace '-', '').Substring(0, 12).ToLower()");
67
+ });
package/src/index.ts CHANGED
@@ -164,6 +164,8 @@ export * from "./models/agent-catalog.js";
164
164
  export * from "./policy/capability-env.js";
165
165
  export * from "./policy/command-classes.js";
166
166
  export * from "./policy/command-run.js";
167
+ export * from "./policy/device-programs.js";
168
+ export * from "./policy/sandbox-power.js";
167
169
  export * from "./policy/safety-policy.js";
168
170
  export * from "./policy/reserved-servers.js";
169
171
  export * from "./policy/credential-material.js";
@@ -205,6 +207,7 @@ export * from "./policy/persona-home.js";
205
207
  export * from "./schemas/agents.js";
206
208
  export * from "./schemas/speaker.js";
207
209
  export * from "./schemas/speech.js";
210
+ export * from "./schemas/voice.js";
208
211
  export * from "./schemas/approvals.js";
209
212
  export * from "./schemas/automations.js";
210
213
  export * from "./schemas/agents-list.js";
@@ -215,6 +218,7 @@ export * from "./schemas/providers/claude-gate.js";
215
218
  export * from "./schemas/codebase-health.js";
216
219
  export * from "./schemas/context-trim.js";
217
220
  export * from "./schemas/devices.js";
221
+ export * from "./schemas/device-swaps.js";
218
222
  export * from "./schemas/project-delivery.js";
219
223
  export * from "./schemas/device-artifacts.js";
220
224
  export * from "./schemas/run-targets.js";
@@ -265,6 +269,7 @@ export * from "./schemas/short-codes.js";
265
269
  export * from "./schemas/system-prompt.js";
266
270
  export * from "./schemas/system.js";
267
271
  export * from "./schemas/state-plan.js";
272
+ export * from "./schemas/restart-intent.js";
268
273
  export * from "./schemas/updates.js";
269
274
  export * from "./schemas/terminal.js";
270
275
  export * from "./schemas/turn-break.js";
@@ -280,11 +285,13 @@ export * from "./schemas/workspace/workspace-repos.js";
280
285
  export * from "./schemas/workspace/workspace-search.js";
281
286
  export * from "./schemas/workspace/workspace-setup.js";
282
287
  export * from "./schemas/workspace/workspace-tree.js";
288
+ export * from "./schemas/workspace/workspace-upload.js";
283
289
  export * from "./state/arrival.js";
284
290
  export * from "./state/definition.js";
285
291
  export * from "./policy/search-globs.js";
286
292
  export * from "./state/starter.js";
287
293
  export * from "./text/title.js";
294
+ export * from "./state/update-probation.js";
288
295
  export * from "./state/versions.js";
289
296
  export * from "./text/model-answer.js";
290
297
  export * from "./text/shell-regions.js";
@@ -73,6 +73,16 @@ export const MODEL_ROLES = [
73
73
  icon: "align-left",
74
74
  setup: "low",
75
75
  },
76
+ {
77
+ // Reads one finished reply (and the ask it answered) and writes what a person hears in hands-free voice: a few
78
+ // short sentences, never markdown. A fast model matters more than a strong one, since the person is waiting.
79
+ id: "spoken-reply",
80
+ label: "Spoken replies",
81
+ blurb: "The few sentences you hear when you talk to the agent hands-free: what it did and what it needs from you, while the full reply stays on screen.",
82
+ kind: "helper",
83
+ icon: "volume-up",
84
+ setup: "low",
85
+ },
76
86
  {
77
87
  // One reading answering both halves of "what does this chat open on": an offered `provider:model` line with an
78
88
  // effort and an account, and a persona id, each a classification over a fixed list rather than free text. The
@@ -36,3 +36,30 @@ export const CONTROL_SCOPE_REACH: readonly ControlScopeReach[] = [
36
36
  note: "Separate because the usual arrangement is a program that works and a person who decides.",
37
37
  },
38
38
  ];
39
+
40
+ // One control token as the roster lists it: everything but the secret, which the daemon keeps only as a hash.
41
+ export const ControlTokenSummarySchema = z.object({
42
+ id: z.string(),
43
+ label: z.string(),
44
+ scope: ControlScopeSchema,
45
+ createdAt: z.number(),
46
+ // The owner or maintainer who minted it, so the roster says whose decision each token was.
47
+ createdBy: z.string().optional(),
48
+ // Epoch ms after which the token is refused; absent means it lives until revoked.
49
+ expiresAt: z.number().optional(),
50
+ // Epoch ms of the last authorized request, coarse on purpose; absent means never used.
51
+ lastUsedAt: z.number().optional(),
52
+ });
53
+ export type ControlTokenSummary = z.infer<typeof ControlTokenSummarySchema>;
54
+ export const ControlTokensListSchema = z.object({ tokens: z.array(ControlTokenSummarySchema) });
55
+
56
+ // POST /system/control/tokens: scope is required, never defaulted; expiry is optional and absent means never.
57
+ export const ControlTokenMintSchema = z.object({
58
+ scope: ControlScopeSchema,
59
+ label: z.string().optional(),
60
+ expiresAt: z.number().optional(),
61
+ });
62
+ export type ControlTokenMint = z.infer<typeof ControlTokenMintSchema>;
63
+ // The raw token, shown exactly once.
64
+ export const ControlTokenMintedSchema = z.object({ id: z.string(), token: z.string() });
65
+ export type ControlTokenMinted = z.infer<typeof ControlTokenMintedSchema>;
@@ -0,0 +1,137 @@
1
+ import { z } from "zod";
2
+
3
+ // WHICH DEVICE TOOLS CARRY A PROGRAM (2026-10-12). A command on somebody's own computer arrives by more doors than
4
+ // `run_command`: `app_start` starts a program with arguments, `open` starts whatever its target names (on Linux a
5
+ // target with spaces is split into a program and arguments and spawned), `android_shell` hands a line to a phone's
6
+ // shell, and the input tools put text where a terminal could run it. The daemon's gate used to know two of those by
7
+ // name, the machine's own checks a different few, and `app_start program=powershell args=[…]` slipped past the judge, the
8
+ // card and the unattended refusal. This is the one table both ends read: the tool, and how to lift the command line
9
+ // out of its arguments. A device tool that carries a program is added here, or the machine's test says it is missing.
10
+
11
+ /**
12
+ * What a call's program is, which decides how it is read:
13
+ * - `command`: a command line for the machine's own shell, or a program started with arguments; judged as `run_command`.
14
+ * - `phone-command`: a line for an attached phone's shell, judged as the `adb shell` program the machine runs for it.
15
+ * - `app-launch`: an application, URL or document started with nothing to interpret; the ordinary GUI launch, low risk.
16
+ * - `typed`: text put where a terminal could run it (typed, pasted, set into a field).
17
+ */
18
+ export type ProgramKind = "command" | "phone-command" | "app-launch" | "typed";
19
+
20
+ /** The program a tool call carries, as the classifier and the judge read it. */
21
+ export interface ProgramReading {
22
+ readonly tool: string;
23
+ readonly kind: ProgramKind;
24
+ /** The command line as a shell would read it. */
25
+ readonly commandLine: string;
26
+ }
27
+
28
+ // The fields of every tool in the table below. One that is the wrong type reads as absent rather than failing the
29
+ // parse: such a call never runs, since the device checks its input against its own schema before anything else.
30
+ export const ProgramArgumentsSchema = z.looseObject({
31
+ command: z.string().optional().catch(undefined),
32
+ serial: z.string().optional().catch(undefined),
33
+ program: z.string().optional().catch(undefined),
34
+ args: z.array(z.string()).optional().catch(undefined),
35
+ target: z.string().optional().catch(undefined),
36
+ action: z.string().optional().catch(undefined),
37
+ text: z.string().optional().catch(undefined),
38
+ value: z.string().optional().catch(undefined),
39
+ });
40
+ export type ProgramArguments = z.infer<typeof ProgramArgumentsSchema>;
41
+
42
+ interface ProgramCarrier {
43
+ readonly tool: string;
44
+ /** In words: where the program is in the arguments. */
45
+ readonly carries: string;
46
+ readonly read: (args: ProgramArguments) => Omit<ProgramReading, "tool"> | undefined;
47
+ }
48
+
49
+ const present = (value: string | undefined): string | undefined => (value === undefined || value.trim() === "" ? undefined : value);
50
+
51
+ // A program by the name it is known by (`rm`, `powershell`), not the path or the `.exe` it was started through.
52
+ const programName = (program: string): string => (program.split(/[/\\]/).at(-1) ?? program).replace(/\.exe$/i, "");
53
+
54
+ /**
55
+ * The program and its arguments as the one command line they amount to: the program by its name, each argument quoted
56
+ * only where a shell would need it, so the line reads the way a person would type it.
57
+ */
58
+ export const commandLineOf = (program: string, args: readonly string[]): string =>
59
+ [
60
+ programName(program),
61
+ ...args.map((arg) => (arg === "" || /[\s'"|;&<>()$`\\]/.test(arg) ? (arg.includes("'") ? `"${arg}"` : `'${arg}'`) : arg)),
62
+ ].join(" ");
63
+
64
+ // A phone's serial as adb prints one (a USB serial, `ip:port`, an mDNS name), and nothing else: a serial is spliced into
65
+ // the program judged below, where a `#` or a quote in it could make the command after it read as inert.
66
+ const ADB_SERIAL = /^[\w.:-]+$/;
67
+
68
+ // A URL, or `www.`/`mailto:` which the machine opens the same way (desktop-automation's looksLikeUrl).
69
+ const URL_TARGET = /^(?:[a-z][a-z0-9+.-]*:\/\/|www\.|mailto:)/i;
70
+
71
+ const typedBy = (tool: string, action: string, field: "text" | "value"): ProgramCarrier => ({
72
+ tool,
73
+ carries: `${field}, when action is ${action}`,
74
+ read: (args) => {
75
+ const typed = args.action === action ? present(args[field]) : undefined;
76
+ return typed === undefined ? undefined : { kind: "typed", commandLine: typed };
77
+ },
78
+ });
79
+
80
+ /** Every device tool that carries a program. */
81
+ export const DEVICE_PROGRAM_TOOLS: readonly ProgramCarrier[] = [
82
+ {
83
+ tool: "run_command",
84
+ carries: "command",
85
+ read: ({ command }) => {
86
+ const line = present(command);
87
+ return line === undefined ? undefined : { kind: "command", commandLine: line };
88
+ },
89
+ },
90
+ {
91
+ tool: "android_shell",
92
+ carries: "command, run as `adb [-s serial] shell <command>`",
93
+ read: ({ command, serial }) => {
94
+ const line = present(command);
95
+ if (line === undefined) {
96
+ return undefined;
97
+ }
98
+ return { kind: "phone-command", commandLine: `adb ${serial !== undefined && ADB_SERIAL.test(serial) ? `-s ${serial} ` : ""}shell ${line}` };
99
+ },
100
+ },
101
+ {
102
+ tool: "app_start",
103
+ carries: "program and args",
104
+ read: ({ program, args }) => {
105
+ const started = present(program);
106
+ if (started === undefined) {
107
+ return undefined;
108
+ }
109
+ // With nothing to interpret it is a launch; with arguments it is a command by another door, whatever the
110
+ // program is: `rm -rf ~`, `curl … | sh` and `powershell -c …` read the same here as in run_command.
111
+ return { kind: args === undefined || args.length === 0 ? "app-launch" : "command", commandLine: commandLineOf(started, args ?? []) };
112
+ },
113
+ },
114
+ {
115
+ tool: "open",
116
+ carries: "target",
117
+ read: ({ target }) => {
118
+ const opened = present(target);
119
+ if (opened === undefined) {
120
+ return undefined;
121
+ }
122
+ // A URL or a single word (an application name, a file path) is opened. A target with spaces that is not a URL
123
+ // can be split into a program and arguments and spawned, so it is read as the command it may be.
124
+ return { kind: URL_TARGET.test(opened) || !/\s/.test(opened.trim()) ? "app-launch" : "command", commandLine: opened };
125
+ },
126
+ },
127
+ typedBy("device", "type", "text"),
128
+ typedBy("android_act", "type", "text"),
129
+ typedBy("clipboard", "write", "text"),
130
+ typedBy("ui_act", "set_value", "value"),
131
+ ];
132
+
133
+ /** The program a `tools/call` of `tool` carries, or undefined for a tool that carries none or a call with nothing in the place the table looks. */
134
+ export const programInCall = (tool: string, args: ProgramArguments): ProgramReading | undefined => {
135
+ const read = DEVICE_PROGRAM_TOOLS.find((carrier) => carrier.tool === tool)?.read(args);
136
+ return read === undefined ? undefined : { tool, ...read };
137
+ };
@@ -0,0 +1,130 @@
1
+ import { createPrivateKey, createPublicKey, randomBytes, sign as edSign, verify as edVerify } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { SANDBOX_ID } from "../ids/tunnel-ids.js";
4
+
5
+ // The platform's signed word that a signed-in person controls an email address, spent on one sandbox's daemon. It is how
6
+ // an address no identity provider vouches for (a custom domain on Fastmail, Proton, a company's own mail server) reaches
7
+ // a sandbox: the platform confirmed the address by mailing it a code, and nobody else could. The daemon accepts it only
8
+ // where its owner opted in for that person (the owner record or the member row says `emailSignIn`), and on hosted
9
+ // machines, where the platform already holds the machine. Signed with a key of its own (IDENTITY_SIGNING_KEY), never the
10
+ // ingress key, so neither credential can stand in for the other.
11
+
12
+ const ASSERTION_PREFIX = "pa1";
13
+
14
+ // One round trip to the daemon and a retry; a lifted assertion is dead before anyone reads the log it sat in.
15
+ export const IDENTITY_ASSERTION_TTL_MS = 2 * 60_000;
16
+ // The longest life a daemon accepts whatever the claim says, so a platform minting with a wrong clock or a wrong ttl
17
+ // still cannot hand out a credential that outlives a replay window the daemon can hold in memory.
18
+ export const IDENTITY_ASSERTION_MAX_TTL_MS = 5 * 60_000;
19
+
20
+ // The env var that carries the platform's identity key on a hosted machine, which boots before its first announce. A
21
+ // sandbox on its owner's own machine learns the same key from the announce answer (`AnnounceAcceptedSchema`).
22
+ export const ENV_PLATFORM_IDENTITY_KEY = "PLATFORM_IDENTITY_KEY";
23
+
24
+ // How the platform confirmed the address it vouches for: a code it mailed, or a Google sign-in it verified.
25
+ export const IdentityAssertionMethodSchema = z.enum(["email", "google"]);
26
+ export type IdentityAssertionMethod = z.infer<typeof IdentityAssertionMethodSchema>;
27
+
28
+ // The signed claim, times in seconds like the owner ticket's. `nonce` is unique per mint: the daemon spends each once.
29
+ export interface IdentityAssertion {
30
+ readonly sandboxId: string;
31
+ readonly email: string;
32
+ readonly method: IdentityAssertionMethod;
33
+ readonly issuedAt: number;
34
+ readonly expiresAt: number;
35
+ readonly nonce: string;
36
+ }
37
+
38
+ const base64url = (bytes: Buffer): string => bytes.toString("base64url");
39
+
40
+ // The prefix is part of what is signed, so an assertion is never a valid signature over anything else the same key
41
+ // might one day sign, and an owner ticket or a grant (which sign their bare payload) is never an assertion.
42
+ const signedBytes = (payload: Buffer): Buffer => Buffer.concat([Buffer.from(`${ASSERTION_PREFIX}.`, "utf8"), payload]);
43
+
44
+ export const isIdentityAssertion = (bearer: string): boolean => bearer.startsWith(`${ASSERTION_PREFIX}.`);
45
+
46
+ export const mintIdentityAssertion = (
47
+ privateKeyPem: string,
48
+ claim: {
49
+ readonly sandboxId: string;
50
+ readonly email: string;
51
+ readonly method: IdentityAssertionMethod;
52
+ readonly issuedAtMs: number;
53
+ readonly ttlMs?: number;
54
+ },
55
+ ): string => {
56
+ if (!SANDBOX_ID.test(claim.sandboxId)) {
57
+ throw new Error(`an identity assertion names a 12-hex sandbox id, got "${claim.sandboxId}"`);
58
+ }
59
+ if (claim.email.trim() === "") {
60
+ throw new Error("an identity assertion names an address");
61
+ }
62
+ const ttlMs = claim.ttlMs ?? IDENTITY_ASSERTION_TTL_MS;
63
+ if (ttlMs <= 0 || ttlMs > IDENTITY_ASSERTION_MAX_TTL_MS) {
64
+ throw new Error(`an identity assertion lives at most ${IDENTITY_ASSERTION_MAX_TTL_MS} ms`);
65
+ }
66
+ const iat = Math.floor(claim.issuedAtMs / 1000);
67
+ const exp = Math.floor((claim.issuedAtMs + ttlMs) / 1000);
68
+ const payload = Buffer.from(
69
+ JSON.stringify({
70
+ v: 1,
71
+ sub: claim.sandboxId,
72
+ email: claim.email.trim().toLowerCase(),
73
+ amr: claim.method,
74
+ iat,
75
+ exp,
76
+ jti: base64url(randomBytes(16)),
77
+ }),
78
+ "utf8",
79
+ );
80
+ const signature = edSign(null, signedBytes(payload), createPrivateKey(privateKeyPem));
81
+ return `${ASSERTION_PREFIX}.${base64url(payload)}.${base64url(signature)}`;
82
+ };
83
+
84
+ // What the signed payload must be, read only after the signature checked out.
85
+ const AssertionPayloadSchema = z.object({
86
+ v: z.literal(1),
87
+ sub: z.string().regex(SANDBOX_ID),
88
+ email: z.string().min(1),
89
+ amr: IdentityAssertionMethodSchema,
90
+ iat: z.number(),
91
+ exp: z.number(),
92
+ jti: z.string().min(16),
93
+ });
94
+
95
+ const parsePayload = (payload: Buffer): z.infer<typeof AssertionPayloadSchema> | undefined => {
96
+ try {
97
+ return AssertionPayloadSchema.safeParse(JSON.parse(payload.toString("utf8"))).data;
98
+ } catch {
99
+ // allow(silent-catch): a payload that is not JSON is an invalid assertion, which undefined is how this says.
100
+ return undefined;
101
+ }
102
+ };
103
+
104
+ // Verifies an assertion against the platform's public key at nowMs; undefined for any way of being invalid (wrong
105
+ // prefix, bad signature, malformed claim, expired, issued in the future, living longer than the daemon allows). The
106
+ // caller checks sandboxId against its own id and spends the nonce.
107
+ export const verifyIdentityAssertion = (publicKeyPem: string, token: string, nowMs: number): IdentityAssertion | undefined => {
108
+ const [prefix, encodedPayload, encodedSignature, ...rest] = token.split(".");
109
+ if (prefix !== ASSERTION_PREFIX || encodedPayload === undefined || encodedSignature === undefined || rest.length > 0) {
110
+ return undefined;
111
+ }
112
+ const payload = Buffer.from(encodedPayload, "base64url");
113
+ try {
114
+ if (!edVerify(null, signedBytes(payload), createPublicKey(publicKeyPem), Buffer.from(encodedSignature, "base64url"))) {
115
+ return undefined;
116
+ }
117
+ } catch {
118
+ // allow(silent-catch): a key or signature that does not parse verifies nothing: the assertion is invalid, said as undefined.
119
+ return undefined;
120
+ }
121
+ const claim = parsePayload(payload);
122
+ if (claim === undefined) {
123
+ return undefined;
124
+ }
125
+ // A minute of clock skew either way, as the daemon allows Google's tokens; never a claim from further ahead.
126
+ if (claim.exp * 1000 <= nowMs || claim.iat * 1000 > nowMs + 60_000 || (claim.exp - claim.iat) * 1000 > IDENTITY_ASSERTION_MAX_TTL_MS) {
127
+ return undefined;
128
+ }
129
+ return { sandboxId: claim.sub, email: claim.email, method: claim.amr, issuedAt: claim.iat, expiresAt: claim.exp, nonce: claim.jti };
130
+ };