@lazyingart/agintiflow 0.20.217 → 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.
|
@@ -267,3 +267,66 @@ platform-aware POSIX and Windows command quoting; and recognition of bounded
|
|
|
267
267
|
stdin `sed -n` pipelines as read-only inspection. Production code contains no
|
|
268
268
|
campaign scenario IDs, target paths, expected prose, commit IDs, or acceptance
|
|
269
269
|
literals.
|
|
270
|
+
|
|
271
|
+
### Live inbox requirement coalescing
|
|
272
|
+
|
|
273
|
+
The next installed-release run tested actual mid-turn input rather than a
|
|
274
|
+
sequence of explicit resume commands. A DeepSeek task started from a normal
|
|
275
|
+
repository-writing request. While its first model call was active, two
|
|
276
|
+
independent corrections were appended through `aginti queue`. Both received
|
|
277
|
+
distinct durable inbox IDs and were applied exactly once at the first safe tool
|
|
278
|
+
boundary, before the source note was read.
|
|
279
|
+
|
|
280
|
+
AgInTiFlow `0.20.217` retained both corrections through later context
|
|
281
|
+
compaction, produced one concise artifact, removed every superseded value,
|
|
282
|
+
preserved the source note, added only narrowly scoped runtime ignore rules,
|
|
283
|
+
committed the intentional files, and left the target repository clean. An
|
|
284
|
+
external semantic and repository-state contract passed at target commit
|
|
285
|
+
`f19ff4b`. No AgInTiFlow source change or task-specific prompt branch was
|
|
286
|
+
needed; the run validates the generic inbox, context, evidence, and completion
|
|
287
|
+
contracts in the published package.
|
|
288
|
+
|
|
289
|
+
### LabCanvas least-privilege worker integration
|
|
290
|
+
|
|
291
|
+
`labcanvas-worker-permission-018` validated the installed AgInTiFlow `0.20.217`
|
|
292
|
+
through LabCanvas after a real WeCom research task paused at
|
|
293
|
+
`permission_required`. The defect was in the integration boundary: LabCanvas
|
|
294
|
+
unconditionally selected AgInTi's blocked package-install tier, which also
|
|
295
|
+
blocked ordinary workspace shell execution. LabCanvas now keeps response-only
|
|
296
|
+
roles blocked while granting genuine worker roles reversible package setup
|
|
297
|
+
inside the Docker workspace. Host access, credentials, payment, publication,
|
|
298
|
+
destructive operations, and other irreversible actions remain separately
|
|
299
|
+
guarded.
|
|
300
|
+
|
|
301
|
+
A direct DeepSeek-backed worker smoke created and validated a bounded workspace
|
|
302
|
+
artifact without a permission prompt. Focused backend regressions, all WeChat
|
|
303
|
+
self-checks, the complete 1,442-test LabCanvas suite, and GitHub Actions passed.
|
|
304
|
+
The originally blocked exact-chat task was then resumed without duplicate model
|
|
305
|
+
work, produced a visually inspected nine-page XeLaTeX research report, and
|
|
306
|
+
delivered one concise message plus the PDF through the verified WeCom transport.
|
|
307
|
+
The follow-up message was preserved in the same chat session. The reusable fix
|
|
308
|
+
is AgenticApp commit `e705bb0`; no AgInTiFlow release change was required.
|
|
309
|
+
|
|
310
|
+
### Durable background-job recovery after foreground interruption
|
|
311
|
+
|
|
312
|
+
`long-job-recovery-019` exercised installed AgInTiFlow `0.20.217` with a normal
|
|
313
|
+
laboratory-export request whose terminal could disconnect. The DeepSeek-backed
|
|
314
|
+
agent inspected the checked-in resumable scripts, rejected duplicate execution,
|
|
315
|
+
and selected the generic `start_long_job` routine. The campaign interrupted only
|
|
316
|
+
the foreground AgInTi pane after durable admission; the independent job tmux
|
|
317
|
+
continued, completed eight delayed batches, ran deferred verification, and
|
|
318
|
+
cleaned up its own tmux session.
|
|
319
|
+
|
|
320
|
+
The same AgInTi session then resumed from retained state. It recovered the exact
|
|
321
|
+
job ID through `long_job_status`, independently reran the project verifier,
|
|
322
|
+
confirmed the artifact and checksum, and finished without calling
|
|
323
|
+
`start_long_job` again. Proactive context compaction reduced the resumed history
|
|
324
|
+
from 190,805 to 30,680 characters while preserving the job identity and original
|
|
325
|
+
task boundary.
|
|
326
|
+
|
|
327
|
+
The independent acceptance contract verified one process invocation, one durable
|
|
328
|
+
job directory, attempt `1`, command and verifier exit `0`, the required durable
|
|
329
|
+
event sequence, a project handoff with exact status and verification commands,
|
|
330
|
+
and no lingering job tmux session. No source patch or npm release was needed;
|
|
331
|
+
this run validates the published package's general long-job, resume, context,
|
|
332
|
+
deduplication, and cleanup contracts without task-specific routing.
|
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",
|
|
@@ -583,6 +583,100 @@ try {
|
|
|
583
583
|
...hostWorkspacePolicy,
|
|
584
584
|
readOnlyRoots: [externalReadRoot],
|
|
585
585
|
};
|
|
586
|
+
const exactReadOnlySkillLoop = [
|
|
587
|
+
"for f in",
|
|
588
|
+
"/home/lachlan/.agintiflow/skillmesh/skills/lazyedit-publish-workflow/SKILL.md",
|
|
589
|
+
"/home/lachlan/.codex/skills/musia-music-production/SKILL.md",
|
|
590
|
+
"/home/lachlan/.codex/skills/musia-lalachan-mv-workflow/SKILL.md",
|
|
591
|
+
"/home/lachlan/.codex/skills/lalachan-xyq-browser-video/SKILL.md",
|
|
592
|
+
"/home/lachlan/.nvm/versions/node/v22.21.0/lib/node_modules/@lazyingart/agintiflow/skills/browser-automation/SKILL.md",
|
|
593
|
+
"/home/lachlan/.codex/skills/musia-song-localization/SKILL.md;",
|
|
594
|
+
'do echo "===== $f ====="; wc -l "$f"; done',
|
|
595
|
+
].join(" ");
|
|
596
|
+
const exactReadOnlySkillLoopPolicy = evaluateCommandPolicy(exactReadOnlySkillLoop, hostWorkspacePolicy);
|
|
597
|
+
assert(
|
|
598
|
+
exactReadOnlySkillLoopPolicy.allowed &&
|
|
599
|
+
exactReadOnlySkillLoopPolicy.category === "read-only" &&
|
|
600
|
+
exactReadOnlySkillLoopPolicy.boundedForLoop === true &&
|
|
601
|
+
exactReadOnlySkillLoopPolicy.needsNetwork === false &&
|
|
602
|
+
exactReadOnlySkillLoopPolicy.writesWorkspace === false,
|
|
603
|
+
"a finite echo/wc skill audit loop incorrectly required destructive host permission"
|
|
604
|
+
);
|
|
605
|
+
const multilineReadOnlyLoopPolicy = evaluateCommandPolicy(
|
|
606
|
+
[
|
|
607
|
+
"for target in README.md package.json",
|
|
608
|
+
"do",
|
|
609
|
+
' echo "== ${target} =="',
|
|
610
|
+
' wc -l "$target"',
|
|
611
|
+
"done",
|
|
612
|
+
].join("\n"),
|
|
613
|
+
hostWorkspacePolicy
|
|
614
|
+
);
|
|
615
|
+
assert(
|
|
616
|
+
multilineReadOnlyLoopPolicy.allowed && multilineReadOnlyLoopPolicy.category === "read-only",
|
|
617
|
+
"a multiline finite read-only loop was not recognized"
|
|
618
|
+
);
|
|
619
|
+
for (const unsafeLoop of [
|
|
620
|
+
'for f in README.md; do rm -rf "$f"; done',
|
|
621
|
+
'for f in README.md; do curl "https://example.com/$f"; done',
|
|
622
|
+
'for f in README.md; do cp "$f" copied.md; done',
|
|
623
|
+
'for f in README.md; do echo "$f" > report.md; done',
|
|
624
|
+
'for f in $(find . -type f); do wc -l "$f"; done',
|
|
625
|
+
'for f in *.md; do wc -l "$f"; done',
|
|
626
|
+
'for f in --pre=unsafe-helper; do rg pattern "$f"; done',
|
|
627
|
+
'for f in $FILES; do wc -l "$f"; done',
|
|
628
|
+
'for f in README.md; do wc -l "${f:-package.json}"; done',
|
|
629
|
+
'for f in README.md; do for g in package.json; do wc -l "$g"; done; done',
|
|
630
|
+
'for f in README.md; do wc -l "$f" & done',
|
|
631
|
+
]) {
|
|
632
|
+
const unsafeLoopPolicy = evaluateCommandPolicy(unsafeLoop, hostWorkspacePolicy);
|
|
633
|
+
assert(
|
|
634
|
+
!unsafeLoopPolicy.allowed && unsafeLoopPolicy.category !== "read-only",
|
|
635
|
+
`unsafe or dynamic shell loop bypassed the bounded read-only policy: ${unsafeLoop}`
|
|
636
|
+
);
|
|
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
|
+
}
|
|
586
680
|
const readRootDoctorPolicy = evaluateCommandPolicy(
|
|
587
681
|
`cd ${externalReadRoot} && node bin/musia.js doctor --json 2>&1 | head -80`,
|
|
588
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+.+)?$/,
|
|
@@ -1372,6 +1372,257 @@ function classifyShellSequence(normalized) {
|
|
|
1372
1372
|
};
|
|
1373
1373
|
}
|
|
1374
1374
|
|
|
1375
|
+
const READ_ONLY_FOR_LOOP_MAX_ITEMS = 64;
|
|
1376
|
+
const READ_ONLY_FOR_LOOP_MAX_COMMANDS = 16;
|
|
1377
|
+
const READ_ONLY_FOR_LOOP_LITERAL_PATTERN = /^[A-Za-z0-9_@%+=:,./~+-]+$/;
|
|
1378
|
+
|
|
1379
|
+
function shellIdentifierPattern(value = "") {
|
|
1380
|
+
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function broadForLoopClassification(normalized, reason) {
|
|
1384
|
+
return {
|
|
1385
|
+
category: "general-shell",
|
|
1386
|
+
needsNetwork: false,
|
|
1387
|
+
writesWorkspace: true,
|
|
1388
|
+
reason: `Shell for-loop is outside the bounded read-only form (${reason}): ${normalized}`,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
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
|
+
|
|
1521
|
+
function classifyReadOnlyForLoop(normalized) {
|
|
1522
|
+
const text = String(normalized || "").trim();
|
|
1523
|
+
if (!/^for\s+/.test(text)) return null;
|
|
1524
|
+
if (text.length > 64 * 1024) {
|
|
1525
|
+
return broadForLoopClassification(text, "command is too large");
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const match = text.match(
|
|
1529
|
+
/^for[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]+in[ \t]+([\s\S]*?)(?:;|\n)[ \t]*do\s+([\s\S]*?)(?:;|\n)[ \t]*done[ \t]*$/
|
|
1530
|
+
);
|
|
1531
|
+
if (!match) return broadForLoopClassification(text, "unsupported loop syntax");
|
|
1532
|
+
|
|
1533
|
+
const [, variableName, itemSource, bodySource] = match;
|
|
1534
|
+
if (hasActiveShellExpansion(itemSource) || hasActiveShellCommandSubstitution(itemSource)) {
|
|
1535
|
+
return broadForLoopClassification(text, "the item list is dynamic");
|
|
1536
|
+
}
|
|
1537
|
+
const items = tokenizeShellWords(itemSource);
|
|
1538
|
+
if (!items.length || items.length > READ_ONLY_FOR_LOOP_MAX_ITEMS) {
|
|
1539
|
+
return broadForLoopClassification(text, "the item list is empty or unbounded");
|
|
1540
|
+
}
|
|
1541
|
+
if (items.some((item) => item.startsWith("-") || !READ_ONLY_FOR_LOOP_LITERAL_PATTERN.test(item))) {
|
|
1542
|
+
return broadForLoopClassification(
|
|
1543
|
+
text,
|
|
1544
|
+
"items must be finite literal words or paths without option prefixes or globs"
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
if (hasActiveShellCommandSubstitution(bodySource)) {
|
|
1549
|
+
return broadForLoopClassification(text, "the body uses command substitution");
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const escapedVariable = shellIdentifierPattern(variableName);
|
|
1553
|
+
const variableReference = new RegExp(
|
|
1554
|
+
`\\$(?:\\{${escapedVariable}\\}|${escapedVariable}(?![A-Za-z0-9_]))`,
|
|
1555
|
+
"g"
|
|
1556
|
+
);
|
|
1557
|
+
const referenceCount = [...bodySource.matchAll(variableReference)].length;
|
|
1558
|
+
if (!referenceCount) {
|
|
1559
|
+
return broadForLoopClassification(text, "the body does not consume the loop item");
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
for (const item of items) {
|
|
1563
|
+
const expandedBody = bodySource.replace(variableReference, item);
|
|
1564
|
+
if (hasActiveShellExpansion(expandedBody) || hasActiveShellCommandSubstitution(expandedBody)) {
|
|
1565
|
+
return broadForLoopClassification(text, "the body contains expansion beyond the loop variable");
|
|
1566
|
+
}
|
|
1567
|
+
const classification = classifyReadOnlyLoopBody(expandedBody);
|
|
1568
|
+
if (classification.category === "blocked" || classification.category === "destructive") {
|
|
1569
|
+
return { ...classification, gitOnly: false };
|
|
1570
|
+
}
|
|
1571
|
+
if (classification.category !== "read-only" || classification.writesWorkspace) {
|
|
1572
|
+
return broadForLoopClassification(text, `body is ${classification.category}`);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
return {
|
|
1577
|
+
category: "read-only",
|
|
1578
|
+
needsNetwork: false,
|
|
1579
|
+
writesWorkspace: false,
|
|
1580
|
+
gitOnly: false,
|
|
1581
|
+
boundedForLoop: true,
|
|
1582
|
+
reason: `Finite read-only shell loop over ${items.length} literal item${items.length === 1 ? "" : "s"}.`,
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
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
|
+
|
|
1375
1626
|
function splitTopLevelPipeline(command = "") {
|
|
1376
1627
|
const parts = [];
|
|
1377
1628
|
let current = "";
|
|
@@ -1536,7 +1787,7 @@ export function classifyCommand(command) {
|
|
|
1536
1787
|
const normalized = String(command || "").trim();
|
|
1537
1788
|
if (!normalized) return { category: "blocked", reason: "Command is empty." };
|
|
1538
1789
|
|
|
1539
|
-
return classifyBackgroundShell(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);
|
|
1540
1791
|
}
|
|
1541
1792
|
|
|
1542
1793
|
export function evaluateCommandPolicy(command, config = {}) {
|