indusagi-coding-agent 0.2.8 → 0.2.10

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.
Files changed (222) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/entry.js +4 -4
  3. package/dist/index.js +4 -4
  4. package/package.json +3 -2
  5. package/src/_decls/entry.ts +18 -0
  6. package/src/_decls/guardrails.ts +35 -0
  7. package/src/_decls/index.ts +26 -0
  8. package/src/addons/contract.ts +236 -0
  9. package/src/addons/dispatch/event-dispatcher.ts +164 -0
  10. package/src/addons/dispatch/index.ts +25 -0
  11. package/src/addons/dispatch/tool-interceptor.ts +208 -0
  12. package/src/addons/host.ts +225 -0
  13. package/src/addons/index.ts +112 -0
  14. package/src/addons/manifest.ts +158 -0
  15. package/src/addons/sandbox.ts +170 -0
  16. package/src/addons/surface.ts +78 -0
  17. package/src/boot/auth-vault.ts +195 -0
  18. package/src/boot/boot.ts +138 -0
  19. package/src/boot/contract.ts +238 -0
  20. package/src/boot/heap.ts +59 -0
  21. package/src/boot/index.ts +28 -0
  22. package/src/boot/invocation.ts +93 -0
  23. package/src/boot/runners/addon-wiring.ts +153 -0
  24. package/src/boot/runners/checkpoint.ts +169 -0
  25. package/src/boot/runners/delegate-runner.ts +294 -0
  26. package/src/boot/runners/index.ts +13 -0
  27. package/src/boot/runners/link-runner.ts +45 -0
  28. package/src/boot/runners/memdir.ts +168 -0
  29. package/src/boot/runners/oneshot-runner.ts +58 -0
  30. package/src/boot/runners/read-state.ts +90 -0
  31. package/src/boot/runners/registry.ts +42 -0
  32. package/src/boot/runners/repl-runner.ts +143 -0
  33. package/src/boot/runners/server-mode.ts +121 -0
  34. package/src/boot/runners/session.ts +641 -0
  35. package/src/boot/server-token.ts +148 -0
  36. package/src/boot/stages.ts +167 -0
  37. package/src/boot/upgrade/apply.ts +94 -0
  38. package/src/boot/upgrade/index.ts +13 -0
  39. package/src/boot/upgrade/upgrades.ts +289 -0
  40. package/src/briefing/compose.ts +150 -0
  41. package/src/briefing/context-docs.ts +19 -0
  42. package/src/briefing/contract.ts +717 -0
  43. package/src/briefing/index.ts +31 -0
  44. package/src/briefing/macros.ts +97 -0
  45. package/src/briefing/skills.ts +47 -0
  46. package/src/capability-deck/bridge-ledger/index.ts +27 -0
  47. package/src/capability-deck/bridge-ledger/key.ts +67 -0
  48. package/src/capability-deck/bridge-ledger/ledger.ts +131 -0
  49. package/src/capability-deck/bridge-ledger/network.ts +117 -0
  50. package/src/capability-deck/builtin-bridge.ts +312 -0
  51. package/src/capability-deck/cards/bg-process-card.ts +335 -0
  52. package/src/capability-deck/cards/index.ts +115 -0
  53. package/src/capability-deck/cards/memory-card.ts +146 -0
  54. package/src/capability-deck/cards/plan-file.ts +97 -0
  55. package/src/capability-deck/cards/plan-tools.ts +185 -0
  56. package/src/capability-deck/cards/saas-card.ts +183 -0
  57. package/src/capability-deck/cards/task-card.ts +207 -0
  58. package/src/capability-deck/cards/todo-card.ts +168 -0
  59. package/src/capability-deck/cards/workflow-card.ts +247 -0
  60. package/src/capability-deck/contract.ts +388 -0
  61. package/src/capability-deck/index.ts +48 -0
  62. package/src/capability-deck/manifest.ts +109 -0
  63. package/src/capability-deck/provision.ts +169 -0
  64. package/src/channels/contract.ts +191 -0
  65. package/src/channels/framer.ts +50 -0
  66. package/src/channels/index.ts +101 -0
  67. package/src/channels/link/dialog.ts +129 -0
  68. package/src/channels/link/driver.ts +190 -0
  69. package/src/channels/link/index.ts +34 -0
  70. package/src/channels/link/server.ts +134 -0
  71. package/src/channels/oneshot.ts +90 -0
  72. package/src/channels/ops.ts +65 -0
  73. package/src/channels/session-ops.ts +81 -0
  74. package/src/conductor/bash-guard.ts +599 -0
  75. package/src/conductor/catalog/catalog.ts +116 -0
  76. package/src/conductor/catalog/index.ts +8 -0
  77. package/src/conductor/catalog/matcher.ts +134 -0
  78. package/src/conductor/conductor.ts +234 -0
  79. package/src/conductor/contract.ts +842 -0
  80. package/src/conductor/diagnostics.ts +227 -0
  81. package/src/conductor/index.ts +33 -0
  82. package/src/conductor/permissions.ts +588 -0
  83. package/src/conductor/quota-error.ts +49 -0
  84. package/src/conductor/signal-hub/hub.ts +46 -0
  85. package/src/conductor/signal-hub/index.ts +2 -0
  86. package/src/conductor/signal-hub/translate.test.ts +81 -0
  87. package/src/conductor/signal-hub/translate.ts +74 -0
  88. package/src/conductor/skill-parse/index.ts +2 -0
  89. package/src/conductor/skill-parse/parse.ts +108 -0
  90. package/src/conductor/transcript-store/index.ts +22 -0
  91. package/src/conductor/transcript-store/serialize.ts +116 -0
  92. package/src/conductor/transcript-store/store.ts +205 -0
  93. package/src/console/auth-status.ts +56 -0
  94. package/src/console/components/AgentsView.ts +165 -0
  95. package/src/console/components/BackgroundAgents.ts +155 -0
  96. package/src/console/components/Banner.ts +334 -0
  97. package/src/console/components/Composer.ts +94 -0
  98. package/src/console/components/StatusBar.ts +49 -0
  99. package/src/console/components/TerminalConsole.ts +1090 -0
  100. package/src/console/components/WorkingIndicator.ts +98 -0
  101. package/src/console/components/banner-sweep.ts +24 -0
  102. package/src/console/components/welcome.ts +74 -0
  103. package/src/console/contract.ts +630 -0
  104. package/src/console/index.ts +34 -0
  105. package/src/console/input/complete.ts +127 -0
  106. package/src/console/input/dir-reader.ts +34 -0
  107. package/src/console/input/index.ts +23 -0
  108. package/src/console/input/keymap.ts +159 -0
  109. package/src/console/input/paste.ts +104 -0
  110. package/src/console/mount.ts +56 -0
  111. package/src/console/overlays/approval-queue.ts +57 -0
  112. package/src/console/overlays/approval.ts +130 -0
  113. package/src/console/overlays/auth.ts +342 -0
  114. package/src/console/overlays/boards.ts +308 -0
  115. package/src/console/overlays/host.ts +36 -0
  116. package/src/console/overlays/index.ts +26 -0
  117. package/src/console/overlays/pickers.ts +258 -0
  118. package/src/console/overlays/sessions.ts +190 -0
  119. package/src/console/reducer.ts +182 -0
  120. package/src/console/slash/builtins.ts +81 -0
  121. package/src/console/slash/commands/dynamic.ts +83 -0
  122. package/src/console/slash/commands/integrations.ts +695 -0
  123. package/src/console/slash/commands/shared.ts +75 -0
  124. package/src/console/slash/commands/transcript.ts +263 -0
  125. package/src/console/slash/commands/workbench.ts +246 -0
  126. package/src/console/slash/index.ts +15 -0
  127. package/src/console/slash/registry.ts +70 -0
  128. package/src/console/slash/resolve.ts +63 -0
  129. package/src/console/startup.ts +209 -0
  130. package/src/console/theme/adapter.ts +45 -0
  131. package/src/console/theme/index.ts +7 -0
  132. package/src/console/theme/palette.ts +68 -0
  133. package/src/console/theme/resolve.ts +39 -0
  134. package/src/console/theme/tokens.ts +71 -0
  135. package/src/entry.ts +55 -0
  136. package/src/guardrails.ts +37 -0
  137. package/src/index.ts +18 -0
  138. package/src/insight/channel.ts +88 -0
  139. package/src/insight/contract.ts +185 -0
  140. package/src/insight/index.ts +110 -0
  141. package/src/insight/recorder.ts +213 -0
  142. package/src/insight/redaction.ts +157 -0
  143. package/src/insight/replay.ts +158 -0
  144. package/src/insight/sampling.ts +70 -0
  145. package/src/insight/serialize.ts +50 -0
  146. package/src/insight/sinks/console.ts +64 -0
  147. package/src/insight/sinks/file.ts +40 -0
  148. package/src/insight/sinks/index.ts +24 -0
  149. package/src/insight/sinks/stream.ts +54 -0
  150. package/src/integrations/sarvam/attach.ts +239 -0
  151. package/src/integrations/sarvam/config.ts +156 -0
  152. package/src/integrations/sarvam/index.ts +25 -0
  153. package/src/integrations/sarvam/sarvam.test.ts +60 -0
  154. package/src/integrations/sarvam/types.ts +27 -0
  155. package/src/integrations/zoho/attach.ts +342 -0
  156. package/src/integrations/zoho/config.ts +125 -0
  157. package/src/integrations/zoho/index.ts +27 -0
  158. package/src/integrations/zoho/types.ts +21 -0
  159. package/src/integrations/zoho/zoho.test.ts +50 -0
  160. package/src/kit/clipboard-image.ts +107 -0
  161. package/src/kit/external-editor.ts +48 -0
  162. package/src/kit/image.ts +59 -0
  163. package/src/kit/index.ts +51 -0
  164. package/src/kit/shell.ts +19 -0
  165. package/src/kit/tool-fetch.ts +85 -0
  166. package/src/launch/catalog.ts +148 -0
  167. package/src/launch/contract.ts +187 -0
  168. package/src/launch/credentials.ts +625 -0
  169. package/src/launch/index.ts +98 -0
  170. package/src/launch/invocation/attachments.ts +179 -0
  171. package/src/launch/invocation/flags.ts +196 -0
  172. package/src/launch/invocation/index.ts +25 -0
  173. package/src/launch/invocation/read.ts +260 -0
  174. package/src/launch/invocation/usage.ts +67 -0
  175. package/src/launch/login.ts +324 -0
  176. package/src/launch/oauth.test.ts +18 -0
  177. package/src/launch/oauth.ts +203 -0
  178. package/src/launch/packages.ts +194 -0
  179. package/src/launch/pickers.ts +189 -0
  180. package/src/runtime-bridge/bridges/_drive.ts +96 -0
  181. package/src/runtime-bridge/bridges/builtins.ts +68 -0
  182. package/src/runtime-bridge/bridges/claude-cli.ts +123 -0
  183. package/src/runtime-bridge/bridges/codex-cli.ts +142 -0
  184. package/src/runtime-bridge/bridges/index.ts +33 -0
  185. package/src/runtime-bridge/bridges/indusagi-cli.ts +155 -0
  186. package/src/runtime-bridge/broker.ts +227 -0
  187. package/src/runtime-bridge/contract.ts +122 -0
  188. package/src/runtime-bridge/index.ts +79 -0
  189. package/src/runtime-bridge/sink.ts +180 -0
  190. package/src/sessions/contract.ts +81 -0
  191. package/src/sessions/index.ts +13 -0
  192. package/src/sessions/library.ts +229 -0
  193. package/src/settings/contract.ts +114 -0
  194. package/src/settings/index.ts +32 -0
  195. package/src/settings/manager.ts +117 -0
  196. package/src/transcript-export/index.ts +45 -0
  197. package/src/transcript-export/publish.ts +260 -0
  198. package/src/transcript-export/sgr.ts +315 -0
  199. package/src/transcript-export/template.ts +272 -0
  200. package/src/transcript-export/theme-bridge.ts +150 -0
  201. package/src/window-budget/budget/estimate.ts +135 -0
  202. package/src/window-budget/budget/gate.ts +33 -0
  203. package/src/window-budget/budget/index.ts +16 -0
  204. package/src/window-budget/budget/slice.ts +56 -0
  205. package/src/window-budget/condenser.ts +58 -0
  206. package/src/window-budget/contract.ts +184 -0
  207. package/src/window-budget/index.ts +19 -0
  208. package/src/window-budget/microcompact.ts +95 -0
  209. package/src/window-budget/rehydrate.ts +136 -0
  210. package/src/window-budget/summarize/condense.ts +103 -0
  211. package/src/window-budget/summarize/index.ts +14 -0
  212. package/src/window-budget/summarize/prompt.ts +149 -0
  213. package/src/workflow-engine/agent-runner.ts +181 -0
  214. package/src/workflow-engine/display.ts +224 -0
  215. package/src/workflow-engine/engine.ts +294 -0
  216. package/src/workflow-engine/index.ts +23 -0
  217. package/src/workflow-engine/parse.ts +172 -0
  218. package/src/workflow-engine/structured-output.ts +35 -0
  219. package/src/workspace/brand.ts +29 -0
  220. package/src/workspace/index.ts +18 -0
  221. package/src/workspace/locator.ts +103 -0
  222. 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
+ }