indusagi-coding-agent 0.2.5 → 0.2.9
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/CHANGELOG.md +9 -0
- package/README.md +9 -5
- package/dist/entry.js +14267 -24282
- package/dist/guardrails.js +924 -7867
- package/dist/index.js +13706 -23775
- package/package.json +3 -2
- package/src/_decls/entry.ts +18 -0
- package/src/_decls/guardrails.ts +35 -0
- package/src/_decls/index.ts +26 -0
- package/src/addons/contract.ts +236 -0
- package/src/addons/dispatch/event-dispatcher.ts +164 -0
- package/src/addons/dispatch/index.ts +25 -0
- package/src/addons/dispatch/tool-interceptor.ts +208 -0
- package/src/addons/host.ts +225 -0
- package/src/addons/index.ts +112 -0
- package/src/addons/manifest.ts +158 -0
- package/src/addons/sandbox.ts +170 -0
- package/src/addons/surface.ts +78 -0
- package/src/boot/auth-vault.ts +195 -0
- package/src/boot/boot.ts +138 -0
- package/src/boot/contract.ts +238 -0
- package/src/boot/heap.ts +59 -0
- package/src/boot/index.ts +28 -0
- package/src/boot/invocation.ts +93 -0
- package/src/boot/runners/addon-wiring.ts +153 -0
- package/src/boot/runners/checkpoint.ts +169 -0
- package/src/boot/runners/delegate-runner.ts +294 -0
- package/src/boot/runners/index.ts +13 -0
- package/src/boot/runners/link-runner.ts +45 -0
- package/src/boot/runners/memdir.ts +168 -0
- package/src/boot/runners/oneshot-runner.ts +58 -0
- package/src/boot/runners/read-state.ts +90 -0
- package/src/boot/runners/registry.ts +42 -0
- package/src/boot/runners/repl-runner.ts +143 -0
- package/src/boot/runners/server-mode.ts +121 -0
- package/src/boot/runners/session.ts +641 -0
- package/src/boot/server-token.ts +148 -0
- package/src/boot/stages.ts +167 -0
- package/src/boot/upgrade/apply.ts +94 -0
- package/src/boot/upgrade/index.ts +13 -0
- package/src/boot/upgrade/upgrades.ts +289 -0
- package/src/briefing/compose.ts +150 -0
- package/src/briefing/context-docs.ts +19 -0
- package/src/briefing/contract.ts +717 -0
- package/src/briefing/index.ts +31 -0
- package/src/briefing/macros.ts +97 -0
- package/src/briefing/skills.ts +47 -0
- package/src/capability-deck/bridge-ledger/index.ts +27 -0
- package/src/capability-deck/bridge-ledger/key.ts +67 -0
- package/src/capability-deck/bridge-ledger/ledger.ts +131 -0
- package/src/capability-deck/bridge-ledger/network.ts +117 -0
- package/src/capability-deck/builtin-bridge.ts +312 -0
- package/src/capability-deck/cards/bg-process-card.ts +335 -0
- package/src/capability-deck/cards/index.ts +115 -0
- package/src/capability-deck/cards/memory-card.ts +146 -0
- package/src/capability-deck/cards/plan-file.ts +97 -0
- package/src/capability-deck/cards/plan-tools.ts +185 -0
- package/src/capability-deck/cards/saas-card.ts +183 -0
- package/src/capability-deck/cards/task-card.ts +207 -0
- package/src/capability-deck/cards/todo-card.ts +168 -0
- package/src/capability-deck/cards/workflow-card.ts +247 -0
- package/src/capability-deck/contract.ts +388 -0
- package/src/capability-deck/index.ts +48 -0
- package/src/capability-deck/manifest.ts +109 -0
- package/src/capability-deck/provision.ts +169 -0
- package/src/channels/contract.ts +191 -0
- package/src/channels/framer.ts +50 -0
- package/src/channels/index.ts +101 -0
- package/src/channels/link/dialog.ts +129 -0
- package/src/channels/link/driver.ts +190 -0
- package/src/channels/link/index.ts +34 -0
- package/src/channels/link/server.ts +134 -0
- package/src/channels/oneshot.ts +90 -0
- package/src/channels/ops.ts +65 -0
- package/src/channels/session-ops.ts +81 -0
- package/src/conductor/bash-guard.ts +599 -0
- package/src/conductor/catalog/catalog.ts +116 -0
- package/src/conductor/catalog/index.ts +8 -0
- package/src/conductor/catalog/matcher.ts +134 -0
- package/src/conductor/conductor.ts +234 -0
- package/src/conductor/contract.ts +842 -0
- package/src/conductor/diagnostics.ts +227 -0
- package/src/conductor/index.ts +33 -0
- package/src/conductor/permissions.ts +588 -0
- package/src/conductor/quota-error.ts +49 -0
- package/src/conductor/signal-hub/hub.ts +46 -0
- package/src/conductor/signal-hub/index.ts +2 -0
- package/src/conductor/signal-hub/translate.test.ts +81 -0
- package/src/conductor/signal-hub/translate.ts +74 -0
- package/src/conductor/skill-parse/index.ts +2 -0
- package/src/conductor/skill-parse/parse.ts +108 -0
- package/src/conductor/transcript-store/index.ts +22 -0
- package/src/conductor/transcript-store/serialize.ts +116 -0
- package/src/conductor/transcript-store/store.ts +205 -0
- package/src/console/auth-status.ts +56 -0
- package/src/console/components/AgentsView.ts +165 -0
- package/src/console/components/BackgroundAgents.ts +155 -0
- package/src/console/components/Banner.ts +334 -0
- package/src/console/components/Composer.ts +94 -0
- package/src/console/components/StatusBar.ts +49 -0
- package/src/console/components/TerminalConsole.ts +1090 -0
- package/src/console/components/WorkingIndicator.ts +98 -0
- package/src/console/components/banner-sweep.ts +24 -0
- package/src/console/components/welcome.ts +74 -0
- package/src/console/contract.ts +630 -0
- package/src/console/index.ts +34 -0
- package/src/console/input/complete.ts +127 -0
- package/src/console/input/dir-reader.ts +34 -0
- package/src/console/input/index.ts +23 -0
- package/src/console/input/keymap.ts +159 -0
- package/src/console/input/paste.ts +104 -0
- package/src/console/mount.ts +56 -0
- package/src/console/overlays/approval-queue.ts +57 -0
- package/src/console/overlays/approval.ts +130 -0
- package/src/console/overlays/auth.ts +342 -0
- package/src/console/overlays/boards.ts +308 -0
- package/src/console/overlays/host.ts +36 -0
- package/src/console/overlays/index.ts +26 -0
- package/src/console/overlays/pickers.ts +258 -0
- package/src/console/overlays/sessions.ts +190 -0
- package/src/console/reducer.ts +182 -0
- package/src/console/slash/builtins.ts +81 -0
- package/src/console/slash/commands/dynamic.ts +83 -0
- package/src/console/slash/commands/integrations.ts +695 -0
- package/src/console/slash/commands/shared.ts +75 -0
- package/src/console/slash/commands/transcript.ts +263 -0
- package/src/console/slash/commands/workbench.ts +246 -0
- package/src/console/slash/index.ts +15 -0
- package/src/console/slash/registry.ts +70 -0
- package/src/console/slash/resolve.ts +63 -0
- package/src/console/startup.ts +209 -0
- package/src/console/theme/adapter.ts +45 -0
- package/src/console/theme/index.ts +7 -0
- package/src/console/theme/palette.ts +68 -0
- package/src/console/theme/resolve.ts +39 -0
- package/src/console/theme/tokens.ts +71 -0
- package/src/entry.ts +55 -0
- package/src/guardrails.ts +37 -0
- package/src/index.ts +18 -0
- package/src/insight/channel.ts +88 -0
- package/src/insight/contract.ts +185 -0
- package/src/insight/index.ts +110 -0
- package/src/insight/recorder.ts +213 -0
- package/src/insight/redaction.ts +157 -0
- package/src/insight/replay.ts +158 -0
- package/src/insight/sampling.ts +70 -0
- package/src/insight/serialize.ts +50 -0
- package/src/insight/sinks/console.ts +64 -0
- package/src/insight/sinks/file.ts +40 -0
- package/src/insight/sinks/index.ts +24 -0
- package/src/insight/sinks/stream.ts +54 -0
- package/src/integrations/sarvam/attach.ts +239 -0
- package/src/integrations/sarvam/config.ts +156 -0
- package/src/integrations/sarvam/index.ts +25 -0
- package/src/integrations/sarvam/sarvam.test.ts +60 -0
- package/src/integrations/sarvam/types.ts +27 -0
- package/src/integrations/zoho/attach.ts +342 -0
- package/src/integrations/zoho/config.ts +125 -0
- package/src/integrations/zoho/index.ts +27 -0
- package/src/integrations/zoho/types.ts +21 -0
- package/src/integrations/zoho/zoho.test.ts +50 -0
- package/src/kit/clipboard-image.ts +107 -0
- package/src/kit/external-editor.ts +48 -0
- package/src/kit/image.ts +59 -0
- package/src/kit/index.ts +51 -0
- package/src/kit/shell.ts +19 -0
- package/src/kit/tool-fetch.ts +85 -0
- package/src/launch/catalog.ts +148 -0
- package/src/launch/contract.ts +187 -0
- package/src/launch/credentials.ts +625 -0
- package/src/launch/index.ts +98 -0
- package/src/launch/invocation/attachments.ts +179 -0
- package/src/launch/invocation/flags.ts +196 -0
- package/src/launch/invocation/index.ts +25 -0
- package/src/launch/invocation/read.ts +260 -0
- package/src/launch/invocation/usage.ts +67 -0
- package/src/launch/login.ts +324 -0
- package/src/launch/oauth.test.ts +18 -0
- package/src/launch/oauth.ts +203 -0
- package/src/launch/packages.ts +194 -0
- package/src/launch/pickers.ts +189 -0
- package/src/runtime-bridge/bridges/_drive.ts +96 -0
- package/src/runtime-bridge/bridges/builtins.ts +68 -0
- package/src/runtime-bridge/bridges/claude-cli.ts +123 -0
- package/src/runtime-bridge/bridges/codex-cli.ts +142 -0
- package/src/runtime-bridge/bridges/index.ts +33 -0
- package/src/runtime-bridge/bridges/indusagi-cli.ts +155 -0
- package/src/runtime-bridge/broker.ts +227 -0
- package/src/runtime-bridge/contract.ts +122 -0
- package/src/runtime-bridge/index.ts +79 -0
- package/src/runtime-bridge/sink.ts +180 -0
- package/src/sessions/contract.ts +81 -0
- package/src/sessions/index.ts +13 -0
- package/src/sessions/library.ts +229 -0
- package/src/settings/contract.ts +114 -0
- package/src/settings/index.ts +32 -0
- package/src/settings/manager.ts +117 -0
- package/src/transcript-export/index.ts +45 -0
- package/src/transcript-export/publish.ts +260 -0
- package/src/transcript-export/sgr.ts +315 -0
- package/src/transcript-export/template.ts +272 -0
- package/src/transcript-export/theme-bridge.ts +150 -0
- package/src/window-budget/budget/estimate.ts +135 -0
- package/src/window-budget/budget/gate.ts +33 -0
- package/src/window-budget/budget/index.ts +16 -0
- package/src/window-budget/budget/slice.ts +56 -0
- package/src/window-budget/condenser.ts +58 -0
- package/src/window-budget/contract.ts +184 -0
- package/src/window-budget/index.ts +19 -0
- package/src/window-budget/microcompact.ts +95 -0
- package/src/window-budget/rehydrate.ts +136 -0
- package/src/window-budget/summarize/condense.ts +103 -0
- package/src/window-budget/summarize/index.ts +14 -0
- package/src/window-budget/summarize/prompt.ts +149 -0
- package/src/workflow-engine/agent-runner.ts +181 -0
- package/src/workflow-engine/display.ts +224 -0
- package/src/workflow-engine/engine.ts +294 -0
- package/src/workflow-engine/index.ts +23 -0
- package/src/workflow-engine/parse.ts +172 -0
- package/src/workflow-engine/structured-output.ts +35 -0
- package/src/workspace/brand.ts +29 -0
- package/src/workspace/index.ts +18 -0
- package/src/workspace/locator.ts +103 -0
- package/src/workspace/runtime-detect.ts +64 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session ops — the concrete operation registry that drives the link.
|
|
3
|
+
*/
|
|
4
|
+
import type { ConductorState, LinkSnapshot, Op, ThinkingLevel } from "./contract.js";
|
|
5
|
+
import { defineOps } from "./contract.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_THINKING: ThinkingLevel = "off";
|
|
8
|
+
|
|
9
|
+
export function projectSnapshot(
|
|
10
|
+
state: ConductorState,
|
|
11
|
+
extra: {
|
|
12
|
+
readonly thinking?: ThinkingLevel;
|
|
13
|
+
readonly sessionFile?: string;
|
|
14
|
+
readonly autoCondense?: boolean;
|
|
15
|
+
readonly messageCount?: number;
|
|
16
|
+
readonly queuedCount?: number;
|
|
17
|
+
} = {},
|
|
18
|
+
): LinkSnapshot {
|
|
19
|
+
const base: LinkSnapshot = {
|
|
20
|
+
model: state.modelId,
|
|
21
|
+
thinking: extra.thinking ?? DEFAULT_THINKING,
|
|
22
|
+
streaming: state.phase === "streaming" || state.phase === "tooling",
|
|
23
|
+
condensing: state.phase === "condensing",
|
|
24
|
+
faulted: state.phase === "faulted",
|
|
25
|
+
sessionId: state.head.sessionId,
|
|
26
|
+
autoCondense: extra.autoCondense ?? true,
|
|
27
|
+
messageCount: extra.messageCount ?? 0,
|
|
28
|
+
queuedCount: extra.queuedCount ?? 0,
|
|
29
|
+
usage: state.usage,
|
|
30
|
+
};
|
|
31
|
+
return extra.sessionFile === undefined ? base : { ...base, sessionFile: extra.sessionFile };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SubmitParams {
|
|
35
|
+
readonly input: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ResumeParams {
|
|
39
|
+
readonly sessionId: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CycleModelParams {
|
|
43
|
+
readonly modelId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ModelEntry {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly active: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function op<P, R>(method: string, handle: (params: P, ctx: import("./contract.js").ChannelContext) => Promise<R>): Op<P, R> {
|
|
52
|
+
return { method, handle };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const SESSION_OPS = defineOps({
|
|
56
|
+
submit: op("submit", async (params: SubmitParams, ctx) => {
|
|
57
|
+
const state = await ctx.conductor.submit(params.input);
|
|
58
|
+
return projectSnapshot(state);
|
|
59
|
+
}),
|
|
60
|
+
abort: op("abort", async (_params, ctx) => {
|
|
61
|
+
ctx.conductor.abort();
|
|
62
|
+
return projectSnapshot(ctx.conductor.snapshot());
|
|
63
|
+
}),
|
|
64
|
+
snapshot: op("snapshot", async (_params, ctx) => {
|
|
65
|
+
return projectSnapshot(ctx.conductor.snapshot());
|
|
66
|
+
}),
|
|
67
|
+
resume: op("resume", async (params: ResumeParams, ctx) => {
|
|
68
|
+
await ctx.conductor.resume(params.sessionId);
|
|
69
|
+
return projectSnapshot(ctx.conductor.snapshot());
|
|
70
|
+
}),
|
|
71
|
+
listModels: op("listModels", async (_params, ctx) => {
|
|
72
|
+
const active = ctx.conductor.snapshot().modelId;
|
|
73
|
+
return [{ id: active, active: true }];
|
|
74
|
+
}),
|
|
75
|
+
cycleModel: op("cycleModel", async (params: CycleModelParams, ctx) => {
|
|
76
|
+
ctx.conductor.cycleModel?.(params.modelId);
|
|
77
|
+
return projectSnapshot(ctx.conductor.snapshot());
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export type SessionOps = typeof SESSION_OPS;
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash command parser/classifier + catastrophic-command blocklist.
|
|
3
|
+
*
|
|
4
|
+
* The permission engine ({@link "./permissions"}) matches a tool call against
|
|
5
|
+
* induscode-style `Bash(...)` allow/ask/deny rules. For most tools the call is a
|
|
6
|
+
* single atomic action, so a rule's argument specifier matches against one
|
|
7
|
+
* subject string. The shell tool is different: `a && b; c | d` is FOUR commands
|
|
8
|
+
* in one call, and a coarse "match the whole command string" check is trivially
|
|
9
|
+
* bypassed — a benign-looking prefix can smuggle a destructive tail past an
|
|
10
|
+
* allow rule (`npm test && rm -rf /`), or hide a blocked binary behind a wrapper
|
|
11
|
+
* (`env X=1 rm -rf /`, `sudo rm -rf /`) or quoting.
|
|
12
|
+
*
|
|
13
|
+
* This module makes bash matching real. It is split in two concerns:
|
|
14
|
+
*
|
|
15
|
+
* 1. **A parser/classifier** — {@link parseBashCommand} splits a shell command
|
|
16
|
+
* string into its constituent *simple* commands across the control
|
|
17
|
+
* operators (`;`, `&&`, `||`, `|`, newlines) and `( … )` subshells /
|
|
18
|
+
* `$( … )` / `` ` … ` `` command substitutions, strips benign leading
|
|
19
|
+
* wrappers (`env VAR=x`, `nice`, `nohup`, `timeout N`, `xargs`, `command`,
|
|
20
|
+
* `builtin`, and `sudo`/`doas` — the last recorded as an *elevation* flag),
|
|
21
|
+
* and returns each base command + its raw argument string. Quoting is
|
|
22
|
+
* handled well enough that `"rm -rf /"` cannot hide behind quotes or a
|
|
23
|
+
* wrapper. The split is quote-aware so an operator *inside* a quoted string
|
|
24
|
+
* (`echo "a && b"`) is NOT treated as a command boundary.
|
|
25
|
+
*
|
|
26
|
+
* 2. **A catastrophic blocklist** — {@link catastrophicReason} matches a
|
|
27
|
+
* single parsed sub-command against a conservative set of always-deny
|
|
28
|
+
* patterns (root-targeting `rm -rf`, fork bombs, `curl … | sh` of remote
|
|
29
|
+
* scripts, `dd of=/dev/…`, `mkfs`, `chmod -R 777 /`, redirect-to-raw-disk).
|
|
30
|
+
* It is deliberately narrow so normal dev commands (`rm -rf node_modules`,
|
|
31
|
+
* `rm -rf ./dist`, `curl … -o file`) are NOT blocked. This is
|
|
32
|
+
* defense-in-depth, NOT a hard security boundary — the OS sandbox and the
|
|
33
|
+
* permission gate are the real boundary.
|
|
34
|
+
*
|
|
35
|
+
* Everything here is pure, synchronous, and side-effect free so the classifier
|
|
36
|
+
* and the blocklist are exhaustively unit-testable, and the permission engine
|
|
37
|
+
* can fold them into its decision without any I/O.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One simple command extracted from a (possibly compound) shell string.
|
|
42
|
+
*
|
|
43
|
+
* `rm -rf /tmp` parses to `{ name: "rm", args: ["-rf", "/tmp"], elevated: false,
|
|
44
|
+
* raw: "rm -rf /tmp" }`. The `name` is the base binary AFTER benign wrappers are
|
|
45
|
+
* stripped; `args` are its tokens (quotes removed); `raw` is the original
|
|
46
|
+
* sub-command slice (wrappers included) so a caller can echo exactly what the
|
|
47
|
+
* model asked for; `elevated` is true when a `sudo`/`doas` wrapper was stripped.
|
|
48
|
+
*/
|
|
49
|
+
export interface ParsedCommand {
|
|
50
|
+
/** The base binary, lower-cased for stable comparison (e.g. `"rm"`). */
|
|
51
|
+
readonly name: string
|
|
52
|
+
/** The argument tokens after the binary, with surrounding quotes removed. */
|
|
53
|
+
readonly args: readonly string[]
|
|
54
|
+
/** Whether a `sudo`/`doas` elevation wrapper preceded this command. */
|
|
55
|
+
readonly elevated: boolean
|
|
56
|
+
/** The original sub-command slice (wrappers included), trimmed. */
|
|
57
|
+
readonly raw: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const OPERATORS = ["&&", "||", ";", "|", "&", "\n"]
|
|
61
|
+
|
|
62
|
+
function splitTopLevel(command: string): string[] {
|
|
63
|
+
const slices: string[] = []
|
|
64
|
+
let current = ""
|
|
65
|
+
let quote: string | undefined
|
|
66
|
+
let i = 0
|
|
67
|
+
const flush = () => {
|
|
68
|
+
const trimmed = current.trim()
|
|
69
|
+
if (trimmed.length > 0) slices.push(trimmed)
|
|
70
|
+
current = ""
|
|
71
|
+
}
|
|
72
|
+
while (i < command.length) {
|
|
73
|
+
const ch = command[i]
|
|
74
|
+
if (ch === "\\" && quote !== "'") {
|
|
75
|
+
current += ch
|
|
76
|
+
if (i + 1 < command.length) {
|
|
77
|
+
current += command[i + 1]
|
|
78
|
+
i += 2
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
i += 1
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (quote !== void 0) {
|
|
85
|
+
current += ch
|
|
86
|
+
if (ch === quote) quote = void 0
|
|
87
|
+
i += 1
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (ch === '"' || ch === "'") {
|
|
91
|
+
quote = ch
|
|
92
|
+
current += ch
|
|
93
|
+
i += 1
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
let matched: string | undefined
|
|
97
|
+
for (const op of OPERATORS) {
|
|
98
|
+
if (command.startsWith(op, i)) {
|
|
99
|
+
matched = op
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (matched !== void 0) {
|
|
104
|
+
flush()
|
|
105
|
+
i += matched.length
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
current += ch
|
|
109
|
+
i += 1
|
|
110
|
+
}
|
|
111
|
+
flush()
|
|
112
|
+
return slices
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function findParenClose(s: string, start: number): number {
|
|
116
|
+
let depth = 1
|
|
117
|
+
let quote: string | undefined
|
|
118
|
+
let i = start
|
|
119
|
+
while (i < s.length) {
|
|
120
|
+
const ch = s[i]
|
|
121
|
+
if (ch === "\\" && quote !== "'") {
|
|
122
|
+
i += 2
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (quote !== void 0) {
|
|
126
|
+
if (ch === quote) quote = void 0
|
|
127
|
+
i += 1
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (ch === '"' || ch === "'") {
|
|
131
|
+
quote = ch
|
|
132
|
+
i += 1
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
if (ch === "(" || (ch === "$" && s[i + 1] === "(")) depth += 1
|
|
136
|
+
else if (ch === ")") {
|
|
137
|
+
depth -= 1
|
|
138
|
+
if (depth === 0) return i
|
|
139
|
+
}
|
|
140
|
+
i += 1
|
|
141
|
+
}
|
|
142
|
+
return s.length
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function findBacktickClose(s: string, start: number): number {
|
|
146
|
+
let i = start
|
|
147
|
+
while (i < s.length) {
|
|
148
|
+
if (s[i] === "\\") {
|
|
149
|
+
i += 2
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (s[i] === "`") return i
|
|
153
|
+
i += 1
|
|
154
|
+
}
|
|
155
|
+
return s.length
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function extractSubshells(slice: string): { outer: string; inner: string[] } {
|
|
159
|
+
const inner: string[] = []
|
|
160
|
+
let outer = ""
|
|
161
|
+
let quote: string | undefined
|
|
162
|
+
let i = 0
|
|
163
|
+
while (i < slice.length) {
|
|
164
|
+
const ch = slice[i]
|
|
165
|
+
if (ch === "\\" && quote !== "'") {
|
|
166
|
+
outer += ch
|
|
167
|
+
if (i + 1 < slice.length) {
|
|
168
|
+
outer += slice[i + 1]
|
|
169
|
+
i += 2
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
i += 1
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
if (quote !== void 0) {
|
|
176
|
+
if (quote === '"' && ch === "`") {
|
|
177
|
+
const close = findBacktickClose(slice, i + 1)
|
|
178
|
+
inner.push(slice.slice(i + 1, close))
|
|
179
|
+
i = close + 1
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
outer += ch
|
|
183
|
+
if (ch === quote) quote = void 0
|
|
184
|
+
i += 1
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
if (ch === '"' || ch === "'") {
|
|
188
|
+
quote = ch
|
|
189
|
+
outer += ch
|
|
190
|
+
i += 1
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
if (ch === "(" || (ch === "$" && slice[i + 1] === "(")) {
|
|
194
|
+
const start = ch === "$" ? i + 2 : i + 1
|
|
195
|
+
const close = findParenClose(slice, start)
|
|
196
|
+
inner.push(slice.slice(start, close))
|
|
197
|
+
i = close + 1
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (ch === "`") {
|
|
201
|
+
const close = findBacktickClose(slice, i + 1)
|
|
202
|
+
inner.push(slice.slice(i + 1, close))
|
|
203
|
+
i = close + 1
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
outer += ch
|
|
207
|
+
i += 1
|
|
208
|
+
}
|
|
209
|
+
return { outer: outer.trim(), inner }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function tokenizeArgv(slice: string): string[] {
|
|
213
|
+
const tokens: string[] = []
|
|
214
|
+
let current = ""
|
|
215
|
+
let started = false
|
|
216
|
+
let quote: string | undefined
|
|
217
|
+
let i = 0
|
|
218
|
+
const flush = () => {
|
|
219
|
+
if (started) tokens.push(current)
|
|
220
|
+
current = ""
|
|
221
|
+
started = false
|
|
222
|
+
}
|
|
223
|
+
while (i < slice.length) {
|
|
224
|
+
const ch = slice[i]
|
|
225
|
+
if (ch === "\\" && quote !== "'") {
|
|
226
|
+
if (i + 1 < slice.length) {
|
|
227
|
+
current += slice[i + 1]
|
|
228
|
+
started = true
|
|
229
|
+
i += 2
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
i += 1
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
if (quote !== void 0) {
|
|
236
|
+
if (ch === quote) {
|
|
237
|
+
quote = void 0
|
|
238
|
+
} else {
|
|
239
|
+
current += ch
|
|
240
|
+
}
|
|
241
|
+
started = true
|
|
242
|
+
i += 1
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
if (ch === '"' || ch === "'") {
|
|
246
|
+
quote = ch
|
|
247
|
+
started = true
|
|
248
|
+
i += 1
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
if (ch === " " || ch === "\t") {
|
|
252
|
+
flush()
|
|
253
|
+
i += 1
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
current += ch
|
|
257
|
+
started = true
|
|
258
|
+
i += 1
|
|
259
|
+
}
|
|
260
|
+
flush()
|
|
261
|
+
return tokens
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const WRAPPERS = new Set([
|
|
265
|
+
"env",
|
|
266
|
+
"nice",
|
|
267
|
+
"nohup",
|
|
268
|
+
"timeout",
|
|
269
|
+
"xargs",
|
|
270
|
+
"command",
|
|
271
|
+
"builtin",
|
|
272
|
+
"time",
|
|
273
|
+
"stdbuf",
|
|
274
|
+
"setsid",
|
|
275
|
+
"ionice",
|
|
276
|
+
])
|
|
277
|
+
|
|
278
|
+
const ELEVATION = new Set(["sudo", "doas"])
|
|
279
|
+
|
|
280
|
+
const FLAG_TAKES_VALUE = new Set([
|
|
281
|
+
"-n",
|
|
282
|
+
"-u",
|
|
283
|
+
"-g",
|
|
284
|
+
"-s",
|
|
285
|
+
"--kill-after",
|
|
286
|
+
"--signal",
|
|
287
|
+
"--user",
|
|
288
|
+
])
|
|
289
|
+
|
|
290
|
+
function consumeWrapperOptions(rest: string[], wrapper: string): void {
|
|
291
|
+
for (;;) {
|
|
292
|
+
const head = rest[0]
|
|
293
|
+
if (head === void 0) return
|
|
294
|
+
if (head === "--") {
|
|
295
|
+
rest.shift()
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
if (head.startsWith("-")) {
|
|
299
|
+
rest.shift()
|
|
300
|
+
if (!head.includes("=") && FLAG_TAKES_VALUE.has(head)) {
|
|
301
|
+
const value = rest[0]
|
|
302
|
+
if (value !== void 0 && !value.startsWith("-")) rest.shift()
|
|
303
|
+
}
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
if ((wrapper === "timeout" || wrapper === "nice") && /^\d+[a-z]?$/i.test(head)) {
|
|
307
|
+
rest.shift()
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function stripWrappers(argv: string[]): { rest: string[]; elevated: boolean } {
|
|
315
|
+
const rest = [...argv]
|
|
316
|
+
let elevated = false
|
|
317
|
+
for (;;) {
|
|
318
|
+
const head = rest[0]
|
|
319
|
+
if (head === void 0) break
|
|
320
|
+
const token = head.toLowerCase()
|
|
321
|
+
if (ELEVATION.has(token)) {
|
|
322
|
+
rest.shift()
|
|
323
|
+
elevated = true
|
|
324
|
+
consumeWrapperOptions(rest, token)
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
if (WRAPPERS.has(token)) {
|
|
328
|
+
rest.shift()
|
|
329
|
+
consumeWrapperOptions(rest, token)
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
332
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(head)) {
|
|
333
|
+
rest.shift()
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
break
|
|
337
|
+
}
|
|
338
|
+
return { rest, elevated }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Strip wrappers/elevation prefixes from a tokenized argv (read-only variant). */
|
|
342
|
+
export function stripSafeWrappers(argv: readonly string[]): { rest: string[]; elevated: boolean } {
|
|
343
|
+
return stripWrappers([...argv])
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Strip every leading `NAME=value` environment assignment from a token list,
|
|
348
|
+
* leaving the actual command name (and any non-env-prefix tokens) untouched.
|
|
349
|
+
*/
|
|
350
|
+
export function stripAllLeadingEnvVars(argv: readonly string[]): string[] {
|
|
351
|
+
const rest = [...argv]
|
|
352
|
+
while (rest.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[0])) {
|
|
353
|
+
rest.shift()
|
|
354
|
+
}
|
|
355
|
+
return rest
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function parseInto(command: string, out: ParsedCommand[]): void {
|
|
359
|
+
for (const slice of splitTopLevel(command)) {
|
|
360
|
+
const { outer, inner } = extractSubshells(slice)
|
|
361
|
+
if (outer.length > 0) {
|
|
362
|
+
const argv = tokenizeArgv(outer)
|
|
363
|
+
if (argv.length > 0) {
|
|
364
|
+
const { rest, elevated } = stripWrappers(argv)
|
|
365
|
+
const name = rest[0]
|
|
366
|
+
if (name !== void 0 && name.length > 0) {
|
|
367
|
+
out.push({
|
|
368
|
+
name: name.toLowerCase(),
|
|
369
|
+
args: rest.slice(1),
|
|
370
|
+
elevated,
|
|
371
|
+
raw: slice.trim(),
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
for (const body of inner) {
|
|
377
|
+
if (body.trim().length > 0) parseInto(body, out)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Parse a (possibly compound) shell command string into the flat list of every
|
|
384
|
+
* simple command it runs — across operators, subshells, and command
|
|
385
|
+
* substitutions, with benign wrappers stripped.
|
|
386
|
+
*
|
|
387
|
+
* The result is order-preserving and exhaustive enough that the permission
|
|
388
|
+
* engine can evaluate a `Bash(...)` rule (and the catastrophic blocklist)
|
|
389
|
+
* against EACH sub-command independently: a deny on any sub-command denies the
|
|
390
|
+
* whole call, and an allow must cover every sub-command to auto-allow.
|
|
391
|
+
*
|
|
392
|
+
* @returns one {@link ParsedCommand} per simple command; an empty array for an
|
|
393
|
+
* empty/whitespace command string.
|
|
394
|
+
*/
|
|
395
|
+
export function parseBashCommand(command: string): ParsedCommand[] {
|
|
396
|
+
if (typeof command !== "string" || command.trim().length === 0) return []
|
|
397
|
+
const out: ParsedCommand[] = []
|
|
398
|
+
parseInto(command, out)
|
|
399
|
+
return out
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function catastrophicMessage(reason: string): string {
|
|
403
|
+
return `This command is blocked: ${reason}. It is on the built-in catastrophic-command blocklist and cannot be run regardless of permission rules.`
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function normPath(token: string): string {
|
|
407
|
+
const collapsed = token.replace(/\/+/g, "/")
|
|
408
|
+
if (collapsed.length > 1 && collapsed.endsWith("/")) return collapsed.slice(0, -1)
|
|
409
|
+
return collapsed
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const ROOT_TARGETS = new Set([
|
|
413
|
+
"/",
|
|
414
|
+
"/*",
|
|
415
|
+
"~",
|
|
416
|
+
"~/",
|
|
417
|
+
"$home",
|
|
418
|
+
"$home/",
|
|
419
|
+
"/bin",
|
|
420
|
+
"/boot",
|
|
421
|
+
"/dev",
|
|
422
|
+
"/etc",
|
|
423
|
+
"/home",
|
|
424
|
+
"/lib",
|
|
425
|
+
"/opt",
|
|
426
|
+
"/proc",
|
|
427
|
+
"/root",
|
|
428
|
+
"/sbin",
|
|
429
|
+
"/srv",
|
|
430
|
+
"/sys",
|
|
431
|
+
"/usr",
|
|
432
|
+
"/var",
|
|
433
|
+
])
|
|
434
|
+
|
|
435
|
+
function isRootRm(cmd: ParsedCommand): boolean {
|
|
436
|
+
if (cmd.name !== "rm") return false
|
|
437
|
+
let recursive = false
|
|
438
|
+
const targets: string[] = []
|
|
439
|
+
for (const arg of cmd.args) {
|
|
440
|
+
if (arg.startsWith("--")) {
|
|
441
|
+
if (arg === "--recursive") recursive = true
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
444
|
+
if (arg.startsWith("-") && arg.length > 1) {
|
|
445
|
+
if (arg.includes("r") || arg.includes("R")) recursive = true
|
|
446
|
+
continue
|
|
447
|
+
}
|
|
448
|
+
targets.push(normPath(arg.toLowerCase()))
|
|
449
|
+
}
|
|
450
|
+
if (!recursive) return false
|
|
451
|
+
return targets.some(
|
|
452
|
+
(t) => ROOT_TARGETS.has(t) || t === "/.." || /^\/[a-z]+\/\*$/.test(t),
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const SHELL_SINKS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish", "csh", "tcsh"])
|
|
457
|
+
const FETCHERS = new Set(["curl", "wget", "fetch"])
|
|
458
|
+
|
|
459
|
+
function isRemoteScriptPipedToShell(cmd: ParsedCommand): boolean {
|
|
460
|
+
if (!SHELL_SINKS.has(cmd.name)) return false
|
|
461
|
+
return /\b(curl|wget|fetch)\b/i.test(cmd.raw) && /https?:\/\//i.test(cmd.raw)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function isRootChmod(cmd: ParsedCommand): boolean {
|
|
465
|
+
if (cmd.name !== "chmod") return false
|
|
466
|
+
let recursive = false
|
|
467
|
+
let world = false
|
|
468
|
+
const targets: string[] = []
|
|
469
|
+
for (const arg of cmd.args) {
|
|
470
|
+
if (arg === "-R" || arg === "--recursive" || /^-[a-z]*R/i.test(arg)) recursive = true
|
|
471
|
+
else if (/^[0-7]?777$/.test(arg) || arg.includes("o+w") || arg.includes("a+w")) world = true
|
|
472
|
+
else if (!arg.startsWith("-")) targets.push(normPath(arg.toLowerCase()))
|
|
473
|
+
}
|
|
474
|
+
return recursive && world && targets.some((t) => ROOT_TARGETS.has(t))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
interface CatastrophicRule {
|
|
478
|
+
reason: string
|
|
479
|
+
test: (cmd: ParsedCommand) => boolean
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const FORK_BOMB = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
|
|
483
|
+
|
|
484
|
+
const CATASTROPHIC_RULES: CatastrophicRule[] = [
|
|
485
|
+
{
|
|
486
|
+
reason: "recursive force-delete of a root or system directory (rm -rf /)",
|
|
487
|
+
test: isRootRm,
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
reason: "piping a downloaded remote script straight into a shell (curl | sh)",
|
|
491
|
+
test: isRemoteScriptPipedToShell,
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
reason: "writing raw bytes to a block device (dd of=/dev/...)",
|
|
495
|
+
test: (cmd) =>
|
|
496
|
+
cmd.name === "dd" &&
|
|
497
|
+
cmd.args.some((a) => /^of=\/dev\/(sd|nvme|disk|hd|mmcblk|vd)/i.test(a)),
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
reason: "formatting a filesystem (mkfs)",
|
|
501
|
+
test: (cmd) => /^mkfs(\.|$)/.test(cmd.name),
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
reason: "redirecting output over a raw disk device (> /dev/sd...)",
|
|
505
|
+
test: (cmd) => /[>]\s*\/dev\/(sd|nvme|disk|hd|mmcblk|vd)/i.test(cmd.raw),
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
reason: "world-writable recursive chmod of a root or system directory (chmod -R 777 /)",
|
|
509
|
+
test: isRootChmod,
|
|
510
|
+
},
|
|
511
|
+
]
|
|
512
|
+
|
|
513
|
+
function scanForRemotePipeToShell(cmds: ParsedCommand[]): string | undefined {
|
|
514
|
+
const hasRemoteFetch = cmds.some(
|
|
515
|
+
(c) => FETCHERS.has(c.name) && c.args.some((a) => /^https?:\/\//i.test(a)),
|
|
516
|
+
)
|
|
517
|
+
if (!hasRemoteFetch) return void 0
|
|
518
|
+
const hasShellSink = cmds.some(
|
|
519
|
+
(c) =>
|
|
520
|
+
SHELL_SINKS.has(c.name) &&
|
|
521
|
+
!c.args.some((a) => !a.startsWith("-") && /\.(sh|bash)$/i.test(a)),
|
|
522
|
+
)
|
|
523
|
+
if (hasShellSink) {
|
|
524
|
+
return "piping a downloaded remote script straight into a shell (curl | sh)"
|
|
525
|
+
}
|
|
526
|
+
return void 0
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Classify a SINGLE parsed sub-command against the catastrophic blocklist.
|
|
531
|
+
*
|
|
532
|
+
* @returns the byte-stable deny reason when the command is catastrophic, else
|
|
533
|
+
* `undefined`. Use {@link evaluateCatastrophic} for a whole compound call (it
|
|
534
|
+
* also runs the cross-sub-command `curl | sh` correlation).
|
|
535
|
+
*/
|
|
536
|
+
export function catastrophicReason(cmd: ParsedCommand): string | undefined {
|
|
537
|
+
for (const rule of CATASTROPHIC_RULES) {
|
|
538
|
+
if (rule.test(cmd)) return rule.reason
|
|
539
|
+
}
|
|
540
|
+
return void 0
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Convenience boolean form of {@link catastrophicReason}. */
|
|
544
|
+
export function isCatastrophicCommand(cmd: ParsedCommand): boolean {
|
|
545
|
+
return catastrophicReason(cmd) !== void 0
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Evaluate the catastrophic blocklist over a WHOLE bash command string.
|
|
550
|
+
*
|
|
551
|
+
* Parses the (possibly compound) command, runs the per-sub-command blocklist on
|
|
552
|
+
* each leaf, AND runs the cross-sub-command `curl | sh` correlation. Returns a
|
|
553
|
+
* byte-stable deny message when ANY check fires, else `undefined`.
|
|
554
|
+
*
|
|
555
|
+
* This is what the permission engine calls for the bash tool: a catastrophic
|
|
556
|
+
* command is denied REGARDLESS of allow/ask/deny rules or mode (it is not even
|
|
557
|
+
* `bypass`-able, mirroring the original always-block guard).
|
|
558
|
+
*/
|
|
559
|
+
export function evaluateCatastrophic(command: string): string | undefined {
|
|
560
|
+
if (FORK_BOMB.test(command)) {
|
|
561
|
+
return catastrophicMessage("fork bomb")
|
|
562
|
+
}
|
|
563
|
+
const cmds = parseBashCommand(command)
|
|
564
|
+
if (cmds.length === 0) return void 0
|
|
565
|
+
for (const cmd of cmds) {
|
|
566
|
+
const reason = catastrophicReason(cmd)
|
|
567
|
+
if (reason !== void 0) return catastrophicMessage(reason)
|
|
568
|
+
}
|
|
569
|
+
const piped = scanForRemotePipeToShell(cmds)
|
|
570
|
+
if (piped !== void 0) return catastrophicMessage(piped)
|
|
571
|
+
return void 0
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* For the permission engine: emit the per-sub-command subject strings (base
|
|
576
|
+
* command + args, joined) used to test a `Bash(...)` rule specifier against an
|
|
577
|
+
* individual call.
|
|
578
|
+
*/
|
|
579
|
+
export function commandSubcommandSubjects(command: string): string[] {
|
|
580
|
+
return bashSubcommandSubjects(command)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Render the rule-matching SUBJECT for each sub-command of a bash call: the base
|
|
585
|
+
* command joined with its argument tokens (wrappers stripped). The permission
|
|
586
|
+
* engine matches an induscode `Bash(...)` specifier against EACH of these — an
|
|
587
|
+
* allow rule must cover them all to auto-allow, a deny on any one denies the
|
|
588
|
+
* whole call.
|
|
589
|
+
*
|
|
590
|
+
* For `npm test && rm important.txt` this yields `["npm test", "rm
|
|
591
|
+
* important.txt"]`, so `Bash(npm test)` allows the first but not the second.
|
|
592
|
+
* Returns the original command (single element) when nothing parses, so a tool
|
|
593
|
+
* call always has at least one subject to match against.
|
|
594
|
+
*/
|
|
595
|
+
export function bashSubcommandSubjects(command: string): string[] {
|
|
596
|
+
const cmds = parseBashCommand(command)
|
|
597
|
+
if (cmds.length === 0) return [command]
|
|
598
|
+
return cmds.map((c) => [c.name, ...c.args].join(" ").trim())
|
|
599
|
+
}
|