pum-agent 0.2.1-beta.1 → 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;
@@ -636,18 +883,46 @@ function addFinding(findings: CheckPolicyFinding[], finding: CheckPolicyFinding)
636
883
  if (!findings.some((item) => item.code === finding.code && item.stage === finding.stage && item.path === finding.path)) findings.push(finding);
637
884
  }
638
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
+
639
911
  function inspectHardBlocks(
640
912
  analysis: BashAnalysis,
641
913
  cwd: string,
642
914
  fs: CheckPolicyFileSystem,
915
+ profile: CheckPolicyProfile,
643
916
  depth = 0,
644
917
  projectCwd = cwd,
645
918
  allowedPaths: readonly string[] = [],
646
- ): CheckPolicyFinding[] {
919
+ protectedPaths: readonly string[] = [],
920
+ ): { findings: CheckPolicyFinding[]; accesses: CheckPathAccess[] } {
647
921
  const findings: CheckPolicyFinding[] = [];
922
+ const accesses: CheckPathAccess[] = [];
648
923
  if (!analysis.complete) {
649
924
  addFinding(findings, { code: analysis.truncated ? "analysis-limit" : "unbalanced-shell", severity: "hard-block", message: analysis.errors.join("; ") || "shell analysis is incomplete" });
650
- return findings;
925
+ return { findings, accesses };
651
926
  }
652
927
 
653
928
  let activeCwd = cwd;
@@ -694,17 +969,44 @@ function inspectHardBlocks(
694
969
  }
695
970
 
696
971
  const effectiveStage = argv === stage.argv ? stage : { ...stage, argv };
697
- for (const value of pathOperands(effectiveStage)) {
698
- 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
+ }
699
989
  if (isExactPosixNullDevice(value, activeCwd)) continue;
700
990
  if (value.startsWith("~")) {
701
- 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 });
702
992
  continue;
703
993
  }
704
- const resolved = normalizedAbsolute(value, activeCwd, projectCwd, allowedPaths);
705
994
  const rootProblem = resolved.additional ? additionalRootProblem(resolved.root, fs) : undefined;
706
995
  if (!resolved.inside) {
707
- 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
+ }
708
1010
  } else if (rootProblem === "missing") {
709
1011
  addFinding(findings, { code: "outside-project", severity: "hard-block", message: "additional Check mode path no longer exists", stage: stage.index, path: value });
710
1012
  } else if (rootProblem === "link") {
@@ -741,8 +1043,9 @@ function inspectHardBlocks(
741
1043
  : SHELL_INTERPRETERS.has(name) ? argv.findIndex((arg) => arg === "-c") : -1;
742
1044
  if (commandFlag >= 0 && argv[commandFlag + 1]) nestedCommands.push(argv[commandFlag + 1]!);
743
1045
  for (const nestedCommand of nestedCommands) {
744
- const nestedFindings = inspectHardBlocks(analyzeBashCommand(nestedCommand), cwd, fs, depth + 1, projectCwd, allowedPaths);
745
- 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) {
746
1049
  addFinding(findings, { ...finding, stage: stage.index, message: `nested command: ${finding.message}` });
747
1050
  }
748
1051
  }
@@ -762,7 +1065,24 @@ function inspectHardBlocks(
762
1065
  addFinding(findings, { code: "remote-script-execution", severity: "hard-block", message: "command executes code received from a remote source", stage: stage.index });
763
1066
  }
764
1067
  }
765
- 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 };
766
1086
  }
767
1087
 
768
1088
  function shellCommandFlag(name: string, argv: string[]): number {
@@ -859,37 +1179,88 @@ function balancedCompleteShellLimits(command: string): Partial<CheckPolicyLimits
859
1179
  };
860
1180
  }
861
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
+
862
1224
  /** Analyze a bash tool call and return a deterministic profile decision. */
863
1225
  export function analyzeCheckPolicy(options: AnalyzeCheckPolicyOptions): CheckPolicyResult {
864
1226
  const profile = options.profile ?? "balanced";
1227
+ const exactCommand = options.command;
865
1228
  const limits = options.limits ?? (profile === "balanced" ? balancedCompleteShellLimits(options.command) : undefined);
866
1229
  const analysis = analyzeBashCommand(options.command, limits);
867
- const findings = inspectHardBlocks(
1230
+ const { findings, accesses } = inspectHardBlocks(
868
1231
  analysis,
869
1232
  options.cwd,
870
1233
  options.fileSystem ?? nodeFileSystem,
1234
+ profile,
871
1235
  0,
872
1236
  options.cwd,
873
1237
  options.allowedPaths,
1238
+ options.protectedPaths,
874
1239
  );
1240
+ const network = checkNetworkIntent(analysis);
875
1241
  if (findings.some((item) => item.severity === "hard-block")) {
876
- return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
1242
+ return { exactCommand, profile, decision: "block", reason: findings[0]!.message, findings, accesses, analysis, network };
877
1243
  }
878
1244
 
879
1245
  if (profile === "ask") {
880
1246
  addFinding(findings, { code: "unrecognized-command", severity: "review", message: "ask profile requires review for every command" });
881
- return { profile, decision: "ask", reason: findings[0]!.message, findings, analysis };
1247
+ return { exactCommand, profile, decision: "ask", reason: findings[0]!.message, findings, accesses, analysis, network };
882
1248
  }
883
1249
 
884
1250
  for (const finding of inspectSuspiciousExecution(analysis)) addFinding(findings, finding);
885
1251
  if (profile === "balanced") {
886
- 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 };
887
1253
  return {
1254
+ exactCommand,
888
1255
  profile,
889
1256
  decision: "allow",
890
- 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",
891
1260
  findings: [],
1261
+ accesses,
892
1262
  analysis,
1263
+ network,
893
1264
  };
894
1265
  }
895
1266
 
@@ -899,11 +1270,11 @@ export function analyzeCheckPolicy(options: AnalyzeCheckPolicyOptions): CheckPol
899
1270
 
900
1271
  const allNarrowReads = !hasComplexShell && analysis.stages.length === 1 && analysis.stages.every(isNarrowRead);
901
1272
  if (allNarrowReads) {
902
- 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 };
903
1274
  }
904
1275
 
905
1276
  if (!findings.length) addFinding(findings, { code: "unrecognized-command", severity: "review", message: "command is not a rigorously narrow built-in policy case" });
906
- return { profile, decision: "ask", reason: findings[0]!.message, findings, analysis };
1277
+ return { exactCommand, profile, decision: "ask", reason: findings[0]!.message, findings, accesses, analysis, network };
907
1278
  }
908
1279
 
909
1280
  /** Analyze direct executable arguments without converting the arguments to shell text. */
@@ -932,11 +1303,12 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
932
1303
  }
933
1304
 
934
1305
  const argv = [options.executable, ...options.args];
1306
+ const exactCommand = JSON.stringify(argv);
935
1307
  const stage: BashStage = {
936
1308
  index: 0,
937
1309
  start: 0,
938
1310
  end: 0,
939
- text: JSON.stringify(argv),
1311
+ text: exactCommand,
940
1312
  pipeline: 0,
941
1313
  argv,
942
1314
  envAssignments: {},
@@ -957,7 +1329,17 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
957
1329
  };
958
1330
  const projectCwd = options.projectCwd ?? options.cwd;
959
1331
  const fs = options.fileSystem ?? nodeFileSystem;
960
- 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);
961
1343
  const executionDirectory = normalizedAbsolute(options.cwd, projectCwd, projectCwd, options.allowedPaths);
962
1344
  const executionRootProblem = executionDirectory.additional
963
1345
  ? additionalRootProblem(executionDirectory.root, fs)
@@ -992,21 +1374,26 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
992
1374
  });
993
1375
  }
994
1376
  if (findings.some((item) => item.severity === "hard-block")) {
995
- return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
1377
+ return { exactCommand, profile, decision: "block", reason: findings[0]!.message, findings, accesses, analysis, network };
996
1378
  }
997
1379
  if (profile === "ask") {
998
1380
  addFinding(findings, { code: "unrecognized-command", severity: "review", message: "ask profile requires review for every command" });
999
- return { profile, decision: "ask", reason: findings[0]!.message, findings, analysis };
1381
+ return { exactCommand, profile, decision: "ask", reason: findings[0]!.message, findings, accesses, analysis, network };
1000
1382
  }
1001
1383
  for (const finding of inspectSuspiciousExecution(analysis)) addFinding(findings, finding);
1002
1384
  if (profile === "balanced") {
1003
- 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 };
1004
1386
  return {
1387
+ exactCommand,
1005
1388
  profile,
1006
1389
  decision: "allow",
1007
- 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",
1008
1393
  findings: [],
1394
+ accesses,
1009
1395
  analysis,
1396
+ network,
1010
1397
  };
1011
1398
  }
1012
1399
  if (analysis.mutationIntent.possible) {
@@ -1015,15 +1402,18 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
1015
1402
  const narrowRead = isNarrowRead(stage);
1016
1403
  if (narrowRead) {
1017
1404
  return {
1405
+ exactCommand,
1018
1406
  profile,
1019
1407
  decision: "allow",
1020
1408
  reason: "narrow project-local inspection",
1021
1409
  findings: [],
1410
+ accesses,
1022
1411
  analysis,
1412
+ network,
1023
1413
  };
1024
1414
  }
1025
1415
  if (!findings.length) {
1026
1416
  addFinding(findings, { code: "unrecognized-command", severity: "review", message: "command is not a rigorously narrow built-in policy case" });
1027
1417
  }
1028
- return { profile, decision: "ask", reason: findings[0]!.message, findings, analysis };
1418
+ return { exactCommand, profile, decision: "ask", reason: findings[0]!.message, findings, accesses, analysis, network };
1029
1419
  }