pum-agent 0.2.0-beta.4 → 0.2.2-beta.1
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/README.md +43 -8
- package/package.json +3 -2
- package/src/app.tsx +49 -8
- package/src/check-mode.ts +4 -1
- package/src/check-mutation.ts +28 -16
- package/src/check-paths.ts +16 -12
- package/src/check-policy.ts +423 -28
- package/src/main.tsx +16 -0
- package/src/platform.ts +39 -0
- package/src/sandbox/index.ts +207 -0
- package/src/sandbox/linux.ts +362 -0
- package/src/sandbox/types.ts +62 -0
- package/src/sandbox/windows.ts +327 -0
- package/src/sandbox-policy.ts +204 -0
- package/src/settings-popup.tsx +2 -0
- package/src/settings.ts +10 -0
- package/src/terminal-title.ts +41 -0
package/src/check-policy.ts
CHANGED
|
@@ -14,11 +14,13 @@ export type CheckPolicyFindingCode =
|
|
|
14
14
|
| "privilege-escalation"
|
|
15
15
|
| "persistence"
|
|
16
16
|
| "remote-script-execution"
|
|
17
|
+
| "external-read-exfiltration"
|
|
17
18
|
| "destructive-git"
|
|
18
19
|
| "broad-deletion"
|
|
19
20
|
| "suspicious-execution"
|
|
20
21
|
| "shell-complexity"
|
|
21
22
|
| "mutation"
|
|
23
|
+
| "unknown-path-access"
|
|
22
24
|
| "unrecognized-command";
|
|
23
25
|
|
|
24
26
|
export type CheckPolicyFinding = {
|
|
@@ -29,6 +31,23 @@ export type CheckPolicyFinding = {
|
|
|
29
31
|
path?: string;
|
|
30
32
|
};
|
|
31
33
|
|
|
34
|
+
export type CheckPathAccessMode = "read" | "write" | "execute" | "location" | "unknown";
|
|
35
|
+
|
|
36
|
+
export type CheckPathAccess = {
|
|
37
|
+
path: string;
|
|
38
|
+
/** Deterministic absolute path resolved during Check mode analysis. */
|
|
39
|
+
resolvedPath: string;
|
|
40
|
+
mode: CheckPathAccessMode;
|
|
41
|
+
source: "operand" | "redirection" | "executable";
|
|
42
|
+
stage: number;
|
|
43
|
+
external: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type CheckNetworkIntent = {
|
|
47
|
+
access: "none" | "host";
|
|
48
|
+
commands: string[];
|
|
49
|
+
};
|
|
50
|
+
|
|
32
51
|
export type BashOperator = {
|
|
33
52
|
operator: ";;&" | ";;" | ";&" | ";" | "newline" | "&&" | "||" | "&" | "|" | "|&";
|
|
34
53
|
start: number;
|
|
@@ -80,11 +99,15 @@ export type BashAnalysis = {
|
|
|
80
99
|
};
|
|
81
100
|
|
|
82
101
|
export type CheckPolicyResult = {
|
|
102
|
+
/** Exact command text, or canonical direct argv text, that produced this result. */
|
|
103
|
+
exactCommand: string;
|
|
83
104
|
profile: CheckPolicyProfile;
|
|
84
105
|
decision: CheckPolicyDecision;
|
|
85
106
|
reason: string;
|
|
86
107
|
findings: CheckPolicyFinding[];
|
|
108
|
+
accesses: CheckPathAccess[];
|
|
87
109
|
analysis: BashAnalysis;
|
|
110
|
+
network: CheckNetworkIntent;
|
|
88
111
|
};
|
|
89
112
|
|
|
90
113
|
export type CheckPolicyLimits = {
|
|
@@ -109,6 +132,8 @@ export type AnalyzeCheckPolicyOptions = {
|
|
|
109
132
|
profile?: CheckPolicyProfile;
|
|
110
133
|
limits?: Partial<CheckPolicyLimits>;
|
|
111
134
|
fileSystem?: CheckPolicyFileSystem;
|
|
135
|
+
/** Canonical sensitive roots that no checked command may access. */
|
|
136
|
+
protectedPaths?: readonly string[];
|
|
112
137
|
};
|
|
113
138
|
|
|
114
139
|
export type ProcessCheckOperation = "create" | "start" | "resume" | "repeat" | "invoke-run";
|
|
@@ -132,6 +157,8 @@ export type AnalyzeExecutablePolicyOptions = Pick<ProcessCheckProposal, "executa
|
|
|
132
157
|
profile?: CheckPolicyProfile;
|
|
133
158
|
limits?: Partial<CheckPolicyLimits>;
|
|
134
159
|
fileSystem?: CheckPolicyFileSystem;
|
|
160
|
+
/** Canonical sensitive roots that no checked process may access. */
|
|
161
|
+
protectedPaths?: readonly string[];
|
|
135
162
|
};
|
|
136
163
|
|
|
137
164
|
export const DEFAULT_CHECK_POLICY_LIMITS: Readonly<CheckPolicyLimits> = Object.freeze({
|
|
@@ -159,6 +186,7 @@ const MUTATION_COMMANDS = new Map<string, string>([
|
|
|
159
186
|
|
|
160
187
|
const SHELL_INTERPRETERS = new Set(["sh", "bash", "dash", "zsh", "ksh", "fish", "pwsh", "powershell", "cmd"]);
|
|
161
188
|
const REMOTE_COMMANDS = new Set(["curl", "wget", "fetch", "invoke-webrequest", "iwr"]);
|
|
189
|
+
const STDIN_NETWORK_COMMANDS = new Set(["nc", "ncat", "netcat", "socat", "ssh"]);
|
|
162
190
|
const PATH_OPERAND_COMMANDS = new Set([
|
|
163
191
|
"cat", "head", "tail", "less", "more", "stat", "file", "ls", "tree", "du", "wc", "realpath",
|
|
164
192
|
"readlink", "rm", "rmdir", "unlink", "shred", "mv", "cp", "install", "mkdir", "touch", "truncate",
|
|
@@ -177,6 +205,10 @@ const SAFE_READ_COMMANDS = new Set([
|
|
|
177
205
|
"pwd", "printf", "echo", "true", "false", "test", "[", "ls", "tree", "cat", "head", "tail",
|
|
178
206
|
"wc", "stat", "file", "du", "realpath", "readlink", "grep", "rg", "git",
|
|
179
207
|
]);
|
|
208
|
+
const EXPLICIT_READ_COMMANDS = new Set([
|
|
209
|
+
"cat", "head", "tail", "less", "more", "stat", "file", "ls", "tree", "du", "wc", "realpath", "readlink",
|
|
210
|
+
]);
|
|
211
|
+
const DATA_OPERAND_COMMANDS = new Set(["printf", "echo"]);
|
|
180
212
|
function commandName(argv0: string | undefined): string {
|
|
181
213
|
if (!argv0) return "";
|
|
182
214
|
const basename = argv0.replaceAll("\\", "/").split("/").at(-1) ?? argv0;
|
|
@@ -570,6 +602,7 @@ function isExactPosixNullDevice(value: string, cwd: string): boolean {
|
|
|
570
602
|
function pathOperands(stage: BashStage): string[] {
|
|
571
603
|
const name = commandName(stage.argv[0]);
|
|
572
604
|
const values = stage.redirections.flatMap((item) => item.target ? [item.target] : []);
|
|
605
|
+
if (DATA_OPERAND_COMMANDS.has(name)) return [...new Set(values)];
|
|
573
606
|
const pathValueOptions = new Set(["-C", "--cwd", "--directory", "--chdir", "--git-dir", "--work-tree", "--prefix"]);
|
|
574
607
|
for (let index = 1; index < stage.argv.length; index++) {
|
|
575
608
|
const value = stage.argv[index]!;
|
|
@@ -592,6 +625,220 @@ function pathOperands(stage: BashStage): string[] {
|
|
|
592
625
|
return [...new Set(values)];
|
|
593
626
|
}
|
|
594
627
|
|
|
628
|
+
type ClassifiedAccess = Omit<CheckPathAccess, "stage" | "external" | "resolvedPath">;
|
|
629
|
+
|
|
630
|
+
function positionalOperands(argv: string[]): string[] {
|
|
631
|
+
const values: string[] = [];
|
|
632
|
+
for (let index = 1; index < argv.length; index++) {
|
|
633
|
+
const value = argv[index]!;
|
|
634
|
+
if (value === "--") return [...values, ...argv.slice(index + 1)];
|
|
635
|
+
if (!value.startsWith("-") || value === "-") values.push(value);
|
|
636
|
+
}
|
|
637
|
+
return values;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function grepReadOperands(argv: string[]): ClassifiedAccess[] {
|
|
641
|
+
const accesses: ClassifiedAccess[] = [];
|
|
642
|
+
let hasExplicitPattern = false;
|
|
643
|
+
let consumedPattern = false;
|
|
644
|
+
const valueOptions = new Set(["-A", "-B", "-C", "-m", "--after-context", "--before-context", "--context", "--max-count", "--type", "-t", "-T", "-g", "--glob"]);
|
|
645
|
+
for (let index = 1; index < argv.length; index++) {
|
|
646
|
+
const value = argv[index]!;
|
|
647
|
+
if (value === "--") {
|
|
648
|
+
const remaining = argv.slice(index + 1);
|
|
649
|
+
if (!hasExplicitPattern && !consumedPattern) remaining.shift();
|
|
650
|
+
accesses.push(...remaining.map((path) => ({ path, mode: "read" as const, source: "operand" as const })));
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
if (["-e", "--regexp"].includes(value)) {
|
|
654
|
+
hasExplicitPattern = true;
|
|
655
|
+
index++;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (["-f", "--file"].includes(value) && argv[index + 1]) {
|
|
659
|
+
hasExplicitPattern = true;
|
|
660
|
+
accesses.push({ path: argv[++index]!, mode: "read", source: "operand" });
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
const fileOption = value.match(/^(?:--file|-f)=(.+)$/)?.[1];
|
|
664
|
+
if (fileOption) {
|
|
665
|
+
hasExplicitPattern = true;
|
|
666
|
+
accesses.push({ path: fileOption, mode: "read", source: "operand" });
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (["--pre"].includes(value) && argv[index + 1]) {
|
|
670
|
+
accesses.push({ path: argv[++index]!, mode: "execute", source: "operand" });
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const preProgram = value.match(/^--pre=(.+)$/)?.[1];
|
|
674
|
+
if (preProgram) {
|
|
675
|
+
accesses.push({ path: preProgram, mode: "execute", source: "operand" });
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
if (valueOptions.has(value)) {
|
|
679
|
+
index++;
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (value.startsWith("-")) continue;
|
|
683
|
+
if (!hasExplicitPattern && !consumedPattern) {
|
|
684
|
+
consumedPattern = true;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
accesses.push({ path: value, mode: "read", source: "operand" });
|
|
688
|
+
}
|
|
689
|
+
return accesses;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function interpreterAccesses(name: string, argv: string[]): ClassifiedAccess[] {
|
|
693
|
+
const accesses: ClassifiedAccess[] = [];
|
|
694
|
+
const inlineFlags = name === "cmd" ? new Set(["/c", "/k"])
|
|
695
|
+
: name === "powershell" || name === "pwsh" ? new Set(["-c", "-command", "-encodedcommand", "-enc"])
|
|
696
|
+
: new Set(["-c", "-e", "-E", "--eval", "--print", "-p"]);
|
|
697
|
+
const executionOptions = new Set(["-r", "--require", "--import", "--loader", "--preload", "--import-map", "--rcfile", "--init-file", "-file"]);
|
|
698
|
+
let inline = false;
|
|
699
|
+
for (let index = 1; index < argv.length; index++) {
|
|
700
|
+
const value = argv[index]!;
|
|
701
|
+
const lower = value.toLowerCase();
|
|
702
|
+
if (inlineFlags.has(lower)) {
|
|
703
|
+
inline = true;
|
|
704
|
+
index++;
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
if (executionOptions.has(lower) && argv[index + 1]) {
|
|
708
|
+
accesses.push({ path: argv[++index]!, mode: "execute", source: "operand" });
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
const executionOption = value.match(/^(?:--require|--import|--loader|--preload|--import-map|--rcfile|--init-file)=(.+)$/i)?.[1]
|
|
712
|
+
?? value.match(/^-I(.+)$/)?.[1];
|
|
713
|
+
if (executionOption) {
|
|
714
|
+
accesses.push({ path: executionOption, mode: "execute", source: "operand" });
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (value.startsWith("-")) continue;
|
|
718
|
+
if (!inline) accesses.push({ path: value, mode: "execute", source: "operand" });
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
return accesses;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function classifyStageAccesses(stage: BashStage): ClassifiedAccess[] {
|
|
725
|
+
const argv = effectiveArgv(stage.argv);
|
|
726
|
+
const name = commandName(argv[0]);
|
|
727
|
+
const accesses: ClassifiedAccess[] = [];
|
|
728
|
+
if (argv[0] && looksLikePath(argv[0])) accesses.push({ path: argv[0], mode: "execute", source: "executable" });
|
|
729
|
+
|
|
730
|
+
for (const redirection of stage.redirections) {
|
|
731
|
+
if (!redirection.target) continue;
|
|
732
|
+
const operator = redirection.operator.replace(/^\d+/, "");
|
|
733
|
+
if (["<<", "<<-", "<<<", "<&"].includes(operator)) continue;
|
|
734
|
+
if (operator === ">&" && (/^\d+$/.test(redirection.target) || redirection.target === "-")) continue;
|
|
735
|
+
const interpreterInput = operator === "<"
|
|
736
|
+
&& (SHELL_INTERPRETERS.has(name) || ["node", "bun", "deno", "python", "python3", "ruby", "perl", "php", "java"].includes(name));
|
|
737
|
+
accesses.push({
|
|
738
|
+
path: redirection.target,
|
|
739
|
+
mode: operator === "<" ? (interpreterInput ? "execute" : "read") : "write",
|
|
740
|
+
source: "redirection",
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (DATA_OPERAND_COMMANDS.has(name)) return accesses;
|
|
745
|
+
if (["cd", "chdir", "set-location"].includes(name)) {
|
|
746
|
+
return [...accesses, ...positionalOperands(argv).map((path) => ({ path, mode: "location" as const, source: "operand" as const }))];
|
|
747
|
+
}
|
|
748
|
+
if (EXPLICIT_READ_COMMANDS.has(name)) {
|
|
749
|
+
return [...accesses, ...positionalOperands(argv).map((path) => ({ path, mode: "read" as const, source: "operand" as const }))];
|
|
750
|
+
}
|
|
751
|
+
if (name === "grep" || name === "rg") return [...accesses, ...grepReadOperands(argv)];
|
|
752
|
+
if (REMOTE_COMMANDS.has(name)) {
|
|
753
|
+
const uploadOptions = new Set(["-d", "--data", "--data-binary", "--data-raw", "--data-urlencode", "-F", "--form", "-T", "--upload-file", "--post-file"]);
|
|
754
|
+
for (let index = 1; index < argv.length; index++) {
|
|
755
|
+
const value = argv[index]!;
|
|
756
|
+
if (uploadOptions.has(value) && argv[index + 1]) {
|
|
757
|
+
const operand = argv[++index]!;
|
|
758
|
+
const atPath = operand.match(/@(.+)$/)?.[1];
|
|
759
|
+
const path = atPath ?? (value === "-T" || value === "--upload-file" || value === "--post-file" ? operand : undefined);
|
|
760
|
+
if (path && path !== "-") accesses.push({ path, mode: "read", source: "operand" });
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (["-InFile", "-Body"].includes(value) && argv[index + 1]) {
|
|
764
|
+
const operand = argv[++index]!;
|
|
765
|
+
const path = operand.startsWith("@") ? operand.slice(1) : operand;
|
|
766
|
+
if (path !== "-") accesses.push({ path, mode: "read", source: "operand" });
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const attached = value.match(/^(?:--data|--data-binary|--data-raw|--data-urlencode|--form|--upload-file|--post-file)=(.+)$/)?.[1];
|
|
770
|
+
if (attached) {
|
|
771
|
+
const atPath = attached.match(/@(.+)$/)?.[1];
|
|
772
|
+
const path = atPath ?? (/^--(?:upload-file|post-file)=/.test(value) ? attached : undefined);
|
|
773
|
+
if (path && path !== "-") accesses.push({ path, mode: "read", source: "operand" });
|
|
774
|
+
}
|
|
775
|
+
const configPath = value.match(/^--config=(.+)$/)?.[1];
|
|
776
|
+
if (configPath) accesses.push({ path: configPath, mode: "execute", source: "operand" });
|
|
777
|
+
else if (value === "--config" && argv[index + 1]) accesses.push({ path: argv[++index]!, mode: "execute", source: "operand" });
|
|
778
|
+
}
|
|
779
|
+
return accesses;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const operands = positionalOperands(argv);
|
|
783
|
+
if (name === "cp" || name === "install") {
|
|
784
|
+
const targetOption = argv.find((arg) => arg.startsWith("--target-directory="))?.slice("--target-directory=".length);
|
|
785
|
+
const targetIndex = argv.findIndex((arg) => arg === "-t" || arg === "--target-directory");
|
|
786
|
+
const target = targetOption ?? (targetIndex >= 0 ? argv[targetIndex + 1] : undefined) ?? operands.at(-1);
|
|
787
|
+
const sources = targetIndex >= 0 || targetOption ? operands.filter((path) => path !== target) : operands.slice(0, -1);
|
|
788
|
+
accesses.push(...sources.map((path) => ({ path, mode: "read" as const, source: "operand" as const })));
|
|
789
|
+
if (target) accesses.push({ path: target, mode: "write", source: "operand" });
|
|
790
|
+
return accesses;
|
|
791
|
+
}
|
|
792
|
+
if (name === "mv") return [...accesses, ...operands.map((path) => ({ path, mode: "write" as const, source: "operand" as const }))];
|
|
793
|
+
if (["rm", "rmdir", "unlink", "shred", "mkdir", "touch", "truncate", "chmod", "chown", "chgrp", "setfacl", "tee"].includes(name)) {
|
|
794
|
+
return [...accesses, ...operands.map((path) => ({ path, mode: "write" as const, source: "operand" as const }))];
|
|
795
|
+
}
|
|
796
|
+
if (name === "dd") {
|
|
797
|
+
for (const value of argv.slice(1)) {
|
|
798
|
+
const match = /^(if|of)=(.+)$/.exec(value);
|
|
799
|
+
if (match) accesses.push({ path: match[2]!, mode: match[1] === "if" ? "read" : "write", source: "operand" });
|
|
800
|
+
}
|
|
801
|
+
return accesses;
|
|
802
|
+
}
|
|
803
|
+
if (name === "sed") {
|
|
804
|
+
const inPlace = argv.slice(1).some((arg) => arg === "--in-place" || arg.startsWith("--in-place=") || /^-[^-]*i/.test(arg));
|
|
805
|
+
const programFiles: string[] = [];
|
|
806
|
+
for (let index = 1; index < argv.length; index++) {
|
|
807
|
+
if ((argv[index] === "-f" || argv[index] === "--file") && argv[index + 1]) programFiles.push(argv[++index]!);
|
|
808
|
+
else {
|
|
809
|
+
const program = argv[index]!.match(/^(?:--file|-f)=(.+)$/)?.[1] ?? argv[index]!.match(/^-f(.+)$/)?.[1];
|
|
810
|
+
if (program) programFiles.push(program);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
accesses.push(...programFiles.map((path) => ({ path, mode: "execute" as const, source: "operand" as const })));
|
|
814
|
+
const files = operands.slice(argv.slice(1).some((arg) => arg === "-e" || arg === "--expression" || arg === "-f" || arg === "--file") ? 0 : 1)
|
|
815
|
+
.filter((path) => !programFiles.includes(path));
|
|
816
|
+
accesses.push(...files.map((path) => ({ path, mode: inPlace ? "write" as const : "read" as const, source: "operand" as const })));
|
|
817
|
+
return accesses;
|
|
818
|
+
}
|
|
819
|
+
if (name === "perl") {
|
|
820
|
+
const inPlace = argv.slice(1).some((arg) => arg === "--in-place" || arg.startsWith("--in-place=") || /^-[^-]*i/.test(arg));
|
|
821
|
+
const inline = argv.slice(1).some((arg) => arg === "-e" || arg === "-E");
|
|
822
|
+
if (!inline) return [...accesses, ...interpreterAccesses(name, argv)];
|
|
823
|
+
return [...accesses, ...operands.map((path) => ({ path, mode: inPlace ? "write" as const : "read" as const, source: "operand" as const }))];
|
|
824
|
+
}
|
|
825
|
+
if (name === "bun" && ["test", "run"].includes(argv[1] ?? "")) {
|
|
826
|
+
return [...accesses, ...argv.slice(2).filter(looksLikePath).map((path) => ({ path, mode: "execute" as const, source: "operand" as const }))];
|
|
827
|
+
}
|
|
828
|
+
if (SHELL_INTERPRETERS.has(name) || ["node", "bun", "deno", "python", "python3", "ruby", "php", "java"].includes(name)) {
|
|
829
|
+
return [...accesses, ...interpreterAccesses(name, argv)];
|
|
830
|
+
}
|
|
831
|
+
const optionPaths = argv.slice(1).flatMap((argument) => {
|
|
832
|
+
const value = argument.includes("=") ? argument.slice(argument.indexOf("=") + 1) : undefined;
|
|
833
|
+
return value && looksLikePath(value) ? [value] : [];
|
|
834
|
+
});
|
|
835
|
+
const possiblePaths = [...pathOperands(stage), ...optionPaths].filter((path) => looksLikePath(path) && !/[\s$`]/.test(path));
|
|
836
|
+
if (PATH_OPERAND_COMMANDS.has(name) || possiblePaths.length > 0) {
|
|
837
|
+
accesses.push(...possiblePaths.map((path) => ({ path, mode: "unknown" as const, source: "operand" as const })));
|
|
838
|
+
}
|
|
839
|
+
return accesses;
|
|
840
|
+
}
|
|
841
|
+
|
|
595
842
|
function symlinkEscapes(path: string, root: string, fs: CheckPolicyFileSystem): boolean {
|
|
596
843
|
const flavor = pathFlavor(path, root);
|
|
597
844
|
let current = path;
|
|
@@ -617,11 +864,16 @@ function additionalRootProblem(
|
|
|
617
864
|
): "missing" | "link" | undefined {
|
|
618
865
|
try {
|
|
619
866
|
if (!fs.exists(root)) return "missing";
|
|
620
|
-
if (fs.isSymbolicLink(root)) return "link";
|
|
621
867
|
const flavor = pathFlavor(root, root);
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
868
|
+
const resolved = flavor.resolve(root);
|
|
869
|
+
const parsed = flavor.parse(resolved);
|
|
870
|
+
let component = parsed.root;
|
|
871
|
+
for (const part of resolved.slice(parsed.root.length).split(/[\\/]+/).filter(Boolean)) {
|
|
872
|
+
component = flavor.join(component, part);
|
|
873
|
+
if (fs.isSymbolicLink(component)) return "link";
|
|
874
|
+
}
|
|
875
|
+
fs.realpath(root);
|
|
876
|
+
return undefined;
|
|
625
877
|
} catch {
|
|
626
878
|
return "link";
|
|
627
879
|
}
|
|
@@ -631,18 +883,46 @@ function addFinding(findings: CheckPolicyFinding[], finding: CheckPolicyFinding)
|
|
|
631
883
|
if (!findings.some((item) => item.code === finding.code && item.stage === finding.stage && item.path === finding.path)) findings.push(finding);
|
|
632
884
|
}
|
|
633
885
|
|
|
886
|
+
function insideProtectedPath(path: string, protectedPaths: readonly string[]): boolean {
|
|
887
|
+
return protectedPaths.some((protectedPath) => {
|
|
888
|
+
const flavor = pathFlavor(path, protectedPath);
|
|
889
|
+
const relative = flavor.relative(flavor.resolve(protectedPath), flavor.resolve(path));
|
|
890
|
+
return relative === "" || (!relative.startsWith("..") && !flavor.isAbsolute(relative));
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function externalPathUsesLink(path: string, fs: CheckPolicyFileSystem): boolean {
|
|
895
|
+
const flavor = pathFlavor(path, path);
|
|
896
|
+
const resolved = flavor.resolve(path);
|
|
897
|
+
const parsed = flavor.parse(resolved);
|
|
898
|
+
let component = parsed.root;
|
|
899
|
+
try {
|
|
900
|
+
for (const part of resolved.slice(parsed.root.length).split(/[\\/]+/).filter(Boolean)) {
|
|
901
|
+
component = flavor.join(component, part);
|
|
902
|
+
if (!fs.exists(component)) return false;
|
|
903
|
+
if (fs.isSymbolicLink(component)) return true;
|
|
904
|
+
}
|
|
905
|
+
return false;
|
|
906
|
+
} catch {
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
634
911
|
function inspectHardBlocks(
|
|
635
912
|
analysis: BashAnalysis,
|
|
636
913
|
cwd: string,
|
|
637
914
|
fs: CheckPolicyFileSystem,
|
|
915
|
+
profile: CheckPolicyProfile,
|
|
638
916
|
depth = 0,
|
|
639
917
|
projectCwd = cwd,
|
|
640
918
|
allowedPaths: readonly string[] = [],
|
|
641
|
-
|
|
919
|
+
protectedPaths: readonly string[] = [],
|
|
920
|
+
): { findings: CheckPolicyFinding[]; accesses: CheckPathAccess[] } {
|
|
642
921
|
const findings: CheckPolicyFinding[] = [];
|
|
922
|
+
const accesses: CheckPathAccess[] = [];
|
|
643
923
|
if (!analysis.complete) {
|
|
644
924
|
addFinding(findings, { code: analysis.truncated ? "analysis-limit" : "unbalanced-shell", severity: "hard-block", message: analysis.errors.join("; ") || "shell analysis is incomplete" });
|
|
645
|
-
return findings;
|
|
925
|
+
return { findings, accesses };
|
|
646
926
|
}
|
|
647
927
|
|
|
648
928
|
let activeCwd = cwd;
|
|
@@ -689,17 +969,44 @@ function inspectHardBlocks(
|
|
|
689
969
|
}
|
|
690
970
|
|
|
691
971
|
const effectiveStage = argv === stage.argv ? stage : { ...stage, argv };
|
|
692
|
-
for (const
|
|
693
|
-
if (
|
|
972
|
+
for (const redirection of stage.redirections) {
|
|
973
|
+
if (!redirection.target) {
|
|
974
|
+
addFinding(findings, { code: "unknown-path-access", severity: "hard-block", message: "redirection target is missing", stage: stage.index });
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
for (const classified of classifyStageAccesses(effectiveStage)) {
|
|
978
|
+
const value = classified.path;
|
|
979
|
+
const dynamic = /(?:\$\(|`|\$\{|\$[A-Za-z_]|%[A-Za-z_][A-Za-z0-9_]*%)/.test(value);
|
|
980
|
+
const resolved = normalizedAbsolute(value, activeCwd, projectCwd, allowedPaths);
|
|
981
|
+
accesses.push({ ...classified, resolvedPath: resolved.absolute, stage: stage.index, external: !resolved.inside });
|
|
982
|
+
if (dynamic) {
|
|
983
|
+
addFinding(findings, { code: "unknown-path-access", severity: "hard-block", message: "path access is dynamic or ambiguous", stage: stage.index, path: value });
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
if (isCredentialSensitivePath(value) || insideProtectedPath(resolved.absolute, protectedPaths)) {
|
|
987
|
+
addFinding(findings, { code: "credential-access", severity: "hard-block", message: "command accesses a credential, secret, or PUM configuration path", stage: stage.index, path: value });
|
|
988
|
+
}
|
|
694
989
|
if (isExactPosixNullDevice(value, activeCwd)) continue;
|
|
695
990
|
if (value.startsWith("~")) {
|
|
696
|
-
addFinding(findings, { code: "outside-project", severity: "hard-block", message: "home-relative path
|
|
991
|
+
addFinding(findings, { code: "outside-project", severity: "hard-block", message: "home-relative path cannot be resolved safely", stage: stage.index, path: value });
|
|
697
992
|
continue;
|
|
698
993
|
}
|
|
699
|
-
const resolved = normalizedAbsolute(value, activeCwd, projectCwd, allowedPaths);
|
|
700
994
|
const rootProblem = resolved.additional ? additionalRootProblem(resolved.root, fs) : undefined;
|
|
701
995
|
if (!resolved.inside) {
|
|
702
|
-
|
|
996
|
+
if (profile !== "balanced" || classified.mode !== "read") {
|
|
997
|
+
const message = classified.mode === "write"
|
|
998
|
+
? "write access resolves outside the project and approved roots"
|
|
999
|
+
: classified.mode === "execute"
|
|
1000
|
+
? "execution operand resolves outside the project and approved roots"
|
|
1001
|
+
: classified.mode === "location"
|
|
1002
|
+
? "directory change resolves outside the project and approved roots"
|
|
1003
|
+
: classified.mode === "unknown"
|
|
1004
|
+
? "unknown path access resolves outside the project and approved roots"
|
|
1005
|
+
: "path resolves outside the project boundary";
|
|
1006
|
+
addFinding(findings, { code: classified.mode === "unknown" ? "unknown-path-access" : "outside-project", severity: "hard-block", message, stage: stage.index, path: value });
|
|
1007
|
+
} else if (externalPathUsesLink(resolved.absolute, fs)) {
|
|
1008
|
+
addFinding(findings, { code: "escaping-symlink", severity: "hard-block", message: "external read traverses a symlink or junction", stage: stage.index, path: value });
|
|
1009
|
+
}
|
|
703
1010
|
} else if (rootProblem === "missing") {
|
|
704
1011
|
addFinding(findings, { code: "outside-project", severity: "hard-block", message: "additional Check mode path no longer exists", stage: stage.index, path: value });
|
|
705
1012
|
} else if (rootProblem === "link") {
|
|
@@ -736,8 +1043,9 @@ function inspectHardBlocks(
|
|
|
736
1043
|
: SHELL_INTERPRETERS.has(name) ? argv.findIndex((arg) => arg === "-c") : -1;
|
|
737
1044
|
if (commandFlag >= 0 && argv[commandFlag + 1]) nestedCommands.push(argv[commandFlag + 1]!);
|
|
738
1045
|
for (const nestedCommand of nestedCommands) {
|
|
739
|
-
const
|
|
740
|
-
for (const
|
|
1046
|
+
const nested = inspectHardBlocks(analyzeBashCommand(nestedCommand), activeCwd, fs, profile, depth + 1, projectCwd, allowedPaths, protectedPaths);
|
|
1047
|
+
for (const access of nested.accesses) accesses.push({ ...access, stage: stage.index });
|
|
1048
|
+
for (const finding of nested.findings) {
|
|
741
1049
|
addFinding(findings, { ...finding, stage: stage.index, message: `nested command: ${finding.message}` });
|
|
742
1050
|
}
|
|
743
1051
|
}
|
|
@@ -757,7 +1065,24 @@ function inspectHardBlocks(
|
|
|
757
1065
|
addFinding(findings, { code: "remote-script-execution", severity: "hard-block", message: "command executes code received from a remote source", stage: stage.index });
|
|
758
1066
|
}
|
|
759
1067
|
}
|
|
760
|
-
|
|
1068
|
+
|
|
1069
|
+
const externalReadStages = new Set(accesses.filter((access) => access.external && access.mode === "read").map((access) => access.stage));
|
|
1070
|
+
for (let index = 0; index < analysis.stages.length; index++) {
|
|
1071
|
+
const stage = analysis.stages[index]!;
|
|
1072
|
+
const argv = effectiveArgv(stage.argv);
|
|
1073
|
+
const name = commandName(argv[0]);
|
|
1074
|
+
if (!REMOTE_COMMANDS.has(name) && !STDIN_NETWORK_COMMANDS.has(name)) continue;
|
|
1075
|
+
const hasUpload = STDIN_NETWORK_COMMANDS.has(name)
|
|
1076
|
+
|| argv.slice(1).some((arg) => /^(?:-d|-F|-T|--data(?:-binary|-raw|-urlencode)?|--form|--upload-file|--post-file|-InFile|-Body)(?:=|$)/i.test(arg));
|
|
1077
|
+
const directlyReadsExternal = accesses.some((access) => access.stage === stage.index && access.external && access.mode === "read");
|
|
1078
|
+
const pipedExternalRead = hasUpload && index > 0
|
|
1079
|
+
&& (analysis.stages[index - 1]?.operatorAfter === "|" || analysis.stages[index - 1]?.operatorAfter === "|&")
|
|
1080
|
+
&& externalReadStages.has(index - 1);
|
|
1081
|
+
if (directlyReadsExternal || pipedExternalRead) {
|
|
1082
|
+
addFinding(findings, { code: "external-read-exfiltration", severity: "hard-block", message: "network command can upload data read outside the approved project roots", stage: stage.index });
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return { findings, accesses };
|
|
761
1086
|
}
|
|
762
1087
|
|
|
763
1088
|
function shellCommandFlag(name: string, argv: string[]): number {
|
|
@@ -854,37 +1179,88 @@ function balancedCompleteShellLimits(command: string): Partial<CheckPolicyLimits
|
|
|
854
1179
|
};
|
|
855
1180
|
}
|
|
856
1181
|
|
|
1182
|
+
const NETWORK_COMMANDS = new Set([
|
|
1183
|
+
"curl", "wget", "fetch", "invoke-webrequest", "iwr", "invoke-restmethod", "irm",
|
|
1184
|
+
"nc", "ncat", "netcat", "socat", "ssh", "scp", "sftp", "rsync", "ftp", "telnet",
|
|
1185
|
+
"gh", "glab",
|
|
1186
|
+
]);
|
|
1187
|
+
|
|
1188
|
+
function stageNetworkCommand(stage: BashStage): string | undefined {
|
|
1189
|
+
const argv = effectiveArgv(stage.argv);
|
|
1190
|
+
const name = commandName(argv[0]);
|
|
1191
|
+
if (NETWORK_COMMANDS.has(name)) return name;
|
|
1192
|
+
const subcommand = (argv[1] ?? "").toLowerCase();
|
|
1193
|
+
if (name === "git" && new Set(["clone", "fetch", "pull", "push", "ls-remote"]).has(subcommand)) return `git ${subcommand}`;
|
|
1194
|
+
if (["npm", "pnpm", "yarn"].includes(name)
|
|
1195
|
+
&& new Set(["add", "audit", "install", "publish", "search", "update", "upgrade", "view", "info"]).has(subcommand)) {
|
|
1196
|
+
return `${name} ${subcommand}`;
|
|
1197
|
+
}
|
|
1198
|
+
if (name === "bun" && new Set(["add", "install", "publish", "update"]).has(subcommand)) return `bun ${subcommand}`;
|
|
1199
|
+
if (["pip", "pip3"].includes(name) && new Set(["download", "index", "install", "search"]).has(subcommand)) return `${name} ${subcommand}`;
|
|
1200
|
+
if (name === "cargo" && new Set(["install", "login", "owner", "publish", "search"]).has(subcommand)) return `cargo ${subcommand}`;
|
|
1201
|
+
if (name === "go" && new Set(["get", "install"]).has(subcommand)) return `go ${subcommand}`;
|
|
1202
|
+
return undefined;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** Return deterministic network intent from the same shell analysis used by Check mode. */
|
|
1206
|
+
export function checkNetworkIntent(analysis: BashAnalysis, depth = 0): CheckNetworkIntent {
|
|
1207
|
+
const commands: string[] = [];
|
|
1208
|
+
for (const stage of analysis.stages) {
|
|
1209
|
+
const direct = stageNetworkCommand(stage);
|
|
1210
|
+
if (direct) commands.push(direct);
|
|
1211
|
+
if (depth >= 2) continue;
|
|
1212
|
+
const argv = effectiveArgv(stage.argv);
|
|
1213
|
+
const nested = stage.substitutions.map((substitution) => substitution.text);
|
|
1214
|
+
const flag = shellCommandFlag(commandName(argv[0]), argv);
|
|
1215
|
+
if (flag >= 0 && argv[flag + 1]) nested.push(argv[flag + 1]!);
|
|
1216
|
+
for (const command of nested) {
|
|
1217
|
+
commands.push(...checkNetworkIntent(analyzeBashCommand(command), depth + 1).commands);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
const unique = [...new Set(commands)].sort();
|
|
1221
|
+
return { access: unique.length > 0 ? "host" : "none", commands: unique };
|
|
1222
|
+
}
|
|
1223
|
+
|
|
857
1224
|
/** Analyze a bash tool call and return a deterministic profile decision. */
|
|
858
1225
|
export function analyzeCheckPolicy(options: AnalyzeCheckPolicyOptions): CheckPolicyResult {
|
|
859
1226
|
const profile = options.profile ?? "balanced";
|
|
1227
|
+
const exactCommand = options.command;
|
|
860
1228
|
const limits = options.limits ?? (profile === "balanced" ? balancedCompleteShellLimits(options.command) : undefined);
|
|
861
1229
|
const analysis = analyzeBashCommand(options.command, limits);
|
|
862
|
-
const findings = inspectHardBlocks(
|
|
1230
|
+
const { findings, accesses } = inspectHardBlocks(
|
|
863
1231
|
analysis,
|
|
864
1232
|
options.cwd,
|
|
865
1233
|
options.fileSystem ?? nodeFileSystem,
|
|
1234
|
+
profile,
|
|
866
1235
|
0,
|
|
867
1236
|
options.cwd,
|
|
868
1237
|
options.allowedPaths,
|
|
1238
|
+
options.protectedPaths,
|
|
869
1239
|
);
|
|
1240
|
+
const network = checkNetworkIntent(analysis);
|
|
870
1241
|
if (findings.some((item) => item.severity === "hard-block")) {
|
|
871
|
-
return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
|
|
1242
|
+
return { exactCommand, profile, decision: "block", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
872
1243
|
}
|
|
873
1244
|
|
|
874
1245
|
if (profile === "ask") {
|
|
875
1246
|
addFinding(findings, { code: "unrecognized-command", severity: "review", message: "ask profile requires review for every command" });
|
|
876
|
-
return { profile, decision: "ask", reason: findings[0]!.message, findings, analysis };
|
|
1247
|
+
return { exactCommand, profile, decision: "ask", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
877
1248
|
}
|
|
878
1249
|
|
|
879
1250
|
for (const finding of inspectSuspiciousExecution(analysis)) addFinding(findings, finding);
|
|
880
1251
|
if (profile === "balanced") {
|
|
881
|
-
if (findings.length) return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
|
|
1252
|
+
if (findings.length) return { exactCommand, profile, decision: "block", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
882
1253
|
return {
|
|
1254
|
+
exactCommand,
|
|
883
1255
|
profile,
|
|
884
1256
|
decision: "allow",
|
|
885
|
-
reason:
|
|
1257
|
+
reason: accesses.some((access) => access.external)
|
|
1258
|
+
? "complete command uses only deterministically classified external reads"
|
|
1259
|
+
: "complete project-local command passed deterministic hard rules",
|
|
886
1260
|
findings: [],
|
|
1261
|
+
accesses,
|
|
887
1262
|
analysis,
|
|
1263
|
+
network,
|
|
888
1264
|
};
|
|
889
1265
|
}
|
|
890
1266
|
|
|
@@ -894,11 +1270,11 @@ export function analyzeCheckPolicy(options: AnalyzeCheckPolicyOptions): CheckPol
|
|
|
894
1270
|
|
|
895
1271
|
const allNarrowReads = !hasComplexShell && analysis.stages.length === 1 && analysis.stages.every(isNarrowRead);
|
|
896
1272
|
if (allNarrowReads) {
|
|
897
|
-
return { profile, decision: "allow", reason: "narrow project-local inspection", findings: [], analysis };
|
|
1273
|
+
return { exactCommand, profile, decision: "allow", reason: "narrow project-local inspection", findings: [], accesses, analysis, network };
|
|
898
1274
|
}
|
|
899
1275
|
|
|
900
1276
|
if (!findings.length) addFinding(findings, { code: "unrecognized-command", severity: "review", message: "command is not a rigorously narrow built-in policy case" });
|
|
901
|
-
return { profile, decision: "ask", reason: findings[0]!.message, findings, analysis };
|
|
1277
|
+
return { exactCommand, profile, decision: "ask", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
902
1278
|
}
|
|
903
1279
|
|
|
904
1280
|
/** Analyze direct executable arguments without converting the arguments to shell text. */
|
|
@@ -927,11 +1303,12 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
|
|
|
927
1303
|
}
|
|
928
1304
|
|
|
929
1305
|
const argv = [options.executable, ...options.args];
|
|
1306
|
+
const exactCommand = JSON.stringify(argv);
|
|
930
1307
|
const stage: BashStage = {
|
|
931
1308
|
index: 0,
|
|
932
1309
|
start: 0,
|
|
933
1310
|
end: 0,
|
|
934
|
-
text:
|
|
1311
|
+
text: exactCommand,
|
|
935
1312
|
pipeline: 0,
|
|
936
1313
|
argv,
|
|
937
1314
|
envAssignments: {},
|
|
@@ -952,7 +1329,17 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
|
|
|
952
1329
|
};
|
|
953
1330
|
const projectCwd = options.projectCwd ?? options.cwd;
|
|
954
1331
|
const fs = options.fileSystem ?? nodeFileSystem;
|
|
955
|
-
const findings = inspectHardBlocks(
|
|
1332
|
+
const { findings, accesses } = inspectHardBlocks(
|
|
1333
|
+
analysis,
|
|
1334
|
+
options.cwd,
|
|
1335
|
+
fs,
|
|
1336
|
+
profile,
|
|
1337
|
+
0,
|
|
1338
|
+
projectCwd,
|
|
1339
|
+
options.allowedPaths,
|
|
1340
|
+
options.protectedPaths,
|
|
1341
|
+
);
|
|
1342
|
+
const network = checkNetworkIntent(analysis);
|
|
956
1343
|
const executionDirectory = normalizedAbsolute(options.cwd, projectCwd, projectCwd, options.allowedPaths);
|
|
957
1344
|
const executionRootProblem = executionDirectory.additional
|
|
958
1345
|
? additionalRootProblem(executionDirectory.root, fs)
|
|
@@ -987,21 +1374,26 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
|
|
|
987
1374
|
});
|
|
988
1375
|
}
|
|
989
1376
|
if (findings.some((item) => item.severity === "hard-block")) {
|
|
990
|
-
return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
|
|
1377
|
+
return { exactCommand, profile, decision: "block", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
991
1378
|
}
|
|
992
1379
|
if (profile === "ask") {
|
|
993
1380
|
addFinding(findings, { code: "unrecognized-command", severity: "review", message: "ask profile requires review for every command" });
|
|
994
|
-
return { profile, decision: "ask", reason: findings[0]!.message, findings, analysis };
|
|
1381
|
+
return { exactCommand, profile, decision: "ask", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
995
1382
|
}
|
|
996
1383
|
for (const finding of inspectSuspiciousExecution(analysis)) addFinding(findings, finding);
|
|
997
1384
|
if (profile === "balanced") {
|
|
998
|
-
if (findings.length) return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
|
|
1385
|
+
if (findings.length) return { exactCommand, profile, decision: "block", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
999
1386
|
return {
|
|
1387
|
+
exactCommand,
|
|
1000
1388
|
profile,
|
|
1001
1389
|
decision: "allow",
|
|
1002
|
-
reason:
|
|
1390
|
+
reason: accesses.some((access) => access.external)
|
|
1391
|
+
? "complete process uses only deterministically classified external reads"
|
|
1392
|
+
: "complete project-local process passed deterministic hard rules",
|
|
1003
1393
|
findings: [],
|
|
1394
|
+
accesses,
|
|
1004
1395
|
analysis,
|
|
1396
|
+
network,
|
|
1005
1397
|
};
|
|
1006
1398
|
}
|
|
1007
1399
|
if (analysis.mutationIntent.possible) {
|
|
@@ -1010,15 +1402,18 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
|
|
|
1010
1402
|
const narrowRead = isNarrowRead(stage);
|
|
1011
1403
|
if (narrowRead) {
|
|
1012
1404
|
return {
|
|
1405
|
+
exactCommand,
|
|
1013
1406
|
profile,
|
|
1014
1407
|
decision: "allow",
|
|
1015
1408
|
reason: "narrow project-local inspection",
|
|
1016
1409
|
findings: [],
|
|
1410
|
+
accesses,
|
|
1017
1411
|
analysis,
|
|
1412
|
+
network,
|
|
1018
1413
|
};
|
|
1019
1414
|
}
|
|
1020
1415
|
if (!findings.length) {
|
|
1021
1416
|
addFinding(findings, { code: "unrecognized-command", severity: "review", message: "command is not a rigorously narrow built-in policy case" });
|
|
1022
1417
|
}
|
|
1023
|
-
return { profile, decision: "ask", reason: findings[0]!.message, findings, analysis };
|
|
1418
|
+
return { exactCommand, profile, decision: "ask", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
1024
1419
|
}
|