@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,400 @@
1
+ /**
2
+ * Behavior definitions for developer tools: git, package managers,
3
+ * interpreters and build tools. Interpreters and build tools run arbitrary
4
+ * code, so they report `unknown` effects (and `complete: false`) — the OS
5
+ * sandbox stays responsible for confining them.
6
+ */
7
+
8
+ import * as fs from 'node:fs'
9
+ import * as path from 'node:path'
10
+ import type { AValue } from '../../analysis/value.ts'
11
+ import { concat, concrete, hole, known, knownPrefix } from '../../analysis/value.ts'
12
+ import type { Effect } from '../../policy/effects.ts'
13
+ import type { Behavior, BehaviorHelpers, BehaviorResult, Invocation } from '../types.ts'
14
+
15
+ const args = (inv: Invocation): AValue[] => inv.argv.slice(1)
16
+
17
+ /** Find the directory containing `marker` at or above `dir` (only for concrete paths). */
18
+ function findUp(dir: string, marker: string): string | undefined {
19
+ let cur = dir
20
+ for (;;) {
21
+ if (fs.existsSync(path.join(cur, marker))) return cur
22
+ const parent = path.dirname(cur)
23
+ if (parent === cur) return undefined
24
+ cur = parent
25
+ }
26
+ }
27
+
28
+ const GIT_READ = new Set([
29
+ 'status', 'log', 'diff', 'show', 'rev-parse', 'ls-files', 'ls-tree', 'blame', 'grep', 'describe', 'shortlog', 'reflog',
30
+ 'cat-file', 'symbolic-ref', 'for-each-ref', 'merge-base', 'name-rev', 'rev-list', 'help', 'version', 'check-ignore',
31
+ 'whatchanged', 'count-objects', 'show-ref', 'var', 'verify-commit', 'difftool', 'range-diff', 'annotate', 'cherry',
32
+ 'diff-tree', 'diff-index', 'diff-files', 'fsck', 'show-branch', 'check-attr', 'check-ref-format', 'check-mailmap', 'verify-pack',
33
+ 'verify-tag', 'patch-id', 'get-tar-commit-id', 'column', 'fmt-merge-msg', 'stripspace', 'interpret-trailers', 'show-index',
34
+ ])
35
+ const GIT_WRITE = new Set([
36
+ 'add', 'commit', 'checkout', 'switch', 'restore', 'reset', 'rm', 'mv', 'merge', 'rebase', 'cherry-pick', 'revert', 'stash',
37
+ 'init', 'apply', 'am', 'gc', 'update-index', 'notes', 'clean', 'worktree', 'prune', 'repack', 'update-ref', 'mergetool',
38
+ 'bisect', 'sparse-checkout', 'format-patch', 'commit-tree', 'read-tree', 'write-tree', 'hash-object', 'replace', 'restore',
39
+ 'maintenance', 'lfs',
40
+ ])
41
+ const GIT_NETWORK = new Set(['fetch', 'pull', 'push', 'ls-remote', 'clone', 'submodule', 'remote'])
42
+
43
+ const GIT_NESTED = new Set(['stash', 'remote', 'worktree', 'submodule', 'bisect', 'notes', 'lfs', 'sparse-checkout', 'maintenance', 'reflog'])
44
+
45
+ /** Subcommands that read stdin (patches, object lists, messages): never cut off from it. */
46
+ const GIT_STDIN = new Set(['apply', 'am', 'hash-object', 'patch-id', 'stripspace', 'interpret-trailers', 'fmt-merge-msg', 'mktree', 'mailinfo', 'mailsplit', 'fast-import', 'pack-objects', 'unpack-objects', 'index-pack', 'credential', 'cat-file', 'update-index'])
47
+
48
+ function gitBehavior(): Behavior {
49
+ return {
50
+ names: ['git'],
51
+ summary: 'version control',
52
+ analyze(inv, h) {
53
+ const r = analyzeGit(inv, h)
54
+ if (r.readsStdin === false) {
55
+ const a = args(inv).map((x) => concrete(x))
56
+ const sub = a.find((x) => x !== undefined && !x.startsWith('-'))
57
+ if ((sub && GIT_STDIN.has(sub)) || a.includes('--stdin') || a.includes('-') || a.includes('--batch') || a.includes('--batch-check')) return { ...r, readsStdin: 'maybe' }
58
+ }
59
+ return r
60
+ },
61
+ }
62
+ }
63
+
64
+ function analyzeGit(inv: Invocation, h: BehaviorHelpers): BehaviorResult {
65
+ {
66
+ {
67
+ const a = args(inv)
68
+ let cwd = inv.cwd
69
+ let i = 0
70
+ for (; i < a.length; i++) {
71
+ const s = concrete(a[i])
72
+ if (s === undefined) return { effects: [{ kind: 'unknown', detail: 'git with unknown arguments' }], complete: false, readsStdin: false }
73
+ if (s === '-C') {
74
+ const d = a[++i]
75
+ if (d) cwd = h.resolve(d)
76
+ continue
77
+ }
78
+ if (s === '-c' || s === '--git-dir' || s === '--work-tree' || s === '--namespace') {
79
+ i++
80
+ continue
81
+ }
82
+ if (s.startsWith('-')) continue
83
+ break
84
+ }
85
+ const sub = concrete(a[i] ?? known('')) ?? ''
86
+ if (a[i]) h.role(a[i], 'subcommand')
87
+ // second-level subcommands: `git stash pop`, `git remote add`, `git worktree list`
88
+ if (GIT_NESTED.has(sub)) {
89
+ const next = a.slice(i + 1).find((x) => !knownPrefix(x).startsWith('-'))
90
+ if (next && /^[a-z][a-z-]*$/.test(concrete(next) ?? '')) h.role(next, 'subcommand')
91
+ }
92
+ const rest = a.slice(i + 1)
93
+ const cwdStr = concrete(cwd)
94
+ const root = cwdStr ? findUp(cwdStr, '.git') : undefined
95
+ const repo: AValue = root ? known(root) : cwdStr ? hole('path', 'git repository root') : hole('path', 'git repository root')
96
+ const effects: Effect[] = []
97
+ const restOps = rest.filter((r) => !knownPrefix(r).startsWith('-'))
98
+ const hasFlag = (...f: string[]): boolean => rest.some((r) => f.includes(concrete(r) ?? ''))
99
+ if (sub === '' || sub === '--version' || sub === 'version' || sub === 'help') return { effects: [], readsStdin: false }
100
+ if (sub === 'config') {
101
+ const write = !hasFlag('--get', '--get-all', '--list', '-l', '--get-regexp', '--show-origin') && restOps.length >= 2
102
+ if (hasFlag('--global')) effects.push({ kind: write ? 'write' : 'read', path: { alts: [[...(h.home()?.alts[0] ?? ['~']), '/.gitconfig']] } })
103
+ else effects.push({ kind: write ? 'write' : 'read', path: { alts: [[...repo.alts[0], '/.git/config']] } })
104
+ return { effects, readsStdin: false }
105
+ }
106
+ if (sub === 'branch' || sub === 'tag') {
107
+ const write = hasFlag('-d', '-D', '-m', '-M', '-c', '-C', '-a', '-s', '-f', '--delete', '--move', '--copy', '--set-upstream-to', '-u') || (restOps.length > 0 && !hasFlag('--list', '-l', '--contains', '--merged', '--no-merged', '--points-at'))
108
+ effects.push({ kind: write ? 'write' : 'read', path: repo, recursive: true })
109
+ return { effects, readsStdin: false }
110
+ }
111
+ if (sub === 'stash' && (restOps.length === 0 ? false : ['list', 'show'].includes(concrete(restOps[0]) ?? ''))) {
112
+ return { effects: [{ kind: 'read', path: repo, recursive: true }], readsStdin: false }
113
+ }
114
+ if (sub === 'remote' && !['add', 'remove', 'rm', 'rename', 'set-url', 'set-head', 'prune', 'update'].includes(concrete(restOps[0] ?? known('')) ?? '')) {
115
+ return { effects: [{ kind: 'read', path: repo, recursive: true }], readsStdin: false }
116
+ }
117
+ if (sub === 'merge-tree') {
118
+ // --write-tree (the default in modern git) stores the merged trees as objects
119
+ effects.push({ kind: 'read', path: repo, recursive: true })
120
+ if (!hasFlag('--trivial-merge')) effects.push({ kind: 'write', path: { alts: [[...repo.alts[0], '/.git']] }, recursive: true, maybe: true })
121
+ return { effects, readsStdin: hasFlag('--stdin') }
122
+ }
123
+ if (sub === 'archive' || sub === 'bundle') {
124
+ effects.push({ kind: 'read', path: repo, recursive: true })
125
+ if (sub === 'archive') {
126
+ if (hasFlag('--remote') || rest.some((r) => knownPrefix(r).startsWith('--remote='))) effects.push({ kind: 'network', detail: 'git archive --remote' })
127
+ for (let j = 0; j < rest.length; j++) {
128
+ const s = concrete(rest[j]) ?? ''
129
+ if ((s === '-o' || s === '--output') && rest[j + 1]) effects.push({ kind: 'write', path: h.resolve(rest[j + 1]) })
130
+ else if (s.startsWith('--output=')) effects.push({ kind: 'write', path: h.resolve(known(s.slice('--output='.length))) })
131
+ }
132
+ return { effects, readsStdin: false }
133
+ }
134
+ const verb = concrete(restOps[0] ?? known('')) ?? ''
135
+ const file = restOps[1]
136
+ if (verb === 'create' && file && concrete(file) !== '-') effects.push({ kind: 'write', path: h.resolve(file) })
137
+ else if ((verb === 'verify' || verb === 'list-heads') && file) effects.push({ kind: 'read', path: h.resolve(file) })
138
+ else if (verb === 'unbundle') effects.push({ kind: 'write', path: repo, recursive: true })
139
+ else if (verb !== 'create') return { effects: [...effects, { kind: 'unknown', detail: `git bundle ${verb}` }], readsStdin: 'maybe', complete: false }
140
+ return { effects, readsStdin: verb === 'unbundle' }
141
+ }
142
+ if (sub === 'filter-branch' || sub === 'credential' || sub === 'filter-repo') {
143
+ // filters and credential helpers are shell commands
144
+ return { effects: [{ kind: 'write', path: repo, recursive: true }, { kind: 'unknown', detail: `git ${sub} runs commands` }], readsStdin: 'maybe', complete: false }
145
+ }
146
+ if (GIT_READ.has(sub)) {
147
+ effects.push({ kind: 'read', path: repo, recursive: true })
148
+ return { effects, readsStdin: false, stdout: sub === 'rev-parse' && hasFlag('--show-toplevel') ? { type: 'path', value: root ? known(root) : undefined } : undefined }
149
+ }
150
+ if (sub === 'clone') {
151
+ const cp = h.parseArgs(rest, {
152
+ short: 'lsqvn', shortWithArg: 'bojcu',
153
+ long: { depth: true, branch: '=b', origin: '=o', config: '=c', reference: true, 'reference-if-able': true, 'separate-git-dir': true, 'upload-pack': '=u', template: true, filter: true, jobs: '=j', 'shallow-since': true, 'shallow-exclude': true, 'bundle-uri': true, 'server-option': true, 'ref-format': true, recursive: false, 'recurse-submodules': true, 'single-branch': false, 'no-single-branch': false, 'no-tags': false, 'shallow-submodules': false, bare: false, mirror: false, quiet: false, verbose: false, progress: false, 'no-checkout': false, local: false, shared: false, dissociate: false, sparse: false, 'also-filter-submodules': false, 'remote-submodules': false, 'no-hardlinks': false, 'no-reject-shallow': false, 'reject-shallow': false },
154
+ })
155
+ const [url, dir] = cp.operands
156
+ const dest = dir ? h.resolve(dir) : { alts: cwd.alts.map((c) => [...c, '/', hole('filename', 'repository name').alts[0][0]]) }
157
+ if (url) effects.push({ kind: 'network', target: url, detail: 'git clone' })
158
+ effects.push({ kind: 'write', path: dest, recursive: true })
159
+ const sep = cp.flags.get('separate-git-dir')
160
+ if (sep && sep !== true) effects.push({ kind: 'write', path: h.resolve(sep), recursive: true })
161
+ // --upload-pack runs a command
162
+ return { effects, readsStdin: false, complete: cp.unknownFlags.length === 0 && !cp.flags.has('u') }
163
+ }
164
+ if (sub === 'init') {
165
+ effects.push({ kind: 'write', path: restOps[0] ? h.resolve(restOps[0]) : cwd, recursive: true })
166
+ return { effects, readsStdin: false }
167
+ }
168
+ if (GIT_NETWORK.has(sub)) {
169
+ effects.push({ kind: 'network', detail: `git ${sub}` })
170
+ if (sub !== 'push' && sub !== 'ls-remote') effects.push({ kind: 'write', path: repo, recursive: true })
171
+ else effects.push({ kind: 'read', path: repo, recursive: true })
172
+ if (sub === 'push') effects.push({ kind: 'write', path: { alts: [[...repo.alts[0], '/.git']] }, recursive: true })
173
+ return { effects, readsStdin: false }
174
+ }
175
+ if (GIT_WRITE.has(sub)) {
176
+ effects.push({ kind: 'write', path: repo, recursive: true })
177
+ if (sub === 'clean' && (hasFlag('-f', '-ff', '-fd', '-fdx', '-df', '-xdf', '--force'))) effects.push({ kind: 'delete', path: cwd, recursive: true })
178
+ // `--fixup=<commit>` needs no message (`--fixup=amend:`/`reword:` open an editor)
179
+ const fixup = rest.some((r) => /^--fixup=(?!amend:|reword:)./.test(concrete(r) ?? ''))
180
+ if (sub === 'commit' && !fixup && !hasFlag('-m', '-F', '--message', '--file', '-C', '--no-edit') && !rest.some((r) => /^-[a-zA-Z]*m|^--(message|file|reuse-message)=/.test(knownPrefix(r)))) {
181
+ effects.push({ kind: 'unknown', detail: 'git commit may open an editor' })
182
+ return { effects, readsStdin: false, complete: false }
183
+ }
184
+ return { effects, readsStdin: false }
185
+ }
186
+ return { effects: [{ kind: 'read', path: repo, recursive: true }, { kind: 'unknown', detail: `git ${sub}` }], complete: false, readsStdin: 'maybe' }
187
+ }
188
+ }
189
+ }
190
+
191
+ const PM_INSTALL = new Set(['install', 'i', 'ci', 'add', 'remove', 'rm', 'uninstall', 'un', 'update', 'up', 'upgrade', 'link', 'unlink', 'prune', 'dedupe', 'rebuild', 'import'])
192
+ const PM_READ = new Set(['ls', 'list', 'view', 'info', 'show', 'outdated', 'why', 'explain', 'audit', 'search', 'config', 'get', 'root', 'bin', 'prefix', 'whoami', 'help', '-v', '--version', 'version', '-h', '--help', 'doctor', 'licenses'])
193
+
194
+ function packageManager(): Behavior {
195
+ return {
196
+ names: ['npm', 'pnpm', 'yarn', 'bun', 'npx', 'pnpx', 'bunx', 'uvx'],
197
+ summary: 'JavaScript package managers',
198
+ analyze(inv, h) {
199
+ const a = args(inv)
200
+ if (inv.name === 'npx' || inv.name === 'pnpx' || inv.name === 'bunx' || inv.name === 'uvx') {
201
+ // npx [-y] [-p pkg] <binary> args…: the binary is analyzed like any command
202
+ let j = 0
203
+ for (; j < a.length; j++) {
204
+ const s = concrete(a[j])
205
+ if (s === undefined || !s.startsWith('-')) break
206
+ if (['-p', '--package', '--from', '--with', '--python', '--with-requirements', '--index-url', '--index'].includes(s)) j++
207
+ }
208
+ const net: Effect = { kind: 'network', detail: `${inv.name} may download packages` }
209
+ if (j >= a.length || concrete(a[j]) === undefined) return { effects: [net, { kind: 'unknown', detail: `${inv.name} runs package binaries` }], complete: false, readsStdin: 'maybe' }
210
+ const sub = h.subcommand(a.slice(j))
211
+ return { ...sub, effects: [net, ...sub.effects], complete: false }
212
+ }
213
+ let i = 0
214
+ let dir = inv.cwd
215
+ for (; i < a.length; i++) {
216
+ const s = concrete(a[i])
217
+ if (s === undefined) break
218
+ if (s === '-C' || s === '--dir' || s === '--prefix' || s === '--cwd') {
219
+ const d = a[++i]
220
+ if (d) dir = h.resolve(d)
221
+ continue
222
+ }
223
+ if (s === '--filter' || s === '-F' || s === '-w' || s === '--workspace') {
224
+ i++
225
+ continue
226
+ }
227
+ if (s.startsWith('-') && !PM_READ.has(s)) continue
228
+ break
229
+ }
230
+ const sub = concrete(a[i] ?? known('')) ?? (a[i] ? '?' : '')
231
+ if (a[i] && sub !== '?' && !sub.startsWith('-')) {
232
+ h.role(a[i], 'subcommand')
233
+ // `npm run build`, `pnpm exec tsc`: the script or binary is a subcommand too
234
+ if (['run', 'run-script', 'exec', 'x', 'dlx'].includes(sub) && a[i + 1] && !knownPrefix(a[i + 1]).startsWith('-')) h.role(a[i + 1], 'subcommand')
235
+ }
236
+ const global = a.some((x) => ['-g', '--global'].includes(concrete(x) ?? ''))
237
+ if (PM_READ.has(sub)) return { effects: [{ kind: 'read', path: dir, recursive: true, maybe: true }, ...(sub === 'view' || sub === 'info' || sub === 'outdated' || sub === 'audit' || sub === 'search' ? [{ kind: 'network', detail: `${inv.name} ${sub}` } as Effect] : [])], readsStdin: false }
238
+ if (PM_INSTALL.has(sub) || (sub === '' && inv.name !== 'npm')) {
239
+ const effects: Effect[] = [
240
+ { kind: 'network', detail: `${inv.name} ${sub || 'install'}` },
241
+ { kind: 'write', path: global ? hole('path', 'global package directory') : dir, recursive: true },
242
+ { kind: 'unknown', detail: 'install scripts may run arbitrary code' },
243
+ ]
244
+ return { effects, readsStdin: false, complete: false }
245
+ }
246
+ // run / exec / test / start / custom scripts
247
+ return { effects: [{ kind: 'read', path: dir, recursive: true, maybe: true }, { kind: 'unknown', detail: `${inv.name} ${sub} runs project scripts` }], readsStdin: 'maybe', complete: false }
248
+ },
249
+ }
250
+ }
251
+
252
+ function interpreter(names: string[], codeFlags: string[], argFlags: string[]): Behavior {
253
+ return {
254
+ names,
255
+ summary: 'language interpreter',
256
+ analyze(inv, h) {
257
+ const a = args(inv)
258
+ for (let i = 0; i < a.length; i++) {
259
+ const s = concrete(a[i])
260
+ if (s === undefined) break
261
+ if (s === '--version' || s === '-V' || s === '-version' || (s === '-v' && names.includes('node'))) return { effects: [], readsStdin: false }
262
+ if (codeFlags.includes(s)) {
263
+ if (a[i + 1]) h.role(a[i + 1], 'code')
264
+ return { effects: [{ kind: 'unknown', detail: `${inv.name} ${s} runs inline code` }], readsStdin: 'maybe', complete: false }
265
+ }
266
+ if (s === '-m' && names.includes('python3')) {
267
+ const mod = concrete(a[i + 1] ?? known('')) ?? ''
268
+ // `python3 -m pip install x`: the module and its first operand read as subcommands
269
+ if (a[i + 1]) h.role(a[i + 1], 'subcommand')
270
+ const op = a.slice(i + 2).find((x) => !knownPrefix(x).startsWith('-'))
271
+ if (op && mod === 'pip' && /^[a-z][a-z-]*$/.test(concrete(op) ?? '')) h.role(op, 'subcommand')
272
+ if (mod === 'pip') {
273
+ const sub = concrete(a[i + 2] ?? known('')) ?? ''
274
+ if (['install', 'uninstall', 'download', 'wheel'].includes(sub)) return { effects: [{ kind: 'network', detail: 'pip' }, { kind: 'write', path: hole('path', 'python site-packages') }, { kind: 'unknown', detail: 'package build scripts' }], complete: false, readsStdin: false }
275
+ return { effects: [{ kind: 'read', path: hole('path', 'python site-packages') }], readsStdin: false }
276
+ }
277
+ if (mod === 'venv') {
278
+ const d = a.slice(i + 2).find((x) => !knownPrefix(x).startsWith('-'))
279
+ return { effects: d ? [{ kind: 'write', path: h.resolve(d), recursive: true }] : [], readsStdin: false }
280
+ }
281
+ if (mod === 'json.tool') return { effects: a.slice(i + 2).filter((x) => !knownPrefix(x).startsWith('-')).map((f) => ({ kind: 'read' as const, path: h.resolve(f) })), readsStdin: true }
282
+ return { effects: [{ kind: 'unknown', detail: `python module ${mod}` }], readsStdin: 'maybe', complete: false }
283
+ }
284
+ if (argFlags.includes(s)) {
285
+ i++
286
+ continue
287
+ }
288
+ if (s.startsWith('-')) continue
289
+ if (s === '-') break
290
+ return { effects: [{ kind: 'read', path: h.resolve(a[i]) }, { kind: 'unknown', detail: `runs script ${s}` }], readsStdin: 'maybe', complete: false }
291
+ }
292
+ return { effects: [{ kind: 'unknown', detail: `${inv.name} reads a program from stdin` }], readsStdin: true, complete: false }
293
+ },
294
+ }
295
+ }
296
+
297
+ function buildTool(names: string[]): Behavior {
298
+ return {
299
+ names,
300
+ summary: 'build tool',
301
+ analyze(inv, h: BehaviorHelpers) {
302
+ const a = args(inv)
303
+ if (a.some((x) => ['--version', '-v', 'version', '--help'].includes(concrete(x) ?? '')) && a.length === 1) return { effects: [], readsStdin: false }
304
+ let dir = inv.cwd
305
+ for (let i = 0; i < a.length; i++) if (['-C', '--directory'].includes(concrete(a[i]) ?? '') && a[i + 1]) dir = h.resolve(a[i + 1])
306
+ return { effects: [{ kind: 'read', path: dir, recursive: true, maybe: true }, { kind: 'write', path: dir, recursive: true, maybe: true }, { kind: 'unknown', detail: `${inv.name} runs build recipes` }], readsStdin: false, complete: false }
307
+ },
308
+ }
309
+ }
310
+
311
+ function systemPackageManager(names: string[], roots: string[]): Behavior {
312
+ return {
313
+ names,
314
+ summary: 'system package manager',
315
+ analyze(inv) {
316
+ const sub = concrete(args(inv).find((x) => !knownPrefix(x).startsWith('-')) ?? known('')) ?? ''
317
+ if (['install', 'upgrade', 'remove', 'uninstall', 'reinstall', 'update', 'tap', 'link', 'unlink', 'cleanup', 'autoremove', 'purge'].includes(sub)) {
318
+ return { effects: [{ kind: 'network', detail: `${inv.name} ${sub}` }, ...roots.map((r): Effect => ({ kind: 'write', path: known(r), recursive: true }))], readsStdin: false }
319
+ }
320
+ return { effects: roots.map((r): Effect => ({ kind: 'read', path: known(r), recursive: true })), readsStdin: false }
321
+ },
322
+ }
323
+ }
324
+
325
+ /** Where CLIs keep their credentials (relative to HOME), and the subcommands that print them. */
326
+ const CREDENTIAL_STORES: Record<string, { stores: string[]; secret: RegExp }> = {
327
+ gh: { stores: ['.config/gh'], secret: /^auth (token|status)/ },
328
+ glab: { stores: ['.config/glab-cli'], secret: /^(auth status|config get token)/ },
329
+ aws: { stores: ['.aws'], secret: /^(configure (get|list|export-credentials)|sts get-session-token|ecr get-login-password|ecr-public get-login-password|codeartifact get-authorization-token)/ },
330
+ gcloud: { stores: ['.config/gcloud'], secret: /^(auth (print-access-token|print-identity-token|application-default print-access-token)|config config-helper)/ },
331
+ az: { stores: ['.azure'], secret: /^account get-access-token/ },
332
+ kubectl: { stores: ['.kube'], secret: /^config view/ },
333
+ helm: { stores: ['.kube'], secret: /(?!)/ },
334
+ docker: { stores: ['.docker/config.json'], secret: /^(login|logout|credential)/ },
335
+ podman: { stores: ['.docker/config.json'], secret: /^(login|logout)/ },
336
+ heroku: { stores: ['.netrc'], secret: /^(auth:token|auth token)/ },
337
+ op: { stores: ['.config/op'], secret: /^(read|inject|item get|document get|run|signin|account get)/ },
338
+ doctl: { stores: ['.config/doctl'], secret: /^auth/ },
339
+ vault: { stores: ['.vault-token'], secret: /^(read|kv get|token lookup|print token|login)/ },
340
+ terraform: { stores: ['.terraform.d/credentials.tfrc.json'], secret: /^login/ },
341
+ tofu: { stores: ['.terraform.d/credentials.tfrc.json'], secret: /^login/ },
342
+ }
343
+
344
+ export const devtoolBehaviors: Behavior[] = [
345
+ gitBehavior(),
346
+ packageManager(),
347
+ interpreter(['python', 'python3', 'python2'], ['-c'], ['-W', '-X', '-Q']),
348
+ interpreter(['node', 'deno', 'tsx', 'ts-node'], ['-e', '--eval', '-p', '--print'], ['-r', '--require', '--import', '--loader']),
349
+ interpreter(['ruby'], ['-e'], ['-r', '-I']),
350
+ interpreter(['perl'], ['-e', '-E'], ['-I', '-M']),
351
+ interpreter(['php'], ['-r'], ['-d']),
352
+ interpreter(['java', 'kotlin', 'scala', 'groovy', 'jshell'], [], ['-cp', '-classpath', '--class-path', '-p', '--module-path', '-jar', '-D']),
353
+ interpreter(['lua', 'luajit', 'Rscript', 'julia'], ['-e', '--eval'], ['-l']),
354
+ buildTool(['make', 'gmake', 'cmake', 'ninja', 'cargo', 'go', 'mvn', 'gradle', 'gradlew', 'bazel', 'xcodebuild', 'swift', 'rustc', 'gcc', 'cc', 'clang', 'g++', 'clang++', 'tsc', 'vite', 'webpack', 'esbuild', 'vitest', 'jest', 'pytest', 'tox', 'swiftc', 'zig', 'ld', 'javac', 'kotlinc', 'svelte-check', 'svelte-kit', 'next', 'nuxt', 'astro', 'rollup', 'parcel', 'turbo', 'nx', 'playwright', 'mocha', 'ava', 'eslint', 'prettier', 'stylelint', 'xcodegen', 'tuist', 'bundle', 'rake', 'mix', 'dotnet', 'sbt', 'lein', 'stack', 'cabal', 'ghc', 'dune', 'nim', 'crystal', 'meson', 'scons', 'just', 'task']),
355
+ systemPackageManager(['brew'], ['/opt/homebrew', '/usr/local']),
356
+ systemPackageManager(['apt', 'apt-get', 'dpkg', 'yum', 'dnf', 'apk', 'pacman', 'zypper'], ['/usr', '/var/lib', '/etc']),
357
+ {
358
+ names: ['pip', 'pip3', 'uv', 'pipx', 'poetry', 'conda'],
359
+ summary: 'Python package managers',
360
+ analyze(inv, h) {
361
+ const sub = concrete(args(inv).find((x) => !knownPrefix(x).startsWith('-')) ?? known('')) ?? ''
362
+ if (['install', 'uninstall', 'add', 'remove', 'sync', 'lock', 'update', 'upgrade', 'download', 'venv', 'create'].includes(sub)) {
363
+ return { effects: [{ kind: 'network', detail: `${inv.name} ${sub}` }, { kind: 'write', path: inv.name === 'pip' || inv.name === 'pip3' ? hole('path', 'python site-packages') : inv.cwd, recursive: true }, { kind: 'unknown', detail: 'package build scripts' }], complete: false, readsStdin: false }
364
+ }
365
+ if (['run', 'exec', 'shell'].includes(sub)) return { effects: [{ kind: 'unknown', detail: `${inv.name} ${sub}` }], complete: false, readsStdin: 'maybe' }
366
+ return { effects: [{ kind: 'read', path: inv.cwd, recursive: true }], readsStdin: false }
367
+ },
368
+ },
369
+ {
370
+ names: ['docker', 'podman', 'container', 'docker-compose', 'kubectl', 'helm', 'terraform', 'tofu', 'pulumi', 'gcloud', 'aws', 'az', 'gh', 'glab', 'vercel', 'fly', 'flyctl', 'heroku', 'wrangler', 'firebase', 'supabase', 'stripe', 'op', 'doctl', 'vault'],
371
+ summary: 'cloud / container CLIs',
372
+ analyze(inv, h) {
373
+ const a = args(inv)
374
+ const sub = concrete(a.find((x) => !knownPrefix(x).startsWith('-')) ?? known('')) ?? ''
375
+ if (a.length === 1 && ['--version', '-v', 'version', 'help', '--help', '-h'].includes(concrete(a[0]) ?? '')) return { effects: [], readsStdin: false }
376
+ const effects: Effect[] = [{ kind: 'network', detail: `${inv.name} ${sub}` }]
377
+ // the credential store: read by (nearly) every call, definitely by the commands that print secrets
378
+ const cred = CREDENTIAL_STORES[inv.name]
379
+ if (cred) {
380
+ const words = a.map((x) => concrete(x) ?? '?').filter((w) => !w.startsWith('-')).join(' ')
381
+ const secret = cred.secret.test(words)
382
+ for (const store of cred.stores) {
383
+ const path = store.startsWith('/') ? known(store) : concat(h.home() ?? known('~'), known('/' + store))
384
+ effects.push({ kind: 'read', path, recursive: true, ...(secret ? {} : { maybe: true }) })
385
+ }
386
+ }
387
+ effects.push({ kind: 'unknown', detail: `${inv.name} ${sub} acts on remote or container state` })
388
+ return { effects, complete: false, readsStdin: 'maybe' }
389
+ },
390
+ },
391
+ {
392
+ names: ['open', 'xdg-open', 'osascript', 'say', 'pbcopy', 'pbpaste', 'systemctl', 'afplay', 'shortcuts', 'automator'],
393
+ summary: 'desktop / system integration',
394
+ analyze(inv) {
395
+ if (inv.name === 'pbpaste' || inv.name === 'say') return { effects: [], readsStdin: inv.name === 'say' }
396
+ if (inv.name === 'pbcopy') return { effects: [{ kind: 'process', detail: 'writes the clipboard' }], readsStdin: true }
397
+ return { effects: [{ kind: 'unknown', detail: `${inv.name} acts outside the shell` }], complete: false, readsStdin: false }
398
+ },
399
+ },
400
+ ]