@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
@@ -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 }