@codyswann/lisa 4.57.3 → 4.57.5
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/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +93 -4
- package/dist/codex/scripts/block-no-verify.sh +93 -4
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +6 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +6 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/block-no-verify.sh +93 -4
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +44 -19
- package/plugins/lisa/hooks/block-no-verify.agy.sh +93 -4
- package/plugins/lisa/hooks/block-no-verify.sh +93 -4
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +44 -19
- package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +93 -4
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +44 -19
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/block-no-verify.sh +93 -4
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +44 -19
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-no-verify.sh +93 -4
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +44 -19
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/block-no-verify.agy.sh +93 -4
- package/plugins/src/base/hooks/block-no-verify.sh +93 -4
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +44 -19
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
# Add a name here in the same commit that closes a vector. A hardening that
|
|
44
44
|
# forgets to is invisible to refresh, and shows up as an unexplained diff at
|
|
45
45
|
# review time instead of a named capability.
|
|
46
|
-
# lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
|
|
46
|
+
# lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach, eval-payload
|
|
47
47
|
#
|
|
48
48
|
# Shell-token matching avoids false positives from issue bodies, heredocs, and
|
|
49
49
|
# commit-message prose while still catching quoted real argv values such as
|
|
@@ -652,6 +652,15 @@ def shell_starts_command(tokens, index):
|
|
|
652
652
|
return status == "ok" and not remainder
|
|
653
653
|
|
|
654
654
|
|
|
655
|
+
def printed_argument(tokens, index):
|
|
656
|
+
"""Whether a token is data passed to an ordinary echo or printf command."""
|
|
657
|
+
start = index - 1
|
|
658
|
+
while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
|
|
659
|
+
start -= 1
|
|
660
|
+
program, _, opaque = command_word(tokens[start + 1:index])
|
|
661
|
+
return not opaque and program in {"echo", "printf"}
|
|
662
|
+
|
|
663
|
+
|
|
655
664
|
def env_uses_split_string(tokens, index):
|
|
656
665
|
"""Whether a command-position env invocation uses split-string parsing.
|
|
657
666
|
|
|
@@ -1249,6 +1258,8 @@ def git_skips_verification(text, depth=0):
|
|
|
1249
1258
|
# (`HUSKY=1 git commit -n`) simply sits in an earlier token.
|
|
1250
1259
|
if token != "git" and not token.endswith("/git"):
|
|
1251
1260
|
continue
|
|
1261
|
+
if printed_argument(scoped_tokens, index):
|
|
1262
|
+
continue
|
|
1252
1263
|
# The LONG flag, scoped to this invocation's argv rather than matched
|
|
1253
1264
|
# anywhere on the line. This replaces the unscoped token match that used
|
|
1254
1265
|
# to live in the flat loop below.
|
|
@@ -1276,8 +1287,8 @@ def git_skips_verification(text, depth=0):
|
|
|
1276
1287
|
def token_bypass(tokens):
|
|
1277
1288
|
"""Whether any token disables hooks by environment or by git config.
|
|
1278
1289
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1290
|
+
Printed echo/printf arguments are filtered before this scan. Assignments
|
|
1291
|
+
otherwise stay unscoped, unlike the `--no-verify` argv check. What matters is
|
|
1281
1292
|
where it appears: `HUSKY=0` and `core.hooksPath=` are assignments that
|
|
1282
1293
|
configure whatever git runs next, so they legitimately sit before the
|
|
1283
1294
|
invocation and often on a different line of a script. `--no-verify` is an
|
|
@@ -1387,11 +1398,47 @@ def flat_tokens(text):
|
|
|
1387
1398
|
The token list, or None.
|
|
1388
1399
|
"""
|
|
1389
1400
|
try:
|
|
1390
|
-
|
|
1401
|
+
tokens = shell_tokens(text)
|
|
1402
|
+
return [
|
|
1403
|
+
token.strip("();|&")
|
|
1404
|
+
for index, token in enumerate(tokens)
|
|
1405
|
+
if not printed_argument(tokens, index)
|
|
1406
|
+
]
|
|
1391
1407
|
except ValueError:
|
|
1392
1408
|
return None
|
|
1393
1409
|
|
|
1394
1410
|
|
|
1411
|
+
def eval_payloads(scoped_tokens):
|
|
1412
|
+
"""Command strings an `eval` in command position would execute.
|
|
1413
|
+
|
|
1414
|
+
`eval` takes no options: it concatenates its arguments and runs the result,
|
|
1415
|
+
so the payload is every token up to the next separator, joined by a space.
|
|
1416
|
+
Quoting is already gone by the time `shell_tokens` hands them over, which is
|
|
1417
|
+
exactly why the payload is invisible to every other scan here — the argv
|
|
1418
|
+
scan sees one opaque token and the git scan sees no `git` token at all.
|
|
1419
|
+
|
|
1420
|
+
Args:
|
|
1421
|
+
scoped_tokens: Tokens of one command line.
|
|
1422
|
+
|
|
1423
|
+
Returns:
|
|
1424
|
+
A list of payload strings, one per `eval` found in command position.
|
|
1425
|
+
"""
|
|
1426
|
+
payloads = []
|
|
1427
|
+
for index, token in enumerate(scoped_tokens):
|
|
1428
|
+
if token.rsplit("/", 1)[-1] != "eval":
|
|
1429
|
+
continue
|
|
1430
|
+
if not shell_starts_command(scoped_tokens, index):
|
|
1431
|
+
continue
|
|
1432
|
+
collected = []
|
|
1433
|
+
for follow in scoped_tokens[index + 1 :]:
|
|
1434
|
+
if follow in COMMAND_SEPARATORS:
|
|
1435
|
+
break
|
|
1436
|
+
collected.append(follow)
|
|
1437
|
+
if collected:
|
|
1438
|
+
payloads.append(" ".join(collected))
|
|
1439
|
+
return payloads
|
|
1440
|
+
|
|
1441
|
+
|
|
1395
1442
|
def verdict(text, depth=0, followed=None):
|
|
1396
1443
|
"""Whether this text, or a script it runs, bypasses verification.
|
|
1397
1444
|
|
|
@@ -1405,6 +1452,48 @@ def verdict(text, depth=0, followed=None):
|
|
|
1405
1452
|
"""
|
|
1406
1453
|
if git_skips_verification(text):
|
|
1407
1454
|
return True
|
|
1455
|
+
# RECURSION, NOT A TWELFTH SPELLING (#3531). `eval` is the builtin that takes
|
|
1456
|
+
# a command string, and it was absent from both recursion sets: it is not a
|
|
1457
|
+
# SHELL_PROGRAM (no `-c` payload) and, unlike `builtin`, not a prefix wrapper
|
|
1458
|
+
# whose argv carries through. So every vector this guard blocks was restored
|
|
1459
|
+
# by writing it inside `eval "..."` — measured on origin/main, nine of them,
|
|
1460
|
+
# including `git push --no-verify`, `core.hooksPath`, `HUSKY=0`, one behind a
|
|
1461
|
+
# separator and one behind a `command` wrapper.
|
|
1462
|
+
#
|
|
1463
|
+
# Enumerating spelling twelve is how spelling thirteen appears, and this
|
|
1464
|
+
# repository has twice declined an arms race of that shape. Recursing through
|
|
1465
|
+
# `verdict` instead gives the payload the IDENTICAL analysis, so anything
|
|
1466
|
+
# covered outside is covered inside — including checks the reporter's own
|
|
1467
|
+
# suggested patch would have missed, because it recursed into the git half
|
|
1468
|
+
# only and `HUSKY=0` and `core.hooksPath` live in `token_bypass`.
|
|
1469
|
+
#
|
|
1470
|
+
# Bounded by the same budget as a followed script: unbounded recursion on
|
|
1471
|
+
# adversarial input is itself a denial of the guard, and a Python traceback
|
|
1472
|
+
# exits non-zero, which the wrapper turns into a spurious block.
|
|
1473
|
+
if depth < FOLLOW_MAX_DEPTH:
|
|
1474
|
+
try:
|
|
1475
|
+
eval_scoped = shell_tokens(text)
|
|
1476
|
+
except ValueError:
|
|
1477
|
+
eval_scoped = []
|
|
1478
|
+
for payload in eval_payloads(eval_scoped):
|
|
1479
|
+
if verdict(payload, depth + 1, followed):
|
|
1480
|
+
return True
|
|
1481
|
+
# AND INSIDE A NESTED SHELL. `git_skips_verification` already recurses
|
|
1482
|
+
# into a `bash -c` payload, but only into ITSELF — so it carries the git
|
|
1483
|
+
# half down and leaves `eval`, `HUSKY=0` and `core.hooksPath` behind.
|
|
1484
|
+
# `bash -c 'eval "git commit --no-verify -m x"'` survived round one for
|
|
1485
|
+
# exactly that reason, and it was found by running the adversarial round
|
|
1486
|
+
# rather than by reasoning about it. Recursing the payload through
|
|
1487
|
+
# `verdict` gives every level the whole analysis.
|
|
1488
|
+
for index, token in enumerate(eval_scoped):
|
|
1489
|
+
if token.rsplit("/", 1)[-1] not in SHELL_PROGRAMS:
|
|
1490
|
+
continue
|
|
1491
|
+
if not shell_starts_command(eval_scoped, index):
|
|
1492
|
+
continue
|
|
1493
|
+
nested = nested_shell_payload(eval_scoped, index)
|
|
1494
|
+
if isinstance(nested, str) and verdict(nested, depth + 1, followed):
|
|
1495
|
+
return True
|
|
1496
|
+
|
|
1408
1497
|
tokens = flat_tokens(text)
|
|
1409
1498
|
if tokens is not None and token_bypass(tokens):
|
|
1410
1499
|
return True
|
package/package.json
CHANGED
|
@@ -187,7 +187,7 @@
|
|
|
187
187
|
"zod-validation-error": "^4.0.0"
|
|
188
188
|
},
|
|
189
189
|
"name": "@codyswann/lisa",
|
|
190
|
-
"version": "4.57.
|
|
190
|
+
"version": "4.57.5",
|
|
191
191
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
192
192
|
"main": "dist/index.js",
|
|
193
193
|
"exports": {
|
|
@@ -336,7 +336,7 @@
|
|
|
336
336
|
"test": "tests"
|
|
337
337
|
},
|
|
338
338
|
"types": "./dist/index.d.ts",
|
|
339
|
-
"lisaReleaseCommit": "
|
|
340
|
-
"gitHead": "
|
|
341
|
-
"lisaReleaseTag": "v4.57.
|
|
339
|
+
"lisaReleaseCommit": "d9d122fb978ba31b95f8c9fc3426c48e738f628c",
|
|
340
|
+
"gitHead": "d9d122fb978ba31b95f8c9fc3426c48e738f628c",
|
|
341
|
+
"lisaReleaseTag": "v4.57.5"
|
|
342
342
|
}
|
|
@@ -232,8 +232,22 @@ export function preflightTools(
|
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Format a count with the appropriate noun phrase.
|
|
237
|
+
* @param {number} n How many.
|
|
238
|
+
* @param {string} one Singular noun phrase.
|
|
239
|
+
* @param {string} many Plural noun phrase.
|
|
240
|
+
* @returns {string} A counted phrase such as "1 tool" or "3 tools".
|
|
241
|
+
*/
|
|
242
|
+
function count(n, one, many) {
|
|
243
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
235
246
|
/**
|
|
236
247
|
* Render a verdict for whoever has to act on it.
|
|
248
|
+
*
|
|
249
|
+
* Put blockers first and scope the stop instruction to that section. The
|
|
250
|
+
* separate installable section must not send usable work to the blocked lane.
|
|
237
251
|
* @param {object} result A {@link preflightTools} result.
|
|
238
252
|
* @returns {string} Operator-readable report, empty when nothing needs saying.
|
|
239
253
|
*/
|
|
@@ -243,42 +257,53 @@ export function reportTools(result) {
|
|
|
243
257
|
// The header tracks the exit code. Only a blocked tool is a failure — one
|
|
244
258
|
// Lisa can install is an action with a command attached, and calling that
|
|
245
259
|
// "FAILED" while exiting zero teaches readers that the word means nothing.
|
|
260
|
+
const blocking = count(result.blocked.length, "tool blocks", "tools block");
|
|
261
|
+
const spare = result.installable.length
|
|
262
|
+
? ` ${result.installable.length} more Lisa can install for you.`
|
|
263
|
+
: ``;
|
|
246
264
|
const header = () => {
|
|
247
|
-
if (result.blocked.length)
|
|
265
|
+
if (result.blocked.length)
|
|
266
|
+
return `Tooling preflight FAILED — ${blocking}.${spare}`;
|
|
248
267
|
if (result.installable.length)
|
|
249
|
-
return
|
|
268
|
+
return (
|
|
269
|
+
`Tooling preflight — action available. Nothing blocks;` +
|
|
270
|
+
` ${count(result.installable.length, "tool", "tools")} Lisa can` +
|
|
271
|
+
` install for you.`
|
|
272
|
+
);
|
|
250
273
|
// Nothing to do and nothing to fix; the note below is the whole message.
|
|
251
|
-
return
|
|
274
|
+
return `Tooling preflight passed, with a note.`;
|
|
252
275
|
};
|
|
253
276
|
const lines = [header()];
|
|
254
|
-
if (result.installable.length) {
|
|
255
|
-
lines.push(
|
|
256
|
-
``,
|
|
257
|
-
`Lisa can install these itself — they are pinned and checksummed.`,
|
|
258
|
-
`Run /lisa:setup:local-env to place them:`,
|
|
259
|
-
``
|
|
260
|
-
);
|
|
261
|
-
for (const step of result.installable) {
|
|
262
|
-
lines.push(` ${step.name} — ${step.reason}`);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
277
|
if (result.blocked.length) {
|
|
266
278
|
lines.push(
|
|
267
279
|
``,
|
|
268
|
-
`
|
|
280
|
+
`STOP — these block the work. Lisa has no pinned artifact it can place,`,
|
|
281
|
+
`so it cannot fix them for you:`,
|
|
269
282
|
``
|
|
270
283
|
);
|
|
271
284
|
for (const step of result.blocked) {
|
|
272
285
|
const why = result.reasons[step.name];
|
|
273
|
-
lines.push(` ${step.name} — ${step.reason}`);
|
|
274
|
-
if (why) lines.push(`
|
|
286
|
+
lines.push(` [BLOCKED] ${step.name} — ${step.reason}`);
|
|
287
|
+
if (why) lines.push(` required because ${why}`);
|
|
275
288
|
}
|
|
276
289
|
lines.push(
|
|
277
290
|
``,
|
|
278
|
-
`Work needing one of
|
|
279
|
-
`blocked
|
|
291
|
+
` Work needing one of the tools above cannot be completed. Route that`,
|
|
292
|
+
` item to blocked citing that tool, rather than claiming it and`,
|
|
293
|
+
` stopping partway. Nothing else in this report is a reason to stop.`
|
|
280
294
|
);
|
|
281
295
|
}
|
|
296
|
+
if (result.installable.length) {
|
|
297
|
+
lines.push(
|
|
298
|
+
``,
|
|
299
|
+
`NOT A BLOCKER — Lisa can install these itself; they are pinned and`,
|
|
300
|
+
`checksummed. Run /lisa:setup:local-env to place them:`,
|
|
301
|
+
``
|
|
302
|
+
);
|
|
303
|
+
for (const step of result.installable) {
|
|
304
|
+
lines.push(` [INSTALLABLE] ${step.name} — ${step.reason}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
282
307
|
if (result.unverified.length) {
|
|
283
308
|
lines.push(
|
|
284
309
|
``,
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
# not JSON). Malformed/empty stdin → allow (fail open, never crash the tool).
|
|
29
29
|
#
|
|
30
30
|
# Capability names below are the canonical guard's, kept identical on every agent.
|
|
31
|
-
# lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
|
|
31
|
+
# lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach, eval-payload
|
|
32
32
|
set -uo pipefail
|
|
33
33
|
|
|
34
34
|
allow() {
|
|
@@ -551,6 +551,15 @@ def shell_starts_command(tokens, index):
|
|
|
551
551
|
return status == "ok" and not remainder
|
|
552
552
|
|
|
553
553
|
|
|
554
|
+
def printed_argument(tokens, index):
|
|
555
|
+
"""Whether a token is data passed to an ordinary echo or printf command."""
|
|
556
|
+
start = index - 1
|
|
557
|
+
while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
|
|
558
|
+
start -= 1
|
|
559
|
+
program, _, opaque = command_word(tokens[start + 1:index])
|
|
560
|
+
return not opaque and program in {"echo", "printf"}
|
|
561
|
+
|
|
562
|
+
|
|
554
563
|
def env_uses_split_string(tokens, index):
|
|
555
564
|
"""Whether a command-position env invocation uses split-string parsing.
|
|
556
565
|
|
|
@@ -1148,6 +1157,8 @@ def git_skips_verification(text, depth=0):
|
|
|
1148
1157
|
# (`HUSKY=1 git commit -n`) simply sits in an earlier token.
|
|
1149
1158
|
if token != "git" and not token.endswith("/git"):
|
|
1150
1159
|
continue
|
|
1160
|
+
if printed_argument(scoped_tokens, index):
|
|
1161
|
+
continue
|
|
1151
1162
|
# The LONG flag, scoped to this invocation's argv rather than matched
|
|
1152
1163
|
# anywhere on the line. This replaces the unscoped token match that used
|
|
1153
1164
|
# to live in the flat loop below.
|
|
@@ -1175,8 +1186,8 @@ def git_skips_verification(text, depth=0):
|
|
|
1175
1186
|
def token_bypass(tokens):
|
|
1176
1187
|
"""Whether any token disables hooks by environment or by git config.
|
|
1177
1188
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1189
|
+
Printed echo/printf arguments are filtered before this scan. Assignments
|
|
1190
|
+
otherwise stay unscoped, unlike the `--no-verify` argv check. What matters is
|
|
1180
1191
|
where it appears: `HUSKY=0` and `core.hooksPath=` are assignments that
|
|
1181
1192
|
configure whatever git runs next, so they legitimately sit before the
|
|
1182
1193
|
invocation and often on a different line of a script. `--no-verify` is an
|
|
@@ -1286,11 +1297,47 @@ def flat_tokens(text):
|
|
|
1286
1297
|
The token list, or None.
|
|
1287
1298
|
"""
|
|
1288
1299
|
try:
|
|
1289
|
-
|
|
1300
|
+
tokens = shell_tokens(text)
|
|
1301
|
+
return [
|
|
1302
|
+
token.strip("();|&")
|
|
1303
|
+
for index, token in enumerate(tokens)
|
|
1304
|
+
if not printed_argument(tokens, index)
|
|
1305
|
+
]
|
|
1290
1306
|
except ValueError:
|
|
1291
1307
|
return None
|
|
1292
1308
|
|
|
1293
1309
|
|
|
1310
|
+
def eval_payloads(scoped_tokens):
|
|
1311
|
+
"""Command strings an `eval` in command position would execute.
|
|
1312
|
+
|
|
1313
|
+
`eval` takes no options: it concatenates its arguments and runs the result,
|
|
1314
|
+
so the payload is every token up to the next separator, joined by a space.
|
|
1315
|
+
Quoting is already gone by the time `shell_tokens` hands them over, which is
|
|
1316
|
+
exactly why the payload is invisible to every other scan here — the argv
|
|
1317
|
+
scan sees one opaque token and the git scan sees no `git` token at all.
|
|
1318
|
+
|
|
1319
|
+
Args:
|
|
1320
|
+
scoped_tokens: Tokens of one command line.
|
|
1321
|
+
|
|
1322
|
+
Returns:
|
|
1323
|
+
A list of payload strings, one per `eval` found in command position.
|
|
1324
|
+
"""
|
|
1325
|
+
payloads = []
|
|
1326
|
+
for index, token in enumerate(scoped_tokens):
|
|
1327
|
+
if token.rsplit("/", 1)[-1] != "eval":
|
|
1328
|
+
continue
|
|
1329
|
+
if not shell_starts_command(scoped_tokens, index):
|
|
1330
|
+
continue
|
|
1331
|
+
collected = []
|
|
1332
|
+
for follow in scoped_tokens[index + 1 :]:
|
|
1333
|
+
if follow in COMMAND_SEPARATORS:
|
|
1334
|
+
break
|
|
1335
|
+
collected.append(follow)
|
|
1336
|
+
if collected:
|
|
1337
|
+
payloads.append(" ".join(collected))
|
|
1338
|
+
return payloads
|
|
1339
|
+
|
|
1340
|
+
|
|
1294
1341
|
def verdict(text, depth=0, followed=None):
|
|
1295
1342
|
"""Whether this text, or a script it runs, bypasses verification.
|
|
1296
1343
|
|
|
@@ -1304,6 +1351,48 @@ def verdict(text, depth=0, followed=None):
|
|
|
1304
1351
|
"""
|
|
1305
1352
|
if git_skips_verification(text):
|
|
1306
1353
|
return True
|
|
1354
|
+
# RECURSION, NOT A TWELFTH SPELLING (#3531). `eval` is the builtin that takes
|
|
1355
|
+
# a command string, and it was absent from both recursion sets: it is not a
|
|
1356
|
+
# SHELL_PROGRAM (no `-c` payload) and, unlike `builtin`, not a prefix wrapper
|
|
1357
|
+
# whose argv carries through. So every vector this guard blocks was restored
|
|
1358
|
+
# by writing it inside `eval "..."` — measured on origin/main, nine of them,
|
|
1359
|
+
# including `git push --no-verify`, `core.hooksPath`, `HUSKY=0`, one behind a
|
|
1360
|
+
# separator and one behind a `command` wrapper.
|
|
1361
|
+
#
|
|
1362
|
+
# Enumerating spelling twelve is how spelling thirteen appears, and this
|
|
1363
|
+
# repository has twice declined an arms race of that shape. Recursing through
|
|
1364
|
+
# `verdict` instead gives the payload the IDENTICAL analysis, so anything
|
|
1365
|
+
# covered outside is covered inside — including checks the reporter's own
|
|
1366
|
+
# suggested patch would have missed, because it recursed into the git half
|
|
1367
|
+
# only and `HUSKY=0` and `core.hooksPath` live in `token_bypass`.
|
|
1368
|
+
#
|
|
1369
|
+
# Bounded by the same budget as a followed script: unbounded recursion on
|
|
1370
|
+
# adversarial input is itself a denial of the guard, and a Python traceback
|
|
1371
|
+
# exits non-zero, which the wrapper turns into a spurious block.
|
|
1372
|
+
if depth < FOLLOW_MAX_DEPTH:
|
|
1373
|
+
try:
|
|
1374
|
+
eval_scoped = shell_tokens(text)
|
|
1375
|
+
except ValueError:
|
|
1376
|
+
eval_scoped = []
|
|
1377
|
+
for payload in eval_payloads(eval_scoped):
|
|
1378
|
+
if verdict(payload, depth + 1, followed):
|
|
1379
|
+
return True
|
|
1380
|
+
# AND INSIDE A NESTED SHELL. `git_skips_verification` already recurses
|
|
1381
|
+
# into a `bash -c` payload, but only into ITSELF — so it carries the git
|
|
1382
|
+
# half down and leaves `eval`, `HUSKY=0` and `core.hooksPath` behind.
|
|
1383
|
+
# `bash -c 'eval "git commit --no-verify -m x"'` survived round one for
|
|
1384
|
+
# exactly that reason, and it was found by running the adversarial round
|
|
1385
|
+
# rather than by reasoning about it. Recursing the payload through
|
|
1386
|
+
# `verdict` gives every level the whole analysis.
|
|
1387
|
+
for index, token in enumerate(eval_scoped):
|
|
1388
|
+
if token.rsplit("/", 1)[-1] not in SHELL_PROGRAMS:
|
|
1389
|
+
continue
|
|
1390
|
+
if not shell_starts_command(eval_scoped, index):
|
|
1391
|
+
continue
|
|
1392
|
+
nested = nested_shell_payload(eval_scoped, index)
|
|
1393
|
+
if isinstance(nested, str) and verdict(nested, depth + 1, followed):
|
|
1394
|
+
return True
|
|
1395
|
+
|
|
1307
1396
|
tokens = flat_tokens(text)
|
|
1308
1397
|
if tokens is not None and token_bypass(tokens):
|
|
1309
1398
|
return True
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
# Add a name here in the same commit that closes a vector. A hardening that
|
|
44
44
|
# forgets to is invisible to refresh, and shows up as an unexplained diff at
|
|
45
45
|
# review time instead of a named capability.
|
|
46
|
-
# lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
|
|
46
|
+
# lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach, eval-payload
|
|
47
47
|
#
|
|
48
48
|
# Shell-token matching avoids false positives from issue bodies, heredocs, and
|
|
49
49
|
# commit-message prose while still catching quoted real argv values such as
|
|
@@ -652,6 +652,15 @@ def shell_starts_command(tokens, index):
|
|
|
652
652
|
return status == "ok" and not remainder
|
|
653
653
|
|
|
654
654
|
|
|
655
|
+
def printed_argument(tokens, index):
|
|
656
|
+
"""Whether a token is data passed to an ordinary echo or printf command."""
|
|
657
|
+
start = index - 1
|
|
658
|
+
while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
|
|
659
|
+
start -= 1
|
|
660
|
+
program, _, opaque = command_word(tokens[start + 1:index])
|
|
661
|
+
return not opaque and program in {"echo", "printf"}
|
|
662
|
+
|
|
663
|
+
|
|
655
664
|
def env_uses_split_string(tokens, index):
|
|
656
665
|
"""Whether a command-position env invocation uses split-string parsing.
|
|
657
666
|
|
|
@@ -1249,6 +1258,8 @@ def git_skips_verification(text, depth=0):
|
|
|
1249
1258
|
# (`HUSKY=1 git commit -n`) simply sits in an earlier token.
|
|
1250
1259
|
if token != "git" and not token.endswith("/git"):
|
|
1251
1260
|
continue
|
|
1261
|
+
if printed_argument(scoped_tokens, index):
|
|
1262
|
+
continue
|
|
1252
1263
|
# The LONG flag, scoped to this invocation's argv rather than matched
|
|
1253
1264
|
# anywhere on the line. This replaces the unscoped token match that used
|
|
1254
1265
|
# to live in the flat loop below.
|
|
@@ -1276,8 +1287,8 @@ def git_skips_verification(text, depth=0):
|
|
|
1276
1287
|
def token_bypass(tokens):
|
|
1277
1288
|
"""Whether any token disables hooks by environment or by git config.
|
|
1278
1289
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1290
|
+
Printed echo/printf arguments are filtered before this scan. Assignments
|
|
1291
|
+
otherwise stay unscoped, unlike the `--no-verify` argv check. What matters is
|
|
1281
1292
|
where it appears: `HUSKY=0` and `core.hooksPath=` are assignments that
|
|
1282
1293
|
configure whatever git runs next, so they legitimately sit before the
|
|
1283
1294
|
invocation and often on a different line of a script. `--no-verify` is an
|
|
@@ -1387,11 +1398,47 @@ def flat_tokens(text):
|
|
|
1387
1398
|
The token list, or None.
|
|
1388
1399
|
"""
|
|
1389
1400
|
try:
|
|
1390
|
-
|
|
1401
|
+
tokens = shell_tokens(text)
|
|
1402
|
+
return [
|
|
1403
|
+
token.strip("();|&")
|
|
1404
|
+
for index, token in enumerate(tokens)
|
|
1405
|
+
if not printed_argument(tokens, index)
|
|
1406
|
+
]
|
|
1391
1407
|
except ValueError:
|
|
1392
1408
|
return None
|
|
1393
1409
|
|
|
1394
1410
|
|
|
1411
|
+
def eval_payloads(scoped_tokens):
|
|
1412
|
+
"""Command strings an `eval` in command position would execute.
|
|
1413
|
+
|
|
1414
|
+
`eval` takes no options: it concatenates its arguments and runs the result,
|
|
1415
|
+
so the payload is every token up to the next separator, joined by a space.
|
|
1416
|
+
Quoting is already gone by the time `shell_tokens` hands them over, which is
|
|
1417
|
+
exactly why the payload is invisible to every other scan here — the argv
|
|
1418
|
+
scan sees one opaque token and the git scan sees no `git` token at all.
|
|
1419
|
+
|
|
1420
|
+
Args:
|
|
1421
|
+
scoped_tokens: Tokens of one command line.
|
|
1422
|
+
|
|
1423
|
+
Returns:
|
|
1424
|
+
A list of payload strings, one per `eval` found in command position.
|
|
1425
|
+
"""
|
|
1426
|
+
payloads = []
|
|
1427
|
+
for index, token in enumerate(scoped_tokens):
|
|
1428
|
+
if token.rsplit("/", 1)[-1] != "eval":
|
|
1429
|
+
continue
|
|
1430
|
+
if not shell_starts_command(scoped_tokens, index):
|
|
1431
|
+
continue
|
|
1432
|
+
collected = []
|
|
1433
|
+
for follow in scoped_tokens[index + 1 :]:
|
|
1434
|
+
if follow in COMMAND_SEPARATORS:
|
|
1435
|
+
break
|
|
1436
|
+
collected.append(follow)
|
|
1437
|
+
if collected:
|
|
1438
|
+
payloads.append(" ".join(collected))
|
|
1439
|
+
return payloads
|
|
1440
|
+
|
|
1441
|
+
|
|
1395
1442
|
def verdict(text, depth=0, followed=None):
|
|
1396
1443
|
"""Whether this text, or a script it runs, bypasses verification.
|
|
1397
1444
|
|
|
@@ -1405,6 +1452,48 @@ def verdict(text, depth=0, followed=None):
|
|
|
1405
1452
|
"""
|
|
1406
1453
|
if git_skips_verification(text):
|
|
1407
1454
|
return True
|
|
1455
|
+
# RECURSION, NOT A TWELFTH SPELLING (#3531). `eval` is the builtin that takes
|
|
1456
|
+
# a command string, and it was absent from both recursion sets: it is not a
|
|
1457
|
+
# SHELL_PROGRAM (no `-c` payload) and, unlike `builtin`, not a prefix wrapper
|
|
1458
|
+
# whose argv carries through. So every vector this guard blocks was restored
|
|
1459
|
+
# by writing it inside `eval "..."` — measured on origin/main, nine of them,
|
|
1460
|
+
# including `git push --no-verify`, `core.hooksPath`, `HUSKY=0`, one behind a
|
|
1461
|
+
# separator and one behind a `command` wrapper.
|
|
1462
|
+
#
|
|
1463
|
+
# Enumerating spelling twelve is how spelling thirteen appears, and this
|
|
1464
|
+
# repository has twice declined an arms race of that shape. Recursing through
|
|
1465
|
+
# `verdict` instead gives the payload the IDENTICAL analysis, so anything
|
|
1466
|
+
# covered outside is covered inside — including checks the reporter's own
|
|
1467
|
+
# suggested patch would have missed, because it recursed into the git half
|
|
1468
|
+
# only and `HUSKY=0` and `core.hooksPath` live in `token_bypass`.
|
|
1469
|
+
#
|
|
1470
|
+
# Bounded by the same budget as a followed script: unbounded recursion on
|
|
1471
|
+
# adversarial input is itself a denial of the guard, and a Python traceback
|
|
1472
|
+
# exits non-zero, which the wrapper turns into a spurious block.
|
|
1473
|
+
if depth < FOLLOW_MAX_DEPTH:
|
|
1474
|
+
try:
|
|
1475
|
+
eval_scoped = shell_tokens(text)
|
|
1476
|
+
except ValueError:
|
|
1477
|
+
eval_scoped = []
|
|
1478
|
+
for payload in eval_payloads(eval_scoped):
|
|
1479
|
+
if verdict(payload, depth + 1, followed):
|
|
1480
|
+
return True
|
|
1481
|
+
# AND INSIDE A NESTED SHELL. `git_skips_verification` already recurses
|
|
1482
|
+
# into a `bash -c` payload, but only into ITSELF — so it carries the git
|
|
1483
|
+
# half down and leaves `eval`, `HUSKY=0` and `core.hooksPath` behind.
|
|
1484
|
+
# `bash -c 'eval "git commit --no-verify -m x"'` survived round one for
|
|
1485
|
+
# exactly that reason, and it was found by running the adversarial round
|
|
1486
|
+
# rather than by reasoning about it. Recursing the payload through
|
|
1487
|
+
# `verdict` gives every level the whole analysis.
|
|
1488
|
+
for index, token in enumerate(eval_scoped):
|
|
1489
|
+
if token.rsplit("/", 1)[-1] not in SHELL_PROGRAMS:
|
|
1490
|
+
continue
|
|
1491
|
+
if not shell_starts_command(eval_scoped, index):
|
|
1492
|
+
continue
|
|
1493
|
+
nested = nested_shell_payload(eval_scoped, index)
|
|
1494
|
+
if isinstance(nested, str) and verdict(nested, depth + 1, followed):
|
|
1495
|
+
return True
|
|
1496
|
+
|
|
1408
1497
|
tokens = flat_tokens(text)
|
|
1409
1498
|
if tokens is not None and token_bypass(tokens):
|
|
1410
1499
|
return True
|
|
@@ -232,8 +232,22 @@ export function preflightTools(
|
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Format a count with the appropriate noun phrase.
|
|
237
|
+
* @param {number} n How many.
|
|
238
|
+
* @param {string} one Singular noun phrase.
|
|
239
|
+
* @param {string} many Plural noun phrase.
|
|
240
|
+
* @returns {string} A counted phrase such as "1 tool" or "3 tools".
|
|
241
|
+
*/
|
|
242
|
+
function count(n, one, many) {
|
|
243
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
235
246
|
/**
|
|
236
247
|
* Render a verdict for whoever has to act on it.
|
|
248
|
+
*
|
|
249
|
+
* Put blockers first and scope the stop instruction to that section. The
|
|
250
|
+
* separate installable section must not send usable work to the blocked lane.
|
|
237
251
|
* @param {object} result A {@link preflightTools} result.
|
|
238
252
|
* @returns {string} Operator-readable report, empty when nothing needs saying.
|
|
239
253
|
*/
|
|
@@ -243,42 +257,53 @@ export function reportTools(result) {
|
|
|
243
257
|
// The header tracks the exit code. Only a blocked tool is a failure — one
|
|
244
258
|
// Lisa can install is an action with a command attached, and calling that
|
|
245
259
|
// "FAILED" while exiting zero teaches readers that the word means nothing.
|
|
260
|
+
const blocking = count(result.blocked.length, "tool blocks", "tools block");
|
|
261
|
+
const spare = result.installable.length
|
|
262
|
+
? ` ${result.installable.length} more Lisa can install for you.`
|
|
263
|
+
: ``;
|
|
246
264
|
const header = () => {
|
|
247
|
-
if (result.blocked.length)
|
|
265
|
+
if (result.blocked.length)
|
|
266
|
+
return `Tooling preflight FAILED — ${blocking}.${spare}`;
|
|
248
267
|
if (result.installable.length)
|
|
249
|
-
return
|
|
268
|
+
return (
|
|
269
|
+
`Tooling preflight — action available. Nothing blocks;` +
|
|
270
|
+
` ${count(result.installable.length, "tool", "tools")} Lisa can` +
|
|
271
|
+
` install for you.`
|
|
272
|
+
);
|
|
250
273
|
// Nothing to do and nothing to fix; the note below is the whole message.
|
|
251
|
-
return
|
|
274
|
+
return `Tooling preflight passed, with a note.`;
|
|
252
275
|
};
|
|
253
276
|
const lines = [header()];
|
|
254
|
-
if (result.installable.length) {
|
|
255
|
-
lines.push(
|
|
256
|
-
``,
|
|
257
|
-
`Lisa can install these itself — they are pinned and checksummed.`,
|
|
258
|
-
`Run /lisa:setup:local-env to place them:`,
|
|
259
|
-
``
|
|
260
|
-
);
|
|
261
|
-
for (const step of result.installable) {
|
|
262
|
-
lines.push(` ${step.name} — ${step.reason}`);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
277
|
if (result.blocked.length) {
|
|
266
278
|
lines.push(
|
|
267
279
|
``,
|
|
268
|
-
`
|
|
280
|
+
`STOP — these block the work. Lisa has no pinned artifact it can place,`,
|
|
281
|
+
`so it cannot fix them for you:`,
|
|
269
282
|
``
|
|
270
283
|
);
|
|
271
284
|
for (const step of result.blocked) {
|
|
272
285
|
const why = result.reasons[step.name];
|
|
273
|
-
lines.push(` ${step.name} — ${step.reason}`);
|
|
274
|
-
if (why) lines.push(`
|
|
286
|
+
lines.push(` [BLOCKED] ${step.name} — ${step.reason}`);
|
|
287
|
+
if (why) lines.push(` required because ${why}`);
|
|
275
288
|
}
|
|
276
289
|
lines.push(
|
|
277
290
|
``,
|
|
278
|
-
`Work needing one of
|
|
279
|
-
`blocked
|
|
291
|
+
` Work needing one of the tools above cannot be completed. Route that`,
|
|
292
|
+
` item to blocked citing that tool, rather than claiming it and`,
|
|
293
|
+
` stopping partway. Nothing else in this report is a reason to stop.`
|
|
280
294
|
);
|
|
281
295
|
}
|
|
296
|
+
if (result.installable.length) {
|
|
297
|
+
lines.push(
|
|
298
|
+
``,
|
|
299
|
+
`NOT A BLOCKER — Lisa can install these itself; they are pinned and`,
|
|
300
|
+
`checksummed. Run /lisa:setup:local-env to place them:`,
|
|
301
|
+
``
|
|
302
|
+
);
|
|
303
|
+
for (const step of result.installable) {
|
|
304
|
+
lines.push(` [INSTALLABLE] ${step.name} — ${step.reason}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
282
307
|
if (result.unverified.length) {
|
|
283
308
|
lines.push(
|
|
284
309
|
``,
|