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.
@@ -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
- return flavor.relative(flavor.resolve(root), flavor.resolve(fs.realpath(root))) === ""
623
- ? undefined
624
- : "link";
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
- ): CheckPolicyFinding[] {
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 value of pathOperands(effectiveStage)) {
693
- if (isCredentialSensitivePath(value)) addFinding(findings, { code: "credential-access", severity: "hard-block", message: "command accesses a credential or secret path", stage: stage.index, path: value });
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 is outside the project boundary", stage: stage.index, path: value });
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
- addFinding(findings, { code: "outside-project", severity: "hard-block", message: "path resolves outside the project boundary", stage: stage.index, path: value });
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 nestedFindings = inspectHardBlocks(analyzeBashCommand(nestedCommand), cwd, fs, depth + 1, projectCwd, allowedPaths);
740
- for (const finding of nestedFindings) {
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
- return findings;
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: "complete project-local command passed deterministic hard rules",
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: JSON.stringify(argv),
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(analysis, options.cwd, fs, 0, projectCwd, options.allowedPaths);
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: "complete project-local process passed deterministic hard rules",
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
  }