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