@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
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument roles for display: which arguments of a command are directories,
|
|
3
|
+
* files, search patterns, program text or nested command names.
|
|
4
|
+
*
|
|
5
|
+
* The behavior definition is run over literal argument values, each a
|
|
6
|
+
* distinct object. Path effects carry *resolved* values, so the registry's
|
|
7
|
+
* tracer records which input each resolved value came from; an effect then
|
|
8
|
+
* maps back to its argument:
|
|
9
|
+
*
|
|
10
|
+
* chdir, or a recursive read/write/delete → dir
|
|
11
|
+
* other reads, writes, deletes → file
|
|
12
|
+
* argv[0] of a nested command (exec effect) → cmd
|
|
13
|
+
* h.role(value, 'pattern' | 'code' | …) → that role (wins over effects)
|
|
14
|
+
* SUBCOMMANDS table (cargo, docker, gh, …) → subcommand (when nothing else said)
|
|
15
|
+
*
|
|
16
|
+
* Arguments no definition explains get a lexical guess (`x.ts` → file,
|
|
17
|
+
* `out/` → dir). Nothing here affects analysis or policy.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { known, unknownString, type AValue } from '../analysis/value.ts'
|
|
21
|
+
import type { Effect } from '../policy/effects.ts'
|
|
22
|
+
import { basename, type BehaviorRegistry } from './registry.ts'
|
|
23
|
+
import type { ArgRole } from './types.ts'
|
|
24
|
+
import { defaultBehaviors } from './index.ts'
|
|
25
|
+
|
|
26
|
+
let shared: BehaviorRegistry | null = null
|
|
27
|
+
|
|
28
|
+
/** A directory that does not exist, so definitions that look at files find nothing. */
|
|
29
|
+
const NOWHERE = '/nonexistent/mash-roles'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Roles of a command's arguments. `words[i]` is the literal text of word i
|
|
33
|
+
* (argv[0] first), or undefined when it is not a literal. Returns one entry
|
|
34
|
+
* per word (index 0 included: always undefined).
|
|
35
|
+
*/
|
|
36
|
+
export function argRoles(words: (string | undefined)[], registry?: BehaviorRegistry): (ArgRole | undefined)[] {
|
|
37
|
+
const roles: (ArgRole | undefined)[] = words.map(() => undefined)
|
|
38
|
+
const name = words[0]
|
|
39
|
+
if (name === undefined) return roles
|
|
40
|
+
const cmd = basename(name)
|
|
41
|
+
|
|
42
|
+
// builtins the registry does not describe
|
|
43
|
+
if (cmd === 'cd' || cmd === 'pushd' || cmd === 'source' || cmd === '.') {
|
|
44
|
+
const i = words.findIndex((w, j) => j > 0 && w !== undefined && !/^-./.test(w))
|
|
45
|
+
if (i > 0) roles[i] = cmd === 'cd' || cmd === 'pushd' ? 'dir' : 'file'
|
|
46
|
+
return roles
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const reg = registry ?? (shared ??= defaultBehaviors())
|
|
50
|
+
const values: AValue[] = words.map((w) => (w === undefined ? unknownString('word') : known(w)))
|
|
51
|
+
const index = new Map<AValue, number>(values.map((v, i) => [v, i]))
|
|
52
|
+
const resolvedFrom = new Map<AValue, number>()
|
|
53
|
+
const hinted = new Set<number>()
|
|
54
|
+
const prev = reg.tracer
|
|
55
|
+
reg.tracer = {
|
|
56
|
+
resolved(input, output) {
|
|
57
|
+
const i = index.get(input)
|
|
58
|
+
if (i !== undefined) resolvedFrom.set(output, i)
|
|
59
|
+
},
|
|
60
|
+
role(value, role) {
|
|
61
|
+
const i = index.get(value)
|
|
62
|
+
if (i !== undefined && i > 0) {
|
|
63
|
+
roles[i] = role
|
|
64
|
+
hinted.add(i)
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
let effects: Effect[] = []
|
|
69
|
+
try {
|
|
70
|
+
effects = reg.analyze({ name: cmd, argv: values, cwd: known(NOWHERE), env: () => undefined, stdin: 'unknown' }).effects
|
|
71
|
+
} catch {
|
|
72
|
+
// display only: ignore failures
|
|
73
|
+
} finally {
|
|
74
|
+
reg.tracer = prev
|
|
75
|
+
}
|
|
76
|
+
const nested: number[] = []
|
|
77
|
+
for (const e of effects) {
|
|
78
|
+
if (e.kind === 'exec') {
|
|
79
|
+
const i = index.get(e.argv[0])
|
|
80
|
+
if (i !== undefined && i > 0 && !hinted.has(i)) {
|
|
81
|
+
roles[i] = 'cmd'
|
|
82
|
+
nested.push(i)
|
|
83
|
+
}
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (e.kind !== 'read' && e.kind !== 'write' && e.kind !== 'delete' && e.kind !== 'chdir') continue
|
|
87
|
+
const i = resolvedFrom.get(e.path) ?? index.get(e.path)
|
|
88
|
+
if (i === undefined || i === 0 || hinted.has(i)) continue
|
|
89
|
+
const role: ArgRole = e.kind === 'chdir' || ('recursive' in e && e.recursive) ? 'dir' : 'file'
|
|
90
|
+
// a later recursive effect on the same argument makes it a directory
|
|
91
|
+
if (roles[i] !== 'dir') roles[i] = role
|
|
92
|
+
}
|
|
93
|
+
// a nested command's own arguments (`timeout 5 npx vitest run x.test.ts`): roles from its own analysis
|
|
94
|
+
for (const i of nested) {
|
|
95
|
+
const inner = argRoles(words.slice(i), reg)
|
|
96
|
+
inner.forEach((r, j) => {
|
|
97
|
+
if (j > 0 && r !== undefined && roles[i + j] === undefined) roles[i + j] = r
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
// tools with subcommands that no definition annotates
|
|
101
|
+
if (!roles.includes('subcommand')) markSubcommands(cmd, words, roles)
|
|
102
|
+
// lexical guesses for arguments nothing explained
|
|
103
|
+
words.forEach((w, i) => {
|
|
104
|
+
if (i === 0 || roles[i] !== undefined || w === undefined) return
|
|
105
|
+
roles[i] = lexicalRole(w)
|
|
106
|
+
})
|
|
107
|
+
return roles
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A guess from the text alone: `dir/`, `~`, `.`, `..` → dir; a path-like name with an extension → file. */
|
|
111
|
+
export function lexicalRole(w: string): ArgRole | undefined {
|
|
112
|
+
if (/^-/.test(w) || /[\s=*?[\]{}$`'"\\|<>;&()]/.test(w) || /^\w+:\/\//.test(w)) return undefined
|
|
113
|
+
if (w === '.' || w === '..' || w === '~' || /\/$/.test(w)) return 'dir'
|
|
114
|
+
if (/^(?:~|\.{1,2})?\/?(?:[\w.@+-]+\/)*[\w@+-][\w.@+-]*\.[A-Za-z][\w]{0,7}$/.test(w) && !/^\d+(\.\d+)+$/.test(w)) return 'file'
|
|
115
|
+
return undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Tools whose first operand is a subcommand. `value`: flags that take a
|
|
120
|
+
* value (skipped together with it); `nested`: subcommands that have their own
|
|
121
|
+
* (`gh pr view`, `docker compose up`); `all: true` for tools where every
|
|
122
|
+
* first-level subcommand is a group (`aws s3 ls`, `gcloud compute instances`).
|
|
123
|
+
* Not exhaustive: unknown tools just get no subcommand color.
|
|
124
|
+
*/
|
|
125
|
+
const SUBCOMMANDS: Record<string, { value?: string[]; nested?: string[]; all?: boolean }> = {
|
|
126
|
+
cargo: { value: ['-Z', '--config', '--color', '-C'] },
|
|
127
|
+
go: {},
|
|
128
|
+
rustup: { nested: ['toolchain', 'target', 'component', 'override', 'self', 'set'] },
|
|
129
|
+
docker: { value: ['-H', '--host', '--context', '-c', '--config', '-l', '--log-level'], nested: ['compose', 'image', 'container', 'network', 'volume', 'buildx', 'system', 'builder', 'context', 'plugin', 'manifest', 'swarm', 'node', 'service', 'stack', 'secret', 'config', 'trust'] },
|
|
130
|
+
podman: { nested: ['image', 'container', 'network', 'volume', 'machine', 'system', 'pod'] },
|
|
131
|
+
'docker-compose': { value: ['-f', '--file', '-p', '--project-name'] },
|
|
132
|
+
kubectl: { value: ['-n', '--namespace', '--context', '--kubeconfig', '-l', '--selector', '-o', '--output', '-f', '--filename'], nested: ['config', 'rollout', 'auth', 'certificate', 'top', 'create'] },
|
|
133
|
+
helm: { value: ['-n', '--namespace', '--kube-context'], nested: ['repo', 'plugin', 'dependency', 'registry', 'search', 'show'] },
|
|
134
|
+
gh: { value: ['-R', '--repo'], all: true },
|
|
135
|
+
glab: { value: ['-R', '--repo'], all: true },
|
|
136
|
+
aws: { value: ['--profile', '--region', '--output', '--endpoint-url'], all: true },
|
|
137
|
+
gcloud: { value: ['--project', '--account', '--format', '--region', '--zone'], all: true },
|
|
138
|
+
az: { value: ['--subscription', '-o', '--output'], all: true },
|
|
139
|
+
brew: { nested: ['services', 'bundle', 'tap-info', 'cask'] },
|
|
140
|
+
apt: {}, 'apt-get': {}, 'apt-cache': {}, dnf: {}, yum: {}, pacman: {}, apk: {}, snap: {}, flatpak: {}, port: {},
|
|
141
|
+
pip: {}, pip3: {}, uv: { nested: ['pip', 'tool', 'python', 'cache', 'self'] }, poetry: { nested: ['env', 'self', 'source', 'cache'] }, pipx: {}, conda: { nested: ['env'] }, pyenv: {},
|
|
142
|
+
swift: { nested: ['package'] }, xcrun: { nested: ['simctl', 'devicectl'], value: ['--sdk', '--toolchain'] }, xcodebuild: {},
|
|
143
|
+
deno: {}, bun: {}, yarn: {}, volta: {}, nvm: {}, corepack: {},
|
|
144
|
+
terraform: { value: ['-chdir'], nested: ['state', 'workspace', 'providers'] }, tofu: { nested: ['state', 'workspace'] }, pulumi: { nested: ['stack', 'config'] },
|
|
145
|
+
systemctl: {}, launchctl: {}, defaults: {}, diskutil: { nested: ['apfs', 'image'] }, hdiutil: {}, security: {}, tmutil: {}, pmset: {}, scutil: {}, networksetup: {},
|
|
146
|
+
tmux: { value: ['-L', '-S', '-f'] }, screen: {}, tailscale: {}, zig: {}, dotnet: {}, mix: {}, gem: {}, bundle: {}, rails: {}, composer: {}, mvn: {}, gradle: {}, sbt: {},
|
|
147
|
+
openssl: {}, gpg: {}, ssh_keygen: {}, git_lfs: {}, jj: { nested: ['git', 'op', 'bookmark', 'branch', 'workspace', 'config'] }, hg: {}, svn: {},
|
|
148
|
+
wrangler: { nested: ['kv', 'r2', 'd1', 'secret', 'pages', 'queues'] }, vercel: {}, netlify: {}, fly: { nested: ['apps', 'machine', 'secrets', 'volumes', 'ips', 'certs', 'postgres', 'redis'] }, flyctl: { nested: ['apps', 'machine', 'secrets', 'volumes'] },
|
|
149
|
+
heroku: {}, firebase: {}, supabase: { nested: ['db', 'functions', 'migration', 'projects', 'secrets'] }, stripe: {},
|
|
150
|
+
ollama: {}, hf: { nested: ['auth', 'repo', 'cache'] }, 'huggingface-cli': {}, code: {}, cursor: {}, op: { nested: ['item', 'vault', 'account', 'document', 'user'] },
|
|
151
|
+
sqlx: { nested: ['migrate', 'database'] }, prisma: { nested: ['migrate', 'db', 'generate'] }, drizzle_kit: {}, turbo: {}, nx: {}, lerna: {},
|
|
152
|
+
ffmpeg: { value: [] }, magick: {}, typst: {}, quarto: {}, jupyter: {}, npm: {}, pnpm: {}, npx: {},
|
|
153
|
+
container: { nested: ['system', 'image', 'images', 'builder', 'network', 'volume', 'registry'] }, log: {}, lms: {}, ruff: {}, taplo: {}, uvx: {}, rye: {}, pixi: {},
|
|
154
|
+
vitest: {}, jest: {}, playwright: {}, vite: {}, next: {}, nuxt: {}, astro: {}, expo: {}, eas: { nested: ['build', 'submit', 'update'] }, tauri: {}, 'wasm-pack': {}, maturin: {}, tsx: {}, biome: {}, eslint: {}, prettier: {},
|
|
155
|
+
}
|
|
156
|
+
// ffmpeg's first operand is not a subcommand
|
|
157
|
+
delete SUBCOMMANDS.ffmpeg
|
|
158
|
+
|
|
159
|
+
function markSubcommands(cmd: string, words: (string | undefined)[], roles: (ArgRole | undefined)[]): void {
|
|
160
|
+
const spec = SUBCOMMANDS[cmd]
|
|
161
|
+
if (!spec || cmd === 'npm' || cmd === 'pnpm' || cmd === 'npx') return
|
|
162
|
+
const value = new Set(spec.value ?? [])
|
|
163
|
+
const isSub = (w: string | undefined): w is string => w !== undefined && /^[a-z][a-z0-9._:-]*$/.test(w)
|
|
164
|
+
let i = 1
|
|
165
|
+
const next = (): number => {
|
|
166
|
+
for (; i < words.length; i++) {
|
|
167
|
+
const w = words[i]
|
|
168
|
+
if (w === undefined) return -1
|
|
169
|
+
if (w === '--') return -1
|
|
170
|
+
if (w.startsWith('+')) continue // cargo +nightly
|
|
171
|
+
if (w.startsWith('-')) {
|
|
172
|
+
if (value.has(w)) i++
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
return i
|
|
176
|
+
}
|
|
177
|
+
return -1
|
|
178
|
+
}
|
|
179
|
+
const first = next()
|
|
180
|
+
if (first < 0 || !isSub(words[first]) || roles[first] !== undefined) return
|
|
181
|
+
roles[first] = 'subcommand'
|
|
182
|
+
if (spec.all || spec.nested?.includes(words[first]!)) {
|
|
183
|
+
i = first + 1
|
|
184
|
+
const second = next()
|
|
185
|
+
if (second > 0 && isSub(words[second]) && roles[second] === undefined) roles[second] = 'subcommand'
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavior definitions: what an executable WOULD read, write, delete,
|
|
3
|
+
* execute or connect to, given its argv — without running it.
|
|
4
|
+
*
|
|
5
|
+
* A behavior receives abstract argv ({@link AValue}s). At runtime every value
|
|
6
|
+
* is concrete; during static analysis some are unknown. Definitions should be
|
|
7
|
+
* conservative: when an argument they need is unknown they still report the
|
|
8
|
+
* effect with the unknown path (the policy then answers `unknown`), and set
|
|
9
|
+
* `complete: false` when they cannot tell what the command does at all.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AValue, HoleType } from '../analysis/value.ts'
|
|
13
|
+
import type { Effect } from '../policy/effects.ts'
|
|
14
|
+
|
|
15
|
+
export type StdinKind = 'pipe' | 'file' | 'null' | 'terminal' | 'unknown'
|
|
16
|
+
|
|
17
|
+
export interface Invocation {
|
|
18
|
+
/** Basename of argv[0]. */
|
|
19
|
+
name: string
|
|
20
|
+
/** Full argv (argv[0] included). */
|
|
21
|
+
argv: AValue[]
|
|
22
|
+
/** Working directory (absolute). */
|
|
23
|
+
cwd: AValue
|
|
24
|
+
/** Environment lookup (exported variables). */
|
|
25
|
+
env(name: string): AValue | undefined
|
|
26
|
+
stdin: StdinKind
|
|
27
|
+
/**
|
|
28
|
+
* Static analysis only: did the snippet (possibly) write this concrete path
|
|
29
|
+
* before this command? Then the file's current contents (a script to
|
|
30
|
+
* follow) are not what the command will see.
|
|
31
|
+
*/
|
|
32
|
+
mayHaveChanged?(path: string): boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface OutputInfo {
|
|
36
|
+
/** Type of the command's stdout (as captured by `$(...)`, trailing newlines stripped). */
|
|
37
|
+
type: HoleType
|
|
38
|
+
/** For `path` outputs: a directory the output lies under. */
|
|
39
|
+
under?: string
|
|
40
|
+
/** Output is a list of lines, each of `type`. */
|
|
41
|
+
lines?: boolean
|
|
42
|
+
/** Exactly known output (e.g. `pwd`, `basename` of a literal). */
|
|
43
|
+
value?: AValue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Where nested script text came from (for effect provenance). */
|
|
47
|
+
export interface ScriptInfo {
|
|
48
|
+
/** The script file (absolute), when the text was read from one. */
|
|
49
|
+
file?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface BehaviorResult {
|
|
53
|
+
effects: Effect[]
|
|
54
|
+
/** Does the command read stdin? Lets the VM avoid pumping stdin into commands that never read it. */
|
|
55
|
+
readsStdin?: boolean | 'maybe'
|
|
56
|
+
stdout?: OutputInfo
|
|
57
|
+
/** False when the definition could not determine all effects (unknown flags, opaque arguments). */
|
|
58
|
+
complete?: boolean
|
|
59
|
+
/** Free-form notes for reports. */
|
|
60
|
+
notes?: string[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface Behavior {
|
|
64
|
+
/** Command basenames this definition handles. */
|
|
65
|
+
names: string[]
|
|
66
|
+
summary?: string
|
|
67
|
+
analyze(inv: Invocation, h: BehaviorHelpers): BehaviorResult
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ParsedArgs {
|
|
71
|
+
/** Short and long flags seen, by canonical name (value `true` or the option argument). */
|
|
72
|
+
flags: Map<string, AValue | true>
|
|
73
|
+
/** Repeated options (`-e x -e y`), in order. */
|
|
74
|
+
multi: Map<string, AValue[]>
|
|
75
|
+
operands: AValue[]
|
|
76
|
+
/** Arguments whose role could not be determined (unknown values that might be options). */
|
|
77
|
+
ambiguous: AValue[]
|
|
78
|
+
/** Flags not in the spec. */
|
|
79
|
+
unknownFlags: string[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ArgSpec {
|
|
83
|
+
/** Short flags without arguments, e.g. `'rfiv'`. */
|
|
84
|
+
short?: string
|
|
85
|
+
/** Short flags taking an argument, e.g. `'tS'`. */
|
|
86
|
+
shortWithArg?: string
|
|
87
|
+
/**
|
|
88
|
+
* Long options: name → takes an argument? A number is how many arguments
|
|
89
|
+
* it takes (`--cropOffset y x`: 2; the last one is recorded). Values may also
|
|
90
|
+
* be an alias (`'=r'` maps to short `r`).
|
|
91
|
+
*/
|
|
92
|
+
long?: Record<string, boolean | number | string>
|
|
93
|
+
/** Short flags (in `shortWithArg`) taking more than one argument: `{ c: 2 }`. The last one is recorded. */
|
|
94
|
+
arity?: Record<string, number>
|
|
95
|
+
/** Single-dash long options (`-layout`, `-show_entries x`): `-word` is looked up in `long` first, then parsed as short flags. */
|
|
96
|
+
longSingle?: boolean
|
|
97
|
+
/** Stop option parsing at the first operand (like POSIX getopt; default false = GNU permutation). */
|
|
98
|
+
posix?: boolean
|
|
99
|
+
/** Accept `-<digits>` (`head -20`) as this flag with the digits as its value. */
|
|
100
|
+
numeric?: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface BehaviorHelpers {
|
|
104
|
+
/** Resolve a (possibly relative) path argument against the invocation's cwd. */
|
|
105
|
+
resolve(p: AValue): AValue
|
|
106
|
+
/** GNU-style argument parsing over abstract values. */
|
|
107
|
+
parseArgs(args: AValue[], spec: ArgSpec): ParsedArgs
|
|
108
|
+
/** Analyze a nested command (xargs, find -exec, env, timeout, sudo...). */
|
|
109
|
+
subcommand(argv: AValue[], opts?: { stdin?: StdinKind; cwd?: AValue }): BehaviorResult
|
|
110
|
+
/** Analyze a shell script string (bash -c, sh -c, eval); `info.file` names the script file it was read from. */
|
|
111
|
+
script(source: AValue, positional?: AValue[], info?: ScriptInfo): BehaviorResult
|
|
112
|
+
/** Home directory, if known. */
|
|
113
|
+
home(): AValue | undefined
|
|
114
|
+
/**
|
|
115
|
+
* Hint what an argument *is* when no effect says so: a search pattern
|
|
116
|
+
* (grep, find -name) or program text (sed/awk/jq programs, `python -c`).
|
|
117
|
+
* Used for display (syntax highlighting, summaries); no effect on policy.
|
|
118
|
+
*/
|
|
119
|
+
role(value: AValue, role: ArgRole): void
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What an argument is, for display. `dir`/`file` come from effects;
|
|
124
|
+
* `pattern`/`code`/`subcommand` from {@link BehaviorHelpers.role} (and a
|
|
125
|
+
* table of tools with subcommands in roles.ts).
|
|
126
|
+
*/
|
|
127
|
+
export type ArgRole = 'dir' | 'file' | 'pattern' | 'code' | 'cmd' | 'subcommand'
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mash — a bash virtual machine for agent harnesses.
|
|
3
|
+
*
|
|
4
|
+
* - `parse` → span-preserving AST (`./parser/ast.ts`)
|
|
5
|
+
* - `Machine.analyze` → static effect analysis and pre-flight policy verdict
|
|
6
|
+
* - `Machine.run` → policy-checked streaming execution with trace events and hooks
|
|
7
|
+
* - behavior definitions → predicted effects of executables (`./behaviors`)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export * as AST from './parser/ast.ts'
|
|
11
|
+
export { parse, ParseError, lineColumn, literalOf, staticWordValue } from './parser/parser.ts'
|
|
12
|
+
export { Machine, type MachineOptions, type RunOptions, type RunResult } from './machine.ts'
|
|
13
|
+
export * from './trace/events.ts'
|
|
14
|
+
export type { Channel, ChannelKind, DataListener } from './vm/io.ts'
|
|
15
|
+
export * from './policy/policy.ts'
|
|
16
|
+
export * from './policy/effects.ts'
|
|
17
|
+
export * from './behaviors/index.ts'
|
|
18
|
+
export * from './analysis/value.ts'
|
|
19
|
+
export { analyzeScript, analyzeSource, type AnalysisReport, type CommandSite, type Violation, type VariableInfo, type AnalyzeOptions } from './analysis/analyze.ts'
|
|
20
|
+
export { defaultSpawn, type SpawnFn, type SpawnRequest } from './vm/external.ts'
|
|
21
|
+
export type { FdPassing } from './vm/fifo.ts'
|
|
22
|
+
export { ProgressTracker, type ProgressSnapshot } from './trace/progress.ts'
|
|
23
|
+
export { format, printScript, type FormatOptions, type FormatResult } from './printer/print.ts'
|
|
24
|
+
export { formatHtml, printScriptHtml, attachHtmlHandlers, HTML_CSS, HTML_SCRIPT, type HtmlOptions, type HtmlResult } from './printer/html.ts'
|
|
25
|
+
export { summarize, summarizeScript, type SummaryOptions, type SummaryResult } from './printer/summary.ts'
|
|
26
|
+
export { argRoles, lexicalRole } from './behaviors/roles.ts'
|
|
27
|
+
export type { ArgRole } from './behaviors/types.ts'
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter for the DeepSea / DeepSeek agent harness (the `deepsea-harness` repo).
|
|
3
|
+
*
|
|
4
|
+
* Dependency-free: the harness types are mirrored structurally so this file
|
|
5
|
+
* can be copied into a harness package (e.g. a `dsh-bash-mash` executor that
|
|
6
|
+
* registers `ctx.shell`) without importing mash's internals.
|
|
7
|
+
*
|
|
8
|
+
* Layering with the harness's existing confinement:
|
|
9
|
+
* - mash checks every command (and redirection) against the policy BEFORE it
|
|
10
|
+
* runs, and rejects whole snippets that would definitely violate it;
|
|
11
|
+
* - each external command mash spawns is still wrapped by `ctx.sandbox.confine`
|
|
12
|
+
* (seatbelt / bwrap / landlock / ACL), so anything mash cannot see
|
|
13
|
+
* (an interpreter's own file writes) remains confined by the OS.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'node:child_process'
|
|
17
|
+
import type { Policy } from '../policy/policy.ts'
|
|
18
|
+
import { describeEffect } from '../policy/effects.ts'
|
|
19
|
+
import type { SpawnFn } from '../vm/external.ts'
|
|
20
|
+
import { Machine, type MachineOptions } from '../machine.ts'
|
|
21
|
+
import { ParseError } from '../parser/parser.ts'
|
|
22
|
+
import type { AnalysisReport } from '../analysis/analyze.ts'
|
|
23
|
+
import type { TraceEvent } from '../trace/events.ts'
|
|
24
|
+
|
|
25
|
+
/** Mirrors `SandboxExecutionPolicy` from `@deepseek-ai/dsh-sandbox`. */
|
|
26
|
+
export interface HarnessSandboxPolicy {
|
|
27
|
+
mode: 'read-only' | 'workspace-write' | 'danger-full-access'
|
|
28
|
+
workspaceRoot: string
|
|
29
|
+
additionalWritableRoots?: readonly string[]
|
|
30
|
+
sessionId?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Mirrors `SandboxProvider.confine` (the `ctx.sandbox` service). */
|
|
34
|
+
export type ConfineFn = (argv: readonly string[], policy: HarnessSandboxPolicy & { mode: 'read-only' | 'workspace-write' }, signal?: AbortSignal) => Promise<{ argv: string[] }>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Map the harness policy onto a mash policy, with optional mash-only
|
|
38
|
+
* restrictions. Harness defaults: `rm -f` of a path that does not exist is a
|
|
39
|
+
* no-op, not a violation (`missingDeletes: 'allow'`; existence is re-checked
|
|
40
|
+
* at runtime just before each delete).
|
|
41
|
+
*/
|
|
42
|
+
export function fromHarnessPolicy(p: HarnessSandboxPolicy, extra: Partial<Policy> = {}): Policy {
|
|
43
|
+
return {
|
|
44
|
+
mode: p.mode,
|
|
45
|
+
workspaceRoot: p.workspaceRoot,
|
|
46
|
+
additionalWritableRoots: p.additionalWritableRoots,
|
|
47
|
+
writableTemp: true,
|
|
48
|
+
missingDeletes: 'allow',
|
|
49
|
+
...extra,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A spawn function that confines every external command through the
|
|
55
|
+
* harness sandbox provider (per command, instead of once around `bash -c`).
|
|
56
|
+
*/
|
|
57
|
+
export function confiningSpawn(confine: ConfineFn, policy: HarnessSandboxPolicy, signal?: AbortSignal): SpawnFn {
|
|
58
|
+
return async (req) => {
|
|
59
|
+
if (policy.mode === 'danger-full-access') {
|
|
60
|
+
return spawn(req.file, req.args, { argv0: req.argv0, cwd: req.cwd, env: req.env, stdio: req.stdio })
|
|
61
|
+
}
|
|
62
|
+
const { argv } = await confine([req.file, ...req.args], { ...policy, mode: policy.mode }, signal)
|
|
63
|
+
return spawn(argv[0], argv.slice(1), { cwd: req.cwd, env: req.env, stdio: req.stdio })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PreflightResult {
|
|
68
|
+
/** `reject`: refuse the tool call; `check-at-runtime`: run with per-step enforcement; `allow`. */
|
|
69
|
+
verdict: AnalysisReport['verdict']
|
|
70
|
+
/** Model-facing explanation (one line per definite violation). */
|
|
71
|
+
message: string
|
|
72
|
+
report: AnalysisReport
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Static pre-flight for the `bash` tool: call before executing a snippet and
|
|
77
|
+
* return `message` as the tool error when `verdict === 'reject'`.
|
|
78
|
+
*/
|
|
79
|
+
export function preflight(command: string, opts: { cwd: string; policy: HarnessSandboxPolicy; env?: Record<string, string>; extra?: Partial<Policy> }): PreflightResult {
|
|
80
|
+
const m = new Machine({ cwd: opts.cwd, env: opts.env, policy: fromHarnessPolicy(opts.policy, opts.extra) })
|
|
81
|
+
let report: AnalysisReport
|
|
82
|
+
try {
|
|
83
|
+
report = m.analyze(command)
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (!(e instanceof ParseError)) throw e
|
|
86
|
+
// never block on mash's own parsing gaps
|
|
87
|
+
const r = emptyReport()
|
|
88
|
+
r.reasons.push(`not analyzed: ${e.message}`)
|
|
89
|
+
return { verdict: 'check-at-runtime', message: '', report: r }
|
|
90
|
+
}
|
|
91
|
+
const message = report.verdict === 'reject'
|
|
92
|
+
? `This command was not run: it would definitely violate the ${opts.policy.mode} sandbox policy.\n${report.reasons.map((r) => `- ${r}`).join('\n')}`
|
|
93
|
+
: report.verdict === 'check-at-runtime'
|
|
94
|
+
? `Some steps can only be checked while running:\n${report.violations.slice(0, 8).map((v) => `- line ${v.line}: ${v.effect} (${v.reach})`).join('\n')}`
|
|
95
|
+
: ''
|
|
96
|
+
return { verdict: report.verdict, message, report }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Mirrors the parts of `ShellRunResult` a tool renders. */
|
|
100
|
+
export interface HarnessRunResult {
|
|
101
|
+
exitCode: number | null
|
|
102
|
+
signal: NodeJS.Signals | null
|
|
103
|
+
timedOut: boolean
|
|
104
|
+
aborted: boolean
|
|
105
|
+
timeoutMs: number
|
|
106
|
+
stdout: { text: string; truncated: boolean }
|
|
107
|
+
stderr: { text: string; truncated: boolean }
|
|
108
|
+
sandbox: { mode: HarnessSandboxPolicy['mode']; denied: boolean }
|
|
109
|
+
/** mash extras: pre-flight report and runtime denials. */
|
|
110
|
+
mash: { preflight: AnalysisReport; denials: { argv: string[]; reason: string }[] }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface HarnessRunOptions {
|
|
114
|
+
command: string
|
|
115
|
+
workdir: string
|
|
116
|
+
timeoutMs: number
|
|
117
|
+
policy: HarnessSandboxPolicy
|
|
118
|
+
env?: Record<string, string>
|
|
119
|
+
stdin?: string
|
|
120
|
+
signal?: AbortSignal
|
|
121
|
+
stdoutMaxBytes?: number
|
|
122
|
+
confine?: ConfineFn
|
|
123
|
+
/** Live trace events (tool-card progress, stream previews). */
|
|
124
|
+
onEvent?: (e: TraceEvent) => void
|
|
125
|
+
/** Machine options to override (hooks, denyUnknown, ...). */
|
|
126
|
+
machine?: Partial<MachineOptions>
|
|
127
|
+
/**
|
|
128
|
+
* When mash cannot parse the snippet (unsupported syntax such as extglob):
|
|
129
|
+
* `bash` runs it with real (confined) bash instead — the current harness
|
|
130
|
+
* behavior; `error` returns a syntax error. Default `bash`.
|
|
131
|
+
*/
|
|
132
|
+
onParseError?: 'bash' | 'error'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Run a `bash` tool call the way `ctx.shell.execute(spec).result()` would,
|
|
137
|
+
* but through mash: static pre-flight, per-command enforcement, optional
|
|
138
|
+
* per-command OS confinement, and a live event stream.
|
|
139
|
+
*/
|
|
140
|
+
export async function runForHarness(o: HarnessRunOptions): Promise<HarnessRunResult> {
|
|
141
|
+
const max = o.stdoutMaxBytes ?? 1024 * 1024
|
|
142
|
+
const out: Uint8Array[] = []
|
|
143
|
+
const err: Uint8Array[] = []
|
|
144
|
+
let outBytes = 0
|
|
145
|
+
let errBytes = 0
|
|
146
|
+
const ac = new AbortController()
|
|
147
|
+
const timer = setTimeout(() => ac.abort(), o.timeoutMs)
|
|
148
|
+
const onAbort = (): void => ac.abort()
|
|
149
|
+
o.signal?.addEventListener('abort', onAbort, { once: true })
|
|
150
|
+
const machine = new Machine({
|
|
151
|
+
cwd: o.workdir,
|
|
152
|
+
env: o.env ?? (process.env as Record<string, string>),
|
|
153
|
+
policy: fromHarnessPolicy(o.policy),
|
|
154
|
+
stdin: o.stdin ? (async function* () { yield o.stdin! })() : undefined,
|
|
155
|
+
stdout: (c) => {
|
|
156
|
+
outBytes += c.length
|
|
157
|
+
if (outBytes <= max) out.push(c)
|
|
158
|
+
},
|
|
159
|
+
stderr: (c) => {
|
|
160
|
+
errBytes += c.length
|
|
161
|
+
if (errBytes <= max) err.push(c)
|
|
162
|
+
},
|
|
163
|
+
spawn: o.confine ? confiningSpawn(o.confine, o.policy, ac.signal) : undefined,
|
|
164
|
+
background: 'detach',
|
|
165
|
+
...o.machine,
|
|
166
|
+
})
|
|
167
|
+
if (o.onEvent) machine.on(o.onEvent)
|
|
168
|
+
let script: ReturnType<Machine['parse']>
|
|
169
|
+
try {
|
|
170
|
+
script = machine.parse(o.command)
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (!(e instanceof ParseError)) throw e
|
|
173
|
+
clearTimeout(timer)
|
|
174
|
+
if (o.onParseError === 'error') {
|
|
175
|
+
return {
|
|
176
|
+
exitCode: 2, signal: null, timedOut: false, aborted: false, timeoutMs: o.timeoutMs,
|
|
177
|
+
stdout: { text: '', truncated: false }, stderr: { text: `mash: syntax error: ${e.message}\n`, truncated: false },
|
|
178
|
+
sandbox: { mode: o.policy.mode, denied: false }, mash: { preflight: emptyReport(), denials: [] },
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return runRealBash(o, e)
|
|
182
|
+
}
|
|
183
|
+
const report = machine.analyze(script)
|
|
184
|
+
const dec = (cs: Uint8Array[]): string => Buffer.concat(cs).toString('utf8')
|
|
185
|
+
if (report.verdict === 'reject') {
|
|
186
|
+
clearTimeout(timer)
|
|
187
|
+
const text = `mash: refusing to run: the snippet would definitely violate the sandbox policy\n${report.reasons.map((r) => ` ✗ ${r}\n`).join('')}`
|
|
188
|
+
return {
|
|
189
|
+
exitCode: 126, signal: null, timedOut: false, aborted: false, timeoutMs: o.timeoutMs,
|
|
190
|
+
stdout: { text: '', truncated: false }, stderr: { text, truncated: false },
|
|
191
|
+
sandbox: { mode: o.policy.mode, denied: true }, mash: { preflight: report, denials: [] },
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const r = await machine.run(script, { signal: ac.signal })
|
|
195
|
+
clearTimeout(timer)
|
|
196
|
+
o.signal?.removeEventListener('abort', onAbort)
|
|
197
|
+
const timedOut = ac.signal.aborted && !o.signal?.aborted
|
|
198
|
+
return {
|
|
199
|
+
exitCode: r.status,
|
|
200
|
+
signal: null,
|
|
201
|
+
timedOut,
|
|
202
|
+
aborted: o.signal?.aborted ?? false,
|
|
203
|
+
timeoutMs: o.timeoutMs,
|
|
204
|
+
stdout: { text: dec(out), truncated: outBytes > max },
|
|
205
|
+
stderr: { text: dec(err), truncated: errBytes > max },
|
|
206
|
+
sandbox: { mode: o.policy.mode, denied: r.denials.length > 0 },
|
|
207
|
+
mash: { preflight: report, denials: r.denials },
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function emptyReport(): AnalysisReport {
|
|
212
|
+
return { commands: [], variables: [], functions: [], violations: [], verdict: 'check-at-runtime', reasons: [] }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Fallback: the snippet as one `bash -c`, confined as a whole (today's harness behavior). */
|
|
216
|
+
async function runRealBash(o: HarnessRunOptions, parseError: ParseError): Promise<HarnessRunResult> {
|
|
217
|
+
const argv = ['bash', '-c', o.command]
|
|
218
|
+
const confined = o.confine && o.policy.mode !== 'danger-full-access'
|
|
219
|
+
? (await o.confine(argv, { ...o.policy, mode: o.policy.mode }, o.signal)).argv
|
|
220
|
+
: argv
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
const child = spawn(confined[0], confined.slice(1), { cwd: o.workdir, env: o.env ?? process.env, stdio: ['pipe', 'pipe', 'pipe'], signal: o.signal })
|
|
223
|
+
const out: Buffer[] = []
|
|
224
|
+
const err: Buffer[] = []
|
|
225
|
+
let timedOut = false
|
|
226
|
+
const timer = setTimeout(() => {
|
|
227
|
+
timedOut = true
|
|
228
|
+
child.kill('SIGKILL')
|
|
229
|
+
}, o.timeoutMs)
|
|
230
|
+
child.stdout.on('data', (d: Buffer) => out.push(d))
|
|
231
|
+
child.stderr.on('data', (d: Buffer) => err.push(d))
|
|
232
|
+
child.stdin.on('error', () => {}) // EPIPE when bash exits without reading stdin
|
|
233
|
+
child.stdin.end(o.stdin ?? '')
|
|
234
|
+
child.on('error', () => {})
|
|
235
|
+
child.on('close', (code, signal) => {
|
|
236
|
+
clearTimeout(timer)
|
|
237
|
+
const report = emptyReport()
|
|
238
|
+
report.reasons.push(`not analyzed: ${parseError.message}`)
|
|
239
|
+
resolve({
|
|
240
|
+
exitCode: code, signal, timedOut, aborted: o.signal?.aborted ?? false, timeoutMs: o.timeoutMs,
|
|
241
|
+
stdout: { text: Buffer.concat(out).toString('utf8'), truncated: false },
|
|
242
|
+
stderr: { text: Buffer.concat(err).toString('utf8'), truncated: false },
|
|
243
|
+
sandbox: { mode: o.policy.mode, denied: false }, mash: { preflight: report, denials: [] },
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** One-line description of a trace event, for tool-card progress lines. */
|
|
250
|
+
export function describeEvent(e: TraceEvent): string | null {
|
|
251
|
+
switch (e.type) {
|
|
252
|
+
case 'command:start': return `$ ${e.argv.join(' ')}`
|
|
253
|
+
case 'command:denied': return `⛔ ${e.argv.join(' ')}: ${e.reason}`
|
|
254
|
+
case 'loop:iteration': return `↻ ${e.variable ? `${e.variable}=${e.value}` : `iteration ${e.iteration}`}`
|
|
255
|
+
default: return null
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export { describeEffect }
|