@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 +1 -1
- package/scripts/smoke-coding-tools.js +77 -0
- package/src/command-policy.js +304 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazyingart/agintiflow",
|
|
3
|
-
"version": "0.20.
|
|
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
|
package/src/command-policy.js
CHANGED
|
@@ -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
|
-
|
|
1420
|
-
|
|
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
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
-
|
|
1449
|
-
|
|
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 = {}) {
|