@mmnto/cli 2.7.0 → 2.8.0
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/dist/commands/gate-install.test.js +1763 -104
- package/dist/commands/gate-install.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +3 -3
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +1030 -253
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +53 -0
- package/dist/commands/init.test.js.map +1 -1
- package/package.json +2 -2
|
@@ -1381,6 +1381,12 @@ export const CLAUDE_GATE_WRAPPER = `// [totem] auto-generated — Claude Code ac
|
|
|
1381
1381
|
// or unknown disposition)
|
|
1382
1382
|
// | an --event this wrapper has no payload projection for (a baked event
|
|
1383
1383
|
// it cannot project is an applicable gate it cannot evaluate)
|
|
1384
|
+
// | the BUDGET spent before a gate could be evaluated — the projection's
|
|
1385
|
+
// git reads did not answer inside it (mmnto-ai/totem#2856 § D)
|
|
1386
|
+
// | the BUDGET spent before the envelope arrived on stdin — the host
|
|
1387
|
+
// opened this hook and never closed its input (same § D, fold F6)
|
|
1388
|
+
// Both budget arms are fail-closed at EVERY tier, --pilot included: an
|
|
1389
|
+
// applicable gate that could not be evaluated is not a softened deny.
|
|
1384
1390
|
'use strict';
|
|
1385
1391
|
|
|
1386
1392
|
const { spawnSync } = require('child_process');
|
|
@@ -1449,93 +1455,128 @@ function resolveCliFromPath() {
|
|
|
1449
1455
|
return null;
|
|
1450
1456
|
}
|
|
1451
1457
|
|
|
1452
|
-
// ─── Parse baked args (--event <name>, optional --pilot / --strict) ─────
|
|
1453
|
-
// The tier is read ONLY from argv (baked into the installed command at
|
|
1454
|
-
// install time). There is NO env-var override: env sourcing would be a
|
|
1455
|
-
// fail-open (any shell with TOTEM_GATE_TIER=pilot could silently downgrade
|
|
1456
|
-
// enforcement). Default (no flag) = strict, so a default install is
|
|
1457
|
-
// environment-immune; --pilot is an explicit install-time opt-in.
|
|
1458
|
-
const argv = process.argv.slice(2);
|
|
1459
|
-
let event = '';
|
|
1460
|
-
let tier = 'strict';
|
|
1461
|
-
for (let i = 0; i < argv.length; i++) {
|
|
1462
|
-
if (argv[i] === '--event') {
|
|
1463
|
-
event = argv[i + 1] || '';
|
|
1464
|
-
i++;
|
|
1465
|
-
} else if (argv[i] === '--pilot') {
|
|
1466
|
-
tier = 'pilot';
|
|
1467
|
-
} else if (argv[i] === '--strict') {
|
|
1468
|
-
tier = 'strict';
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
1458
|
// ─── merge-ready: \`gh pr merge\` at COMMAND POSITION + its payload ──────
|
|
1473
1459
|
//
|
|
1474
1460
|
// One walk over the command text does BOTH jobs, so recognition and argv
|
|
1475
1461
|
// extraction can never disagree: it tracks quoting, splits on the unquoted
|
|
1476
|
-
// command separators (\`;\`, \`&\`, \`|\`, a newline, \`(\`/\`)\`, \`{\`/\`}
|
|
1477
|
-
// tokenizes each segment. A segment whose FIRST token is
|
|
1478
|
-
// \`pr\` and \`merge\`, is a merge at command
|
|
1479
|
-
// "gh pr merge" is a single token and never matches, so
|
|
1480
|
-
// \`echo "gh pr merge"\` does not fire.
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1483
|
-
// \`
|
|
1484
|
-
// \`NAME=value\` assignment prefixes — so \`for … ; do gh pr merge;
|
|
1485
|
-
// \`if gh pr merge 5; then …\` and \`GH_TOKEN=x gh pr merge 5\` all fire.
|
|
1486
|
-
// the PR's review round only \`do\`/\`then\`/\`else\`/\`!\` were skipped, so a
|
|
1487
|
-
// used AS an \`if\` condition, or behind an assignment prefix, went
|
|
1488
|
-
// (mmnto-ai/totem#2844 round 1, greptile). EVERY matching segment is
|
|
1462
|
+
// command separators (\`;\`, \`&\`, \`|\`, a newline, \`(\`/\`)\`, \`{\`/\`}\`, and a
|
|
1463
|
+
// backtick) and tokenizes each segment. A segment whose FIRST token is the
|
|
1464
|
+
// \`gh\` executable, followed by \`pr\` and \`merge\`, is a merge at command
|
|
1465
|
+
// position; a quoted "gh pr merge" is a single token and never matches, so
|
|
1466
|
+
// \`echo "gh pr merge"\` does not fire. What is NOT the command is stripped from
|
|
1467
|
+
// the segment's front before the anchor is read — a leading redirection, the
|
|
1468
|
+
// transparent wrapper programs with their options, the shell's reserved words
|
|
1469
|
+
// (\`do\`, \`then\`, \`else\`, \`if\`, \`elif\`, \`while\`, \`until\`, \`!\`, \`coproc\`) and
|
|
1470
|
+
// any run of \`NAME=value\` assignment prefixes — so \`for … ; do gh pr merge;
|
|
1471
|
+
// done\`, \`if gh pr merge 5; then …\` and \`GH_TOKEN=x gh pr merge 5\` all fire.
|
|
1472
|
+
// Before the PR's review round only \`do\`/\`then\`/\`else\`/\`!\` were skipped, so a
|
|
1473
|
+
// merge used AS an \`if\` condition, or behind an assignment prefix, went
|
|
1474
|
+
// unjudged (mmnto-ai/totem#2844 round 1, greptile). EVERY matching segment is
|
|
1489
1475
|
// collected, not the first: \`gh pr merge 7; gh pr merge 8\` yields two argv
|
|
1490
1476
|
// lists and the wrapper judges each PR on its own facts (same round).
|
|
1491
1477
|
//
|
|
1492
|
-
// HEREDOC BODIES ARE BLANKED FIRST (mmnto-ai/totem#2800 fold F4).
|
|
1493
|
-
// body is DATA, not commands: \`cat <<EOF\` … \`gh pr merge 5\` … \`EOF\`
|
|
1494
|
-
// line of text and merges nothing, and firing there was a false deny —
|
|
1495
|
-
// direction this projection must not have.
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
//
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
//
|
|
1478
|
+
// HEREDOC BODIES AND COMMENTS ARE BLANKED FIRST (mmnto-ai/totem#2800 fold F4).
|
|
1479
|
+
// A heredoc body is DATA, not commands: \`cat <<EOF\` … \`gh pr merge 5\` … \`EOF\`
|
|
1480
|
+
// writes a line of text and merges nothing, and firing there was a false deny —
|
|
1481
|
+
// the one direction this projection must not have. Since mmnto-ai/totem#2857
|
|
1482
|
+
// the scanner that finds those bodies is a VERBATIM port of core's
|
|
1483
|
+
// \`findHeredocs\` (see the sync anchor below), not a second reading of the same
|
|
1484
|
+
// grammar: quoted (\`<<'EOF'\`, \`<<"EOF"\`, \`<<\\EOF\`) and bare delimiters,
|
|
1485
|
+
// \`<<\` and \`<<-\` (whose terminator may be tab-indented), an unterminated body
|
|
1486
|
+
// read to the end of the command, \`<<<\` left alone (a here-string is not a
|
|
1487
|
+
// heredoc), \`$(( … ))\` / \`(( … ))\` skipped whole so a shift opens nothing, a
|
|
1488
|
+
// \`#\` that begins a word discarded to end-of-line, and the paren bookkeeping
|
|
1489
|
+
// that says whether a \`)\` ends a word. Every \`<<\` on the operator line is
|
|
1490
|
+
// queued and its body consumed in order, as bash does for \`cat <<A <<B\`.
|
|
1491
|
+
//
|
|
1492
|
+
// WHAT THE ANCHOR NOW READS (mmnto-ai/totem#2856, the strict tier's
|
|
1493
|
+
// precondition — under PILOT each of these was one lost advisory read, under
|
|
1494
|
+
// STRICT a bypass): the executable may be spelled \`gh\`, \`gh.exe\` or either
|
|
1495
|
+
// behind a path; a CLOSED table of transparent wrapper programs (\`sudo\`,
|
|
1496
|
+
// \`env\`, \`timeout\`, \`nice\`, \`nohup\`, \`command\`, \`exec\`, \`time\`) is stripped
|
|
1497
|
+
// with its option grammar; \`eval\` re-tokenizes its operand once and
|
|
1498
|
+
// \`env -S\` / \`--split-string\` splits its own operand into the words that
|
|
1499
|
+
// take the option's place; a leading redirection is skipped with its file;
|
|
1500
|
+
// and a backtick substitution is a segment of its own. In POWERSHELL mode a
|
|
1501
|
+
// trailing backtick is that shell's LINE CONTINUATION instead — at a WORD
|
|
1502
|
+
// BOUNDARY the backtick and the newline are consumed and the next line
|
|
1503
|
+
// continues the command, the twin of bash's trailing backslash there
|
|
1504
|
+
// (round-6 leg, G3, bounded by round-7's H4); it was a separator in both
|
|
1505
|
+
// modes before, which made the backtick itself the merge's target and left
|
|
1506
|
+
// the real one in the next segment. INSIDE a word the two shells differ:
|
|
1507
|
+
// PowerShell's backtick escapes the newline INTO the argument, so
|
|
1508
|
+
// \`gh pr merg<backtick><LF>e 5\` is the word \`merg<LF>e\` and merges
|
|
1509
|
+
// nothing — and neither does this.
|
|
1507
1510
|
//
|
|
1508
1511
|
// Disclosed misses, same posture as transport-shield's scanner — the gate does
|
|
1509
|
-
// NOT fire, which is the safe direction, never a false deny
|
|
1510
|
-
//
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
1513
|
-
// \`
|
|
1514
|
-
//
|
|
1515
|
-
// -
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
1533
|
-
//
|
|
1534
|
-
//
|
|
1535
|
-
//
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1512
|
+
// NOT fire, which is the safe direction, never a false deny. Every one of them
|
|
1513
|
+
// is a LOCKED row in gate-install.test.ts, so this list is read from the suite,
|
|
1514
|
+
// not from memory:
|
|
1515
|
+
// - a VARIABLE executable (\`$GH pr merge 5\`, \`\${GH} pr merge 5\`): the walk
|
|
1516
|
+
// cannot expand it, and the \`unresolvedTarget\` arm covers only the PR
|
|
1517
|
+
// argument, not the program;
|
|
1518
|
+
// - an UNQUOTED win32 path (\`C:\\tools\\gh.exe pr merge 5\`): this walk reads
|
|
1519
|
+
// POSIX quoting for BOTH tools, so the separators are consumed as escapes
|
|
1520
|
+
// and the token arrives as \`C:toolsgh.exe\`. Quoted, it projects;
|
|
1521
|
+
// - \`timeout\` with NO duration (\`timeout gh pr merge 5\`): the grammar
|
|
1522
|
+
// consumes exactly one positional before the command, so \`gh\` reads as the
|
|
1523
|
+
// duration. The form is invalid to \`timeout\` itself;
|
|
1524
|
+
// - a wrapper program not on the table (\`npx\`, \`xargs\`, \`bash -c "…"\`) and a
|
|
1525
|
+
// builtin flag not in it: the table is closed on purpose — the walk cannot
|
|
1526
|
+
// know which operand of an arbitrary program is the command;
|
|
1527
|
+
// - a table word spelled by PATH (\`/usr/bin/time -f x gh pr merge 5\`): the
|
|
1528
|
+
// table is keyed on the bare word the shell reads at command position, and
|
|
1529
|
+
// \`/usr/bin/time\` is a PROGRAM with its own option grammar, not the
|
|
1530
|
+
// reserved word this table models;
|
|
1531
|
+
// - env's OWN splitting rules inside a \`-S\` / \`--split-string\` operand.
|
|
1532
|
+
// The operand itself is no longer a miss: it is SPLIT and read, because
|
|
1533
|
+
// every spelling of it RUNS the merge (fold 3, measured on coreutils
|
|
1534
|
+
// 8.32 with a stub \`gh\`) and consuming it with the option made all of
|
|
1535
|
+
// them a bypass under STRICT. But it is split on WHITESPACE and nothing
|
|
1536
|
+
// more: env's own escapes (\`\\_\` is a SPACE, \`\\n\`, \`\\t\`, \`\\#\`,
|
|
1537
|
+
// \`\\$\`), its \`$VAR\` expansion inside the string and its \`#\` comment
|
|
1538
|
+
// are not modelled, and quotes INSIDE the string are not stripped.
|
|
1539
|
+
// Measured: \`env -S 'gh\\_pr\\_merge\\_5'\` runs \`gh pr merge 5\`, while
|
|
1540
|
+
// the split reads ONE word here and nothing projects; and
|
|
1541
|
+
// \`env -S 'env -S "gh pr merge 5"'\` runs it too, because env strips the
|
|
1542
|
+
// quotes inside its own operand while this walk keeps them and reads
|
|
1543
|
+
// \`"gh\` as the executable (both locked rows, round-7 leg H5). The
|
|
1544
|
+
// ATTACHED SHORT spelling is no longer among them: \`env -Sgh pr merge 5\`
|
|
1545
|
+
// and \`env -S'gh pr merge 5'\` both arrive as the token
|
|
1546
|
+
// \`-Sgh pr merge 5\`, and the rest of that token is now read as the
|
|
1547
|
+
// operand, so both are judged;
|
|
1548
|
+
// - CLUSTERED short options on a table word (\`env -vu X gh pr merge 5\`,
|
|
1549
|
+
// \`env -iS '<cmd>'\`): the option test reads the WHOLE \`-\` token, so a
|
|
1550
|
+
// cluster matches no entry of that word's operand list, is dropped as one
|
|
1551
|
+
// flag, and the operand belonging to the cluster's LAST letter (\`X\` for
|
|
1552
|
+
// \`-vu\`, the command string for \`-iS\`) is left standing at the front of
|
|
1553
|
+
// the strip, where it blocks the anchor. coreutils RUNS the merge in both
|
|
1554
|
+
// (measured, 8.32 — the \`-i\` spelling with absolute paths inside the
|
|
1555
|
+
// operand, since \`-i\` clears the environment). ATTACHMENT is not the gap:
|
|
1556
|
+
// \`env -uX\`, \`nice -n10\`, \`timeout -k5 30\` and \`timeout -sTERM 30\` all
|
|
1557
|
+
// project and all run. CLUSTERING is (round-8 leg, J4);
|
|
1558
|
+
// - \`eval\` nested deeper than ONE level
|
|
1559
|
+
// (\`eval "eval \\"gh pr merge 5\\""\`);
|
|
1560
|
+
// - a substitution inside DOUBLE quotes (\`echo "\`gh pr merge 5\`"\`,
|
|
1561
|
+
// \`echo "$(gh pr merge 5)"\`): bash EXECUTES both of those, but the
|
|
1562
|
+
// tokenizer's quote arm swallows the whole string as ONE token, so the
|
|
1563
|
+
// merge inside runs unjudged. Filed as mmnto-ai/totem#2893 (round-5 leg,
|
|
1564
|
+
// F4); the single-quoted spelling really is data and stays a control row;
|
|
1565
|
+
// - a redirection operator carrying a tokenizer separator (\`>|\`, \`2>&1\`,
|
|
1566
|
+
// \`>& file\`, \`<& 3\`, \`exec 3>&1 …\`): \`|\` and \`&\` end the segment before
|
|
1567
|
+
// the operator is read as one word, and ALL FIVE of those are merges the
|
|
1568
|
+
// shell runs — measured with a stub on bash 5.3, each applies its
|
|
1569
|
+
// redirection and then runs \`gh pr merge 5\` (the \`<& 3\` form once that
|
|
1570
|
+
// descriptor is open). The segment they leave starts at the FILE
|
|
1571
|
+
// (\`out.txt\`, \`1\`, \`file\`, \`3\`), so nothing of the merge is read: a
|
|
1572
|
+
// fail-open, not text the shell ignores (round-6 leg, G2). A \`&>\` splits
|
|
1573
|
+
// the same way but leaves a readable \`>\` at the front of the next
|
|
1574
|
+
// segment, so THAT one projects;
|
|
1575
|
+
// - PowerShell's own quoting (backtick escapes outside double quotes,
|
|
1576
|
+
// here-strings) is not modelled — the walk reads POSIX quoting for both
|
|
1577
|
+
// tools. PowerShell's call operator is NOT a miss: \`& gh pr merge 5\`
|
|
1578
|
+
// projects, because \`&\` is one of the separators and the segment after it
|
|
1579
|
+
// starts at \`gh\` (row, not memory).
|
|
1539
1580
|
// Disclosed FALSE FIRES, the deny direction, all contrived — text the shell
|
|
1540
1581
|
// does not execute as a merge but that sits at a segment's front here: a bash
|
|
1541
1582
|
// array assignment whose elements spell a merge (\`A=(gh pr merge 8)\`) is judged
|
|
@@ -1545,21 +1586,86 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
1545
1586
|
// to be collected, round 2 F6); and a function DEFINITION whose body is a
|
|
1546
1587
|
// merge (\`f() { gh pr merge 5; }\`) fires at definition time, because \`{\` is a
|
|
1547
1588
|
// separator and the body is its own segment (round 3, F4; it fired before this
|
|
1548
|
-
// PR's rounds too).
|
|
1549
|
-
//
|
|
1550
|
-
//
|
|
1551
|
-
//
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1589
|
+
// PR's rounds too). A fourth, PowerShell's own: a double-quoted string whose
|
|
1590
|
+
// backtick escapes a quote (\`Write-Output "a \`"; gh pr merge 5\`"b"\`) is ONE
|
|
1591
|
+
// string to PowerShell and merges nothing, but this walk reads POSIX quoting
|
|
1592
|
+
// for both tools, so the \`"\` after the escaping backtick closes the string and
|
|
1593
|
+
// the merge reaches a segment's front (round-5 leg, F13; a row asserts it).
|
|
1594
|
+
// A fifth, and the only one that is not contrived: an INVALID OPTION to a
|
|
1595
|
+
// word on the table above (\`command -x\`, \`exec -x\`, \`timeout -Z 30\`,
|
|
1596
|
+
// \`nice -Z\`, \`env -Z\`, \`nohup -x\`, \`sudo -Z\`). Each makes the program answer
|
|
1597
|
+
// "invalid option" and run NOTHING, while the strip below reads any unknown
|
|
1598
|
+
// \`-\` token as one of that word's own options and projects the merge behind
|
|
1599
|
+
// it. Ruled disclose-not-cure (round-6 leg, G4): the cure is a closed \`flags\`
|
|
1600
|
+
// list per program — the shape \`time\` carries, whose reserved-word grammar
|
|
1601
|
+
// really is two flags — and on a mutant with that list everywhere it turns
|
|
1602
|
+
// every real flag the list omits (\`sudo -n\`, \`sudo -E\`,
|
|
1603
|
+
// \`timeout --foreground\`) into a MISS, which is a bypass under STRICT. A
|
|
1604
|
+
// false fire on a command that runs nothing costs one bogus deny; rows assert
|
|
1605
|
+
// each of them, so this paragraph is read from the suite.
|
|
1606
|
+
// A sixth, PowerShell's again (round-7 leg, H4): a backtick that is the LAST
|
|
1607
|
+
// CHARACTER OF THE INPUT (\`gh pr merge 5 <backtick>\`, with nothing after it,
|
|
1608
|
+
// not even a newline) is a continuation with nothing to continue — pwsh
|
|
1609
|
+
// answers with a parse error and runs NOTHING — while here the backtick is
|
|
1610
|
+
// not followed by a newline, so it falls through to the separator arm and the
|
|
1611
|
+
// merge in front of it is judged. Narrowed to that one spelling on a
|
|
1612
|
+
// measurement (round-8 leg, J3): give the SAME input a trailing newline
|
|
1613
|
+
// (\`gh pr merge 5 <backtick><LF>\`) and pwsh runs the merge, while the
|
|
1614
|
+
// continuation arm here consumes the pair and projects it — no divergence.
|
|
1615
|
+
// A seventh, env's (round-7 leg, H5): a \`$VAR\` inside a \`-S\` operand
|
|
1616
|
+
// (\`env -S 'gh pr merge $PR'\`) makes env REFUSE the whole command — it
|
|
1617
|
+
// supports only \`\${VARNAME}\` and answers "only \${VARNAME} expansion is
|
|
1618
|
+
// supported" — so NOTHING runs, while the split reaches the anchor and
|
|
1619
|
+
// \`$PR\` rides as an \`unresolvedTarget\` the strict tier denies.
|
|
1620
|
+
// An eighth, PowerShell's third (round-8 leg, J3): a backtick followed by
|
|
1621
|
+
// WHITESPACE and then a newline (\`gh pr merge <backtick><space><LF>5\`) is not
|
|
1622
|
+
// a continuation either, because the backtick escapes that SPACE. pwsh runs
|
|
1623
|
+
// \`gh pr merge\` with NO TARGET — the current branch's PR merges — and reads
|
|
1624
|
+
// the next line as its own statement, while here the backtick is not
|
|
1625
|
+
// IMMEDIATELY followed by a newline, so the separator arm takes it and the
|
|
1626
|
+
// walk projects \`unresolvedTarget\` naming the backtick: a target nobody
|
|
1627
|
+
// wrote, which the strict tier denies (measured on pwsh 7 with a stub \`gh\`;
|
|
1628
|
+
// a row asserts the projection).
|
|
1629
|
+
// \`TOTEM_MERGE_GATE_OVERRIDE=1\` is the audited way past any of them.
|
|
1630
|
+
// ─── The heredoc scanner (mmnto-ai/totem#2857) ─────────────────────────
|
|
1631
|
+
// sync-anchor: findHeredocs-scanner-downstream (packages/core/src/transport-shield.ts findHeredocs; the parity test in gate-install.test.ts is the lock)
|
|
1632
|
+
//
|
|
1633
|
+
// A VERBATIM port of core's \`findHeredocs\` and its three tables, not a second
|
|
1634
|
+
// reading of the same grammar. The hand copy this replaces had diverged in the
|
|
1635
|
+
// FAIL-OPEN direction — it opened heredocs core does not, and each one blanked
|
|
1636
|
+
// the \`gh pr merge\` on a following line (a lost advisory read under PILOT, a
|
|
1637
|
+
// bypass under STRICT): no paren-boundary arms, so a \`#\` after \`(\` or after an
|
|
1638
|
+
// operator \`)\` was text and a \`<<word\` inside it opened a body; a narrower
|
|
1639
|
+
// bare-delimiter class, so \`<<E:F\` read as the prefix \`E\` and the terminator
|
|
1640
|
+
// line never matched; and a double-quote backslash that escaped ANY next
|
|
1641
|
+
// character where core escapes only DQ_ESCAPABLE.
|
|
1642
|
+
//
|
|
1643
|
+
// A distributed hook cannot import core (its exports map carries \`import\`
|
|
1644
|
+
// conditions only and no scanner subpath — mmnto-ai/totem#2851), so the cohort
|
|
1645
|
+
// lesson for an inlined standalone utility rules: port verbatim, anchor both
|
|
1646
|
+
// sites, lock it with an executable parity test. Change nothing here without
|
|
1647
|
+
// changing core's \`findHeredocs\` and re-running that test.
|
|
1648
|
+
|
|
1649
|
+
/** Inside double quotes a backslash escapes only these (POSIX); elsewhere it is kept. */
|
|
1650
|
+
const DQ_ESCAPABLE = ['$', '\`', '"', '\\\\', '\\n'];
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* \`<<\` or \`<<-\`, optional blanks, then the delimiter WORD as bash delimits it:
|
|
1654
|
+
* single-quoted, double-quoted, backslash-quoted (\`\\EOF\`) or bare — a bare
|
|
1655
|
+
* word running to the next blank, quote, backslash or operator character, so
|
|
1656
|
+
* \`EOF.TXT\`, \`E:F\` and \`1EOF\` are whole delimiter words. Groups: 1 the dash,
|
|
1657
|
+
* 2 single-quoted, 3 double-quoted, 4 backslash-quoted, 5 bare.
|
|
1658
|
+
*/
|
|
1659
|
+
const HEREDOC_AT =
|
|
1660
|
+
/^<<(-?)[ \\t]*(?:'([^'\\n]+)'|"([^"\\n]+)"|\\\\([^\\s'"\\\\<>()|&;]+)|([^\\s'"\\\\<>()|&;]+))/;
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Characters after which the next character BEGINS a word — where a \`#\` starts
|
|
1664
|
+
* a comment (POSIX 2.3 rule 9). Parentheses are not here: an opening \`(\` and an
|
|
1665
|
+
* OPERATOR \`)\` begin a word, but the \`)\` that closes a \`$( … )\` continues one,
|
|
1666
|
+
* so the walk tracks which \`(\` each \`)\` closes and sets the boundary from that.
|
|
1667
|
+
*/
|
|
1668
|
+
const WORD_BOUNDARY = [' ', '\\t', '\\r', '\\n', ';', '|', '&'];
|
|
1563
1669
|
|
|
1564
1670
|
// The index just past the \`))\` that closes an arithmetic expansion whose
|
|
1565
1671
|
// opening \`$((\` / \`((\` ends at \`from\`; the end of the command when it is
|
|
@@ -1581,192 +1687,238 @@ function skipArithmetic(command, from) {
|
|
|
1581
1687
|
return command.length;
|
|
1582
1688
|
}
|
|
1583
1689
|
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1690
|
+
/**
|
|
1691
|
+
* ONE pass over the command that tracks shell quoting and SKIPS heredoc
|
|
1692
|
+
* bodies, returning every heredoc's span. Core's \`findHeredocs\`, arm for arm:
|
|
1693
|
+
* an operator inside a quoted argument is text, \`<<<\` is a here-string, a
|
|
1694
|
+
* \`#\` that begins a word discards the rest of its line without quote
|
|
1695
|
+
* processing, \`$(( … ))\` / \`(( … ))\` is skipped whole, and \`parens\` records
|
|
1696
|
+
* what each open \`(\` is — a substitution (\`$(\`, \`<(\`, \`>(\`), which is part of
|
|
1697
|
+
* a word, or a grouping operator — so the \`)\` that closes it can say whether
|
|
1698
|
+
* the next character begins a word. A body starts after the newline that ends
|
|
1699
|
+
* the operator's line and runs to the first line that IS the delimiter (an
|
|
1700
|
+
* exact line match, as bash reads it), or to the end of the command.
|
|
1701
|
+
*
|
|
1702
|
+
* The one thing core's scanner does not need and this one does: the COMMENT
|
|
1703
|
+
* regions. Core's tokenizer reads comments itself; this wrapper's does not, so
|
|
1704
|
+
* the blanker below has to blank them exactly as the hand copy dropped them,
|
|
1705
|
+
* or a \`<# … #>\` block or a \`#\` comment whose text begins with a merge would
|
|
1706
|
+
* reach the anchor. They are collected in the SAME two arms that discard them,
|
|
1707
|
+
* so the two readings cannot disagree, and they are NOT part of the span list
|
|
1708
|
+
* the parity lock compares.
|
|
1709
|
+
*/
|
|
1710
|
+
function scanShell(command, ps) {
|
|
1711
|
+
const spans = [];
|
|
1712
|
+
const comments = [];
|
|
1713
|
+
const pending = [];
|
|
1714
|
+
const parens = [];
|
|
1715
|
+
let inSingle = false;
|
|
1716
|
+
let inDouble = false;
|
|
1588
1717
|
let boundary = true;
|
|
1589
|
-
let
|
|
1590
|
-
|
|
1718
|
+
let i = 0;
|
|
1591
1719
|
// Consume EVERY body queued on the operator line, in order, starting just
|
|
1592
1720
|
// past that line's newline — bash reads \`cat <<A <<B\` as two bodies, so a
|
|
1593
|
-
// command sitting in B's body is data too (F2).
|
|
1594
|
-
//
|
|
1595
|
-
// stay separated exactly as the shell separates them. An unterminated body
|
|
1596
|
-
// runs to the end and is dropped whole.
|
|
1721
|
+
// command sitting in B's body is data too (F2). An unterminated body runs to
|
|
1722
|
+
// the end of the command and no later heredoc on that line can start.
|
|
1597
1723
|
const consumeBodies = (from) => {
|
|
1598
1724
|
let cursor = from;
|
|
1599
1725
|
for (let p = 0; p < pending.length; p++) {
|
|
1600
1726
|
const h = pending[p];
|
|
1601
|
-
|
|
1602
|
-
|
|
1727
|
+
const bodyStart = cursor;
|
|
1728
|
+
let bodyEnd = command.length;
|
|
1729
|
+
let unterminated = true;
|
|
1730
|
+
let resume = command.length;
|
|
1731
|
+
let at = bodyStart;
|
|
1603
1732
|
while (at <= command.length) {
|
|
1604
1733
|
const nl = command.indexOf('\\n', at);
|
|
1605
1734
|
const stop = nl === -1 ? command.length : nl;
|
|
1606
1735
|
let line = command.slice(at, stop);
|
|
1607
1736
|
if (h.stripTabs) line = line.replace(/^\\t+/, '');
|
|
1608
|
-
line = line.replace(/\\r$/, '');
|
|
1609
|
-
const next = nl === -1 ? command.length : nl + 1;
|
|
1610
1737
|
if (line === h.delimiter) {
|
|
1611
|
-
|
|
1612
|
-
|
|
1738
|
+
bodyEnd = at;
|
|
1739
|
+
unterminated = false;
|
|
1740
|
+
resume = nl === -1 ? command.length : nl + 1;
|
|
1613
1741
|
break;
|
|
1614
1742
|
}
|
|
1615
1743
|
if (nl === -1) break;
|
|
1616
|
-
|
|
1617
|
-
at = next;
|
|
1744
|
+
at = nl + 1;
|
|
1618
1745
|
}
|
|
1619
|
-
|
|
1620
|
-
|
|
1746
|
+
spans.push({
|
|
1747
|
+
delimiter: h.delimiter,
|
|
1748
|
+
quoted: h.quoted,
|
|
1749
|
+
stripTabs: h.stripTabs,
|
|
1750
|
+
unterminated: unterminated,
|
|
1751
|
+
bodyStart: bodyStart,
|
|
1752
|
+
bodyEnd: bodyEnd,
|
|
1753
|
+
});
|
|
1754
|
+
cursor = resume;
|
|
1755
|
+
if (unterminated) break;
|
|
1756
|
+
}
|
|
1757
|
+
pending.length = 0;
|
|
1621
1758
|
return cursor;
|
|
1622
1759
|
};
|
|
1623
|
-
|
|
1624
1760
|
while (i < command.length) {
|
|
1625
1761
|
const ch = command[i];
|
|
1626
|
-
if (
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1762
|
+
if (inSingle) {
|
|
1763
|
+
if (ch === "'") inSingle = false;
|
|
1764
|
+
i += 1;
|
|
1765
|
+
boundary = false;
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1768
|
+
if (inDouble) {
|
|
1769
|
+
if (ps && ch === '\`' && i + 1 < command.length) {
|
|
1770
|
+
// PowerShell's escape inside a double-quoted string is the backtick.
|
|
1630
1771
|
i += 2;
|
|
1772
|
+
boundary = false;
|
|
1631
1773
|
continue;
|
|
1632
1774
|
}
|
|
1633
|
-
if (ch ===
|
|
1634
|
-
|
|
1775
|
+
if (ch === '\\\\' && i + 1 < command.length && DQ_ESCAPABLE.indexOf(command[i + 1]) !== -1) {
|
|
1776
|
+
i += 2;
|
|
1777
|
+
boundary = false;
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
if (ch === '"') inDouble = false;
|
|
1781
|
+
i += 1;
|
|
1635
1782
|
boundary = false;
|
|
1636
1783
|
continue;
|
|
1637
1784
|
}
|
|
1638
|
-
if (
|
|
1639
|
-
quote
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1785
|
+
if (ps && command.slice(i, i + 2) === '<#') {
|
|
1786
|
+
// PowerShell's block comment, discarded without quote processing; an
|
|
1787
|
+
// unterminated one runs to the end. Applied ONLY for the PowerShell tool
|
|
1788
|
+
// (round 4, F8): in bash \`sort <#tmp\` is a redirect from a file named
|
|
1789
|
+
// \`#tmp\`, and discarding from it to a later \`#>\` would swallow real
|
|
1790
|
+
// commands.
|
|
1791
|
+
const close = command.indexOf('#>', i + 2);
|
|
1792
|
+
const end = close === -1 ? command.length : close + 2;
|
|
1793
|
+
comments.push({ start: i, end: end });
|
|
1794
|
+
i = end;
|
|
1795
|
+
boundary = true;
|
|
1643
1796
|
continue;
|
|
1644
1797
|
}
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1798
|
+
if (ch === '#' && boundary) {
|
|
1799
|
+
// A comment: discarded to the end of the line without quote processing;
|
|
1800
|
+
// the newline itself stays (it may end an operator line).
|
|
1801
|
+
const nl = command.indexOf('\\n', i);
|
|
1802
|
+
const end = nl === -1 ? command.length : nl;
|
|
1803
|
+
comments.push({ start: i, end: end });
|
|
1804
|
+
i = end;
|
|
1651
1805
|
continue;
|
|
1652
1806
|
}
|
|
1653
|
-
if (ch === '
|
|
1654
|
-
|
|
1655
|
-
i += 2;
|
|
1807
|
+
if (ch === '$' && command.slice(i, i + 3) === '$((') {
|
|
1808
|
+
i = skipArithmetic(command, i + 3);
|
|
1656
1809
|
boundary = false;
|
|
1657
1810
|
continue;
|
|
1658
1811
|
}
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
// line whose bodies are still queued.
|
|
1663
|
-
if (ch === '#' && boundary) {
|
|
1664
|
-
const nl = command.indexOf('\\n', i);
|
|
1665
|
-
i = nl === -1 ? command.length : nl;
|
|
1812
|
+
if (ch === '(' && command[i + 1] === '(' && boundary) {
|
|
1813
|
+
i = skipArithmetic(command, i + 2);
|
|
1814
|
+
boundary = false;
|
|
1666
1815
|
continue;
|
|
1667
1816
|
}
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
if (powershell && ch === '<' && command[i + 1] === '#') {
|
|
1675
|
-
const close = command.indexOf('#>', i + 2);
|
|
1676
|
-
i = close === -1 ? command.length : close + 2;
|
|
1817
|
+
if ((ch === '$' || ch === '<' || ch === '>') && command[i + 1] === '(') {
|
|
1818
|
+
// A command or process substitution: part of the word that carries it.
|
|
1819
|
+
// Its first character begins a word (a \`#\` right after \`$(\` is a
|
|
1820
|
+
// comment).
|
|
1821
|
+
parens.push('subst');
|
|
1822
|
+
i += 2;
|
|
1677
1823
|
boundary = true;
|
|
1678
1824
|
continue;
|
|
1679
1825
|
}
|
|
1680
|
-
if (ch === '
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
boundary = false;
|
|
1826
|
+
if (ch === '(') {
|
|
1827
|
+
parens.push('group');
|
|
1828
|
+
i += 1;
|
|
1829
|
+
boundary = true;
|
|
1685
1830
|
continue;
|
|
1686
1831
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1832
|
+
if (ch === ')') {
|
|
1833
|
+
// The \`)\` of a substitution continues the word; an operator \`)\` ends one.
|
|
1834
|
+
boundary = parens.pop() !== 'subst';
|
|
1835
|
+
i += 1;
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1838
|
+
if (ch === '\\\\') {
|
|
1694
1839
|
i += 2;
|
|
1695
|
-
boundary =
|
|
1840
|
+
boundary = false;
|
|
1696
1841
|
continue;
|
|
1697
1842
|
}
|
|
1698
|
-
if (ch === '
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
i = end;
|
|
1843
|
+
if (ch === "'") {
|
|
1844
|
+
inSingle = true;
|
|
1845
|
+
i += 1;
|
|
1702
1846
|
boundary = false;
|
|
1703
1847
|
continue;
|
|
1704
1848
|
}
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
// whose body swallowed every later line, a fail-open path — CodeRabbit on
|
|
1709
|
-
// mmnto-ai/totem#2855).
|
|
1710
|
-
if (ch === '<' && command[i + 1] === '<' && command[i - 1] !== '<' && command[i + 2] !== '<') {
|
|
1711
|
-
let j = i + 2;
|
|
1712
|
-
let head = '<<';
|
|
1713
|
-
let dash = false;
|
|
1714
|
-
if (command[j] === '-') {
|
|
1715
|
-
dash = true;
|
|
1716
|
-
head += '-';
|
|
1717
|
-
j++;
|
|
1718
|
-
}
|
|
1719
|
-
while (j < command.length && (command[j] === ' ' || command[j] === '\\t')) {
|
|
1720
|
-
head += command[j];
|
|
1721
|
-
j++;
|
|
1722
|
-
}
|
|
1723
|
-
// The delimiter word, quoted (\`<<'EOF'\`, \`<<"EOF"\`) or bare, with a
|
|
1724
|
-
// backslash-quoted form (\`<<\\EOF\`) read as bash reads it.
|
|
1725
|
-
let delim = '';
|
|
1726
|
-
const q = command[j] === "'" || command[j] === '"' ? command[j] : '';
|
|
1727
|
-
if (q !== '') {
|
|
1728
|
-
head += q;
|
|
1729
|
-
j++;
|
|
1730
|
-
}
|
|
1731
|
-
while (j < command.length) {
|
|
1732
|
-
const c = command[j];
|
|
1733
|
-
if (q !== '') {
|
|
1734
|
-
head += c;
|
|
1735
|
-
j++;
|
|
1736
|
-
if (c === q) break;
|
|
1737
|
-
delim += c;
|
|
1738
|
-
continue;
|
|
1739
|
-
}
|
|
1740
|
-
if (c === '\\\\' && j + 1 < command.length) {
|
|
1741
|
-
head += c + command[j + 1];
|
|
1742
|
-
delim += command[j + 1];
|
|
1743
|
-
j += 2;
|
|
1744
|
-
continue;
|
|
1745
|
-
}
|
|
1746
|
-
if (/[A-Za-z0-9_.\\-\\/]/.test(c)) {
|
|
1747
|
-
head += c;
|
|
1748
|
-
delim += c;
|
|
1749
|
-
j++;
|
|
1750
|
-
continue;
|
|
1751
|
-
}
|
|
1752
|
-
break;
|
|
1753
|
-
}
|
|
1754
|
-
out += head;
|
|
1755
|
-
i = j;
|
|
1849
|
+
if (ch === '"') {
|
|
1850
|
+
inDouble = true;
|
|
1851
|
+
i += 1;
|
|
1756
1852
|
boundary = false;
|
|
1757
|
-
if (delim !== '') pending.push({ delimiter: delim, stripTabs: dash });
|
|
1758
1853
|
continue;
|
|
1759
1854
|
}
|
|
1760
1855
|
if (ch === '\\n') {
|
|
1761
|
-
|
|
1762
|
-
i += 1;
|
|
1763
|
-
if (pending.length > 0) i = consumeBodies(i);
|
|
1856
|
+
i = pending.length > 0 ? consumeBodies(i + 1) : i + 1;
|
|
1764
1857
|
boundary = true;
|
|
1765
1858
|
continue;
|
|
1766
1859
|
}
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1860
|
+
// \`<<\` opens a heredoc; \`<<<\` is a here-string and is left alone — at BOTH
|
|
1861
|
+
// of its first two characters (without the preceding-character guard the
|
|
1862
|
+
// second \`<\` of \`<<<\` opened a heredoc whose body swallowed every later
|
|
1863
|
+
// line, a fail-open path — CodeRabbit on mmnto-ai/totem#2855).
|
|
1864
|
+
if (ch === '<' && command[i + 1] === '<' && command[i - 1] !== '<' && command[i + 2] !== '<') {
|
|
1865
|
+
const m = HEREDOC_AT.exec(command.slice(i));
|
|
1866
|
+
if (m !== null) {
|
|
1867
|
+
pending.push({
|
|
1868
|
+
stripTabs: (m[1] || '') === '-',
|
|
1869
|
+
quoted: m[2] !== undefined || m[3] !== undefined || m[4] !== undefined,
|
|
1870
|
+
delimiter:
|
|
1871
|
+
m[2] !== undefined
|
|
1872
|
+
? m[2]
|
|
1873
|
+
: m[3] !== undefined
|
|
1874
|
+
? m[3]
|
|
1875
|
+
: m[4] !== undefined
|
|
1876
|
+
? m[4]
|
|
1877
|
+
: m[5] !== undefined
|
|
1878
|
+
? m[5]
|
|
1879
|
+
: '',
|
|
1880
|
+
});
|
|
1881
|
+
i += m[0].length;
|
|
1882
|
+
boundary = false;
|
|
1883
|
+
continue;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
boundary = WORD_BOUNDARY.indexOf(ch) !== -1;
|
|
1887
|
+
i += 1;
|
|
1888
|
+
}
|
|
1889
|
+
if (pending.length > 0) consumeBodies(command.length);
|
|
1890
|
+
return { heredocs: spans, comments: comments };
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/** Every heredoc in the command — core's span shape minus the unused \`body\`. */
|
|
1894
|
+
function findHeredocSpans(command, powershell) {
|
|
1895
|
+
return scanShell(command, powershell === true).heredocs;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* The command with every heredoc body, and every comment, replaced by SPACES:
|
|
1900
|
+
* core's blanking shape, so LENGTH and every offset are preserved (the hand
|
|
1901
|
+
* copy dropped body lines instead, which moved every offset after them). The
|
|
1902
|
+
* tokenizer below treats any run of spaces as one boundary, so the change of
|
|
1903
|
+
* shape is invisible to it — the heredoc rows in the suite are that proof.
|
|
1904
|
+
*/
|
|
1905
|
+
function blankHeredocBodies(command, powershell) {
|
|
1906
|
+
const scan = scanShell(command, powershell === true);
|
|
1907
|
+
const regions = [];
|
|
1908
|
+
for (let s = 0; s < scan.heredocs.length; s++) {
|
|
1909
|
+
regions.push({ start: scan.heredocs[s].bodyStart, end: scan.heredocs[s].bodyEnd });
|
|
1910
|
+
}
|
|
1911
|
+
for (let c = 0; c < scan.comments.length; c++) {
|
|
1912
|
+
regions.push(scan.comments[c]);
|
|
1913
|
+
}
|
|
1914
|
+
let out = command;
|
|
1915
|
+
for (let r = 0; r < regions.length; r++) {
|
|
1916
|
+
const region = regions[r];
|
|
1917
|
+
if (region.end <= region.start) continue;
|
|
1918
|
+
out =
|
|
1919
|
+
out.slice(0, region.start) +
|
|
1920
|
+
' '.repeat(region.end - region.start) +
|
|
1921
|
+
out.slice(region.end);
|
|
1770
1922
|
}
|
|
1771
1923
|
return out;
|
|
1772
1924
|
}
|
|
@@ -1775,10 +1927,14 @@ function blankHeredocBodies(command, powershell) {
|
|
|
1775
1927
|
// reserved words that introduce a compound command (\`time\` and \`coproc\` are
|
|
1776
1928
|
// reserved words too — the round-2 leg found them missing), the negation, and
|
|
1777
1929
|
// the builtins that execute their operand as the command (\`exec\`, \`command\`,
|
|
1778
|
-
//
|
|
1779
|
-
//
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1930
|
+
// \`eval\`). Stripped from a segment's front, in any run, before the
|
|
1931
|
+
// \`gh pr merge\` anchor is read.
|
|
1932
|
+
//
|
|
1933
|
+
// Four of them — \`time\`, \`exec\`, \`command\`, \`eval\` — also carry an OPTION
|
|
1934
|
+
// grammar, so they appear again in TRANSPARENT_WRAPPERS below and the strip
|
|
1935
|
+
// reads them from THERE (the table is consulted first). They stay here so this
|
|
1936
|
+
// list still reads as what it is: every word the shell itself skips at command
|
|
1937
|
+
// position.
|
|
1782
1938
|
const COMMAND_POSITION_WORDS = [
|
|
1783
1939
|
'do',
|
|
1784
1940
|
'then',
|
|
@@ -1801,35 +1957,307 @@ function isAssignmentPrefix(token) {
|
|
|
1801
1957
|
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
|
|
1802
1958
|
}
|
|
1803
1959
|
|
|
1960
|
+
// The executable spellings the anchor accepts (mmnto-ai/totem#2856 § A):
|
|
1961
|
+
// \`gh\`, \`gh.exe\`, and either of those behind a path (\`./gh\`,
|
|
1962
|
+
// \`/usr/local/bin/gh\`, \`'C:\\tools\\gh.exe'\`). The BASENAME after the last
|
|
1963
|
+
// \`/\` or \`\\\` is what is read, and the \`.exe\` suffix is case-insensitive as
|
|
1964
|
+
// win32 resolves it. Reading only the bare token \`gh\` left every other
|
|
1965
|
+
// spelling of the SAME executable unjudged — one lost advisory read under
|
|
1966
|
+
// PILOT, a bypass under STRICT (greptile P1 on mmnto-ai/totem#2855).
|
|
1967
|
+
// A VARIABLE executable (\`$GH\`, \`\${GH}\`) is not a spelling this wrapper can
|
|
1968
|
+
// expand, and stays a disclosed miss below.
|
|
1969
|
+
function isGhExecutable(token) {
|
|
1970
|
+
if (typeof token !== 'string' || token === '') return false;
|
|
1971
|
+
const slash = token.lastIndexOf('/');
|
|
1972
|
+
const back = token.lastIndexOf('\\\\');
|
|
1973
|
+
const cut = slash > back ? slash : back;
|
|
1974
|
+
const base = cut === -1 ? token : token.slice(cut + 1);
|
|
1975
|
+
if (base === 'gh') return true;
|
|
1976
|
+
// The whole \`gh.exe\` basename compares case-insensitively: win32 resolves
|
|
1977
|
+
// file names without case, so \`GH.EXE pr merge 5\` and \`Gh.exe pr merge 5\`
|
|
1978
|
+
// run the same executable as \`gh.exe pr merge 5\` (CodeRabbit on
|
|
1979
|
+
// mmnto-ai/totem#2894 — the stem-only lower-casing left both unjudged). The
|
|
1980
|
+
// bare \`gh\` stays EXACT: \`GH\` is a different name on a POSIX filesystem,
|
|
1981
|
+
// and on win32 it is a disclosed miss locked in the suite.
|
|
1982
|
+
return base.length === 6 && base.toLowerCase() === 'gh.exe';
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// ─── Transparent wrapper programs (mmnto-ai/totem#2856 § B) ─────────────
|
|
1986
|
+
// A CLOSED table of words that RUN their operand as the command, with the
|
|
1987
|
+
// option grammar needed to find that operand:
|
|
1988
|
+
// operand — options that take a SEPARATE next token (skip the option AND
|
|
1989
|
+
// that token);
|
|
1990
|
+
// positional — how many non-option words the program itself consumes before
|
|
1991
|
+
// the command (only \`timeout\`'s duration);
|
|
1992
|
+
// terminator — whether a \`--\` ends its options;
|
|
1993
|
+
// describe — options that make the word DESCRIBE its operand instead of
|
|
1994
|
+
// executing it (\`command -v\`, \`sudo -l\`): the segment ends
|
|
1995
|
+
// with NO projection, because nothing is executed;
|
|
1996
|
+
// flags — when present, the ONLY options the word HAS: any other
|
|
1997
|
+
// \`-\` token is not an option of it, so the shell runs no
|
|
1998
|
+
// command and there is nothing to project (bash's \`time\`
|
|
1999
|
+
// reserved word answers \`-f: command not found\`);
|
|
2000
|
+
// evaluates — the builtin whose operand is a STRING to re-tokenize;
|
|
2001
|
+
// evaluatesOperand
|
|
2002
|
+
// — the OPTIONS whose own operand is a command STRING: the
|
|
2003
|
+
// operand is split into words and those words TAKE THE
|
|
2004
|
+
// OPTION'S PLACE, so the strip reads on from them
|
|
2005
|
+
// (\`env -S 'gh pr merge 5'\`).
|
|
2006
|
+
// Every other \`-\` token is skipped as a flag of the wrapper, so a long option
|
|
2007
|
+
// with an ATTACHED value (\`--user=x\`, \`--kill-after=5\`, \`--adjustment=10\`)
|
|
2008
|
+
// needs no entry and a bare \`-10\` reads as \`nice\`'s adjustment. A long option
|
|
2009
|
+
// that takes a SEPARATE operand does need one, beside its short spelling, or
|
|
2010
|
+
// the operand itself reads as the command (\`sudo --user root gh …\` read
|
|
2011
|
+
// \`root\` as the program — round-5 leg, F2). After a wrapper is consumed the
|
|
2012
|
+
// strip loops, so the assignment prefixes of \`env NAME=v gh …\` and a wrapper
|
|
2013
|
+
// wrapping a wrapper both resolve.
|
|
2014
|
+
//
|
|
2015
|
+
// The table is CLOSED on purpose (ADR-082 A1, Tenet 19): a program that is not
|
|
2016
|
+
// on it IS the command, because this walk cannot know which of an arbitrary
|
|
2017
|
+
// program's operands is a command — \`npx\` runs a package, \`xargs\` builds its
|
|
2018
|
+
// own argv. Widening it is a later PR with its own rows, never a guess here.
|
|
2019
|
+
const TRANSPARENT_WRAPPERS = {
|
|
2020
|
+
sudo: {
|
|
2021
|
+
// Every sudo option that takes an argument, per sudo(8): \`-a type\`,
|
|
2022
|
+
// \`-C num\`, \`-c class\`, \`-D directory\`, \`-g group\`, \`-h host\`,
|
|
2023
|
+
// \`-p prompt\`, \`-R directory\`, \`-r role\`, \`-t type\`, \`-T timeout\`,
|
|
2024
|
+
// \`-u user\`, \`-U user\`, each with its long spelling. \`-R\`, \`-a\` and
|
|
2025
|
+
// \`-c\` were missing (Greptile P1 on mmnto-ai/totem#2894 named \`-R\`): the
|
|
2026
|
+
// generic path dropped the option alone and left its operand standing at
|
|
2027
|
+
// command position, so \`sudo -R /chroot gh pr merge 5\` ran unjudged.
|
|
2028
|
+
// \`--preserve-env=list\` is long-only with an attached operand, so the
|
|
2029
|
+
// generic drop already reads it right.
|
|
2030
|
+
operand: [
|
|
2031
|
+
'-a',
|
|
2032
|
+
'-c',
|
|
2033
|
+
'-u',
|
|
2034
|
+
'-g',
|
|
2035
|
+
'-p',
|
|
2036
|
+
'-C',
|
|
2037
|
+
'-D',
|
|
2038
|
+
'-h',
|
|
2039
|
+
'-R',
|
|
2040
|
+
'-r',
|
|
2041
|
+
'-t',
|
|
2042
|
+
'-T',
|
|
2043
|
+
'-U',
|
|
2044
|
+
'--auth-type',
|
|
2045
|
+
'--login-class',
|
|
2046
|
+
'--user',
|
|
2047
|
+
'--group',
|
|
2048
|
+
'--prompt',
|
|
2049
|
+
'--chdir',
|
|
2050
|
+
'--chroot',
|
|
2051
|
+
'--host',
|
|
2052
|
+
'--role',
|
|
2053
|
+
'--type',
|
|
2054
|
+
'--other-user',
|
|
2055
|
+
'--command-timeout',
|
|
2056
|
+
],
|
|
2057
|
+
positional: 0,
|
|
2058
|
+
terminator: true,
|
|
2059
|
+
// sudo's DESCRIBE-only options: \`-l\`/\`--list\` prints what the user may
|
|
2060
|
+
// run, \`-v\`/\`--validate\` refreshes the timestamp, \`-V\`/\`--version\`
|
|
2061
|
+
// prints the version, \`-K\`/\`--remove-timestamp\` clears the credentials
|
|
2062
|
+
// and may not carry a command. None of them executes the operand, so
|
|
2063
|
+
// \`sudo -l gh pr merge 5\` merges nothing — projecting there was a FALSE
|
|
2064
|
+
// DENY (round-5 leg, F1).
|
|
2065
|
+
describe: ['-l', '--list', '-v', '--validate', '-V', '--version', '-K', '--remove-timestamp'],
|
|
2066
|
+
},
|
|
2067
|
+
env: {
|
|
2068
|
+
operand: ['-u', '-C', '--unset', '--chdir'],
|
|
2069
|
+
positional: 0,
|
|
2070
|
+
terminator: false,
|
|
2071
|
+
// \`-S\` / \`--split-string\` does NOT consume its operand: env splits that
|
|
2072
|
+
// string into words and PREPENDS them to the arguments that follow, then
|
|
2073
|
+
// runs the first word as the command. Measured on coreutils 8.32 with a
|
|
2074
|
+
// stub \`gh\`, every one of \`env -S 'gh pr merge 5'\`,
|
|
2075
|
+
// \`env -S "gh pr merge" 5\`, \`env -S gh pr merge 5\`,
|
|
2076
|
+
// \`env --split-string gh pr merge 5\`, \`env --split-string='gh pr merge 5'\`,
|
|
2077
|
+
// \`env -u X -S 'gh pr merge 5'\` and \`env -S 'A=1 gh pr merge 5'\` RUNS
|
|
2078
|
+
// \`gh pr merge 5\`. Consuming the operand with the option made all seven a
|
|
2079
|
+
// MISS — consistency in the miss direction, which is a bypass under STRICT
|
|
2080
|
+
// (fold 3, on the round-6 fold's own measurement). So the words take the
|
|
2081
|
+
// option's place and the strip reads on from them, the way \`eval\`'s
|
|
2082
|
+
// operand is re-read. The ATTACHED SHORT spellings join them (round-7
|
|
2083
|
+
// leg, H5): \`env -Sgh pr merge 5\` and \`env -S'gh pr merge 5'\` both
|
|
2084
|
+
// arrive as the one token \`-Sgh pr merge 5\` and run the merge too. An
|
|
2085
|
+
// \`=\` is NOT a separator for a short option, so \`env -S=x\` reads its
|
|
2086
|
+
// operand as \`=x\` — which is what env does with it.
|
|
2087
|
+
evaluatesOperand: ['-S', '--split-string'],
|
|
2088
|
+
},
|
|
2089
|
+
timeout: { operand: ['-k', '-s', '--kill-after', '--signal'], positional: 1, terminator: true },
|
|
2090
|
+
nice: { operand: ['-n', '--adjustment'], positional: 0, terminator: false },
|
|
2091
|
+
nohup: { operand: [], positional: 0, terminator: false },
|
|
2092
|
+
command: { operand: [], positional: 0, terminator: false, describe: ['-v', '-V'] },
|
|
2093
|
+
exec: { operand: ['-a'], positional: 0, terminator: false },
|
|
2094
|
+
// \`time\` here is BASH'S RESERVED WORD, not \`/usr/bin/time\`: its grammar is
|
|
2095
|
+
// \`time [-p] [--] pipeline\` — no option of it takes an operand, and any
|
|
2096
|
+
// other \`-\` token is not an option at all (bash runs \`-f\` as a command and
|
|
2097
|
+
// answers "command not found", merging nothing). \`time -f x gh pr merge 5\`
|
|
2098
|
+
// was read with GNU time's option grammar and projected a merge the shell
|
|
2099
|
+
// never runs — a false deny (round-5 leg, F3). The PROGRAM \`/usr/bin/time\`
|
|
2100
|
+
// is a path-spelled wrapper, which this closed table does not carry.
|
|
2101
|
+
time: { operand: [], positional: 0, terminator: true, flags: ['-p'] },
|
|
2102
|
+
eval: { operand: [], positional: 0, terminator: false, evaluates: true },
|
|
2103
|
+
};
|
|
2104
|
+
|
|
2105
|
+
// A REDIRECTION is not the command — and it is not an ARGUMENT either
|
|
2106
|
+
// (mmnto-ai/totem#2856 § C, widened by the round-5 leg's F5 and F12). The
|
|
2107
|
+
// shell applies it wherever it stands and runs the rest, so
|
|
2108
|
+
// \`> out.txt gh pr merge 5\` merges, \`gh > out.txt pr merge 5\` merges, and
|
|
2109
|
+
// \`gh pr merge 5 > out.txt\` merges PR 5 — while reading it at the segment's
|
|
2110
|
+
// FRONT only left \`>\` riding into argv as the merge's target, where the
|
|
2111
|
+
// engine denied a pull request on branch "\`>\`" with a reason no one wrote.
|
|
2112
|
+
// So: ONE strip over the WHOLE segment, ahead of every other strip and of the
|
|
2113
|
+
// anchor test.
|
|
2114
|
+
//
|
|
2115
|
+
// An operator ALONE (\`>\`, \`>>\`, \`<\`, \`<>\`, \`2>\`, \`<<<\`) takes the next
|
|
2116
|
+
// token — the file — with it; a FUSED form (\`>out.txt\`, \`2>/dev/null\`,
|
|
2117
|
+
// \`<<<bar\`, \`2<>file\`) is one token and drops alone. A \`<<EOF\` head is a
|
|
2118
|
+
// fused form too and drops harmlessly: the scanner blanked its BODY long
|
|
2119
|
+
// before this, so nothing of the heredoc is left to decide here.
|
|
2120
|
+
//
|
|
2121
|
+
// Residue, disclosed and unreachable rather than claimed: an operator carrying
|
|
2122
|
+
// \`|\` or \`&\` (\`>|\`, \`2>&1\`, \`>& file\`, \`<& 3\`, \`exec 3>&1 …\`) never
|
|
2123
|
+
// arrives as ONE token, because those two characters are the tokenizer's own
|
|
2124
|
+
// segment separators and end the token first. ALL FIVE of those run the merge
|
|
2125
|
+
// — measured with a stub on bash 5.3, each applies its redirection and then
|
|
2126
|
+
// runs \`gh pr merge 5\` (the \`<& 3\` form once that descriptor is open) — so
|
|
2127
|
+
// every one of them is a fail-open miss, not text the shell ignores (round-6
|
|
2128
|
+
// leg, G2). \`&>\` splits the same way but leaves a readable \`>\` at the front
|
|
2129
|
+
// of the next segment, so that one IS read.
|
|
2130
|
+
//
|
|
2131
|
+
// WHAT MAKES A REDIRECTION REAL IS THE QUOTING OF THE OPERATOR, not of the
|
|
2132
|
+
// word it sits in (round-7 leg, H1/H2; the round-6 rule this replaces read
|
|
2133
|
+
// "any part of which came from inside quotes or from an escape", which is not
|
|
2134
|
+
// the shell's). Bash decides on the operator characters alone: quote the
|
|
2135
|
+
// FILENAME and the redirection still happens — \`>"out.txt" gh pr merge 5\`
|
|
2136
|
+
// truncates out.txt and merges PR 5 — while quoting the OPERATOR makes the
|
|
2137
|
+
// whole word an argument: \`gh pr merge --squash ">"out.txt\` passes the string
|
|
2138
|
+
// \`>out.txt\` to gh and redirects nothing. So the walk records, per token, the
|
|
2139
|
+
// INDEX of its first character that came from inside quotes or from a
|
|
2140
|
+
// backslash escape (\`-1\` when none), and a token is stripped only when an
|
|
2141
|
+
// operator prefix lies ENTIRELY BEFORE that index — the LONGEST prefix that
|
|
2142
|
+
// does, which is not always the longest the patterns match (the bounded rule
|
|
2143
|
+
// below). Under the round-6 rule every one of \`>"out.txt"\`, \`2>"err.log"\`,
|
|
2144
|
+
// \`<<<'bar'\` and \`>"$FILE"\` rode into argv as data — a merge judged on a
|
|
2145
|
+
// target nobody wrote, or (trailing) a branch named \`>merge.log\`. The rows
|
|
2146
|
+
// that made the round-6 rule necessary are unchanged by this one, because
|
|
2147
|
+
// their operator character is itself quoted or escaped: \`-b "<br>" 5\` and
|
|
2148
|
+
// \`-b \\<br\\> 5\` both have their first literal character at index 0.
|
|
2149
|
+
//
|
|
2150
|
+
// The FILENAME may hold ANYTHING, whitespace included (round-8 leg, J1):
|
|
2151
|
+
// \`>"out file.txt" gh pr merge 5\` is one token here, and while the fused
|
|
2152
|
+
// pattern's filename class excluded whitespace that token matched neither
|
|
2153
|
+
// pattern — so it stood in front of \`gh\`, broke the anchor, and a real
|
|
2154
|
+
// redirection with a real merge behind it went unjudged; trailing, the same
|
|
2155
|
+
// word rode into argv and the engine read a branch named \`>merge log.txt\`.
|
|
2156
|
+
// The operator prefix is what decides, so the class is \`[\\s\\S]+\` and the
|
|
2157
|
+
// first-literal index above is what still keeps a quoted OPERATOR out.
|
|
2158
|
+
//
|
|
2159
|
+
// The operator prefix is BOUNDED by that first literal index, and the bound
|
|
2160
|
+
// is what picks the operator (fold 6, the round-8 corpus partitioned by
|
|
2161
|
+
// provenance): bash extends an operator token over UNQUOTED characters only,
|
|
2162
|
+
// so \`>">"out.txt gh pr merge 5\` is the operator \`>\` with the filename
|
|
2163
|
+
// \`>out.txt\` — a real redirection with a real merge behind it. The greedy
|
|
2164
|
+
// \`[<>]{1,2}\` reads \`>>\` there, a prefix that ends PAST the index, and under
|
|
2165
|
+
// the index rule alone the word stayed, stood in front of \`gh\`, broke the
|
|
2166
|
+
// anchor and nothing was judged: a bypass under STRICT, and the same shape in
|
|
2167
|
+
// \`<<"<"bar\` and \`2<">"out.txt\`. So the prefix taken is the LONGEST one that
|
|
2168
|
+
// is itself an operator and ends at or before the index (\`>>\` → \`>\`,
|
|
2169
|
+
// \`<<<\` → \`<<\` → \`<\`, \`2<>\` → \`2<\`), which is the shell's own rule; a word
|
|
2170
|
+
// whose first literal character is at index 0 has no such prefix and stays
|
|
2171
|
+
// data (\`">"out.txt\` is the ARGUMENT \`>out.txt\`).
|
|
2172
|
+
//
|
|
2173
|
+
// Residue of both rules, disclosed and locked as rows (round-8 leg, J5,
|
|
2174
|
+
// re-measured at fold 6 over the 3 768-case quoting corpus, where the two
|
|
2175
|
+
// fail-open families read 0 and every divergence left is one of three kinds).
|
|
2176
|
+
// FIRST, a quote pair that opens at index 0 of the word — \`"">out.txt\`,
|
|
2177
|
+
// \`">">out.txt\`, \`"2">file\` — leaves no operator prefix before it, so the
|
|
2178
|
+
// word is data here (\`>out.txt\`, \`>>out.txt\`, \`2>file\`) while bash passes
|
|
2179
|
+
// the empty string, \`>\` or \`2\` as an ARGUMENT and applies the redirection
|
|
2180
|
+
// that follows it. SECOND, a \`$VAR\` in a kept filename is not expanded here,
|
|
2181
|
+
// so the word this walk names is \`>$FILE\` where bash wrote \`>varfile.txt\`.
|
|
2182
|
+
// Both of those are confined to the argv's TEXT, and each word they keep
|
|
2183
|
+
// names a target the engine denies. THIRD, where the quote sits INSIDE the
|
|
2184
|
+
// operator prefix the bounded rule strips the word, and bash sometimes runs
|
|
2185
|
+
// nothing at all behind it: an empty filename (\`>"">out.txt\`) or a file that
|
|
2186
|
+
// does not exist (\`<"<"<out.txt\`, \`2<">"out.txt\`) fails the redirection, so
|
|
2187
|
+
// the walk judges a merge the shell never ran — a bogus deny, not a bypass.
|
|
2188
|
+
// All three are the deny direction, and the rows assert the projection.
|
|
2189
|
+
const REDIRECTION_ALONE = /^[0-9]*(?:<<<|[<>]{1,2})$/;
|
|
2190
|
+
const REDIRECTION_FUSED = /^([0-9]*(?:<<<|[<>]{1,2}))[\\s\\S]+$/;
|
|
2191
|
+
|
|
2192
|
+
// The length of the operator prefix BOUNDED by \`at\`, the token's first
|
|
2193
|
+
// literal index: the longest prefix of the greedy match that is itself a
|
|
2194
|
+
// redirection operator and ends at or before \`at\`, or \`-1\` when none is
|
|
2195
|
+
// (\`">"out.txt\`, first literal at 0). A token with no literal character at
|
|
2196
|
+
// all (\`at === -1\`) keeps the greedy match, as it always has.
|
|
2197
|
+
function boundedOperatorLength(prefix, at) {
|
|
2198
|
+
if (at === -1) return prefix.length;
|
|
2199
|
+
for (let n = prefix.length < at ? prefix.length : at; n > 0; n--) {
|
|
2200
|
+
if (REDIRECTION_ALONE.test(prefix.slice(0, n))) return n;
|
|
2201
|
+
}
|
|
2202
|
+
return -1;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
1804
2205
|
/**
|
|
1805
2206
|
* The argv after EVERY \`gh pr merge\` at command position in the command —
|
|
1806
2207
|
* one array per merge, in command order — or an empty array when there is
|
|
1807
2208
|
* none. A compound command that merges twice yields two, and the wrapper
|
|
1808
2209
|
* judges each (mmnto-ai/totem#2844 round 1).
|
|
1809
2210
|
*/
|
|
1810
|
-
function ghPrMergeArgvs(rawCommand, powershell) {
|
|
2211
|
+
function ghPrMergeArgvs(rawCommand, powershell, depth) {
|
|
2212
|
+
// \`eval\` re-enters this function ONCE (§ B); every other caller is depth 0.
|
|
2213
|
+
const level = typeof depth === 'number' ? depth : 0;
|
|
1811
2214
|
const command = blankHeredocBodies(rawCommand, powershell === true);
|
|
2215
|
+
// Each segment's tokens, and beside them ONE NUMBER PER TOKEN: the INDEX,
|
|
2216
|
+
// within the token, of the first character that came from inside quotes or
|
|
2217
|
+
// from a backslash escape — \`-1\` when the whole word is bare. The
|
|
2218
|
+
// redirection strip below is its only reader, and it needs the index rather
|
|
2219
|
+
// than a yes/no because the shell decides a redirection on the QUOTING OF
|
|
2220
|
+
// THE OPERATOR: \`>"out.txt"\` redirects (first literal character at 1, past
|
|
2221
|
+
// the \`>\`) while \`">"out.txt\` is the argument \`>out.txt\` (first literal
|
|
2222
|
+
// character at 0, on the operator itself). A yes/no answered both with
|
|
2223
|
+
// "data" and let a real redirection ride into argv (round-7 leg, H1/H2); it
|
|
2224
|
+
// answered \`-b "<br>" 5\` correctly, and so does the index (round-6 leg,
|
|
2225
|
+
// G1). The numbers ride in a PARALLEL array so every reader of a token stays
|
|
2226
|
+
// a reader of a plain string. It annotates the walk's output; it changes no
|
|
2227
|
+
// grammar.
|
|
1812
2228
|
const segments = [];
|
|
2229
|
+
const literalAts = [];
|
|
1813
2230
|
let current = [];
|
|
2231
|
+
let currentLiteralAt = [];
|
|
1814
2232
|
let token = '';
|
|
1815
2233
|
let hasToken = false;
|
|
2234
|
+
let tokenLiteralAt = -1;
|
|
1816
2235
|
let i = 0;
|
|
2236
|
+
/** The next character appended to this token is literal: mark the first. */
|
|
2237
|
+
const markLiteral = () => {
|
|
2238
|
+
if (tokenLiteralAt === -1) tokenLiteralAt = token.length;
|
|
2239
|
+
};
|
|
1817
2240
|
const endToken = () => {
|
|
1818
2241
|
if (hasToken) {
|
|
1819
2242
|
current.push(token);
|
|
2243
|
+
currentLiteralAt.push(tokenLiteralAt);
|
|
1820
2244
|
token = '';
|
|
1821
2245
|
hasToken = false;
|
|
2246
|
+
tokenLiteralAt = -1;
|
|
1822
2247
|
}
|
|
1823
2248
|
};
|
|
1824
2249
|
const endSegment = () => {
|
|
1825
2250
|
endToken();
|
|
1826
2251
|
segments.push(current);
|
|
2252
|
+
literalAts.push(currentLiteralAt);
|
|
1827
2253
|
current = [];
|
|
2254
|
+
currentLiteralAt = [];
|
|
1828
2255
|
};
|
|
1829
2256
|
while (i < command.length) {
|
|
1830
2257
|
const ch = command[i];
|
|
1831
2258
|
if (ch === "'") {
|
|
1832
2259
|
hasToken = true;
|
|
2260
|
+
markLiteral();
|
|
1833
2261
|
i++;
|
|
1834
2262
|
while (i < command.length && command[i] !== "'") {
|
|
1835
2263
|
token += command[i];
|
|
@@ -1840,6 +2268,7 @@ function ghPrMergeArgvs(rawCommand, powershell) {
|
|
|
1840
2268
|
}
|
|
1841
2269
|
if (ch === '"') {
|
|
1842
2270
|
hasToken = true;
|
|
2271
|
+
markLiteral();
|
|
1843
2272
|
i++;
|
|
1844
2273
|
while (i < command.length && command[i] !== '"') {
|
|
1845
2274
|
if (command[i] === '\\\\' && i + 1 < command.length) {
|
|
@@ -1871,6 +2300,58 @@ function ghPrMergeArgvs(rawCommand, powershell) {
|
|
|
1871
2300
|
i++;
|
|
1872
2301
|
continue;
|
|
1873
2302
|
}
|
|
2303
|
+
// A BACKTICK command substitution opens a segment of its own
|
|
2304
|
+
// (mmnto-ai/totem#2856 § C): its operand is a command the shell runs, so a
|
|
2305
|
+
// merge inside one has to be judged, exactly as a merge inside \`$( … )\`
|
|
2306
|
+
// is. The backtick is kept as a TOKEN, the way \`$(\` leaves its \`$\` behind:
|
|
2307
|
+
// without it a merge whose TARGET is a backtick substitution would lose
|
|
2308
|
+
// that target and fall back to the current branch — judging a pull request
|
|
2309
|
+
// the command never named. One inside a heredoc body is already blanked,
|
|
2310
|
+
// and that is correct — a body is data. One inside DOUBLE quotes never
|
|
2311
|
+
// reaches here either, because the quote arm above swallows the whole
|
|
2312
|
+
// string as one token — and that one is NOT data: bash executes a backtick
|
|
2313
|
+
// pair and a \`$( … )\` inside double quotes, so \`echo "\`gh pr merge 5\`"\`
|
|
2314
|
+
// merges PR 5 unjudged. A disclosed fail-open, filed as
|
|
2315
|
+
// mmnto-ai/totem#2893 (round-5 leg, F4).
|
|
2316
|
+
// POWERSHELL'S LINE CONTINUATION is a trailing BACKTICK — the twin of the
|
|
2317
|
+
// backslash-newline arm below AT A WORD BOUNDARY, and the reason this one
|
|
2318
|
+
// has to be read first: in ps mode the backtick and the newline after it
|
|
2319
|
+
// are consumed and the next line's words continue the command, so
|
|
2320
|
+
// \`gh pr merge <backtick><LF>5\` is \`gh pr merge 5\`. Read as the segment
|
|
2321
|
+
// separator it is in BASH, that command projected the backtick itself as
|
|
2322
|
+
// the merge's target (\`unresolvedTarget\`, a deny on a target nobody wrote
|
|
2323
|
+
// under strict) while the real target sat in the next segment and merged
|
|
2324
|
+
// unjudged (round-6 leg, G3). Bash keeps the separator: there a backtick
|
|
2325
|
+
// opens a command substitution, whatever follows it.
|
|
2326
|
+
//
|
|
2327
|
+
// INSIDE A WORD the two shells part company (round-7 leg, H4). Bash's
|
|
2328
|
+
// backslash-newline really joins the halves — \`me\\<LF>rge\` is \`merge\`
|
|
2329
|
+
// — while PowerShell's backtick is its ESCAPE character and
|
|
2330
|
+
// \`merg<backtick><LF>e\` is the single argument \`merg<LF>e\`, which is not
|
|
2331
|
+
// \`merge\` and runs nothing. So the join applies only where no token is
|
|
2332
|
+
// open; inside one, the escaped newline lands IN the token, the anchor
|
|
2333
|
+
// fails to match, and nothing is projected — which is what PowerShell
|
|
2334
|
+
// does. Joining there projected a merge the shell never runs.
|
|
2335
|
+
if (
|
|
2336
|
+
powershell === true &&
|
|
2337
|
+
ch === '\`' &&
|
|
2338
|
+
(command[i + 1] === '\\n' || (command[i + 1] === '\\r' && command[i + 2] === '\\n'))
|
|
2339
|
+
) {
|
|
2340
|
+
if (hasToken) {
|
|
2341
|
+
markLiteral();
|
|
2342
|
+
token += '\\n';
|
|
2343
|
+
}
|
|
2344
|
+
i += command[i + 1] === '\\r' ? 3 : 2;
|
|
2345
|
+
continue;
|
|
2346
|
+
}
|
|
2347
|
+
if (ch === '\`') {
|
|
2348
|
+
endToken();
|
|
2349
|
+
current.push('\`');
|
|
2350
|
+
currentLiteralAt.push(-1);
|
|
2351
|
+
endSegment();
|
|
2352
|
+
i++;
|
|
2353
|
+
continue;
|
|
2354
|
+
}
|
|
1874
2355
|
if (
|
|
1875
2356
|
ch === ';' ||
|
|
1876
2357
|
ch === '&' ||
|
|
@@ -1893,6 +2374,7 @@ function ghPrMergeArgvs(rawCommand, powershell) {
|
|
|
1893
2374
|
continue;
|
|
1894
2375
|
}
|
|
1895
2376
|
if (ch === '\\\\' && i + 1 < command.length) {
|
|
2377
|
+
markLiteral();
|
|
1896
2378
|
token += command[i + 1];
|
|
1897
2379
|
hasToken = true;
|
|
1898
2380
|
i += 2;
|
|
@@ -1905,24 +2387,201 @@ function ghPrMergeArgvs(rawCommand, powershell) {
|
|
|
1905
2387
|
endSegment();
|
|
1906
2388
|
|
|
1907
2389
|
const found = [];
|
|
1908
|
-
for (
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
2390
|
+
for (let s = 0; s < segments.length; s++) {
|
|
2391
|
+
const segment = segments[s];
|
|
2392
|
+
const literalAt = literalAts[s];
|
|
2393
|
+
// FIRST, over the WHOLE segment: drop every redirection (the two patterns
|
|
2394
|
+
// above). It runs before the strip below and before the anchor test, so a
|
|
2395
|
+
// redirection in front of the command does not hide it, one in the middle
|
|
2396
|
+
// does not break the anchor, and a trailing one never rides into argv.
|
|
2397
|
+
// The OPERATOR's own quoting decides, as it does in the shell: strip only
|
|
2398
|
+
// when an operator prefix lies entirely before the token's first literal
|
|
2399
|
+
// character (round-7 leg, H1/H2), and the prefix taken is the longest one
|
|
2400
|
+
// that does — \`boundedOperatorLength\` above, fold 6.
|
|
2401
|
+
let tokens = [];
|
|
2402
|
+
for (let r = 0; r < segment.length; r++) {
|
|
2403
|
+
const word = segment[r];
|
|
2404
|
+
const at = literalAt[r];
|
|
2405
|
+
if (REDIRECTION_ALONE.test(word) && (at === -1 || word.length <= at)) {
|
|
2406
|
+
// The operator and the file it names, both gone — however that file
|
|
2407
|
+
// is spelled: \`> "out.txt"\` is as real a redirection as \`> out.txt\`.
|
|
2408
|
+
r += 1;
|
|
2409
|
+
continue;
|
|
2410
|
+
}
|
|
2411
|
+
const fused = REDIRECTION_FUSED.exec(word);
|
|
2412
|
+
if (fused !== null && boundedOperatorLength(fused[1], at) !== -1) continue;
|
|
2413
|
+
tokens.push(word);
|
|
2414
|
+
}
|
|
2415
|
+
// Then strip everything at the segment's front that is NOT the command, in
|
|
2416
|
+
// any run: a transparent wrapper with its options (the table above), a
|
|
2417
|
+
// command-position word, an assignment prefix. The loop re-runs after each
|
|
2418
|
+
// one, so \`env -u X A=1 gh …\` and \`sudo -u root timeout 30 gh …\` both
|
|
2419
|
+
// resolve to the same anchor test.
|
|
2420
|
+
let stripping = true;
|
|
2421
|
+
while (stripping && tokens.length > 0) {
|
|
2422
|
+
const head = tokens[0];
|
|
2423
|
+
const wrapper = Object.prototype.hasOwnProperty.call(TRANSPARENT_WRAPPERS, head)
|
|
2424
|
+
? TRANSPARENT_WRAPPERS[head]
|
|
2425
|
+
: null;
|
|
2426
|
+
if (wrapper === null) {
|
|
2427
|
+
if (COMMAND_POSITION_WORDS.indexOf(head) !== -1 || isAssignmentPrefix(head)) {
|
|
2428
|
+
tokens = tokens.slice(1);
|
|
2429
|
+
continue;
|
|
2430
|
+
}
|
|
2431
|
+
break;
|
|
2432
|
+
}
|
|
1914
2433
|
tokens = tokens.slice(1);
|
|
2434
|
+
// \`eval\` hands the shell a STRING: join what is left with one space and
|
|
2435
|
+
// re-tokenize it ONCE. That inner projection IS this segment's, and the
|
|
2436
|
+
// depth bound keeps \`eval "eval \\"gh pr merge 5\\""\` a disclosed miss.
|
|
2437
|
+
if (wrapper.evaluates === true) {
|
|
2438
|
+
if (level < 1 && tokens.length > 0) {
|
|
2439
|
+
const inner = ghPrMergeArgvs(tokens.join(' '), powershell, level + 1);
|
|
2440
|
+
for (let k = 0; k < inner.length; k++) {
|
|
2441
|
+
found.push(inner[k]);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
tokens = [];
|
|
2445
|
+
break;
|
|
2446
|
+
}
|
|
2447
|
+
while (tokens.length > 0 && tokens[0].charAt(0) === '-' && tokens[0].length > 1) {
|
|
2448
|
+
const opt = tokens[0];
|
|
2449
|
+
if (opt === '--') {
|
|
2450
|
+
tokens = tokens.slice(1);
|
|
2451
|
+
if (wrapper.terminator === true) break;
|
|
2452
|
+
continue;
|
|
2453
|
+
}
|
|
2454
|
+
if (wrapper.describe !== undefined && wrapper.describe.indexOf(opt) !== -1) {
|
|
2455
|
+
// \`command -v gh …\` prints a path and \`sudo -l gh …\` prints a
|
|
2456
|
+
// policy line; each runs nothing, so there is nothing to judge and
|
|
2457
|
+
// nothing to project.
|
|
2458
|
+
tokens = [];
|
|
2459
|
+
stripping = false;
|
|
2460
|
+
break;
|
|
2461
|
+
}
|
|
2462
|
+
if (wrapper.flags !== undefined && wrapper.flags.indexOf(opt) === -1) {
|
|
2463
|
+
// A \`-\` token that is not one of this word's OWN options: the shell
|
|
2464
|
+
// has no command to run here (\`time -f x gh pr merge 5\` makes bash
|
|
2465
|
+
// try to run \`-f\`), so the segment ends with no projection.
|
|
2466
|
+
tokens = [];
|
|
2467
|
+
stripping = false;
|
|
2468
|
+
break;
|
|
2469
|
+
}
|
|
2470
|
+
if (wrapper.evaluatesOperand !== undefined) {
|
|
2471
|
+
// Where the operand is: a LONG option carries an attached one after
|
|
2472
|
+
// an \`=\` (\`--split-string='gh pr merge 5'\`), a SHORT one carries it
|
|
2473
|
+
// with no separator at all (\`-Sgh pr merge 5\`, and
|
|
2474
|
+
// \`-S'gh pr merge 5'\`, which the quote arm joins into that same
|
|
2475
|
+
// token), and otherwise it is the NEXT token. An \`=\` is not a
|
|
2476
|
+
// separator for a short option — \`env -S=x\` hands env the operand
|
|
2477
|
+
// \`=x\` — so the split is long-only (round-7 leg, H5).
|
|
2478
|
+
//
|
|
2479
|
+
// …and when an \`=\` FOLLOWS the short option's letter this arm does
|
|
2480
|
+
// not apply at all: the token falls through to be dropped as a flag
|
|
2481
|
+
// of the word, which is the route that reads both spellings right
|
|
2482
|
+
// (round-8 leg, J2). \`env -S=X gh pr merge 5\` is env splitting the
|
|
2483
|
+
// operand \`=X\` into one word, an assignment with an EMPTY NAME, so
|
|
2484
|
+
// the command is \`gh pr merge 5\` and the merge RUNS (measured,
|
|
2485
|
+
// coreutils 8.32); reading \`=X\` as the operand here put it at the
|
|
2486
|
+
// front of the strip, where it is neither an assignment this walk
|
|
2487
|
+
// accepts nor a command, and the merge behind it went unjudged.
|
|
2488
|
+
// \`env -S='gh pr merge 5'\` is the same rule the other way: env's
|
|
2489
|
+
// words are \`=gh\`, \`pr\`, \`merge\`, \`5\`, the command is coreutils
|
|
2490
|
+
// \`pr\` and no merge runs — and none is projected.
|
|
2491
|
+
let name = opt;
|
|
2492
|
+
let attached = null;
|
|
2493
|
+
if (opt.charAt(1) === '-') {
|
|
2494
|
+
const eq = opt.indexOf('=');
|
|
2495
|
+
if (eq !== -1) {
|
|
2496
|
+
name = opt.slice(0, eq);
|
|
2497
|
+
attached = opt.slice(eq + 1);
|
|
2498
|
+
}
|
|
2499
|
+
} else if (opt.charAt(2) !== '=') {
|
|
2500
|
+
name = opt.slice(0, 2);
|
|
2501
|
+
if (opt.length > 2) attached = opt.slice(2);
|
|
2502
|
+
}
|
|
2503
|
+
if (wrapper.evaluatesOperand.indexOf(name) !== -1) {
|
|
2504
|
+
// The operand is a COMMAND STRING, not a value to skip past: env
|
|
2505
|
+
// splits it into words and prepends them to what follows. Split
|
|
2506
|
+
// on whitespace — env's own rule — put the words where the option
|
|
2507
|
+
// stood, and let the strip read on, so the assignment strip runs
|
|
2508
|
+
// for \`env -S 'A=1 gh pr merge 5'\` and the anchor sees \`gh\`.
|
|
2509
|
+
const operandText = attached === null ? (tokens.length > 1 ? tokens[1] : '') : attached;
|
|
2510
|
+
const rest = tokens.slice(attached === null ? 2 : 1);
|
|
2511
|
+
const words = operandText.split(/\\s+/);
|
|
2512
|
+
tokens = [];
|
|
2513
|
+
for (let w = 0; w < words.length; w++) {
|
|
2514
|
+
if (words[w] !== '') tokens.push(words[w]);
|
|
2515
|
+
}
|
|
2516
|
+
tokens = tokens.concat(rest);
|
|
2517
|
+
continue;
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
if (wrapper.operand.indexOf(opt) !== -1) {
|
|
2521
|
+
tokens = tokens.slice(2);
|
|
2522
|
+
continue;
|
|
2523
|
+
}
|
|
2524
|
+
tokens = tokens.slice(1);
|
|
2525
|
+
}
|
|
2526
|
+
for (let p = 0; stripping && p < wrapper.positional && tokens.length > 0; p++) {
|
|
2527
|
+
tokens = tokens.slice(1);
|
|
2528
|
+
}
|
|
1915
2529
|
}
|
|
1916
|
-
if (
|
|
2530
|
+
if (
|
|
2531
|
+
tokens.length >= 3 &&
|
|
2532
|
+
isGhExecutable(tokens[0]) &&
|
|
2533
|
+
tokens[1] === 'pr' &&
|
|
2534
|
+
tokens[2] === 'merge'
|
|
2535
|
+
) {
|
|
1917
2536
|
found.push(tokens.slice(3));
|
|
1918
2537
|
}
|
|
1919
2538
|
}
|
|
1920
2539
|
return found;
|
|
1921
2540
|
}
|
|
1922
2541
|
|
|
1923
|
-
|
|
2542
|
+
// ─── The one budget for the whole run (mmnto-ai/totem#2856 § D) ─────────
|
|
2543
|
+
// The instant this hook must be finished by. It is set as the FIRST thing the
|
|
2544
|
+
// entry does — before stdin is read and before any projection — so that EVERY
|
|
2545
|
+
// spawn this process makes, the projection's git reads included, is bounded by
|
|
2546
|
+
// it. Before this PR the deadline came into being only at the evaluation loop,
|
|
2547
|
+
// and the projection ran up to three 10-second git reads per merge ahead of
|
|
2548
|
+
// it: a hung git on a multi-merge envelope reached the HOST's hook timeout,
|
|
2549
|
+
// where a killed hook's exit code is never applied — a fail-OPEN on a gate
|
|
2550
|
+
// whose posture is fail-closed (round 3, F3).
|
|
2551
|
+
//
|
|
2552
|
+
// Zero until the entry sets it, which reads as "already spent": an exported
|
|
2553
|
+
// \`projectMergeReady\` (the seam below) therefore does no git reads at all.
|
|
2554
|
+
let deadline = 0;
|
|
2555
|
+
|
|
2556
|
+
/** The default budget, and the ceiling \`--budget-ms\` is clamped to. */
|
|
2557
|
+
const DEFAULT_BUDGET_MS = 30000;
|
|
2558
|
+
|
|
2559
|
+
/**
|
|
2560
|
+
* The budget a \`--budget-ms <n>\` argument asks for, clamped to
|
|
2561
|
+
* [1000, 30000]. The clamp is SILENT and one-directional by design: the
|
|
2562
|
+
* argument exists so a test can shorten the window, and a malformed or
|
|
2563
|
+
* oversized value must never WIDEN it (Tenet 4 keeps the safe direction). The
|
|
2564
|
+
* value it settles on is echoed in the budget line when the arm fires.
|
|
2565
|
+
*/
|
|
2566
|
+
function clampBudgetMs(raw) {
|
|
2567
|
+
const n = parseInt(String(raw), 10);
|
|
2568
|
+
if (!isFinite(n) || n > DEFAULT_BUDGET_MS) return DEFAULT_BUDGET_MS;
|
|
2569
|
+
if (n < 1000) return 1000;
|
|
2570
|
+
return n;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
/**
|
|
2574
|
+
* Run git read-only and return trimmed stdout, or '' when it did not answer.
|
|
2575
|
+
* Bounded by what is LEFT of the budget (never more than 10 s, never less than
|
|
2576
|
+
* a 250 ms floor), and it does not spawn at all once the budget is spent.
|
|
2577
|
+
*/
|
|
1924
2578
|
function gitRead(args) {
|
|
1925
|
-
const
|
|
2579
|
+
const remaining = deadline - Date.now();
|
|
2580
|
+
if (remaining <= 0) return '';
|
|
2581
|
+
const res = spawnSync('git', args, {
|
|
2582
|
+
encoding: 'utf-8',
|
|
2583
|
+
timeout: Math.max(250, Math.min(10000, remaining)),
|
|
2584
|
+
});
|
|
1926
2585
|
if (res.error || typeof res.status !== 'number' || res.status !== 0) return '';
|
|
1927
2586
|
return (res.stdout || '').trim();
|
|
1928
2587
|
}
|
|
@@ -2017,6 +2676,99 @@ function projectMergeReady(argv) {
|
|
|
2017
2676
|
return out;
|
|
2018
2677
|
}
|
|
2019
2678
|
|
|
2679
|
+
// ─── The export seam (mmnto-ai/totem#2856 § E) ─────────────────────────
|
|
2680
|
+
// Everything above is pure and side-effect free; everything below is the
|
|
2681
|
+
// hook's ENTRY — it reads argv and stdin and exits the process. A \`require\`
|
|
2682
|
+
// of this file (the suite's in-process driver for the strip table, the
|
|
2683
|
+
// executable test, the budget clamp and the scanner-parity lock) must run
|
|
2684
|
+
// NEITHER, so the entry runs only when this file is the main module. The
|
|
2685
|
+
// module-scope \`return\` is CommonJS's own early exit, and it sits AFTER every
|
|
2686
|
+
// module-level binding above so the exported functions are all initialized.
|
|
2687
|
+
//
|
|
2688
|
+
// Nothing else changes when the file runs as a hook: \`require.main\` is this
|
|
2689
|
+
// module, the \`return\` is not taken, and the entry below is the same code it
|
|
2690
|
+
// has always been. One consequence worth naming: an exported
|
|
2691
|
+
// \`projectMergeReady\` runs with no budget set (see \`deadline\`), so it does no
|
|
2692
|
+
// git reads — the projection's shape is what the seam is for, the git facts
|
|
2693
|
+
// are the entry's.
|
|
2694
|
+
if (require.main !== module) {
|
|
2695
|
+
module.exports = {
|
|
2696
|
+
blankHeredocBodies: blankHeredocBodies,
|
|
2697
|
+
clampBudgetMs: clampBudgetMs,
|
|
2698
|
+
findHeredocSpans: findHeredocSpans,
|
|
2699
|
+
ghPrMergeArgvs: ghPrMergeArgvs,
|
|
2700
|
+
isGhExecutable: isGhExecutable,
|
|
2701
|
+
projectMergeReady: projectMergeReady,
|
|
2702
|
+
};
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
// ─── Parse baked args (--event <name>, optional --pilot / --strict) ─────
|
|
2707
|
+
// The tier is read ONLY from argv (baked into the installed command at
|
|
2708
|
+
// install time). There is NO env-var override: env sourcing would be a
|
|
2709
|
+
// fail-open (any shell with TOTEM_GATE_TIER=pilot could silently downgrade
|
|
2710
|
+
// enforcement). Default (no flag) = strict, so a default install is
|
|
2711
|
+
// environment-immune; --pilot is an explicit install-time opt-in.
|
|
2712
|
+
//
|
|
2713
|
+
// \`--budget-ms <n>\` is the one argument the install line never writes: it
|
|
2714
|
+
// exists so a test can SHORTEN the run's budget, and it is clamped so it can
|
|
2715
|
+
// only ever shorten it (§ D). An env var was the alternative and was ruled
|
|
2716
|
+
// out for the same reason the tier is argv-only — any shell could set it.
|
|
2717
|
+
// BOTH spellings parse: \`--budget-ms 1500\` and \`--budget-ms=1500\`. The
|
|
2718
|
+
// attached form used to fall through as an unknown argument and silently left
|
|
2719
|
+
// the 30 000 ms default standing — a WIDENING on a caller that wrote the
|
|
2720
|
+
// argument to shorten the window (round-5 leg, F7). A repeated flag is
|
|
2721
|
+
// last-wins, and no spelling of it can ever exceed the default, because every
|
|
2722
|
+
// value goes through the same clamp.
|
|
2723
|
+
const argv = process.argv.slice(2);
|
|
2724
|
+
let event = '';
|
|
2725
|
+
let tier = 'strict';
|
|
2726
|
+
let budgetMs = DEFAULT_BUDGET_MS;
|
|
2727
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2728
|
+
if (argv[i] === '--event') {
|
|
2729
|
+
event = argv[i + 1] || '';
|
|
2730
|
+
i++;
|
|
2731
|
+
} else if (argv[i] === '--pilot') {
|
|
2732
|
+
tier = 'pilot';
|
|
2733
|
+
} else if (argv[i] === '--strict') {
|
|
2734
|
+
tier = 'strict';
|
|
2735
|
+
} else if (argv[i] === '--budget-ms') {
|
|
2736
|
+
budgetMs = clampBudgetMs(argv[i + 1]);
|
|
2737
|
+
i++;
|
|
2738
|
+
} else if (argv[i].slice(0, 12) === '--budget-ms=') {
|
|
2739
|
+
budgetMs = clampBudgetMs(argv[i].slice(12));
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
// The FIRST thing the entry does after reading its own arguments: from here on
|
|
2744
|
+
// every spawn — the projection's git reads and the evaluation loop's gate
|
|
2745
|
+
// checks alike — is bounded by one deadline, so the wrapper always terminates
|
|
2746
|
+
// within the budget plus one 1 000 ms floor with its OWN exit code.
|
|
2747
|
+
deadline = Date.now() + budgetMs;
|
|
2748
|
+
|
|
2749
|
+
// …and the READ of the envelope is inside it too (round-5 leg, F6). The budget
|
|
2750
|
+
// used to start counting for everything the hook did AFTER the envelope had
|
|
2751
|
+
// arrived; arriving itself was unbounded. A host that writes the envelope and
|
|
2752
|
+
// holds the pipe open, or hands this hook a stdin that never ends, left it
|
|
2753
|
+
// waiting with no deadline of its own until the HOST's own timeout killed it —
|
|
2754
|
+
// and a killed hook's exit code is never applied, which is a fail-OPEN on a
|
|
2755
|
+
// gate whose posture is fail-closed. Exactly the class § D cured for the
|
|
2756
|
+
// projection's git reads, one step earlier in the run.
|
|
2757
|
+
//
|
|
2758
|
+
// The timer is cleared by the \`end\` handler below BEFORE anything is
|
|
2759
|
+
// evaluated, so a normal run — every run where stdin closes — never sees it.
|
|
2760
|
+
const stdinBudgetTimer = setTimeout(
|
|
2761
|
+
() => {
|
|
2762
|
+
process.stderr.write(
|
|
2763
|
+
'[totem gate-wrapper] the ' +
|
|
2764
|
+
budgetMs +
|
|
2765
|
+
' ms budget was spent before the envelope arrived on stdin — blocking (fail-closed).\\n',
|
|
2766
|
+
);
|
|
2767
|
+
process.exit(2);
|
|
2768
|
+
},
|
|
2769
|
+
Math.max(0, deadline - Date.now()),
|
|
2770
|
+
);
|
|
2771
|
+
|
|
2020
2772
|
// Read the PreToolUse stdin envelope.
|
|
2021
2773
|
let stdin = '';
|
|
2022
2774
|
process.stdin.setEncoding('utf-8');
|
|
@@ -2024,6 +2776,7 @@ process.stdin.on('data', (chunk) => {
|
|
|
2024
2776
|
stdin += chunk;
|
|
2025
2777
|
});
|
|
2026
2778
|
process.stdin.on('end', () => {
|
|
2779
|
+
clearTimeout(stdinBudgetTimer);
|
|
2027
2780
|
let parsed;
|
|
2028
2781
|
try {
|
|
2029
2782
|
parsed = stdin ? JSON.parse(stdin) : {};
|
|
@@ -2254,21 +3007,32 @@ process.stdin.on('end', () => {
|
|
|
2254
3007
|
// merge in the envelope gets its stderr line; exit 0 only once every payload
|
|
2255
3008
|
// has allowed or warned.
|
|
2256
3009
|
//
|
|
2257
|
-
// ONE
|
|
2258
|
-
//
|
|
2259
|
-
//
|
|
2260
|
-
//
|
|
2261
|
-
//
|
|
2262
|
-
//
|
|
2263
|
-
//
|
|
2264
|
-
//
|
|
2265
|
-
//
|
|
2266
|
-
//
|
|
2267
|
-
//
|
|
2268
|
-
//
|
|
2269
|
-
//
|
|
2270
|
-
|
|
3010
|
+
// ONE budget across every payload, not one per payload (round 2, F5): the
|
|
3011
|
+
// hook host kills a PreToolUse hook at its own default budget (60 s on both
|
|
3012
|
+
// Claude Code and Gemini, the same figure the session-hook templates above
|
|
3013
|
+
// cut their legs against) and a killed hook's exit code is never applied — a
|
|
3014
|
+
// fail-OPEN on a gate whose posture is fail-closed. The budget is set at the
|
|
3015
|
+
// ENTRY (see \`deadline\` above), so it now covers the projection's git reads
|
|
3016
|
+
// too (mmnto-ai/totem#2856 § D); before that it began here, and a hung git
|
|
3017
|
+
// ahead of it could run the hook into the host's kill through up to three
|
|
3018
|
+
// 10-second reads per merge (round 3, F3).
|
|
3019
|
+
//
|
|
3020
|
+
// Two arms keep the whole run inside it: a payload whose spawn would start
|
|
3021
|
+
// past the deadline gets the fail-closed line below INSTEAD of a spawn, and
|
|
3022
|
+
// a spawn that starts inside it still gets the one-second floor (round 3,
|
|
3023
|
+
// F2) and times out into the evaluation-failed arm. Either way the wrapper
|
|
3024
|
+
// exits with its OWN code, inside the budget plus one floor.
|
|
2271
3025
|
for (let p = 0; p < payloads.length; p++) {
|
|
3026
|
+
if (Date.now() >= deadline) {
|
|
3027
|
+
process.stderr.write(
|
|
3028
|
+
'[totem gate-wrapper] the ' +
|
|
3029
|
+
budgetMs +
|
|
3030
|
+
' ms budget was spent before gate "' +
|
|
3031
|
+
event +
|
|
3032
|
+
"\\" could be evaluated (the projection's git reads did not answer in time) — blocking (fail-closed).\\n",
|
|
3033
|
+
);
|
|
3034
|
+
process.exit(2);
|
|
3035
|
+
}
|
|
2272
3036
|
const result = spawnSync(process.execPath, checkArgs, {
|
|
2273
3037
|
encoding: 'utf-8',
|
|
2274
3038
|
timeout: Math.max(1000, deadline - Date.now()),
|
|
@@ -2706,6 +3470,19 @@ ${SKILL_MARKER_START}
|
|
|
2706
3470
|
|
|
2707
3471
|
Triage PR review comments from all bots for PR $ARGUMENTS.
|
|
2708
3472
|
|
|
3473
|
+
## Before Phase 1: confirm each invoked bot's review is on the head
|
|
3474
|
+
|
|
3475
|
+
A review trigger is the operator's to post, and what a bot does with it is not ours to control — so before triaging, confirm every invoked bot's review against the review object for THIS head sha, or the bot's summary comment for that sha, never a green commit status on the head: CodeRabbit's status settles green on every push head whether or not it reviewed that head (\`Review completed\` on the sha it reviewed, \`Review skipped\` on every push head it did not), and a PENDING status means still reviewing; Greptile's green \`Greptile Review\` check run does mark the sha it reviewed; GCA posted neither a status nor a check run on any GCA-reviewed sha measured so far. The reads, in order — the head sha; every review with the sha it was submitted against; the sha each Greptile summary comment names as its \`Last reviewed commit\`; the sha each CodeRabbit summary comment names as covered in its \`final_review_risk_coverage\` marker — so both halves compare a printed sha against a printed head, never an improvised one:
|
|
3476
|
+
|
|
3477
|
+
\`\`\`bash
|
|
3478
|
+
gh pr view $ARGUMENTS --json headRefOid --jq .headRefOid
|
|
3479
|
+
gh api --paginate "repos/{owner}/{repo}/pulls/$ARGUMENTS/reviews" --jq '.[] | [.user.login, .commit_id, .state, .submitted_at] | join(" ")'
|
|
3480
|
+
gh api --paginate "repos/{owner}/{repo}/issues/$ARGUMENTS/comments" --jq '.[] | select(.user.login == "greptile-apps[bot]") | .body | capture("Last reviewed commit:.*?/commit/(?<sha>[0-9a-f]{40})") | .sha'
|
|
3481
|
+
gh api --paginate "repos/{owner}/{repo}/issues/$ARGUMENTS/comments" --jq '.[] | select(.user.login == "coderabbitai[bot]") | .body | capture("coveredCommitId.:.(?<sha>[0-9a-f]{40})") | .sha'
|
|
3482
|
+
\`\`\`
|
|
3483
|
+
|
|
3484
|
+
A summary-comment verdict is read from the PR's issue comments and matched to the head by the \`Last reviewed commit\` sha its own body names — never by the comment's timestamp, which an in-place re-review does not advance. A chat reply or silence with no review to confirm is not a pass under the cadence of one external pass per chosen bot, so the pass is a standalone re-trigger, posted by the operator on the same terms — that bot's first pass, not a re-invoke, which the cadence reserves for risky rework with the reason recorded in the round comment. Before merging on the other reviewers, either wait one acknowledgement window (about 12 minutes from the trigger) or merge and record the late acknowledgement as one line on the PR thread naming the bot and the time it acknowledged.
|
|
3485
|
+
|
|
2709
3486
|
## Phase 1: Fetch & Categorize (Deterministic)
|
|
2710
3487
|
|
|
2711
3488
|
Run the triage command to fetch, normalize, deduplicate, and categorize all bot comments:
|