@humwick/runner 0.0.0-stage → 0.130.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/LICENSE +167 -0
- package/agent-bin/agent-relay-browser +10 -0
- package/agent-bin/humwick +10 -0
- package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
- package/hooks/fff-search-redirect/hooks.json +13 -0
- package/hooks/rg-r-guard/hooks.json +18 -0
- package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
- package/hooks/web-tools-redirect/hooks.json +13 -0
- package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
- package/node_modules/@humwick/offload/LICENSE +167 -0
- package/node_modules/@humwick/offload/README.md +57 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
- package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.js +80 -0
- package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
- package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/index.js +5 -0
- package/node_modules/@humwick/offload/dist/index.js.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
- package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/types.js +176 -0
- package/node_modules/@humwick/offload/dist/types.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.js +1 -0
- package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
- package/node_modules/@humwick/offload/package.json +42 -0
- package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
- package/node_modules/@humwick/offload/src/envelope.ts +92 -0
- package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
- package/node_modules/@humwick/offload/src/index.ts +4 -0
- package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
- package/node_modules/@humwick/offload/src/types.ts +235 -0
- package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
- package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
- package/node_modules/@humwick/offload/src/worker.ts +5 -0
- package/package.json +47 -4
- package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
- package/plugins/claude/.claude-plugin/plugin.json +8 -0
- package/plugins/claude/claude-statusline-probe.cjs +144 -0
- package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
- package/plugins/claude/hooks/gate-run-guard.sh +9 -0
- package/plugins/claude/hooks/gate-run-guard.ts +237 -0
- package/plugins/claude/hooks/hooks.json +158 -0
- package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
- package/plugins/claude/hooks/kill-guard.sh +341 -0
- package/plugins/claude/hooks/kill-guard.ts +337 -0
- package/plugins/claude/hooks/permission-request.sh +22 -0
- package/plugins/claude/hooks/post-compact.sh +10 -0
- package/plugins/claude/hooks/post-tool-use.sh +8 -0
- package/plugins/claude/hooks/pre-compact.sh +12 -0
- package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
- package/plugins/claude/hooks/relay-status.sh +373 -0
- package/plugins/claude/hooks/session-end.sh +26 -0
- package/plugins/claude/hooks/session-start.sh +31 -0
- package/plugins/claude/hooks/shell-trust.ts +28 -0
- package/plugins/claude/hooks/stop-failure.sh +47 -0
- package/plugins/claude/hooks/stop.sh +48 -0
- package/plugins/claude/hooks/subagent-guard.cjs +26 -0
- package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
- package/plugins/claude/hooks/subagent-start.sh +13 -0
- package/plugins/claude/hooks/subagent-stop.sh +19 -0
- package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
- package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
- package/plugins/claude/monitors/monitors.json +7 -0
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
- package/plugins/claude/monitors/relay-monitor.ts +58 -0
- package/plugins/claude/skills/disconnect/SKILL.md +16 -0
- package/plugins/claude/skills/label/SKILL.md +23 -0
- package/plugins/claude/skills/message/SKILL.md +40 -0
- package/plugins/claude/skills/pair/SKILL.md +30 -0
- package/plugins/claude/skills/react/SKILL.md +18 -0
- package/plugins/claude/skills/read-message/SKILL.md +24 -0
- package/plugins/claude/skills/reply/SKILL.md +44 -0
- package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
- package/plugins/claude/skills/slice-first/SKILL.md +32 -0
- package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/claude/skills/status/SKILL.md +16 -0
- package/plugins/claude/skills/tags/SKILL.md +25 -0
- package/plugins/claude/skills/task/SKILL.md +28 -0
- package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
- package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
- package/plugins/codex/skills/disconnect/SKILL.md +15 -0
- package/plugins/codex/skills/guide/SKILL.md +15 -0
- package/plugins/codex/skills/label/SKILL.md +22 -0
- package/plugins/codex/skills/message/SKILL.md +40 -0
- package/plugins/codex/skills/pair/SKILL.md +29 -0
- package/plugins/codex/skills/react/SKILL.md +17 -0
- package/plugins/codex/skills/read-message/SKILL.md +23 -0
- package/plugins/codex/skills/reply/SKILL.md +44 -0
- package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
- package/plugins/codex/skills/slice-first/SKILL.md +32 -0
- package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/codex/skills/status/SKILL.md +15 -0
- package/plugins/codex/skills/tags/SKILL.md +24 -0
- package/plugins/codex/skills/task/SKILL.md +28 -0
- package/src/adapter.ts +1061 -0
- package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
- package/src/adapters/claude-api.ts +243 -0
- package/src/adapters/claude-context-command.ts +16 -0
- package/src/adapters/claude-credentials.ts +22 -0
- package/src/adapters/claude-delivery-lanes.ts +28 -0
- package/src/adapters/claude-delivery.ts +166 -0
- package/src/adapters/claude-effort.ts +94 -0
- package/src/adapters/claude-exit.ts +290 -0
- package/src/adapters/claude-headless-mirror.ts +88 -0
- package/src/adapters/claude-headless-refusal.ts +51 -0
- package/src/adapters/claude-headless.ts +617 -0
- package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
- package/src/adapters/claude-permission.ts +243 -0
- package/src/adapters/claude-prompt-gates.ts +269 -0
- package/src/adapters/claude-prompt-hook.ts +145 -0
- package/src/adapters/claude-session-capture.ts +293 -0
- package/src/adapters/claude-session-probe.ts +414 -0
- package/src/adapters/claude-shutdown.ts +190 -0
- package/src/adapters/claude-status-detectors.ts +426 -0
- package/src/adapters/claude-subagents.ts +93 -0
- package/src/adapters/claude-submit-confirmation.ts +287 -0
- package/src/adapters/claude-tmux.ts +548 -0
- package/src/adapters/claude-token-usage-capture.ts +253 -0
- package/src/adapters/claude-tool-policy.ts +165 -0
- package/src/adapters/claude-transcript-tail.ts +193 -0
- package/src/adapters/claude-transcript.ts +812 -0
- package/src/adapters/claude.ts +1097 -0
- package/src/adapters/codex-agent-message-capture.ts +189 -0
- package/src/adapters/codex-app-server.ts +70 -0
- package/src/adapters/codex-client.ts +362 -0
- package/src/adapters/codex-effective-config.ts +87 -0
- package/src/adapters/codex-launch-config.ts +153 -0
- package/src/adapters/codex-rollout-context.ts +272 -0
- package/src/adapters/codex-skill-discovery.ts +18 -0
- package/src/adapters/codex-token-usage-capture.ts +163 -0
- package/src/adapters/codex-tool-output.ts +53 -0
- package/src/adapters/codex-turn-outcome.ts +27 -0
- package/src/adapters/codex-version.ts +64 -0
- package/src/adapters/codex.ts +1442 -0
- package/src/adapters/incremental-file.ts +110 -0
- package/src/adapters/opencode-api.ts +71 -0
- package/src/adapters/opencode-context.ts +277 -0
- package/src/adapters/opencode-model-registry.ts +126 -0
- package/src/adapters/opencode-provider.ts +169 -0
- package/src/adapters/opencode-server-retry.ts +50 -0
- package/src/adapters/opencode-soft-steer.ts +302 -0
- package/src/adapters/opencode-spawn.ts +145 -0
- package/src/adapters/opencode-subscribe.ts +62 -0
- package/src/adapters/opencode-token-usage-capture.ts +187 -0
- package/src/adapters/opencode-tool-events.ts +302 -0
- package/src/adapters/opencode-tools-config.ts +17 -0
- package/src/adapters/opencode.ts +2264 -0
- package/src/adapters/project-shell.ts +80 -0
- package/src/adapters/provider-launch-config.ts +21 -0
- package/src/adapters/provider-quota-harvest.ts +127 -0
- package/src/adapters/service-access.ts +153 -0
- package/src/adapters/tmux-offload.ts +207 -0
- package/src/adapters/tool-summary.ts +214 -0
- package/src/agent-redact.ts +75 -0
- package/src/agent-runner/commands/dispatch.ts +185 -0
- package/src/agent-runner/commands/lifecycle.ts +84 -0
- package/src/agent-runner/commands/prompt.ts +284 -0
- package/src/agent-runner/credentials.ts +208 -0
- package/src/agent-runner/http-liveness.ts +85 -0
- package/src/agent-runner/lifecycle-milestones.ts +207 -0
- package/src/agent-runner/lifecycle-phase.ts +80 -0
- package/src/agent-runner/registration.ts +163 -0
- package/src/agent-runner/runner-context.ts +158 -0
- package/src/agent-runner/session-mirror.ts +278 -0
- package/src/agent-runner/task-claims.ts +116 -0
- package/src/agent-runner/token-usage-coverage.ts +54 -0
- package/src/agent-runner/turn-report.ts +269 -0
- package/src/api-budget-accounting.ts +91 -0
- package/src/area-memory.ts +255 -0
- package/src/artifact-path-materialize.ts +52 -0
- package/src/async-guard.ts +35 -0
- package/src/attachment-cache-sweep.ts +31 -0
- package/src/attachment-cache.ts +195 -0
- package/src/background-script-reconcile.ts +106 -0
- package/src/bounded-log.ts +59 -0
- package/src/bounded-spawn-sync.ts +18 -0
- package/src/busy-reconciler.ts +282 -0
- package/src/claim-tracker.ts +140 -0
- package/src/claude-settings.ts +167 -0
- package/src/claude-workspace-trust.ts +289 -0
- package/src/config.ts +547 -0
- package/src/context-boundary.ts +101 -0
- package/src/context-command.ts +151 -0
- package/src/context-probe-state.ts +6 -0
- package/src/continuation-archive.ts +179 -0
- package/src/control-server.ts +730 -0
- package/src/delivery-policy.ts +9 -0
- package/src/delivery-result.ts +24 -0
- package/src/effort-command.ts +125 -0
- package/src/empty-turn.ts +816 -0
- package/src/free-port.ts +25 -0
- package/src/fs-offload.ts +26 -0
- package/src/gate-run-config.ts +35 -0
- package/src/gate-run-lease-probe.ts +38 -0
- package/src/host-asset-plan.ts +450 -0
- package/src/host-claude-config-worker.ts +34 -0
- package/src/host-sources.ts +40 -0
- package/src/inbox-carrier.ts +7 -0
- package/src/index.ts +386 -0
- package/src/launch-assembly.ts +955 -0
- package/src/liveness.ts +105 -0
- package/src/logger.ts +108 -0
- package/src/mcp-outbox.ts +204 -0
- package/src/mcp-preflight.ts +86 -0
- package/src/mcp-source-selection.ts +54 -0
- package/src/mcp-tool-deferral.ts +35 -0
- package/src/mcp-vault-env.ts +25 -0
- package/src/message-body-config/config.ts +16 -0
- package/src/message-body-config.ts +1 -0
- package/src/mid-turn-delivery.ts +43 -0
- package/src/native-memory-lever.ts +329 -0
- package/src/native-self-resume.ts +244 -0
- package/src/outbox.ts +453 -0
- package/src/park-post.ts +17 -0
- package/src/pending-permission-store.ts +148 -0
- package/src/process-meta.ts +9 -0
- package/src/profile-home.ts +419 -0
- package/src/profile-projection.ts +411 -0
- package/src/project-hooks.ts +65 -0
- package/src/project-instructions.ts +119 -0
- package/src/provider-double.ts +59 -0
- package/src/provider-hold.ts +228 -0
- package/src/provider-home-marker.ts +3 -0
- package/src/provider-home-tombstone.ts +4 -0
- package/src/provider-input-queue.ts +134 -0
- package/src/provider-integration.ts +93 -0
- package/src/provider-processes.ts +17 -0
- package/src/provider-submit-failure.ts +15 -0
- package/src/providers.ts +35 -0
- package/src/provisioning.ts +703 -0
- package/src/question-decision.ts +18 -0
- package/src/quota.ts +22 -0
- package/src/rate-limit.ts +351 -0
- package/src/refused-prompt-store.ts +36 -0
- package/src/relay-injection-events.ts +161 -0
- package/src/relay-instructions.ts +107 -0
- package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
- package/src/relay-mcp-proxy.ts +704 -0
- package/src/relay-mcp.ts +583 -0
- package/src/reply-obligation-cache.ts +211 -0
- package/src/response-capture-report.ts +111 -0
- package/src/response-routing.ts +6 -0
- package/src/runner-core.ts +2955 -0
- package/src/runner-exit.ts +12 -0
- package/src/runner-helpers.ts +582 -0
- package/src/runner-insights.ts +105 -0
- package/src/runner-mcp-proxy.ts +46 -0
- package/src/runner-spawn-config.ts +68 -0
- package/src/runner-stale-scratch-sweep.ts +21 -0
- package/src/runner.ts +9 -0
- package/src/runtime-token-file.ts +45 -0
- package/src/runtime-token-remint.ts +56 -0
- package/src/service-access.ts +88 -0
- package/src/session-destroy.ts +27 -0
- package/src/session-insights.ts +118 -0
- package/src/session-scratch.ts +296 -0
- package/src/spawn-input.ts +147 -0
- package/src/startup-config-fetch.ts +48 -0
- package/src/subreaper.ts +113 -0
- package/src/template-resolver.ts +29 -0
- package/src/test-run-dedup.ts +435 -0
- package/src/transcript-context.ts +99 -0
- package/src/transport-errors.ts +48 -0
- package/src/turn-opener.ts +55 -0
- package/src/turn-reconcile-event.ts +88 -0
- package/src/vault-proxy-hop.ts +99 -0
- package/src/version.ts +44 -0
- package/src/web-tool-call-proxy.ts +128 -0
- package/src/web-tools.ts +59 -0
- package/README.md +0 -3
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shared "fleet fratricide" kill-pattern guard (issue #753).
|
|
3
|
+
#
|
|
4
|
+
# Every managed agent's runner runs as
|
|
5
|
+
# bun run .../agent-relay-runner/src/index.ts <provider> ... (scoped install: .../@humwick/runner/...)
|
|
6
|
+
# so a broad process kill such as `pkill -f "src/index.ts"` (or `pkill bun`,
|
|
7
|
+
# `killall node`, ...) matches EVERY sibling agent host-wide and silently SIGTERMs
|
|
8
|
+
# the whole fleet. This is the structural guard so that can't happen again.
|
|
9
|
+
#
|
|
10
|
+
# It blocks broad process-kill commands (pkill / killall, and `kill` with a
|
|
11
|
+
# pattern that leaks a fleet token) whose target would match sibling runners,
|
|
12
|
+
# while still allowing the safe shapes: PID-scoped kills (kill 12345,
|
|
13
|
+
# kill "$SERVER_PID") and pkill -f on a token that is unique to the caller's own
|
|
14
|
+
# throwaway server (its exact test port, or "$PWD/public").
|
|
15
|
+
#
|
|
16
|
+
# Layers (#3117): when neither the segment matcher below nor a raw kill-verb +
|
|
17
|
+
# fleet-token check fires, the command passes without starting a process.
|
|
18
|
+
# Otherwise kill-guard.ts parses it and answers block (an executed broad kill, e.g.
|
|
19
|
+
# behind sudo or bash -c), static (no dynamic execution, so a kill word in an
|
|
20
|
+
# argument, heredoc or comment is prose and passes), or sink (text reaches a shell,
|
|
21
|
+
# eval, xargs, ...): then the segment matcher decides, so the guard is never weaker
|
|
22
|
+
# than the segment matcher alone. No parser (no bun, or it fails) → the same.
|
|
23
|
+
#
|
|
24
|
+
# DUAL MODE:
|
|
25
|
+
# - sourced: defines fratricide_deny_reason() / fratricide_raw_reason() plus the
|
|
26
|
+
# shell-parsing helpers (trim_shell_text / split_segments / shell_words). The
|
|
27
|
+
# Claude PreToolUse Bash guard sources this and is wired to it.
|
|
28
|
+
# - executed: `kill-guard.sh "<command text>"` prints the guidance + exits 0 when
|
|
29
|
+
# the command is a dangerous broad kill, and exits 1 (silent) when it is safe.
|
|
30
|
+
# This lets non-bash callers reuse the SAME matcher as a subprocess.
|
|
31
|
+
#
|
|
32
|
+
# TODO(#753 follow-up): wire the Codex runner/exec layer to invoke this guard.
|
|
33
|
+
# Codex managed agents have no Claude PreToolUse hook, so the same broad pkill can
|
|
34
|
+
# still fratricide the fleet from a Codex session until that wiring lands. The
|
|
35
|
+
# matcher is intentionally factored here so the Codex side can call it directly
|
|
36
|
+
# (e.g. `bash kill-guard.sh "$command"`) without duplicating the pattern list.
|
|
37
|
+
|
|
38
|
+
shopt -s extglob
|
|
39
|
+
|
|
40
|
+
# Parameter expansion, not dirname: the jq-less hook PATH may hold no coreutils.
|
|
41
|
+
case "${BASH_SOURCE[0]}" in */*) KILL_GUARD_PARSER="${BASH_SOURCE[0]%/*}/kill-guard.ts" ;; *) KILL_GUARD_PARSER=kill-guard.ts ;; esac
|
|
42
|
+
|
|
43
|
+
# Guidance returned on a block — tells the caller how to clean up safely instead.
|
|
44
|
+
FRATRICIDE_KILL_GUIDANCE='Blocked a broad process-kill (#753 fleet-fratricide guard): this pattern would match sibling agents'\'' runners, because every managed agent runs "bun run .../agent-relay-runner/src/index.ts". Kill only a PID you own — capture it when you spawn the server (SERVER_PID=$!; kill "$SERVER_PID") — or pkill -f a token that is unique to your own process (your exact test port, or "$PWD/public"). Never pkill/killall on bun, node, src/index.ts, agent-relay, humwick, or their runner packages.'
|
|
45
|
+
# The segment matcher reads raw text, so it cannot tell a command from prose.
|
|
46
|
+
FRATRICIDE_PATTERN_GUIDANCE="${FRATRICIDE_KILL_GUIDANCE}"' If this command only MENTIONS a kill in prose (an issue body, a commit message, a heredoc), this guard cannot tell: write the text to a file with the Write tool and pass it by file (gh --body-file, git commit -F), or drop the fleet token from the prose.'
|
|
47
|
+
|
|
48
|
+
trim_shell_text() {
|
|
49
|
+
local value="$1"
|
|
50
|
+
value="${value##+([[:space:]])}"
|
|
51
|
+
value="${value%%+([[:space:]])}"
|
|
52
|
+
printf '%s' "$value"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
split_segments() {
|
|
56
|
+
local text="$1"
|
|
57
|
+
local current="" i len ch next quote="" escaped=0
|
|
58
|
+
SEGMENTS=()
|
|
59
|
+
len=${#text}
|
|
60
|
+
for ((i = 0; i < len; i++)); do
|
|
61
|
+
ch="${text:i:1}"
|
|
62
|
+
if (( escaped )); then
|
|
63
|
+
current+="$ch"
|
|
64
|
+
escaped=0
|
|
65
|
+
continue
|
|
66
|
+
fi
|
|
67
|
+
if [ -n "$quote" ]; then
|
|
68
|
+
current+="$ch"
|
|
69
|
+
if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
|
|
70
|
+
escaped=1
|
|
71
|
+
continue
|
|
72
|
+
fi
|
|
73
|
+
if [ "$ch" = "$quote" ]; then
|
|
74
|
+
quote=""
|
|
75
|
+
fi
|
|
76
|
+
continue
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
case "$ch" in
|
|
80
|
+
"'"|'"')
|
|
81
|
+
quote="$ch"
|
|
82
|
+
current+="$ch"
|
|
83
|
+
;;
|
|
84
|
+
"\\")
|
|
85
|
+
escaped=1
|
|
86
|
+
current+="$ch"
|
|
87
|
+
;;
|
|
88
|
+
$'\n'|";")
|
|
89
|
+
SEGMENTS+=("$current")
|
|
90
|
+
current=""
|
|
91
|
+
;;
|
|
92
|
+
"&")
|
|
93
|
+
next="${text:i+1:1}"
|
|
94
|
+
if [ "$next" = "&" ]; then
|
|
95
|
+
((i += 1))
|
|
96
|
+
fi
|
|
97
|
+
SEGMENTS+=("$current")
|
|
98
|
+
current=""
|
|
99
|
+
;;
|
|
100
|
+
"|")
|
|
101
|
+
next="${text:i+1:1}"
|
|
102
|
+
if [ "$next" = "|" ]; then
|
|
103
|
+
((i += 1))
|
|
104
|
+
fi
|
|
105
|
+
SEGMENTS+=("$current")
|
|
106
|
+
current=""
|
|
107
|
+
;;
|
|
108
|
+
*)
|
|
109
|
+
current+="$ch"
|
|
110
|
+
;;
|
|
111
|
+
esac
|
|
112
|
+
done
|
|
113
|
+
|
|
114
|
+
if [ -n "$quote" ] || (( escaped )); then
|
|
115
|
+
return 1
|
|
116
|
+
fi
|
|
117
|
+
SEGMENTS+=("$current")
|
|
118
|
+
return 0
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
shell_words() {
|
|
122
|
+
local text="$1"
|
|
123
|
+
local word="" i len ch quote="" escaped=0 in_word=0
|
|
124
|
+
WORDS=()
|
|
125
|
+
len=${#text}
|
|
126
|
+
for ((i = 0; i < len; i++)); do
|
|
127
|
+
ch="${text:i:1}"
|
|
128
|
+
if (( escaped )); then
|
|
129
|
+
word+="$ch"
|
|
130
|
+
in_word=1
|
|
131
|
+
escaped=0
|
|
132
|
+
continue
|
|
133
|
+
fi
|
|
134
|
+
if [ -n "$quote" ]; then
|
|
135
|
+
if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
|
|
136
|
+
escaped=1
|
|
137
|
+
continue
|
|
138
|
+
fi
|
|
139
|
+
if [ "$ch" = "$quote" ]; then
|
|
140
|
+
quote=""
|
|
141
|
+
else
|
|
142
|
+
word+="$ch"
|
|
143
|
+
fi
|
|
144
|
+
in_word=1
|
|
145
|
+
continue
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
case "$ch" in
|
|
149
|
+
[[:space:]])
|
|
150
|
+
if (( in_word )); then
|
|
151
|
+
WORDS+=("$word")
|
|
152
|
+
word=""
|
|
153
|
+
in_word=0
|
|
154
|
+
fi
|
|
155
|
+
;;
|
|
156
|
+
"'"|'"')
|
|
157
|
+
quote="$ch"
|
|
158
|
+
in_word=1
|
|
159
|
+
;;
|
|
160
|
+
"\\")
|
|
161
|
+
escaped=1
|
|
162
|
+
in_word=1
|
|
163
|
+
;;
|
|
164
|
+
*)
|
|
165
|
+
word+="$ch"
|
|
166
|
+
in_word=1
|
|
167
|
+
;;
|
|
168
|
+
esac
|
|
169
|
+
done
|
|
170
|
+
|
|
171
|
+
if [ -n "$quote" ] || (( escaped )); then
|
|
172
|
+
return 1
|
|
173
|
+
fi
|
|
174
|
+
if (( in_word )); then
|
|
175
|
+
WORDS+=("$word")
|
|
176
|
+
fi
|
|
177
|
+
return 0
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# True (0) when text mentions a process-kill verb (pkill / killall / kill).
|
|
181
|
+
_looks_like_kill() {
|
|
182
|
+
if [[ "${1,,}" =~ (^|[^a-z])(pkill|killall|kill)([^a-z]|$) ]]; then
|
|
183
|
+
return 0
|
|
184
|
+
fi
|
|
185
|
+
return 1
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# True (0) when a kill pattern/token would match sibling runners across the fleet.
|
|
189
|
+
# Matches the runner path tokens, and the bare interpreter process names bun/node
|
|
190
|
+
# even when wrapped in pgrep/regex metacharacters such as [b]un, ^node$, bun.* .
|
|
191
|
+
_pattern_hits_fleet() {
|
|
192
|
+
local lower="${1,,}"
|
|
193
|
+
case "$lower" in
|
|
194
|
+
*index.ts*|*agent-relay*|*humwick*)
|
|
195
|
+
return 0
|
|
196
|
+
;;
|
|
197
|
+
esac
|
|
198
|
+
if [[ "$lower" =~ (^|[^a-z0-9])(bun|node)([^a-z0-9]|$) ]]; then
|
|
199
|
+
return 0
|
|
200
|
+
fi
|
|
201
|
+
local cleaned="$lower" mc
|
|
202
|
+
for mc in '[' ']' '(' ')' '{' '}' '^' '$' '*' '+' '?' '\' '|'; do
|
|
203
|
+
cleaned="${cleaned//"$mc"/}"
|
|
204
|
+
done
|
|
205
|
+
if [[ "$cleaned" =~ (^|[^a-z0-9])(bun|node)([^a-z0-9]|$) ]]; then
|
|
206
|
+
return 0
|
|
207
|
+
fi
|
|
208
|
+
return 1
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# Inspect one shell segment. Prints guidance + returns 0 when it is a dangerous
|
|
212
|
+
# broad kill; returns 1 when the segment is safe or not a kill.
|
|
213
|
+
_fratricide_segment_reason() {
|
|
214
|
+
local raw="$1" segment base i=0 j arg
|
|
215
|
+
segment="$(trim_shell_text "$raw")"
|
|
216
|
+
if [ -z "$segment" ]; then
|
|
217
|
+
return 1
|
|
218
|
+
fi
|
|
219
|
+
if ! shell_words "$segment"; then
|
|
220
|
+
# Unparseable — only worry if it both looks like a kill and leaks a fleet token.
|
|
221
|
+
if _looks_like_kill "$segment" && _pattern_hits_fleet "$segment"; then
|
|
222
|
+
printf '%s' "$FRATRICIDE_PATTERN_GUIDANCE"
|
|
223
|
+
return 0
|
|
224
|
+
fi
|
|
225
|
+
return 1
|
|
226
|
+
fi
|
|
227
|
+
if (( ${#WORDS[@]} == 0 )); then
|
|
228
|
+
return 1
|
|
229
|
+
fi
|
|
230
|
+
|
|
231
|
+
# Skip any leading VAR=value assignments (e.g. SERVER_PID=$!; kill ...).
|
|
232
|
+
while (( i < ${#WORDS[@]} )) && [[ "${WORDS[i]}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do
|
|
233
|
+
((i += 1))
|
|
234
|
+
done
|
|
235
|
+
if (( i >= ${#WORDS[@]} )); then
|
|
236
|
+
return 1
|
|
237
|
+
fi
|
|
238
|
+
|
|
239
|
+
base="${WORDS[i]##*/}"
|
|
240
|
+
base="${base,,}"
|
|
241
|
+
case "$base" in
|
|
242
|
+
pkill|killall)
|
|
243
|
+
# Pattern-matching kills: block if ANY non-flag operand hits the fleet.
|
|
244
|
+
j=$((i + 1))
|
|
245
|
+
while (( j < ${#WORDS[@]} )); do
|
|
246
|
+
arg="${WORDS[j]}"
|
|
247
|
+
if [[ "$arg" == -* ]]; then
|
|
248
|
+
((j += 1))
|
|
249
|
+
continue
|
|
250
|
+
fi
|
|
251
|
+
if _pattern_hits_fleet "$arg"; then
|
|
252
|
+
printf '%s' "$FRATRICIDE_PATTERN_GUIDANCE"
|
|
253
|
+
return 0
|
|
254
|
+
fi
|
|
255
|
+
((j += 1))
|
|
256
|
+
done
|
|
257
|
+
return 1
|
|
258
|
+
;;
|
|
259
|
+
kill)
|
|
260
|
+
# PID-scoped kills (kill 123, kill "$PID", kill -9 %1) are safe. Block only
|
|
261
|
+
# when an operand leaks a fleet token — e.g. kill $(pgrep -f src/index.ts).
|
|
262
|
+
j=$((i + 1))
|
|
263
|
+
while (( j < ${#WORDS[@]} )); do
|
|
264
|
+
arg="${WORDS[j]}"
|
|
265
|
+
if _pattern_hits_fleet "$arg"; then
|
|
266
|
+
printf '%s' "$FRATRICIDE_PATTERN_GUIDANCE"
|
|
267
|
+
return 0
|
|
268
|
+
fi
|
|
269
|
+
((j += 1))
|
|
270
|
+
done
|
|
271
|
+
return 1
|
|
272
|
+
;;
|
|
273
|
+
*)
|
|
274
|
+
return 1
|
|
275
|
+
;;
|
|
276
|
+
esac
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# Segment matcher: split a full command into segments and check each one.
|
|
280
|
+
# Prints guidance + returns 0 on the first dangerous broad kill; returns 1 if safe.
|
|
281
|
+
_fratricide_segment_matcher_reason() {
|
|
282
|
+
local text="$1" segment reason
|
|
283
|
+
if ! split_segments "$text"; then
|
|
284
|
+
fratricide_raw_reason "$text"
|
|
285
|
+
return $?
|
|
286
|
+
fi
|
|
287
|
+
local -a segments=("${SEGMENTS[@]}")
|
|
288
|
+
for segment in "${segments[@]}"; do
|
|
289
|
+
if reason="$(_fratricide_segment_reason "$segment")"; then
|
|
290
|
+
printf '%s' "$reason"
|
|
291
|
+
return 0
|
|
292
|
+
fi
|
|
293
|
+
done
|
|
294
|
+
return 1
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
# Parser-backed matcher. Prints guidance + returns 0 when the command is a broad
|
|
298
|
+
# fleet kill; returns 1 if safe.
|
|
299
|
+
fratricide_deny_reason() {
|
|
300
|
+
local text="$1" verdict segment_reason="" segment_blocks=0
|
|
301
|
+
if segment_reason="$(_fratricide_segment_matcher_reason "$text")"; then
|
|
302
|
+
segment_blocks=1
|
|
303
|
+
fi
|
|
304
|
+
if (( ! segment_blocks )) && ! { _looks_like_kill "$text" && _pattern_hits_fleet "$text"; }; then
|
|
305
|
+
return 1
|
|
306
|
+
fi
|
|
307
|
+
if command -v bun >/dev/null 2>&1 && verdict="$(printf '%s' "$text" | bun "$KILL_GUARD_PARSER" 2>/dev/null)"; then
|
|
308
|
+
case "$verdict" in
|
|
309
|
+
block:*)
|
|
310
|
+
printf '%s Blocked command: %s' "$FRATRICIDE_KILL_GUIDANCE" "${verdict#block:}"
|
|
311
|
+
return 0
|
|
312
|
+
;;
|
|
313
|
+
static)
|
|
314
|
+
return 1
|
|
315
|
+
;;
|
|
316
|
+
esac
|
|
317
|
+
fi
|
|
318
|
+
(( segment_blocks )) || return 1
|
|
319
|
+
printf '%s' "$segment_reason"
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# Coarse raw-text fallback for when structured parsing is unavailable (no jq, or
|
|
323
|
+
# unparseable shell). Prints guidance + returns 0 only when the text both looks
|
|
324
|
+
# like a kill and leaks a fleet token.
|
|
325
|
+
fratricide_raw_reason() {
|
|
326
|
+
local text="$1"
|
|
327
|
+
if _looks_like_kill "$text" && _pattern_hits_fleet "$text"; then
|
|
328
|
+
printf '%s' "$FRATRICIDE_PATTERN_GUIDANCE"
|
|
329
|
+
return 0
|
|
330
|
+
fi
|
|
331
|
+
return 1
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
# Executed directly (not sourced): argv[1] is the command text to inspect.
|
|
335
|
+
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
|
336
|
+
if reason="$(fratricide_deny_reason "${1:-}")"; then
|
|
337
|
+
printf '%s\n' "$reason"
|
|
338
|
+
exit 0
|
|
339
|
+
fi
|
|
340
|
+
exit 1
|
|
341
|
+
fi
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// Fleet-fratricide parser (#753, #3117). stdin: command text. stdout: `block:<command>` for an executed
|
|
2
|
+
// broad kill, `static` when nothing in the command executes text (so a kill word in an argument, heredoc
|
|
3
|
+
// or comment is prose), or `sink` when text may reach a dynamic executor and kill-guard.sh's segment
|
|
4
|
+
// matcher must decide. A non-zero exit means "parser unavailable". Bundled to kill-guard.provisioned.mjs.
|
|
5
|
+
import { forEachChild, parse, type Command, type Pipeline, type Redirection, type SyntaxNode, type Word } from "unbash";
|
|
6
|
+
|
|
7
|
+
// Past this depth of `bash -c` / `eval` nesting the parser gives up and reports a sink.
|
|
8
|
+
const MAX_DEPTH = 8;
|
|
9
|
+
const MAX_HIT_CHARS = 200;
|
|
10
|
+
const KILL_VERBS = new Set(["pkill", "killall", "kill"]);
|
|
11
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
12
|
+
// Commands that run text they are given (their own args, a file or stdin) in ways the walker does not follow.
|
|
13
|
+
const DYNAMIC = new Set(["eval", "source", ".", "trap", "python", "python2", "python3", "perl", "ruby", "node", "bun", "deno", "php", "lua", "osascript"]);
|
|
14
|
+
// Readers with no option that executes anything: stdin stays data whatever their arguments.
|
|
15
|
+
const PLAIN_READERS = new Set(["cat", "tee", "head", "tail", "wc", "tr", "cut", "nl", "rev", "tac", "fold", "base64", "od", "md5sum", "sha256sum", "grep", "egrep", "fgrep", "jq", "echo", "printf", "true"]);
|
|
16
|
+
// Tools that take a message body on stdin, only in these exact subcommand forms. Anything else
|
|
17
|
+
// (`git -c alias.x='!sh' x`, a gh alias or extension) may run the text.
|
|
18
|
+
const GIT_BODY_COMMANDS = new Set(["commit", "tag", "notes"]);
|
|
19
|
+
const GH_BODY_COMMANDS = new Set(["issue create", "issue comment", "issue edit", "pr create", "pr comment", "pr edit", "pr review", "release create", "release edit", "gist create"]);
|
|
20
|
+
const RELAY_BODY_COMMANDS = new Set(["message", "/message", "reply", "/reply", "pair", "/pair"]);
|
|
21
|
+
const STDIN_PATHS = new Set(["/dev/stdin", "/dev/fd/0", "/proc/self/fd/0"]);
|
|
22
|
+
// Builtins that change what a later command name runs.
|
|
23
|
+
const REDEFINERS = new Set(["alias", "shopt", "enable", "hash"]);
|
|
24
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
25
|
+
|
|
26
|
+
/** How a wrapper's own options look: `valued` options take the next word unless attached
|
|
27
|
+
* (`-uroot`, `--user=root`); `nonExec` options mean the wrapped command never runs. */
|
|
28
|
+
interface WrapperSpec {
|
|
29
|
+
valued?: string[];
|
|
30
|
+
/** Options whose value is itself a command line (`env -S 'cmd'`, `su -c 'cmd'`). */
|
|
31
|
+
script?: string[];
|
|
32
|
+
nonExec?: string[];
|
|
33
|
+
assignments?: boolean;
|
|
34
|
+
positionals?: number;
|
|
35
|
+
/** The wrapped words are joined and run by a shell (`ssh host cmd`, `watch cmd`). */
|
|
36
|
+
joinsRest?: boolean;
|
|
37
|
+
/** Options may follow operands (`su root -c cmd`). */
|
|
38
|
+
permute?: boolean;
|
|
39
|
+
/** Any use is a dynamic-execution sink, even when the walker also follows the command. */
|
|
40
|
+
sink?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const WRAPPERS: Record<string, WrapperSpec> = {
|
|
44
|
+
sudo: {
|
|
45
|
+
valued: ["u", "g", "h", "p", "C", "D", "r", "t", "T", "U", "--user", "--group", "--host", "--prompt", "--close-from", "--chdir", "--role", "--type", "--command-timeout", "--other-user"],
|
|
46
|
+
assignments: true,
|
|
47
|
+
},
|
|
48
|
+
doas: { valued: ["u", "C"] },
|
|
49
|
+
su: { script: ["c", "--command"], valued: ["s", "g", "G", "w", "--shell", "--group", "--supp-group", "--whitelist-environment"], permute: true, sink: true },
|
|
50
|
+
env: { script: ["S", "--split-string"], valued: ["u", "C", "--unset", "--chdir"], assignments: true },
|
|
51
|
+
xargs: { valued: ["a", "d", "E", "I", "L", "n", "P", "s", "--arg-file", "--delimiter", "--max-args", "--max-procs", "--max-chars", "--process-slot-var"], sink: true },
|
|
52
|
+
timeout: { valued: ["s", "k", "--signal", "--kill-after"], positionals: 1 },
|
|
53
|
+
nice: { valued: ["n", "--adjustment"] },
|
|
54
|
+
ionice: { valued: ["c", "n", "--class", "--classdata"] },
|
|
55
|
+
stdbuf: { valued: ["i", "o", "e", "--input", "--output", "--error"] },
|
|
56
|
+
time: { valued: ["f", "o", "--format", "--output"] },
|
|
57
|
+
exec: { valued: ["a"], sink: true },
|
|
58
|
+
command: { nonExec: ["v", "V"] },
|
|
59
|
+
nohup: {},
|
|
60
|
+
setsid: {},
|
|
61
|
+
builtin: {},
|
|
62
|
+
watch: { valued: ["n", "--interval"], joinsRest: true, sink: true },
|
|
63
|
+
ssh: { valued: [..."BbcDEeFIiJLlmOoPpQRSWw"], positionals: 1, joinsRest: true, sink: true },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Per-call walk state: the whole command, and whether any sink was seen. */
|
|
67
|
+
interface Walk {
|
|
68
|
+
source: string;
|
|
69
|
+
sink: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Context {
|
|
73
|
+
depth: number;
|
|
74
|
+
/** stdin carries data from a heredoc, here-string or an earlier pipeline stage. */
|
|
75
|
+
stdinData?: boolean;
|
|
76
|
+
/** That data when it is literal (a heredoc, here-string, or `echo`/`cat <<EOF` piped in). */
|
|
77
|
+
stdinText?: string;
|
|
78
|
+
/** Arguments are read from stdin (`xargs kill`), so the kill targets are not in its words. */
|
|
79
|
+
xargsInput?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const baseName = (word: string): string => word.slice(word.lastIndexOf("/") + 1);
|
|
83
|
+
|
|
84
|
+
/** True when `words` provably reads stdin as data: a known command in a known form, no other path. */
|
|
85
|
+
function readsAsData(words: string[]): boolean {
|
|
86
|
+
const command = words[0]!;
|
|
87
|
+
if (command.includes("/") && !/^\/(usr\/)?bin\/[^/]+$/.test(command)) return false;
|
|
88
|
+
const name = baseName(command);
|
|
89
|
+
if (PLAIN_READERS.has(name)) return true;
|
|
90
|
+
if (name === "git") return GIT_BODY_COMMANDS.has(words[1] ?? "");
|
|
91
|
+
if (name === "gh") return GH_BODY_COMMANDS.has(`${words[1]} ${words[2]}`);
|
|
92
|
+
return (name === "agent-relay" || name === "humwick") && RELAY_BODY_COMMANDS.has(words[1] ?? "");
|
|
93
|
+
}
|
|
94
|
+
// Values keep expansions verbatim (`$X`, `$(cmd)`), so a `$` or backtick means the word is not static.
|
|
95
|
+
const isDynamicWord = (value: string): boolean => /[$`]/.test(value);
|
|
96
|
+
|
|
97
|
+
// Keep in sync with _pattern_hits_fleet in kill-guard.sh. True when a kill pattern would match
|
|
98
|
+
// sibling runners: the runner path tokens, or bare bun/node even inside regex metacharacters.
|
|
99
|
+
export function hitsFleet(text: string): boolean {
|
|
100
|
+
const lower = text.toLowerCase();
|
|
101
|
+
if (lower.includes("index.ts") || lower.includes("agent-relay") || lower.includes("humwick")) return true;
|
|
102
|
+
const fleetToken = /(^|[^a-z0-9])(bun|node)([^a-z0-9]|$)/;
|
|
103
|
+
return fleetToken.test(lower) || fleetToken.test(lower.replace(/[[\](){}^$*+?\\|]/g, ""));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type Option = { kind: "skip"; next: number } | { kind: "script"; value: string; next: number } | { kind: "nonExec" };
|
|
107
|
+
|
|
108
|
+
/** Reads the wrapper option at `words[i]`, or returns undefined when it is not an option. */
|
|
109
|
+
function readOption(words: string[], i: number, spec: WrapperSpec): Option | undefined {
|
|
110
|
+
const word = words[i]!;
|
|
111
|
+
const valueOf = (name: string, attached: string | undefined): Option => {
|
|
112
|
+
if (spec.nonExec?.includes(name)) return { kind: "nonExec" };
|
|
113
|
+
const takesValue = spec.script?.includes(name) || spec.valued?.includes(name);
|
|
114
|
+
if (!takesValue) return { kind: "skip", next: i + 1 };
|
|
115
|
+
const next = attached === undefined ? i + 2 : i + 1;
|
|
116
|
+
const value = attached ?? words[i + 1];
|
|
117
|
+
return spec.script?.includes(name) && value !== undefined ? { kind: "script", value, next } : { kind: "skip", next };
|
|
118
|
+
};
|
|
119
|
+
if (word.startsWith("--") && word.length > 2) {
|
|
120
|
+
const eq = word.indexOf("=");
|
|
121
|
+
return eq < 0 ? valueOf(word, undefined) : valueOf(word.slice(0, eq), word.slice(eq + 1));
|
|
122
|
+
}
|
|
123
|
+
if (word.length < 2 || !word.startsWith("-")) return undefined;
|
|
124
|
+
// A cluster (`-iS'cmd'`, `-uroot`): the first option taking a value takes the rest of the word.
|
|
125
|
+
for (let k = 1; k < word.length; k += 1) {
|
|
126
|
+
const letter = word[k]!;
|
|
127
|
+
if (spec.nonExec?.includes(letter)) return { kind: "nonExec" };
|
|
128
|
+
if (spec.script?.includes(letter) || spec.valued?.includes(letter)) return valueOf(letter, k + 1 < word.length ? word.slice(k + 1) : undefined);
|
|
129
|
+
}
|
|
130
|
+
return { kind: "skip", next: i + 1 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type Handoff = { next: number } | { script: string } | { none: "nonExec" | "stdin" };
|
|
134
|
+
|
|
135
|
+
/** Where a wrapper hands off: the wrapped command word, a command line it runs through a shell,
|
|
136
|
+
* or nothing (a lookup like `command -v`, or an interactive shell reading stdin). */
|
|
137
|
+
function scanWrapper(words: string[], start: number, spec: WrapperSpec): Handoff {
|
|
138
|
+
let i = start + 1;
|
|
139
|
+
while (i < words.length) {
|
|
140
|
+
const word = words[i]!;
|
|
141
|
+
if (word === "--") {
|
|
142
|
+
i += 1;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
const option = readOption(words, i, spec);
|
|
146
|
+
if (option?.kind === "nonExec") return { none: "nonExec" };
|
|
147
|
+
if (option?.kind === "script") return { script: [option.value, ...words.slice(option.next)].join(" ") };
|
|
148
|
+
if (option) {
|
|
149
|
+
i = option.next;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!(spec.permute || (spec.assignments && ASSIGNMENT.test(word)))) break;
|
|
153
|
+
i += 1;
|
|
154
|
+
}
|
|
155
|
+
if (spec.permute) return { none: "stdin" };
|
|
156
|
+
const next = i + (spec.positionals ?? 0);
|
|
157
|
+
if (!spec.joinsRest) return { next };
|
|
158
|
+
return next < words.length ? { script: words.slice(next).join(" ") } : { none: "stdin" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A shell's `-c` command line, or undefined when it reads a script from stdin (`-s`) or a file. */
|
|
162
|
+
function shellCommand(words: string[]): string | undefined {
|
|
163
|
+
let i = 1;
|
|
164
|
+
let command = false;
|
|
165
|
+
for (; i < words.length; i += 1) {
|
|
166
|
+
const word = words[i]!;
|
|
167
|
+
if (word === "--" || word === "-") {
|
|
168
|
+
i += 1;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
if (word.startsWith("--")) {
|
|
172
|
+
if (word === "--rcfile" || word === "--init-file") i += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (word.length > 1 && (word.startsWith("-") || word.startsWith("+"))) {
|
|
176
|
+
const letters = word.slice(1);
|
|
177
|
+
if (letters.includes("c")) command = true;
|
|
178
|
+
if (/[oO]/.test(letters)) i += 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
return command ? (words[i] ?? "") : undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function checkKill(verb: string, words: string[], ctx: Context, walk: Walk): boolean {
|
|
187
|
+
const operands = verb === "kill" ? words.slice(1) : words.slice(1).filter((word) => !word.startsWith("-"));
|
|
188
|
+
// `xargs kill` takes its targets from stdin, which the walker does not trace: any fleet token decides.
|
|
189
|
+
return operands.some(hitsFleet) || (ctx.xargsInput === true && hitsFleet(walk.source));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A shell reading its script from stdin: a sink, and when the script is literal, parsed too. */
|
|
193
|
+
function checkStdinShell(ctx: Context, walk: Walk): boolean {
|
|
194
|
+
walk.sink = true;
|
|
195
|
+
return ctx.stdinText !== undefined && checkScript(ctx.stdinText, { depth: ctx.depth }, walk);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function checkArgv(words: string[], ctx: Context, walk: Walk): boolean {
|
|
199
|
+
let i = 0;
|
|
200
|
+
let xargsInput = ctx.xargsInput;
|
|
201
|
+
while (i < words.length) {
|
|
202
|
+
const base = baseName(words[i]!);
|
|
203
|
+
const spec = WRAPPERS[base];
|
|
204
|
+
if (!spec) break;
|
|
205
|
+
if (spec.sink) walk.sink = true;
|
|
206
|
+
if (base === "xargs") xargsInput = true;
|
|
207
|
+
const handoff = scanWrapper(words, i, spec);
|
|
208
|
+
if ("none" in handoff) return handoff.none === "stdin" && checkStdinShell(ctx, walk);
|
|
209
|
+
if ("script" in handoff) {
|
|
210
|
+
walk.sink = true;
|
|
211
|
+
return checkScript(handoff.script, { ...ctx, xargsInput }, walk);
|
|
212
|
+
}
|
|
213
|
+
i = handoff.next;
|
|
214
|
+
}
|
|
215
|
+
if (i >= words.length) {
|
|
216
|
+
// `echo cmd | sudo -s`: a wrapper with no command starts a shell reading stdin.
|
|
217
|
+
return ctx.stdinData === true && i > 0 && checkStdinShell(ctx, walk);
|
|
218
|
+
}
|
|
219
|
+
const rest = words.slice(i);
|
|
220
|
+
const verb = baseName(rest[0]!);
|
|
221
|
+
if (isDynamicWord(rest[0]!) || DYNAMIC.has(verb) || REDEFINERS.has(verb)) walk.sink = true;
|
|
222
|
+
// `env PATH=/tmp/x cat`: a changed PATH means a known name may run something else.
|
|
223
|
+
if (words.slice(0, i).some((word) => word.startsWith("PATH="))) walk.sink = true;
|
|
224
|
+
if (KILL_VERBS.has(verb)) return checkKill(verb, rest, { ...ctx, xargsInput }, walk);
|
|
225
|
+
if (verb === "eval") return checkScript(rest.slice(1).join(" "), { ...ctx, xargsInput }, walk);
|
|
226
|
+
// `trap 'cmd' EXIT` runs its first operand as a command line.
|
|
227
|
+
if (verb === "trap") return rest.length > 2 && !rest[1]!.startsWith("-") && checkScript(rest[1]!, ctx, walk);
|
|
228
|
+
if ((verb === "source" || verb === ".") && STDIN_PATHS.has(rest[1] ?? "")) return checkStdinShell(ctx, walk);
|
|
229
|
+
if (SHELLS.has(verb)) {
|
|
230
|
+
const command = shellCommand(rest);
|
|
231
|
+
return command === undefined ? checkStdinShell(ctx, walk) : checkScript(command, { ...ctx, xargsInput }, walk);
|
|
232
|
+
}
|
|
233
|
+
if (ctx.stdinData && !readsAsData(rest)) walk.sink = true;
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function checkCommand(command: Command, ctx: Context, walk: Walk): boolean {
|
|
238
|
+
const words = [command.name, ...command.args]
|
|
239
|
+
.filter((arg): arg is Word => arg?.type === "Word")
|
|
240
|
+
.map((word) => word.value);
|
|
241
|
+
const hereText = hereTextOf(command.redirects);
|
|
242
|
+
const stdin = hereText === undefined ? {} : { stdinData: true, stdinText: hereText };
|
|
243
|
+
return checkArgv(words, { ...ctx, ...stdin }, walk);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The literal text a heredoc or here-string among `redirects` feeds to stdin, if any. */
|
|
247
|
+
function hereTextOf(redirects: readonly Redirection[]): string | undefined {
|
|
248
|
+
let text: string | undefined;
|
|
249
|
+
for (const redirect of redirects) {
|
|
250
|
+
if (redirect.type === "HereString") text = redirect.target?.value;
|
|
251
|
+
else if (redirect.type === "HereDoc") text = redirect.body.text;
|
|
252
|
+
}
|
|
253
|
+
return text;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** What a pipeline stage writes when that is literal: `echo words`, `printf` lines or `cat <<EOF`. */
|
|
257
|
+
function literalOutput(stage: SyntaxNode): string | undefined {
|
|
258
|
+
if (stage.type !== "Command") return undefined;
|
|
259
|
+
const words = stage.args.filter((arg): arg is Word => arg.type === "Word").map((word) => word.value);
|
|
260
|
+
const name = stage.name?.value;
|
|
261
|
+
if (name === "echo") return words.filter((word) => !/^-[neE]+$/.test(word)).join(" ");
|
|
262
|
+
// Each printf argument as its own line approximates `printf '%s\n' a b`; a lone format is printed.
|
|
263
|
+
if (name === "printf") return words.length > 1 ? words.slice(1).join("\n") : words[0]?.replace(/\\n/g, "\n");
|
|
264
|
+
return name === "cat" && words.length === 0 ? hereTextOf(stage.redirects) : undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function pipelineHit(pipeline: Pipeline, ctx: Context, walk: Walk): boolean {
|
|
268
|
+
return pipeline.commands.some((stage, index) => {
|
|
269
|
+
if (index === 0) return nodeHit(stage, ctx, walk);
|
|
270
|
+
// Only a simple command can be a proven data reader; a grouped stage is a sink.
|
|
271
|
+
if (stage.type !== "Command") walk.sink = true;
|
|
272
|
+
return nodeHit(stage, { ...ctx, stdinData: true, stdinText: literalOutput(pipeline.commands[index - 1]!) }, walk);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function childHit(node: SyntaxNode, ctx: Context, walk: Walk): boolean {
|
|
277
|
+
let hit = false;
|
|
278
|
+
forEachChild(node, (child) => {
|
|
279
|
+
if (!hit) hit = nodeHit(child, ctx, walk);
|
|
280
|
+
});
|
|
281
|
+
return hit;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function nodeHit(node: SyntaxNode, ctx: Context, walk: Walk): boolean {
|
|
285
|
+
switch (node.type) {
|
|
286
|
+
case "Script":
|
|
287
|
+
if ("errors" in node && (node as { errors?: unknown[] }).errors?.length) walk.sink = true;
|
|
288
|
+
break;
|
|
289
|
+
case "Pipeline":
|
|
290
|
+
return pipelineHit(node, ctx, walk);
|
|
291
|
+
case "Command":
|
|
292
|
+
if (checkCommand(node, ctx, walk)) return true;
|
|
293
|
+
return childHit(node, { depth: ctx.depth }, walk);
|
|
294
|
+
case "Function":
|
|
295
|
+
// A function can shadow any command name, including a data reader.
|
|
296
|
+
walk.sink = true;
|
|
297
|
+
break;
|
|
298
|
+
case "Assignment":
|
|
299
|
+
if (node.name === "PATH") walk.sink = true;
|
|
300
|
+
break;
|
|
301
|
+
case "Redirected": {
|
|
302
|
+
// Every command in `{ sh; } <<< text` shares that stdin.
|
|
303
|
+
const hereText = hereTextOf(node.redirects);
|
|
304
|
+
if (hereText === undefined) break;
|
|
305
|
+
walk.sink = true;
|
|
306
|
+
return childHit(node, { ...ctx, stdinData: true, stdinText: hereText }, walk);
|
|
307
|
+
}
|
|
308
|
+
case "ProcessSubstitution":
|
|
309
|
+
walk.sink = true;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
return childHit(node, ctx, walk);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function checkScript(source: string, ctx: Context, walk: Walk): boolean {
|
|
316
|
+
if (ctx.depth >= MAX_DEPTH) {
|
|
317
|
+
walk.sink = true;
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
return nodeHit(parse(source), { ...ctx, depth: ctx.depth + 1 }, walk);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export type KillGuardVerdict = { kind: "block"; command: string } | { kind: "static" } | { kind: "sink" };
|
|
324
|
+
|
|
325
|
+
export function fleetKillVerdict(command: string): KillGuardVerdict {
|
|
326
|
+
const walk: Walk = { source: command, sink: false };
|
|
327
|
+
const script = parse(command);
|
|
328
|
+
if (script.errors?.length) walk.sink = true;
|
|
329
|
+
const statement = script.commands.find((candidate) => nodeHit(candidate, { depth: 0 }, walk));
|
|
330
|
+
if (statement) return { kind: "block", command: command.slice(statement.pos, statement.end).slice(0, MAX_HIT_CHARS) };
|
|
331
|
+
return walk.sink ? { kind: "sink" } : { kind: "static" };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (import.meta.main) {
|
|
335
|
+
const verdict = fleetKillVerdict(await Bun.stdin.text());
|
|
336
|
+
process.stdout.write(verdict.kind === "block" ? `block:${verdict.command}` : verdict.kind);
|
|
337
|
+
}
|