@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,102 @@
1
+ /**
2
+ * Effects: what a command does to the world, as predicted by a behavior
3
+ * definition or derived from redirections. Paths are absolute abstract values
4
+ * (concrete at runtime).
5
+ */
6
+
7
+ import type { AValue } from '../analysis/value.ts'
8
+ import { describe } from '../analysis/value.ts'
9
+
10
+ /**
11
+ * Provenance of a nested effect: the inner command it comes from, for effects
12
+ * of `bash -c`, script files, `eval`, `source` and `trap` actions. A list,
13
+ * outermost hop first (`bash -c "bash s.sh"` → [`bash s.sh`, s.sh line 3]).
14
+ */
15
+ export interface Via {
16
+ /** Source text of the inner command. */
17
+ text: string
18
+ /** Line of the inner command in its script (1-based). */
19
+ line: number
20
+ /** The script file, when the code came from one. */
21
+ file?: string
22
+ }
23
+
24
+ interface EffectBase {
25
+ /** The command name the effect was predicted for (behavior name, `redirect`). */
26
+ source?: string
27
+ /** Where a nested effect came from (see {@link Via}). */
28
+ via?: Via[]
29
+ }
30
+
31
+ /**
32
+ * `maybe`: the command might do this (e.g. a build tool writing into its
33
+ * directory, or a conditional command in a nested script) — a possible
34
+ * effect never yields a definite policy `deny`, only `unknown`, so it cannot
35
+ * cause a pre-flight rejection on its own.
36
+ */
37
+ export type Effect = EffectBase & (
38
+ /** `names`: only names and metadata are read (`find`, `ls -R`, `du`), never file contents. */
39
+ | { kind: 'read'; path: AValue; recursive?: boolean; maybe?: boolean; names?: boolean }
40
+ | { kind: 'write'; path: AValue; recursive?: boolean; append?: boolean; maybe?: boolean }
41
+ /** `ifExists`: a missing path is not an error and nothing happens (`rm -f`). */
42
+ | { kind: 'delete'; path: AValue; recursive?: boolean; maybe?: boolean; ifExists?: boolean }
43
+ | { kind: 'network'; target?: AValue; detail: string; maybe?: boolean }
44
+ | { kind: 'exec'; argv: AValue[]; maybe?: boolean }
45
+ | { kind: 'chdir'; path: AValue }
46
+ | { kind: 'process'; detail: string }
47
+ /** The command's effects could not be determined (no behavior definition, or opaque arguments). */
48
+ | { kind: 'unknown'; detail: string; maybe?: boolean }
49
+ )
50
+
51
+ export function describeEffect(e: Effect): string {
52
+ return describeEffectOnly(e) + viaSuffix(e)
53
+ }
54
+
55
+ function describeEffectOnly(e: Effect): string {
56
+ const r = ((e as { recursive?: boolean }).recursive ? ' (recursive)' : '') + ((e as { maybe?: boolean }).maybe ? ' (possibly)' : '')
57
+ switch (e.kind) {
58
+ case 'read': return `read ${describe(e.path)}${r}`
59
+ case 'write': return `${e.append ? 'append' : 'write'} ${describe(e.path)}${r}`
60
+ case 'delete': return `delete ${describe(e.path)}${r}`
61
+ case 'network': return `network ${e.target ? describe(e.target) : ''} ${e.detail}${r}`.replace(/ +/g, ' ').trim()
62
+ case 'exec': return `exec ${e.argv.map(describe).join(' ')}${r}`
63
+ case 'chdir': return `cd ${describe(e.path)}`
64
+ case 'process': return `process ${e.detail}`
65
+ case 'unknown': return `unknown: ${e.detail}${r}`
66
+ }
67
+ }
68
+
69
+ const MAX_VIA_TEXT = 60
70
+
71
+ /** `` `rm -rf /opt/x` (s.sh:3) → `touch y` `` */
72
+ export function describeVia(via: readonly Via[]): string {
73
+ return via
74
+ .map((v) => {
75
+ const one = v.text.replace(/\s*\n\s*/g, '; ')
76
+ const t = one.length > MAX_VIA_TEXT ? one.slice(0, MAX_VIA_TEXT - 1) + '…' : one
77
+ const where = v.file ? ` (${v.file}:${v.line})` : v.line > 1 ? ` (line ${v.line})` : ''
78
+ return `\`${t}\`${where}`
79
+ })
80
+ .join(' → ')
81
+ }
82
+
83
+ /** ` (via …)` for nested effects, else empty. */
84
+ export function viaSuffix(e: Effect): string {
85
+ return e.via && e.via.length > 0 ? ` (via ${describeVia(e.via)})` : ''
86
+ }
87
+
88
+ /** Prepend a provenance hop to an effect (a copy). */
89
+ export function withVia(e: Effect, hop: Via): Effect {
90
+ return { ...e, via: [hop, ...(e.via ?? [])] }
91
+ }
92
+
93
+ /** The effect as a possible one: a conditional command in a nested script. */
94
+ export function asPossible(e: Effect): Effect {
95
+ switch (e.kind) {
96
+ case 'chdir':
97
+ case 'process':
98
+ return e
99
+ default:
100
+ return { ...e, maybe: true }
101
+ }
102
+ }
@@ -0,0 +1,578 @@
1
+ /**
2
+ * Sandbox policy and effect checking.
3
+ *
4
+ * The file vocabulary mirrors the DeepSea harness (`SandboxExecutionPolicy`
5
+ * in dsh-sandbox): `read-only`, `workspace-write` (workspace root, extra
6
+ * roots, temp areas) and `danger-full-access`. mash adds optional network,
7
+ * command and read restrictions that the OS sandbox cannot express.
8
+ *
9
+ * Checking works on abstract paths: a verdict is `deny` only when EVERY
10
+ * possible concretisation violates the policy, `allow` only when every one is
11
+ * permitted, and `unknown` otherwise. Unknown substrings that may contain
12
+ * `/` or `..` therefore never produce a definite verdict — the runtime check
13
+ * on the concrete value decides.
14
+ */
15
+
16
+ import * as fs from 'node:fs'
17
+ import * as os from 'node:os'
18
+ import * as path from 'node:path'
19
+ import type { AValue, Seg } from '../analysis/value.ts'
20
+ import { describe, isHole, isNameLike, isSlashFree } from '../analysis/value.ts'
21
+ import type { Effect } from './effects.ts'
22
+ import { describeEffect } from './effects.ts'
23
+
24
+ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
25
+
26
+ export interface Policy {
27
+ mode: SandboxMode
28
+ /** Absolute workspace root (writable under `workspace-write`). */
29
+ workspaceRoot: string
30
+ additionalWritableRoots?: readonly string[]
31
+ /** Temp areas are writable under `workspace-write` (default true). */
32
+ writableTemp?: boolean
33
+ /**
34
+ * Network access (default `allow`; the harness OS sandbox does not govern
35
+ * network). `{ allow: hosts }` permits only connections to those hosts:
36
+ * `example.com` (exactly), `*.example.com` (any subdomain), `*` (any).
37
+ */
38
+ network?: 'allow' | 'deny' | { allow: readonly string[] }
39
+ /** Commands without a behavior definition (default `allow`: the OS sandbox still confines them). */
40
+ unknownCommands?: 'allow' | 'deny'
41
+ /** Command names (basenames) that may never run. */
42
+ deniedCommands?: readonly string[]
43
+ /** Directories (or files) that may not be read, in every mode (e.g. `~/.ssh`). */
44
+ readDeniedRoots?: readonly string[]
45
+ /**
46
+ * Read policy coverage for credential stores: also deny reading the
47
+ * locations in {@link credentialReadRoots} (`~/.ssh`, `~/.aws`, `~/.config/gh`,
48
+ * keychains, …). Opt-in (default false).
49
+ */
50
+ readDenyCredentials?: boolean
51
+ /** Home directory for {@link credentialReadRoots} (default `os.homedir()`). */
52
+ home?: string
53
+ /**
54
+ * `rm -f` of a path that does not exist: `deny` (default; the attempt is the
55
+ * violation, as in path-based OS sandboxes) or `allow` (nothing happens, so
56
+ * nothing is violated; statically, when the path is missing now and the
57
+ * snippet does not create it).
58
+ */
59
+ missingDeletes?: 'deny' | 'allow'
60
+ }
61
+
62
+ /**
63
+ * Where credentials live: private keys, cloud and CLI tokens, keychains and
64
+ * password stores. Files and directories, relative to `home` unless absolute.
65
+ */
66
+ export const CREDENTIAL_LOCATIONS: readonly string[] = [
67
+ '.ssh', '.gnupg', '.aws', '.azure', '.config/gcloud', '.config/gh', '.config/hub', '.config/op', '.config/doctl',
68
+ '.kube', '.docker/config.json', '.netrc', '.git-credentials', '.npmrc', '.yarnrc.yml', '.pypirc', '.gem/credentials',
69
+ '.cargo/credentials', '.cargo/credentials.toml', '.terraform.d/credentials.tfrc.json', '.vault-token', '.password-store',
70
+ '.local/share/keyrings', 'Library/Keychains', '/Library/Keychains', '/etc/shadow', '/etc/gshadow', '/private/etc/master.passwd',
71
+ ]
72
+
73
+ /**
74
+ * Non-secret files inside credential locations that stay readable (agents
75
+ * debug ssh setups): public keys, `config`, `known_hosts`, `authorized_keys`.
76
+ * Listing a credential directory (`ls ~/.ssh`) is allowed too.
77
+ */
78
+ export const CREDENTIAL_READ_EXCEPTIONS: readonly string[] = ['.ssh/*.pub', '.ssh/config', '.ssh/known_hosts', '.ssh/known_hosts.old', '.ssh/authorized_keys']
79
+
80
+ /** Is every concretisation of `alt` one of the non-secret files (`dir/*.pub` or an exact name)? */
81
+ function isCredentialException(alt: Seg[], home: string): boolean {
82
+ for (const ex of CREDENTIAL_READ_EXCEPTIONS) {
83
+ const full = path.join(home, ex)
84
+ const star = full.indexOf('*')
85
+ if (star === -1) {
86
+ if (alt.length === 1 && typeof alt[0] === 'string' && path.normalize(alt[0]) === full) return true
87
+ continue
88
+ }
89
+ // `dir/*suffix`: a literal `dir/`, then name-like holes and literals without `/`, ending in `suffix`
90
+ const dir = full.slice(0, star)
91
+ const suffix = full.slice(star + 1)
92
+ const first = alt[0]
93
+ if (typeof first !== 'string' || !first.startsWith(dir)) continue
94
+ const rest: Seg[] = [first.slice(dir.length), ...alt.slice(1)]
95
+ if (rest.some((s) => (typeof s === 'string' ? s.includes('/') : !isNameLike(s.type)))) continue
96
+ const last = rest[rest.length - 1]
97
+ const holes = rest.some((s) => typeof s !== 'string')
98
+ // a literal ending (with something before it), or a glob match whose pattern has the ending
99
+ if (typeof last === 'string' ? last.endsWith(suffix) && (holes || last.length > suffix.length) : last.pattern?.endsWith(suffix) === true) return true
100
+ }
101
+ return false
102
+ }
103
+
104
+ /** {@link CREDENTIAL_LOCATIONS} resolved against a home directory. */
105
+ export function credentialReadRoots(home: string = os.homedir()): string[] {
106
+ return CREDENTIAL_LOCATIONS.map((p) => (p.startsWith('/') ? p : path.join(home, p)))
107
+ }
108
+
109
+ /** Every read-denied root of a policy (explicit ones, plus credential stores when enabled), with realpaths. */
110
+ export function readDeniedRoots(policy: Policy): string[] {
111
+ const roots = [...(policy.readDeniedRoots ?? [])]
112
+ if (policy.readDenyCredentials) roots.push(...credentialReadRoots(policy.home ?? os.homedir()))
113
+ return withRealpaths(roots)
114
+ }
115
+
116
+ export type Verdict = 'allow' | 'deny' | 'unknown'
117
+
118
+ export interface PolicyFinding {
119
+ effect: Effect
120
+ verdict: Verdict
121
+ reason: string
122
+ }
123
+
124
+ /** Paths every mode may write: sinks and terminals. */
125
+ const ALWAYS_WRITABLE = ['/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty', '/dev/fd', '/dev/zero']
126
+
127
+ function tempRoots(): string[] {
128
+ const roots = new Set<string>(['/tmp', '/private/tmp', '/var/tmp', '/private/var/tmp'])
129
+ const t = os.tmpdir()
130
+ roots.add(t)
131
+ try {
132
+ roots.add(fs.realpathSync(t))
133
+ } catch {
134
+ // ignore
135
+ }
136
+ if (process.env.TMPDIR) roots.add(process.env.TMPDIR.replace(/\/+$/, ''))
137
+ return [...roots]
138
+ }
139
+
140
+ function withRealpaths(roots: readonly string[]): string[] {
141
+ const out = new Set<string>()
142
+ for (const r of roots) {
143
+ const n = path.resolve(r)
144
+ out.add(n)
145
+ try {
146
+ out.add(fs.realpathSync(n))
147
+ } catch {
148
+ // ignore
149
+ }
150
+ }
151
+ return [...out]
152
+ }
153
+
154
+ export function writableRoots(policy: Policy): string[] {
155
+ if (policy.mode !== 'workspace-write') return []
156
+ const roots = [policy.workspaceRoot, ...(policy.additionalWritableRoots ?? [])]
157
+ if (policy.writableTemp !== false) roots.push(...tempRoots())
158
+ return withRealpaths(roots)
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Abstract path shapes
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * What we can say about where an abstract absolute path points:
167
+ * - `exact`: one concrete normalised path
168
+ * - `below`: the path is `dir` or somewhere beneath it
169
+ * - `unknown`: nothing definite (an unknown part may contain `/..`)
170
+ */
171
+ export type PathShape =
172
+ | { kind: 'exact'; path: string }
173
+ | { kind: 'below'; dir: string }
174
+ | { kind: 'unknown'; hint: string }
175
+
176
+ /**
177
+ * A resolved path component: a literal name, or a `pattern` standing for one
178
+ * unknown real name (it never is `.`/`..`, so a later `..` pops exactly it).
179
+ * A `deep` pattern is the unknown tail of an anchored `path` hole: zero or
180
+ * more components, so a `..` that would pop it may escape the anchor.
181
+ */
182
+ type Comp = string | { pattern: true; deep?: boolean }
183
+
184
+ /** One component of the input alternative, before `.`/`..` resolution. */
185
+ type RawComp =
186
+ | { kind: 'lit'; text: string }
187
+ /** Contains a name-like hole or a literal character other than `.`: one real name. */
188
+ | { kind: 'name' }
189
+ /** Only slash-free holes that may be empty/`.`/`..`, and dots: any of a name, `.` or `..`. */
190
+ | { kind: 'ambiguous' }
191
+
192
+ const MAX_SHAPE_BRANCHES = 64
193
+
194
+ /**
195
+ * Shapes of one alternative of an absolute (or cwd-relative already joined)
196
+ * path. Usually one shape; a component that may be `..` (a `segment` hole on
197
+ * its own) branches into the possible resolutions.
198
+ */
199
+ export function altShapes(alt: Seg[]): PathShape[] {
200
+ const hint = alt.map((s) => (typeof s === 'string' ? s : `⟨${s.origin}⟩`)).join('')
201
+ const unknown: PathShape[] = [{ kind: 'unknown', hint }]
202
+ if (alt.length === 0) return unknown
203
+ let start: Comp[] = []
204
+ let i = 0
205
+ const first = alt[0]
206
+ if (isHole(first)) {
207
+ if (first.type === 'path' && first.under) {
208
+ start = path.resolve(first.under).split('/').filter(Boolean)
209
+ start.push({ pattern: true, deep: true })
210
+ i = 1
211
+ } else {
212
+ return unknown
213
+ }
214
+ } else if (!first.startsWith('/')) {
215
+ return unknown
216
+ }
217
+ // split into raw components
218
+ const raws: RawComp[] = []
219
+ let text = ''
220
+ let holes = false
221
+ let nameLike = false
222
+ const flush = (): void => {
223
+ if (!holes) raws.push({ kind: 'lit', text })
224
+ else if (nameLike || /[^.]/.test(text)) raws.push({ kind: 'name' })
225
+ else raws.push({ kind: 'ambiguous' })
226
+ text = ''
227
+ holes = false
228
+ nameLike = false
229
+ }
230
+ for (; i < alt.length; i++) {
231
+ const s = alt[i]
232
+ if (typeof s === 'string') {
233
+ for (const ch of s) {
234
+ if (ch === '/') flush()
235
+ else text += ch
236
+ }
237
+ } else if (isSlashFree(s.type)) {
238
+ holes = true
239
+ if (isNameLike(s.type)) nameLike = true
240
+ } else {
241
+ return unknown
242
+ }
243
+ }
244
+ flush()
245
+ // resolve `.`/`..`, branching on ambiguous components
246
+ const out: PathShape[] = []
247
+ let branches = 0
248
+ const walk = (k: number, comps: Comp[]): boolean => {
249
+ if (k === raws.length) {
250
+ if (++branches > MAX_SHAPE_BRANCHES) return false
251
+ out.push(finish(comps))
252
+ return true
253
+ }
254
+ const r = raws[k]
255
+ const pop = (): Comp[] | null => {
256
+ const top = comps[comps.length - 1]
257
+ if (top !== undefined && typeof top !== 'string' && top.deep) return null // may escape the anchor
258
+ return comps.slice(0, -1)
259
+ }
260
+ if (r.kind === 'lit') {
261
+ if (r.text === '' || r.text === '.') return walk(k + 1, comps)
262
+ if (r.text === '..') {
263
+ const next = pop()
264
+ return next !== null && walk(k + 1, next)
265
+ }
266
+ return walk(k + 1, [...comps, r.text])
267
+ }
268
+ if (r.kind === 'name') return walk(k + 1, [...comps, { pattern: true }])
269
+ // ambiguous: a name, `.`/empty, or `..`
270
+ const next = pop()
271
+ return next !== null && walk(k + 1, [...comps, { pattern: true }]) && walk(k + 1, comps) && walk(k + 1, next)
272
+ }
273
+ if (!walk(0, start)) return unknown
274
+ return out
275
+ }
276
+
277
+ /** All-literal components are an exact path; otherwise the literal prefix up to the first pattern is a `below` directory. */
278
+ function finish(comps: Comp[]): PathShape {
279
+ const prefix: string[] = []
280
+ for (const c of comps) {
281
+ if (typeof c !== 'string') break
282
+ prefix.push(c)
283
+ }
284
+ const dir = '/' + prefix.join('/')
285
+ return prefix.length === comps.length ? { kind: 'exact', path: dir } : { kind: 'below', dir }
286
+ }
287
+
288
+ /** The first shape of one alternative (see {@link altShapes}). */
289
+ export function altShape(alt: Seg[]): PathShape {
290
+ return altShapes(alt)[0]
291
+ }
292
+
293
+ export function shapesOf(v: AValue): PathShape[] {
294
+ return v.alts.flatMap(altShapes)
295
+ }
296
+
297
+ function isInside(p: string, root: string): boolean {
298
+ return p === root || p.startsWith(root.endsWith('/') ? root : root + '/')
299
+ }
300
+
301
+ /** Is a shape inside `root`? */
302
+ function shapeInside(shape: PathShape, root: string): 'yes' | 'no' | 'maybe' {
303
+ switch (shape.kind) {
304
+ case 'exact':
305
+ return isInside(shape.path, root) ? 'yes' : 'no'
306
+ case 'below':
307
+ if (isInside(shape.dir, root)) return 'yes'
308
+ if (isInside(root, shape.dir)) return 'maybe'
309
+ return 'no'
310
+ case 'unknown':
311
+ return 'maybe'
312
+ }
313
+ }
314
+
315
+ function insideAny(shape: PathShape, roots: string[]): 'yes' | 'no' | 'maybe' {
316
+ let maybe = false
317
+ for (const r of roots) {
318
+ const v = shapeInside(shape, r)
319
+ if (v === 'yes') return 'yes'
320
+ if (v === 'maybe') maybe = true
321
+ }
322
+ return maybe ? 'maybe' : 'no'
323
+ }
324
+
325
+ function lexists(p: string): boolean {
326
+ try {
327
+ fs.lstatSync(p)
328
+ return true
329
+ } catch {
330
+ return false
331
+ }
332
+ }
333
+
334
+ function isFile(p: string): boolean {
335
+ try {
336
+ return fs.statSync(p).isFile()
337
+ } catch {
338
+ return false
339
+ }
340
+ }
341
+
342
+ /** Resolve symlinks of the deepest existing ancestor of a concrete path. */
343
+ export function realpathLoose(p: string): string {
344
+ let cur = p
345
+ const tail: string[] = []
346
+ for (;;) {
347
+ try {
348
+ const real = fs.realpathSync(cur)
349
+ return tail.length === 0 ? real : path.join(real, ...tail.reverse())
350
+ } catch {
351
+ const parent = path.dirname(cur)
352
+ if (parent === cur) return p
353
+ tail.push(path.basename(cur))
354
+ cur = parent
355
+ }
356
+ }
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Checking
361
+ // ---------------------------------------------------------------------------
362
+
363
+ export interface CheckOptions {
364
+ /** Concrete runtime check: also resolve symlinks. */
365
+ runtime?: boolean
366
+ }
367
+
368
+ function checkWritePath(p: AValue, policy: Policy, opts: CheckOptions): { verdict: Verdict; reason: string } {
369
+ if (policy.mode === 'danger-full-access') return { verdict: 'allow', reason: 'danger-full-access' }
370
+ const shapes = shapesOf(p)
371
+ const roots = [...writableRoots(policy), ...ALWAYS_WRITABLE]
372
+ let allYes = true
373
+ let allNo = true
374
+ for (const s of shapes) {
375
+ let v = insideAny(s, roots)
376
+ if (opts.runtime && s.kind === 'exact' && v === 'yes' && !ALWAYS_WRITABLE.some((r) => isInside(s.path, r))) {
377
+ // a symlink inside the workspace may point outside it
378
+ const real = realpathLoose(s.path)
379
+ if (real !== s.path) v = insideAny({ kind: 'exact', path: real }, roots)
380
+ }
381
+ if (v !== 'yes') allYes = false
382
+ if (v !== 'no') allNo = false
383
+ }
384
+ const where = policy.mode === 'read-only' ? 'the policy is read-only' : `outside the writable roots (${[policy.workspaceRoot, ...(policy.additionalWritableRoots ?? [])].join(', ')}${policy.writableTemp !== false ? ', temp' : ''})`
385
+ if (allYes) return { verdict: 'allow', reason: 'inside a writable root' }
386
+ if (allNo) return { verdict: 'deny', reason: `${describe(p)} is ${where}` }
387
+ return { verdict: 'unknown', reason: `${describe(p)} may be ${where}` }
388
+ }
389
+
390
+ /**
391
+ * Reads are denied inside read-denied roots. A recursive read (`grep -r`,
392
+ * `tar c`, `cp -r`) of a directory that contains a denied root reads into it,
393
+ * so it is denied too (`grep -r token ~`); when the directory is only possibly
394
+ * such a parent (an unknown path), it is `unknown`.
395
+ */
396
+ function checkReadPath(p: AValue, policy: Policy, recursive: boolean, opts: CheckOptions, names = false): { verdict: Verdict; reason: string } {
397
+ const denied = readDeniedRoots(policy)
398
+ if (denied.length === 0) return { verdict: 'allow', reason: 'reads are unrestricted' }
399
+ // listing names and metadata (find, ls -R, du) reveals no file contents
400
+ if (names) return { verdict: 'allow', reason: 'lists names only' }
401
+ let anyMaybe = false
402
+ let allYes = true
403
+ const home = policy.home ?? os.homedir()
404
+ const shapes = p.alts.flatMap((alt) => (policy.readDenyCredentials && isCredentialException(alt, home) ? [] : altShapes(alt)))
405
+ if (shapes.length === 0) return { verdict: 'allow', reason: 'a non-secret file in a credential location' }
406
+ for (const s of shapes) {
407
+ let v = insideAny(s, denied)
408
+ // listing a denied directory itself (`ls ~/.ssh`) reads names, not contents
409
+ if (v === 'yes' && !recursive && s.kind === 'exact' && denied.includes(s.path) && !isFile(s.path)) v = 'no'
410
+ // at runtime, a symlink may lead into a denied root
411
+ if (opts.runtime && s.kind === 'exact' && v === 'no') {
412
+ const real = realpathLoose(s.path)
413
+ if (real !== s.path) {
414
+ v = insideAny({ kind: 'exact', path: real }, denied)
415
+ if (v === 'no' && recursive && denied.some((r) => isInside(r, real))) v = 'yes'
416
+ }
417
+ }
418
+ if (v === 'no' && recursive && s.kind === 'exact' && denied.some((r) => isInside(r, s.path))) v = 'yes'
419
+ if (v !== 'yes') allYes = false
420
+ if (v !== 'no') anyMaybe = true
421
+ }
422
+ const what = 'a read-denied location'
423
+ if (allYes) return { verdict: 'deny', reason: recursive ? `${describe(p)} is or contains ${what}` : `${describe(p)} is in ${what}` }
424
+ if (anyMaybe) return { verdict: 'unknown', reason: `${describe(p)} may be in or contain ${what}` }
425
+ return { verdict: 'allow', reason: 'not in a read-denied location' }
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Network hosts
430
+ // ---------------------------------------------------------------------------
431
+
432
+ /**
433
+ * The host a network target names, when it is determined by the literal text
434
+ * of the value: `https://user@Host:8080/x` → `host`, `git@github.com:o/r` →
435
+ * `github.com`, `user@example.com` → `example.com`.
436
+ * Undefined when the host depends on an unknown part (`https://$H/x`), when
437
+ * there is none (`file:///x`), and for a bare word (`example.com`,
438
+ * `example.com/x`, `localhost:3000`): behaviors cannot always tell a host operand from an
439
+ * option's value (`ssh -i key host`, `git clone --depth 1 url`), so a bare
440
+ * word is never trusted for a definite verdict. Neither is anything that is
441
+ * not a host name or IP address (`BatchMode=yes`, `+short`, `8080`).
442
+ */
443
+ export function targetHost(alt: Seg[]): string | undefined {
444
+ const complete = alt.every((s) => typeof s === 'string')
445
+ const lit = typeof alt[0] === 'string' ? alt[0] : ''
446
+ const text = complete ? alt.join('') : lit
447
+ // the literal part must reach past the end of the host (or be the whole value)
448
+ const upTo = (s: string, stops: RegExp): string | undefined => {
449
+ const m = stops.exec(s)
450
+ if (m) return s.slice(0, m.index)
451
+ return complete ? s : undefined
452
+ }
453
+ const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(text)
454
+ let authority: string | undefined
455
+ if (scheme) {
456
+ if (scheme[1].toLowerCase() === 'file') return undefined
457
+ authority = upTo(text.slice(scheme[0].length), /[/?#]/)
458
+ } else {
459
+ // scp-like `user@host:path` (a `:` before any `/`), or `user@host`; without
460
+ // the `user@` it may be an option value (`ssh -L localhost:80:x:80 h`)
461
+ const scp = /^([^/:]*):(?!\/\/)/.exec(text)
462
+ authority = scp && !/^\[/.test(text) ? scp[1] : upTo(text, /[/?#]/)
463
+ if (authority !== undefined && !authority.includes('@')) return undefined
464
+ }
465
+ if (authority === undefined) return undefined
466
+ let host = authority.slice(authority.lastIndexOf('@') + 1)
467
+ if (host.startsWith('[')) {
468
+ const end = host.indexOf(']')
469
+ if (end === -1) return undefined
470
+ host = host.slice(1, end)
471
+ } else host = host.replace(/:\d*$/, '')
472
+ host = host.toLowerCase().replace(/\.$/, '')
473
+ const ipv6 = host.includes(':')
474
+ if (ipv6 ? !/^[0-9a-f:.]+$/.test(host) : !/^[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?)*$/.test(host) || /^\d+$/.test(host)) return undefined
475
+ return host
476
+ }
477
+
478
+ /** Does an allow-list entry match a host? */
479
+ export function hostMatches(host: string, entry: string): boolean {
480
+ const e = entry.toLowerCase().replace(/\.$/, '')
481
+ if (e === '*') return true
482
+ if (e.startsWith('*.')) return host.endsWith(e.slice(1)) && host.length > e.length - 1
483
+ return host === e
484
+ }
485
+
486
+ function checkHosts(target: AValue | undefined, allow: readonly string[]): { verdict: Verdict; reason: string } {
487
+ const list = allow.join(', ')
488
+ if (!target) return { verdict: 'unknown', reason: `network access to hosts not known statically (allowed: ${list})` }
489
+ let allYes = true
490
+ let allNo = true
491
+ const bad: string[] = []
492
+ for (const alt of target.alts) {
493
+ const host = targetHost(alt)
494
+ if (host === undefined) {
495
+ allYes = false
496
+ allNo = false
497
+ continue
498
+ }
499
+ if (allow.some((e) => hostMatches(host, e))) allNo = false
500
+ else {
501
+ allYes = false
502
+ bad.push(host)
503
+ }
504
+ }
505
+ if (allYes) return { verdict: 'allow', reason: 'host allowed' }
506
+ if (allNo) return { verdict: 'deny', reason: `network access to ${[...new Set(bad)].join(', ')} is not allowed (allowed: ${list})` }
507
+ return { verdict: 'unknown', reason: `network access to ${describe(target)} may not be allowed (allowed: ${list})` }
508
+ }
509
+
510
+ export function checkEffect(effect: Effect, policy: Policy, opts: CheckOptions = {}): PolicyFinding {
511
+ const f = (verdict: Verdict, reason: string): PolicyFinding => ({ effect, verdict, reason })
512
+ switch (effect.kind) {
513
+ case 'read': {
514
+ const r = checkReadPath(effect.path, policy, effect.recursive === true, opts, effect.names === true)
515
+ return f(effect.maybe && r.verdict === 'deny' ? 'unknown' : r.verdict, r.reason)
516
+ }
517
+ case 'write':
518
+ case 'delete': {
519
+ // `rm -f` of a path that does not exist does nothing
520
+ if (opts.runtime && effect.kind === 'delete' && effect.ifExists && policy.missingDeletes === 'allow' && effect.path.alts.every((a) => a.length === 1 && typeof a[0] === 'string' && !lexists(a[0]))) {
521
+ return f('allow', 'delete: nothing to delete (missing path, -f)')
522
+ }
523
+ const r = checkWritePath(effect.path, policy, opts)
524
+ const verb = effect.kind === 'delete' ? 'delete' : 'write'
525
+ if (effect.maybe && r.verdict === 'deny') return f('unknown', `may ${verb}: ${r.reason}`)
526
+ return f(r.verdict, `${verb}: ${r.reason}`)
527
+ }
528
+ case 'network': {
529
+ if (policy.network === 'deny') return f(effect.maybe ? 'unknown' : 'deny', `network access is denied (${describeEffect(effect)})`)
530
+ if (policy.network === undefined || policy.network === 'allow') return f('allow', 'network allowed')
531
+ const r = checkHosts(effect.target, policy.network.allow)
532
+ return f(effect.maybe && r.verdict === 'deny' ? 'unknown' : r.verdict, r.reason)
533
+ }
534
+ case 'exec':
535
+ case 'chdir':
536
+ case 'process':
537
+ return f('allow', '')
538
+ case 'unknown':
539
+ if (policy.unknownCommands === 'deny' && policy.mode !== 'danger-full-access') return f(effect.maybe ? 'unknown' : 'deny', `effects unknown: ${effect.detail}`)
540
+ return f('unknown', effect.detail)
541
+ }
542
+ }
543
+
544
+ export function checkCommandName(name: string, policy: Policy): PolicyFinding | null {
545
+ const base = name.split('/').pop() ?? name
546
+ if (policy.deniedCommands?.includes(base)) {
547
+ return { effect: { kind: 'exec', argv: [{ alts: [[name]] }] }, verdict: 'deny', reason: `command '${base}' is denied by policy` }
548
+ }
549
+ return null
550
+ }
551
+
552
+ export function checkEffects(effects: Effect[], policy: Policy, opts: CheckOptions = {}): PolicyFinding[] {
553
+ return effects.map((e) => checkEffect(e, policy, opts))
554
+ }
555
+
556
+ /** Denied command names among nested `exec` effects (a possible exec is only `unknown`). */
557
+ export function checkExecEffects(effects: Effect[], policy: Policy): PolicyFinding[] {
558
+ const out: PolicyFinding[] = []
559
+ for (const e of effects) {
560
+ if (e.kind !== 'exec') continue
561
+ const first = e.argv[0]
562
+ const n = first && first.alts.length === 1 && first.alts[0].length === 1 && typeof first.alts[0][0] === 'string' ? first.alts[0][0] : undefined
563
+ const f = n ? checkCommandName(n, policy) : null
564
+ if (f) out.push(e.maybe ? { effect: e, verdict: 'unknown', reason: `may run: ${f.reason}` } : { ...f, effect: e })
565
+ }
566
+ return out
567
+ }
568
+
569
+ /** The most severe verdict. */
570
+ export function worstVerdict(findings: PolicyFinding[]): Verdict {
571
+ if (findings.some((f) => f.verdict === 'deny')) return 'deny'
572
+ if (findings.some((f) => f.verdict === 'unknown')) return 'unknown'
573
+ return 'allow'
574
+ }
575
+
576
+ export function defaultPolicy(workspaceRoot: string): Policy {
577
+ return { mode: 'workspace-write', workspaceRoot, writableTemp: true, network: 'allow', unknownCommands: 'allow' }
578
+ }