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.
- package/LICENSE +21 -0
- package/README.md +262 -2
- package/cordis.patch.yml +17 -0
- package/lib/src/guard/capability.d.ts +30 -0
- package/lib/src/guard/capability.js +337 -0
- package/lib/src/guard/entry.d.ts +56 -0
- package/lib/src/guard/entry.js +133 -0
- package/lib/src/guard/interp.d.ts +35 -0
- package/lib/src/guard/interp.js +199 -0
- package/lib/src/guard/lexical.d.ts +79 -0
- package/lib/src/guard/lexical.js +279 -0
- package/lib/src/guard/mount.d.ts +85 -0
- package/lib/src/guard/mount.js +130 -0
- package/lib/src/guard/normalize.d.ts +73 -0
- package/lib/src/guard/normalize.js +265 -0
- package/lib/src/guard/obfusc.d.ts +9 -0
- package/lib/src/guard/obfusc.js +89 -0
- package/lib/src/guard/rules.d.ts +15 -0
- package/lib/src/guard/rules.js +402 -0
- package/lib/src/guard/shape.d.ts +34 -0
- package/lib/src/guard/shape.js +54 -0
- package/lib/src/guard/split.d.ts +21 -0
- package/lib/src/guard/split.js +71 -0
- package/lib/src/guard/types.d.ts +72 -0
- package/lib/src/guard/types.js +1 -0
- package/lib/src/guard/unwrap.d.ts +14 -0
- package/lib/src/guard/unwrap.js +126 -0
- package/lib/src/guard/verbs.d.ts +36 -0
- package/lib/src/guard/verbs.js +133 -0
- package/lib/src/guard/verify.d.ts +36 -0
- package/lib/src/guard/verify.js +126 -0
- package/lib/src/guard/why.d.ts +21 -0
- package/lib/src/guard/why.js +260 -0
- package/lib/src/index.d.ts +32 -0
- package/lib/src/index.js +162 -0
- package/package.json +56 -6
|
@@ -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;
|