@codyswann/lisa 4.32.1 → 4.32.3
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/parity-safety-net.sh +673 -2
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +8 -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 +3 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +673 -2
- 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/hooks/parity-safety-net.sh +673 -2
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +673 -2
- package/plugins/lisa-agy/plugin.json +1 -1
- 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/parity-safety-net.sh +673 -2
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +673 -2
- 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/parity-safety-net.sh +673 -2
|
@@ -266,8 +266,11 @@ Every commit must also carry a Co-authored-by trailer for a supported agent
|
|
|
266
266
|
(Claude/Codex/OpenCode) — the commit-msg hook enforces this."
|
|
267
267
|
fi
|
|
268
268
|
block "$1" "Heredoc payloads are blocked here (the payload is executable shell).
|
|
269
|
-
Fix: write the payload to a file with the Write tool, then
|
|
270
|
-
|
|
269
|
+
Fix: write the payload to a file with the Write tool, then run the file
|
|
270
|
+
(\`bash <file>\`). These guards READ the shell script an invocation runs, so a
|
|
271
|
+
destructive command inside that file is refused exactly as the inline form is:
|
|
272
|
+
the file is a way to get the payload out of a heredoc, never a way past the
|
|
273
|
+
guards."
|
|
271
274
|
}
|
|
272
275
|
|
|
273
276
|
command_for_guards="$command_str"
|
|
@@ -298,6 +301,674 @@ case "$command_str" in
|
|
|
298
301
|
;;
|
|
299
302
|
esac
|
|
300
303
|
|
|
304
|
+
# ─── Follow execution into the file a command RUNS (issue #3612) ─────────────
|
|
305
|
+
#
|
|
306
|
+
# Every guard below classifies SHELL COMMAND TEXT, and until this block existed
|
|
307
|
+
# the only text they ever saw was the command string. A destructive line
|
|
308
|
+
# written into a file and then executed reached them as an interpreter name and
|
|
309
|
+
# a path, and was ALLOWED — silently, with no output at all — while the same
|
|
310
|
+
# line typed inline was refused. Nine spellings were measured against 4.31.1 and
|
|
311
|
+
# all nine allowed: `bash <file>`, `sh <file>`, `zsh <file>`, `env bash <file>`,
|
|
312
|
+
# `source <file>`, `. <file>`, a shebang script invoked directly,
|
|
313
|
+
# `bash -c 'bash <file>'`, and `eval "$(cat <file>)"`. Two more found while
|
|
314
|
+
# fixing it — `bash < <file>` and `cat <file> | bash` — allowed as well.
|
|
315
|
+
#
|
|
316
|
+
# Three more found by review before this shipped, all one shape: a WRAPPER'S OWN
|
|
317
|
+
# OPERAND closing the command position before the program is reached, so
|
|
318
|
+
# `nice -n 5 bash <file>`, `sudo -u nobody bash <file>` and `timeout 5 bash
|
|
319
|
+
# <file>` walked past the invocation. See the wrapper-prefix arms below.
|
|
320
|
+
#
|
|
321
|
+
# A guard that fails CLOSED spends time; this one failed OPEN on destructive
|
|
322
|
+
# actions and reported nothing, so an operator who installed it to gate `rm -rf`
|
|
323
|
+
# outside the project got no signal that the check had not run.
|
|
324
|
+
#
|
|
325
|
+
# WHY IT IS THE DEFAULT PATH rather than an edge case: block_heredoc() above
|
|
326
|
+
# refuses heredocs with an instruction to write the payload to a file and
|
|
327
|
+
# execute that file. The tooling therefore routed agents onto exactly the shape
|
|
328
|
+
# the destructive scan could not see. Nobody had to evade anything. That remedy
|
|
329
|
+
# is reworded above, and this block is the half that makes the reworded remedy
|
|
330
|
+
# true.
|
|
331
|
+
#
|
|
332
|
+
# THE RULE: the blind spot is precisely FILE CONTENTS. Every other part of a
|
|
333
|
+
# command — including the inner command of a substitution — is text these guards
|
|
334
|
+
# already read. So resolve the file an invocation actually RUNS, read it under a
|
|
335
|
+
# size cap, and hand it to the guards as further command text.
|
|
336
|
+
#
|
|
337
|
+
# WHAT THIS DELIBERATELY DOES NOT DO — the known-wrong fix, already made once in
|
|
338
|
+
# this family. #3484 was fixed by teaching a sibling guard to read the contents
|
|
339
|
+
# of any file a command NAMES, and that produced #3604: the guard now opens any
|
|
340
|
+
# readable file named by any argument and attributes its contents to the
|
|
341
|
+
# command, including a path quoted as prose inside a fenced code block in a
|
|
342
|
+
# `--body-file` markdown. A path is followed here ONLY when the command EXECUTES
|
|
343
|
+
# it. `grep -n x <file>`, `gh pr create --body-file <file>` and
|
|
344
|
+
# `git commit -F <file>` name a file as DATA — and $DESTRUCTIVE_GUIDANCE above
|
|
345
|
+
# tells operators to use exactly those forms when the destructive text is a
|
|
346
|
+
# deliverable, so reading them would break the one remedy that works.
|
|
347
|
+
#
|
|
348
|
+
# FAIL CLOSED ON WHAT IT CANNOT FOLLOW. At the top level a computed target
|
|
349
|
+
# (`bash "$SCRIPT"`), a target that does not exist or cannot be read, a file
|
|
350
|
+
# past the size cap, and an operand that is not a path at all (`find … -exec
|
|
351
|
+
# bash {} \;`) are each REFUSED with a reason. Silence on an unclassifiable
|
|
352
|
+
# destructive command is the bug being fixed, and a truncated scan would report
|
|
353
|
+
# a confident ALLOW about text it never read.
|
|
354
|
+
#
|
|
355
|
+
# NOT FOLLOWED, deliberately. Each one is text these guards were never able to
|
|
356
|
+
# classify in the first place, so failing closed on it would spend the operator
|
|
357
|
+
# a refusal it cannot buy anything with:
|
|
358
|
+
# - Non-shell interpreters (`python3 <file>`, `node <file>`). These patterns
|
|
359
|
+
# are shell syntax; running them over a Python or JS file buys
|
|
360
|
+
# mis-attribution, not coverage. The heredoc remedy no longer names one.
|
|
361
|
+
# - A payload piped from a program's OUTPUT (`curl … | bash`). There is no
|
|
362
|
+
# file to read, and the producing command's own text is already scanned.
|
|
363
|
+
# - An indirection nested inside an ALREADY-FOLLOWED file whose target is
|
|
364
|
+
# computed — `source "$(dirname "$0")/lib.sh"` is the universal shell idiom,
|
|
365
|
+
# and failing closed on it would refuse most real scripts. Top-level
|
|
366
|
+
# indirection is authored by the agent in the command it just wrote, which
|
|
367
|
+
# is where a refusal is both actionable and rare.
|
|
368
|
+
# Each is a fail-open the header's ACCEPTED-BYPASS reasoning already covers: the
|
|
369
|
+
# net catches ACCIDENTAL catastrophic commands.
|
|
370
|
+
readonly FOLLOW_MAX_BYTES=262144
|
|
371
|
+
readonly FOLLOW_MAX_TOTAL_BYTES=524288
|
|
372
|
+
readonly FOLLOW_MAX_FILES=8
|
|
373
|
+
readonly FOLLOW_MAX_DEPTH=3
|
|
374
|
+
|
|
375
|
+
follow_bases=""
|
|
376
|
+
follow_paths=$'\n'
|
|
377
|
+
follow_names=""
|
|
378
|
+
follow_count=0
|
|
379
|
+
follow_bytes=0
|
|
380
|
+
followed_text=""
|
|
381
|
+
|
|
382
|
+
FOLLOW_GUIDANCE="This command EXECUTES a file. These guards classify shell text, so the script an
|
|
383
|
+
invocation runs is read and scanned as part of the command — a destructive line
|
|
384
|
+
inside the file is refused exactly as the inline form is. This one could not be
|
|
385
|
+
read, so the guard denied rather than passed: a destructive command it cannot
|
|
386
|
+
see is the thing it exists to stop.
|
|
387
|
+
|
|
388
|
+
- Name the script by a LITERAL path that exists and is readable — \`bash
|
|
389
|
+
./scripts/x.sh\` rather than \`bash \"\$SCRIPT\"\` — and the guard can classify it.
|
|
390
|
+
- A file past the inspection cap is refused rather than half-scanned, because a
|
|
391
|
+
truncated scan reports a confident ALLOW about text it never read. Split it,
|
|
392
|
+
or run it manually outside the agent.
|
|
393
|
+
- You genuinely intend to run it as it stands. Ask the user to confirm, then run
|
|
394
|
+
it manually outside the agent."
|
|
395
|
+
|
|
396
|
+
# Deny, naming the file the guard could not classify. Deliberately NOT the
|
|
397
|
+
# ordinary destructive guidance: that guidance is about a pattern the COMMAND
|
|
398
|
+
# matched, and this is a claim that the guard could not look at all.
|
|
399
|
+
follow_refuse() {
|
|
400
|
+
block "cannot classify the file this command executes ($1): $2" "$FOLLOW_GUIDANCE"
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
# Result of the last follow_unwrap / follow_next_operand. These two SET a global
|
|
404
|
+
# rather than printing, and every caller reads it.
|
|
405
|
+
#
|
|
406
|
+
# Printing would be the natural shape, but `$(follow_unwrap …)` is a FORK, and
|
|
407
|
+
# the walk runs it once per token of every followed file. Measured: a script
|
|
408
|
+
# just under the 256 KiB inspection cap holds ~48k tokens and cost 27 SECONDS of
|
|
409
|
+
# wall clock — inside a PreToolUse hook that gates every Bash call. Both
|
|
410
|
+
# functions are pure parameter expansion, so the subshell bought nothing.
|
|
411
|
+
#
|
|
412
|
+
# The concurrent-process ceiling was never the risk: the forks are sequential,
|
|
413
|
+
# so peak process count never rises. The latency was the whole problem.
|
|
414
|
+
follow_unwrapped=""
|
|
415
|
+
follow_operand_index=0
|
|
416
|
+
|
|
417
|
+
# Strip substitution, quote and alias wrappers off one token. A sibling of
|
|
418
|
+
# strip_subst_wrappers() below rather than a call to it: that one is defined
|
|
419
|
+
# after this block runs, and it exists for a different job (normalizing a
|
|
420
|
+
# DELETION TARGET). Kept separate so tightening either cannot silently move the
|
|
421
|
+
# other's verdicts.
|
|
422
|
+
follow_unwrap() {
|
|
423
|
+
local t="$1" prev=""
|
|
424
|
+
while [ "$t" != "$prev" ]; do
|
|
425
|
+
prev="$t"
|
|
426
|
+
case "$t" in
|
|
427
|
+
'$(('*) break ;;
|
|
428
|
+
'\'*) t="${t#\\}" ;;
|
|
429
|
+
'$('*) t="${t#'$('}" ;;
|
|
430
|
+
'<('*) t="${t#'<('}" ;;
|
|
431
|
+
'>('*) t="${t#'>('}" ;;
|
|
432
|
+
'"'*) t="${t#'"'}" ;;
|
|
433
|
+
"'"*) t="${t#\'}" ;;
|
|
434
|
+
'`'*) t="${t#'`'}" ;;
|
|
435
|
+
'('*) t="${t#(}" ;;
|
|
436
|
+
esac
|
|
437
|
+
done
|
|
438
|
+
prev=""
|
|
439
|
+
while [ "$t" != "$prev" ]; do
|
|
440
|
+
prev="$t"
|
|
441
|
+
case "$t" in
|
|
442
|
+
*')') t="${t%)}" ;;
|
|
443
|
+
*'`') t="${t%'`'}" ;;
|
|
444
|
+
*'"') t="${t%'"'}" ;;
|
|
445
|
+
*"'") t="${t%\'}" ;;
|
|
446
|
+
*';') t="${t%;}" ;;
|
|
447
|
+
esac
|
|
448
|
+
done
|
|
449
|
+
follow_unwrapped="$t"
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
# A shell interpreter, bare or path-prefixed. Only shells: see NOT FOLLOWED.
|
|
453
|
+
follow_is_interpreter() {
|
|
454
|
+
case "$1" in
|
|
455
|
+
bash | sh | zsh | ksh | ksh93 | dash | ash \
|
|
456
|
+
| */bash | */sh | */zsh | */ksh | */ksh93 | */dash | */ash) return 0 ;;
|
|
457
|
+
esac
|
|
458
|
+
return 1
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
# Could this token itself be the thing a wrapper prefix goes on to invoke?
|
|
462
|
+
#
|
|
463
|
+
# Used ONLY to yield when stepping over a wrapper's own operand, so that
|
|
464
|
+
# step-over can never swallow the invocation. Deliberately generous, because the
|
|
465
|
+
# cost of the two answers is asymmetric: yielding wrongly leaves the walk where
|
|
466
|
+
# it already was, while consuming wrongly loses the program and the guard falls
|
|
467
|
+
# open — which is the entire defect this block exists to close. Anything
|
|
468
|
+
# path-shaped yields too, so `env -i <file>` still reaches the script rather
|
|
469
|
+
# than eating it as a value `-i` does not take.
|
|
470
|
+
follow_may_invoke() {
|
|
471
|
+
follow_is_interpreter "$1" && return 0
|
|
472
|
+
# shellcheck disable=SC2088
|
|
473
|
+
case "$1" in
|
|
474
|
+
"" | -* | [A-Za-z_]*=*) return 0 ;;
|
|
475
|
+
/* | ./* | ../* | '~/'* | */*) return 0 ;;
|
|
476
|
+
sudo | env | command | exec | nohup | nice | time | builtin | timeout \
|
|
477
|
+
| do | then | else | while | until | if | '!' | '{' \
|
|
478
|
+
| eval | find | xargs | cd | source | . | cat) return 0 ;;
|
|
479
|
+
esac
|
|
480
|
+
return 1
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
# Split the command into statements at UNQUOTED separators, printing one
|
|
484
|
+
# statement per line as "<separator><TAB><statement>". Quote-aware on purpose: a
|
|
485
|
+
# `;` or `|` inside a quoted string is not a statement boundary, and splitting
|
|
486
|
+
# there would invent a command position in the middle of a quoted argument and
|
|
487
|
+
# make prose look like an invocation — the #3604 failure mode, arrived at from
|
|
488
|
+
# the other side. Parens are NOT separators, so `eval "$(cat <file>)"` stays one
|
|
489
|
+
# statement and its command word is still `eval`.
|
|
490
|
+
#
|
|
491
|
+
# awk rather than a bash char loop because this runs on EVERY intercepted Bash
|
|
492
|
+
# command, and it addresses the text by index instead of accumulating it a
|
|
493
|
+
# character at a time so the cost stays linear on a followed file. The text
|
|
494
|
+
# arrives through the ENVIRONMENT: `awk -v` processes backslash escapes in the
|
|
495
|
+
# value it is given and would rewrite the command before it was ever read.
|
|
496
|
+
follow_split() {
|
|
497
|
+
SN_FOLLOW_TEXT="$1" awk '
|
|
498
|
+
BEGIN {
|
|
499
|
+
s = ENVIRON["SN_FOLLOW_TEXT"]; n = length(s);
|
|
500
|
+
q = ""; esc = 0; start = 1; sep = "o";
|
|
501
|
+
for (i = 1; i <= n; i++) {
|
|
502
|
+
c = substr(s, i, 1);
|
|
503
|
+
if (esc == 1) { esc = 0; continue }
|
|
504
|
+
if (c == "\\" && q != "\047") { esc = 1; continue }
|
|
505
|
+
if (q != "") { if (c == q) { q = "" } continue }
|
|
506
|
+
if (c == "\"" || c == "\047") { q = c; continue }
|
|
507
|
+
if (c == ";" || c == "&" || c == "|" || c == "\n") {
|
|
508
|
+
printf "%s\t%s\n", sep, substr(s, start, i - start);
|
|
509
|
+
start = i + 1;
|
|
510
|
+
if (c == "|") { sep = "|" } else { sep = "o" }
|
|
511
|
+
continue
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
printf "%s\t%s\n", sep, substr(s, start, n - start + 1);
|
|
515
|
+
}
|
|
516
|
+
'
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
# Record a literal `cd` target as an additional base for resolving a relative
|
|
520
|
+
# executed path, so `cd sub && ./run.sh` resolves the way the shell will.
|
|
521
|
+
follow_add_base() {
|
|
522
|
+
local d="$1"
|
|
523
|
+
case "$d" in
|
|
524
|
+
"" | -* | *'$'* | *'*'* | *'?'*) return 0 ;;
|
|
525
|
+
esac
|
|
526
|
+
[ -d "$d" ] || return 0
|
|
527
|
+
follow_bases="$follow_bases"$'\n'"$d"
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
# Resolve one executed-file operand to a readable regular file, or fail.
|
|
531
|
+
#
|
|
532
|
+
# The variable expansions are LOCATE-ONLY and cannot widen a verdict: a wrong
|
|
533
|
+
# guess simply fails to find a file, and an unresolved target is refused, not
|
|
534
|
+
# passed. They are here because an agent's own scratch path arrives spelled with
|
|
535
|
+
# one of them, and refusing every `bash "$TMPDIR/x.sh"` would be a wall with no
|
|
536
|
+
# door. Nothing else is expanded — any remaining `$`, glob or brace is a target
|
|
537
|
+
# this hook cannot evaluate.
|
|
538
|
+
follow_locate() {
|
|
539
|
+
local token="$1" home="${HOME:-}" tmp="${TMPDIR:-/tmp}" proj="${CLAUDE_PROJECT_DIR:-$PWD}" base
|
|
540
|
+
tmp="${tmp%/}"
|
|
541
|
+
[ -n "$tmp" ] || tmp="/tmp"
|
|
542
|
+
# The tilde arms below are LITERAL text: this is the unexpanded spelling the
|
|
543
|
+
# hook was handed, and turning it into a path is the whole point of the arm.
|
|
544
|
+
# shellcheck disable=SC2088
|
|
545
|
+
case "$token" in
|
|
546
|
+
'~') token="$home" ;;
|
|
547
|
+
'~/'*) token="$home/${token#'~/'}" ;;
|
|
548
|
+
'$TMPDIR' | '$TMPDIR'/*) token="$tmp${token#'$TMPDIR'}" ;;
|
|
549
|
+
'${TMPDIR}' | '${TMPDIR}'/*) token="$tmp${token#'${TMPDIR}'}" ;;
|
|
550
|
+
'$HOME' | '$HOME'/*) token="$home${token#'$HOME'}" ;;
|
|
551
|
+
'${HOME}' | '${HOME}'/*) token="$home${token#'${HOME}'}" ;;
|
|
552
|
+
'$PWD' | '$PWD'/*) token="$PWD${token#'$PWD'}" ;;
|
|
553
|
+
'${PWD}' | '${PWD}'/*) token="$PWD${token#'${PWD}'}" ;;
|
|
554
|
+
'$CLAUDE_PROJECT_DIR' | '$CLAUDE_PROJECT_DIR'/*) token="$proj${token#'$CLAUDE_PROJECT_DIR'}" ;;
|
|
555
|
+
'${CLAUDE_PROJECT_DIR}' | '${CLAUDE_PROJECT_DIR}'/*) token="$proj${token#'${CLAUDE_PROJECT_DIR}'}" ;;
|
|
556
|
+
esac
|
|
557
|
+
case "$token" in
|
|
558
|
+
"" | *'$'* | *'*'* | *'?'* | *'`'* | *'{'* | *'['*) return 1 ;;
|
|
559
|
+
/*)
|
|
560
|
+
[ -f "$token" ] && [ -r "$token" ] || return 1
|
|
561
|
+
printf '%s' "$token"
|
|
562
|
+
return 0
|
|
563
|
+
;;
|
|
564
|
+
esac
|
|
565
|
+
while IFS= read -r base; do
|
|
566
|
+
[ -n "$base" ] || continue
|
|
567
|
+
if [ -f "$base/$token" ] && [ -r "$base/$token" ]; then
|
|
568
|
+
printf '%s' "$base/$token"
|
|
569
|
+
return 0
|
|
570
|
+
fi
|
|
571
|
+
done <<<"$PWD"$'\n'"$proj$follow_bases"
|
|
572
|
+
return 1
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
# Read one executed file into the text the guards will scan, then follow what IT
|
|
576
|
+
# executes, to a bounded depth. Full-line comments are dropped: a commented line
|
|
577
|
+
# is never executed, so scanning it can only manufacture a false refusal.
|
|
578
|
+
follow_take() {
|
|
579
|
+
local path="$1" depth="$2" size="" content=""
|
|
580
|
+
case "$follow_paths" in
|
|
581
|
+
*$'\n'"$path"$'\n'*) return 0 ;;
|
|
582
|
+
esac
|
|
583
|
+
follow_paths="$follow_paths$path"$'\n'
|
|
584
|
+
follow_count=$((follow_count + 1))
|
|
585
|
+
if [ "$follow_count" -gt "$FOLLOW_MAX_FILES" ]; then
|
|
586
|
+
follow_refuse "$path" "this command executes more files than the guard will inspect (${FOLLOW_MAX_FILES})"
|
|
587
|
+
fi
|
|
588
|
+
size="$(wc -c <"$path" 2>/dev/null | tr -cd '0-9')" || size=""
|
|
589
|
+
case "$size" in
|
|
590
|
+
"") follow_refuse "$path" "its size could not be determined" ;;
|
|
591
|
+
esac
|
|
592
|
+
if [ "$size" -gt "$FOLLOW_MAX_BYTES" ]; then
|
|
593
|
+
follow_refuse "$path" "it is larger than the ${FOLLOW_MAX_BYTES}-byte inspection cap"
|
|
594
|
+
fi
|
|
595
|
+
follow_bytes=$((follow_bytes + size))
|
|
596
|
+
if [ "$follow_bytes" -gt "$FOLLOW_MAX_TOTAL_BYTES" ]; then
|
|
597
|
+
follow_refuse "$path" "the files this command executes total more than the ${FOLLOW_MAX_TOTAL_BYTES}-byte inspection budget"
|
|
598
|
+
fi
|
|
599
|
+
content="$(LC_ALL=C tr -d '\000' <"$path" | sed 's/^[[:space:]]*#.*$//')" \
|
|
600
|
+
|| follow_refuse "$path" "it could not be read"
|
|
601
|
+
follow_names="$follow_names $path"
|
|
602
|
+
followed_text="$followed_text"$'\n'"$content"
|
|
603
|
+
if [ "$depth" -lt "$FOLLOW_MAX_DEPTH" ]; then
|
|
604
|
+
follow_scan "$content" "$((depth + 1))"
|
|
605
|
+
fi
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
# An operand a command EXECUTES. Unresolvable at the top level is a refusal;
|
|
609
|
+
# nested inside an already-followed script it is the documented residual above.
|
|
610
|
+
follow_target() {
|
|
611
|
+
local token="$1" depth="$2" path=""
|
|
612
|
+
[ -n "$token" ] || return 0
|
|
613
|
+
if ! path="$(follow_locate "$token")"; then
|
|
614
|
+
if [ "$depth" -eq 0 ]; then
|
|
615
|
+
follow_refuse "$token" "no readable file could be resolved for it"
|
|
616
|
+
fi
|
|
617
|
+
return 0
|
|
618
|
+
fi
|
|
619
|
+
follow_take "$path" "$depth"
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
# A command word that IS the executable (`./deploy.sh`, `/tmp/x.sh`). Followed
|
|
623
|
+
# only when the file's shebang names a shell: a compiled binary or a Python/Node
|
|
624
|
+
# script is not text these patterns can classify, and `/usr/bin/git` sitting at
|
|
625
|
+
# a command position must stay an ordinary command.
|
|
626
|
+
#
|
|
627
|
+
# This arm alone does NOT fail closed on an unresolvable token, and the line is
|
|
628
|
+
# deliberate. Elsewhere the command TEXT declares that a file is being executed
|
|
629
|
+
# — `bash <file>` says so whether or not the file can be read — so "I cannot
|
|
630
|
+
# read it" is a real inability to classify a stated execution. Here the only
|
|
631
|
+
# evidence that the token is a script at all is the shebang inside it, so an
|
|
632
|
+
# unreadable token is not an unclassifiable execution; it is a command word this
|
|
633
|
+
# hook cannot see, and a shell that cannot see it cannot run it either. Refusing
|
|
634
|
+
# on shape alone would deny `/usr/bin/charm x` and `./scripts/confirm -rf /`,
|
|
635
|
+
# both of which the guard-parity matrix pins as ALLOW (#1960 PR-A2/PR-A3).
|
|
636
|
+
# Accepted residual: a shebang script whose relative path resolves only from a
|
|
637
|
+
# working directory this hook does not share is not followed.
|
|
638
|
+
follow_direct() {
|
|
639
|
+
local token="$1" depth="$2" path="" first=""
|
|
640
|
+
if ! path="$(follow_locate "$token")"; then
|
|
641
|
+
return 0
|
|
642
|
+
fi
|
|
643
|
+
first="$(LC_ALL=C head -c 256 -- "$path" 2>/dev/null | LC_ALL=C head -n 1 2>/dev/null | LC_ALL=C tr -dc '[:print:]' 2>/dev/null)" || first=""
|
|
644
|
+
case "$first" in
|
|
645
|
+
'#!'*[/[:space:]]bash* | '#!'*[/[:space:]]sh* | '#!'*[/[:space:]]zsh* \
|
|
646
|
+
| '#!'*[/[:space:]]ksh* | '#!'*[/[:space:]]dash* | '#!'*[/[:space:]]ash*) ;;
|
|
647
|
+
*) return 0 ;;
|
|
648
|
+
esac
|
|
649
|
+
follow_take "$path" "$depth"
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
# Index of the next non-option token after $1, or $2 when there is none.
|
|
653
|
+
follow_next_operand() {
|
|
654
|
+
local from="$1" upto="$2" j
|
|
655
|
+
j=$((from + 1))
|
|
656
|
+
while [ "$j" -lt "$upto" ]; do
|
|
657
|
+
follow_unwrap "${toks[j]}"
|
|
658
|
+
case "$follow_unwrapped" in
|
|
659
|
+
-*) j=$((j + 1)) ;;
|
|
660
|
+
*) break ;;
|
|
661
|
+
esac
|
|
662
|
+
done
|
|
663
|
+
follow_operand_index="$j"
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
# Walk one text for the files it executes. Runs in the PARENT shell, never in a
|
|
667
|
+
# command substitution: follow_refuse() must be able to deny, and an exit inside
|
|
668
|
+
# a subshell ends that subshell rather than this hook — the mistake documented
|
|
669
|
+
# on the rm segmentation below.
|
|
670
|
+
follow_scan() {
|
|
671
|
+
local text="$1" depth="$2"
|
|
672
|
+
local split_out line sep stmt t tj i j n cmd_pos eval_mode inline arg_exec
|
|
673
|
+
local heredoc redirected pipe_cat prev_cat pending_operand
|
|
674
|
+
local find_mode dispatch_pending
|
|
675
|
+
local -a toks=()
|
|
676
|
+
split_out="$(follow_split "$text")"
|
|
677
|
+
prev_cat=""
|
|
678
|
+
while IFS= read -r line; do
|
|
679
|
+
sep="${line%%$'\t'*}"
|
|
680
|
+
stmt="${line#*$'\t'}"
|
|
681
|
+
pipe_cat=""
|
|
682
|
+
[ "$sep" != "|" ] || pipe_cat="$prev_cat"
|
|
683
|
+
prev_cat=""
|
|
684
|
+
case "$stmt" in
|
|
685
|
+
*[![:space:]]*) ;;
|
|
686
|
+
*) continue ;;
|
|
687
|
+
esac
|
|
688
|
+
set -f
|
|
689
|
+
# The word split is the point: this is shell tokenization, and the glob
|
|
690
|
+
# guard above keeps a literal `*` from expanding against the hook's cwd.
|
|
691
|
+
# shellcheck disable=SC2206
|
|
692
|
+
toks=($stmt)
|
|
693
|
+
set +f
|
|
694
|
+
n=${#toks[@]}
|
|
695
|
+
[ "$n" -gt 0 ] || continue
|
|
696
|
+
i=0
|
|
697
|
+
cmd_pos=1
|
|
698
|
+
eval_mode=0
|
|
699
|
+
arg_exec=0
|
|
700
|
+
pending_operand=0
|
|
701
|
+
find_mode=0
|
|
702
|
+
dispatch_pending=0
|
|
703
|
+
while [ "$i" -lt "$n" ]; do
|
|
704
|
+
# Only a COMMAND POSITION can execute something. A path anywhere else is
|
|
705
|
+
# an argument, and an argument is data (#3604).
|
|
706
|
+
#
|
|
707
|
+
# The one exception is a dispatcher that BUILDS a command out of its own
|
|
708
|
+
# arguments: `find … -exec bash {} \;` and `xargs bash` put an interpreter
|
|
709
|
+
# at a command position the text does not spell as one. Scoping the
|
|
710
|
+
# exception to a `find`/`xargs` statement is what keeps it from becoming
|
|
711
|
+
# "an interpreter name anywhere is an invocation", which would read
|
|
712
|
+
# `echo "run bash <file>"` as one.
|
|
713
|
+
if [ "$cmd_pos" -ne 1 ]; then
|
|
714
|
+
# A dispatcher BUILDS a command out of its own arguments, putting an
|
|
715
|
+
# interpreter at a command position the text does not spell as one.
|
|
716
|
+
# That position is exactly ONE token: the word after `find -exec` or
|
|
717
|
+
# `-execdir`, and the first operand of `xargs`.
|
|
718
|
+
#
|
|
719
|
+
# Latching "any interpreter NAME until the statement ends" instead was
|
|
720
|
+
# a false positive, because an interpreter name is far more often data
|
|
721
|
+
# than a command: it refused `find . -name bash`, where `bash` is a
|
|
722
|
+
# filename PATTERN, and `find … | xargs grep bash`, where it is a
|
|
723
|
+
# search term. Both are ordinary commands that delete nothing.
|
|
724
|
+
if [ "$dispatch_pending" -ne 1 ]; then
|
|
725
|
+
if [ "$find_mode" -eq 1 ]; then
|
|
726
|
+
follow_unwrap "${toks[i]}"
|
|
727
|
+
case "$follow_unwrapped" in
|
|
728
|
+
-exec | -execdir) dispatch_pending=1 ;;
|
|
729
|
+
esac
|
|
730
|
+
fi
|
|
731
|
+
i=$((i + 1))
|
|
732
|
+
continue
|
|
733
|
+
fi
|
|
734
|
+
follow_unwrap "${toks[i]}"
|
|
735
|
+
t="$follow_unwrapped"
|
|
736
|
+
# `xargs -n1 bash` — the dispatcher's own options precede its command.
|
|
737
|
+
case "$t" in
|
|
738
|
+
-*)
|
|
739
|
+
i=$((i + 1))
|
|
740
|
+
continue
|
|
741
|
+
;;
|
|
742
|
+
esac
|
|
743
|
+
dispatch_pending=0
|
|
744
|
+
cmd_pos=1
|
|
745
|
+
else
|
|
746
|
+
follow_unwrap "${toks[i]}"
|
|
747
|
+
t="$follow_unwrapped"
|
|
748
|
+
fi
|
|
749
|
+
case "$t" in
|
|
750
|
+
# Prefixes that keep the command position open: assignments, wrappers,
|
|
751
|
+
# and the shell keywords a statement can start with. Without them
|
|
752
|
+
# `if …; then bash <file>` and `env bash <file>` walk past the
|
|
753
|
+
# invocation.
|
|
754
|
+
"" | [A-Za-z_]*=* | --*) i=$((i + 1)); continue ;;
|
|
755
|
+
-*)
|
|
756
|
+
# A SHORT option inside a wrapper prefix can take a separate VALUE,
|
|
757
|
+
# and that value is not a command. `nice -n 5 bash <file>` and
|
|
758
|
+
# `sudo -u nobody bash <file>` put `5` and `nobody` where the walk
|
|
759
|
+
# expected the program, so the invocation was never reached and the
|
|
760
|
+
# file was never scanned — the same fail-open this whole block exists
|
|
761
|
+
# to close, one layer along. Step the value over.
|
|
762
|
+
#
|
|
763
|
+
# But YIELD whenever the next token could itself be the invocation,
|
|
764
|
+
# so `env -i bash <file>` still reads `bash` as the program: `-i`
|
|
765
|
+
# takes no value, and consuming one would lose the interpreter.
|
|
766
|
+
#
|
|
767
|
+
# Long options are deliberately left alone. Their value is spelled
|
|
768
|
+
# with `=`, and a `--foo <path> bash <file>` is rare enough that
|
|
769
|
+
# consuming the operand would cost more than it buys.
|
|
770
|
+
i=$((i + 1))
|
|
771
|
+
if [ "$i" -lt "$n" ]; then
|
|
772
|
+
follow_unwrap "${toks[i]}"
|
|
773
|
+
follow_may_invoke "$follow_unwrapped" || i=$((i + 1))
|
|
774
|
+
fi
|
|
775
|
+
continue
|
|
776
|
+
;;
|
|
777
|
+
timeout | */timeout)
|
|
778
|
+
# `timeout` takes a mandatory DURATION before the program. A bare
|
|
779
|
+
# number is not an option, so the short-option arm above cannot see
|
|
780
|
+
# it. Mark the operand pending and let the fall-through consume it,
|
|
781
|
+
# which keeps working when options sit in between — `timeout -s KILL
|
|
782
|
+
# 5 bash <file>` reaches `bash` just as `timeout 5 bash <file>` does.
|
|
783
|
+
pending_operand=1
|
|
784
|
+
i=$((i + 1))
|
|
785
|
+
continue
|
|
786
|
+
;;
|
|
787
|
+
sudo | env | command | exec | nohup | nice | time | builtin \
|
|
788
|
+
| do | then | else | while | until | if | '!' | '{' \
|
|
789
|
+
| */sudo | */env | */nohup | */nice | */time)
|
|
790
|
+
i=$((i + 1))
|
|
791
|
+
continue
|
|
792
|
+
;;
|
|
793
|
+
eval)
|
|
794
|
+
# `eval` executes its ARGUMENTS as shell, so a file whose contents are
|
|
795
|
+
# interpolated into them is executed text.
|
|
796
|
+
eval_mode=1
|
|
797
|
+
i=$((i + 1))
|
|
798
|
+
continue
|
|
799
|
+
;;
|
|
800
|
+
find | */find)
|
|
801
|
+
# `find` only builds a command after `-exec`/`-execdir`. Every other
|
|
802
|
+
# operand is a path, a predicate, or a pattern.
|
|
803
|
+
arg_exec=1
|
|
804
|
+
find_mode=1
|
|
805
|
+
cmd_pos=0
|
|
806
|
+
i=$((i + 1))
|
|
807
|
+
continue
|
|
808
|
+
;;
|
|
809
|
+
xargs | */xargs)
|
|
810
|
+
# `xargs` takes its command as the first non-option operand; anything
|
|
811
|
+
# after that is an argument TO that command, not another command.
|
|
812
|
+
arg_exec=1
|
|
813
|
+
dispatch_pending=1
|
|
814
|
+
cmd_pos=0
|
|
815
|
+
i=$((i + 1))
|
|
816
|
+
continue
|
|
817
|
+
;;
|
|
818
|
+
cd)
|
|
819
|
+
if [ $((i + 1)) -lt "$n" ]; then
|
|
820
|
+
follow_unwrap "${toks[i + 1]}"
|
|
821
|
+
follow_add_base "$follow_unwrapped"
|
|
822
|
+
fi
|
|
823
|
+
cmd_pos=0
|
|
824
|
+
i=$((i + 1))
|
|
825
|
+
continue
|
|
826
|
+
;;
|
|
827
|
+
source | .)
|
|
828
|
+
follow_next_operand "$i" "$n"
|
|
829
|
+
j="$follow_operand_index"
|
|
830
|
+
if [ "$j" -lt "$n" ]; then
|
|
831
|
+
follow_unwrap "${toks[j]}"
|
|
832
|
+
follow_target "$follow_unwrapped" "$depth"
|
|
833
|
+
fi
|
|
834
|
+
cmd_pos=0
|
|
835
|
+
i=$((j + 1))
|
|
836
|
+
continue
|
|
837
|
+
;;
|
|
838
|
+
cat | '<')
|
|
839
|
+
follow_next_operand "$i" "$n"
|
|
840
|
+
j="$follow_operand_index"
|
|
841
|
+
if [ "$j" -lt "$n" ]; then
|
|
842
|
+
follow_unwrap "${toks[j]}"
|
|
843
|
+
if [ "$eval_mode" -eq 1 ]; then
|
|
844
|
+
follow_target "$follow_unwrapped" "$depth"
|
|
845
|
+
else
|
|
846
|
+
# Not executed here — but `cat <file> | bash` is, and the
|
|
847
|
+
# interpreter arm below reads this when the pipe hands it over.
|
|
848
|
+
prev_cat="$follow_unwrapped"
|
|
849
|
+
fi
|
|
850
|
+
fi
|
|
851
|
+
cmd_pos=0
|
|
852
|
+
i=$((j + 1))
|
|
853
|
+
continue
|
|
854
|
+
;;
|
|
855
|
+
esac
|
|
856
|
+
if follow_is_interpreter "$t"; then
|
|
857
|
+
inline=0
|
|
858
|
+
redirected=""
|
|
859
|
+
heredoc=0
|
|
860
|
+
j=$((i + 1))
|
|
861
|
+
while [ "$j" -lt "$n" ]; do
|
|
862
|
+
follow_unwrap "${toks[j]}"
|
|
863
|
+
tj="$follow_unwrapped"
|
|
864
|
+
case "$tj" in
|
|
865
|
+
--) j=$((j + 1)); break ;;
|
|
866
|
+
# A heredoc or here-string payload is IN the command text, which the
|
|
867
|
+
# guards already read. It is not a file, and treating it as one
|
|
868
|
+
# refused `bash <<EOF` outright.
|
|
869
|
+
'<<'*) heredoc=1; break ;;
|
|
870
|
+
# `bash < <file>` executes the file just as `bash <file>` does.
|
|
871
|
+
'<')
|
|
872
|
+
j=$((j + 1))
|
|
873
|
+
if [ "$j" -lt "$n" ]; then
|
|
874
|
+
follow_unwrap "${toks[j]}"
|
|
875
|
+
redirected="$follow_unwrapped"
|
|
876
|
+
fi
|
|
877
|
+
break
|
|
878
|
+
;;
|
|
879
|
+
'<'*) redirected="${tj#<}"; break ;;
|
|
880
|
+
# An output redirection names a DESTINATION, never the script. Step
|
|
881
|
+
# over it (and its target when the operator stands alone) so the
|
|
882
|
+
# operand scan does not stop on `>` and read `/dev/null` as a
|
|
883
|
+
# script it cannot resolve.
|
|
884
|
+
*'>'*)
|
|
885
|
+
case "$tj" in
|
|
886
|
+
*'>') j=$((j + 2)) ;;
|
|
887
|
+
*) j=$((j + 1)) ;;
|
|
888
|
+
esac
|
|
889
|
+
;;
|
|
890
|
+
--*) j=$((j + 1)) ;;
|
|
891
|
+
-*c*) inline=1; j=$((j + 1)) ;;
|
|
892
|
+
-*) j=$((j + 1)) ;;
|
|
893
|
+
*) break ;;
|
|
894
|
+
esac
|
|
895
|
+
done
|
|
896
|
+
if [ "$heredoc" -eq 1 ] && [ "$inline" -eq 0 ]; then
|
|
897
|
+
cmd_pos=0
|
|
898
|
+
i=$((j + 1))
|
|
899
|
+
continue
|
|
900
|
+
fi
|
|
901
|
+
if [ -n "$redirected" ]; then
|
|
902
|
+
follow_target "$redirected" "$depth"
|
|
903
|
+
cmd_pos=0
|
|
904
|
+
i=$((j + 1))
|
|
905
|
+
continue
|
|
906
|
+
fi
|
|
907
|
+
if [ "$inline" -eq 1 ]; then
|
|
908
|
+
# `-c` takes a COMMAND STRING, not a script path. That string is text
|
|
909
|
+
# the guards already read, and it can itself invoke a script, so the
|
|
910
|
+
# walk re-enters it at a fresh command position rather than reading it
|
|
911
|
+
# as a filename — this is what catches `bash -c 'bash <file>'`.
|
|
912
|
+
cmd_pos=1
|
|
913
|
+
i="$j"
|
|
914
|
+
continue
|
|
915
|
+
fi
|
|
916
|
+
if [ "$j" -ge "$n" ]; then
|
|
917
|
+
# A dispatcher supplies the script from ANOTHER command's output, so
|
|
918
|
+
# the operand exists but is unreadable here. That is the fail-closed
|
|
919
|
+
# case, not the interactive one.
|
|
920
|
+
if [ "$arg_exec" -eq 1 ]; then
|
|
921
|
+
follow_refuse "$t" "a dispatcher supplies its script from another command's output"
|
|
922
|
+
fi
|
|
923
|
+
# No operand: an interactive or stdin-fed interpreter. Only the
|
|
924
|
+
# `cat <file> | bash` shape names a FILE, and only that is followed.
|
|
925
|
+
[ -z "$pipe_cat" ] || follow_target "$pipe_cat" "$depth"
|
|
926
|
+
cmd_pos=0
|
|
927
|
+
i="$j"
|
|
928
|
+
continue
|
|
929
|
+
fi
|
|
930
|
+
follow_unwrap "${toks[j]}"
|
|
931
|
+
tj="$follow_unwrapped"
|
|
932
|
+
follow_target "$tj" "$depth"
|
|
933
|
+
cmd_pos=0
|
|
934
|
+
i=$((j + 1))
|
|
935
|
+
continue
|
|
936
|
+
fi
|
|
937
|
+
if [ "$pending_operand" -eq 1 ]; then
|
|
938
|
+
# A wrapper's own mandatory operand (a `timeout` duration), not the
|
|
939
|
+
# program it wraps. Consume it and leave the command position OPEN so
|
|
940
|
+
# the walk carries on to the invocation. Yields on anything that could
|
|
941
|
+
# itself be the program, for the reason follow_may_invoke documents.
|
|
942
|
+
pending_operand=0
|
|
943
|
+
if ! follow_may_invoke "$t"; then
|
|
944
|
+
i=$((i + 1))
|
|
945
|
+
continue
|
|
946
|
+
fi
|
|
947
|
+
fi
|
|
948
|
+
# The tilde arm is LITERAL text, not a path this line expands:
|
|
949
|
+
# follow_locate turns it into one.
|
|
950
|
+
# shellcheck disable=SC2088
|
|
951
|
+
case "$t" in
|
|
952
|
+
/* | ./* | ../* | '~/'* | */*) follow_direct "$t" "$depth" ;;
|
|
953
|
+
esac
|
|
954
|
+
cmd_pos=0
|
|
955
|
+
i=$((i + 1))
|
|
956
|
+
done
|
|
957
|
+
done <<<"$split_out"
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
follow_scan "$command_for_guards" 0
|
|
961
|
+
if [ -n "$follow_names" ]; then
|
|
962
|
+
command_for_guards="$command_for_guards"$'\n'"$followed_text"
|
|
963
|
+
# A refusal must say WHERE the match is. Without this the operator reads
|
|
964
|
+
# advice about rewording a quoted string for a line they never typed.
|
|
965
|
+
DESTRUCTIVE_GUIDANCE="$DESTRUCTIVE_GUIDANCE
|
|
966
|
+
|
|
967
|
+
The match may be inside a file this command EXECUTES rather than in the command
|
|
968
|
+
you typed. These scripts were read and scanned as part of it:$follow_names
|
|
969
|
+
Edit the file, or ask the user to confirm and run it manually outside the agent."
|
|
970
|
+
fi
|
|
971
|
+
|
|
301
972
|
# Normalize bash line-continuations (a trailing backslash + newline → space)
|
|
302
973
|
# before segmenting the command. Without this, "git push --force origin
|
|
303
974
|
# \<newline>main" splits into a segment matching --force but not `main`, letting a
|