@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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +72 -16
- package/src/core/artifacts.mjs +10 -2
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/events.mjs +42 -3
- package/src/core/ask/follow.mjs +10 -4
- package/src/core/ask/limits.mjs +6 -3
- package/src/core/ask/prompt.mjs +37 -12
- package/src/core/ask/spawn.mjs +6 -3
- package/src/core/ask/store.mjs +89 -11
- package/src/core/ask/tool-deps.mjs +27 -3
- package/src/core/ask/tools.mjs +41 -10
- package/src/core/ask/turn.mjs +58 -12
- package/src/core/chat/command-router.mjs +8 -4
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +120 -18
- package/src/core/config.mjs +46 -3
- package/src/core/db.mjs +92 -9
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/graph/scheduler.mjs +8 -1
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +68 -0
- package/src/core/orchestrator.mjs +128 -35
- package/src/core/plugin-shim.mjs +3 -3
- package/src/core/run-harness.mjs +410 -61
- package/src/core/settings.mjs +76 -1
- package/ui/public/app.js +259 -39
- package/ui/public/ask-model.mjs +60 -7
- package/ui/public/ask-panel.mjs +314 -65
- package/ui/public/index.html +42 -0
- package/ui/public/style.css +28 -0
- package/ui/server.mjs +286 -65
|
@@ -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
|
+
}
|
package/src/core/model-env.mjs
CHANGED
|
@@ -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
|