@lazyingart/agintiflow 0.20.218 → 0.20.219
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 +42 -0
- package/src/command-policy.js +178 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazyingart/agintiflow",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.219",
|
|
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,48 @@ 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
|
+
for (const unsafeCompoundAudit of [
|
|
662
|
+
'cp README.md copied.md; for d in /tmp; do echo "$d"; done',
|
|
663
|
+
'echo start; for d in /tmp; do if [ -d "$d" ]; then rm -rf "$d"; else echo missing; fi; done',
|
|
664
|
+
'echo start; for d in /tmp; do if [ -d "$d" ]; then curl https://example.com; else echo missing; fi; done',
|
|
665
|
+
'echo start; for d in /tmp; do if [ -d "$d" ]; then echo "$d" > report.md; else echo missing; fi; done',
|
|
666
|
+
'echo start; for d in /tmp; do if [ -d "$(pwd)" ]; then ls; else echo missing; fi; done',
|
|
667
|
+
'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',
|
|
668
|
+
'echo start; for d in $ROOTS; do if [ -d "$d" ]; then ls "$d"; else echo missing; fi; done',
|
|
669
|
+
'echo start; for d in /tmp; do if [ -d "$d" ]; then ls "$d"; else echo missing; fi; done; rm -rf report.md',
|
|
670
|
+
]) {
|
|
671
|
+
const unsafeCompoundAuditPolicy = evaluateCommandPolicy(unsafeCompoundAudit, hostReadRootPolicy);
|
|
672
|
+
assert(
|
|
673
|
+
!unsafeCompoundAuditPolicy.allowed ||
|
|
674
|
+
unsafeCompoundAuditPolicy.category !== "read-only" ||
|
|
675
|
+
unsafeCompoundAuditPolicy.writesWorkspace === true ||
|
|
676
|
+
unsafeCompoundAuditPolicy.needsNetwork === true,
|
|
677
|
+
`unsafe compound shell audit bypassed the bounded read-only policy: ${unsafeCompoundAudit}`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
638
680
|
const readRootDoctorPolicy = evaluateCommandPolicy(
|
|
639
681
|
`cd ${externalReadRoot} && node bin/musia.js doctor --json 2>&1 | head -80`,
|
|
640
682
|
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+.+)?$/,
|
|
@@ -1389,6 +1389,135 @@ function broadForLoopClassification(normalized, reason) {
|
|
|
1389
1389
|
};
|
|
1390
1390
|
}
|
|
1391
1391
|
|
|
1392
|
+
function findUnquotedShellWord(value = "", expectedWord = "", startIndex = 0) {
|
|
1393
|
+
const text = String(value || "");
|
|
1394
|
+
const word = String(expectedWord || "");
|
|
1395
|
+
let quote = "";
|
|
1396
|
+
let escaped = false;
|
|
1397
|
+
|
|
1398
|
+
for (let index = 0; index <= text.length - word.length; index += 1) {
|
|
1399
|
+
const char = text[index];
|
|
1400
|
+
if (quote === "'") {
|
|
1401
|
+
if (char === "'") quote = "";
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
if (escaped) {
|
|
1405
|
+
escaped = false;
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
if (char === "\\") {
|
|
1409
|
+
escaped = true;
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
if (quote === '"') {
|
|
1413
|
+
if (char === '"') quote = "";
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
if (char === "'" || char === '"') {
|
|
1417
|
+
quote = char;
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
if (index < Math.max(0, startIndex) || text.slice(index, index + word.length) !== word) continue;
|
|
1421
|
+
const before = index > 0 ? text[index - 1] : "";
|
|
1422
|
+
const after = text[index + word.length] || "";
|
|
1423
|
+
if ((!before || !/[A-Za-z0-9_]/.test(before)) && (!after || !/[A-Za-z0-9_]/.test(after))) {
|
|
1424
|
+
return index;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
return -1;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function trimShellListBoundary(value = "") {
|
|
1431
|
+
return String(value || "")
|
|
1432
|
+
.replace(/^[\s;]+/, "")
|
|
1433
|
+
.replace(/[\s;]+$/, "")
|
|
1434
|
+
.trim();
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function classifyReadOnlyCommandList(value = "") {
|
|
1438
|
+
const text = trimShellListBoundary(value);
|
|
1439
|
+
if (!text) {
|
|
1440
|
+
return {
|
|
1441
|
+
category: "read-only",
|
|
1442
|
+
needsNetwork: false,
|
|
1443
|
+
writesWorkspace: false,
|
|
1444
|
+
emptyCommandList: true,
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
const sequence = splitTopLevelShellSequence(text);
|
|
1448
|
+
if (sequence && sequence.parts.length > READ_ONLY_FOR_LOOP_MAX_COMMANDS) {
|
|
1449
|
+
return broadForLoopClassification(text, "the command list is too large");
|
|
1450
|
+
}
|
|
1451
|
+
return classifyShellSequence(text) || classifyPipelineSequence(text) || classifySimpleCommand(text);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function isReadOnlyShellCondition(value = "") {
|
|
1455
|
+
const tokens = tokenizeShellWords(trimShellListBoundary(value));
|
|
1456
|
+
let option = "";
|
|
1457
|
+
let candidate = "";
|
|
1458
|
+
if (tokens.length === 4 && tokens[0] === "[" && tokens[3] === "]") {
|
|
1459
|
+
[, option, candidate] = tokens;
|
|
1460
|
+
} else if (tokens.length === 3 && tokens[0] === "test") {
|
|
1461
|
+
[, option, candidate] = tokens;
|
|
1462
|
+
} else {
|
|
1463
|
+
return false;
|
|
1464
|
+
}
|
|
1465
|
+
return /^-[efdx]$/.test(option) &&
|
|
1466
|
+
!candidate.startsWith("-") &&
|
|
1467
|
+
READ_ONLY_FOR_LOOP_LITERAL_PATTERN.test(candidate);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function classifyReadOnlyLoopBody(bodySource = "") {
|
|
1471
|
+
const text = String(bodySource || "").trim();
|
|
1472
|
+
const controlWords = ["if", "then", "else", "elif", "fi", "for", "do", "done", "while", "until", "case", "esac"];
|
|
1473
|
+
const presentControls = controlWords.filter((word) => findUnquotedShellWord(text, word) >= 0);
|
|
1474
|
+
if (!presentControls.length) return classifyReadOnlyCommandList(text);
|
|
1475
|
+
|
|
1476
|
+
if (presentControls.some((word) => !["if", "then", "else", "fi"].includes(word))) {
|
|
1477
|
+
return broadForLoopClassification(text, "the body contains nested or unsupported control flow");
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const ifIndex = findUnquotedShellWord(text, "if");
|
|
1481
|
+
const thenIndex = findUnquotedShellWord(text, "then", ifIndex + 2);
|
|
1482
|
+
const elseIndex = findUnquotedShellWord(text, "else", thenIndex + 4);
|
|
1483
|
+
const fiIndex = findUnquotedShellWord(text, "fi", elseIndex + 4);
|
|
1484
|
+
if (ifIndex < 0 || thenIndex < 0 || elseIndex < 0 || fiIndex < 0 ||
|
|
1485
|
+
findUnquotedShellWord(text, "if", ifIndex + 2) >= 0 ||
|
|
1486
|
+
findUnquotedShellWord(text, "then", thenIndex + 4) >= 0 ||
|
|
1487
|
+
findUnquotedShellWord(text, "else", elseIndex + 4) >= 0 ||
|
|
1488
|
+
findUnquotedShellWord(text, "fi", fiIndex + 2) >= 0) {
|
|
1489
|
+
return broadForLoopClassification(text, "the conditional is not a single bounded if/else block");
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const prefix = trimShellListBoundary(text.slice(0, ifIndex));
|
|
1493
|
+
const condition = trimShellListBoundary(text.slice(ifIndex + 2, thenIndex));
|
|
1494
|
+
const thenBranch = trimShellListBoundary(text.slice(thenIndex + 4, elseIndex));
|
|
1495
|
+
const elseBranch = trimShellListBoundary(text.slice(elseIndex + 4, fiIndex));
|
|
1496
|
+
const suffix = trimShellListBoundary(text.slice(fiIndex + 2));
|
|
1497
|
+
if (!isReadOnlyShellCondition(condition) || !thenBranch || !elseBranch) {
|
|
1498
|
+
return broadForLoopClassification(text, "the conditional test or branch is not bounded read-only syntax");
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
const classifications = [prefix, thenBranch, elseBranch, suffix]
|
|
1502
|
+
.filter(Boolean)
|
|
1503
|
+
.map((part) => classifyReadOnlyCommandList(part));
|
|
1504
|
+
const blocked = classifications.find(
|
|
1505
|
+
(classification) => classification.category === "blocked" || classification.category === "destructive"
|
|
1506
|
+
);
|
|
1507
|
+
if (blocked) return { ...blocked, gitOnly: false };
|
|
1508
|
+
if (classifications.some(
|
|
1509
|
+
(classification) => classification.category !== "read-only" || classification.writesWorkspace
|
|
1510
|
+
)) {
|
|
1511
|
+
return broadForLoopClassification(text, "a conditional branch is not read-only");
|
|
1512
|
+
}
|
|
1513
|
+
return {
|
|
1514
|
+
category: "read-only",
|
|
1515
|
+
needsNetwork: false,
|
|
1516
|
+
writesWorkspace: false,
|
|
1517
|
+
boundedConditional: true,
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1392
1521
|
function classifyReadOnlyForLoop(normalized) {
|
|
1393
1522
|
const text = String(normalized || "").trim();
|
|
1394
1523
|
if (!/^for\s+/.test(text)) return null;
|
|
@@ -1416,9 +1545,8 @@ function classifyReadOnlyForLoop(normalized) {
|
|
|
1416
1545
|
);
|
|
1417
1546
|
}
|
|
1418
1547
|
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
return broadForLoopClassification(text, "the body uses control, redirection, or substitution syntax");
|
|
1548
|
+
if (hasActiveShellCommandSubstitution(bodySource)) {
|
|
1549
|
+
return broadForLoopClassification(text, "the body uses command substitution");
|
|
1422
1550
|
}
|
|
1423
1551
|
|
|
1424
1552
|
const escapedVariable = shellIdentifierPattern(variableName);
|
|
@@ -1436,23 +1564,12 @@ function classifyReadOnlyForLoop(normalized) {
|
|
|
1436
1564
|
if (hasActiveShellExpansion(expandedBody) || hasActiveShellCommandSubstitution(expandedBody)) {
|
|
1437
1565
|
return broadForLoopClassification(text, "the body contains expansion beyond the loop variable");
|
|
1438
1566
|
}
|
|
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");
|
|
1567
|
+
const classification = classifyReadOnlyLoopBody(expandedBody);
|
|
1568
|
+
if (classification.category === "blocked" || classification.category === "destructive") {
|
|
1569
|
+
return { ...classification, gitOnly: false };
|
|
1447
1570
|
}
|
|
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
|
-
}
|
|
1571
|
+
if (classification.category !== "read-only" || classification.writesWorkspace) {
|
|
1572
|
+
return broadForLoopClassification(text, `body is ${classification.category}`);
|
|
1456
1573
|
}
|
|
1457
1574
|
}
|
|
1458
1575
|
|
|
@@ -1466,6 +1583,46 @@ function classifyReadOnlyForLoop(normalized) {
|
|
|
1466
1583
|
};
|
|
1467
1584
|
}
|
|
1468
1585
|
|
|
1586
|
+
function classifyReadOnlyCompoundSequence(normalized) {
|
|
1587
|
+
const text = String(normalized || "").trim();
|
|
1588
|
+
if (!text || /^for\s+/.test(text) || hasActiveShellCommandSubstitution(text)) return null;
|
|
1589
|
+
|
|
1590
|
+
let forIndex = findUnquotedShellWord(text, "for");
|
|
1591
|
+
const startsAtCommandBoundary = (index) => {
|
|
1592
|
+
let boundary = index - 1;
|
|
1593
|
+
while (boundary >= 0 && /[ \t]/.test(text[boundary])) boundary -= 1;
|
|
1594
|
+
return boundary >= 0 && /[;\n\r]/.test(text[boundary]);
|
|
1595
|
+
};
|
|
1596
|
+
while (forIndex > 0 && !startsAtCommandBoundary(forIndex)) {
|
|
1597
|
+
forIndex = findUnquotedShellWord(text, "for", forIndex + 3);
|
|
1598
|
+
}
|
|
1599
|
+
if (forIndex <= 0) return null;
|
|
1600
|
+
|
|
1601
|
+
const prefixSource = trimShellListBoundary(text.slice(0, forIndex));
|
|
1602
|
+
const loopSource = text.slice(forIndex).trim();
|
|
1603
|
+
const prefixClassification = classifyReadOnlyCommandList(prefixSource);
|
|
1604
|
+
if (prefixClassification.category === "blocked" || prefixClassification.category === "destructive") {
|
|
1605
|
+
return { ...prefixClassification, gitOnly: false };
|
|
1606
|
+
}
|
|
1607
|
+
if (prefixClassification.category !== "read-only" || prefixClassification.writesWorkspace) {
|
|
1608
|
+
return broadForLoopClassification(text, "the command prelude is not read-only");
|
|
1609
|
+
}
|
|
1610
|
+
const loopClassification = classifyReadOnlyForLoop(loopSource);
|
|
1611
|
+
if (!loopClassification) return null;
|
|
1612
|
+
if (loopClassification.category !== "read-only" || loopClassification.writesWorkspace) {
|
|
1613
|
+
return loopClassification;
|
|
1614
|
+
}
|
|
1615
|
+
return {
|
|
1616
|
+
category: "read-only",
|
|
1617
|
+
needsNetwork: false,
|
|
1618
|
+
writesWorkspace: false,
|
|
1619
|
+
gitOnly: false,
|
|
1620
|
+
boundedForLoop: true,
|
|
1621
|
+
boundedCompoundSequence: true,
|
|
1622
|
+
reason: "Finite read-only command prelude followed by a bounded read-only shell loop.",
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1469
1626
|
function splitTopLevelPipeline(command = "") {
|
|
1470
1627
|
const parts = [];
|
|
1471
1628
|
let current = "";
|
|
@@ -1630,7 +1787,7 @@ export function classifyCommand(command) {
|
|
|
1630
1787
|
const normalized = String(command || "").trim();
|
|
1631
1788
|
if (!normalized) return { category: "blocked", reason: "Command is empty." };
|
|
1632
1789
|
|
|
1633
|
-
return classifyBackgroundShell(normalized) || classifyReadOnlyForLoop(normalized) || classifyCdCommand(normalized) || classifyShellSequence(normalized) || classifyPipelineSequence(normalized) || classifySimpleCommand(normalized);
|
|
1790
|
+
return classifyBackgroundShell(normalized) || classifyReadOnlyCompoundSequence(normalized) || classifyReadOnlyForLoop(normalized) || classifyCdCommand(normalized) || classifyShellSequence(normalized) || classifyPipelineSequence(normalized) || classifySimpleCommand(normalized);
|
|
1634
1791
|
}
|
|
1635
1792
|
|
|
1636
1793
|
export function evaluateCommandPolicy(command, config = {}) {
|