@lazyingart/agintiflow 0.20.223 → 0.20.225
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.
|
@@ -73,6 +73,8 @@ Current contract:
|
|
|
73
73
|
- `danger` is trusted host/full-access mode: host shell, destructive actions, host installs, password typing, and outside-workspace file paths are enabled. Use it only for tasks you trust; obvious secret/publish exfiltration guards remain hard stops.
|
|
74
74
|
- Workspace file tools may read and write inside the configured project folder when file tools are enabled.
|
|
75
75
|
- Narrow workspace-local shell actions such as safe probes, Gradle/TeX builds, and `chmod +x` on a project script may run when the command policy can classify them precisely.
|
|
76
|
+
- Finite literal read-only audits may bind a small literal path, inspect/hash it, count matches through a bounded read-only pipeline, and display numeric results with `echo` or `printf` without asking for destructive permission. Shell line continuations are accepted for finite literal item lists. Every producer, consumer, and surrounding command must still classify as bounded read-only work; network access, redirects, secret/dynamic expansion, shell-state `printf -v`, mutation, dynamic item lists, and computed values used as executable arguments remain blocked.
|
|
77
|
+
- Completion checks distinguish a resolved historical state such as "previously paused" from work that is currently pending or incomplete. A resolved past blocker therefore does not force another verification loop, while any explicit current unfinished state still does.
|
|
76
78
|
- Android/JVM toolchain commands may include safe local env assignments such as `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `JAVA_HOME`, or `GRADLE_USER_HOME`, plus small workspace-local status-log redirects. Secret-like env vars and redirects outside the workspace remain blocked.
|
|
77
79
|
- Workspace writes outside that folder, secret paths, `.git` internals, and dependency folders such as `node_modules` are blocked.
|
|
78
80
|
- `git clone`, `git fetch`, `git pull --ff-only`, `git push`, `curl`, and `wget` are classified as network operations.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazyingart/agintiflow",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.225",
|
|
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",
|
|
@@ -673,6 +673,47 @@ try {
|
|
|
673
673
|
exactReadOnlyExistenceLoopWithSuffixPolicy.writesWorkspace === false,
|
|
674
674
|
"a finite read-only existence loop with bounded list suffix incorrectly required destructive host permission"
|
|
675
675
|
);
|
|
676
|
+
const exactReadOnlyReportLiteralAudit =
|
|
677
|
+
'cd . && for s in \'handoff/LABCANVAS_AGENT_API_HANDOFF_2026_07_29.md\' \'/home/lachlan/ProjectsLFS/AgenticApp\' \'src/agenticapp/musia_ops.py\' \'scripts/xyq_chrome/watch_thread_dom_download.py\' \'Reviewed master\' \'ffprobe verification\' \'Song-only versus MV separation\' \'MV request versus publication separation\'; do n=$(grep -c -- "$s" media-routine-readiness.md); echo "$n :: $s"; done; echo \'--- git status ---\'; git status --short';
|
|
678
|
+
const exactReadOnlyReportLiteralAuditPolicy = evaluateCommandPolicy(
|
|
679
|
+
exactReadOnlyReportLiteralAudit,
|
|
680
|
+
hostWorkspacePolicy
|
|
681
|
+
);
|
|
682
|
+
assert(
|
|
683
|
+
exactReadOnlyReportLiteralAuditPolicy.allowed &&
|
|
684
|
+
exactReadOnlyReportLiteralAuditPolicy.category === "read-only" &&
|
|
685
|
+
exactReadOnlyReportLiteralAuditPolicy.boundedForLoop === true &&
|
|
686
|
+
exactReadOnlyReportLiteralAuditPolicy.needsNetwork === false &&
|
|
687
|
+
exactReadOnlyReportLiteralAuditPolicy.writesWorkspace === false,
|
|
688
|
+
"a bounded report literal-count audit incorrectly required destructive host permission"
|
|
689
|
+
);
|
|
690
|
+
const exactReadOnlyFormattedReportAudit = [
|
|
691
|
+
"f=media-routine-readiness.md",
|
|
692
|
+
"printf 'lines\\tbytes\\tsha256\\n'",
|
|
693
|
+
'wc -lc "$f"',
|
|
694
|
+
'sha256sum "$f"',
|
|
695
|
+
"printf '\\n--- literal occurrence counts (bounded) ---\\n'",
|
|
696
|
+
"for s in \\",
|
|
697
|
+
" '/home/lachlan/ProjectsLFS/Musia' \\",
|
|
698
|
+
" '/home/lachlan/ProjectsLFS/AgenticApp' \\",
|
|
699
|
+
" 'music generation' \\",
|
|
700
|
+
"; do",
|
|
701
|
+
' n=$(grep -oF -- "$s" "$f" | wc -l | tr -d \' \')',
|
|
702
|
+
' printf \'%-58s %s\\n\' "$s" "$n"',
|
|
703
|
+
"done",
|
|
704
|
+
].join("\n");
|
|
705
|
+
const exactReadOnlyFormattedReportAuditPolicy = evaluateCommandPolicy(
|
|
706
|
+
exactReadOnlyFormattedReportAudit,
|
|
707
|
+
hostWorkspacePolicy
|
|
708
|
+
);
|
|
709
|
+
assert(
|
|
710
|
+
exactReadOnlyFormattedReportAuditPolicy.allowed &&
|
|
711
|
+
exactReadOnlyFormattedReportAuditPolicy.category === "read-only" &&
|
|
712
|
+
exactReadOnlyFormattedReportAuditPolicy.boundedForLoop === true &&
|
|
713
|
+
exactReadOnlyFormattedReportAuditPolicy.needsNetwork === false &&
|
|
714
|
+
exactReadOnlyFormattedReportAuditPolicy.writesWorkspace === false,
|
|
715
|
+
"a literal-file formatted count audit incorrectly required destructive host permission"
|
|
716
|
+
);
|
|
676
717
|
const exactReadOnlyGitIdentityProbe =
|
|
677
718
|
'git status --short && echo "TOPLEVEL=$(git rev-parse --show-toplevel 2>&1)" && echo "BRANCH=$(git rev-parse --abbrev-ref HEAD 2>&1)"';
|
|
678
719
|
const exactReadOnlyGitIdentityProbePolicy = evaluateCommandPolicy(
|
|
@@ -747,6 +788,16 @@ try {
|
|
|
747
788
|
'for d in /tmp; do git -C "$d" status --short > report.md; done',
|
|
748
789
|
'for d in /tmp; do git -C "$d" remote add origin https://example.com/repo.git; done',
|
|
749
790
|
'for d in /tmp; do git -C "$d" remote set-url origin https://example.com/repo.git; done',
|
|
791
|
+
'for s in README.md; do n=$(rm -rf report.md); echo "$n :: $s"; done',
|
|
792
|
+
'for s in README.md; do n=$(curl https://example.com); echo "$n :: $s"; done',
|
|
793
|
+
'for s in README.md; do n=$(cat "$s"); echo "$n :: $s"; done',
|
|
794
|
+
'for s in README.md; do n=$(grep -c -- "$s" README.md); rm -rf "$n"; done',
|
|
795
|
+
'f=README.md; printf "%s\\n" "$(cat .aginti/.env)"; for s in one; do echo "$s"; done',
|
|
796
|
+
'f=README.md; printf "%s\\n" "$f" > copied.md; for s in one; do echo "$s"; done',
|
|
797
|
+
'f=$(cat README.md); printf "%s\\n" "$f"; for s in one; do echo "$s"; done',
|
|
798
|
+
'f=README.md; for s in one; do n=$(grep -oF -- "$s" "$f" | wc -l | tr -d " "); printf -v target "%s" "$n"; done',
|
|
799
|
+
'f=README.md; for s in one; do n=$(grep -oF -- "$s" "$f" | wc -l | tr -d " "); rm -rf "$n"; done',
|
|
800
|
+
'for s in \'README.md; rm -rf report.md\'; do echo "$s"; done',
|
|
750
801
|
]) {
|
|
751
802
|
const unsafeLoopSubstitutionPolicy = evaluateCommandPolicy(
|
|
752
803
|
unsafeLoopSubstitution,
|
|
@@ -7,11 +7,41 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { runAgent } from "../src/agent-runner.js";
|
|
8
8
|
import { resolveRuntimeConfig } from "../src/config.js";
|
|
9
9
|
import { SessionStore } from "../src/session-store.js";
|
|
10
|
+
import { finishResultClaimsIncompleteWork } from "../src/scs-evidence.js";
|
|
10
11
|
|
|
11
12
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
13
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-truthful-completion-"));
|
|
13
14
|
process.env.AGINTIFLOW_HOME = path.join(tempRoot, "home");
|
|
14
15
|
|
|
16
|
+
assert.equal(
|
|
17
|
+
finishResultClaimsIncompleteWork(
|
|
18
|
+
"The report is complete and the verification that was previously paused now passes. No work remains."
|
|
19
|
+
),
|
|
20
|
+
false,
|
|
21
|
+
"historical paused wording was mistaken for current unfinished work"
|
|
22
|
+
);
|
|
23
|
+
assert.equal(
|
|
24
|
+
finishResultClaimsIncompleteWork(
|
|
25
|
+
"The earlier step was paused, but the report is complete and verified now."
|
|
26
|
+
),
|
|
27
|
+
false,
|
|
28
|
+
"resolved historical pause was mistaken for current unfinished work"
|
|
29
|
+
);
|
|
30
|
+
assert.equal(
|
|
31
|
+
finishResultClaimsIncompleteWork(
|
|
32
|
+
"The earlier step was paused and the report is still incomplete."
|
|
33
|
+
),
|
|
34
|
+
true,
|
|
35
|
+
"historical-pause normalization hid genuinely incomplete current work"
|
|
36
|
+
);
|
|
37
|
+
assert.equal(
|
|
38
|
+
finishResultClaimsIncompleteWork(
|
|
39
|
+
"The earlier report was complete, but the current task is unfinished."
|
|
40
|
+
),
|
|
41
|
+
true,
|
|
42
|
+
"historical wording hid an unrelated current unfinished task"
|
|
43
|
+
);
|
|
44
|
+
|
|
15
45
|
function assistant(content, toolCalls = []) {
|
|
16
46
|
return {
|
|
17
47
|
choices: [
|
|
@@ -9,9 +9,8 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
10
|
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-web-port-fallback-"));
|
|
11
11
|
const host = "127.0.0.1";
|
|
12
|
-
const occupiedPort = 43100 + Math.floor(Math.random() * 1200);
|
|
13
12
|
|
|
14
|
-
function listenOccupier(port) {
|
|
13
|
+
function listenOccupier(port = 0) {
|
|
15
14
|
return new Promise((resolve, reject) => {
|
|
16
15
|
const server = net.createServer();
|
|
17
16
|
server.once("error", reject);
|
|
@@ -49,7 +48,15 @@ async function waitForHealth(port, child, stdout, stderr) {
|
|
|
49
48
|
throw new Error(`web fallback health failed. stdout=${stdout()} stderr=${stderr()}`);
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
|
|
51
|
+
// Let the kernel allocate an actually free port and retain the listener. A
|
|
52
|
+
// random high port can already belong to another project on a shared runner,
|
|
53
|
+
// which made this fallback test itself flaky before the product was exercised.
|
|
54
|
+
const occupier = await listenOccupier();
|
|
55
|
+
const occupiedAddress = occupier.address();
|
|
56
|
+
const occupiedPort = typeof occupiedAddress === "object" && occupiedAddress
|
|
57
|
+
? Number(occupiedAddress.port)
|
|
58
|
+
: 0;
|
|
59
|
+
if (!occupiedPort) throw new Error("failed to reserve the occupied test port");
|
|
53
60
|
let stdout = "";
|
|
54
61
|
let stderr = "";
|
|
55
62
|
const child = spawn(process.execPath, [path.join(repoRoot, "bin/aginti-cli.js"), "web", "--port", String(occupiedPort), "--host", host], {
|
package/src/command-policy.js
CHANGED
|
@@ -78,6 +78,35 @@ function stripBenignRedirections(command = "") {
|
|
|
78
78
|
.trim();
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
function isReadOnlyPrintfCommand(command = "") {
|
|
82
|
+
const normalized = stripBenignRedirections(command);
|
|
83
|
+
if (!/^printf(?:\s|$)/.test(normalized) || hasActiveShellExpansion(normalized)) return false;
|
|
84
|
+
const tokens = tokenizeShellWords(normalized);
|
|
85
|
+
if (!tokens.length || tokens[0] !== "printf") return false;
|
|
86
|
+
// Bash's `printf -v name ...` mutates shell state and can influence a later
|
|
87
|
+
// command. Ordinary printf writes only to stdout and is safe once expansion
|
|
88
|
+
// and redirection checks have passed.
|
|
89
|
+
return !tokens.slice(1).some((token) => token === "-v" || /^-v[A-Za-z_]/.test(token));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isReadOnlyTrDeleteFilter(command = "") {
|
|
93
|
+
const normalized = stripBenignRedirections(command);
|
|
94
|
+
if (hasActiveShellExpansion(normalized)) return false;
|
|
95
|
+
const tokens = tokenizeShellWords(normalized);
|
|
96
|
+
return tokens.length === 3 && tokens[0] === "tr" && tokens[1] === "-d";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isReadOnlySha256Command(command = "") {
|
|
100
|
+
const normalized = stripBenignRedirections(command);
|
|
101
|
+
if (hasActiveShellExpansion(normalized)) return false;
|
|
102
|
+
const tokens = tokenizeShellWords(normalized);
|
|
103
|
+
if (tokens[0] !== "sha256sum" || tokens.length < 2) return false;
|
|
104
|
+
const paths = tokens.slice(1).filter((token) => token !== "--");
|
|
105
|
+
return paths.length > 0 && paths.every((token) =>
|
|
106
|
+
!token.startsWith("-") && READ_ONLY_FOR_LOOP_LITERAL_PATTERN.test(token)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
81
110
|
function isReadOnlyFindCommand(command = "") {
|
|
82
111
|
const normalized = stripBenignRedirections(command);
|
|
83
112
|
if (!/^find\s+/.test(normalized)) return false;
|
|
@@ -1359,6 +1388,9 @@ function classifySimpleCommand(normalized) {
|
|
|
1359
1388
|
|
|
1360
1389
|
if (
|
|
1361
1390
|
matchAny(READ_ONLY_PATTERNS, commandForPatternMatching) ||
|
|
1391
|
+
isReadOnlyPrintfCommand(commandForPatternMatching) ||
|
|
1392
|
+
isReadOnlyTrDeleteFilter(commandForPatternMatching) ||
|
|
1393
|
+
isReadOnlySha256Command(commandForPatternMatching) ||
|
|
1362
1394
|
isReadOnlyFindCommand(normalized) ||
|
|
1363
1395
|
(!hasActiveShellExpansion(benignRedirectCommand) && isReadOnlyShellCondition(benignRedirectCommand))
|
|
1364
1396
|
) {
|
|
@@ -1543,7 +1575,7 @@ function classifyShellSequence(normalized) {
|
|
|
1543
1575
|
|
|
1544
1576
|
const READ_ONLY_FOR_LOOP_MAX_ITEMS = 64;
|
|
1545
1577
|
const READ_ONLY_FOR_LOOP_MAX_COMMANDS = 16;
|
|
1546
|
-
const READ_ONLY_FOR_LOOP_LITERAL_PATTERN = /^[A-Za-z0-9_
|
|
1578
|
+
const READ_ONLY_FOR_LOOP_LITERAL_PATTERN = /^[A-Za-z0-9_@%+=:,./~+ -]+$/;
|
|
1547
1579
|
|
|
1548
1580
|
function shellIdentifierPattern(value = "") {
|
|
1549
1581
|
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -1708,6 +1740,342 @@ function loopBodyHasOnlyBoundedReadOnlyExpansions(bodySource = "") {
|
|
|
1708
1740
|
});
|
|
1709
1741
|
}
|
|
1710
1742
|
|
|
1743
|
+
function isBoundedReadOnlyScalarCommand(command = "") {
|
|
1744
|
+
const normalized = stripBenignRedirections(command);
|
|
1745
|
+
if (!normalized || hasActiveShellExpansion(normalized)) return false;
|
|
1746
|
+
const pipeline = splitTopLevelPipeline(normalized);
|
|
1747
|
+
if (pipeline) {
|
|
1748
|
+
if (pipeline.length > 4) return false;
|
|
1749
|
+
const classifications = pipeline.map((part) => classifySimpleCommand(part));
|
|
1750
|
+
if (classifications.some(
|
|
1751
|
+
(classification) => classification.category !== "read-only" || classification.writesWorkspace
|
|
1752
|
+
)) {
|
|
1753
|
+
return false;
|
|
1754
|
+
}
|
|
1755
|
+
const producers = [...pipeline];
|
|
1756
|
+
if (isReadOnlyTrDeleteFilter(producers.at(-1))) producers.pop();
|
|
1757
|
+
const finalProducer = stripBenignRedirections(producers.at(-1) || "");
|
|
1758
|
+
const finalTokens = tokenizeShellWords(finalProducer);
|
|
1759
|
+
return finalTokens[0] === "wc" &&
|
|
1760
|
+
finalTokens.length === 2 &&
|
|
1761
|
+
["-l", "--lines"].includes(finalTokens[1]);
|
|
1762
|
+
}
|
|
1763
|
+
const tokens = tokenizeShellWords(normalized);
|
|
1764
|
+
if (!tokens.length) return false;
|
|
1765
|
+
if (["grep", "rg"].includes(tokens[0])) {
|
|
1766
|
+
return tokens.slice(1).some((token) =>
|
|
1767
|
+
token === "--count" || /^-[A-Za-z]*c[A-Za-z]*$/.test(token)
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
return tokens[0] === "wc" && tokens.slice(1).includes("-l");
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function parseReadOnlyLoopBodySequence(value = "") {
|
|
1774
|
+
const text = String(value || "");
|
|
1775
|
+
const commands = [];
|
|
1776
|
+
const separators = [];
|
|
1777
|
+
let current = "";
|
|
1778
|
+
let quote = "";
|
|
1779
|
+
let escaped = false;
|
|
1780
|
+
let substitutionDepth = 0;
|
|
1781
|
+
|
|
1782
|
+
const push = (separator) => {
|
|
1783
|
+
const command = current.trim();
|
|
1784
|
+
if (!command) return false;
|
|
1785
|
+
commands.push(command);
|
|
1786
|
+
separators.push(separator);
|
|
1787
|
+
current = "";
|
|
1788
|
+
return true;
|
|
1789
|
+
};
|
|
1790
|
+
|
|
1791
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1792
|
+
const char = text[index];
|
|
1793
|
+
if (quote === "'") {
|
|
1794
|
+
current += char;
|
|
1795
|
+
if (char === "'") quote = "";
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
if (escaped) {
|
|
1799
|
+
current += char;
|
|
1800
|
+
escaped = false;
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
if (char === "\\") {
|
|
1804
|
+
current += char;
|
|
1805
|
+
escaped = true;
|
|
1806
|
+
continue;
|
|
1807
|
+
}
|
|
1808
|
+
if (char === '"') {
|
|
1809
|
+
quote = quote === '"' ? "" : '"';
|
|
1810
|
+
current += char;
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
if (!quote && char === "'") {
|
|
1814
|
+
quote = "'";
|
|
1815
|
+
current += char;
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
if (char === "$" && text[index + 1] === "(") {
|
|
1819
|
+
substitutionDepth += 1;
|
|
1820
|
+
current += "$(";
|
|
1821
|
+
index += 1;
|
|
1822
|
+
continue;
|
|
1823
|
+
}
|
|
1824
|
+
if (!quote && char === ")" && substitutionDepth > 0) {
|
|
1825
|
+
substitutionDepth -= 1;
|
|
1826
|
+
current += char;
|
|
1827
|
+
continue;
|
|
1828
|
+
}
|
|
1829
|
+
if (!quote && substitutionDepth === 0) {
|
|
1830
|
+
const pair = text.slice(index, index + 2);
|
|
1831
|
+
if (pair === "&&" || pair === "||") {
|
|
1832
|
+
if (!push(pair)) return null;
|
|
1833
|
+
index += 1;
|
|
1834
|
+
continue;
|
|
1835
|
+
}
|
|
1836
|
+
if (char === ";" || char === "\n") {
|
|
1837
|
+
if (current.trim() && !push(char === "\n" ? "newline" : char)) return null;
|
|
1838
|
+
continue;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
current += char;
|
|
1842
|
+
}
|
|
1843
|
+
if (quote || escaped || substitutionDepth !== 0) return null;
|
|
1844
|
+
if (current.trim()) commands.push(current.trim());
|
|
1845
|
+
if (!commands.length || separators.length >= commands.length) return null;
|
|
1846
|
+
return { commands, separators };
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function commandHasUnbalancedSubstitution(value = "") {
|
|
1850
|
+
const text = String(value || "");
|
|
1851
|
+
let depth = 0;
|
|
1852
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1853
|
+
if (text[index] === "$" && text[index + 1] === "(") {
|
|
1854
|
+
depth += 1;
|
|
1855
|
+
index += 1;
|
|
1856
|
+
continue;
|
|
1857
|
+
}
|
|
1858
|
+
if (text[index] === ")" && depth > 0) depth -= 1;
|
|
1859
|
+
}
|
|
1860
|
+
return depth !== 0;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
function sanitizeReadOnlyLoopAssignments(bodySource = "") {
|
|
1864
|
+
const text = String(bodySource || "").trim();
|
|
1865
|
+
const standardSequence = parseTopLevelShellSequence(text);
|
|
1866
|
+
const standardIsUsable =
|
|
1867
|
+
standardSequence.commands.length > 0 &&
|
|
1868
|
+
!standardSequence.openQuote &&
|
|
1869
|
+
!standardSequence.trailingEscape &&
|
|
1870
|
+
!standardSequence.trailingSeparator &&
|
|
1871
|
+
!standardSequence.commands.some(commandHasUnbalancedSubstitution);
|
|
1872
|
+
const sequence = standardIsUsable
|
|
1873
|
+
? standardSequence
|
|
1874
|
+
: parseReadOnlyLoopBodySequence(text);
|
|
1875
|
+
if (!sequence) return null;
|
|
1876
|
+
|
|
1877
|
+
const assigned = new Map();
|
|
1878
|
+
const commands = [];
|
|
1879
|
+
for (const sourceCommand of sequence.commands) {
|
|
1880
|
+
let command = sourceCommand;
|
|
1881
|
+
for (const [name, state] of assigned.entries()) {
|
|
1882
|
+
const escapedName = shellIdentifierPattern(name);
|
|
1883
|
+
const reference = new RegExp(`\\$(?:\\{${escapedName}\\}|${escapedName}(?![A-Za-z0-9_]))`, "g");
|
|
1884
|
+
if (!reference.test(command)) continue;
|
|
1885
|
+
if (!/^(?:echo|printf)\s+/.test(command)) return null;
|
|
1886
|
+
command = command.replace(reference, `AGINTI_READ_VALUE_${name}`);
|
|
1887
|
+
state.used = true;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
const assignment = command.match(/^([A-Za-z_][A-Za-z0-9_]*)=(\$\([\s\S]+\))$/);
|
|
1891
|
+
if (!assignment) {
|
|
1892
|
+
commands.push(command);
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1895
|
+
const [, name, expression] = assignment;
|
|
1896
|
+
if (assigned.has(name) || assigned.size >= 4) return null;
|
|
1897
|
+
const extracted = extractBoundedCommandSubstitutions(`echo ${expression}`);
|
|
1898
|
+
if (
|
|
1899
|
+
!extracted ||
|
|
1900
|
+
extracted.commands.length !== 1 ||
|
|
1901
|
+
extracted.template !== "echo AGINTI_READ_VALUE_1"
|
|
1902
|
+
) {
|
|
1903
|
+
return null;
|
|
1904
|
+
}
|
|
1905
|
+
const inner = extracted.commands[0];
|
|
1906
|
+
const classification = classifyShellSequence(inner) ||
|
|
1907
|
+
classifyPipelineSequence(inner) ||
|
|
1908
|
+
classifySimpleCommand(inner);
|
|
1909
|
+
if (
|
|
1910
|
+
classification.category !== "read-only" ||
|
|
1911
|
+
classification.writesWorkspace ||
|
|
1912
|
+
classification.needsNetwork ||
|
|
1913
|
+
!isBoundedReadOnlyScalarCommand(inner)
|
|
1914
|
+
) {
|
|
1915
|
+
return null;
|
|
1916
|
+
}
|
|
1917
|
+
assigned.set(name, { used: false });
|
|
1918
|
+
commands.push("true");
|
|
1919
|
+
}
|
|
1920
|
+
if ([...assigned.values()].some((state) => !state.used)) return null;
|
|
1921
|
+
|
|
1922
|
+
let sanitized = commands[0];
|
|
1923
|
+
for (let index = 1; index < commands.length; index += 1) {
|
|
1924
|
+
const separator = sequence.separators[index - 1] === "newline"
|
|
1925
|
+
? "\n"
|
|
1926
|
+
: sequence.separators[index - 1] || ";";
|
|
1927
|
+
sanitized += separator === "\n"
|
|
1928
|
+
? `\n${commands[index]}`
|
|
1929
|
+
: ` ${separator} ${commands[index]}`;
|
|
1930
|
+
}
|
|
1931
|
+
return sanitized;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
const READ_ONLY_LITERAL_ASSIGNMENT_MAX = 8;
|
|
1935
|
+
|
|
1936
|
+
function boundedLiteralAssignment(command = "") {
|
|
1937
|
+
const source = String(command || "").trim();
|
|
1938
|
+
if (!source || hasActiveShellExpansion(source)) return null;
|
|
1939
|
+
const tokens = tokenizeShellWords(source);
|
|
1940
|
+
if (tokens.length !== 1) return null;
|
|
1941
|
+
const match = tokens[0].match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]+)$/);
|
|
1942
|
+
if (!match) return null;
|
|
1943
|
+
const [, name, value] = match;
|
|
1944
|
+
if (
|
|
1945
|
+
value.startsWith("-") ||
|
|
1946
|
+
!READ_ONLY_FOR_LOOP_LITERAL_PATTERN.test(value) ||
|
|
1947
|
+
/[\r\n]/.test(value)
|
|
1948
|
+
) {
|
|
1949
|
+
return null;
|
|
1950
|
+
}
|
|
1951
|
+
return { name, value };
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function replaceBoundedLiteralReferences(source = "", name = "", value = "") {
|
|
1955
|
+
const text = String(source || "");
|
|
1956
|
+
let result = "";
|
|
1957
|
+
let quote = "";
|
|
1958
|
+
let escaped = false;
|
|
1959
|
+
let replacements = 0;
|
|
1960
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1961
|
+
const char = text[index];
|
|
1962
|
+
if (quote === "'") {
|
|
1963
|
+
result += char;
|
|
1964
|
+
if (char === "'") quote = "";
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
if (escaped) {
|
|
1968
|
+
result += char;
|
|
1969
|
+
escaped = false;
|
|
1970
|
+
continue;
|
|
1971
|
+
}
|
|
1972
|
+
if (char === "\\") {
|
|
1973
|
+
result += char;
|
|
1974
|
+
escaped = true;
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1977
|
+
if (char === '"') {
|
|
1978
|
+
quote = quote === '"' ? "" : '"';
|
|
1979
|
+
result += char;
|
|
1980
|
+
continue;
|
|
1981
|
+
}
|
|
1982
|
+
if (!quote && char === "'") {
|
|
1983
|
+
quote = "'";
|
|
1984
|
+
result += char;
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
if (char !== "$") {
|
|
1988
|
+
result += char;
|
|
1989
|
+
continue;
|
|
1990
|
+
}
|
|
1991
|
+
const braced = text.startsWith(`\${${name}}`, index);
|
|
1992
|
+
const bare = text.startsWith(`$${name}`, index) &&
|
|
1993
|
+
!/[A-Za-z0-9_]/.test(text[index + name.length + 1] || "");
|
|
1994
|
+
if (!braced && !bare) {
|
|
1995
|
+
result += char;
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1998
|
+
result += quote === '"' || !/\s/.test(value) ? value : `'${value}'`;
|
|
1999
|
+
replacements += 1;
|
|
2000
|
+
index += braced ? name.length + 2 : name.length;
|
|
2001
|
+
}
|
|
2002
|
+
return { text: result, replacements };
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
function joinParsedShellSequence(commands = [], separators = []) {
|
|
2006
|
+
let result = commands[0] || "";
|
|
2007
|
+
for (let index = 1; index < commands.length; index += 1) {
|
|
2008
|
+
const separator = separators[index - 1] === "newline"
|
|
2009
|
+
? "\n"
|
|
2010
|
+
: separators[index - 1] || ";";
|
|
2011
|
+
result += separator === "\n"
|
|
2012
|
+
? `\n${commands[index]}`
|
|
2013
|
+
: ` ${separator} ${commands[index]}`;
|
|
2014
|
+
}
|
|
2015
|
+
return result;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function sanitizeReadOnlyCompoundLiteralAssignments({
|
|
2019
|
+
prefixSource = "",
|
|
2020
|
+
loopSource = "",
|
|
2021
|
+
suffixSource = "",
|
|
2022
|
+
loopVariable = "",
|
|
2023
|
+
} = {}) {
|
|
2024
|
+
const parsed = parseTopLevelShellSequence(prefixSource);
|
|
2025
|
+
if (
|
|
2026
|
+
!parsed.commands.length ||
|
|
2027
|
+
parsed.openQuote ||
|
|
2028
|
+
parsed.trailingEscape ||
|
|
2029
|
+
parsed.trailingSeparator
|
|
2030
|
+
) {
|
|
2031
|
+
return null;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
const assignments = new Map();
|
|
2035
|
+
const commands = [];
|
|
2036
|
+
for (const sourceCommand of parsed.commands) {
|
|
2037
|
+
const assignment = boundedLiteralAssignment(sourceCommand);
|
|
2038
|
+
if (assignment) {
|
|
2039
|
+
if (
|
|
2040
|
+
assignments.has(assignment.name) ||
|
|
2041
|
+
assignment.name === loopVariable ||
|
|
2042
|
+
assignments.size >= READ_ONLY_LITERAL_ASSIGNMENT_MAX
|
|
2043
|
+
) {
|
|
2044
|
+
return null;
|
|
2045
|
+
}
|
|
2046
|
+
assignments.set(assignment.name, { value: assignment.value, used: false });
|
|
2047
|
+
commands.push("true");
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
let command = sourceCommand;
|
|
2051
|
+
for (const [name, state] of assignments.entries()) {
|
|
2052
|
+
const replaced = replaceBoundedLiteralReferences(command, name, state.value);
|
|
2053
|
+
command = replaced.text;
|
|
2054
|
+
state.used ||= replaced.replacements > 0;
|
|
2055
|
+
}
|
|
2056
|
+
commands.push(command);
|
|
2057
|
+
}
|
|
2058
|
+
if (!assignments.size) {
|
|
2059
|
+
return { prefixSource, loopSource, suffixSource };
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
let sanitizedLoop = loopSource;
|
|
2063
|
+
let sanitizedSuffix = suffixSource;
|
|
2064
|
+
for (const [name, state] of assignments.entries()) {
|
|
2065
|
+
const loopReplacement = replaceBoundedLiteralReferences(sanitizedLoop, name, state.value);
|
|
2066
|
+
sanitizedLoop = loopReplacement.text;
|
|
2067
|
+
const suffixReplacement = replaceBoundedLiteralReferences(sanitizedSuffix, name, state.value);
|
|
2068
|
+
sanitizedSuffix = suffixReplacement.text;
|
|
2069
|
+
state.used ||= loopReplacement.replacements > 0 || suffixReplacement.replacements > 0;
|
|
2070
|
+
}
|
|
2071
|
+
if ([...assignments.values()].some((state) => !state.used)) return null;
|
|
2072
|
+
return {
|
|
2073
|
+
prefixSource: joinParsedShellSequence(commands, parsed.separators),
|
|
2074
|
+
loopSource: sanitizedLoop,
|
|
2075
|
+
suffixSource: sanitizedSuffix,
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
|
|
1711
2079
|
function classifyReadOnlyForLoop(normalized) {
|
|
1712
2080
|
const text = String(normalized || "").trim();
|
|
1713
2081
|
if (!/^for\s+/.test(text)) return null;
|
|
@@ -1724,7 +2092,11 @@ function classifyReadOnlyForLoop(normalized) {
|
|
|
1724
2092
|
if (hasActiveShellExpansion(itemSource) || hasActiveShellCommandSubstitution(itemSource)) {
|
|
1725
2093
|
return broadForLoopClassification(text, "the item list is dynamic");
|
|
1726
2094
|
}
|
|
1727
|
-
|
|
2095
|
+
// A model commonly formats a finite literal list with shell line
|
|
2096
|
+
// continuations. Removing only the exact backslash-newline token preserves
|
|
2097
|
+
// the same static list without admitting arbitrary escapes or expansion.
|
|
2098
|
+
const normalizedItemSource = itemSource.replace(/\\\r?\n/g, " ");
|
|
2099
|
+
const items = tokenizeShellWords(normalizedItemSource);
|
|
1728
2100
|
if (!items.length || items.length > READ_ONLY_FOR_LOOP_MAX_ITEMS) {
|
|
1729
2101
|
return broadForLoopClassification(text, "the item list is empty or unbounded");
|
|
1730
2102
|
}
|
|
@@ -1747,10 +2119,11 @@ function classifyReadOnlyForLoop(normalized) {
|
|
|
1747
2119
|
|
|
1748
2120
|
for (const item of items) {
|
|
1749
2121
|
const expandedBody = bodySource.replace(variableReference, item);
|
|
1750
|
-
|
|
2122
|
+
const sanitizedBody = sanitizeReadOnlyLoopAssignments(expandedBody);
|
|
2123
|
+
if (!sanitizedBody || !loopBodyHasOnlyBoundedReadOnlyExpansions(sanitizedBody)) {
|
|
1751
2124
|
return broadForLoopClassification(text, "the body contains expansion beyond the loop variable");
|
|
1752
2125
|
}
|
|
1753
|
-
const classification = classifyReadOnlyLoopBody(
|
|
2126
|
+
const classification = classifyReadOnlyLoopBody(sanitizedBody);
|
|
1754
2127
|
if (classification.category === "blocked" || classification.category === "destructive") {
|
|
1755
2128
|
return { ...classification, gitOnly: false };
|
|
1756
2129
|
}
|
|
@@ -1771,7 +2144,7 @@ function classifyReadOnlyForLoop(normalized) {
|
|
|
1771
2144
|
|
|
1772
2145
|
function classifyReadOnlyCompoundSequence(normalized) {
|
|
1773
2146
|
const text = String(normalized || "").trim();
|
|
1774
|
-
if (!text
|
|
2147
|
+
if (!text) return null;
|
|
1775
2148
|
|
|
1776
2149
|
let forIndex = findUnquotedShellWord(text, "for");
|
|
1777
2150
|
const startsAtCommandBoundary = (index) => {
|
|
@@ -1784,13 +2157,27 @@ function classifyReadOnlyCompoundSequence(normalized) {
|
|
|
1784
2157
|
}
|
|
1785
2158
|
if (forIndex < 0) return null;
|
|
1786
2159
|
|
|
1787
|
-
|
|
2160
|
+
let prefixSource = trimShellListBoundary(text.slice(0, forIndex));
|
|
1788
2161
|
const doneIndex = findUnquotedShellWord(text, "done", forIndex + 3);
|
|
1789
2162
|
if (doneIndex < 0) return null;
|
|
1790
|
-
|
|
1791
|
-
|
|
2163
|
+
let loopSource = text.slice(forIndex, doneIndex + 4).trim();
|
|
2164
|
+
let suffixSource = trimShellListBoundary(text.slice(doneIndex + 4));
|
|
1792
2165
|
if (!prefixSource && !suffixSource) return null;
|
|
1793
2166
|
|
|
2167
|
+
const loopVariable = loopSource.match(/^for[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]+in\b/)?.[1] || "";
|
|
2168
|
+
if (prefixSource) {
|
|
2169
|
+
const sanitized = sanitizeReadOnlyCompoundLiteralAssignments({
|
|
2170
|
+
prefixSource,
|
|
2171
|
+
loopSource,
|
|
2172
|
+
suffixSource,
|
|
2173
|
+
loopVariable,
|
|
2174
|
+
});
|
|
2175
|
+
if (!sanitized) {
|
|
2176
|
+
return broadForLoopClassification(text, "the prelude contains an unsafe or unused shell assignment");
|
|
2177
|
+
}
|
|
2178
|
+
({ prefixSource, loopSource, suffixSource } = sanitized);
|
|
2179
|
+
}
|
|
2180
|
+
|
|
1794
2181
|
for (const [label, source] of [["prelude", prefixSource], ["suffix", suffixSource]]) {
|
|
1795
2182
|
if (!source) continue;
|
|
1796
2183
|
const classification = classifyReadOnlyCommandList(source);
|
|
@@ -1908,7 +2295,11 @@ function classifyCdCommand(normalized) {
|
|
|
1908
2295
|
if (!isSafeRelativeDir(dir) && !virtualWorkspacePath) {
|
|
1909
2296
|
return { category: "blocked", reason: `cd target must be a safe workspace-relative directory: ${dir}` };
|
|
1910
2297
|
}
|
|
1911
|
-
const innerClassification =
|
|
2298
|
+
const innerClassification = classifyReadOnlyCompoundSequence(inner.trim()) ||
|
|
2299
|
+
classifyReadOnlyForLoop(inner.trim()) ||
|
|
2300
|
+
classifyShellSequence(inner.trim()) ||
|
|
2301
|
+
classifyPipelineSequence(inner.trim()) ||
|
|
2302
|
+
classifySimpleCommand(inner.trim());
|
|
1912
2303
|
if (innerClassification.category === "blocked") return innerClassification;
|
|
1913
2304
|
return { ...innerClassification, cdDir: dir, virtualWorkspacePath };
|
|
1914
2305
|
}
|
|
@@ -1929,7 +2320,9 @@ function classifyReadOnlyRootCd(normalized, config = {}) {
|
|
|
1929
2320
|
const readOnlyRoot = configuredReadOnlyRoot(config, dir);
|
|
1930
2321
|
if (!readOnlyRoot) return null;
|
|
1931
2322
|
|
|
1932
|
-
const innerClassification =
|
|
2323
|
+
const innerClassification = classifyReadOnlyCompoundSequence(inner.trim()) ||
|
|
2324
|
+
classifyReadOnlyForLoop(inner.trim()) ||
|
|
2325
|
+
classifyShellSequence(inner.trim()) ||
|
|
1933
2326
|
classifyPipelineSequence(inner.trim()) ||
|
|
1934
2327
|
classifySimpleCommand(inner.trim());
|
|
1935
2328
|
const safeCategory = ["read-only", "network-fetch"].includes(innerClassification.category);
|
package/src/scs-evidence.js
CHANGED
|
@@ -2952,6 +2952,14 @@ export function finishResultClaimsBlocker(result = "") {
|
|
|
2952
2952
|
|
|
2953
2953
|
export function finishResultClaimsIncompleteWork(result = "") {
|
|
2954
2954
|
const text = String(result || "")
|
|
2955
|
+
.replace(
|
|
2956
|
+
/\b(?:earlier|previous|prior|former)\s+(?:step|attempt|run|verification|check|phase|command|process|audit)\s+(?:was|were|had\s+been)\s+(?:paused|pending|incomplete|unfinished)\b/gi,
|
|
2957
|
+
""
|
|
2958
|
+
)
|
|
2959
|
+
.replace(
|
|
2960
|
+
/\b(?:(?:was|were|had\s+been)\s+)?(?:previously|formerly|earlier|already)\s+(?:paused|pending|incomplete|unfinished)\b/gi,
|
|
2961
|
+
""
|
|
2962
|
+
)
|
|
2955
2963
|
.replace(/\b(?:not|never)\s+(?:paused|pending|incomplete|unfinished)\b/gi, "")
|
|
2956
2964
|
.replace(/\b(?:no|without)\s+(?:remaining|pending|unfinished)\s+(?:work|steps?|tasks?|actions?)\b/gi, "");
|
|
2957
2965
|
const explicitIncompleteState =
|