@worca/app 1.1.1 → 1.2.0-rc.2
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 +23 -1
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +247 -27
- 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/src/core/ui-instance.mjs +235 -0
- 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 +377 -80
package/src/core/db.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
20
|
import { mkdirSync, existsSync } from 'node:fs';
|
|
21
|
-
import { join } from 'node:path';
|
|
21
|
+
import { join, dirname } from 'node:path';
|
|
22
22
|
import { worcaHome } from './projects.mjs';
|
|
23
23
|
import { maybeMigrateFromFs } from './migrate-fs-to-db.mjs';
|
|
24
24
|
import { SEED_TEMPLATES, NODE_ID_MAP, FB_WIRE_MAP } from './graph/seed-templates.mjs';
|
|
@@ -54,7 +54,7 @@ const OPEN_BACKOFF_MS = 15;
|
|
|
54
54
|
/** Latest schema version. Bump + append a new migration step when the DDL grows.
|
|
55
55
|
* Exported so migration tests assert "reached the module's current version"
|
|
56
56
|
* instead of hardcoding the number — a schema bump then touches no test file. */
|
|
57
|
-
export const SCHEMA_VERSION =
|
|
57
|
+
export const SCHEMA_VERSION = 27;
|
|
58
58
|
|
|
59
59
|
/** Absolute path to the database file: <worcaHome>/worca-cc.db. */
|
|
60
60
|
export function dbPath() {
|
|
@@ -103,15 +103,22 @@ function _openConfiguredMigrated() {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
|
-
* True when err is a transient SQLite
|
|
107
|
-
* structured errcode
|
|
108
|
-
*
|
|
109
|
-
*
|
|
106
|
+
* True when err is a transient SQLite error that retrying the open can clear. Prefers
|
|
107
|
+
* the structured errcode — 5 = SQLITE_BUSY, 6 = SQLITE_LOCKED, and primary code 10 =
|
|
108
|
+
* SQLITE_IOERR (extended codes carry it in the low byte) — and falls back to the
|
|
109
|
+
* message so a lock is still caught on any node:sqlite build that doesn't populate
|
|
110
|
+
* errcode. IOERR is here for the first-launch race on Windows: while one process
|
|
111
|
+
* performs the journal_mode=WAL switch, a competitor opening the same file can get
|
|
112
|
+
* "disk I/O error" from the -wal/-shm files being created and unlinked under it
|
|
113
|
+
* (seen on the Windows 11 VM with 12 concurrent openers). A persistent I/O error
|
|
114
|
+
* still surfaces: the retry is bounded and re-throws the original error. A false
|
|
115
|
+
* positive only costs that bounded retry.
|
|
110
116
|
*/
|
|
111
117
|
function _isBusyError(err) {
|
|
112
118
|
if (err && (err.errcode === 5 || err.errcode === 6)) return true;
|
|
119
|
+
if (err && Number.isInteger(err.errcode) && (err.errcode & 0xff) === 10) return true;
|
|
113
120
|
const msg = err && err.message ? err.message : String(err);
|
|
114
|
-
return /locked|busy/i.test(msg);
|
|
121
|
+
return /locked|busy|disk I\/O error/i.test(msg);
|
|
115
122
|
}
|
|
116
123
|
|
|
117
124
|
/** Synchronous sleep (node:sqlite is sync; we must block this thread, not yield it). */
|
|
@@ -745,6 +752,8 @@ const INCREMENTAL_COLUMNS = {
|
|
|
745
752
|
workflows: { domain: 'TEXT', origin: 'TEXT', graph: 'TEXT', archived_at: 'TEXT' },
|
|
746
753
|
config_workflow_nodes: { ask_questions: 'INTEGER', subagent_model: 'TEXT' }, // v25: sub-agent model policy
|
|
747
754
|
ask_run_links: { comment_ids: 'TEXT' }, // v22: JSON array of dc_ ids pending at launch
|
|
755
|
+
ask_attachments: { kind: "TEXT NOT NULL DEFAULT 'text'", // v27: text | image | binary (#398)
|
|
756
|
+
mime: 'TEXT' }, // v27: sniffed mime; NULL on pre-v27 rows (= text)
|
|
748
757
|
};
|
|
749
758
|
|
|
750
759
|
/** v23: per-loop-wire cycle budgets, the graph-engine twin of
|
|
@@ -1080,6 +1089,77 @@ function applySchemaV25(db) {
|
|
|
1080
1089
|
repairSchemaGaps(db, schemaGaps(db));
|
|
1081
1090
|
}
|
|
1082
1091
|
|
|
1092
|
+
/** v26 (Fable 5.1 replaces Fable 5 in PREDEFINED_MODELS): a pin left on the
|
|
1093
|
+
* retired id would render as "(default model)" in every picker — its option is
|
|
1094
|
+
* gone — and be rejected on the next write (config.mjs `unknown model
|
|
1095
|
+
* "claude-fable-5"`), while the run itself kept passing the old id to
|
|
1096
|
+
* `claude --model`. So every stored pin moves to the successor: the
|
|
1097
|
+
* config_workflow_nodes.model column, the per-role project_config.steps JSON,
|
|
1098
|
+
* and node defaults inside workflows.graph (nodes[].config.model is the shape
|
|
1099
|
+
* workflows.mjs validates; a bare nodes[].model is covered too). Ids match
|
|
1100
|
+
* case-insensitively, as config.mjs compares them. History (pipelines,
|
|
1101
|
+
* sub_agents.run_model, ask_*) records what actually ran and is left alone.
|
|
1102
|
+
* A one-word rename is reversible, so unlike V24 it takes no backup. JSON
|
|
1103
|
+
* that does not parse is left exactly as found — a broken row must not take
|
|
1104
|
+
* the ladder down. */
|
|
1105
|
+
const V26_MODEL_RENAMES = [['claude-fable-5', 'claude-fable-5-1']];
|
|
1106
|
+
|
|
1107
|
+
function applySchemaV26(db) {
|
|
1108
|
+
for (const [from, to] of V26_MODEL_RENAMES) renameStoredModelPins(db, from, to);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** v27 (Ask Worca binary attachments, #398): ask_attachments.kind/mime — plain
|
|
1112
|
+
* additive columns declared in INCREMENTAL_COLUMNS, applySchemaV25's shape: this
|
|
1113
|
+
* repairSchemaGaps call is what CREATES them on the ladder path (a DB stamped
|
|
1114
|
+
* exactly 26), reconcileSchema covers the fast path. Existing rows keep the
|
|
1115
|
+
* column DEFAULT 'text', which is exactly what every pre-v27 attachment is. */
|
|
1116
|
+
function applySchemaV27(db) {
|
|
1117
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** Move every stored pin on model id `from` (lower-case) to `to`. Each table
|
|
1121
|
+
* is guarded like V24's: hand-seeded upgrade fixtures (and a DB from before the
|
|
1122
|
+
* fs->db import) reach this step without some of them. */
|
|
1123
|
+
function renameStoredModelPins(db, from, to) {
|
|
1124
|
+
const hasTable = (t) => hasSqliteTable(db, t);
|
|
1125
|
+
const isFrom = (v) => typeof v === 'string' && v.trim().toLowerCase() === from;
|
|
1126
|
+
const renameIn = (sel) => {
|
|
1127
|
+
if (!sel || typeof sel !== 'object' || !isFrom(sel.model)) return false;
|
|
1128
|
+
sel.model = to;
|
|
1129
|
+
return true;
|
|
1130
|
+
};
|
|
1131
|
+
if (hasTable('config_workflow_nodes')) {
|
|
1132
|
+
db.prepare('UPDATE config_workflow_nodes SET model = ? WHERE lower(trim(model)) = ?').run(to, from);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const like = `%${from}%`; // cheap pre-filter; the JSON walk below decides
|
|
1136
|
+
const setSteps = hasTable('project_config') && db.prepare('UPDATE project_config SET steps = ? WHERE project_key = ?');
|
|
1137
|
+
for (const row of setSteps ? db.prepare('SELECT project_key, steps FROM project_config WHERE steps LIKE ?').all(like) : []) {
|
|
1138
|
+
let steps;
|
|
1139
|
+
try { steps = JSON.parse(row.steps); } catch { continue; }
|
|
1140
|
+
if (!steps || typeof steps !== 'object' || Array.isArray(steps)) continue;
|
|
1141
|
+
let changed = false;
|
|
1142
|
+
for (const sel of Object.values(steps)) changed = renameIn(sel) || changed;
|
|
1143
|
+
if (changed) setSteps.run(JSON.stringify(steps), row.project_key);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const hasGraphColumn = () => db.prepare('PRAGMA table_info(workflows)').all().some((c) => c.name === 'graph');
|
|
1147
|
+
const setGraph = hasTable('workflows') && hasGraphColumn()
|
|
1148
|
+
&& db.prepare('UPDATE workflows SET graph = ? WHERE id = ?');
|
|
1149
|
+
for (const row of setGraph ? db.prepare('SELECT id, graph FROM workflows WHERE graph LIKE ?').all(like) : []) {
|
|
1150
|
+
let graph;
|
|
1151
|
+
try { graph = JSON.parse(row.graph); } catch { continue; }
|
|
1152
|
+
if (!graph || typeof graph !== 'object' || !Array.isArray(graph.nodes)) continue;
|
|
1153
|
+
let changed = false;
|
|
1154
|
+
for (const node of graph.nodes) {
|
|
1155
|
+
if (!node || typeof node !== 'object') continue;
|
|
1156
|
+
changed = renameIn(node.config) || changed;
|
|
1157
|
+
changed = renameIn(node) || changed;
|
|
1158
|
+
}
|
|
1159
|
+
if (changed) setGraph.run(JSON.stringify(graph), row.id);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1083
1163
|
/** Audit channel for V24 (dev convention: one console.warn per decision). */
|
|
1084
1164
|
const auditV24 = (msg) => console.warn(`[worca] V24: ${msg}`);
|
|
1085
1165
|
|
|
@@ -1111,7 +1191,8 @@ function usableBackup(bak) {
|
|
|
1111
1191
|
}
|
|
1112
1192
|
|
|
1113
1193
|
/**
|
|
1114
|
-
* V24 is the
|
|
1194
|
+
* V24 is the only ladder step that rewrites user data destructively (V26 renames
|
|
1195
|
+
* one model id, reversibly), so an existing DB is
|
|
1115
1196
|
* snapshotted BEFORE the transaction opens (`VACUUM INTO` cannot run inside one
|
|
1116
1197
|
* — measured: "cannot VACUUM from within a transaction"). Skipped for a fresh
|
|
1117
1198
|
* file (nothing to lose) and for `:memory:` (PRAGMA database_list gives file '').
|
|
@@ -1141,7 +1222,7 @@ function backupBeforeV24(db) {
|
|
|
1141
1222
|
throw new Error(`worca cannot take the pre-v24 database backup at ${bak}: `
|
|
1142
1223
|
+ `${err && err.message ? err.message : err}. The v2 upgrade rewrites saved `
|
|
1143
1224
|
+ 'pipelines, so it refuses to run without one — free disk space or make '
|
|
1144
|
-
+ `${file
|
|
1225
|
+
+ `${dirname(file)} writable and start worca again.`, { cause: err });
|
|
1145
1226
|
}
|
|
1146
1227
|
}
|
|
1147
1228
|
|
|
@@ -1417,6 +1498,8 @@ export function migrate(db) {
|
|
|
1417
1498
|
if (current < 23) applySchemaV23(db); // graph columns + config_workflow_wires
|
|
1418
1499
|
if (current < 24) applySchemaV24(db, { existing: current >= 1 }); // the v2 break
|
|
1419
1500
|
if (current < 25) applySchemaV25(db); // sub-agent model policy + recorded child model
|
|
1501
|
+
if (current < 26) applySchemaV26(db); // Fable 5 pins -> Fable 5.1 (catalog swap)
|
|
1502
|
+
if (current < 27) applySchemaV27(db); // ask_attachments.kind/mime (#398)
|
|
1420
1503
|
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
1421
1504
|
db.exec('COMMIT');
|
|
1422
1505
|
} catch (err) {
|
|
@@ -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
|
+
}
|