@volter/teams 0.2.53 → 0.2.55

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.
@@ -1,20 +1,42 @@
1
1
  #!/usr/bin/env node
2
- // The board's native PreToolUse command policy. No process lookup or signal occurs here.
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
- // Inspect shell bodies as well as nested `sh -c` / substitutions. Removing shell
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`) stays allowed.
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
- if (patternKill(command)) process.stdout.write(JSON.stringify({ hookSpecificOutput: {
61
- hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: processGuardReason,
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.2.53",
3
+ "version": "0.2.55",
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",
package/server/wire.mjs CHANGED
@@ -4,7 +4,7 @@ import { createHash, randomBytes } from 'node:crypto';
4
4
 
5
5
  export class ContractError extends Error { constructor(message, details = null) { super(message); this.name = 'ContractError'; this.code = 'invalid_request'; this.details = details; } }
6
6
  export function canonicalJson(value) { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`; return JSON.stringify(value); }
7
- export function validationFailure(error, requestId) { return { version:1, error:{ code:error?.code ?? 'invalid_request', message:error?.message ?? 'invalid request', request_id:requestId, retryable:false, details:error?.details ?? null } }; }
7
+ export function validationFailure(error, requestId) { return { version:1, error:{ code:error?.code ?? 'invalid_request', message:error?.message ?? 'invalid request', request_id:requestId, retryable:error?.retryable===true, details:error?.details ?? null } }; }
8
8
  export const cursorToken = () => randomBytes(32).toString('base64url');
9
9
  export const cursorHash = token => createHash('sha256').update(token).digest('hex');
10
10
  export const sha256 = value => createHash('sha256').update(value).digest('hex');