@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.
- package/LICENSE +21 -0
- package/README.md +275 -2
- package/bin/mash.ts +253 -0
- package/dist/bin/mash.d.ts +39 -0
- package/dist/bin/mash.d.ts.map +1 -0
- package/dist/bin/mash.js +281 -0
- package/dist/bin/mash.js.map +1 -0
- package/dist/src/analysis/analyze.d.ts +97 -0
- package/dist/src/analysis/analyze.d.ts.map +1 -0
- package/dist/src/analysis/analyze.js +1406 -0
- package/dist/src/analysis/analyze.js.map +1 -0
- package/dist/src/analysis/fs.d.ts +39 -0
- package/dist/src/analysis/fs.d.ts.map +1 -0
- package/dist/src/analysis/fs.js +117 -0
- package/dist/src/analysis/fs.js.map +1 -0
- package/dist/src/analysis/outputs.d.ts +16 -0
- package/dist/src/analysis/outputs.d.ts.map +1 -0
- package/dist/src/analysis/outputs.js +277 -0
- package/dist/src/analysis/outputs.js.map +1 -0
- package/dist/src/analysis/value.d.ts +76 -0
- package/dist/src/analysis/value.d.ts.map +1 -0
- package/dist/src/analysis/value.js +248 -0
- package/dist/src/analysis/value.js.map +1 -0
- package/dist/src/behaviors/defs/core.d.ts +15 -0
- package/dist/src/behaviors/defs/core.d.ts.map +1 -0
- package/dist/src/behaviors/defs/core.js +1084 -0
- package/dist/src/behaviors/defs/core.js.map +1 -0
- package/dist/src/behaviors/defs/devtools.d.ts +9 -0
- package/dist/src/behaviors/defs/devtools.d.ts.map +1 -0
- package/dist/src/behaviors/defs/devtools.js +432 -0
- package/dist/src/behaviors/defs/devtools.js.map +1 -0
- package/dist/src/behaviors/defs/macos.d.ts +12 -0
- package/dist/src/behaviors/defs/macos.d.ts.map +1 -0
- package/dist/src/behaviors/defs/macos.js +454 -0
- package/dist/src/behaviors/defs/macos.js.map +1 -0
- package/dist/src/behaviors/defs/tools.d.ts +9 -0
- package/dist/src/behaviors/defs/tools.d.ts.map +1 -0
- package/dist/src/behaviors/defs/tools.js +639 -0
- package/dist/src/behaviors/defs/tools.js.map +1 -0
- package/dist/src/behaviors/dsl.d.ts +95 -0
- package/dist/src/behaviors/dsl.d.ts.map +1 -0
- package/dist/src/behaviors/dsl.js +267 -0
- package/dist/src/behaviors/dsl.js.map +1 -0
- package/dist/src/behaviors/index.d.ts +12 -0
- package/dist/src/behaviors/index.d.ts.map +1 -0
- package/dist/src/behaviors/index.js +18 -0
- package/dist/src/behaviors/index.js.map +1 -0
- package/dist/src/behaviors/registry.d.ts +45 -0
- package/dist/src/behaviors/registry.d.ts.map +1 -0
- package/dist/src/behaviors/registry.js +266 -0
- package/dist/src/behaviors/registry.js.map +1 -0
- package/dist/src/behaviors/roles.d.ts +29 -0
- package/dist/src/behaviors/roles.d.ts.map +1 -0
- package/dist/src/behaviors/roles.js +198 -0
- package/dist/src/behaviors/roles.js.map +1 -0
- package/dist/src/behaviors/types.d.ts +120 -0
- package/dist/src/behaviors/types.d.ts.map +1 -0
- package/dist/src/behaviors/types.js +12 -0
- package/dist/src/behaviors/types.js.map +1 -0
- package/dist/src/index.d.ts +27 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +24 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/integrations/deepsea.d.ts +121 -0
- package/dist/src/integrations/deepsea.d.ts.map +1 -0
- package/dist/src/integrations/deepsea.js +199 -0
- package/dist/src/integrations/deepsea.js.map +1 -0
- package/dist/src/machine.d.ts +139 -0
- package/dist/src/machine.d.ts.map +1 -0
- package/dist/src/machine.js +400 -0
- package/dist/src/machine.js.map +1 -0
- package/dist/src/parser/ast.d.ts +369 -0
- package/dist/src/parser/ast.d.ts.map +1 -0
- package/dist/src/parser/ast.js +17 -0
- package/dist/src/parser/ast.js.map +1 -0
- package/dist/src/parser/parser.d.ts +48 -0
- package/dist/src/parser/parser.d.ts.map +1 -0
- package/dist/src/parser/parser.js +1950 -0
- package/dist/src/parser/parser.js.map +1 -0
- package/dist/src/policy/effects.d.ts +86 -0
- package/dist/src/policy/effects.d.ts.map +1 -0
- package/dist/src/policy/effects.js +53 -0
- package/dist/src/policy/effects.js.map +1 -0
- package/dist/src/policy/policy.d.ts +130 -0
- package/dist/src/policy/policy.d.ts.map +1 -0
- package/dist/src/policy/policy.js +532 -0
- package/dist/src/policy/policy.js.map +1 -0
- package/dist/src/printer/doc.d.ts +151 -0
- package/dist/src/printer/doc.d.ts.map +1 -0
- package/dist/src/printer/doc.js +409 -0
- package/dist/src/printer/doc.js.map +1 -0
- package/dist/src/printer/highlight.d.ts +53 -0
- package/dist/src/printer/highlight.d.ts.map +1 -0
- package/dist/src/printer/highlight.js +339 -0
- package/dist/src/printer/highlight.js.map +1 -0
- package/dist/src/printer/html.d.ts +72 -0
- package/dist/src/printer/html.d.ts.map +1 -0
- package/dist/src/printer/html.js +423 -0
- package/dist/src/printer/html.js.map +1 -0
- package/dist/src/printer/print.d.ts +63 -0
- package/dist/src/printer/print.d.ts.map +1 -0
- package/dist/src/printer/print.js +484 -0
- package/dist/src/printer/print.js.map +1 -0
- package/dist/src/printer/summary.d.ts +50 -0
- package/dist/src/printer/summary.d.ts.map +1 -0
- package/dist/src/printer/summary.js +621 -0
- package/dist/src/printer/summary.js.map +1 -0
- package/dist/src/trace/events.d.ts +183 -0
- package/dist/src/trace/events.d.ts.map +1 -0
- package/dist/src/trace/events.js +9 -0
- package/dist/src/trace/events.js.map +1 -0
- package/dist/src/trace/progress.d.ts +58 -0
- package/dist/src/trace/progress.d.ts.map +1 -0
- package/dist/src/trace/progress.js +83 -0
- package/dist/src/trace/progress.js.map +1 -0
- package/dist/src/vm/arith.d.ts +29 -0
- package/dist/src/vm/arith.d.ts.map +1 -0
- package/dist/src/vm/arith.js +258 -0
- package/dist/src/vm/arith.js.map +1 -0
- package/dist/src/vm/builtins.d.ts +31 -0
- package/dist/src/vm/builtins.d.ts.map +1 -0
- package/dist/src/vm/builtins.js +1773 -0
- package/dist/src/vm/builtins.js.map +1 -0
- package/dist/src/vm/emulate.d.ts +75 -0
- package/dist/src/vm/emulate.d.ts.map +1 -0
- package/dist/src/vm/emulate.js +380 -0
- package/dist/src/vm/emulate.js.map +1 -0
- package/dist/src/vm/expand.d.ts +68 -0
- package/dist/src/vm/expand.d.ts.map +1 -0
- package/dist/src/vm/expand.js +744 -0
- package/dist/src/vm/expand.js.map +1 -0
- package/dist/src/vm/external.d.ts +34 -0
- package/dist/src/vm/external.d.ts.map +1 -0
- package/dist/src/vm/external.js +228 -0
- package/dist/src/vm/external.js.map +1 -0
- package/dist/src/vm/fifo.d.ts +66 -0
- package/dist/src/vm/fifo.d.ts.map +1 -0
- package/dist/src/vm/fifo.js +232 -0
- package/dist/src/vm/fifo.js.map +1 -0
- package/dist/src/vm/interp.d.ts +209 -0
- package/dist/src/vm/interp.d.ts.map +1 -0
- package/dist/src/vm/interp.js +1641 -0
- package/dist/src/vm/interp.js.map +1 -0
- package/dist/src/vm/io.d.ts +180 -0
- package/dist/src/vm/io.d.ts.map +1 -0
- package/dist/src/vm/io.js +502 -0
- package/dist/src/vm/io.js.map +1 -0
- package/dist/src/vm/pattern.d.ts +46 -0
- package/dist/src/vm/pattern.d.ts.map +1 -0
- package/dist/src/vm/pattern.js +498 -0
- package/dist/src/vm/pattern.js.map +1 -0
- package/dist/src/vm/proc.d.ts +94 -0
- package/dist/src/vm/proc.d.ts.map +1 -0
- package/dist/src/vm/proc.js +130 -0
- package/dist/src/vm/proc.js.map +1 -0
- package/dist/src/vm/state.d.ts +104 -0
- package/dist/src/vm/state.d.ts.map +1 -0
- package/dist/src/vm/state.js +233 -0
- package/dist/src/vm/state.js.map +1 -0
- package/dist/src/vm/test.d.ts +11 -0
- package/dist/src/vm/test.d.ts.map +1 -0
- package/dist/src/vm/test.js +277 -0
- package/dist/src/vm/test.js.map +1 -0
- package/package.json +63 -4
- package/src/analysis/analyze.ts +1395 -0
- package/src/analysis/fs.ts +121 -0
- package/src/analysis/outputs.ts +242 -0
- package/src/analysis/value.ts +264 -0
- package/src/behaviors/defs/core.ts +970 -0
- package/src/behaviors/defs/devtools.ts +400 -0
- package/src/behaviors/defs/macos.ts +426 -0
- package/src/behaviors/defs/tools.ts +596 -0
- package/src/behaviors/dsl.ts +293 -0
- package/src/behaviors/index.ts +19 -0
- package/src/behaviors/registry.ts +261 -0
- package/src/behaviors/roles.ts +187 -0
- package/src/behaviors/types.ts +127 -0
- package/src/index.ts +27 -0
- package/src/integrations/deepsea.ts +259 -0
- package/src/machine.ts +483 -0
- package/src/parser/ast.ts +472 -0
- package/src/parser/parser.ts +1826 -0
- package/src/policy/effects.ts +102 -0
- package/src/policy/policy.ts +578 -0
- package/src/printer/doc.ts +454 -0
- package/src/printer/highlight.ts +283 -0
- package/src/printer/html.ts +441 -0
- package/src/printer/print.ts +528 -0
- package/src/printer/summary.ts +601 -0
- package/src/trace/events.ts +102 -0
- package/src/trace/progress.ts +119 -0
- package/src/vm/arith.ts +236 -0
- package/src/vm/builtins.ts +1590 -0
- package/src/vm/emulate.ts +337 -0
- package/src/vm/expand.ts +721 -0
- package/src/vm/external.ts +265 -0
- package/src/vm/fifo.ts +226 -0
- package/src/vm/interp.ts +1623 -0
- package/src/vm/io.ts +570 -0
- package/src/vm/pattern.ts +477 -0
- package/src/vm/proc.ts +147 -0
- package/src/vm/state.ts +267 -0
- 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 }
|