@taliesinb/mash 0.0.0-stage → 0.1.0

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 (203) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -2
  3. package/bin/mash.ts +253 -0
  4. package/dist/bin/mash.d.ts +39 -0
  5. package/dist/bin/mash.d.ts.map +1 -0
  6. package/dist/bin/mash.js +281 -0
  7. package/dist/bin/mash.js.map +1 -0
  8. package/dist/src/analysis/analyze.d.ts +97 -0
  9. package/dist/src/analysis/analyze.d.ts.map +1 -0
  10. package/dist/src/analysis/analyze.js +1406 -0
  11. package/dist/src/analysis/analyze.js.map +1 -0
  12. package/dist/src/analysis/fs.d.ts +39 -0
  13. package/dist/src/analysis/fs.d.ts.map +1 -0
  14. package/dist/src/analysis/fs.js +117 -0
  15. package/dist/src/analysis/fs.js.map +1 -0
  16. package/dist/src/analysis/outputs.d.ts +16 -0
  17. package/dist/src/analysis/outputs.d.ts.map +1 -0
  18. package/dist/src/analysis/outputs.js +277 -0
  19. package/dist/src/analysis/outputs.js.map +1 -0
  20. package/dist/src/analysis/value.d.ts +76 -0
  21. package/dist/src/analysis/value.d.ts.map +1 -0
  22. package/dist/src/analysis/value.js +248 -0
  23. package/dist/src/analysis/value.js.map +1 -0
  24. package/dist/src/behaviors/defs/core.d.ts +15 -0
  25. package/dist/src/behaviors/defs/core.d.ts.map +1 -0
  26. package/dist/src/behaviors/defs/core.js +1084 -0
  27. package/dist/src/behaviors/defs/core.js.map +1 -0
  28. package/dist/src/behaviors/defs/devtools.d.ts +9 -0
  29. package/dist/src/behaviors/defs/devtools.d.ts.map +1 -0
  30. package/dist/src/behaviors/defs/devtools.js +432 -0
  31. package/dist/src/behaviors/defs/devtools.js.map +1 -0
  32. package/dist/src/behaviors/defs/macos.d.ts +12 -0
  33. package/dist/src/behaviors/defs/macos.d.ts.map +1 -0
  34. package/dist/src/behaviors/defs/macos.js +454 -0
  35. package/dist/src/behaviors/defs/macos.js.map +1 -0
  36. package/dist/src/behaviors/defs/tools.d.ts +9 -0
  37. package/dist/src/behaviors/defs/tools.d.ts.map +1 -0
  38. package/dist/src/behaviors/defs/tools.js +639 -0
  39. package/dist/src/behaviors/defs/tools.js.map +1 -0
  40. package/dist/src/behaviors/dsl.d.ts +95 -0
  41. package/dist/src/behaviors/dsl.d.ts.map +1 -0
  42. package/dist/src/behaviors/dsl.js +267 -0
  43. package/dist/src/behaviors/dsl.js.map +1 -0
  44. package/dist/src/behaviors/index.d.ts +12 -0
  45. package/dist/src/behaviors/index.d.ts.map +1 -0
  46. package/dist/src/behaviors/index.js +18 -0
  47. package/dist/src/behaviors/index.js.map +1 -0
  48. package/dist/src/behaviors/registry.d.ts +45 -0
  49. package/dist/src/behaviors/registry.d.ts.map +1 -0
  50. package/dist/src/behaviors/registry.js +266 -0
  51. package/dist/src/behaviors/registry.js.map +1 -0
  52. package/dist/src/behaviors/roles.d.ts +29 -0
  53. package/dist/src/behaviors/roles.d.ts.map +1 -0
  54. package/dist/src/behaviors/roles.js +198 -0
  55. package/dist/src/behaviors/roles.js.map +1 -0
  56. package/dist/src/behaviors/types.d.ts +120 -0
  57. package/dist/src/behaviors/types.d.ts.map +1 -0
  58. package/dist/src/behaviors/types.js +12 -0
  59. package/dist/src/behaviors/types.js.map +1 -0
  60. package/dist/src/index.d.ts +27 -0
  61. package/dist/src/index.d.ts.map +1 -0
  62. package/dist/src/index.js +24 -0
  63. package/dist/src/index.js.map +1 -0
  64. package/dist/src/integrations/deepsea.d.ts +121 -0
  65. package/dist/src/integrations/deepsea.d.ts.map +1 -0
  66. package/dist/src/integrations/deepsea.js +199 -0
  67. package/dist/src/integrations/deepsea.js.map +1 -0
  68. package/dist/src/machine.d.ts +139 -0
  69. package/dist/src/machine.d.ts.map +1 -0
  70. package/dist/src/machine.js +400 -0
  71. package/dist/src/machine.js.map +1 -0
  72. package/dist/src/parser/ast.d.ts +369 -0
  73. package/dist/src/parser/ast.d.ts.map +1 -0
  74. package/dist/src/parser/ast.js +17 -0
  75. package/dist/src/parser/ast.js.map +1 -0
  76. package/dist/src/parser/parser.d.ts +48 -0
  77. package/dist/src/parser/parser.d.ts.map +1 -0
  78. package/dist/src/parser/parser.js +1950 -0
  79. package/dist/src/parser/parser.js.map +1 -0
  80. package/dist/src/policy/effects.d.ts +86 -0
  81. package/dist/src/policy/effects.d.ts.map +1 -0
  82. package/dist/src/policy/effects.js +53 -0
  83. package/dist/src/policy/effects.js.map +1 -0
  84. package/dist/src/policy/policy.d.ts +130 -0
  85. package/dist/src/policy/policy.d.ts.map +1 -0
  86. package/dist/src/policy/policy.js +532 -0
  87. package/dist/src/policy/policy.js.map +1 -0
  88. package/dist/src/printer/doc.d.ts +151 -0
  89. package/dist/src/printer/doc.d.ts.map +1 -0
  90. package/dist/src/printer/doc.js +409 -0
  91. package/dist/src/printer/doc.js.map +1 -0
  92. package/dist/src/printer/highlight.d.ts +53 -0
  93. package/dist/src/printer/highlight.d.ts.map +1 -0
  94. package/dist/src/printer/highlight.js +339 -0
  95. package/dist/src/printer/highlight.js.map +1 -0
  96. package/dist/src/printer/html.d.ts +72 -0
  97. package/dist/src/printer/html.d.ts.map +1 -0
  98. package/dist/src/printer/html.js +423 -0
  99. package/dist/src/printer/html.js.map +1 -0
  100. package/dist/src/printer/print.d.ts +63 -0
  101. package/dist/src/printer/print.d.ts.map +1 -0
  102. package/dist/src/printer/print.js +484 -0
  103. package/dist/src/printer/print.js.map +1 -0
  104. package/dist/src/printer/summary.d.ts +50 -0
  105. package/dist/src/printer/summary.d.ts.map +1 -0
  106. package/dist/src/printer/summary.js +621 -0
  107. package/dist/src/printer/summary.js.map +1 -0
  108. package/dist/src/trace/events.d.ts +183 -0
  109. package/dist/src/trace/events.d.ts.map +1 -0
  110. package/dist/src/trace/events.js +9 -0
  111. package/dist/src/trace/events.js.map +1 -0
  112. package/dist/src/trace/progress.d.ts +58 -0
  113. package/dist/src/trace/progress.d.ts.map +1 -0
  114. package/dist/src/trace/progress.js +83 -0
  115. package/dist/src/trace/progress.js.map +1 -0
  116. package/dist/src/vm/arith.d.ts +29 -0
  117. package/dist/src/vm/arith.d.ts.map +1 -0
  118. package/dist/src/vm/arith.js +258 -0
  119. package/dist/src/vm/arith.js.map +1 -0
  120. package/dist/src/vm/builtins.d.ts +31 -0
  121. package/dist/src/vm/builtins.d.ts.map +1 -0
  122. package/dist/src/vm/builtins.js +1773 -0
  123. package/dist/src/vm/builtins.js.map +1 -0
  124. package/dist/src/vm/emulate.d.ts +75 -0
  125. package/dist/src/vm/emulate.d.ts.map +1 -0
  126. package/dist/src/vm/emulate.js +380 -0
  127. package/dist/src/vm/emulate.js.map +1 -0
  128. package/dist/src/vm/expand.d.ts +68 -0
  129. package/dist/src/vm/expand.d.ts.map +1 -0
  130. package/dist/src/vm/expand.js +744 -0
  131. package/dist/src/vm/expand.js.map +1 -0
  132. package/dist/src/vm/external.d.ts +34 -0
  133. package/dist/src/vm/external.d.ts.map +1 -0
  134. package/dist/src/vm/external.js +228 -0
  135. package/dist/src/vm/external.js.map +1 -0
  136. package/dist/src/vm/fifo.d.ts +66 -0
  137. package/dist/src/vm/fifo.d.ts.map +1 -0
  138. package/dist/src/vm/fifo.js +232 -0
  139. package/dist/src/vm/fifo.js.map +1 -0
  140. package/dist/src/vm/interp.d.ts +209 -0
  141. package/dist/src/vm/interp.d.ts.map +1 -0
  142. package/dist/src/vm/interp.js +1641 -0
  143. package/dist/src/vm/interp.js.map +1 -0
  144. package/dist/src/vm/io.d.ts +180 -0
  145. package/dist/src/vm/io.d.ts.map +1 -0
  146. package/dist/src/vm/io.js +502 -0
  147. package/dist/src/vm/io.js.map +1 -0
  148. package/dist/src/vm/pattern.d.ts +46 -0
  149. package/dist/src/vm/pattern.d.ts.map +1 -0
  150. package/dist/src/vm/pattern.js +498 -0
  151. package/dist/src/vm/pattern.js.map +1 -0
  152. package/dist/src/vm/proc.d.ts +94 -0
  153. package/dist/src/vm/proc.d.ts.map +1 -0
  154. package/dist/src/vm/proc.js +130 -0
  155. package/dist/src/vm/proc.js.map +1 -0
  156. package/dist/src/vm/state.d.ts +104 -0
  157. package/dist/src/vm/state.d.ts.map +1 -0
  158. package/dist/src/vm/state.js +233 -0
  159. package/dist/src/vm/state.js.map +1 -0
  160. package/dist/src/vm/test.d.ts +11 -0
  161. package/dist/src/vm/test.d.ts.map +1 -0
  162. package/dist/src/vm/test.js +277 -0
  163. package/dist/src/vm/test.js.map +1 -0
  164. package/package.json +63 -4
  165. package/src/analysis/analyze.ts +1395 -0
  166. package/src/analysis/fs.ts +121 -0
  167. package/src/analysis/outputs.ts +242 -0
  168. package/src/analysis/value.ts +264 -0
  169. package/src/behaviors/defs/core.ts +970 -0
  170. package/src/behaviors/defs/devtools.ts +400 -0
  171. package/src/behaviors/defs/macos.ts +426 -0
  172. package/src/behaviors/defs/tools.ts +596 -0
  173. package/src/behaviors/dsl.ts +293 -0
  174. package/src/behaviors/index.ts +19 -0
  175. package/src/behaviors/registry.ts +261 -0
  176. package/src/behaviors/roles.ts +187 -0
  177. package/src/behaviors/types.ts +127 -0
  178. package/src/index.ts +27 -0
  179. package/src/integrations/deepsea.ts +259 -0
  180. package/src/machine.ts +483 -0
  181. package/src/parser/ast.ts +472 -0
  182. package/src/parser/parser.ts +1826 -0
  183. package/src/policy/effects.ts +102 -0
  184. package/src/policy/policy.ts +578 -0
  185. package/src/printer/doc.ts +454 -0
  186. package/src/printer/highlight.ts +283 -0
  187. package/src/printer/html.ts +441 -0
  188. package/src/printer/print.ts +528 -0
  189. package/src/printer/summary.ts +601 -0
  190. package/src/trace/events.ts +102 -0
  191. package/src/trace/progress.ts +119 -0
  192. package/src/vm/arith.ts +236 -0
  193. package/src/vm/builtins.ts +1590 -0
  194. package/src/vm/emulate.ts +337 -0
  195. package/src/vm/expand.ts +721 -0
  196. package/src/vm/external.ts +265 -0
  197. package/src/vm/fifo.ts +226 -0
  198. package/src/vm/interp.ts +1623 -0
  199. package/src/vm/io.ts +570 -0
  200. package/src/vm/pattern.ts +477 -0
  201. package/src/vm/proc.ts +147 -0
  202. package/src/vm/state.ts +267 -0
  203. package/src/vm/test.ts +253 -0
package/src/machine.ts ADDED
@@ -0,0 +1,483 @@
1
+ /**
2
+ * The public entry point: a {@link Machine} runs bash snippets under a policy,
3
+ * with behavior definitions, hooks and a trace event stream.
4
+ *
5
+ * ```ts
6
+ * const m = new Machine({ cwd, policy: { mode: 'workspace-write', workspaceRoot: cwd } })
7
+ * m.on((e) => console.log(e.type))
8
+ * const r = await m.run('ls | wc -l')
9
+ * ```
10
+ */
11
+
12
+ import * as os from 'node:os'
13
+ import * as path from 'node:path'
14
+ import type * as A from './parser/ast.ts'
15
+ import { parse, lineColumn } from './parser/parser.ts'
16
+ import { known, type AValue } from './analysis/value.ts'
17
+ import { BehaviorRegistry, basename } from './behaviors/registry.ts'
18
+ import { defaultBehaviors } from './behaviors/index.ts'
19
+ import type { BehaviorResult, Invocation, ScriptInfo } from './behaviors/types.ts'
20
+ import type { Effect } from './policy/effects.ts'
21
+ import { checkCommandName, checkEffects, checkExecEffects, defaultPolicy, type Policy, type PolicyFinding } from './policy/policy.ts'
22
+ import { asPossible, viaSuffix, withVia } from './policy/effects.ts'
23
+ import type { CommandContext, HookDecision, Hooks, TraceEvent, TraceListener } from './trace/events.ts'
24
+ import { defaultSpawn, type SpawnFn } from './vm/external.ts'
25
+ import { defaultFdPassing, type FdPassing } from './vm/fifo.ts'
26
+ import { Interpreter, type Assessment, type ExecIntent, type Runtime } from './vm/interp.ts'
27
+ import { Capture, FdTable, HostStream, type Channel, type ChannelKind, type DataListener } from './vm/io.ts'
28
+ import { AbortRun, Proc, type Job } from './vm/proc.ts'
29
+ import { initShellVars, newVar, ShellState } from './vm/state.ts'
30
+ import { analyzeScript, type AnalysisReport, type AnalyzeOptions } from './analysis/analyze.ts'
31
+
32
+ export interface MachineOptions {
33
+ /** Initial working directory (default: process.cwd()). */
34
+ cwd?: string
35
+ /** Environment (default: process.env). */
36
+ env?: Record<string, string | undefined>
37
+ /** Sandbox policy (default: workspace-write rooted at cwd). */
38
+ policy?: Policy
39
+ behaviors?: BehaviorRegistry
40
+ hooks?: Hooks
41
+ /** Host stdin (default: empty). */
42
+ stdin?: AsyncIterable<Uint8Array | string>
43
+ /** Host stdout/stderr sinks. Default: captured and returned by `run`. */
44
+ stdout?: (chunk: Uint8Array) => void | Promise<void>
45
+ stderr?: (chunk: Uint8Array) => void | Promise<void>
46
+ /** Spawn function for external commands (wrap it to confine children). */
47
+ spawn?: SpawnFn
48
+ /** Report `data` events for every channel (pipes, redirections, captures). Default false. */
49
+ traceData?: boolean
50
+ /**
51
+ * What to do with background jobs still running when the script ends:
52
+ * `detach` (like bash: leave them running), `wait`, or `kill`. Default `wait`.
53
+ */
54
+ background?: 'detach' | 'wait' | 'kill'
55
+ /**
56
+ * Deny commands whose policy verdict is `unknown` (effects not provably
57
+ * allowed). Default false: unknown effects are allowed (the OS sandbox
58
+ * still confines them) but reported.
59
+ */
60
+ denyUnknown?: boolean
61
+ /** Status of a denied command (default 126). */
62
+ deniedStatus?: number
63
+ /** Abort the whole run on the first denial instead of failing the command. */
64
+ abortOnDeny?: boolean
65
+ /** Dry run: never execute external commands; report their predicted effects instead. */
66
+ dryRun?: boolean
67
+ /** `$0` (default `mash`). */
68
+ arg0?: string
69
+ /**
70
+ * Run nested `bash -c`, `bash file` and bash/sh shebang scripts inside the
71
+ * VM so their commands are traced and checked individually (default true).
72
+ */
73
+ inlineScripts?: boolean
74
+ /**
75
+ * Emulate `xargs` and `find -exec/-delete` inside the VM so every command
76
+ * they run is traced and checked individually (default true).
77
+ */
78
+ emulate?: boolean
79
+ /**
80
+ * How process substitutions and other `/dev/fd/N` arguments naming VM fds
81
+ * reach external commands. `devfd`: an extra stdio slot, passed as
82
+ * `/dev/fd/K` (works on macOS, where the child can open a socket by that
83
+ * name). `fifo`: a named FIFO in a private temp dir, passed by path (needed
84
+ * on Linux, where opening `/dev/fd/K` on a socket fails with ENXIO).
85
+ * Default: the `MASH_PROCSUBST` environment variable (`fifo`|`devfd`), else
86
+ * `devfd` on macOS and `fifo` elsewhere.
87
+ */
88
+ procSubst?: FdPassing
89
+ }
90
+
91
+ export interface RunOptions {
92
+ /** Positional parameters ($1...). */
93
+ args?: string[]
94
+ signal?: AbortSignal
95
+ }
96
+
97
+ export interface RunResult {
98
+ status: number
99
+ /** Captured output when no stdout/stderr sink was configured. */
100
+ stdout: string
101
+ stderr: string
102
+ /** Set when the run was aborted (policy abort or signal). */
103
+ aborted?: string
104
+ /** Commands denied by policy or hooks during the run. */
105
+ denials: { argv: string[]; reason: string }[]
106
+ }
107
+
108
+ interface SourceRecord {
109
+ firstId: number
110
+ lastId: number
111
+ text: string
112
+ origin: string
113
+ }
114
+
115
+ export class Machine {
116
+ readonly options: MachineOptions
117
+ readonly policy: Policy
118
+ readonly behaviors: BehaviorRegistry
119
+ private readonly listeners: TraceListener[] = []
120
+ private readonly dataListeners: DataListener[] = []
121
+ private nextId = 1
122
+ private readonly sources: SourceRecord[] = []
123
+ private channelCounter = 0
124
+ private pidCounter = 0
125
+ private execCounter = 0
126
+ private runCounter = 0
127
+ private readonly contexts = new Map<number, CommandContext>()
128
+
129
+ constructor(options: MachineOptions = {}) {
130
+ this.options = options
131
+ const cwd = path.resolve(options.cwd ?? process.cwd())
132
+ this.policy = options.policy ?? defaultPolicy(cwd)
133
+ this.behaviors = options.behaviors ?? defaultBehaviors()
134
+ this.behaviors.scriptAnalyzer ??= (source, positional, inv, info) => this.analyzeNested(source, positional, inv, info)
135
+ if (options.traceData) {
136
+ this.dataListeners.push((channel, bytes, direction) => this.emit({ type: 'data', channel, bytes, direction }))
137
+ }
138
+ this.pidCounter = 10000 + (process.pid % 1000) * 10
139
+ }
140
+
141
+ /** Subscribe to trace events. Returns an unsubscribe function. */
142
+ on(listener: TraceListener): () => void {
143
+ this.listeners.push(listener)
144
+ return () => {
145
+ const i = this.listeners.indexOf(listener)
146
+ if (i >= 0) this.listeners.splice(i, 1)
147
+ }
148
+ }
149
+
150
+ /** Tap the bytes flowing through every channel (pipes, redirections, captures, stdio). */
151
+ tap(listener: DataListener): () => void {
152
+ this.dataListeners.push(listener)
153
+ return () => {
154
+ const i = this.dataListeners.indexOf(listener)
155
+ if (i >= 0) this.dataListeners.splice(i, 1)
156
+ }
157
+ }
158
+
159
+ private emit(e: TraceEvent): void {
160
+ for (const l of this.listeners) {
161
+ try {
162
+ l(e)
163
+ } catch {
164
+ // listeners must not break execution
165
+ }
166
+ }
167
+ }
168
+
169
+ /** Parse source, registering it so trace consumers can map node ids back to text. */
170
+ parse(source: string, origin = 'script', opts: { partial?: boolean } = {}): A.Script {
171
+ const firstId = this.nextId
172
+ const script = parse(source, { firstId, partial: opts.partial })
173
+ this.nextId = script.id + 1
174
+ this.sources.push({ firstId, lastId: script.id, text: source, origin })
175
+ return script
176
+ }
177
+
178
+ private sourceOf(id: number): SourceRecord | undefined {
179
+ for (let i = this.sources.length - 1; i >= 0; i--) {
180
+ const s = this.sources[i]
181
+ if (id >= s.firstId && id <= s.lastId) return s
182
+ }
183
+ return undefined
184
+ }
185
+
186
+ /** Source text of a node. */
187
+ textOf(node: { id: number; span: A.Span }): string {
188
+ const s = this.sourceOf(node.id)
189
+ return s ? s.text.slice(node.span.start, node.span.end) : ''
190
+ }
191
+
192
+ /** Static analysis: predicted commands, effects and policy findings, without running anything. */
193
+ analyze(source: string | A.Script, options: Partial<AnalyzeOptions> = {}): AnalysisReport {
194
+ const script = typeof source === 'string' ? this.parse(source, 'analysis') : source
195
+ const text = typeof source === 'string' ? source : this.sourceOf(source.id)?.text ?? ''
196
+ const cwd = path.resolve(this.options.cwd ?? process.cwd())
197
+ return analyzeScript(script, text, {
198
+ cwd: known(cwd),
199
+ env: this.initialEnv(),
200
+ policy: this.policy,
201
+ behaviors: this.behaviors,
202
+ ...options,
203
+ })
204
+ }
205
+
206
+ private analyzeNested(source: AValue, positional: AValue[], inv: Invocation, info: ScriptInfo = {}): BehaviorResult {
207
+ const text = source.alts.length === 1 && source.alts[0].every((s) => typeof s === 'string') ? (source.alts[0] as string[]).join('') : undefined
208
+ if (text === undefined) return { effects: [{ kind: 'unknown', detail: 'script text is not known statically' }], complete: false }
209
+ const file = info.file ? path.normalize(info.file) : undefined
210
+ if (file && inv.mayHaveChanged?.(file)) return { effects: [{ kind: 'unknown', detail: `script ${file} may be changed by the snippet before it runs` }], complete: false }
211
+ let script: A.Script
212
+ try {
213
+ script = parse(text, { firstId: 2_000_000_000 })
214
+ } catch (e) {
215
+ return { effects: [{ kind: 'unknown', detail: `script does not parse: ${(e as Error).message}` }], complete: false }
216
+ }
217
+ const env: Record<string, string> = {}
218
+ const report = analyzeScript(script, text, {
219
+ cwd: inv.cwd,
220
+ env,
221
+ envValues: (n) => inv.env(n),
222
+ policy: this.policy,
223
+ behaviors: this.behaviors,
224
+ positional,
225
+ nested: true,
226
+ })
227
+ // Every effect says which inner command it comes from. Conditional (`may`)
228
+ // effects are only possible effects of the outer command: never a definite
229
+ // violation, and checked individually when the script runs.
230
+ // External commands become `exec` effects, so denied command names are seen.
231
+ const effects: Effect[] = report.commands.flatMap((c) => {
232
+ const own: Effect[] = c.kind === 'external' || c.kind === 'not-found' ? [{ kind: 'exec', argv: c.argv }, ...c.effects] : c.effects
233
+ return own.map((e) => {
234
+ const nested = withVia(e, { text: c.text, line: c.line, ...(file ? { file } : {}) })
235
+ return c.reach === 'may' ? asPossible(nested) : nested
236
+ })
237
+ })
238
+ return { effects, complete: report.commands.every((c) => c.complete), readsStdin: 'maybe' }
239
+ }
240
+
241
+ private initialEnv(): Record<string, string> {
242
+ const env: Record<string, string> = {}
243
+ for (const [k, v] of Object.entries(this.options.env ?? process.env)) if (v !== undefined) env[k] = v
244
+ return env
245
+ }
246
+
247
+ /** Run a snippet. */
248
+ async run(source: string | A.Script, opts: RunOptions = {}): Promise<RunResult> {
249
+ // bash reads scripts line by line: lines before a syntax error still run
250
+ const script = typeof source === 'string' ? this.parse(source, 'script', { partial: true }) : source
251
+ const runId = ++this.runCounter
252
+ const controller = new AbortController()
253
+ const external = opts.signal
254
+ const onExternalAbort = (): void => controller.abort()
255
+ external?.addEventListener('abort', onExternalAbort, { once: true })
256
+ if (external?.aborted) controller.abort()
257
+
258
+ const cwd = path.resolve(this.options.cwd ?? process.cwd())
259
+ const state = new ShellState(cwd)
260
+ for (const [k, v] of Object.entries(this.initialEnv())) state.globals.set(k, newVar({ kind: 'string', value: v }, { exported: true }))
261
+ state.globals.set('PWD', newVar({ kind: 'string', value: cwd }, { exported: true }))
262
+ if (!state.globals.has('HOME')) state.globals.set('HOME', newVar({ kind: 'string', value: os.homedir() }, { exported: true }))
263
+ const shlvl = Number(state.getString('SHLVL') ?? '0') + 1
264
+ state.globals.set('SHLVL', newVar({ kind: 'string', value: String(shlvl) }, { exported: true }))
265
+ initShellVars(state)
266
+ state.positional = opts.args ?? []
267
+ state.arg0 = this.options.arg0 ?? 'mash'
268
+
269
+ const stdoutCapture: Uint8Array[] = []
270
+ const stderrCapture: Uint8Array[] = []
271
+ const denials: RunResult['denials'] = []
272
+ const fds = new FdTable()
273
+ const dl = this.dataListeners
274
+ fds.set(0, new HostStream(this.channel('host', 'stdin'), dl, { input: this.options.stdin ?? (async function* () {})() }))
275
+ fds.set(1, new HostStream(this.channel('host', 'stdout'), dl, { write: this.options.stdout ?? ((c) => void stdoutCapture.push(c)) }))
276
+ fds.set(2, new HostStream(this.channel('host', 'stderr'), dl, { write: this.options.stderr ?? ((c) => void stderrCapture.push(c)) }))
277
+
278
+ const shellPid = this.nextPidValue()
279
+ const jobs = new Map<number, Job>()
280
+ const allJobs: Job[] = []
281
+ const tracked: Promise<unknown>[] = []
282
+ const proc = new Proc(state, fds, shellPid, process.pid, 0, jobs, false)
283
+
284
+ const rt: Runtime = {
285
+ emit: (e) => this.emit(e),
286
+ listeners: dl,
287
+ channel: (kind, label, extra) => this.channel(kind, label, extra),
288
+ nextPid: () => this.nextPidValue(),
289
+ nextExecId: () => ++this.execCounter,
290
+ shellPid,
291
+ assess: async (intent) => {
292
+ const a = await this.assess(intent)
293
+ if (a.action === 'deny' || a.action === 'abort') denials.push({ argv: intent.argv, reason: a.reason })
294
+ return a
295
+ },
296
+ assessRedirects: async (effects, node, p) => {
297
+ const a = await this.assessEffectsOnly(effects, node)
298
+ if (a.action === 'deny' || a.action === 'abort') denials.push({ argv: [this.textOf(node)], reason: a.reason })
299
+ return a
300
+ },
301
+ afterCommand: async (execId, status) => {
302
+ const ctx = this.contexts.get(execId)
303
+ this.contexts.delete(execId)
304
+ if (ctx && this.options.hooks?.afterCommand) await this.options.hooks.afterCommand(ctx, status)
305
+ },
306
+ spawn: this.options.spawn ?? defaultSpawn,
307
+ signal: controller.signal,
308
+ text: (node) => this.textOf(node),
309
+ line: (node) => {
310
+ const s = this.sourceOf(node.id)
311
+ return s ? lineColumn(s.text, node.span.start).line : 0
312
+ },
313
+ parse: (text, origin, opts) => this.parse(text, origin, opts),
314
+ findJob: (pid) => allJobs.find((j) => j.pid === pid),
315
+ track: (p) => void tracked.push(p.catch(() => {})),
316
+ registerJob: (job) => void allJobs.push(job),
317
+ inlineScripts: this.options.inlineScripts ?? true,
318
+ emulate: this.options.emulate ?? true,
319
+ fdPassing: this.options.procSubst ?? defaultFdPassing(),
320
+ }
321
+
322
+ const interp = new Interpreter(rt)
323
+ this.emit({ type: 'run:start', runId, pid: shellPid })
324
+ let status: number
325
+ let aborted: string | undefined
326
+ try {
327
+ status = await interp.runTop(script, proc)
328
+ } catch (e) {
329
+ if (e instanceof AbortRun) {
330
+ status = e.status
331
+ aborted = e.message
332
+ controller.abort()
333
+ for (const j of allJobs) j.kill()
334
+ const errText = `mash: aborted: ${e.message}\n`
335
+ await fds.get(2)?.write(errText).catch(() => {})
336
+ } else {
337
+ throw e
338
+ }
339
+ }
340
+ const bg = this.options.background ?? 'wait'
341
+ if (bg === 'wait') await Promise.all(allJobs.map((j) => j.done.catch(() => 0)))
342
+ else if (bg === 'kill') {
343
+ for (const j of allJobs) if (j.status === null) j.kill()
344
+ await Promise.all(allJobs.map((j) => j.done.catch(() => 0)))
345
+ }
346
+ if (bg !== 'detach') await Promise.all(tracked)
347
+ fds.closeAll()
348
+ external?.removeEventListener('abort', onExternalAbort)
349
+ this.emit({ type: 'run:end', runId, status })
350
+ const dec = new TextDecoder()
351
+ const join = (chunks: Uint8Array[]): string => chunks.map((c) => dec.decode(c, { stream: true })).join('') + dec.decode()
352
+ return { status, stdout: join(stdoutCapture), stderr: join(stderrCapture), aborted, denials }
353
+ }
354
+
355
+ private nextPidValue(): number {
356
+ return ++this.pidCounter
357
+ }
358
+
359
+ private channel(kind: ChannelKind, label: string, extra: Partial<Channel> = {}): Channel {
360
+ const ch: Channel = { id: ++this.channelCounter, kind, label, ...extra }
361
+ if (kind !== 'host' && kind !== 'null') this.emit({ type: 'channel:open', channel: ch })
362
+ return ch
363
+ }
364
+
365
+ // -------------------------------------------------------------------------
366
+ // Assessment: behavior → effects → policy → hooks
367
+ // -------------------------------------------------------------------------
368
+
369
+ /** Effects of a builtin (most have none). */
370
+ private builtinEffects(argv: string[], cwd: string, env: Record<string, string>): BehaviorResult {
371
+ const name = argv[0]
372
+ const resolve = (p: string): AValue => known(path.resolve(cwd, p))
373
+ switch (name) {
374
+ case 'cd':
375
+ case 'pushd':
376
+ return { effects: argv[1] && argv[1] !== '-' ? [{ kind: 'chdir', path: resolve(argv[1]) }] : [] }
377
+ case 'source':
378
+ case '.':
379
+ return { effects: argv[1] ? [{ kind: 'read', path: resolve(argv[1]) }] : [] }
380
+ case 'exec':
381
+ case 'command':
382
+ case 'builtin': {
383
+ const rest = argv.slice(1).filter((a, i) => !(i === 0 && a.startsWith('-')))
384
+ if (rest.length === 0 || argv.slice(1).some((a) => a === '-v' || a === '-V')) return { effects: [] }
385
+ return this.behaviors.analyze(this.invocation(rest, cwd, env))
386
+ }
387
+ case 'kill':
388
+ return { effects: [{ kind: 'process', detail: `kill ${argv.slice(1).join(' ')}` }] }
389
+ default:
390
+ return { effects: [] }
391
+ }
392
+ }
393
+
394
+ private invocation(argv: string[], cwd: string, env: Record<string, string>, stdin: Invocation['stdin'] = 'unknown'): Invocation {
395
+ return {
396
+ name: basename(argv[0]),
397
+ argv: argv.map(known),
398
+ cwd: known(cwd),
399
+ env: (n) => (env[n] === undefined ? undefined : known(env[n])),
400
+ stdin,
401
+ }
402
+ }
403
+
404
+ private async assess(intent: ExecIntent): Promise<Assessment> {
405
+ const policy = this.policy
406
+ let result: BehaviorResult
407
+ if (intent.kind === 'external') {
408
+ result = this.behaviors.analyze(this.invocation(intent.argv, intent.cwd, intent.env, intent.stdinKind))
409
+ } else if (intent.kind === 'builtin') {
410
+ result = this.builtinEffects(intent.argv, intent.cwd, intent.env)
411
+ } else {
412
+ result = { effects: [] }
413
+ }
414
+ const effects: Effect[] = [...intent.redirectEffects, ...result.effects]
415
+ const findings: PolicyFinding[] = checkEffects(effects, policy, { runtime: true })
416
+ const nameFinding = intent.kind === 'external' || intent.kind === 'not-found' ? checkCommandName(intent.argv[0], policy) : null
417
+ if (nameFinding) findings.unshift(nameFinding)
418
+ findings.unshift(...checkExecEffects(result.effects, policy))
419
+ const denied = findings.filter((f) => f.verdict === 'deny')
420
+ const unknown = findings.filter((f) => f.verdict === 'unknown')
421
+ let decision: 'allow' | 'deny' = denied.length > 0 || (this.options.denyUnknown && unknown.length > 0) ? 'deny' : 'allow'
422
+ const ctx: CommandContext = {
423
+ execId: intent.execId,
424
+ nodeId: intent.node.id,
425
+ span: intent.node.span,
426
+ text: this.textOf(intent.node),
427
+ argv: intent.argv,
428
+ kind: intent.kind,
429
+ path: intent.path,
430
+ cwd: intent.cwd,
431
+ env: intent.env,
432
+ effects,
433
+ effectsComplete: result.complete !== false,
434
+ findings,
435
+ decision,
436
+ pid: intent.pid,
437
+ }
438
+ this.contexts.set(intent.execId, ctx)
439
+ let hook: HookDecision | void = undefined
440
+ if (this.options.hooks?.beforeCommand) hook = await this.options.hooks.beforeCommand(ctx)
441
+ const reasonOf = (): string => (denied.length > 0 ? denied : unknown).map((f) => f.reason + viaSuffix(f.effect)).join('; ') || 'denied by policy'
442
+ const status = this.options.deniedStatus ?? 126
443
+ if (hook) {
444
+ switch (hook.action) {
445
+ case 'allow':
446
+ decision = 'allow'
447
+ break
448
+ case 'deny':
449
+ this.emit({ type: 'command:denied', execId: intent.execId, nodeId: intent.node.id, argv: intent.argv, reason: hook.reason, findings })
450
+ return { action: 'deny', reason: hook.reason, status: hook.status ?? status }
451
+ case 'abort':
452
+ this.emit({ type: 'command:denied', execId: intent.execId, nodeId: intent.node.id, argv: intent.argv, reason: hook.reason, findings })
453
+ return { action: 'abort', reason: hook.reason, status: hook.status ?? status }
454
+ case 'simulate':
455
+ return { action: 'simulate', stdout: hook.stdout ?? '', stderr: hook.stderr ?? '', status: hook.status ?? 0 }
456
+ }
457
+ }
458
+ if (decision === 'deny') {
459
+ const reason = `denied by policy: ${reasonOf()}`
460
+ this.emit({ type: 'command:denied', execId: intent.execId, nodeId: intent.node.id, argv: intent.argv, reason, findings })
461
+ return this.options.abortOnDeny ? { action: 'abort', reason, status } : { action: 'deny', reason, status }
462
+ }
463
+ if (this.options.dryRun && intent.kind === 'external') {
464
+ return { action: 'simulate', stdout: '', stderr: '', status: 0 }
465
+ }
466
+ return { action: 'run', readsStdin: result.readsStdin ?? 'maybe' }
467
+ }
468
+
469
+ private async assessEffectsOnly(effects: Effect[], node: A.Node): Promise<Assessment> {
470
+ if (effects.length === 0) return { action: 'run', readsStdin: 'maybe' }
471
+ const findings = checkEffects(effects, this.policy, { runtime: true })
472
+ const denied = findings.filter((f) => f.verdict === 'deny')
473
+ if (denied.length > 0) {
474
+ const reason = `denied by policy: ${denied.map((f) => f.reason + viaSuffix(f.effect)).join('; ')}`
475
+ this.emit({ type: 'command:denied', execId: 0, nodeId: node.id, argv: [this.textOf(node)], reason, findings })
476
+ const status = this.options.deniedStatus ?? 126
477
+ return this.options.abortOnDeny ? { action: 'abort', reason, status } : { action: 'deny', reason, status }
478
+ }
479
+ return { action: 'run', readsStdin: 'maybe' }
480
+ }
481
+ }
482
+
483
+ export { Capture }