@worca/app 1.0.0 → 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 +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// src/core/graph/seed-templates.mjs
|
|
2
|
+
// The 7 hand-written v2 seed templates the V17 migration re-seeds the user's
|
|
3
|
+
// saved pipelines as, plus the static overlay-migration maps.
|
|
4
|
+
//
|
|
5
|
+
// Each entry is a FLAT Template object — `{ id, name, version, domain, createdAt,
|
|
6
|
+
// nodes, wires }` at the TOP level, never nested under `graph`. Flat is
|
|
7
|
+
// load-bearing: validateGraph runs on the whole template and V1 reads `version`
|
|
8
|
+
// off the object it is handed.
|
|
9
|
+
//
|
|
10
|
+
// Shape rules the graphs follow (a copy-check, not an authoring recipe — these
|
|
11
|
+
// were hand-authored and verified against the v1 rows):
|
|
12
|
+
// - Every template has a Task source node and exactly ONE End node, wired from
|
|
13
|
+
// `webui.pass` where a webui node exists, else `reviewer.pass`.
|
|
14
|
+
// - `implementer.done -> reviewer.done`, `reviewer.pass -> checklist.await` and
|
|
15
|
+
// `checklist.checklist -> webui.checklist` reproduce v1's linear order; refine
|
|
16
|
+
// self-loops and review loops carry `config.maxCycles: 3`.
|
|
17
|
+
// - The double-loop templates (wf_full, wf_provided-plan, wf_full-no-decompose)
|
|
18
|
+
// fan `reviewer.review -> n_or.in1` and `webui.review -> n_or.in2` — each
|
|
19
|
+
// in-wire keeps its OWN maxCycles, because those are the loop wires and the
|
|
20
|
+
// gate sites — while `n_or.out -> implementer.fix` carries NO config
|
|
21
|
+
// (maxCycles on an always-sourced wire fails V13). wf_no-clarify has no or
|
|
22
|
+
// card: only w10 targets `fix`, and `webui.review` is UNWIRED, matching its
|
|
23
|
+
// two-feedback v1 row.
|
|
24
|
+
//
|
|
25
|
+
// Parity note: the OR in-wires keep their v1-era wire ids and budgets, so source
|
|
26
|
+
// firings, per-wire delivery counts and gate sites are identical to the direct
|
|
27
|
+
// wire shape; the OR adds one $0 instant exec row per loop emission and forwards
|
|
28
|
+
// the identical review path to `fix`.
|
|
29
|
+
|
|
30
|
+
import { deepFreeze } from './builtin-workflows.mjs';
|
|
31
|
+
|
|
32
|
+
/** Full: clarify -> plan -> refine -> decompose -> implement -> review ->
|
|
33
|
+
* checklist -> web UI, with both review loops fanned through the or card.
|
|
34
|
+
* 11 nodes / 17 wires. */
|
|
35
|
+
const WF_FULL = {
|
|
36
|
+
id: 'wf_full',
|
|
37
|
+
name: 'Full',
|
|
38
|
+
version: 2,
|
|
39
|
+
domain: 'coding',
|
|
40
|
+
createdAt: '2026-07-29T19:39:27.650Z',
|
|
41
|
+
nodes: [
|
|
42
|
+
{ id: 'n_task', kind: 'task', x: 60, y: 198, config: {} },
|
|
43
|
+
{ id: 'n_clarify', kind: 'agent', key: 'clarify', x: 360, y: 198, config: {} },
|
|
44
|
+
{ id: 'n_plan', kind: 'agent', key: 'planner', x: 660, y: 198, config: {} },
|
|
45
|
+
{ id: 'n_refine', kind: 'agent', key: 'refiner', x: 960, y: 198, config: {} },
|
|
46
|
+
{ id: 'n_decompose', kind: 'agent', key: 'decomposer', x: 1260, y: 198, config: {} },
|
|
47
|
+
{ id: 'n_impl', kind: 'agent', key: 'implementer', x: 1560, y: 198, config: {} },
|
|
48
|
+
{ id: 'n_review', kind: 'agent', key: 'reviewer', x: 1860, y: 198, config: {} },
|
|
49
|
+
{ id: 'n_check', kind: 'agent', key: 'manualTestsChecklist', x: 2160, y: 198, config: {} },
|
|
50
|
+
{ id: 'n_webui', kind: 'agent', key: 'manualWebUiTesting', x: 2460, y: 198, config: {} },
|
|
51
|
+
{ id: 'n_end', kind: 'end', x: 2760, y: 198, config: {} },
|
|
52
|
+
{ id: 'n_or', kind: 'or', x: 2010, y: 430, config: { arity: 2 } },
|
|
53
|
+
],
|
|
54
|
+
wires: [
|
|
55
|
+
{ id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_clarify', port: 'task' } },
|
|
56
|
+
{ id: 'w2', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
|
|
57
|
+
{ id: 'w3', from: { node: 'n_clarify', port: 'answers' }, to: { node: 'n_plan', port: 'answers' } },
|
|
58
|
+
{ id: 'w4', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_refine', port: 'plan' } },
|
|
59
|
+
{ id: 'w5', from: { node: 'n_refine', port: 'revise' }, to: { node: 'n_refine', port: 'revise' }, config: { maxCycles: 3 } },
|
|
60
|
+
{ id: 'w6', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_decompose', port: 'plan' } },
|
|
61
|
+
{ id: 'w7', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
|
|
62
|
+
{ id: 'w8', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
|
|
63
|
+
{ id: 'w9', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_check', port: 'plan' } },
|
|
64
|
+
{ id: 'w10', from: { node: 'n_decompose', port: 'tasks' }, to: { node: 'n_impl', port: 'task' } },
|
|
65
|
+
{ id: 'w11', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
|
|
66
|
+
{ id: 'w12', from: { node: 'n_review', port: 'review' }, to: { node: 'n_or', port: 'in1' }, config: { maxCycles: 3 } },
|
|
67
|
+
{ id: 'w13', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_check', port: 'await' } },
|
|
68
|
+
{ id: 'w14', from: { node: 'n_check', port: 'checklist' }, to: { node: 'n_webui', port: 'checklist' } },
|
|
69
|
+
{ id: 'w15', from: { node: 'n_webui', port: 'review' }, to: { node: 'n_or', port: 'in2' }, config: { maxCycles: 3 } },
|
|
70
|
+
{ id: 'w16', from: { node: 'n_webui', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
|
|
71
|
+
{ id: 'w17', from: { node: 'n_or', port: 'out' }, to: { node: 'n_impl', port: 'fix' } },
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** No Clarify: Full minus the clarify node, and with only ONE feedback into
|
|
76
|
+
* `fix` — w10 reviewer.review. `webui.review` is deliberately unwired, matching
|
|
77
|
+
* the v1 row's two feedbacks. 9 nodes / 13 wires. */
|
|
78
|
+
const WF_NO_CLARIFY = {
|
|
79
|
+
id: 'wf_no-clarify',
|
|
80
|
+
name: 'No Clarify',
|
|
81
|
+
version: 2,
|
|
82
|
+
domain: 'coding',
|
|
83
|
+
createdAt: '2026-07-29T19:40:22.212Z',
|
|
84
|
+
nodes: [
|
|
85
|
+
{ id: 'n_task', kind: 'task', x: 60, y: 198, config: {} },
|
|
86
|
+
{ id: 'n_plan', kind: 'agent', key: 'planner', x: 360, y: 198, config: {} },
|
|
87
|
+
{ id: 'n_refine', kind: 'agent', key: 'refiner', x: 660, y: 198, config: {} },
|
|
88
|
+
{ id: 'n_decompose', kind: 'agent', key: 'decomposer', x: 960, y: 198, config: {} },
|
|
89
|
+
{ id: 'n_impl', kind: 'agent', key: 'implementer', x: 1260, y: 198, config: {} },
|
|
90
|
+
{ id: 'n_review', kind: 'agent', key: 'reviewer', x: 1560, y: 198, config: {} },
|
|
91
|
+
{ id: 'n_check', kind: 'agent', key: 'manualTestsChecklist', x: 1860, y: 198, config: {} },
|
|
92
|
+
{ id: 'n_webui', kind: 'agent', key: 'manualWebUiTesting', x: 2160, y: 198, config: {} },
|
|
93
|
+
{ id: 'n_end', kind: 'end', x: 2460, y: 198, config: {} },
|
|
94
|
+
],
|
|
95
|
+
wires: [
|
|
96
|
+
{ id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
|
|
97
|
+
{ id: 'w2', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_refine', port: 'plan' } },
|
|
98
|
+
{ id: 'w3', from: { node: 'n_refine', port: 'revise' }, to: { node: 'n_refine', port: 'revise' }, config: { maxCycles: 3 } },
|
|
99
|
+
{ id: 'w4', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_decompose', port: 'plan' } },
|
|
100
|
+
{ id: 'w5', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
|
|
101
|
+
{ id: 'w6', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
|
|
102
|
+
{ id: 'w7', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_check', port: 'plan' } },
|
|
103
|
+
{ id: 'w8', from: { node: 'n_decompose', port: 'tasks' }, to: { node: 'n_impl', port: 'task' } },
|
|
104
|
+
{ id: 'w9', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
|
|
105
|
+
{ id: 'w10', from: { node: 'n_review', port: 'review' }, to: { node: 'n_impl', port: 'fix' }, config: { maxCycles: 3 } },
|
|
106
|
+
{ id: 'w11', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_check', port: 'await' } },
|
|
107
|
+
{ id: 'w12', from: { node: 'n_check', port: 'checklist' }, to: { node: 'n_webui', port: 'checklist' } },
|
|
108
|
+
{ id: 'w13', from: { node: 'n_webui', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Provided Plan: no planner — the task node carries `planStoreSeed` (A2) and
|
|
113
|
+
* wires straight into `refiner.plan`. 9 nodes / 14 wires. */
|
|
114
|
+
const WF_PROVIDED_PLAN = {
|
|
115
|
+
id: 'wf_provided-plan',
|
|
116
|
+
name: 'Provided Plan',
|
|
117
|
+
version: 2,
|
|
118
|
+
domain: 'coding',
|
|
119
|
+
createdAt: '2026-08-07T11:29:56.074Z',
|
|
120
|
+
nodes: [
|
|
121
|
+
{ id: 'n_task', kind: 'task', x: 60, y: 198, config: { planStoreSeed: true } },
|
|
122
|
+
{ id: 'n_refine', kind: 'agent', key: 'refiner', x: 360, y: 198, config: {} },
|
|
123
|
+
{ id: 'n_decompose', kind: 'agent', key: 'decomposer', x: 660, y: 198, config: {} },
|
|
124
|
+
{ id: 'n_impl', kind: 'agent', key: 'implementer', x: 960, y: 198, config: {} },
|
|
125
|
+
{ id: 'n_review', kind: 'agent', key: 'reviewer', x: 1260, y: 198, config: {} },
|
|
126
|
+
{ id: 'n_check', kind: 'agent', key: 'manualTestsChecklist', x: 1560, y: 198, config: {} },
|
|
127
|
+
{ id: 'n_webui', kind: 'agent', key: 'manualWebUiTesting', x: 1860, y: 198, config: {} },
|
|
128
|
+
{ id: 'n_end', kind: 'end', x: 2160, y: 198, config: {} },
|
|
129
|
+
{ id: 'n_or', kind: 'or', x: 1410, y: 430, config: { arity: 2 } },
|
|
130
|
+
],
|
|
131
|
+
wires: [
|
|
132
|
+
{ id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_refine', port: 'plan' } },
|
|
133
|
+
{ id: 'w2', from: { node: 'n_refine', port: 'revise' }, to: { node: 'n_refine', port: 'revise' }, config: { maxCycles: 3 } },
|
|
134
|
+
{ id: 'w3', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_decompose', port: 'plan' } },
|
|
135
|
+
{ id: 'w4', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
|
|
136
|
+
{ id: 'w5', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
|
|
137
|
+
{ id: 'w6', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_check', port: 'plan' } },
|
|
138
|
+
{ id: 'w7', from: { node: 'n_decompose', port: 'tasks' }, to: { node: 'n_impl', port: 'task' } },
|
|
139
|
+
{ id: 'w8', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
|
|
140
|
+
{ id: 'w9', from: { node: 'n_review', port: 'review' }, to: { node: 'n_or', port: 'in1' }, config: { maxCycles: 3 } },
|
|
141
|
+
{ id: 'w10', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_check', port: 'await' } },
|
|
142
|
+
{ id: 'w11', from: { node: 'n_check', port: 'checklist' }, to: { node: 'n_webui', port: 'checklist' } },
|
|
143
|
+
{ id: 'w12', from: { node: 'n_webui', port: 'review' }, to: { node: 'n_or', port: 'in2' }, config: { maxCycles: 3 } },
|
|
144
|
+
{ id: 'w13', from: { node: 'n_webui', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
|
|
145
|
+
{ id: 'w14', from: { node: 'n_or', port: 'out' }, to: { node: 'n_impl', port: 'fix' } },
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** FULL-NO-Decompose: Full minus the decomposer, so the implementer takes the
|
|
150
|
+
* whole plan. 10 nodes / 15 wires. */
|
|
151
|
+
const WF_FULL_NO_DECOMPOSE = {
|
|
152
|
+
id: 'wf_full-no-decompose',
|
|
153
|
+
name: 'FULL-NO-Decompose',
|
|
154
|
+
version: 2,
|
|
155
|
+
domain: 'coding',
|
|
156
|
+
createdAt: '2026-08-08T00:02:32.776Z',
|
|
157
|
+
nodes: [
|
|
158
|
+
{ id: 'n_task', kind: 'task', x: 60, y: 198, config: {} },
|
|
159
|
+
{ id: 'n_clarify', kind: 'agent', key: 'clarify', x: 360, y: 198, config: {} },
|
|
160
|
+
{ id: 'n_plan', kind: 'agent', key: 'planner', x: 660, y: 198, config: {} },
|
|
161
|
+
{ id: 'n_refine', kind: 'agent', key: 'refiner', x: 960, y: 198, config: {} },
|
|
162
|
+
{ id: 'n_impl', kind: 'agent', key: 'implementer', x: 1260, y: 198, config: {} },
|
|
163
|
+
{ id: 'n_review', kind: 'agent', key: 'reviewer', x: 1560, y: 198, config: {} },
|
|
164
|
+
{ id: 'n_check', kind: 'agent', key: 'manualTestsChecklist', x: 1860, y: 198, config: {} },
|
|
165
|
+
{ id: 'n_webui', kind: 'agent', key: 'manualWebUiTesting', x: 2160, y: 198, config: {} },
|
|
166
|
+
{ id: 'n_end', kind: 'end', x: 2460, y: 198, config: {} },
|
|
167
|
+
{ id: 'n_or', kind: 'or', x: 1710, y: 430, config: { arity: 2 } },
|
|
168
|
+
],
|
|
169
|
+
wires: [
|
|
170
|
+
{ id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_clarify', port: 'task' } },
|
|
171
|
+
{ id: 'w2', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
|
|
172
|
+
{ id: 'w3', from: { node: 'n_clarify', port: 'answers' }, to: { node: 'n_plan', port: 'answers' } },
|
|
173
|
+
{ id: 'w4', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_refine', port: 'plan' } },
|
|
174
|
+
{ id: 'w5', from: { node: 'n_refine', port: 'revise' }, to: { node: 'n_refine', port: 'revise' }, config: { maxCycles: 3 } },
|
|
175
|
+
{ id: 'w6', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
|
|
176
|
+
{ id: 'w7', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
|
|
177
|
+
{ id: 'w8', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_check', port: 'plan' } },
|
|
178
|
+
{ id: 'w9', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
|
|
179
|
+
{ id: 'w10', from: { node: 'n_review', port: 'review' }, to: { node: 'n_or', port: 'in1' }, config: { maxCycles: 3 } },
|
|
180
|
+
{ id: 'w11', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_check', port: 'await' } },
|
|
181
|
+
{ id: 'w12', from: { node: 'n_check', port: 'checklist' }, to: { node: 'n_webui', port: 'checklist' } },
|
|
182
|
+
{ id: 'w13', from: { node: 'n_webui', port: 'review' }, to: { node: 'n_or', port: 'in2' }, config: { maxCycles: 3 } },
|
|
183
|
+
{ id: 'w14', from: { node: 'n_webui', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
|
|
184
|
+
{ id: 'w15', from: { node: 'n_or', port: 'out' }, to: { node: 'n_impl', port: 'fix' } },
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** Quick Fix: plan -> implement -> review, no refiner, no checklist. The
|
|
189
|
+
* reviewer's only other input is the VOID `done` port, which is why it stays
|
|
190
|
+
* warning-free under V18 (exemption (b)). 5 nodes / 6 wires. */
|
|
191
|
+
const WF_QUICK_FIX = {
|
|
192
|
+
id: 'wf_quick-fix',
|
|
193
|
+
name: 'Quick Fix',
|
|
194
|
+
version: 2,
|
|
195
|
+
domain: 'coding',
|
|
196
|
+
createdAt: '2026-08-09T14:40:59.262Z',
|
|
197
|
+
nodes: [
|
|
198
|
+
{ id: 'n_task', kind: 'task', x: 60, y: 198, config: {} },
|
|
199
|
+
{ id: 'n_plan', kind: 'agent', key: 'planner', x: 360, y: 198, config: {} },
|
|
200
|
+
{ id: 'n_impl', kind: 'agent', key: 'implementer', x: 660, y: 198, config: {} },
|
|
201
|
+
{ id: 'n_review', kind: 'agent', key: 'reviewer', x: 960, y: 198, config: {} },
|
|
202
|
+
{ id: 'n_end', kind: 'end', x: 1260, y: 198, config: {} },
|
|
203
|
+
],
|
|
204
|
+
wires: [
|
|
205
|
+
{ id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
|
|
206
|
+
{ id: 'w2', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
|
|
207
|
+
{ id: 'w3', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
|
|
208
|
+
{ id: 'w4', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
|
|
209
|
+
{ id: 'w5', from: { node: 'n_review', port: 'review' }, to: { node: 'n_impl', port: 'fix' }, config: { maxCycles: 3 } },
|
|
210
|
+
{ id: 'w6', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** Clarify -> Implement: clarify -> plan -> refine -> implement -> review.
|
|
215
|
+
* 7 nodes / 10 wires. */
|
|
216
|
+
const WF_CLARIFY_IMPLEMENT = {
|
|
217
|
+
id: 'wf_clarify-implement',
|
|
218
|
+
name: 'Clarify -> Implement',
|
|
219
|
+
version: 2,
|
|
220
|
+
domain: 'coding',
|
|
221
|
+
createdAt: '2026-08-09T15:16:43.806Z',
|
|
222
|
+
nodes: [
|
|
223
|
+
{ id: 'n_task', kind: 'task', x: 60, y: 198, config: {} },
|
|
224
|
+
{ id: 'n_clarify', kind: 'agent', key: 'clarify', x: 360, y: 198, config: {} },
|
|
225
|
+
{ id: 'n_plan', kind: 'agent', key: 'planner', x: 660, y: 198, config: {} },
|
|
226
|
+
{ id: 'n_refine', kind: 'agent', key: 'refiner', x: 960, y: 198, config: {} },
|
|
227
|
+
{ id: 'n_impl', kind: 'agent', key: 'implementer', x: 1260, y: 198, config: {} },
|
|
228
|
+
{ id: 'n_review', kind: 'agent', key: 'reviewer', x: 1560, y: 198, config: {} },
|
|
229
|
+
{ id: 'n_end', kind: 'end', x: 1860, y: 198, config: {} },
|
|
230
|
+
],
|
|
231
|
+
wires: [
|
|
232
|
+
{ id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_clarify', port: 'task' } },
|
|
233
|
+
{ id: 'w2', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
|
|
234
|
+
{ id: 'w3', from: { node: 'n_clarify', port: 'answers' }, to: { node: 'n_plan', port: 'answers' } },
|
|
235
|
+
{ id: 'w4', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_refine', port: 'plan' } },
|
|
236
|
+
{ id: 'w5', from: { node: 'n_refine', port: 'revise' }, to: { node: 'n_refine', port: 'revise' }, config: { maxCycles: 3 } },
|
|
237
|
+
{ id: 'w6', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
|
|
238
|
+
{ id: 'w7', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
|
|
239
|
+
{ id: 'w8', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
|
|
240
|
+
{ id: 'w9', from: { node: 'n_review', port: 'review' }, to: { node: 'n_impl', port: 'fix' }, config: { maxCycles: 3 } },
|
|
241
|
+
{ id: 'w10', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** Clarify -> Quick Fix: Quick Fix with a clarify step in front. 6 nodes /
|
|
246
|
+
* 8 wires. */
|
|
247
|
+
const WF_CLARIFY_QUICK_FIX = {
|
|
248
|
+
id: 'wf_clarify-quick-fix',
|
|
249
|
+
name: 'Clarify -> Quick Fix',
|
|
250
|
+
version: 2,
|
|
251
|
+
domain: 'coding',
|
|
252
|
+
createdAt: '2026-08-09T15:18:40.077Z',
|
|
253
|
+
nodes: [
|
|
254
|
+
{ id: 'n_task', kind: 'task', x: 60, y: 198, config: {} },
|
|
255
|
+
{ id: 'n_clarify', kind: 'agent', key: 'clarify', x: 360, y: 198, config: {} },
|
|
256
|
+
{ id: 'n_plan', kind: 'agent', key: 'planner', x: 660, y: 198, config: {} },
|
|
257
|
+
{ id: 'n_impl', kind: 'agent', key: 'implementer', x: 960, y: 198, config: {} },
|
|
258
|
+
{ id: 'n_review', kind: 'agent', key: 'reviewer', x: 1260, y: 198, config: {} },
|
|
259
|
+
{ id: 'n_end', kind: 'end', x: 1560, y: 198, config: {} },
|
|
260
|
+
],
|
|
261
|
+
wires: [
|
|
262
|
+
{ id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_clarify', port: 'task' } },
|
|
263
|
+
{ id: 'w2', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
|
|
264
|
+
{ id: 'w3', from: { node: 'n_clarify', port: 'answers' }, to: { node: 'n_plan', port: 'answers' } },
|
|
265
|
+
{ id: 'w4', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
|
|
266
|
+
{ id: 'w5', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
|
|
267
|
+
{ id: 'w6', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
|
|
268
|
+
{ id: 'w7', from: { node: 'n_review', port: 'review' }, to: { node: 'n_impl', port: 'fix' }, config: { maxCycles: 3 } },
|
|
269
|
+
{ id: 'w8', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** The 7 seeds, in seeding order. */
|
|
274
|
+
export const SEED_TEMPLATES = deepFreeze([
|
|
275
|
+
WF_FULL,
|
|
276
|
+
WF_NO_CLARIFY,
|
|
277
|
+
WF_PROVIDED_PLAN,
|
|
278
|
+
WF_FULL_NO_DECOMPOSE,
|
|
279
|
+
WF_QUICK_FIX,
|
|
280
|
+
WF_CLARIFY_IMPLEMENT,
|
|
281
|
+
WF_CLARIFY_QUICK_FIX,
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
/** V17 overlay migration: `config_workflow_nodes.node_id` rewrites, old v1 step
|
|
285
|
+
* id -> v2 node id. wf_default is included for projects carrying overlays on
|
|
286
|
+
* the builtin default workflow. */
|
|
287
|
+
export const NODE_ID_MAP = deepFreeze({
|
|
288
|
+
wf_full: { s0_0: 'n_clarify', s1_0: 'n_plan', s2_0: 'n_refine', s3_0: 'n_decompose', s4_0: 'n_impl', s5_0: 'n_review', s6_0: 'n_check', s7_0: 'n_webui' },
|
|
289
|
+
'wf_no-clarify': { s0_0: 'n_plan', s1_0: 'n_refine', s2_0: 'n_decompose', s3_0: 'n_impl', s4_0: 'n_review', s5_0: 'n_check', s6_0: 'n_webui' },
|
|
290
|
+
'wf_provided-plan': { s0_0: 'n_refine', s1_0: 'n_decompose', s2_0: 'n_impl', s3_0: 'n_review', s4_0: 'n_check', s5_0: 'n_webui' },
|
|
291
|
+
'wf_full-no-decompose': { s0_0: 'n_clarify', s1_0: 'n_plan', s2_0: 'n_refine', s3_0: 'n_impl', s4_0: 'n_review', s5_0: 'n_check', s6_0: 'n_webui' },
|
|
292
|
+
'wf_quick-fix': { s0_0: 'n_plan', s1_0: 'n_impl', s2_0: 'n_review' },
|
|
293
|
+
'wf_clarify-implement': { s0_0: 'n_clarify', s1_0: 'n_plan', s2_0: 'n_refine', s3_0: 'n_impl', s4_0: 'n_review' },
|
|
294
|
+
'wf_clarify-quick-fix': { s0_0: 'n_clarify', s1_0: 'n_plan', s2_0: 'n_impl', s3_0: 'n_review' },
|
|
295
|
+
wf_default: { s_clarify: 'n_clarify', s0_0: 'n_plan', s1_0: 'n_refine', s2_0: 'n_impl', s3_0: 'n_review' },
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
/** V17 overlay migration: `config_workflow_feedbacks` -> `config_workflow_wires`
|
|
299
|
+
* rows, old feedback id -> v2 wire id. Wire ids reflect Amendment f as revised
|
|
300
|
+
* (single-wire inputs; the double-loop seeds' blocking wires keep their ids as
|
|
301
|
+
* the OR valve's in-wires — exactly where budgets and gates now count, so
|
|
302
|
+
* migrated overlays land correctly by construction).
|
|
303
|
+
*
|
|
304
|
+
* wf_no-clarify's fb_0 is the refine self-wire and fb_1 the review loop wire —
|
|
305
|
+
* the user's max_cycles=6 overlays ride those two. wf_clarify-implement's fb
|
|
306
|
+
* order is BELIEVED swapped in the DB and is UNVERIFIED (that template is absent
|
|
307
|
+
* from the reference DB); V17's dynamic resolver, not this row, is what makes
|
|
308
|
+
* the migration safe. */
|
|
309
|
+
export const FB_WIRE_MAP = deepFreeze({
|
|
310
|
+
wf_full: { fb_0: 'w5', fb_1: 'w12', fb_2: 'w15' },
|
|
311
|
+
'wf_no-clarify': { fb_0: 'w3', fb_1: 'w10' },
|
|
312
|
+
'wf_provided-plan': { fb_0: 'w2', fb_1: 'w9', fb_2: 'w12' },
|
|
313
|
+
'wf_full-no-decompose': { fb_0: 'w5', fb_1: 'w10', fb_2: 'w13' },
|
|
314
|
+
'wf_quick-fix': { fb_0: 'w5' },
|
|
315
|
+
'wf_clarify-implement': { fb_0: 'w9', fb_1: 'w5' },
|
|
316
|
+
'wf_clarify-quick-fix': { fb_0: 'w7' },
|
|
317
|
+
wf_default: { fb_refine: 'w5', fb_review: 'w9' },
|
|
318
|
+
});
|
|
@@ -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
|
+
}
|