toolroll 0.9.71 → 0.9.73
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.
- package/CHANGELOG.md +155 -0
- package/dist/agent-fence.d.ts +10 -2
- package/dist/agent-fence.js +47 -9
- package/dist/approval-authority.d.ts +20 -0
- package/dist/approval-authority.js +67 -0
- package/dist/assignment.js +26 -4
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +28 -28
- package/dist/builder.js +48 -6
- package/dist/chat-actions.js +13 -8
- package/dist/chat-decide.d.ts +47 -1
- package/dist/chat-decide.js +186 -5
- package/dist/check-access.js +2 -2
- package/dist/check-report.d.ts +109 -0
- package/dist/check-report.js +205 -0
- package/dist/claim.js +1 -1
- package/dist/coding-provider.js +4 -2
- package/dist/commit-extract.d.ts +111 -0
- package/dist/commit-extract.js +514 -0
- package/dist/contracts/console-api.d.ts +4 -4
- package/dist/contracts/console-api.js +5 -4
- package/dist/contracts/flow.d.ts +16 -0
- package/dist/contracts/flow.js +3 -0
- package/dist/contracts/release-check.d.ts +335 -0
- package/dist/contracts/release-check.js +159 -0
- package/dist/contracts/release-terms.d.ts +66 -0
- package/dist/contracts/release-terms.js +74 -0
- package/dist/contracts/verification-receipt.d.ts +42 -8
- package/dist/contracts/verification-receipt.js +24 -7
- package/dist/contracts/verification-view.d.ts +1 -0
- package/dist/deploy-gate.d.ts +17 -0
- package/dist/deploy-gate.js +68 -0
- package/dist/dispatch.js +3 -10
- package/dist/dispose.js +4 -1
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +20 -2
- package/dist/evidence.d.ts +1 -0
- package/dist/evidence.js +2 -1
- package/dist/exec.d.ts +1 -1
- package/dist/exec.js +7 -3
- package/dist/flow-engine.js +5 -1
- package/dist/flow-gallery-ui.js +4 -2
- package/dist/flow-gallery.d.ts +3 -0
- package/dist/flow-gallery.js +6 -2
- package/dist/flow-pull-request.d.ts +5 -2
- package/dist/flow-pull-request.js +14 -5
- package/dist/flow-schedule.js +25 -6
- package/dist/flow-triggers.d.ts +8 -0
- package/dist/flow-triggers.js +15 -0
- package/dist/flows-cli.js +13 -3
- package/dist/flows-ui.js +7 -1
- package/dist/guides.js +10 -1
- package/dist/invoke.d.ts +17 -6
- package/dist/invoke.js +48 -18
- package/dist/kits-ui.js +4 -2
- package/dist/kits.d.ts +6 -4
- package/dist/kits.js +9 -11
- package/dist/lead-status.js +5 -2
- package/dist/live-terminal.d.ts +10 -6
- package/dist/live-terminal.js +55 -14
- package/dist/mcp-connect.d.ts +10 -0
- package/dist/mcp-connect.js +2 -0
- package/dist/nightly-check.d.ts +104 -0
- package/dist/nightly-check.js +362 -0
- package/dist/operate.js +39 -2
- package/dist/person-proof.d.ts +2 -0
- package/dist/person-proof.js +14 -0
- package/dist/plan-auto.js +4 -4
- package/dist/planner-source.d.ts +1 -1
- package/dist/project-delete-ui.js +1 -1
- package/dist/project-tools.js +3 -1
- package/dist/proof.js +2 -0
- package/dist/protected-storage.d.ts +67 -0
- package/dist/protected-storage.js +398 -0
- package/dist/publish.js +156 -144
- package/dist/pull-request-flow.d.ts +2 -0
- package/dist/pull-request-flow.js +24 -2
- package/dist/release-adapters.d.ts +16 -1
- package/dist/release-adapters.js +292 -29
- package/dist/release-authority.d.ts +118 -0
- package/dist/release-authority.js +135 -0
- package/dist/release-card.d.ts +76 -0
- package/dist/release-card.js +252 -0
- package/dist/release-carry.d.ts +236 -0
- package/dist/release-carry.js +664 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +158 -6
- package/dist/release-coverage.js +3 -0
- package/dist/release-git.d.ts +43 -0
- package/dist/release-git.js +106 -0
- package/dist/release-recheck.d.ts +146 -0
- package/dist/release-recheck.js +657 -0
- package/dist/release-view.d.ts +34 -0
- package/dist/release-view.js +27 -0
- package/dist/release.d.ts +139 -3
- package/dist/release.js +641 -250
- package/dist/remote-main.d.ts +2 -0
- package/dist/remote-main.js +1 -1
- package/dist/result-completion.d.ts +6 -0
- package/dist/result-completion.js +15 -1
- package/dist/result-follow-ups.js +29 -1
- package/dist/sandbox-runtime.d.ts +16 -5
- package/dist/sandbox-runtime.js +19 -6
- package/dist/scope.d.ts +122 -5
- package/dist/scope.js +236 -7
- package/dist/server/flows.js +40 -10
- package/dist/server/render-settings.js +1 -1
- package/dist/server/render-tasks.d.ts +8 -0
- package/dist/server/render-tasks.js +50 -9
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +26 -5
- package/dist/server/task-views.js +4 -0
- package/dist/server/tasks.js +12 -0
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +230 -5
- package/dist/store.js +733 -37
- package/dist/subagent-memory.d.ts +1 -0
- package/dist/subagent-memory.js +1 -1
- package/dist/subagent-tools.d.ts +22 -6
- package/dist/subagent-tools.js +44 -8
- package/dist/subagent-work.d.ts +2 -0
- package/dist/subagent-work.js +5 -2
- package/dist/subagents-ui.js +6 -4
- package/dist/task-outcome-cli.js +7 -3
- package/dist/telegram.d.ts +6 -1
- package/dist/telegram.js +58 -6
- package/dist/toolroll-update.js +2 -2
- package/dist/verification-evidence.d.ts +25 -1
- package/dist/verification-evidence.js +57 -2
- package/dist/work-index.js +3 -6
- package/package.json +1 -1
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protected storage (docs/plans/chat-management.md §1.1): where the person's chat apps, browser profiles and password
|
|
3
|
+
* managers keep their sessions, denied to every launch Toolroll fences, read and write, under both fence policies and
|
|
4
|
+
* on every backend. A linked chat account is not proof of a person: whatever holds the Slack desktop's token, Telegram
|
|
5
|
+
* Desktop's `tdata` or a browser's cookies can press the bot's buttons as them. So no build, check, flow or tool server
|
|
6
|
+
* may read those, whatever its project's settings say.
|
|
7
|
+
*
|
|
8
|
+
* The list is outside HOME_CONFIGURATION (agent-fence.ts), which only version-1 fences read: `agentFence` adds these to
|
|
9
|
+
* every fence it makes, as paths no grant can reopen, and `taskBoundary` names them for both policies.
|
|
10
|
+
*
|
|
11
|
+
* Each root is denied by its name, present or not (a build can't make the folder now and swap in a link later), and by
|
|
12
|
+
* its real path (a root that is itself a link to a relocated folder). Inside each present root a bounded walk that
|
|
13
|
+
* never follows a link finds links leading outward and denies where they lead. The overrides Toolroll can know are read
|
|
14
|
+
* from the plane's own environment at each launch, never from a checkout: XDG_CONFIG_HOME and XDG_DATA_HOME (Linux),
|
|
15
|
+
* OP_CONFIG_DIR, BITWARDENCLI_APPDATA_DIR, and the profiles Firefox's (and Zen's) profiles.ini places elsewhere.
|
|
16
|
+
* Storage Toolroll doesn't know about (a browser started with its own --user-data-dir, a portable app) isn't covered.
|
|
17
|
+
*
|
|
18
|
+
* The chat and vault variables (SLACK_*, TELEGRAM_*, DISCORD_*, TEAMS_*, OP_*, BW_*, BITWARDENCLI_*) are kept out of
|
|
19
|
+
* every launch's environment (`withoutProtectedEnv`, exec.ts).
|
|
20
|
+
*/
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import { closeSync, existsSync, fstatSync, lstatSync, openSync, constants as fsConstants, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
25
|
+
import { configBase, namedFolder } from "./names.js";
|
|
26
|
+
const AS = "Library/Application Support";
|
|
27
|
+
const CONTAINERS = "Library/Containers";
|
|
28
|
+
const GROUPS = "Library/Group Containers";
|
|
29
|
+
/**
|
|
30
|
+
* Every listed root. Checked against a real Mac (2026-10-25): existence only, never a file's contents. The 1Password
|
|
31
|
+
* containers and the extra Chrome, Edge and other browsers' folders below were found there and added.
|
|
32
|
+
*/
|
|
33
|
+
export const PROTECTED_STORAGE = [
|
|
34
|
+
{ app: "Slack", darwin: [`${AS}/Slack`, `${CONTAINERS}/com.tinyspeck.slackmacgap`], linux: [".config/Slack", "snap/slack", ".var/app/com.slack.Slack"] },
|
|
35
|
+
{ app: "Telegram", darwin: [`${AS}/Telegram Desktop`, `${CONTAINERS}/org.telegram.desktop`, `${GROUPS}/6N38VWS5BX.ru.keepcoder.Telegram`, `${CONTAINERS}/ru.keepcoder.Telegram`],
|
|
36
|
+
linux: [".local/share/TelegramDesktop", "snap/telegram-desktop", ".var/app/org.telegram.desktop"] },
|
|
37
|
+
{ app: "Discord", darwin: [`${AS}/discord`, `${AS}/discordptb`, `${AS}/discordcanary`],
|
|
38
|
+
linux: [".config/discord", ".config/discordptb", ".config/discordcanary", "snap/discord", ".var/app/com.discordapp.Discord"] },
|
|
39
|
+
// New Teams keeps its sign-in in Microsoft's shared sign-in cache (OneAuth) too.
|
|
40
|
+
{ app: "Teams", darwin: [`${CONTAINERS}/com.microsoft.teams2`, `${GROUPS}/UBF8T346G9.com.microsoft.teams`, `${AS}/Microsoft/Teams`, `${GROUPS}/UBF8T346G9.com.microsoft.oneauth`],
|
|
41
|
+
linux: [".config/Microsoft/Microsoft Teams", ".config/teams-for-linux"] },
|
|
42
|
+
{ app: "WhatsApp", darwin: [`${CONTAINERS}/net.whatsapp.WhatsApp`, `${GROUPS}/group.net.whatsapp.WhatsApp.shared`, `${GROUPS}/group.net.whatsapp.WhatsApp.private`, `${GROUPS}/group.net.whatsapp.family`], linux: [] },
|
|
43
|
+
{ app: "1Password",
|
|
44
|
+
darwin: [`${GROUPS}/2BUA8C4S2C.com.1password`, `${GROUPS}/group.2BUA8C4S2C.com.1password`, `${GROUPS}/2BUA8C4S2C.com.agilebits`, `${CONTAINERS}/com.1password.1password`,
|
|
45
|
+
`${CONTAINERS}/com.1password.1password-launcher`, `${CONTAINERS}/com.1password.browser-support`, `${CONTAINERS}/2BUA8C4S2C.com.1password.browser-helper`,
|
|
46
|
+
`${CONTAINERS}/com.1password.1password.autofill-extension`, `${AS}/1Password`, ".config/1Password", ".config/op", ".op"],
|
|
47
|
+
linux: [".config/1Password", "snap/1password", ".var/app/com.onepassword.OnePassword", ".config/op", ".op"] },
|
|
48
|
+
{ app: "Bitwarden", darwin: [`${AS}/Bitwarden`, `${CONTAINERS}/com.bitwarden.desktop`, `${GROUPS}/LTZ2PFU5D6.com.bitwarden.desktop`, `${AS}/Bitwarden CLI`],
|
|
49
|
+
linux: [".config/Bitwarden", "snap/bitwarden", ".var/app/com.bitwarden.desktop", ".config/Bitwarden CLI"] },
|
|
50
|
+
// The browser extensions of both password managers live inside the browser profiles below.
|
|
51
|
+
{ app: "Chrome", darwin: [`${AS}/Google/Chrome`, `${AS}/Google/Chrome Beta`, `${AS}/Google/Chrome Canary`, `${AS}/Google/Chrome Dev`],
|
|
52
|
+
linux: [".config/google-chrome", ".config/google-chrome-beta", ".config/google-chrome-unstable", ".var/app/com.google.Chrome"] },
|
|
53
|
+
{ app: "Chromium", darwin: [`${AS}/Chromium`], linux: [".config/chromium", "snap/chromium", ".var/app/org.chromium.Chromium"] },
|
|
54
|
+
{ app: "Brave", darwin: [`${AS}/BraveSoftware`], linux: [".config/BraveSoftware", "snap/brave", ".var/app/com.brave.Browser"] },
|
|
55
|
+
{ app: "Edge", darwin: [`${AS}/Microsoft Edge`, `${AS}/Microsoft Edge Beta`, `${AS}/Microsoft Edge Dev`, `${AS}/Microsoft Edge Canary`],
|
|
56
|
+
linux: [".config/microsoft-edge", ".config/microsoft-edge-beta", ".config/microsoft-edge-dev", ".var/app/com.microsoft.Edge"] },
|
|
57
|
+
{ app: "Arc", darwin: [`${AS}/Arc`], linux: [] },
|
|
58
|
+
{ app: "Firefox", darwin: [`${AS}/Firefox`], linux: [".mozilla/firefox", "snap/firefox", ".var/app/org.mozilla.firefox"] },
|
|
59
|
+
{ app: "Zen", darwin: [`${AS}/zen`], linux: [".zen", ".var/app/app.zen_browser.zen"] },
|
|
60
|
+
{ app: "Vivaldi", darwin: [`${AS}/Vivaldi`, `${AS}/Vivaldi Snapshot`], linux: [".config/vivaldi", ".config/vivaldi-snapshot"] },
|
|
61
|
+
{ app: "Opera", darwin: [`${AS}/com.operasoftware.Opera`], linux: [".config/opera"] },
|
|
62
|
+
{ app: "Orion", darwin: [`${AS}/Orion`], linux: [] },
|
|
63
|
+
{ app: "Safari", darwin: ["Library/Safari", `${CONTAINERS}/com.apple.Safari`, `${CONTAINERS}/com.apple.Safari.WebApp`, "Library/Cookies", "Library/HTTPStorages", "Library/WebKit"], linux: [] },
|
|
64
|
+
];
|
|
65
|
+
/** The roots whose profiles.ini may place a profile anywhere (Firefox and its forks). */
|
|
66
|
+
const PROFILE_INDEXES = {
|
|
67
|
+
darwin: [`${AS}/Firefox`, `${AS}/zen`],
|
|
68
|
+
linux: [".mozilla/firefox", "snap/firefox/common/.mozilla/firefox", ".var/app/org.mozilla.firefox/.mozilla/firefox", ".zen"],
|
|
69
|
+
};
|
|
70
|
+
/** The chat and vault variables no launch gets. */
|
|
71
|
+
export const PROTECTED_ENV_PREFIXES = ["SLACK_", "TELEGRAM_", "DISCORD_", "TEAMS_", "OP_", "BW_", "BITWARDENCLI_"];
|
|
72
|
+
export function isProtectedEnvName(name) {
|
|
73
|
+
const upper = name.toUpperCase();
|
|
74
|
+
return PROTECTED_ENV_PREFIXES.some(prefix => upper.startsWith(prefix));
|
|
75
|
+
}
|
|
76
|
+
/** `env` without a chat or vault variable. */
|
|
77
|
+
export function withoutProtectedEnv(env) {
|
|
78
|
+
const out = { ...env };
|
|
79
|
+
for (const name of Object.keys(out))
|
|
80
|
+
if (isProtectedEnvName(name))
|
|
81
|
+
delete out[name];
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** How far the link walk goes in each present root. */
|
|
85
|
+
export const PROTECTED_WALK = { depth: 4, entries: 5_000 };
|
|
86
|
+
const real = (path) => { try {
|
|
87
|
+
return realpathSync(path);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return resolve(path);
|
|
91
|
+
} };
|
|
92
|
+
const within = (child, parent) => { const rel = relative(parent, child); return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); };
|
|
93
|
+
const platformOf = (platform) => platform === "darwin" ? "darwin" : platform === "linux" ? "linux" : null;
|
|
94
|
+
/** A path the fence can't deny without denying the machine: `/`, the home folder or a folder above it. */
|
|
95
|
+
function tooWide(path, home) {
|
|
96
|
+
return !isAbsolute(path) || path === dirname(path) || path === home || within(home, path);
|
|
97
|
+
}
|
|
98
|
+
/** The roots by name, under the (real) home folder and the overrides the plane's environment names. */
|
|
99
|
+
export function protectedStorageNames(home = homedir(), env = process.env, platform = process.platform) {
|
|
100
|
+
const os = platformOf(platform);
|
|
101
|
+
if (os === null)
|
|
102
|
+
return [];
|
|
103
|
+
const base = real(home);
|
|
104
|
+
const names = new Set();
|
|
105
|
+
const add = (path) => { if (!tooWide(path, base))
|
|
106
|
+
names.add(path); };
|
|
107
|
+
const relatives = PROTECTED_STORAGE.flatMap(row => row[os]);
|
|
108
|
+
for (const one of relatives)
|
|
109
|
+
add(join(base, one));
|
|
110
|
+
if (os === "linux") {
|
|
111
|
+
const absolute = (name) => { const value = env[name]; return value !== undefined && isAbsolute(value) ? value : null; };
|
|
112
|
+
const config = absolute("XDG_CONFIG_HOME"), data = absolute("XDG_DATA_HOME");
|
|
113
|
+
for (const one of relatives) {
|
|
114
|
+
if (config !== null && one.startsWith(".config/"))
|
|
115
|
+
add(join(config, one.slice(".config/".length)));
|
|
116
|
+
if (data !== null && one.startsWith(".local/share/"))
|
|
117
|
+
add(join(data, one.slice(".local/share/".length)));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const name of ["OP_CONFIG_DIR", "BITWARDENCLI_APPDATA_DIR"]) {
|
|
121
|
+
const value = env[name];
|
|
122
|
+
if (value !== undefined && isAbsolute(value))
|
|
123
|
+
add(resolve(value));
|
|
124
|
+
}
|
|
125
|
+
for (const index of PROFILE_INDEXES[os])
|
|
126
|
+
for (const profile of profilesElsewhere(join(base, index)))
|
|
127
|
+
add(profile);
|
|
128
|
+
return [...names].sort();
|
|
129
|
+
}
|
|
130
|
+
/** The absolute `Path=` entries of a profiles.ini in `root`, read only as the plain file it is, bounded. */
|
|
131
|
+
function profilesElsewhere(root) {
|
|
132
|
+
const file = join(root, "profiles.ini");
|
|
133
|
+
let fd;
|
|
134
|
+
try {
|
|
135
|
+
fd = openSync(file, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const held = fstatSync(fd);
|
|
142
|
+
if (!held.isFile())
|
|
143
|
+
return [];
|
|
144
|
+
const buffer = Buffer.alloc(Math.min(held.size, 64 * 1024));
|
|
145
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0);
|
|
146
|
+
return buffer.subarray(0, read).toString("utf8").split(/\r?\n/)
|
|
147
|
+
.map(line => /^\s*Path\s*=\s*(.+?)\s*$/i.exec(line)?.[1] ?? null)
|
|
148
|
+
.filter((path) => path !== null && isAbsolute(path));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
closeSync(fd);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const unwalkedSaid = new Set();
|
|
158
|
+
/**
|
|
159
|
+
* Every protected path one launch denies: each root by name and by real path, every link inside a present root that
|
|
160
|
+
* leads outward (where it leads, by real path and as spelled), and the overrides. Resolved at each launch, never kept.
|
|
161
|
+
* A link macOS makes in a sandboxed app's container to the same place under the home folder (its Desktop, Downloads,
|
|
162
|
+
* Fonts…) is the system's own mirror of the person's folders, not the app's data, and isn't followed (systemMirror).
|
|
163
|
+
*
|
|
164
|
+
* On a Mac, listing inside another app's container waits for the person to answer macOS's "access data from other apps"
|
|
165
|
+
* prompt when the process asking has no answer yet (a plane run by launchd: every launch, and the console with it, hung
|
|
166
|
+
* there in release check 3249). So the containers are walked only after a time-limited look in a child process
|
|
167
|
+
* (`containers`, containersListable) shows they can be listed without waiting. A look that fails any other way refuses
|
|
168
|
+
* the launch (it throws). When macOS is waiting on the person, the containers stay denied by name and real path, and
|
|
169
|
+
* the outward links the last complete walk found (kept in the plane's own state folder, linkCacheFile) stay denied too;
|
|
170
|
+
* with none kept, only the roots are denied, and that is said once.
|
|
171
|
+
*/
|
|
172
|
+
export function protectedStorageDenies(home = homedir(), env = process.env, platform = process.platform, say = line => process.stderr.write(`${line}\n`), containers = roots => containersListable(roots), list = path => readdirSync(path, { withFileTypes: true })) {
|
|
173
|
+
const base = real(home);
|
|
174
|
+
const out = new Set();
|
|
175
|
+
const add = (path) => { if (!tooWide(path, base))
|
|
176
|
+
out.add(path); };
|
|
177
|
+
const present = [];
|
|
178
|
+
for (const name of protectedStorageNames(base, env, platform)) {
|
|
179
|
+
add(name);
|
|
180
|
+
try {
|
|
181
|
+
lstatSync(name);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
continue; /* denied by name only */
|
|
185
|
+
}
|
|
186
|
+
const root = real(name);
|
|
187
|
+
add(root);
|
|
188
|
+
present.push({ name, root });
|
|
189
|
+
}
|
|
190
|
+
const held = platform === "darwin" ? present.filter(one => inAppContainer(one.root, base)).map(one => one.root) : [];
|
|
191
|
+
// Throws when the look itself failed: the launch is refused, never fenced with less.
|
|
192
|
+
const listable = held.length === 0 || containers(held);
|
|
193
|
+
const cache = held.length === 0 ? null : linkCacheFile(base, env);
|
|
194
|
+
if (!listable) {
|
|
195
|
+
const kept = cache === null ? null : readLinkCache(cache, base);
|
|
196
|
+
for (const target of kept ?? [])
|
|
197
|
+
add(target);
|
|
198
|
+
if (!unwalkedSaid.has(CONTAINERS_UNLISTED)) {
|
|
199
|
+
unwalkedSaid.add(CONTAINERS_UNLISTED);
|
|
200
|
+
say(`toolroll: fence: macOS didn't let Toolroll look inside other apps' containers without asking you; they stay denied, ${kept === null ? "but links inside them weren't checked" : "with the links found inside them when they were last checked"}. Allow Toolroll's service to access data from other apps (Privacy & Security) to check them now.`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const walked = { complete: true }, inside = [];
|
|
204
|
+
for (const { name, root } of present) {
|
|
205
|
+
const container = held.includes(root);
|
|
206
|
+
if (container && !listable)
|
|
207
|
+
continue;
|
|
208
|
+
for (const target of outwardLinks(name, root, base, say, list, container ? walked : undefined)) {
|
|
209
|
+
add(target);
|
|
210
|
+
if (container)
|
|
211
|
+
inside.push(target);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Only a walk of every container that met no error but "not there" or "not allowed" is kept for later.
|
|
215
|
+
if (listable && held.length > 0 && walked.complete && cache !== null)
|
|
216
|
+
writeLinkCache(cache, base, inside);
|
|
217
|
+
return [...out].sort();
|
|
218
|
+
}
|
|
219
|
+
/** Where the links inside a present root lead, when outside it: a walk that never follows a link, bounded. */
|
|
220
|
+
function outwardLinks(name, root, home, say, list, walked) {
|
|
221
|
+
const found = [];
|
|
222
|
+
let seen = 0;
|
|
223
|
+
const queue = [{ path: root, depth: 0 }];
|
|
224
|
+
while (queue.length > 0) {
|
|
225
|
+
const { path, depth } = queue.shift();
|
|
226
|
+
let entries;
|
|
227
|
+
try {
|
|
228
|
+
entries = list(path);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
if (walked !== undefined && !ANSWERED.has(error?.code ?? ""))
|
|
232
|
+
walked.complete = false;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
if (++seen > PROTECTED_WALK.entries) {
|
|
237
|
+
if (!unwalkedSaid.has(root)) {
|
|
238
|
+
unwalkedSaid.add(root);
|
|
239
|
+
say(`toolroll: fence: ${name} holds more than ${PROTECTED_WALK.entries.toLocaleString("en-US")} entries; links past that weren't checked (not fully walked)`);
|
|
240
|
+
}
|
|
241
|
+
return found;
|
|
242
|
+
}
|
|
243
|
+
const child = join(path, entry.name);
|
|
244
|
+
if (entry.isSymbolicLink()) {
|
|
245
|
+
let spelled;
|
|
246
|
+
try {
|
|
247
|
+
spelled = resolve(dirname(child), readlinkSync(child));
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (systemMirror(child, root, spelled, home))
|
|
253
|
+
continue;
|
|
254
|
+
const target = real(spelled);
|
|
255
|
+
if (target === root || within(target, root))
|
|
256
|
+
continue;
|
|
257
|
+
found.push(target);
|
|
258
|
+
if (spelled !== target)
|
|
259
|
+
found.push(spelled);
|
|
260
|
+
}
|
|
261
|
+
else if (entry.isDirectory() && depth + 1 < PROTECTED_WALK.depth)
|
|
262
|
+
queue.push({ path: child, depth: depth + 1 });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return found;
|
|
266
|
+
}
|
|
267
|
+
/** The links macOS makes in every sandboxed app's container (`Data/X` → `~/X`), as found on a real Mac (2026-10-26). */
|
|
268
|
+
const CONTAINER_MIRRORS = new Set([".CFUserTextEncoding", "Desktop", "Downloads", "Movies", "Music", "Pictures",
|
|
269
|
+
...["Audio", "Calendars", "ColorPickers", "ColorSync", "Colors", "Components", "Compositions", "Dictionaries", "Favorites", "Filters", "FontCollections", "Fonts",
|
|
270
|
+
"Input Methods", "KeyBindings", "Keyboard Layouts", "Keychains", "Mail", "PDF Services", "QuickLook", "Sounds", "Spelling", "Images/People",
|
|
271
|
+
"Application Support/AddressBook", "Application Support/iCloud", "Application Support/SyncServices",
|
|
272
|
+
"Preferences/com.apple.security.plist", "Preferences/com.apple.security_common.plist"].map(one => `Library/${one}`)]);
|
|
273
|
+
/**
|
|
274
|
+
* The system's own redirect in a sandboxed app's container, and nothing else: the root is an app container (its links
|
|
275
|
+
* under `Data/`) or a group container, directly in the home folder's Library, and the link is one macOS makes there
|
|
276
|
+
* (CONTAINER_MIRRORS, or the app's own Application Scripts folder) leading to the same place under the home folder.
|
|
277
|
+
* Any other outward link — in a browser profile, a password manager's folder, or an unknown name in a container — is
|
|
278
|
+
* where that app's data was moved, and is denied.
|
|
279
|
+
*/
|
|
280
|
+
function systemMirror(link, root, target, home) {
|
|
281
|
+
const app = dirname(root) === real(join(home, CONTAINERS)), group = dirname(root) === real(join(home, GROUPS));
|
|
282
|
+
if (!app && !group)
|
|
283
|
+
return false;
|
|
284
|
+
const rel = relative(root, link).split(sep);
|
|
285
|
+
if (app && rel[0] !== "Data")
|
|
286
|
+
return false;
|
|
287
|
+
const under = app ? rel.slice(1) : rel;
|
|
288
|
+
const named = under.join("/");
|
|
289
|
+
const known = CONTAINER_MIRRORS.has(named) || named === `Library/Application Scripts/${root.split(sep).at(-1)}`;
|
|
290
|
+
return known && target === join(home, ...under);
|
|
291
|
+
}
|
|
292
|
+
/** The key `unwalkedSaid` keeps for "the containers couldn't be listed" (said once per process). */
|
|
293
|
+
const CONTAINERS_UNLISTED = "\0containers";
|
|
294
|
+
/** A root directly in the home folder's Library/Containers or Library/Group Containers: another app's container. */
|
|
295
|
+
function inAppContainer(root, home) {
|
|
296
|
+
return dirname(root) === real(join(home, CONTAINERS)) || dirname(root) === real(join(home, GROUPS));
|
|
297
|
+
}
|
|
298
|
+
/** How long the look at other apps' containers may take before Toolroll takes it that macOS is waiting on the person. */
|
|
299
|
+
export const CONTAINER_LOOK_MS = 3_000;
|
|
300
|
+
/** How long a "can't list them" answer stands before the next launch looks again (the person may have allowed it since). */
|
|
301
|
+
const CONTAINER_RELOOK_MS = 10 * 60_000;
|
|
302
|
+
/** Each set of container roots' last answer: listable, or not and when that was seen. */
|
|
303
|
+
const containerLooks = new Map();
|
|
304
|
+
/** The look itself, in a child process: the same bounded walk `outwardLinks` makes, never following a link. */
|
|
305
|
+
const CONTAINER_LOOK = `const fs=require("fs"),path=require("path");const [depth,limit,...roots]=process.argv.slice(1);
|
|
306
|
+
for(const root of roots){let seen=0;const queue=[{p:root,d:0}];while(queue.length>0){const {p,d}=queue.shift();let entries;
|
|
307
|
+
try{entries=fs.readdirSync(p,{withFileTypes:true});}catch{continue;}for(const e of entries){if(++seen>Number(limit)){queue.length=0;break;}
|
|
308
|
+
if(!e.isSymbolicLink()&&e.isDirectory()&&d+1<Number(depth))queue.push({p:path.join(p,e.name),d:d+1});}}}`;
|
|
309
|
+
/**
|
|
310
|
+
* Whether this process can list inside these app containers without macOS stopping to ask the person: the walk run
|
|
311
|
+
* first in a child process that is killed at CONTAINER_LOOK_MS. Finishing in time (listed, or refused outright) means
|
|
312
|
+
* the walk can run here too; only running into that limit means macOS is waiting on the person (only the child waited).
|
|
313
|
+
* A look that fails any other way is tried once more, then throws: the launch is refused rather than fenced with less.
|
|
314
|
+
* A yes is kept (macOS keeps the person's answer); a no is looked at again after CONTAINER_RELOOK_MS.
|
|
315
|
+
*/
|
|
316
|
+
export function containersListable(roots, look = lookInContainers, now = Date.now) {
|
|
317
|
+
const key = [...roots].sort().join("\0");
|
|
318
|
+
const last = containerLooks.get(key);
|
|
319
|
+
if (last !== undefined && (last.listable || now() - last.at < CONTAINER_RELOOK_MS))
|
|
320
|
+
return last.listable;
|
|
321
|
+
let answer = look(roots);
|
|
322
|
+
if (answer === "failed")
|
|
323
|
+
answer = look(roots);
|
|
324
|
+
if (answer === "failed")
|
|
325
|
+
throw new Error("Toolroll couldn't look inside other apps' containers to fence them (the look failed twice), so nothing was launched.");
|
|
326
|
+
const listable = answer === "listable";
|
|
327
|
+
containerLooks.set(key, { listable, at: now() });
|
|
328
|
+
return listable;
|
|
329
|
+
}
|
|
330
|
+
/** The child-process look (containersListable): node (or Electron as node) walks the roots, killed at its limit. */
|
|
331
|
+
function lookInContainers(roots) {
|
|
332
|
+
const result = spawnSync(process.execPath, ["-e", CONTAINER_LOOK, String(PROTECTED_WALK.depth), String(PROTECTED_WALK.entries), ...roots], { stdio: "ignore", timeout: CONTAINER_LOOK_MS, killSignal: "SIGKILL", env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } });
|
|
333
|
+
return lookOutcome(result);
|
|
334
|
+
}
|
|
335
|
+
/** A look's outcome from its child's result: only its own limit (ETIMEDOUT) is macOS waiting; any other error, signal
|
|
336
|
+
* or exit code is a failed look. */
|
|
337
|
+
export function lookOutcome(result) {
|
|
338
|
+
if (result.error?.code === "ETIMEDOUT")
|
|
339
|
+
return "waiting";
|
|
340
|
+
return result.error === undefined && result.signal === null && result.status === 0 ? "listable" : "failed";
|
|
341
|
+
}
|
|
342
|
+
/** The answers a container walk takes as complete: the folder isn't there, or macOS refused it outright. */
|
|
343
|
+
const ANSWERED = new Set(["ENOENT", "ENOTDIR", "EPERM", "EACCES"]);
|
|
344
|
+
/** At most this much is ever read back from the link cache. */
|
|
345
|
+
const LINK_CACHE_MAX = 256 * 1024;
|
|
346
|
+
/** Where the outward links the last complete container walk found are kept: the plane's own state folder (fenced from
|
|
347
|
+
* every build), one file per user. */
|
|
348
|
+
export function linkCacheFile(home, env = process.env) {
|
|
349
|
+
return join(namedFolder(configBase({ ...env }, home)), "protected-links.json");
|
|
350
|
+
}
|
|
351
|
+
/** The kept links for this home: only a plain file of this user's (no link), at most LINK_CACHE_MAX, of exactly the
|
|
352
|
+
* kept shape; anything else is no cache at all. */
|
|
353
|
+
export function readLinkCache(file, home) {
|
|
354
|
+
let fd;
|
|
355
|
+
try {
|
|
356
|
+
const held = lstatSync(file);
|
|
357
|
+
if (!held.isFile() || held.size > LINK_CACHE_MAX || (process.getuid !== undefined && held.uid !== process.getuid()))
|
|
358
|
+
return null;
|
|
359
|
+
fd = openSync(file, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const opened = fstatSync(fd);
|
|
366
|
+
if (!opened.isFile() || opened.size > LINK_CACHE_MAX || (process.getuid !== undefined && opened.uid !== process.getuid()))
|
|
367
|
+
return null;
|
|
368
|
+
const buffer = Buffer.alloc(opened.size);
|
|
369
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0);
|
|
370
|
+
const value = JSON.parse(buffer.subarray(0, read).toString("utf8"));
|
|
371
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
372
|
+
return null;
|
|
373
|
+
const { version, home: kept, targets } = value;
|
|
374
|
+
if (version !== 1 || kept !== home || !Array.isArray(targets) || targets.some(one => typeof one !== "string" || !isAbsolute(one)))
|
|
375
|
+
return null;
|
|
376
|
+
return targets;
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
closeSync(fd);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** Keep a complete walk's links: written aside (owner-only) and renamed into place, only into a state folder that is
|
|
386
|
+
* already there. A failed write keeps whatever was kept before. */
|
|
387
|
+
export function writeLinkCache(file, home, targets) {
|
|
388
|
+
if (!existsSync(dirname(file)))
|
|
389
|
+
return;
|
|
390
|
+
const aside = `${file}.${process.pid}.part`;
|
|
391
|
+
try {
|
|
392
|
+
writeFileSync(aside, JSON.stringify({ version: 1, home, targets: [...new Set(targets)].sort() }), { mode: 0o600, flag: "wx" });
|
|
393
|
+
renameSync(aside, file);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
rmSync(aside, { force: true });
|
|
397
|
+
}
|
|
398
|
+
}
|