@volter/teams 0.2.52 → 0.2.54
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/board-process-guard.mjs +176 -8
- package/browser-channel.d.ts +1 -1
- package/package.json +1 -1
package/board-process-guard.mjs
CHANGED
|
@@ -1,20 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The board's native PreToolUse command policy.
|
|
2
|
+
// The board's native PreToolUse command policy. It reads the command; it looks a process up only when the command kills
|
|
3
|
+
// one by PID, and never signals anything.
|
|
4
|
+
//
|
|
5
|
+
// A kill by name or pattern is refused. So is a kill by PID that is a pattern in disguise: on volter-desktop
|
|
6
|
+
// (2026-10-10 04:27Z) a session ended a test run with `for p in $(list); do taskkill //PID $p //F; done`, `list` matched
|
|
7
|
+
// processes by a folder name that came out empty, and every process of the machine's user (Explorer, the terminal and
|
|
8
|
+
// every session in it, the browser) was ended past this guard, which let any kill by PID through. A kill whose PIDs are
|
|
9
|
+
// built at run time is now refused outright, whatever its form.
|
|
10
|
+
//
|
|
11
|
+
// A PID written out as a number (or `$!`, the shell's own last child) is the rule's own allowed kill, and goes through
|
|
12
|
+
// for a process this session started: one running under it, or one its harness marked when it ran it, which is how a
|
|
13
|
+
// process started with `&` or `nohup` is still known after the Bash call's shell returned and left it re-parented.
|
|
14
|
+
// One that is another's is refused (`deny`, which blocks whatever the permission mode is; never `ask`, whose effect
|
|
15
|
+
// under a board session's `bypassPermissions` is undocumented, so it would either stall a machine nobody is at or do
|
|
16
|
+
// nothing). Where a process's environment cannot be read (Windows), whose it is cannot be read either: there the form
|
|
17
|
+
// of the kill is all this guard judges, and one written-out PID goes through unjudged (docs/adr/0030). A kill made
|
|
18
|
+
// inside a program (`process.kill` in a script) is that program's, and is not read here.
|
|
19
|
+
import { execFileSync } from 'node:child_process';
|
|
3
20
|
import { readFileSync } from 'node:fs';
|
|
4
21
|
import { fileURLToPath } from 'node:url';
|
|
5
22
|
import { resolve } from 'node:path';
|
|
6
23
|
export const processGuardReason = 'Pattern kills are refused for board sessions. Kill only the PID this session recorded when it started that process; use the owning service door for a service.';
|
|
24
|
+
export const computedKillReason = `A kill names each process by the PID this session recorded when it started it, written out as a number. A PID taken from a variable, a loop or a command's output is a pattern kill: one over a match that came out empty ended every process of a machine's user. ${processGuardReason}`;
|
|
25
|
+
export const foreignKillReason = (pid) => `PID ${pid} is not a process this session started: it is neither running under this session nor marked by it (its environment names no session of this one). Kill only what this session started, by the PID it recorded; a process another session or the person started is theirs to end, through its owning service door or by asking them.`;
|
|
26
|
+
// Shell bodies are read as well as nested `sh -c` / substitutions. Removing shell escapes and quotes catches split
|
|
27
|
+
// words, paths and quoted embedded commands.
|
|
28
|
+
const normalize = (command) => String(command).replace(/\\\r?\n/g, '').replace(/\\([a-zA-Z-])/g, '$1').replace(/["']/g, '');
|
|
29
|
+
// Where a command starts: a separator, a nested `-c`, or a prefix that runs the next word (env, sudo, xargs, a loop's do…)
|
|
30
|
+
const head = String.raw`(?:^|[;|&(\n{}]|\s-[a-zA-Z]*c\s+)\s*(?:(?:env|sudo|exec|command|xargs|nohup|do|then|else|!)\s+(?:--?[^\s;|&()]+\s+)*|[A-Za-z_][A-Za-z0-9_]*=[^\s;|&()]+\s+)*(?:[\w./-]+/)?`;
|
|
7
31
|
export function patternKill(command) {
|
|
8
|
-
|
|
9
|
-
// escapes and quotes catches split words, paths and quoted embedded commands.
|
|
10
|
-
const text = String(command).replace(/\\\r?\n/g, '').replace(/\\([a-zA-Z-])/g, '$1').replace(/["']/g, '');
|
|
11
|
-
const head = String.raw`(?:^|[;|&(\n{}]|\s-[a-zA-Z]*c\s+)\s*(?:(?:env|sudo|exec|command|xargs|nohup|do|then|else|!)\s+(?:--?[^\s;|&()]+\s+)*|[A-Za-z_][A-Za-z0-9_]*=[^\s;|&()]+\s+)*(?:[\w./-]+/)?`;
|
|
32
|
+
const text = normalize(command);
|
|
12
33
|
return new RegExp(head + String.raw`(?:pkill|killall)(?=$|[\s;|&()])`).test(text) ||
|
|
13
34
|
new RegExp(head + String.raw`pgrep\s+[^;|&\n)]*(?:--full\b|-[a-zA-Z]*f[a-zA-Z]*(?=$|\s))`).test(text) ||
|
|
14
35
|
windowsPatternKill(text);
|
|
15
36
|
}
|
|
16
37
|
// Windows names a process by image, title or command line through its own tools (cmd, PowerShell, Git Bash's
|
|
17
|
-
// `//IM`): each such kill is refused as pkill is. A kill by PID (`taskkill /PID`, `Stop-Process -Id`)
|
|
38
|
+
// `//IM`): each such kill is refused as pkill is. A kill by PID (`taskkill /PID`, `Stop-Process -Id`) is read by
|
|
39
|
+
// `pidKills` below.
|
|
18
40
|
function windowsPatternKill(text) {
|
|
19
41
|
return /\btaskkill(?:\.exe)?\b[^;|&\n]*\s(?:\/{1,2}|-)(?:im|fi)\b/i.test(text) ||
|
|
20
42
|
/(?:^|[^\w-])(?:Stop-Process|spps)\b[^;|\n]*\s-(?:N[a-z]*|ProcessName)\b/i.test(text) ||
|
|
@@ -24,6 +46,151 @@ function windowsPatternKill(text) {
|
|
|
24
46
|
/\bwmic\b[^;|&\n]*\bprocess\b[^;|&\n]*\bwhere\b[^;|&\n]*\b(?:name|commandline|executablepath)\b/i.test(text) ||
|
|
25
47
|
/\bWin32_Process\b[^\n]*\b(?:Name|CommandLine|ExecutablePath)\b[^\n]*\b(?:Terminate|Remove-CimInstance|Remove-WmiObject|Stop-Process)\b/i.test(text);
|
|
26
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The kills by PID in a command (`kill`, `taskkill /PID`, `Stop-Process -Id`): `{ pids, computed }`. `pids` are the PIDs
|
|
52
|
+
* written out as numbers; `computed` is set when any target is not one (a variable, a substitution, a loop's word,
|
|
53
|
+
* `xargs`' input, a pipe into Stop-Process, a process group): a list built at run time, which is a pattern kill whatever
|
|
54
|
+
* its form. `kill -0` sends no signal and `kill %1` names this shell's own job: neither is a kill here.
|
|
55
|
+
*/
|
|
56
|
+
export function pidKills(command) {
|
|
57
|
+
const text = normalize(command);
|
|
58
|
+
const pids = [];
|
|
59
|
+
let computed = false;
|
|
60
|
+
// `$!` is the PID of the process this shell started last, and so is a variable this command set from it
|
|
61
|
+
// (`node server.mjs & s=$!; …; kill $s`): this session's own, read as such
|
|
62
|
+
const own = new Set(['$!', '${!}']);
|
|
63
|
+
for (const [, name] of text.matchAll(/\b([A-Za-z_]\w*)=\$!/g)) { own.add(`$${name}`); own.add(`\${${name}}`); }
|
|
64
|
+
// one kill's own targets, kept apart until its whole line is read: a probe (`kill -0`, `kill -l`) signals nothing
|
|
65
|
+
// and its targets are no kill's
|
|
66
|
+
let mine = [], mineComputed = false;
|
|
67
|
+
const target = (word) => {
|
|
68
|
+
if (/^%/.test(word) || own.has(word)) return;
|
|
69
|
+
if (/^\d+$/.test(word) && Number(word) > 0) mine.push(Number(word));
|
|
70
|
+
else mineComputed = true;
|
|
71
|
+
};
|
|
72
|
+
const taken = () => { pids.push(...mine); computed ||= mineComputed; mine = []; mineComputed = false; };
|
|
73
|
+
const kills = new RegExp(head + String.raw`(kill|taskkill(?:\.exe)?|Stop-Process|spps)(?=$|[\s;|&()])([^;|&\n)]*)`, 'gi');
|
|
74
|
+
for (const [whole, tool, rest] of text.matchAll(kills)) {
|
|
75
|
+
const before = whole.slice(0, whole.length - tool.length - rest.length);
|
|
76
|
+
// its targets come from its input
|
|
77
|
+
if (/\bxargs\s/.test(before) || /^\s*\|/.test(before)) { computed = true; continue; }
|
|
78
|
+
const words = rest.trim().split(/\s+/).filter(Boolean);
|
|
79
|
+
const name = tool.toLowerCase();
|
|
80
|
+
if (name.startsWith('taskkill')) {
|
|
81
|
+
for (let i = 0; i < words.length; i++) if (/^(?:\/{1,2}|-)pid$/i.test(words[i])) target(words[++i] ?? '');
|
|
82
|
+
taken();
|
|
83
|
+
} else if (name === 'stop-process' || name === 'spps') {
|
|
84
|
+
for (let i = 0; i < words.length; i++) {
|
|
85
|
+
const id = /^-Id(?::(.+))?$/i.exec(words[i]);
|
|
86
|
+
if (id) { for (const part of (id[1] ?? words[++i] ?? '').split(',').filter(Boolean)) target(part); }
|
|
87
|
+
else if (/^-InputObject\b/i.test(words[i])) mineComputed = true;
|
|
88
|
+
else if (!words[i].startsWith('-')) for (const part of words[i].split(',').filter(Boolean)) target(part);
|
|
89
|
+
}
|
|
90
|
+
taken();
|
|
91
|
+
} else {
|
|
92
|
+
// kill: one signal (-9, -KILL, -s KILL, -n 9), then its targets; a dash-number after the signal is a group
|
|
93
|
+
let signalled = false, targets = false, probe = false;
|
|
94
|
+
for (let i = 0; i < words.length; i++) {
|
|
95
|
+
const word = words[i];
|
|
96
|
+
if (targets || (signalled && /^-\d+$/.test(word))) { target(word); targets = true; continue; }
|
|
97
|
+
if (word === '--') { targets = true; continue; }
|
|
98
|
+
if (/^-(?:l|L|-list|-table)$/.test(word)) { probe = true; break; }
|
|
99
|
+
if (/^-(?:s|n)$/.test(word)) { if (words[i + 1] === '0') probe = true; i++; signalled = true; continue; }
|
|
100
|
+
const id = /^-Id(?::(.+))?$/i.exec(word); // PowerShell's kill alias
|
|
101
|
+
if (id) { for (const part of (id[1] ?? words[++i] ?? '').split(',').filter(Boolean)) target(part); targets = true; continue; }
|
|
102
|
+
if (/^-[A-Za-z0-9]+$/.test(word) && !signalled) { if (word === '-0') probe = true; signalled = true; continue; }
|
|
103
|
+
target(word); targets = true;
|
|
104
|
+
}
|
|
105
|
+
if (probe) { mine = []; mineComputed = false; continue; }
|
|
106
|
+
taken();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { pids, computed };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const SHELLS = /^(?:sh|bash|dash|zsh|cmd|cmd\.exe|powershell|powershell\.exe|pwsh|pwsh\.exe)$/i;
|
|
113
|
+
|
|
114
|
+
/** This OS user's processes: pid → `{ ppid, name, started }` (started: Windows' FILETIME); null when they cannot be read. */
|
|
115
|
+
export function processTable() {
|
|
116
|
+
try {
|
|
117
|
+
if (process.platform === 'win32') {
|
|
118
|
+
const script = "Get-CimInstance Win32_Process | ForEach-Object { '{0} {1} {2} {3}' -f $_.ProcessId, $_.ParentProcessId, $(if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 }), $_.Name }";
|
|
119
|
+
const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true, timeout: 8_000, maxBuffer: 1 << 24 });
|
|
120
|
+
return new Map(out.split(/\r?\n/).map((line) => line.trim().match(/^(\d+) (\d+) (\d+) (.*)$/)).filter(Boolean)
|
|
121
|
+
.map(([, pid, ppid, started, name]) => [Number(pid), { ppid: Number(ppid), started: Number(started) || null, name }]));
|
|
122
|
+
}
|
|
123
|
+
const out = execFileSync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8', timeout: 5_000, maxBuffer: 1 << 24 });
|
|
124
|
+
return new Map(out.split('\n').map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/)).filter(Boolean)
|
|
125
|
+
.map(([, pid, ppid, comm]) => [Number(pid), { ppid: Number(ppid), started: null, name: comm.split('/').pop() }]));
|
|
126
|
+
} catch { return null; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The session this hook runs for: the harness that started it, past a shell a hook's command line ran through. */
|
|
130
|
+
export function sessionProcess(table, parent = process.ppid) {
|
|
131
|
+
const row = table.get(parent);
|
|
132
|
+
return row && SHELLS.test(row.name ?? '') ? row.ppid : parent;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether a process's own environment can be read here: a session's background process is known by it. */
|
|
136
|
+
export const ownershipReadable = (platform = process.platform) => platform === 'linux' || platform === 'darwin';
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether the process `pid` was started by this session, by the marks its harness gives everything it runs
|
|
140
|
+
* (`CLAUDE_CODE_SESSION_ID`, `CLAUDE_PID`: sdk/terminal/substrate/harness-markers.json). A process started with `&` or
|
|
141
|
+
* `nohup` is re-parented when the Bash call's own shell returns (its parent is init, or on Windows a pid that is gone),
|
|
142
|
+
* so its session cannot be read from its parents; its environment still names the session that ran it. A session can
|
|
143
|
+
* set those marks only on what it starts, so this says a process is its own and never that another's is.
|
|
144
|
+
*/
|
|
145
|
+
export function startedBySession(pid, root, sessionId, platform = process.platform) {
|
|
146
|
+
const marks = [...(sessionId ? [`CLAUDE_CODE_SESSION_ID=${sessionId}`] : []), ...(root ? [`CLAUDE_PID=${root}`] : [])];
|
|
147
|
+
if (!marks.length) return false;
|
|
148
|
+
try {
|
|
149
|
+
const text = platform === 'linux'
|
|
150
|
+
? readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0').join('\n')
|
|
151
|
+
: execFileSync('ps', ['-E', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8', timeout: 3_000, maxBuffer: 1 << 22 });
|
|
152
|
+
return marks.some((mark) => text.includes(mark));
|
|
153
|
+
} catch { return false; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Whether `pid` is `root` or started under it, parent by parent. */
|
|
157
|
+
export function descends(table, pid, root) {
|
|
158
|
+
let at = pid;
|
|
159
|
+
for (let hops = 0; hops < 256; hops++) {
|
|
160
|
+
if (at === root) return true;
|
|
161
|
+
const row = table.get(at);
|
|
162
|
+
if (!row || !row.ppid || row.ppid === at) return false;
|
|
163
|
+
// Windows keeps a dead parent's pid as its children's parent, and a later process can take it: a "parent" that
|
|
164
|
+
// started after its child is not its parent
|
|
165
|
+
const parent = table.get(row.ppid);
|
|
166
|
+
if (row.started && parent?.started && parent.started > row.started) return false;
|
|
167
|
+
at = row.ppid;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The guard's answer to a command: null to let it run, or `{ decision, reason }`. A kill by name or pattern, or by a PID
|
|
174
|
+
* list built at run time, is denied. A PID written out that is not this session's own is put to the person at the
|
|
175
|
+
* machine (`ask`): a process another session or the person started is theirs to end, and they can say yes to it.
|
|
176
|
+
*/
|
|
177
|
+
export function killDecision(command, { table, session, sessionId, platform = process.platform } = {}) {
|
|
178
|
+
if (patternKill(command)) return { decision: 'deny', reason: processGuardReason };
|
|
179
|
+
const kills = pidKills(command);
|
|
180
|
+
if (kills.computed) return { decision: 'deny', reason: computedKillReason };
|
|
181
|
+
if (!kills.pids.length) return null;
|
|
182
|
+
// Whose a process is can only be read where a process's environment can: see `startedBySession`. Where it cannot
|
|
183
|
+
// (Windows), the form of the kill is all this guard judges, and one written-out PID goes through.
|
|
184
|
+
if (!ownershipReadable(platform)) return null;
|
|
185
|
+
const rows = table ?? processTable();
|
|
186
|
+
if (!rows) return null;
|
|
187
|
+
const root = session ?? sessionProcess(rows);
|
|
188
|
+
const foreign = kills.pids.filter((pid) => rows.has(pid) && !descends(rows, pid, root) && !startedBySession(pid, root, sessionId, platform));
|
|
189
|
+
// A hook's `deny` blocks whatever the session's permission mode is, and a board session runs with
|
|
190
|
+
// `--permission-mode bypassPermissions` (sdk/orchestrator/activation.mjs). `ask` is not used: what it does under
|
|
191
|
+
// that mode is undocumented, so it would either stall a machine nobody is at or do nothing at all.
|
|
192
|
+
return foreign.length ? { decision: 'deny', reason: foreign.map(foreignKillReason).join(' ') } : null;
|
|
193
|
+
}
|
|
27
194
|
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
28
195
|
export function boardGuardArguments(kind, args, { cwd, enabled, node = process.execPath } = {}) {
|
|
29
196
|
if (!enabled) return args;
|
|
@@ -57,8 +224,9 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
|
|
57
224
|
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
58
225
|
const command = input.tool_input?.command ?? input.tool_input?.cmd;
|
|
59
226
|
if (typeof command !== 'string') throw new Error('Bash hook input has no command');
|
|
60
|
-
|
|
61
|
-
|
|
227
|
+
const verdict = killDecision(command, { sessionId: typeof input.session_id === 'string' ? input.session_id : null });
|
|
228
|
+
if (verdict) process.stdout.write(JSON.stringify({ hookSpecificOutput: {
|
|
229
|
+
hookEventName: 'PreToolUse', permissionDecision: verdict.decision, permissionDecisionReason: verdict.reason,
|
|
62
230
|
} }) + '\n');
|
|
63
231
|
} catch (error) {
|
|
64
232
|
process.stderr.write(`Board process policy could not read the command: ${error.message}. ${processGuardReason}\n`);
|
package/browser-channel.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ export function openBrowserMachine(target: { origin: string; teamId: string; mac
|
|
|
47
47
|
*/
|
|
48
48
|
export function openBrowserSession(target: { origin: string; teamId: string; sessionId: string; token: string }, options?: ChannelOptions): Promise<BrowserChannel>;
|
|
49
49
|
|
|
50
|
-
/** The machine's answer to a typed turn. `waiting`: filed, typed
|
|
50
|
+
/** The machine's answer to a typed turn. `waiting`: filed, not typed yet. */
|
|
51
51
|
export type BrowserTurnAnswer =
|
|
52
52
|
| { code: 0; turn_id?: string; door?: 'runtime' | 'pane' | 'waiting'; text?: string }
|
|
53
53
|
| { code: number; text?: string };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.54",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
|
|
6
6
|
"license": "MIT",
|