@lazyingart/agintiflow 0.20.218 → 0.20.220

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.218",
3
+ "version": "0.20.220",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
6
6
  "license": "Apache-2.0",
@@ -635,6 +635,83 @@ try {
635
635
  `unsafe or dynamic shell loop bypassed the bounded read-only policy: ${unsafeLoop}`
636
636
  );
637
637
  }
638
+ const exactCompoundReadOnlyAudit = [
639
+ 'echo "=== CWD ==="',
640
+ 'pwd',
641
+ 'echo "=== workspace listing ==="',
642
+ 'ls -la',
643
+ 'echo "=== workspace git status ==="',
644
+ 'git status --short 2>&1 | head -40',
645
+ 'echo "=== read roots ==="',
646
+ 'for d in /home/lachlan/ProjectsLFS/AgenticApp /home/lachlan/ProjectsLFS/Musia /home/lachlan/ProjectsLFS/LALACHAN /home/lachlan/DiskMech/Projects/lazyedit; do echo "--- $d ---"; if [ -d "$d" ]; then ls -la "$d" 2>&1 | head -80; else echo "MISSING"; fi; done',
647
+ ].join("; ");
648
+ const exactCompoundReadOnlyAuditPolicy = evaluateCommandPolicy(
649
+ exactCompoundReadOnlyAudit,
650
+ hostReadRootPolicy
651
+ );
652
+ assert(
653
+ exactCompoundReadOnlyAuditPolicy.allowed &&
654
+ exactCompoundReadOnlyAuditPolicy.category === "read-only" &&
655
+ exactCompoundReadOnlyAuditPolicy.boundedForLoop === true &&
656
+ exactCompoundReadOnlyAuditPolicy.boundedCompoundSequence === true &&
657
+ exactCompoundReadOnlyAuditPolicy.needsNetwork === false &&
658
+ exactCompoundReadOnlyAuditPolicy.writesWorkspace === false,
659
+ "a finite read-only prelude and conditional directory audit incorrectly required destructive host permission"
660
+ );
661
+ const exactReadOnlyGitIdentityProbe =
662
+ 'git status --short && echo "TOPLEVEL=$(git rev-parse --show-toplevel 2>&1)" && echo "BRANCH=$(git rev-parse --abbrev-ref HEAD 2>&1)"';
663
+ const exactReadOnlyGitIdentityProbePolicy = evaluateCommandPolicy(
664
+ exactReadOnlyGitIdentityProbe,
665
+ hostWorkspacePolicy
666
+ );
667
+ assert(
668
+ exactReadOnlyGitIdentityProbePolicy.allowed &&
669
+ exactReadOnlyGitIdentityProbePolicy.category === "read-only" &&
670
+ exactReadOnlyGitIdentityProbePolicy.needsNetwork === false &&
671
+ exactReadOnlyGitIdentityProbePolicy.writesWorkspace === false,
672
+ "bounded read-only git identity substitutions incorrectly required destructive host permission"
673
+ );
674
+ for (const unsafeEchoSubstitution of [
675
+ 'echo "VALUE=$(rm -rf report.md)"',
676
+ 'echo "VALUE=$(cp README.md report.md)"',
677
+ 'echo "VALUE=$(curl https://example.com)"',
678
+ 'echo "VALUE=$(git status; rm -rf report.md)"',
679
+ 'echo "VALUE=$(echo $(pwd))"',
680
+ 'echo "VALUE=$((1 + 1))"',
681
+ 'echo "VALUE=$(git status)" > report.md',
682
+ 'echo "VALUE=`git status`"',
683
+ ]) {
684
+ const unsafeEchoSubstitutionPolicy = evaluateCommandPolicy(
685
+ unsafeEchoSubstitution,
686
+ hostWorkspacePolicy
687
+ );
688
+ assert(
689
+ !unsafeEchoSubstitutionPolicy.allowed ||
690
+ unsafeEchoSubstitutionPolicy.category !== "read-only" ||
691
+ unsafeEchoSubstitutionPolicy.writesWorkspace === true ||
692
+ unsafeEchoSubstitutionPolicy.needsNetwork === true,
693
+ `unsafe echo command substitution bypassed the bounded read-only policy: ${unsafeEchoSubstitution}`
694
+ );
695
+ }
696
+ for (const unsafeCompoundAudit of [
697
+ 'cp README.md copied.md; for d in /tmp; do echo "$d"; done',
698
+ 'echo start; for d in /tmp; do if [ -d "$d" ]; then rm -rf "$d"; else echo missing; fi; done',
699
+ 'echo start; for d in /tmp; do if [ -d "$d" ]; then curl https://example.com; else echo missing; fi; done',
700
+ 'echo start; for d in /tmp; do if [ -d "$d" ]; then echo "$d" > report.md; else echo missing; fi; done',
701
+ 'echo start; for d in /tmp; do if [ -d "$(pwd)" ]; then ls; else echo missing; fi; done',
702
+ 'echo start; for d in /tmp; do if [ -d "$d" ]; then if [ -f "$d/x" ]; then cat "$d/x"; else echo missing; fi; else echo absent; fi; done',
703
+ 'echo start; for d in $ROOTS; do if [ -d "$d" ]; then ls "$d"; else echo missing; fi; done',
704
+ 'echo start; for d in /tmp; do if [ -d "$d" ]; then ls "$d"; else echo missing; fi; done; rm -rf report.md',
705
+ ]) {
706
+ const unsafeCompoundAuditPolicy = evaluateCommandPolicy(unsafeCompoundAudit, hostReadRootPolicy);
707
+ assert(
708
+ !unsafeCompoundAuditPolicy.allowed ||
709
+ unsafeCompoundAuditPolicy.category !== "read-only" ||
710
+ unsafeCompoundAuditPolicy.writesWorkspace === true ||
711
+ unsafeCompoundAuditPolicy.needsNetwork === true,
712
+ `unsafe compound shell audit bypassed the bounded read-only policy: ${unsafeCompoundAudit}`
713
+ );
714
+ }
638
715
  const readRootDoctorPolicy = evaluateCommandPolicy(
639
716
  `cd ${externalReadRoot} && node bin/musia.js doctor --json 2>&1 | head -80`,
640
717
  hostReadRootPolicy
@@ -16,7 +16,7 @@ const READ_ONLY_PATTERNS = [
16
16
  /^which\s+[-\w.]+(?:\s+[-\w.]+)*$/,
17
17
  /^command\s+-v\s+[-\w.]+$/,
18
18
  /^uname(?:\s+-a)?$/,
19
- /^ls(?:\s+[-\w./~*]+)*$/,
19
+ /^ls(?:\s+(?:"[^"\n]*"|'[^'\n]*'|[-\w./~*]+))*$/,
20
20
  /^find(?:\s+[./~\w-]+)*(?:\s+-maxdepth\s+\d+)?(?:\s+-type\s+[fd])?$/,
21
21
  /^rg(?:\s+.+)?$/,
22
22
  /^grep(?:\s+.+)?$/,
@@ -30,7 +30,7 @@ const READ_ONLY_PATTERNS = [
30
30
  /^sha256sum(?:\s+[-\w./~*]+)+$/,
31
31
  /^sed\s+-n\s+['"0-9,:p\s-]+(?:\s+[-\w./~*]+)?$/,
32
32
  /^git\s+(status|branch|log|show|diff(?:\s+--stat)?|remote\s+-v)(?:\s+.+)?$/,
33
- /^git\s+rev-parse(?:\s+(?:--show-toplevel|--git-dir|--is-inside-work-tree|--show-prefix|--show-cdup|--verify|--short(?:=\d+)?|[-\w./@^{}~:]+))+$/,
33
+ /^git\s+rev-parse(?:\s+(?:--show-toplevel|--git-dir|--is-inside-work-tree|--show-prefix|--show-cdup|--abbrev-ref|--verify|--short(?:=\d+)?|[-\w./@^{}~:]+))+$/,
34
34
  /^git\s+ls-files(?:\s+(?:--(?:cached|deleted|modified|others|ignored|stage|unmerged|exclude-standard)|[-\w./*]+))*$/,
35
35
  /^node\s+(?:-v|--version)$/,
36
36
  /^node\s+[-\w./]+\.(?:c?js|mjs)\s+(?:--help|help|doctor|status|health)(?:\s+[-\w./:=]+)*$/,
@@ -1010,6 +1010,129 @@ function classifySafeEchoRedirect(normalized = "") {
1010
1010
  };
1011
1011
  }
1012
1012
 
1013
+ function extractBoundedCommandSubstitutions(value = "") {
1014
+ const text = String(value || "");
1015
+ if (!text.includes("$(") || text.length > 16 * 1024 || text.includes("`")) return null;
1016
+ const commands = [];
1017
+ let template = "";
1018
+ let quote = "";
1019
+ let escaped = false;
1020
+
1021
+ for (let index = 0; index < text.length; index += 1) {
1022
+ const char = text[index];
1023
+ if (quote === "'") {
1024
+ template += char;
1025
+ if (char === "'") quote = "";
1026
+ continue;
1027
+ }
1028
+ if (escaped) {
1029
+ template += char;
1030
+ escaped = false;
1031
+ continue;
1032
+ }
1033
+ if (char === "\\") {
1034
+ template += char;
1035
+ escaped = true;
1036
+ continue;
1037
+ }
1038
+ if (quote === '"' && char === '"') {
1039
+ template += char;
1040
+ quote = "";
1041
+ continue;
1042
+ }
1043
+ if (!quote && char === "'") {
1044
+ template += char;
1045
+ quote = char;
1046
+ continue;
1047
+ }
1048
+ if (!quote && char === '"') {
1049
+ template += char;
1050
+ quote = '"';
1051
+ continue;
1052
+ }
1053
+ if (char !== "$" || text[index + 1] !== "(") {
1054
+ template += char;
1055
+ continue;
1056
+ }
1057
+ if (text[index + 2] === "(" || commands.length >= 4) return null;
1058
+
1059
+ let inner = "";
1060
+ let innerQuote = "";
1061
+ let innerEscaped = false;
1062
+ let closed = false;
1063
+ index += 2;
1064
+ for (; index < text.length; index += 1) {
1065
+ const innerChar = text[index];
1066
+ if (innerQuote === "'") {
1067
+ inner += innerChar;
1068
+ if (innerChar === "'") innerQuote = "";
1069
+ continue;
1070
+ }
1071
+ if (innerEscaped) {
1072
+ inner += innerChar;
1073
+ innerEscaped = false;
1074
+ continue;
1075
+ }
1076
+ if (innerChar === "\\") {
1077
+ inner += innerChar;
1078
+ innerEscaped = true;
1079
+ continue;
1080
+ }
1081
+ if (innerQuote === '"') {
1082
+ inner += innerChar;
1083
+ if (innerChar === '"') innerQuote = "";
1084
+ else if (innerChar === "$" && text[index + 1] === "(") return null;
1085
+ continue;
1086
+ }
1087
+ if (innerChar === "'" || innerChar === '"') {
1088
+ inner += innerChar;
1089
+ innerQuote = innerChar;
1090
+ continue;
1091
+ }
1092
+ if (innerChar === "$" && text[index + 1] === "(") return null;
1093
+ if (innerChar === "(" || innerChar === "{") return null;
1094
+ if (innerChar === ")") {
1095
+ closed = true;
1096
+ break;
1097
+ }
1098
+ inner += innerChar;
1099
+ }
1100
+ if (!closed || innerQuote || innerEscaped || !inner.trim()) return null;
1101
+ commands.push(inner.trim());
1102
+ template += `AGINTI_READ_VALUE_${commands.length}`;
1103
+ }
1104
+ if (!commands.length || quote || escaped || hasActiveShellExpansion(template)) return null;
1105
+ return { commands, template };
1106
+ }
1107
+
1108
+ function classifySafeEchoCommandSubstitution(normalized = "") {
1109
+ if (!/^echo\s+/.test(String(normalized || ""))) return null;
1110
+ const extracted = extractBoundedCommandSubstitutions(normalized);
1111
+ if (!extracted) return null;
1112
+ const classifications = extracted.commands.map(
1113
+ (command) => classifyShellSequence(command) || classifyPipelineSequence(command) || classifySimpleCommand(command)
1114
+ );
1115
+ const blocked = classifications.find(
1116
+ (classification) => classification.category === "blocked" || classification.category === "destructive"
1117
+ );
1118
+ if (blocked) return blocked;
1119
+ if (classifications.some(
1120
+ (classification) => classification.category !== "read-only" || classification.writesWorkspace || classification.needsNetwork
1121
+ )) {
1122
+ return null;
1123
+ }
1124
+ const templateClassification = classifySimpleCommand(extracted.template);
1125
+ if (templateClassification.category !== "read-only" || templateClassification.writesWorkspace) return null;
1126
+ return {
1127
+ category: "read-only",
1128
+ needsNetwork: false,
1129
+ writesWorkspace: false,
1130
+ gitOnly: false,
1131
+ boundedCommandSubstitution: true,
1132
+ reason: `Echo uses ${extracted.commands.length} bounded read-only command substitution${extracted.commands.length === 1 ? "" : "s"}.`,
1133
+ };
1134
+ }
1135
+
1013
1136
  function classifyGitCleanDryRun(normalized) {
1014
1137
  const match = normalized.match(/^git\s+clean\b([\s\S]*)$/);
1015
1138
  if (!match) return null;
@@ -1150,6 +1273,8 @@ function classifySimpleCommand(normalized) {
1150
1273
  if (envExportClassification) return envExportClassification;
1151
1274
  const echoRedirectClassification = classifySafeEchoRedirect(normalized);
1152
1275
  if (echoRedirectClassification) return echoRedirectClassification;
1276
+ const echoCommandSubstitutionClassification = classifySafeEchoCommandSubstitution(normalized);
1277
+ if (echoCommandSubstitutionClassification) return echoCommandSubstitutionClassification;
1153
1278
 
1154
1279
  if (isUnboundedRecursiveGrep(normalized)) {
1155
1280
  return {
@@ -1389,6 +1514,135 @@ function broadForLoopClassification(normalized, reason) {
1389
1514
  };
1390
1515
  }
1391
1516
 
1517
+ function findUnquotedShellWord(value = "", expectedWord = "", startIndex = 0) {
1518
+ const text = String(value || "");
1519
+ const word = String(expectedWord || "");
1520
+ let quote = "";
1521
+ let escaped = false;
1522
+
1523
+ for (let index = 0; index <= text.length - word.length; index += 1) {
1524
+ const char = text[index];
1525
+ if (quote === "'") {
1526
+ if (char === "'") quote = "";
1527
+ continue;
1528
+ }
1529
+ if (escaped) {
1530
+ escaped = false;
1531
+ continue;
1532
+ }
1533
+ if (char === "\\") {
1534
+ escaped = true;
1535
+ continue;
1536
+ }
1537
+ if (quote === '"') {
1538
+ if (char === '"') quote = "";
1539
+ continue;
1540
+ }
1541
+ if (char === "'" || char === '"') {
1542
+ quote = char;
1543
+ continue;
1544
+ }
1545
+ if (index < Math.max(0, startIndex) || text.slice(index, index + word.length) !== word) continue;
1546
+ const before = index > 0 ? text[index - 1] : "";
1547
+ const after = text[index + word.length] || "";
1548
+ if ((!before || !/[A-Za-z0-9_]/.test(before)) && (!after || !/[A-Za-z0-9_]/.test(after))) {
1549
+ return index;
1550
+ }
1551
+ }
1552
+ return -1;
1553
+ }
1554
+
1555
+ function trimShellListBoundary(value = "") {
1556
+ return String(value || "")
1557
+ .replace(/^[\s;]+/, "")
1558
+ .replace(/[\s;]+$/, "")
1559
+ .trim();
1560
+ }
1561
+
1562
+ function classifyReadOnlyCommandList(value = "") {
1563
+ const text = trimShellListBoundary(value);
1564
+ if (!text) {
1565
+ return {
1566
+ category: "read-only",
1567
+ needsNetwork: false,
1568
+ writesWorkspace: false,
1569
+ emptyCommandList: true,
1570
+ };
1571
+ }
1572
+ const sequence = splitTopLevelShellSequence(text);
1573
+ if (sequence && sequence.parts.length > READ_ONLY_FOR_LOOP_MAX_COMMANDS) {
1574
+ return broadForLoopClassification(text, "the command list is too large");
1575
+ }
1576
+ return classifyShellSequence(text) || classifyPipelineSequence(text) || classifySimpleCommand(text);
1577
+ }
1578
+
1579
+ function isReadOnlyShellCondition(value = "") {
1580
+ const tokens = tokenizeShellWords(trimShellListBoundary(value));
1581
+ let option = "";
1582
+ let candidate = "";
1583
+ if (tokens.length === 4 && tokens[0] === "[" && tokens[3] === "]") {
1584
+ [, option, candidate] = tokens;
1585
+ } else if (tokens.length === 3 && tokens[0] === "test") {
1586
+ [, option, candidate] = tokens;
1587
+ } else {
1588
+ return false;
1589
+ }
1590
+ return /^-[efdx]$/.test(option) &&
1591
+ !candidate.startsWith("-") &&
1592
+ READ_ONLY_FOR_LOOP_LITERAL_PATTERN.test(candidate);
1593
+ }
1594
+
1595
+ function classifyReadOnlyLoopBody(bodySource = "") {
1596
+ const text = String(bodySource || "").trim();
1597
+ const controlWords = ["if", "then", "else", "elif", "fi", "for", "do", "done", "while", "until", "case", "esac"];
1598
+ const presentControls = controlWords.filter((word) => findUnquotedShellWord(text, word) >= 0);
1599
+ if (!presentControls.length) return classifyReadOnlyCommandList(text);
1600
+
1601
+ if (presentControls.some((word) => !["if", "then", "else", "fi"].includes(word))) {
1602
+ return broadForLoopClassification(text, "the body contains nested or unsupported control flow");
1603
+ }
1604
+
1605
+ const ifIndex = findUnquotedShellWord(text, "if");
1606
+ const thenIndex = findUnquotedShellWord(text, "then", ifIndex + 2);
1607
+ const elseIndex = findUnquotedShellWord(text, "else", thenIndex + 4);
1608
+ const fiIndex = findUnquotedShellWord(text, "fi", elseIndex + 4);
1609
+ if (ifIndex < 0 || thenIndex < 0 || elseIndex < 0 || fiIndex < 0 ||
1610
+ findUnquotedShellWord(text, "if", ifIndex + 2) >= 0 ||
1611
+ findUnquotedShellWord(text, "then", thenIndex + 4) >= 0 ||
1612
+ findUnquotedShellWord(text, "else", elseIndex + 4) >= 0 ||
1613
+ findUnquotedShellWord(text, "fi", fiIndex + 2) >= 0) {
1614
+ return broadForLoopClassification(text, "the conditional is not a single bounded if/else block");
1615
+ }
1616
+
1617
+ const prefix = trimShellListBoundary(text.slice(0, ifIndex));
1618
+ const condition = trimShellListBoundary(text.slice(ifIndex + 2, thenIndex));
1619
+ const thenBranch = trimShellListBoundary(text.slice(thenIndex + 4, elseIndex));
1620
+ const elseBranch = trimShellListBoundary(text.slice(elseIndex + 4, fiIndex));
1621
+ const suffix = trimShellListBoundary(text.slice(fiIndex + 2));
1622
+ if (!isReadOnlyShellCondition(condition) || !thenBranch || !elseBranch) {
1623
+ return broadForLoopClassification(text, "the conditional test or branch is not bounded read-only syntax");
1624
+ }
1625
+
1626
+ const classifications = [prefix, thenBranch, elseBranch, suffix]
1627
+ .filter(Boolean)
1628
+ .map((part) => classifyReadOnlyCommandList(part));
1629
+ const blocked = classifications.find(
1630
+ (classification) => classification.category === "blocked" || classification.category === "destructive"
1631
+ );
1632
+ if (blocked) return { ...blocked, gitOnly: false };
1633
+ if (classifications.some(
1634
+ (classification) => classification.category !== "read-only" || classification.writesWorkspace
1635
+ )) {
1636
+ return broadForLoopClassification(text, "a conditional branch is not read-only");
1637
+ }
1638
+ return {
1639
+ category: "read-only",
1640
+ needsNetwork: false,
1641
+ writesWorkspace: false,
1642
+ boundedConditional: true,
1643
+ };
1644
+ }
1645
+
1392
1646
  function classifyReadOnlyForLoop(normalized) {
1393
1647
  const text = String(normalized || "").trim();
1394
1648
  if (!/^for\s+/.test(text)) return null;
@@ -1416,9 +1670,8 @@ function classifyReadOnlyForLoop(normalized) {
1416
1670
  );
1417
1671
  }
1418
1672
 
1419
- const unquotedBody = stripQuotedSegments(bodySource);
1420
- if (/[|&<>(){}]/.test(unquotedBody) || hasActiveShellCommandSubstitution(bodySource)) {
1421
- return broadForLoopClassification(text, "the body uses control, redirection, or substitution syntax");
1673
+ if (hasActiveShellCommandSubstitution(bodySource)) {
1674
+ return broadForLoopClassification(text, "the body uses command substitution");
1422
1675
  }
1423
1676
 
1424
1677
  const escapedVariable = shellIdentifierPattern(variableName);
@@ -1436,23 +1689,12 @@ function classifyReadOnlyForLoop(normalized) {
1436
1689
  if (hasActiveShellExpansion(expandedBody) || hasActiveShellCommandSubstitution(expandedBody)) {
1437
1690
  return broadForLoopClassification(text, "the body contains expansion beyond the loop variable");
1438
1691
  }
1439
- const sequence = splitTopLevelShellSequence(expandedBody);
1440
- const commands = sequence?.parts || [expandedBody.trim()];
1441
- if (
1442
- !commands.length ||
1443
- commands.length > READ_ONLY_FOR_LOOP_MAX_COMMANDS ||
1444
- (sequence && sequence.separators.some((separator) => ![";", "newline"].includes(separator)))
1445
- ) {
1446
- return broadForLoopClassification(text, "the body is not a bounded sequential command list");
1692
+ const classification = classifyReadOnlyLoopBody(expandedBody);
1693
+ if (classification.category === "blocked" || classification.category === "destructive") {
1694
+ return { ...classification, gitOnly: false };
1447
1695
  }
1448
- for (const command of commands) {
1449
- const classification = classifySimpleCommand(command);
1450
- if (classification.category === "blocked" || classification.category === "destructive") {
1451
- return { ...classification, gitOnly: false };
1452
- }
1453
- if (classification.category !== "read-only" || classification.writesWorkspace) {
1454
- return broadForLoopClassification(text, `body command is ${classification.category}`);
1455
- }
1696
+ if (classification.category !== "read-only" || classification.writesWorkspace) {
1697
+ return broadForLoopClassification(text, `body is ${classification.category}`);
1456
1698
  }
1457
1699
  }
1458
1700
 
@@ -1466,6 +1708,46 @@ function classifyReadOnlyForLoop(normalized) {
1466
1708
  };
1467
1709
  }
1468
1710
 
1711
+ function classifyReadOnlyCompoundSequence(normalized) {
1712
+ const text = String(normalized || "").trim();
1713
+ if (!text || /^for\s+/.test(text) || hasActiveShellCommandSubstitution(text)) return null;
1714
+
1715
+ let forIndex = findUnquotedShellWord(text, "for");
1716
+ const startsAtCommandBoundary = (index) => {
1717
+ let boundary = index - 1;
1718
+ while (boundary >= 0 && /[ \t]/.test(text[boundary])) boundary -= 1;
1719
+ return boundary >= 0 && /[;\n\r]/.test(text[boundary]);
1720
+ };
1721
+ while (forIndex > 0 && !startsAtCommandBoundary(forIndex)) {
1722
+ forIndex = findUnquotedShellWord(text, "for", forIndex + 3);
1723
+ }
1724
+ if (forIndex <= 0) return null;
1725
+
1726
+ const prefixSource = trimShellListBoundary(text.slice(0, forIndex));
1727
+ const loopSource = text.slice(forIndex).trim();
1728
+ const prefixClassification = classifyReadOnlyCommandList(prefixSource);
1729
+ if (prefixClassification.category === "blocked" || prefixClassification.category === "destructive") {
1730
+ return { ...prefixClassification, gitOnly: false };
1731
+ }
1732
+ if (prefixClassification.category !== "read-only" || prefixClassification.writesWorkspace) {
1733
+ return broadForLoopClassification(text, "the command prelude is not read-only");
1734
+ }
1735
+ const loopClassification = classifyReadOnlyForLoop(loopSource);
1736
+ if (!loopClassification) return null;
1737
+ if (loopClassification.category !== "read-only" || loopClassification.writesWorkspace) {
1738
+ return loopClassification;
1739
+ }
1740
+ return {
1741
+ category: "read-only",
1742
+ needsNetwork: false,
1743
+ writesWorkspace: false,
1744
+ gitOnly: false,
1745
+ boundedForLoop: true,
1746
+ boundedCompoundSequence: true,
1747
+ reason: "Finite read-only command prelude followed by a bounded read-only shell loop.",
1748
+ };
1749
+ }
1750
+
1469
1751
  function splitTopLevelPipeline(command = "") {
1470
1752
  const parts = [];
1471
1753
  let current = "";
@@ -1630,7 +1912,7 @@ export function classifyCommand(command) {
1630
1912
  const normalized = String(command || "").trim();
1631
1913
  if (!normalized) return { category: "blocked", reason: "Command is empty." };
1632
1914
 
1633
- return classifyBackgroundShell(normalized) || classifyReadOnlyForLoop(normalized) || classifyCdCommand(normalized) || classifyShellSequence(normalized) || classifyPipelineSequence(normalized) || classifySimpleCommand(normalized);
1915
+ return classifyBackgroundShell(normalized) || classifyReadOnlyCompoundSequence(normalized) || classifyReadOnlyForLoop(normalized) || classifyCdCommand(normalized) || classifyShellSequence(normalized) || classifyPipelineSequence(normalized) || classifySimpleCommand(normalized);
1634
1916
  }
1635
1917
 
1636
1918
  export function evaluateCommandPolicy(command, config = {}) {