dsh-cmd-guard 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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Layer 0: tool identity.
3
+ *
4
+ * This layer exists because of one concrete near-miss. The first draft assumed
5
+ * the command tool was called `run_command` and took `cmd`. The shipped tools
6
+ * are `bash` and `pwsh`, and they take `command`. A guard written against the
7
+ * assumed names installs cleanly, activates cleanly, and intercepts nothing -
8
+ * which is worse than failing to install, because it looks like it is working.
9
+ *
10
+ * So: anything that is not a known execution tool, but is passed a
11
+ * command-shaped string, is an ask. Not a deny - a new shell tool is a
12
+ * legitimate thing for the harness to grow. Just not something this parser
13
+ * has any evidence about.
14
+ */
15
+ import type { Finding, GuardInput, GuardState } from './types.js';
16
+ /**
17
+ * Tool names DSH dispatches commands through.
18
+ *
19
+ * Verified against the shipped type declarations of 0.2.x: the POSIX shell
20
+ * tool registers as `bash`, the Windows one as `pwsh`, and the persistent
21
+ * variant reuses `bash` (same registered name, so name matching covers it).
22
+ *
23
+ * The rest of the set is deliberate over-inclusion. A tool that runs a command
24
+ * and is named something obvious here should be treated as a command source,
25
+ * even if this build never registers it.
26
+ */
27
+ export declare const EXEC_TOOLS: ReadonlySet<string>;
28
+ /**
29
+ * Keys the command legitimately arrives under, per tool.
30
+ *
31
+ * `command` is the shipped key for the shell tools. Anything outside the set
32
+ * for that tool is drift and gets surfaced rather than trusted.
33
+ */
34
+ export declare function primaryKeysFor(tool: string): ReadonlySet<string>;
35
+ /** Keys every execution tool may carry without it being drift. */
36
+ export declare const PRIMARY_COMMAND_KEYS: ReadonlySet<string>;
37
+ /**
38
+ * Keys whose value is document or payload data, never a command line.
39
+ *
40
+ * Two independent reasons this lives here rather than only in the mount:
41
+ *
42
+ * 1. The unknown-tool scan reads every string argument. Without an exclusion,
43
+ * `write_file` with a README that mentions `del /s /q` was surfaced as a
44
+ * command, and so was writing this package's own test corpus. With no
45
+ * approval UI composed, ask degrades to deny, which made `write_file`
46
+ * effectively unusable.
47
+ * 2. `verify()` is exported and tests call it directly with a `commandSource`.
48
+ * The exclusion has to hold at the layer, not only at the seam, or the same
49
+ * false positive comes back through the library entry point.
50
+ *
51
+ * This is a name-based exclusion and therefore a residual risk: a tool that
52
+ * genuinely executes the string in `content` would be skipped. Stated in the
53
+ * README, not papered over.
54
+ */
55
+ export declare const DATA_KEYS: ReadonlySet<string>;
56
+ export declare function check(input: GuardInput, _state: GuardState): Finding | null;
@@ -0,0 +1,133 @@
1
+ import { looksLikeCommandString } from './shape.js';
2
+ /**
3
+ * Tool names DSH dispatches commands through.
4
+ *
5
+ * Verified against the shipped type declarations of 0.2.x: the POSIX shell
6
+ * tool registers as `bash`, the Windows one as `pwsh`, and the persistent
7
+ * variant reuses `bash` (same registered name, so name matching covers it).
8
+ *
9
+ * The rest of the set is deliberate over-inclusion. A tool that runs a command
10
+ * and is named something obvious here should be treated as a command source,
11
+ * even if this build never registers it.
12
+ */
13
+ export const EXEC_TOOLS = new Set([
14
+ 'bash',
15
+ 'sh',
16
+ 'zsh',
17
+ 'fish',
18
+ 'pwsh',
19
+ 'powershell',
20
+ 'powershell.exe',
21
+ 'pwsh.exe',
22
+ 'cmd',
23
+ 'cmd.exe',
24
+ 'shell',
25
+ 'terminal',
26
+ 'run_command',
27
+ 'exec',
28
+ ]);
29
+ /**
30
+ * Tools that historically carried the command under `cmd`.
31
+ *
32
+ * Only these tolerate the short key. `bash` and `pwsh` are verified to take
33
+ * `command`; accepting `cmd` for them would mean a call that renames the key
34
+ * - the exact silent-rename failure this layer exists to catch - slides
35
+ * through as if nothing happened. Measured: `pwsh` handed `{cmd: "git status"}`
36
+ * passed, because `cmd` was globally tolerated.
37
+ */
38
+ const LEGACY_CMD_TOOLS = new Set(['run_command', 'exec']);
39
+ /**
40
+ * Keys the command legitimately arrives under, per tool.
41
+ *
42
+ * `command` is the shipped key for the shell tools. Anything outside the set
43
+ * for that tool is drift and gets surfaced rather than trusted.
44
+ */
45
+ export function primaryKeysFor(tool) {
46
+ return LEGACY_CMD_TOOLS.has(tool.toLowerCase())
47
+ ? new Set(['command', 'cmd'])
48
+ : new Set(['command']);
49
+ }
50
+ /** Keys every execution tool may carry without it being drift. */
51
+ export const PRIMARY_COMMAND_KEYS = new Set(['command', 'cmd']);
52
+ /**
53
+ * Keys whose value is document or payload data, never a command line.
54
+ *
55
+ * Two independent reasons this lives here rather than only in the mount:
56
+ *
57
+ * 1. The unknown-tool scan reads every string argument. Without an exclusion,
58
+ * `write_file` with a README that mentions `del /s /q` was surfaced as a
59
+ * command, and so was writing this package's own test corpus. With no
60
+ * approval UI composed, ask degrades to deny, which made `write_file`
61
+ * effectively unusable.
62
+ * 2. `verify()` is exported and tests call it directly with a `commandSource`.
63
+ * The exclusion has to hold at the layer, not only at the seam, or the same
64
+ * false positive comes back through the library entry point.
65
+ *
66
+ * This is a name-based exclusion and therefore a residual risk: a tool that
67
+ * genuinely executes the string in `content` would be skipped. Stated in the
68
+ * README, not papered over.
69
+ */
70
+ export const DATA_KEYS = new Set([
71
+ 'content',
72
+ 'contents',
73
+ 'body',
74
+ 'text',
75
+ 'new_string',
76
+ 'old_string',
77
+ 'new_str',
78
+ 'old_str',
79
+ 'patch',
80
+ 'diff',
81
+ 'message',
82
+ 'query',
83
+ 'prompt',
84
+ 'note',
85
+ 'summary',
86
+ 'explanation',
87
+ 'description',
88
+ 'why',
89
+ 'reason',
90
+ 'intent',
91
+ 'purpose',
92
+ ]);
93
+ function ask(code, reason) {
94
+ return { layer: 'entry', verdict: 'ask', code, reason };
95
+ }
96
+ export function check(input, _state) {
97
+ // The string came from a payload key, so it is a document, not a command
98
+ // line - whatever the tool name is. Checked before the tool-name branch on
99
+ // purpose: `write_file` must not be flagged for what its `content` says.
100
+ const source = input.commandSource;
101
+ if (source !== undefined && DATA_KEYS.has(source.toLowerCase()))
102
+ return null;
103
+ // Shape, not identity: how many command keys a call carries does not
104
+ // depend on whether a tool name is set. This sat after the tool guard, so a
105
+ // direct verify() call carrying both \`cmd\` and \`command\` was never checked.
106
+ // Two command keys in one call: the parser read one of them, and the
107
+ // tool may run the other. This is not drift, it is divergence, and no
108
+ // token analysis of a single string can resolve it. Checked before the
109
+ // tool-name branch because an unfamiliar tool returned early and the
110
+ // comparison never happened.
111
+ if (input.commandKeys !== undefined && input.commandKeys.length > 1) {
112
+ return ask('ENTRY_COMMAND_KEY_AMBIGUOUS', `The call carries more than one command key (${input.commandKeys.join(', ')}). The string below was read from "${source ?? 'an unnamed key'}", but which one runs is not something this parser can establish.`);
113
+ }
114
+ // Direct calls (tests, the exported verify()) carry no tool name. Layer 0
115
+ // has nothing to say about those, and inventing an opinion would make the
116
+ // pipeline unusable as a library.
117
+ if (input.tool === undefined)
118
+ return null;
119
+ const tool = input.tool.toLowerCase();
120
+ if (!EXEC_TOOLS.has(tool)) {
121
+ // Not every bare string argument is a command. `read_file` carries a path,
122
+ // and with no approval UI composed ask degrades to deny, so a shape-free
123
+ // scan made every file read impossible. Measured false positive; the shape
124
+ // heuristic now decides instead of the argument's existence.
125
+ if (!looksLikeCommandString(input.cmd))
126
+ return null;
127
+ return ask('ENTRY_UNKNOWN_EXEC_TOOL', `"${input.tool}" is not a shell tool this parser knows, yet it was handed a command-shaped argument. A new or renamed execution tool fails open, so it is surfaced instead.`);
128
+ }
129
+ if (source !== undefined && !primaryKeysFor(tool).has(source.toLowerCase())) {
130
+ return ask('ENTRY_COMMAND_KEY_DRIFT', `The command arrived under "${source}", which is not a key "${tool}" takes. Either the tool schema changed or a wrapper is reshaping arguments; both invalidate the assumption that the string below is the whole command.`);
131
+ }
132
+ return null;
133
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Layer 5: interpreters.
3
+ *
4
+ * The plugin performs no IO and never opens the script. "The file exists" is
5
+ * therefore not an audit, and a rule based on existence would hand out a
6
+ * complete bypass: write a file, then run it. The only defensible line is
7
+ * where the path comes from.
8
+ *
9
+ * Two identity rules here are not cosmetic. Both were measured bypasses
10
+ * against the first draft:
11
+ *
12
+ * - `INTERPRETERS.has(head)` compared the entire first token, so
13
+ * `C:\Python39\python.exe`, `python3.11`, `node18` and `/usr/bin/python3`
14
+ * all fell through as if they were ordinary programs.
15
+ * - `INLINE_FLAGS` was an exact-match set, so bundled short flags (`-uc`,
16
+ * `-pe`, `-ep`) read as an ordinary script call with no script.
17
+ */
18
+ import type { Finding, GuardInput, GuardState } from './types.js';
19
+ /**
20
+ * Resolve the interpreter behind a token: strip the path, the `.exe` suffix
21
+ * and a version suffix, then look for a known name.
22
+ *
23
+ * `C:\Python39\python.exe` -> python
24
+ * `python3.11` -> python3
25
+ * `node18` -> node
26
+ * `py` -> py
27
+ */
28
+ export declare function interpreterOf(token: string): string | undefined;
29
+ /**
30
+ * Bundled short flags. `-uc` is `-u -c`, `-pe` is `-p -e`. Only `c` and `e`
31
+ * matter: they are the ones that carry a program body. `-Bu` carries none, so
32
+ * treating every bundle as inline would be a false positive on normal runs.
33
+ */
34
+ export declare function isInlineFlag(token: string): boolean;
35
+ export declare function check(input: GuardInput, state: GuardState): Finding | null;
@@ -0,0 +1,199 @@
1
+ import { baseName, hasParentEscape, isAbsolutePath, isInsideWorkspace, isVolatilePath, normalizePath, tokenize, } from './lexical.js';
2
+ const INTERPRETERS = new Set([
3
+ 'python',
4
+ 'python3',
5
+ 'py',
6
+ 'node',
7
+ 'nodejs',
8
+ 'deno',
9
+ 'bun',
10
+ 'ruby',
11
+ 'perl',
12
+ 'php',
13
+ 'lua',
14
+ 'npx',
15
+ 'pnpx',
16
+ 'bunx',
17
+ 'pip',
18
+ 'pip3',
19
+ 'pipx',
20
+ ]);
21
+ /** Fetch-and-execute hosts: the payload does not exist on disk yet. */
22
+ const FETCH_EXEC = new Set(['npx', 'pnpx', 'bunx']);
23
+ /**
24
+ * Packages that are a local build toolchain rather than an arbitrary fetch.
25
+ *
26
+ * This is a measured false positive, not a convenience: `npx tsc -p
27
+ * tsconfig.json` is this project's own build, and asking for approval on every
28
+ * run teaches the operator to click through. The allowlist is deliberately
29
+ * short and excludes anything that downloads and runs a package's own scripts
30
+ * (there is no `npm` here, no `create-*`, no `vite create`).
31
+ */
32
+ const LOCAL_TOOLCHAIN = new Set([
33
+ 'tsc',
34
+ 'tsc-alias',
35
+ 'typescript',
36
+ 'eslint',
37
+ 'prettier',
38
+ 'vitest',
39
+ 'jest',
40
+ 'tsup',
41
+ 'esbuild',
42
+ 'vite',
43
+ 'rollup',
44
+ 'rimraf',
45
+ 'tsx',
46
+ 'ts-node',
47
+ ]);
48
+ const INLINE_FLAGS = new Set(['-c', '-e', '--eval', '-command', 'eval']);
49
+ const STDIN_TARGETS = new Set(['-', 'con', '/dev/stdin', '/dev/fd/0']);
50
+ const QUERY_FLAGS = new Set(['-v', '--version', '-h', '--help']);
51
+ function ask(code, reason) {
52
+ return { layer: 'interp', verdict: 'ask', code, reason };
53
+ }
54
+ /**
55
+ * Resolve the interpreter behind a token: strip the path, the `.exe` suffix
56
+ * and a version suffix, then look for a known name.
57
+ *
58
+ * `C:\Python39\python.exe` -> python
59
+ * `python3.11` -> python3
60
+ * `node18` -> node
61
+ * `py` -> py
62
+ */
63
+ export function interpreterOf(token) {
64
+ const base = baseName(token).toLowerCase();
65
+ const noExt = base.endsWith('.exe') ? base.slice(0, -4) : base;
66
+ const candidates = [noExt];
67
+ // python3.11 -> python3 (keep the major, drop the patch series)
68
+ const noPatch = noExt.replace(/(\.\d+)+$/, '');
69
+ if (noPatch !== noExt)
70
+ candidates.push(noPatch);
71
+ // node18 -> node
72
+ const noDigits = noPatch.replace(/\d+$/, '');
73
+ if (noDigits !== noPatch)
74
+ candidates.push(noDigits);
75
+ for (const c of candidates) {
76
+ if (INTERPRETERS.has(c))
77
+ return c;
78
+ }
79
+ return undefined;
80
+ }
81
+ /**
82
+ * Bundled short flags. `-uc` is `-u -c`, `-pe` is `-p -e`. Only `c` and `e`
83
+ * matter: they are the ones that carry a program body. `-Bu` carries none, so
84
+ * treating every bundle as inline would be a false positive on normal runs.
85
+ */
86
+ export function isInlineFlag(token) {
87
+ if (INLINE_FLAGS.has(token))
88
+ return true;
89
+ if (!/^-[a-z]{2,}$/i.test(token))
90
+ return false;
91
+ return /[ce]/i.test(token.slice(1));
92
+ }
93
+ /**
94
+ * A local toolchain invocation, as opposed to fetching and running a package.
95
+ *
96
+ * The first non-flag word is the package. npx's own options must come before
97
+ * that word, so a local package name in first position means every later flag
98
+ * belongs to the package, not to npx. That distinction is not cosmetic:
99
+ * `tsc -p tsconfig.json` uses `-p` for "project", while npx's `-p` means
100
+ * "package". Reading them as the same thing made this project's own build ask
101
+ * on every run.
102
+ */
103
+ function isLocalToolchain(rest) {
104
+ let i = 0;
105
+ while (i < rest.length && rest[i]?.startsWith('-') === true)
106
+ i += 1;
107
+ const pkg = rest[i]?.toLowerCase();
108
+ if (pkg === undefined || !LOCAL_TOOLCHAIN.has(pkg))
109
+ return false;
110
+ // Options before the package name belong to npx itself; `-c` / `--call` run
111
+ // an arbitrary shell command and `-p` / `--package` swaps the package.
112
+ return !rest.slice(0, i).some((t) => {
113
+ const l = t.toLowerCase();
114
+ return l === '-c' || l === '--call' || l === '-p' || l === '--package';
115
+ });
116
+ }
117
+ /**
118
+ * Modules reachable through `-m` whose effect is not "run this file".
119
+ *
120
+ * The set is about capability, not about the module's name: installing a
121
+ * package runs its build backend, a `*.server` module publishes the working
122
+ * directory, and a debugger is an interactive shell by another route.
123
+ */
124
+ const RISKY_MODULE = /^(?:pip\d*|ensurepip|http\.server|simplehttpserver|smtpd|cgi\.?httpserver|pdb|pty|rlcompleter)$/;
125
+ export function check(input, state) {
126
+ const tokens = tokenize(input.cmd);
127
+ const rawHead = tokens[0];
128
+ if (rawHead === undefined)
129
+ return null;
130
+ const head = interpreterOf(rawHead);
131
+ if (head === undefined)
132
+ return null;
133
+ const rest = tokens.slice(1);
134
+ const lowered = rest.map((t) => t.toLowerCase());
135
+ // `node -v` prints a version and runs nothing. This is the one shape that
136
+ // may pass without inspecting a path.
137
+ if (rest.length === 1 && QUERY_FLAGS.has(lowered[0] ?? ''))
138
+ return null;
139
+ if (FETCH_EXEC.has(head) && !isLocalToolchain(rest)) {
140
+ return ask('INTERP_FETCH_EXECUTE', `"${head}" downloads and runs a package that does not exist on disk yet. There is nothing local to audit.`);
141
+ }
142
+ const inline = lowered.find((t) => isInlineFlag(t));
143
+ if (inline !== undefined) {
144
+ return ask('INTERP_INLINE_BODY', `Inline program body ("${inline}"). The shape is unauditable by definition, regardless of what the body does.`);
145
+ }
146
+ if (lowered.some((t) => STDIN_TARGETS.has(t))) {
147
+ return ask('INTERP_STDIN', 'Program text arrives on stdin or a device path. There is no file argument, so an existence check would miss this entirely.');
148
+ }
149
+ // `-m` names an installed module. Unlike `-c` it cannot carry a body, so
150
+ // there is no code to audit and no path to check - which is why asking on
151
+ // every `-m` was a false positive: `python -m pytest` is an ordinary
152
+ // read-only test run, and with no approval UI composed ask means deny.
153
+ // The module is judged by what it can do instead: install, serve, or open a
154
+ // shell. Everything else is a normal program invocation.
155
+ const moduleAt = lowered.indexOf('-m');
156
+ if (moduleAt >= 0) {
157
+ const mod = lowered[moduleAt + 1] ?? '';
158
+ if (RISKY_MODULE.test(mod)) {
159
+ return ask('INTERP_MODULE_SELECTOR', `"-m ${mod}" runs a module that installs, serves the network, or opens an interactive shell, rather than a local script.`);
160
+ }
161
+ }
162
+ const workspaceRoot = state.workspaceRoot ?? process.cwd();
163
+ if (head === 'pip' || head === 'pip3' || head === 'pipx') {
164
+ const isInstall = lowered.includes('install');
165
+ const localSource = rest.some((t) => t === '.' || t === './' || /^[\\/]/.test(t));
166
+ if (isInstall && localSource) {
167
+ return ask('INTERP_LOCAL_BUILD', 'Local install runs the package build backend, which is arbitrary code at install time.');
168
+ }
169
+ }
170
+ // The first non-flag argument is the script target.
171
+ const target = rest.find((t) => !t.startsWith('-'));
172
+ if (target === undefined)
173
+ return null;
174
+ if (isVolatilePath(target)) {
175
+ return ask('INTERP_VOLATILE_PATH', `"${target}" lives under a temp root any process on the machine can write to. That is not a provenance claim.`);
176
+ }
177
+ if (isAbsolutePath(target)) {
178
+ if (!isInsideWorkspace(target, workspaceRoot)) {
179
+ return ask('INTERP_OUTSIDE_WORKSPACE', `"${target}" is outside the workspace. The operator cannot see the target without leaving the project.`);
180
+ }
181
+ }
182
+ else if (hasParentEscape(target)) {
183
+ return ask('INTERP_PARENT_ESCAPE', `"${target}" climbs above the workspace root with "..". It only looks relative.`);
184
+ }
185
+ // TOCTOU: write-then-run in the same session. Judging the two independently
186
+ // is a complete bypass, so the guard keeps a short-lived write ledger.
187
+ const recent = state.recentWrites ?? [];
188
+ const wanted = normalizePath(target);
189
+ const written = recent.some((w) => {
190
+ const n = normalizePath(w);
191
+ return n === wanted || n.endsWith('/' + wanted);
192
+ });
193
+ if (written) {
194
+ return ask('INTERP_RECENT_WRITE', `"${target}" was written earlier in this session and is being executed immediately. Write-then-run defeats any per-command check.`);
195
+ }
196
+ // Falls through: workspace-relative explicit script, no escape. Note what
197
+ // this does NOT assert: the file was never opened.
198
+ return null;
199
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Lexical helpers. Pure string handling, no IO, no process access.
3
+ *
4
+ * Everything here is deliberately naive about shell grammar. The goal is not
5
+ * to emulate cmd.exe; it is to make sure that anything whose shape we cannot
6
+ * confidently read surfaces as an ask instead of sliding through as a pass.
7
+ */
8
+ /** Split into whitespace-separated tokens, keeping quoted runs intact. */
9
+ export declare function tokenize(cmd: string): string[];
10
+ /**
11
+ * A newline outside quotes. Both shells treat it as a statement separator, so
12
+ * it chains exactly like `;` - but scanOperators covered only punctuation, and
13
+ * the chain therefore reached the rules layer as one command.
14
+ *
15
+ * Quoted newlines are data: `git commit -m "line one\nline two"` is one
16
+ * command, and treating it as two would be a false positive on every
17
+ * multi-line commit message.
18
+ */
19
+ export declare function hasUnquotedNewline(cmd: string): boolean;
20
+ /**
21
+ * Operators found outside quotes. Chaining is what hides a second stage
22
+ * behind a harmless first one, so any operator at all is worth surfacing.
23
+ */
24
+ export declare function scanOperators(cmd: string): string[];
25
+ /**
26
+ * Command substitution: `$(...)` and paired backticks.
27
+ *
28
+ * Measured bypass: `echo $(rm -rf /)` and a backtick-wrapped `rm -rf /` both
29
+ * passed with nothing objecting. Substitution was never in scanOperators, and
30
+ * the rules layer only ever reads tokens[0], so the inner command was invisible
31
+ * to every layer.
32
+ *
33
+ * Single quotes suppress substitution in POSIX shells, so their contents are
34
+ * skipped. Double quotes do not, so they are not.
35
+ *
36
+ * Backticks are ambiguous: in PowerShell a lone backtick is an escape
37
+ * (`"a`tb"` is a tab). A backtick with a closing partner is treated as
38
+ * substitution, a lone one as an escape. That keeps normal PowerShell strings
39
+ * from being flagged while closing the POSIX channel.
40
+ */
41
+ export declare function hasCommandSubstitution(cmd: string): boolean;
42
+ /**
43
+ * Bodies of every command substitution, so the split layer can run the hard
44
+ * rules against the inner text. Without this, `echo $(rm -rf /)` would only
45
+ * ever be an ask; the delete itself is what should decide the verdict.
46
+ */
47
+ export declare function substitutionBodies(cmd: string): string[];
48
+ /**
49
+ * Split into the individual statements a chain would run. Used only to escalate
50
+ * a hard deny that sits behind a separator: `git status && rm -rf /` used to be
51
+ * an ask from the split layer, which hid the deny the rules layer would have
52
+ * produced for the second statement. Deny must win.
53
+ *
54
+ * Quotes are honoured, so `echo "a;b"` stays one segment.
55
+ */
56
+ export declare function splitSegments(cmd: string): string[];
57
+ /**
58
+ * Process substitution. The path handed to the tool is an anonymous pipe that
59
+ * never exists on disk, so no provenance check can apply to it. Checked with
60
+ * quotes ignored on purpose: it is legal inside a quoted shell argument.
61
+ */
62
+ export declare function hasProcessSubstitution(cmd: string): boolean;
63
+ /**
64
+ * A caret wedged inside a word (`d^e^l`). Outside the word, `^|` is an
65
+ * escaped operator and belongs to the split layer instead.
66
+ */
67
+ export declare function hasCaretInsideWord(cmd: string): boolean;
68
+ /** Forward slashes, lowercase. Used for every path comparison. */
69
+ export declare function normalizePath(p: string): string;
70
+ /** Last segment of a Windows or POSIX path, quotes already stripped. */
71
+ export declare function baseName(p: string): string;
72
+ /** True when `p` is `root` itself or sits underneath it. */
73
+ export declare function isInsideWorkspace(p: string, root: string): boolean;
74
+ /** `C:\x`, `C:/x`, `\\server\share`, `/usr/x`. */
75
+ export declare function isAbsolutePath(p: string): boolean;
76
+ /** Any `..` segment, which may climb above the workspace root. */
77
+ export declare function hasParentEscape(p: string): boolean;
78
+ /** Roots any process on the machine can write to. Not a provenance claim. */
79
+ export declare function isVolatilePath(p: string): boolean;