@worca/app 1.1.1 → 1.2.0-rc.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.
@@ -0,0 +1,201 @@
1
+ // failure-policy.mjs — the ONE place that decides what a failure does to a run.
2
+ //
3
+ // Every site in the engine that can see a failure — the per-node retry loop, the
4
+ // flow-card dispatcher, the budget gate, run()/resume()'s setup and shell catch
5
+ // blocks — asks `resolveFailure()` for a VERDICT and then enacts it with its own
6
+ // mechanics (throwing the pause sentinel through the scheduler, picking a resume
7
+ // point, stamping a setup replay). No site makes the decision itself, so shifting
8
+ // a case from "terminal error" to "pause" (or back) is a one-cell edit in
9
+ // FAILURE_POLICY below plus its row in test/failure-policy.test.mjs.
10
+ //
11
+ // Inputs (all plain values — this module is pure and imports nothing):
12
+ // site where the failure surfaced (SITES)
13
+ // cls classifyError()'s class, or null for an unclassified error; the
14
+ // budget gate passes its cost code
15
+ // auto --yes / headless (true) or interactive (false)
16
+ // attempt 1-based attempt number, for bounded retries
17
+ // answer the recovery prompt's answer once the user gave one ('retry'|'giveup')
18
+ //
19
+ // Verdicts:
20
+ // { outcome: 'retry' } try again (the site backs off)
21
+ // { outcome: 'prompt', options: [...] } ask the user (interactive only)
22
+ // { outcome: 'pause', reason: ReasonCode } park the run, resumable
23
+ // { outcome: 'error' } end the run as a terminal error
24
+ //
25
+ // Control-flow signals — a PauseError, an AbortError, a pause already requested,
26
+ // the user's Stop — are NOT failures and never reach this table; every site guards
27
+ // for them first. Stop is user-only and always ends the run as 'stopped'.
28
+ //
29
+ // A verdict is issued ONCE. When a site enacts 'error' it marks the error
30
+ // terminal (markTerminal) so every enclosing catch — the flow dispatcher, the
31
+ // shell — enacts that same verdict instead of re-deciding at its own site.
32
+
33
+ /** Where a failure can surface. */
34
+ export const SITES = Object.freeze(['node', 'flow', 'budget', 'setup', 'launch', 'shell', 'resume']);
35
+
36
+ /** Machine-readable pause reasons. The human text rides `pauseDetail`. */
37
+ export const REASON = Object.freeze({
38
+ USAGE_LIMIT: 'usage_limit', // a session/usage cap that clears after a multi-hour reset
39
+ RECOVERABLE: 'recoverable', // a classified (auth/quota/rate_limit/network) error the run could not outwait
40
+ ERROR: 'error', // a failure that would otherwise have ended the run
41
+ COST_PIPELINE: 'cost_pipeline', // the per-pipeline cost cap
42
+ COST_TOTAL: 'cost_total', // the total (weekly/monthly) cost cap
43
+ });
44
+ export const REASON_CODES = Object.freeze(Object.values(REASON));
45
+
46
+ /** Max auto-mode retries for a recoverable error before the row's `then` verdict. */
47
+ export const RECOVERY_MAX_AUTO_ATTEMPTS = (() => {
48
+ const n = Number(process.env.WORCA_RECOVERY_MAX_ATTEMPTS);
49
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 3;
50
+ })();
51
+
52
+ // ── verdict constructors ──────────────────────────────────────────────────────
53
+ const pause = (reason) => Object.freeze({ outcome: 'pause', reason });
54
+ const error = () => Object.freeze({ outcome: 'error' });
55
+ /** Bounded retries (auto mode): `max` retries, then the `then` verdict. */
56
+ const retry = (max, then) => Object.freeze({ outcome: 'retry', max, then });
57
+ /** Interactive prompt: Retry re-runs in place; the give-up option enacts `giveUp`. */
58
+ const prompt = (giveUp) => Object.freeze({ outcome: 'prompt', giveUp });
59
+
60
+ /** One matrix cell: the verdict per run mode. */
61
+ const cell = (auto, interactive) => Object.freeze({ auto, interactive });
62
+ /** The same verdict in both modes. */
63
+ const both = (v) => cell(v, v);
64
+
65
+ // ── THE MATRIX ────────────────────────────────────────────────────────────────
66
+ // Rows are keyed by site, then by error class; '*' is the row for any class the
67
+ // site has no specific row for. Edit a cell to shift a case.
68
+ //
69
+ // A note on the two contested rows (PR #415 vs. the policy PR #412 shipped):
70
+ // node/'*' an UNCLASSIFIED error (often a genuine bug) pauses instead of ending
71
+ // the run; resume retries the node in place. Flip to error() to
72
+ // restore "a bug ends the run".
73
+ // node/auth… an interactive recovery prompt's give-up option PAUSES (no Abort
74
+ // verdict). Flip prompt(error()) to offer Abort as a terminal error.
75
+ export const FAILURE_POLICY = Object.freeze({
76
+ node: Object.freeze({
77
+ usage_limit: both(pause(REASON.USAGE_LIMIT)),
78
+ // auth/quota are user-fixable but never time-fixable — a 1s/2s/4s backoff
79
+ // cannot re-login or top up a balance — so auto mode pauses on the first hit.
80
+ // A self-parked auto run pauses as RECOVERABLE (the class is kept: "resume when
81
+ // it clears"); a user who gives up on the prompt pauses as ERROR (a verdict).
82
+ auth: cell(pause(REASON.RECOVERABLE), prompt(pause(REASON.ERROR))),
83
+ quota: cell(pause(REASON.RECOVERABLE), prompt(pause(REASON.ERROR))),
84
+ rate_limit: cell(retry(RECOVERY_MAX_AUTO_ATTEMPTS, pause(REASON.RECOVERABLE)), prompt(pause(REASON.ERROR))),
85
+ network: cell(retry(RECOVERY_MAX_AUTO_ATTEMPTS, pause(REASON.RECOVERABLE)), prompt(pause(REASON.ERROR))),
86
+ '*': both(pause(REASON.ERROR)),
87
+ }),
88
+ // A flow card, the questions loop, _afterExecution, a composite shell mode, an
89
+ // allocation failure — engine-side throws around an execution.
90
+ flow: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
91
+ // The step-boundary budget gate (not an error: a cap was reached).
92
+ budget: Object.freeze({
93
+ cost_pipeline: both(pause(REASON.COST_PIPELINE)),
94
+ cost_total: both(pause(REASON.COST_TOTAL)),
95
+ }),
96
+ // run()'s setup — checkout, graph build, skills gate — failed with the pipeline
97
+ // row already created. A pause here stamps `setupIncomplete`; resume replays it.
98
+ setup: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
99
+ // Before the pipeline row exists (topology, preflight, tool detection) there is
100
+ // nothing to resume into: a launch error is the only enactable verdict.
101
+ launch: Object.freeze({ '*': both(error()) }),
102
+ // Anything that escaped the engine after setup (a scheduler throw, a persist
103
+ // failure, a bookkeeping bug).
104
+ shell: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
105
+ // resume() could not REHYDRATE the paused run — the checkout is gone, run.json
106
+ // is corrupt, a guardrail set or agent prompt no longer loads. The point on disk
107
+ // is already the best the run can offer: parking it again would re-persist the
108
+ // same point (and re-notify the task source) on every attempt, forever. A
109
+ // structurally unrecoverable resume ends the run.
110
+ resume: Object.freeze({ '*': both(error()) }),
111
+ });
112
+
113
+ /** The recovery prompt's options, derived from the row: what Retry does is fixed;
114
+ * the give-up option's id is the wire `decision` value and names its verdict. */
115
+ export function promptOptions(giveUp) {
116
+ const giveUpId = giveUp.outcome === 'pause' ? 'pause' : 'abort';
117
+ return Object.freeze([
118
+ Object.freeze({ id: 'retry', label: 'Retry' }),
119
+ giveUpId === 'pause'
120
+ ? Object.freeze({ id: 'pause', label: 'Pause the run (nothing is discarded — resume later)' })
121
+ : Object.freeze({ id: 'abort', label: 'Abort the run' }),
122
+ ]);
123
+ }
124
+
125
+ /** The give-up option of a prompt's options (the CLI/UI/chat render its label and
126
+ * send its id back as the decision). Falls back to the pause option for a prompt
127
+ * payload that predates options. */
128
+ export function giveUpOption(options) {
129
+ const found = Array.isArray(options) ? options.find((o) => o && o.id !== 'retry') : null;
130
+ return found || promptOptions({ outcome: 'pause' })[1];
131
+ }
132
+
133
+ /** A recovery answer's `decision` wire value → the policy answer. 'abort' is the
134
+ * pre-policy wire value (older UI tabs, chat /abort) and means give up too. */
135
+ export function answerFromDecision(decision) {
136
+ return decision === 'retry' ? 'retry' : 'giveup';
137
+ }
138
+
139
+ /**
140
+ * Decide what a failure does. Pure.
141
+ * @param {{site:string, cls?:string|null, auto?:boolean, attempt?:number, answer?:'retry'|'giveup'}} f
142
+ * @returns {{outcome:'retry'|'prompt'|'pause'|'error', reason?:string, options?:readonly object[]}}
143
+ */
144
+ export function resolveFailure({ site, cls = null, auto = false, attempt = 1, answer } = {}) {
145
+ const rows = FAILURE_POLICY[site];
146
+ if (!rows) throw new Error(`failure-policy: unknown site '${site}'`);
147
+ const row = (cls != null && rows[cls]) || rows['*'];
148
+ if (!row) throw new Error(`failure-policy: no row for ${site}/${cls}`);
149
+ let v = auto ? row.auto : row.interactive;
150
+ if (v.outcome === 'retry') {
151
+ v = attempt > v.max ? v.then : { outcome: 'retry' };
152
+ }
153
+ if (v.outcome === 'prompt') {
154
+ if (answer === undefined) return { outcome: 'prompt', options: promptOptions(v.giveUp) };
155
+ v = answer === 'retry' ? { outcome: 'retry' } : v.giveUp;
156
+ }
157
+ return v.outcome === 'pause' ? { outcome: 'pause', reason: v.reason } : { outcome: v.outcome };
158
+ }
159
+
160
+ // ── terminal-verdict stamp ────────────────────────────────────────────────────
161
+ const TERMINAL = Symbol.for('worca.failure.terminal');
162
+ /** Stamp an error whose verdict is 'error' so enclosing sites enact, not re-decide. */
163
+ export function markTerminal(err) {
164
+ if (err && typeof err === 'object') { try { err[TERMINAL] = true; } catch { /* frozen */ } }
165
+ return err;
166
+ }
167
+ export function isTerminal(err) {
168
+ return !!(err && typeof err === 'object' && err[TERMINAL] === true);
169
+ }
170
+
171
+ // ── consequences of a pause, keyed on its reason ──────────────────────────────
172
+ // What each surface does with a parked run is a function of the reason code, not
173
+ // of ad hoc string checks. `null` is a manual pause (the user pressed Pause).
174
+ const CONSEQUENCES = Object.freeze({
175
+ manual: { reportsToSource: false, stagesResults: false, severity: 'info', notifyPref: 'paused', exitInteractive: 0, label: null },
176
+ [REASON.USAGE_LIMIT]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'session/usage limit reached' },
177
+ [REASON.RECOVERABLE]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'recoverable error — resume to retry' },
178
+ [REASON.COST_PIPELINE]:{ reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'pipeline cost limit reached' },
179
+ [REASON.COST_TOTAL]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'total cost limit reached' },
180
+ [REASON.ERROR]: { reportsToSource: true, stagesResults: true, severity: 'error', notifyPref: 'error', exitInteractive: 1, label: 'a step failed' },
181
+ });
182
+
183
+ /** The consequences row for a pause reason (unknown/legacy free-text reasons read
184
+ * as a forced pause with a warning severity — every reasoned pause is forced). */
185
+ export function pauseConsequences(reason) {
186
+ if (reason == null || reason === '') return CONSEQUENCES.manual;
187
+ return CONSEQUENCES[reason] || CONSEQUENCES[REASON.USAGE_LIMIT];
188
+ }
189
+
190
+ /** CLI exit code for a paused run. Under --yes every pause is the run parking
191
+ * ITSELF with nobody left to resume: 3, so a wrapper can tell a resumable pause
192
+ * from a hard error (1) and a usage error (2). Interactive: 0 when the user asked
193
+ * for it or a cap/limit holds the run; 1 when an error forced it. */
194
+ export function pauseExitCode(reason, auto) {
195
+ return auto ? 3 : pauseConsequences(reason).exitInteractive;
196
+ }
197
+
198
+ /** Human label for a reason code, or null for a manual pause / unknown code. */
199
+ export function describePauseReason(reason) {
200
+ return pauseConsequences(reason).label;
201
+ }
@@ -393,6 +393,12 @@ export function createScheduler(opts) {
393
393
  async function runComposite(h) {
394
394
  const portId = h.entry.expandsPort;
395
395
  const expanded = await execute({ ...h.args, composite: 'expand', expandsPort: portId });
396
+ // A pause raised INSIDE the expansion (the adapter converted a throw) settles the
397
+ // shell row here, with its expands binding intact. Falling through to
398
+ // runUnexpanded would strip `expandsPort`/the binding from the very entry the
399
+ // resume re-invokes, so the node would re-run once as a plain execution and the
400
+ // decomposition would never be re-read.
401
+ if (expanded?.paused === true) return { paused: true };
396
402
  const phases = Array.isArray(expanded?.phases) ? expanded.phases : [];
397
403
  if (!phases.length) return runUnexpanded(h, portId);
398
404
 
@@ -444,7 +450,8 @@ export function createScheduler(opts) {
444
450
  const tasks = Array.isArray(ph.tasks) ? ph.tasks : [];
445
451
  const phaseAbort = new AbortController();
446
452
  let firstError = null;
447
- await execute({ ...h.args, composite: 'phase', phase: ph.ordinal, phaseStatus: 'running' });
453
+ const opened = await execute({ ...h.args, composite: 'phase', phase: ph.ordinal, phaseStatus: 'running' });
454
+ if (opened?.paused === true) return { paused: true }; // the phase bookkeeping paused the run: no slice launches
448
455
 
449
456
  const results = await Promise.allSettled(tasks.map((task, index) =>
450
457
  runSlice(h, portId, ph, task, index, phaseAbort).catch((err) => {
@@ -0,0 +1,271 @@
1
+ // src/core/host-guard.mjs
2
+ // The host-process guard. Every agent worca spawns carries this file as a
3
+ // PreToolUse hook on Bash (see buildSettingsPayload in claude-runner.mjs), so
4
+ // no agent — predefined role, custom agent, plugin agent, or their in-process
5
+ // sub-agents — can kill the worca server that runs it.
6
+ //
7
+ // Born of the 2026-08-31 incident: an implementer cleaning up stray test
8
+ // servers ran `ps aux | grep '[n]ode --disable' | awk '{print $2}' | while
9
+ // read p; do kill $p; done` and took down its own host mid-run (the pattern
10
+ // matches the production server argv exactly).
11
+ //
12
+ // Policy — deny when the command:
13
+ // - invokes `pkill` or `killall` (pattern kills, unbounded blast radius);
14
+ // - invokes `kill` with anything but literal numeric PIDs or `%N` jobspecs
15
+ // (variables, substitutions, piped/xargs input, `-PGID` group targets —
16
+ // all of these are how a kill reaches processes nobody named);
17
+ // - names the host PID (WORCA_HOST_PID) as a literal kill target;
18
+ // - wraps a kill in a nested `sh -c` / `bash -c` payload.
19
+ // Everything else is allowed, so an agent can still stop processes it spawned:
20
+ // look PIDs up first (ps is read-only and always allowed), then kill the
21
+ // literal numbers. Forcing literal PIDs is the point — every literal target
22
+ // passes through the host-PID check.
23
+ //
24
+ // The hook CLI is deliberately fail-open on malformed input: a broken payload
25
+ // must not brick every Bash call of every agent. The DECISION is fail-closed.
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+
28
+ /** ON unless WORCA_HOST_GUARD is "0"/"false" — the one kill-switch for the
29
+ * hook, the WORCA_HOST_PID env var, and the system-prompt preamble alike. */
30
+ export function hostGuardEnabled() {
31
+ const v = process.env.WORCA_HOST_GUARD;
32
+ return !(v === '0' || String(v ?? '').toLowerCase() === 'false');
33
+ }
34
+
35
+ /** The PreToolUse hook entry buildSettingsPayload merges into --settings.
36
+ * Runs THIS file with the server's own node; JSON.stringify double-quotes
37
+ * both paths for the shell. */
38
+ export function hostGuardHookEntry() {
39
+ // Forward slashes on purpose: JSON.stringify would double every Windows
40
+ // backslash and the hook shell's unescaping is unverified there, while
41
+ // CreateProcess and Git Bash both accept forward-slash paths. POSIX node
42
+ // paths never contain backslashes, so the replace is a no-op off Windows.
43
+ const q = (s) => JSON.stringify(String(s).replaceAll('\\', '/'));
44
+ return {
45
+ matcher: 'Bash',
46
+ hooks: [{ type: 'command', command: `${q(process.execPath)} ${q(fileURLToPath(import.meta.url))}` }],
47
+ };
48
+ }
49
+
50
+ /** The system-prompt preamble every real spawn carries (runReal prepends it). */
51
+ export function hostGuardSystemPrompt(pid) {
52
+ return [
53
+ '## Host process protection',
54
+ `You run under the worca app server (PID ${pid}, \`node ui/server.mjs\`). Never kill it, and never kill any process you did not spawn yourself.`,
55
+ 'Pattern kills are forbidden and a PreToolUse hook blocks them on every OS: `pkill`, `killall`, `xargs kill`, `taskkill /IM`, `Stop-Process -Name`, `wmic process … delete`, and any `kill` fed from a pipe, variable, or substitution (e.g. `ps aux | grep … | while read p; do kill $p; done`).',
56
+ 'To stop a process you started: record its PID when you spawn it (or list PIDs with `ps`, which is always allowed), then run `kill <literal pid>`.',
57
+ ].join('\n');
58
+ }
59
+
60
+ /** Command words that may prefix the one we care about. */
61
+ const SKIP_WORDS = new Set([
62
+ 'do', 'then', 'else', 'elif', 'if', 'while', 'until',
63
+ 'exec', 'command', 'builtin', 'nohup', 'time', 'sudo', 'env',
64
+ ]);
65
+
66
+ const NESTED_SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'powershell', 'pwsh', 'cmd']);
67
+
68
+ /** Any killer, POSIX or Windows, appearing anywhere in a nested-shell payload. */
69
+ const KILLER_WORD_RE = /\b(?:p?kill|killall|taskkill|stop-process|spps|wmic)\b/i;
70
+
71
+ /** Killers that xargs / find -exec can hand targets to. */
72
+ const KILLER_CMDS = new Set(['kill', 'pkill', 'killall', 'taskkill']);
73
+ const isKillerToken = (t) => KILLER_CMDS.has(basename(t).toLowerCase().replace(/\.exe$/, ''));
74
+
75
+ /** Replace quoted spans so quoted data ("fix; killall handling") never looks
76
+ * like a command, and substitutions so `kill $(…)` / `kill \`…\`` surface as a
77
+ * non-literal target instead of vanishing into the segment split; the RAW
78
+ * text is still consulted for nested-shell payloads. The `{}` placeholder
79
+ * (xargs -I{}, find -exec … {}) becomes a token BEFORE the split eats the
80
+ * braces — otherwise `xargs -I{} kill {}` splits into an xargs segment with
81
+ * no kill word and a kill segment with no targets, and both pass. */
82
+ function stripQuotes(s) {
83
+ return s
84
+ .replace(/'[^']*'/g, ' __q__ ')
85
+ .replace(/"(?:[^"\\]|\\.)*"/g, ' __q__ ')
86
+ .replace(/\$\([^()]*\)/g, ' __sub__ ')
87
+ .replace(/`[^`]*`/g, ' __sub__ ')
88
+ .replace(/\{\}/g, ' __ph__ ');
89
+ }
90
+
91
+ const basename = (w) => w.slice(w.lastIndexOf('/') + 1);
92
+
93
+ /** Leading env assignments and wrapper words stripped off a token list. */
94
+ function commandWord(tokens) {
95
+ let i = 0;
96
+ while (i < tokens.length && (SKIP_WORDS.has(tokens[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))) i++;
97
+ return { cmd: basename(tokens[i] ?? ''), rest: tokens.slice(i + 1) };
98
+ }
99
+
100
+ /** kill's targets: signal options consumed, redirections ignored. */
101
+ function killTargets(rest) {
102
+ const targets = [];
103
+ let sawSignal = false;
104
+ let afterDashDash = false;
105
+ for (let i = 0; i < rest.length; i++) {
106
+ const t = rest[i];
107
+ if (t.startsWith('#')) break; // trailing comment
108
+ if (/^\d*(?:>>?|<<?|>&|&>>?)$/.test(t)) { i++; continue; } // spaced redirect: skip its operand too
109
+ if (/^\d*[<>]/.test(t) || t.includes('>') || t.includes('<')) continue; // attached redirection
110
+ if (!afterDashDash && t === '--') { afterDashDash = true; continue; }
111
+ if (!afterDashDash && t.startsWith('-')) {
112
+ if (t === '-s' || t === '-n') { i++; sawSignal = true; continue; } // -s TERM / -n 15
113
+ if (!sawSignal && /^-(?:\d+|[A-Za-z]+\d*)$/.test(t)) { sawSignal = true; continue; } // first -9/-TERM
114
+ targets.push(t); // second dash token = a target
115
+ continue;
116
+ }
117
+ targets.push(t);
118
+ }
119
+ return targets;
120
+ }
121
+
122
+ /**
123
+ * The pure decision. `null` = allow; a non-empty string = deny, with the reason
124
+ * the agent will read (hook exit 2 feeds stderr back to the model).
125
+ * @param {string} command the Bash tool's command text
126
+ * @param {number} [hostPid] the protected server PID (WORCA_HOST_PID); pattern
127
+ * bans apply even without it
128
+ * @returns {string|null}
129
+ */
130
+ export function evaluateKillCommand(command, hostPid) {
131
+ const raw = String(command ?? '');
132
+ if (!raw.trim()) return null;
133
+ const pidNote = Number.isFinite(hostPid)
134
+ ? ` The worca app server (PID ${hostPid}) runs this agent and must survive.`
135
+ : ' The worca app server runs this agent and must survive.';
136
+ const advice = ' To stop a process you spawned: list PIDs first (ps is always allowed), then `kill <literal pid>`.';
137
+
138
+ const segments = stripQuotes(raw).split(/(?:\|\||&&|;|\||&|\n|\$\(|`|[(){}])+/);
139
+ for (const seg of segments) {
140
+ const tokens = seg.trim().split(/\s+/).filter(Boolean);
141
+ if (!tokens.length) continue;
142
+ const { cmd: cmdRaw, rest } = commandWord(tokens);
143
+ // Windows commands are case-insensitive and may carry .exe (`TASKKILL`,
144
+ // `taskkill.exe`) — normalize once; POSIX names pass through unchanged.
145
+ const cmd = cmdRaw.toLowerCase().replace(/\.exe$/, '');
146
+
147
+ if (cmd === 'pkill' || cmd === 'killall') {
148
+ return `host guard: blocked \`${cmd}\` — pattern kills are forbidden.${pidNote}${advice}`;
149
+ }
150
+ if (cmd === 'xargs' && rest.some(isKillerToken)) {
151
+ return `host guard: blocked \`xargs kill\` — kill may only take literal numeric PIDs you name yourself.${pidNote}${advice}`;
152
+ }
153
+ // find spawns its -exec/-ok payload itself, so the kill never surfaces as
154
+ // a command word of its own segment; a killer anywhere in find's arguments
155
+ // alongside an -exec-family flag is the same unbounded fan-out as xargs.
156
+ if (cmd === 'find'
157
+ && rest.some((t) => /^-(?:exec|ok)(?:dir)?$/.test(t))
158
+ && rest.some(isKillerToken)) {
159
+ return `host guard: blocked \`find -exec kill\` — kill may only take literal numeric PIDs you name yourself.${pidNote}${advice}`;
160
+ }
161
+ // A nested shell is suspicious only when it carries an INLINE payload
162
+ // (-c / /c / -Command); `bash scripts/kill-dev-server.sh` is a script FILE
163
+ // and stays allowed (deliberate evasion via files is out of the threat
164
+ // model). PowerShell is the exception: its bare argument IS an inline
165
+ // command (`powershell "Stop-Process -Name node"`), so it is always scanned.
166
+ if (NESTED_SHELLS.has(cmd)
167
+ && (cmd === 'powershell' || cmd === 'pwsh' || rest.some((t) => /^(?:-c|\/c|-command|--command)$/i.test(t)))
168
+ && KILLER_WORD_RE.test(raw)) {
169
+ return `host guard: blocked a kill inside a nested \`${cmd}\` invocation — run the kill directly with literal PIDs so it can be checked.${pidNote}${advice}`;
170
+ }
171
+
172
+ // ── Windows-native killers (reachable from Git Bash on Windows) ──────────
173
+ if (cmd === 'taskkill') {
174
+ for (let i = 0; i < rest.length; i++) {
175
+ const flag = rest[i].toLowerCase().replace(/^[/-]+/, '');
176
+ if (flag === 'im') {
177
+ return `host guard: blocked \`taskkill /IM\` — killing by image name is a pattern kill.${pidNote}${advice}`;
178
+ }
179
+ if (flag === 'pid') {
180
+ const target = rest[++i] ?? '';
181
+ if (!/^\d+$/.test(target)) {
182
+ return `host guard: blocked \`taskkill /PID ${target}\` — only literal numeric PIDs are allowed.${pidNote}${advice}`;
183
+ }
184
+ if (Number.isFinite(hostPid) && Number(target) === hostPid) {
185
+ return `host guard: blocked taskkill of PID ${hostPid} — that is the worca app server this agent runs under. Kill only processes you spawned yourself.`;
186
+ }
187
+ }
188
+ }
189
+ continue;
190
+ }
191
+ if (cmd === 'stop-process' || cmd === 'spps') {
192
+ let sawId = false;
193
+ for (let i = 0; i < rest.length; i++) {
194
+ const t = rest[i].toLowerCase();
195
+ if (t === '-name' || t.startsWith('-name:')) {
196
+ return `host guard: blocked \`Stop-Process -Name\` — killing by process name is a pattern kill.${pidNote}${advice}`;
197
+ }
198
+ if (t === '-id' || t.startsWith('-id:')) {
199
+ sawId = true;
200
+ const value = t.includes(':') ? t.split(':')[1] : (rest[++i] ?? '');
201
+ for (const part of value.split(',')) {
202
+ if (!/^\d+$/.test(part)) {
203
+ return `host guard: blocked \`Stop-Process -Id ${part}\` — only literal numeric PIDs are allowed.${pidNote}${advice}`;
204
+ }
205
+ if (Number.isFinite(hostPid) && Number(part) === hostPid) {
206
+ return `host guard: blocked Stop-Process of PID ${hostPid} — that is the worca app server this agent runs under. Kill only processes you spawned yourself.`;
207
+ }
208
+ }
209
+ }
210
+ }
211
+ if (!sawId) {
212
+ return `host guard: blocked \`Stop-Process\` with no literal -Id — pipeline-fed or bare Stop-Process is a pattern kill.${pidNote}${advice}`;
213
+ }
214
+ continue;
215
+ }
216
+ if (cmd === 'wmic') {
217
+ // Checked against the RAW command: a parenthesized WHERE clause splits
218
+ // the segment (`where (name="node.exe") delete`), hiding the verb from
219
+ // this segment's tokens. A kill-verb elsewhere in a compound command can
220
+ // false-positive here — acceptable, the reason explains itself.
221
+ if (/\bprocess\b/i.test(raw) && /\b(?:delete|terminate)\b/i.test(raw)) {
222
+ return `host guard: blocked \`wmic process … delete\` — pattern kills are forbidden.${pidNote}${advice}`;
223
+ }
224
+ continue; // read-only wmic queries stay allowed
225
+ }
226
+
227
+ if (cmd !== 'kill') continue;
228
+
229
+ for (const t of killTargets(rest)) {
230
+ if (/^\d+$/.test(t)) {
231
+ if (Number.isFinite(hostPid) && Number(t) === hostPid) {
232
+ return `host guard: blocked kill of PID ${hostPid} — that is the worca app server this agent runs under. Kill only processes you spawned yourself.`;
233
+ }
234
+ continue;
235
+ }
236
+ if (/^%\d+$/.test(t)) continue; // this shell's own job
237
+ if (t.startsWith('-')) {
238
+ return `host guard: blocked \`kill ${t}\` — process-group/broadcast kills are forbidden.${pidNote}${advice}`;
239
+ }
240
+ return `host guard: blocked \`kill ${t}\` — kill accepts only literal numeric PIDs (no variables, substitutions, or piped input).${pidNote}${advice}`;
241
+ }
242
+ }
243
+ return null;
244
+ }
245
+
246
+ // ── hook CLI ─────────────────────────────────────────────────────────────────
247
+ // stdin: the PreToolUse payload ({ tool_name, tool_input: { command } }).
248
+ // exit 0 = allow; exit 2 = block, stderr is shown to the agent.
249
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
250
+ if (isMain) {
251
+ let raw = '';
252
+ process.stdin.setEncoding('utf8');
253
+ process.stdin.on('data', (d) => { raw += d; });
254
+ process.stdin.on('end', () => {
255
+ let command = '';
256
+ try {
257
+ const payload = JSON.parse(raw);
258
+ if (payload?.tool_name !== 'Bash') process.exit(0);
259
+ command = String(payload?.tool_input?.command ?? '');
260
+ } catch {
261
+ process.exit(0); // fail-open: a malformed payload must not brick Bash
262
+ }
263
+ const pid = Number(process.env.WORCA_HOST_PID);
264
+ const reason = evaluateKillCommand(command, Number.isFinite(pid) && pid > 0 ? pid : undefined);
265
+ if (reason) {
266
+ process.stderr.write(`${reason}\n`);
267
+ process.exit(2);
268
+ }
269
+ process.exit(0);
270
+ });
271
+ }
@@ -83,6 +83,74 @@ export function prepareModelEnv(modelEnv, sourceEnv = process.env) {
83
83
  return { env, dropped };
84
84
  }
85
85
 
86
+ // ── env flags + masking (shared by claude-runner.mjs, plugin-shim.mjs, ui/server.mjs)
87
+
88
+ /**
89
+ * The ONE "is this env flag on" rule for worca's own knobs (WORCA_MOCK,
90
+ * WORCA_SUBAGENT_HOOKS, WORCA_DEBUG_SPAWN, …): a denylist — anything but unset,
91
+ * "", "0" and "false" (any case) is on. Several names may be given; the first
92
+ * one that is set wins (WORCA_MOCK ?? ORCH_MOCK). Lives in this zero-import leaf
93
+ * so every gate shares it instead of hand-copying the comparison.
94
+ * @param {...string} names
95
+ */
96
+ export function envFlag(...names) {
97
+ let v;
98
+ for (const n of names) { v = process.env[n]; if (v !== undefined) break; }
99
+ return !!v && v !== '0' && v.toLowerCase() !== 'false';
100
+ }
101
+
102
+ /**
103
+ * Mask a model-env VALUE for an operator-facing display (the Models editor):
104
+ * six bullets + the last 4 chars when longer than 8, else six bullets. The
105
+ * `••` prefix is what ui/server.mjs#isMaskedEcho keys on to treat an echoed
106
+ * value as "keep", so the shape is a contract — change both together. For LOG
107
+ * lines use describeModelEnvEntry: a per-spawn log must not carry a suffix.
108
+ */
109
+ export function maskModelEnvValue(v) {
110
+ const s = String(v ?? '');
111
+ return s.length > 8 ? `••••••${s.slice(-4)}` : '••••••';
112
+ }
113
+
114
+ // Keys whose value is routing configuration, not a credential, and therefore
115
+ // SAFE to print in a spawn log: which endpoint / which wire id a spawn used is
116
+ // exactly the diagnostic question, and masking them makes two gateway cards
117
+ // indistinguishable. Everything else (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY,
118
+ // ANTHROPIC_CUSTOM_HEADERS, plugin {secret} values, …) is treated as a secret.
119
+ const READABLE_MODEL_ENV_KEYS = new Set([
120
+ 'ANTHROPIC_MODEL', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL',
121
+ ]);
122
+ const READABLE_MODEL_ENV_KEY_RES = [/^ANTHROPIC_DEFAULT_[A-Z0-9]+_MODEL$/, /^CLAUDE_CODE_USE_[A-Z0-9]+$/];
123
+
124
+ /** Whether a model-env key's value may be printed verbatim in a log line. */
125
+ export function isReadableModelEnvKey(key) {
126
+ return typeof key === 'string'
127
+ && (READABLE_MODEL_ENV_KEYS.has(key) || READABLE_MODEL_ENV_KEY_RES.some((re) => re.test(key)));
128
+ }
129
+
130
+ /**
131
+ * One `KEY=value` fragment for a log line. Readable keys print their value
132
+ * (a URL with userinfo has the credentials stripped; an unparsable URL is
133
+ * treated as a secret); every other key prints `<set, N chars>` — presence and
134
+ * length prove the env reached the spawn without leaking any part of it.
135
+ */
136
+ export function describeModelEnvEntry(key, value) {
137
+ const s = String(value ?? '');
138
+ const secret = `<set, ${s.length} chars>`;
139
+ if (!isReadableModelEnvKey(key)) return `${key}=${secret}`;
140
+ if (key === 'ANTHROPIC_BASE_URL') {
141
+ let u;
142
+ try { u = new URL(s); } catch { return `${key}=${secret}`; }
143
+ if (u.username || u.password) { u.username = ''; u.password = ''; }
144
+ return `${key}=${u.href}`;
145
+ }
146
+ return `${key}=${s}`;
147
+ }
148
+
149
+ /** The sorted, log-safe `KEY=value, …` rendering of a whole model env. */
150
+ export function describeModelEnv(env) {
151
+ return Object.keys(env || {}).sort().map((k) => describeModelEnvEntry(k, env[k])).join(', ');
152
+ }
153
+
86
154
  // ── per-model cost override (opt-in pricing, config.mjs resolveModelCost) ─────
87
155
  // Lives HERE for the same reason the env policy does: BOTH catalog layers must
88
156
  // validate it against one rule. settings.mjs owns the user's global catalog and