@codyswann/lisa 4.34.4 → 4.34.6
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-gates.mjs +40 -1
- package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +436 -9
- package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +636 -101
- package/all/copy-overwrite/scripts/lisa-test-node.mjs +44 -1
- package/dist/codex/scripts/block-no-verify.sh +608 -99
- package/dist/configs/vitest/scratch-global-setup.d.ts +29 -0
- package/dist/configs/vitest/scratch-global-setup.d.ts.map +1 -1
- package/dist/configs/vitest/scratch-global-setup.js +77 -12
- package/dist/configs/vitest/scratch-global-setup.js.map +1 -1
- package/dist/core/config-shadowing.d.ts +72 -0
- package/dist/core/config-shadowing.d.ts.map +1 -0
- package/dist/core/config-shadowing.js +152 -0
- package/dist/core/config-shadowing.js.map +1 -0
- package/dist/core/config.d.ts +16 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +1 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +10 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa.d.ts +21 -0
- package/dist/core/lisa.d.ts.map +1 -1
- package/dist/core/lisa.js +46 -0
- package/dist/core/lisa.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 +19 -11
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/block-no-verify.sh +636 -101
- package/dist/strategies/copy-overwrite.d.ts +42 -0
- package/dist/strategies/copy-overwrite.d.ts.map +1 -1
- package/dist/strategies/copy-overwrite.js +69 -5
- package/dist/strategies/copy-overwrite.js.map +1 -1
- package/expo/copy-overwrite/scripts/check-lighthouse-details.mjs +138 -11
- package/expo/create-only/.github/workflows/deploy.yml +51 -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/hooks/block-managed-file-edits.sh +436 -9
- package/plugins/lisa/hooks/block-no-verify.agy.sh +608 -99
- package/plugins/lisa/hooks/block-no-verify.sh +636 -101
- package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +608 -99
- 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/block-managed-file-edits.sh +436 -9
- package/plugins/lisa-copilot/hooks/block-no-verify.sh +636 -101
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +436 -9
- package/plugins/lisa-cursor/hooks/block-no-verify.sh +636 -101
- 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-managed-file-edits.sh +436 -9
- package/plugins/src/base/hooks/block-no-verify.agy.sh +608 -99
- package/plugins/src/base/hooks/block-no-verify.sh +636 -101
- package/rails/create-only/.github/workflows/deploy.yml +45 -1
- package/scripts/two-channel-couplings.json +1 -1
|
@@ -77,7 +77,7 @@ import {
|
|
|
77
77
|
statSync,
|
|
78
78
|
} from "node:fs";
|
|
79
79
|
import { createRequire } from "node:module";
|
|
80
|
-
import { dirname, join, parse } from "node:path";
|
|
80
|
+
import { dirname, isAbsolute, join, parse, relative } from "node:path";
|
|
81
81
|
import { fileURLToPath } from "node:url";
|
|
82
82
|
|
|
83
83
|
import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
@@ -6109,6 +6109,34 @@ export function artifactDigest(file) {
|
|
|
6109
6109
|
}
|
|
6110
6110
|
}
|
|
6111
6111
|
|
|
6112
|
+
/**
|
|
6113
|
+
* WHICH COPY the digests above were taken from, rendered for a reader.
|
|
6114
|
+
*
|
|
6115
|
+
* A digest alone cannot be checked. Several copies of this registry can be on
|
|
6116
|
+
* one runner at once — the installed package under `node_modules`, the copy
|
|
6117
|
+
* `lisa apply` writes into `scripts/`, and this repository's own source tree —
|
|
6118
|
+
* and a stamp naming none of them is a hash a reader has no way to attribute.
|
|
6119
|
+
* The failure that made this load-bearing was a stamp resolved on a filesystem
|
|
6120
|
+
* the gates had not yet been installed onto: it named bytes that decided
|
|
6121
|
+
* nothing, on the run whose verdicts a reader was trying to date, and read as
|
|
6122
|
+
* the one fact that could not be mistaken.
|
|
6123
|
+
*
|
|
6124
|
+
* Relative to the working directory when it is inside it, because that is the
|
|
6125
|
+
* form the resolving shell prints and the form two reports can be compared in;
|
|
6126
|
+
* absolute otherwise, rather than a `../..` chain that names a location only
|
|
6127
|
+
* this process can resolve.
|
|
6128
|
+
* @param {string} directory The directory whose artifacts were digested.
|
|
6129
|
+
* @param {string} [cwd] The directory paths are rendered relative to.
|
|
6130
|
+
* @returns {string} The path, relative where that is meaningful.
|
|
6131
|
+
*/
|
|
6132
|
+
export function identityRegistryPath(directory, cwd = process.cwd()) {
|
|
6133
|
+
const rendered = relative(cwd, directory);
|
|
6134
|
+
if (rendered === "") return ".";
|
|
6135
|
+
if (isAbsolute(rendered) || rendered.split(/[\\/]/).includes(".."))
|
|
6136
|
+
return directory;
|
|
6137
|
+
return rendered;
|
|
6138
|
+
}
|
|
6139
|
+
|
|
6112
6140
|
/**
|
|
6113
6141
|
* WHICH Lisa is running: the surface, the version, the ref, and the bytes.
|
|
6114
6142
|
*
|
|
@@ -6125,15 +6153,18 @@ export function artifactDigest(file) {
|
|
|
6125
6153
|
* @param {object} [options] Inputs.
|
|
6126
6154
|
* @param {Record<string,string|undefined>} [options.env] The environment.
|
|
6127
6155
|
* @param {string} [options.directory] Where the enforcement scripts live.
|
|
6156
|
+
* @param {string} [options.cwd] What `registry_path` is rendered relative to.
|
|
6128
6157
|
* @returns {object} The identity record.
|
|
6129
6158
|
*/
|
|
6130
6159
|
export function lisaIdentity({
|
|
6131
6160
|
env = process.env,
|
|
6132
6161
|
directory = SCRIPTS_DIRECTORY,
|
|
6162
|
+
cwd = process.cwd(),
|
|
6133
6163
|
} = {}) {
|
|
6134
6164
|
return {
|
|
6135
6165
|
surface: env.GITHUB_ACTIONS === "true" ? "ci" : "local",
|
|
6136
6166
|
registry_version: registryVersion(),
|
|
6167
|
+
registry_path: identityRegistryPath(directory, cwd),
|
|
6137
6168
|
workflow_ref: env.GITHUB_WORKFLOW_REF ?? null,
|
|
6138
6169
|
workflow_sha: env.GITHUB_WORKFLOW_SHA ?? null,
|
|
6139
6170
|
artifacts: Object.fromEntries(
|
|
@@ -6169,6 +6200,7 @@ export function formatIdentityLine(identity) {
|
|
|
6169
6200
|
return [
|
|
6170
6201
|
`🔖 Lisa identity — surface=${identity.surface}`,
|
|
6171
6202
|
`package=${LISA_PACKAGE}@${identity.registry_version ?? IDENTITY_UNKNOWN}`,
|
|
6203
|
+
`registry=${identity.registry_path ?? IDENTITY_UNKNOWN}`,
|
|
6172
6204
|
`workflow=${identityWorkflow(identity)}`,
|
|
6173
6205
|
...Object.entries(identity.artifacts ?? {}).map(
|
|
6174
6206
|
([name, sha]) => `${name}=${sha ?? IDENTITY_UNKNOWN}`
|
|
@@ -6186,6 +6218,7 @@ export function formatIdentityMarkdown(identity) {
|
|
|
6186
6218
|
const rows = [
|
|
6187
6219
|
["surface", identity.surface],
|
|
6188
6220
|
["package", `${LISA_PACKAGE}@${version}`],
|
|
6221
|
+
["registry path", identity.registry_path ?? IDENTITY_UNKNOWN],
|
|
6189
6222
|
["workflow ref", identity.workflow_ref ?? IDENTITY_UNKNOWN],
|
|
6190
6223
|
["workflow sha", identity.workflow_sha ?? IDENTITY_UNKNOWN],
|
|
6191
6224
|
...Object.entries(identity.artifacts ?? {}).map(([name, sha]) => [
|
|
@@ -6205,6 +6238,12 @@ export function formatIdentityMarkdown(identity) {
|
|
|
6205
6238
|
"fields, so the two can be compared without either repository: equal " +
|
|
6206
6239
|
"digests mean the same enforcement bytes ran, and a differing " +
|
|
6207
6240
|
"`workflow sha` dates any claim made from this run.",
|
|
6241
|
+
"",
|
|
6242
|
+
"`registry path` names WHICH copy those digests were taken from. Several " +
|
|
6243
|
+
"live at once on one runner — the installed package, the copy `lisa " +
|
|
6244
|
+
"apply` writes into `scripts/`, and a repository's own source tree — so " +
|
|
6245
|
+
"a digest attributed to none of them cannot be checked against the copy " +
|
|
6246
|
+
"a gate job executed.",
|
|
6208
6247
|
].join("\n");
|
|
6209
6248
|
}
|
|
6210
6249
|
|
|
@@ -56,8 +56,30 @@
|
|
|
56
56
|
# Blocked signatures:
|
|
57
57
|
# 1. Write / Edit / MultiEdit / NotebookEdit whose target resolves to a
|
|
58
58
|
# copy-overwrite template in the installed Lisa package;
|
|
59
|
-
# 2. Bash output redirection (`>`, `>>`, `>|`), `tee`, or
|
|
60
|
-
# one.
|
|
59
|
+
# 2. Bash output redirection (`>`, `>>`, `>|`), `tee`, or an in-place `sed`
|
|
60
|
+
# aimed at one — `-i`, `-i ''`, `-i.bak`, a cluster such as `-ni`, and the
|
|
61
|
+
# long `--in-place[=SUFFIX]`. Reads never fire;
|
|
62
|
+
# 3. any of those inside a script the command EXECUTES — `bash edit.sh`,
|
|
63
|
+
# `sh`, `source`, `.`, `bash < edit.sh`, and behind wrappers that carry an
|
|
64
|
+
# operand of their own (`nice -n 5`, `timeout 5`, `sudo`).
|
|
65
|
+
#
|
|
66
|
+
# ## What signature 3 deliberately does NOT do
|
|
67
|
+
#
|
|
68
|
+
# A path is followed ONLY when the command EXECUTES it. `grep -n x edit.sh`,
|
|
69
|
+
# `wc -l edit.sh`, `git diff edit.sh` and a test run over it name a file as
|
|
70
|
+
# DATA, and following those is the known-wrong fix this guard family has already
|
|
71
|
+
# made once: it refuses ordinary reads and attributes a file's capability to a
|
|
72
|
+
# command that merely mentions it. Command POSITION decides. Taint does not
|
|
73
|
+
# propagate any further either — a path a followed script merely NAMES is data
|
|
74
|
+
# one file further out, not a third hop.
|
|
75
|
+
#
|
|
76
|
+
# ## Parity gap, recorded rather than silently dropped
|
|
77
|
+
#
|
|
78
|
+
# This guard has no Antigravity, Codex or OpenCode port — only the Claude
|
|
79
|
+
# reference and the copies generated from it. Its siblings do. That predates
|
|
80
|
+
# this change and is not closed by it: opening three ports is its own work, and
|
|
81
|
+
# doing it here would triple the surface under review. Recorded so it reads as a
|
|
82
|
+
# known gap rather than an oversight.
|
|
61
83
|
#
|
|
62
84
|
# Exemptions (allowed):
|
|
63
85
|
# - `LISA_ALLOW_MANAGED_FILE_WRITE` set — the operator's explicit override,
|
|
@@ -65,6 +87,13 @@
|
|
|
65
87
|
# - paths under `node_modules/` or `dist/` — vendored copies, not the host's;
|
|
66
88
|
# - the Lisa source repository itself, where these files are the originals and
|
|
67
89
|
# editing them is the entire point.
|
|
90
|
+
#
|
|
91
|
+
# The line below lets `lisa apply` tell a downstream copy of this guard that is
|
|
92
|
+
# BEHIND from one that is AHEAD. Byte comparison cannot: both look like "differs
|
|
93
|
+
# from mine", and guessing "behind" is how a fork's stronger guard gets silently
|
|
94
|
+
# replaced by a weaker upstream one. Add a name here in the same commit that
|
|
95
|
+
# closes a vector.
|
|
96
|
+
# lisa-guard-capabilities: managed-path-resolution, lisaignore-precedence, generated-path-rebuild, redirect-target, tee-target, sed-in-place-all-spellings, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand
|
|
68
97
|
set -euo pipefail
|
|
69
98
|
|
|
70
99
|
input="$(cat)"
|
|
@@ -366,19 +395,417 @@ EOF
|
|
|
366
395
|
Bash)
|
|
367
396
|
command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"
|
|
368
397
|
[ -n "$command_str" ] || exit 0
|
|
369
|
-
#
|
|
370
|
-
#
|
|
371
|
-
#
|
|
398
|
+
# A missing interpreter must be announced rather than swallowed. This guard
|
|
399
|
+
# degrades to "allow" — a hook that cannot parse its input cannot tell a
|
|
400
|
+
# write from a read — but a guard that is silently absent reads exactly like
|
|
401
|
+
# a guard that is passing.
|
|
402
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
403
|
+
printf 'block-managed-file-edits: python3 not found; Bash write protection is NOT active\n' >&2
|
|
404
|
+
exit 0
|
|
405
|
+
fi
|
|
406
|
+
# Why a tokenizer replaced the two-stage regex.
|
|
407
|
+
#
|
|
408
|
+
# The old extraction was `grep -Eo` for a redirect / tee / in-place sed,
|
|
409
|
+
# followed by a `sed -E` strip of the matched prefix. MEASURED against it:
|
|
410
|
+
# `tee <path>` and `tee -a <path>` extracted correctly, and EVERY `sed -i`
|
|
411
|
+
# spelling extracted NOTHING — `-i`, `-i ''` and `-i.bak` alike. The
|
|
412
|
+
# `grep` matched; the strip pattern `sed[[:space:]]+[^|;&]*-i[^|;&]*` is
|
|
413
|
+
# greedy, so it consumed the path along with the flags and left an empty
|
|
414
|
+
# token. The arm was DEAD, not merely missing macOS's two-token spelling.
|
|
415
|
+
#
|
|
416
|
+
# A regex cannot answer the question this guard actually asks — is this path
|
|
417
|
+
# WRITTEN or merely NAMED — because that depends on token position, which is
|
|
418
|
+
# what a tokenizer is for.
|
|
419
|
+
#
|
|
420
|
+
# The analyzer is read into a variable by `read -d ''` rather than run
|
|
421
|
+
# inside a `$(python3 - <<'PY' … PY)`. Bash has to find the closing paren of
|
|
422
|
+
# a command substitution by re-scanning its text, and a heredoc body nested
|
|
423
|
+
# there is scanned too: the lone backtick in `re.compile(r"[$`]")` below
|
|
424
|
+
# reads as an unterminated backtick substitution and the whole script fails
|
|
425
|
+
# to parse. Keeping the heredoc out of the substitution is the fix.
|
|
426
|
+
analyzer=''
|
|
427
|
+
IFS= read -r -d '' analyzer <<'PY' || true
|
|
428
|
+
import os
|
|
429
|
+
import re
|
|
430
|
+
import shlex
|
|
431
|
+
|
|
432
|
+
command = os.environ.get("MANAGED_EDIT_COMMAND", "")
|
|
433
|
+
project = os.environ.get("MANAGED_EDIT_PROJECT", "") or os.getcwd()
|
|
434
|
+
|
|
435
|
+
# Following a script costs a read on every intercepted command, so the walk is
|
|
436
|
+
# bounded three ways, and a file past the cap is skipped rather than half-read:
|
|
437
|
+
# a truncated scan reports a confident ALLOW about text it never saw.
|
|
438
|
+
FOLLOW_MAX_BYTES = 262144
|
|
439
|
+
FOLLOW_MAX_FILES = 8
|
|
440
|
+
FOLLOW_MAX_DEPTH = 3
|
|
441
|
+
|
|
442
|
+
# Spelled with chr() rather than backslash escapes on purpose. This source is
|
|
443
|
+
# embedded in a shell heredoc and copied verbatim through several generators
|
|
444
|
+
# into every shipped plugin; a backslash literal is the one thing in it most
|
|
445
|
+
# likely to be mangled in transit, and the mangling would be silent.
|
|
446
|
+
BACKSLASH = chr(92)
|
|
447
|
+
NEWLINE = chr(10)
|
|
448
|
+
|
|
449
|
+
STATEMENT_SEPARATORS = {";", "|", "||", "&", "&&", "(", ")", "&|"}
|
|
450
|
+
# `>`, `>>`, and the noclobber override `>|`.
|
|
451
|
+
REDIRECT_OUT = {">", ">>", ">|"}
|
|
452
|
+
REDIRECT_IN = "<"
|
|
453
|
+
|
|
454
|
+
SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
|
|
455
|
+
SOURCE_BUILTINS = {"source", "."}
|
|
456
|
+
|
|
457
|
+
# Wrappers that run what FOLLOWS them without changing what it is, mapped to
|
|
458
|
+
# (options whose value is a SEPARATE token, positional operands consumed).
|
|
459
|
+
# The positional count is why this is a table rather than a set: `timeout 5
|
|
460
|
+
# bash x` puts an operand between the wrapper and the interpreter, so a walk
|
|
461
|
+
# that steps over `-flags` only stops at `5` and never reaches `bash`.
|
|
462
|
+
FOLLOW_WRAPPERS = {
|
|
463
|
+
"builtin": (frozenset(), 0),
|
|
464
|
+
"command": (frozenset(), 0),
|
|
465
|
+
"exec": (frozenset({"-a"}), 0),
|
|
466
|
+
"nice": (frozenset({"-n", "--adjustment"}), 0),
|
|
467
|
+
"nohup": (frozenset(), 0),
|
|
468
|
+
"setsid": (frozenset(), 0),
|
|
469
|
+
"stdbuf": (frozenset({"-i", "-o", "-e"}), 0),
|
|
470
|
+
"sudo": (
|
|
471
|
+
frozenset(
|
|
472
|
+
{
|
|
473
|
+
"-C", "--close-from", "-g", "--group", "-h", "--host",
|
|
474
|
+
"-p", "--prompt", "-r", "--role", "-t", "--type",
|
|
475
|
+
"-U", "--other-user", "-u", "--user",
|
|
476
|
+
}
|
|
477
|
+
),
|
|
478
|
+
0,
|
|
479
|
+
),
|
|
480
|
+
"time": (frozenset(), 0),
|
|
481
|
+
"timeout": (frozenset({"-k", "--kill-after", "-s", "--signal"}), 1),
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
# `-i`, `-i.bak`, a cluster such as `-ni`, and the long `--in-place[=SUFFIX]`.
|
|
485
|
+
IN_PLACE = re.compile(r"^(--in-place(=.*)?|-[a-zA-Z]*i.*)$")
|
|
486
|
+
# A value the SHELL computes; this hook runs before expansion and cannot know
|
|
487
|
+
# which file it will name.
|
|
488
|
+
COMPUTED_VALUE = re.compile(r"[$`]")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def normalise_lines(text):
|
|
492
|
+
"""Spell line breaks as statement separators.
|
|
493
|
+
|
|
494
|
+
A newline ends a command exactly as `;` does, but shlex treats it as plain
|
|
495
|
+
whitespace — so two consecutive lines of a followed script would read as
|
|
496
|
+
one invocation and only the first program would be classified. A
|
|
497
|
+
backslash-newline is joined first, because there the newline is NOT a
|
|
498
|
+
boundary.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
text: A command line or a script's contents.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
The same text with line breaks spelled as separators.
|
|
505
|
+
"""
|
|
506
|
+
joined = text.replace(BACKSLASH + NEWLINE, " ")
|
|
507
|
+
return joined.replace(NEWLINE, " ; ")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def tokenize(text):
|
|
511
|
+
"""Tokenize with shell operators kept as their own tokens.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
text: A command line or a script's contents.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
The token list, or None when the text does not lex.
|
|
518
|
+
"""
|
|
519
|
+
lexer = shlex.shlex(normalise_lines(text), posix=True, punctuation_chars=True)
|
|
520
|
+
lexer.whitespace_split = True
|
|
521
|
+
# `#` is not a comment introducer on a typed command line, and letting shlex
|
|
522
|
+
# treat it as one silently truncates the rest of the line.
|
|
523
|
+
lexer.commenters = ""
|
|
524
|
+
try:
|
|
525
|
+
return list(lexer)
|
|
526
|
+
except ValueError:
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def strip_comments(text):
|
|
531
|
+
"""Drop `#` comments from a FOLLOWED SCRIPT, quote-aware.
|
|
532
|
+
|
|
533
|
+
Applied to file contents and never to a typed command. A comment cannot
|
|
534
|
+
execute, and Lisa's own managed templates discuss redirection and `sed -i`
|
|
535
|
+
against managed paths in their headers — classifying a followed file
|
|
536
|
+
without dropping its comments reads documentation as an edit.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
text: A script's contents.
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
The same text with unquoted comments removed.
|
|
543
|
+
"""
|
|
544
|
+
output = []
|
|
545
|
+
for line in text.split(NEWLINE):
|
|
546
|
+
quote = ""
|
|
547
|
+
escaped = False
|
|
548
|
+
cut = None
|
|
549
|
+
for position, character in enumerate(line):
|
|
550
|
+
if escaped:
|
|
551
|
+
escaped = False
|
|
552
|
+
continue
|
|
553
|
+
if character == BACKSLASH and quote != "'":
|
|
554
|
+
escaped = True
|
|
555
|
+
continue
|
|
556
|
+
if quote:
|
|
557
|
+
if character == quote:
|
|
558
|
+
quote = ""
|
|
559
|
+
continue
|
|
560
|
+
if character in "'\"":
|
|
561
|
+
quote = character
|
|
562
|
+
continue
|
|
563
|
+
# A `#` opens a comment only at the start of a word, so `x#y` and
|
|
564
|
+
# `${x#y}` keep their hashes.
|
|
565
|
+
if character == "#" and (position == 0 or line[position - 1].isspace()):
|
|
566
|
+
cut = position
|
|
567
|
+
break
|
|
568
|
+
output.append(line if cut is None else line[:cut])
|
|
569
|
+
return NEWLINE.join(output)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def statements(tokens):
|
|
573
|
+
"""Split a token list at statement separators, keeping redirects inline.
|
|
574
|
+
|
|
575
|
+
Redirect operators are deliberately NOT separators here: `>` and its target
|
|
576
|
+
have to stay in the same group, because the pair is the write signature.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
tokens: The operator-aware token list.
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
A list of statements.
|
|
583
|
+
"""
|
|
584
|
+
grouped = []
|
|
585
|
+
current = []
|
|
586
|
+
for token in tokens:
|
|
587
|
+
if token in STATEMENT_SEPARATORS:
|
|
588
|
+
grouped.append(current)
|
|
589
|
+
current = []
|
|
590
|
+
continue
|
|
591
|
+
current.append(token)
|
|
592
|
+
grouped.append(current)
|
|
593
|
+
return grouped
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def command_word(statement):
|
|
597
|
+
"""The program a statement runs, and the tokens after it.
|
|
598
|
+
|
|
599
|
+
Steps over leading variable assignments and over wrappers that do not
|
|
600
|
+
change what runs. Reading a wrapper as the command is how a payload hides
|
|
601
|
+
from a guard that inspects only a statement's first word.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
statement: One statement's tokens.
|
|
605
|
+
|
|
606
|
+
Returns:
|
|
607
|
+
A pair (program, args); (None, []) when no program is named.
|
|
608
|
+
"""
|
|
609
|
+
index = 0
|
|
610
|
+
while index < len(statement):
|
|
611
|
+
token = statement[index]
|
|
612
|
+
if "=" in token and not token.startswith(("=", "-")):
|
|
613
|
+
index += 1
|
|
614
|
+
continue
|
|
615
|
+
program = token.rsplit("/", 1)[-1]
|
|
616
|
+
if program not in FOLLOW_WRAPPERS:
|
|
617
|
+
return (program, statement[index + 1 :])
|
|
618
|
+
separate, positional = FOLLOW_WRAPPERS[program]
|
|
619
|
+
index += 1
|
|
620
|
+
while index < len(statement) and statement[index].startswith("-"):
|
|
621
|
+
option = statement[index]
|
|
622
|
+
if option == "--":
|
|
623
|
+
index += 1
|
|
624
|
+
break
|
|
625
|
+
index += 2 if option in separate else 1
|
|
626
|
+
index += positional
|
|
627
|
+
return (None, [])
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def shell_script_operand(args):
|
|
631
|
+
"""The file a shell invocation runs, or None when it runs no script file.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
args: The tokens following the shell's own name.
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
The operand token, or None for an interactive shell or a `-c` string.
|
|
638
|
+
"""
|
|
639
|
+
for token in args:
|
|
640
|
+
if token == "--":
|
|
641
|
+
continue
|
|
642
|
+
if token.startswith(("-", "+")):
|
|
643
|
+
# A single-dash cluster containing `c` carries a command string,
|
|
644
|
+
# not a script file.
|
|
645
|
+
if not token.startswith("--") and "c" in token[1:]:
|
|
646
|
+
return None
|
|
647
|
+
continue
|
|
648
|
+
return token
|
|
649
|
+
return None
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def write_targets(statement):
|
|
653
|
+
"""Paths this statement WRITES.
|
|
654
|
+
|
|
655
|
+
Reads never fire. A path has to be the target of an output redirection, or
|
|
656
|
+
an operand of `tee` or of an in-place `sed`. That is the whole distinction
|
|
657
|
+
this guard turns on: `grep -n x <managed>` names the same path and stays
|
|
658
|
+
allowed, and following a merely-named path is the known-wrong fix that
|
|
659
|
+
refuses ordinary reads elsewhere in this family.
|
|
660
|
+
|
|
661
|
+
Extraction is deliberately GENEROUS for `tee` and `sed`: every non-flag
|
|
662
|
+
operand is offered. It costs nothing, because the caller classifies each
|
|
663
|
+
candidate by resolving it against the installed package, and a `sed` script
|
|
664
|
+
expression such as `s/a/b/` resolves to no template. Being narrow here is
|
|
665
|
+
precisely what left the old `sed -i` arm dead in every spelling.
|
|
666
|
+
|
|
667
|
+
Args:
|
|
668
|
+
statement: One statement's tokens.
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
Candidate paths this statement writes.
|
|
672
|
+
"""
|
|
673
|
+
found = []
|
|
674
|
+
program, args = command_word(statement)
|
|
675
|
+
for index, token in enumerate(statement):
|
|
676
|
+
if token in REDIRECT_OUT and index + 1 < len(statement):
|
|
677
|
+
found.append(statement[index + 1])
|
|
678
|
+
if program == "tee":
|
|
679
|
+
found.extend(token for token in args if not token.startswith("-"))
|
|
680
|
+
elif program == "sed" and any(IN_PLACE.match(token) for token in args):
|
|
681
|
+
found.extend(token for token in args if not token.startswith("-"))
|
|
682
|
+
return found
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def executed_script(statement):
|
|
686
|
+
"""The path this statement EXECUTES, or None.
|
|
687
|
+
|
|
688
|
+
Only a COMMAND POSITION can execute something. A path anywhere else is an
|
|
689
|
+
argument, and an argument is data.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
statement: One statement's tokens.
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
The operand token naming a script that will run, or None.
|
|
696
|
+
"""
|
|
697
|
+
program, args = command_word(statement)
|
|
698
|
+
if program is None:
|
|
699
|
+
return None
|
|
700
|
+
if program in SHELL_PROGRAMS:
|
|
701
|
+
for index, token in enumerate(statement):
|
|
702
|
+
# `bash < script.sh` runs that file as surely as `bash script.sh`.
|
|
703
|
+
if token == REDIRECT_IN and index + 1 < len(statement):
|
|
704
|
+
return statement[index + 1]
|
|
705
|
+
return shell_script_operand(args)
|
|
706
|
+
if program in SOURCE_BUILTINS:
|
|
707
|
+
return args[0] if args else None
|
|
708
|
+
return None
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def resolve(token):
|
|
712
|
+
"""An existing readable script a token names, or None.
|
|
713
|
+
|
|
714
|
+
A computed target (`bash "$SCRIPT"`) is NOT followed and NOT refused. This
|
|
715
|
+
guard's harm is a template that silently forks — recoverable, and reported
|
|
716
|
+
by `lisa doctor` on every run — so failing closed on every `bash "$SCRIPT"`
|
|
717
|
+
in every host project would cost far more than it buys. Its sibling
|
|
718
|
+
`block-no-verify` DOES fail closed on the identical shape, because an
|
|
719
|
+
unverified commit is not recoverable. The divergence is deliberate, and it
|
|
720
|
+
is recorded here so the next reader does not read it as an oversight.
|
|
721
|
+
|
|
722
|
+
Args:
|
|
723
|
+
token: The operand naming a script.
|
|
724
|
+
|
|
725
|
+
Returns:
|
|
726
|
+
A path, or None.
|
|
727
|
+
"""
|
|
728
|
+
if COMPUTED_VALUE.search(token):
|
|
729
|
+
return None
|
|
730
|
+
text = token.strip().strip("'\"")
|
|
731
|
+
if not text or text == "-":
|
|
732
|
+
return None
|
|
733
|
+
if os.path.isabs(text):
|
|
734
|
+
candidates = [text]
|
|
735
|
+
else:
|
|
736
|
+
candidates = [os.path.join(project, text), os.path.join(os.getcwd(), text)]
|
|
737
|
+
for candidate in candidates:
|
|
738
|
+
try:
|
|
739
|
+
if not os.path.isfile(candidate):
|
|
740
|
+
continue
|
|
741
|
+
if os.path.getsize(candidate) > FOLLOW_MAX_BYTES:
|
|
742
|
+
continue
|
|
743
|
+
except OSError:
|
|
744
|
+
continue
|
|
745
|
+
return candidate
|
|
746
|
+
return None
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def collect(text, depth, seen, out):
|
|
750
|
+
"""Accumulate every write target in this text and in the scripts it runs.
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
text: A command line, or a followed script's contents.
|
|
754
|
+
depth: Number of scripts already followed.
|
|
755
|
+
seen: Real paths already inspected, so a cycle terminates.
|
|
756
|
+
out: Accumulator for candidate paths.
|
|
757
|
+
|
|
758
|
+
Returns:
|
|
759
|
+
Nothing; `out` is extended in place.
|
|
760
|
+
"""
|
|
761
|
+
tokens = tokenize(text)
|
|
762
|
+
if tokens is None:
|
|
763
|
+
return
|
|
764
|
+
for statement in statements(tokens):
|
|
765
|
+
if not statement:
|
|
766
|
+
continue
|
|
767
|
+
out.extend(write_targets(statement))
|
|
768
|
+
operand = executed_script(statement)
|
|
769
|
+
if operand is None:
|
|
770
|
+
continue
|
|
771
|
+
if depth >= FOLLOW_MAX_DEPTH or len(seen) >= FOLLOW_MAX_FILES:
|
|
772
|
+
continue
|
|
773
|
+
path = resolve(operand)
|
|
774
|
+
if path is None:
|
|
775
|
+
continue
|
|
776
|
+
try:
|
|
777
|
+
key = os.path.realpath(path)
|
|
778
|
+
except OSError:
|
|
779
|
+
key = path
|
|
780
|
+
if key in seen:
|
|
781
|
+
continue
|
|
782
|
+
seen.add(key)
|
|
783
|
+
try:
|
|
784
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
785
|
+
body = handle.read(FOLLOW_MAX_BYTES)
|
|
786
|
+
except OSError:
|
|
787
|
+
continue
|
|
788
|
+
collect(strip_comments(body), depth + 1, seen, out)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
targets = []
|
|
792
|
+
collect(command, 0, set(), targets)
|
|
793
|
+
for target in targets:
|
|
794
|
+
cleaned = target.strip().strip("'\"")
|
|
795
|
+
if cleaned:
|
|
796
|
+
print(cleaned)
|
|
797
|
+
PY
|
|
798
|
+
write_targets="$(printf '%s' "$analyzer" |
|
|
799
|
+
MANAGED_EDIT_COMMAND="$command_str" \
|
|
800
|
+
MANAGED_EDIT_PROJECT="$project_root" \
|
|
801
|
+
python3 - 2>/dev/null || true)"
|
|
372
802
|
while IFS= read -r token; do
|
|
373
803
|
[ -n "$token" ] || continue
|
|
374
804
|
if source_path="$(managed_source "$token")"; then
|
|
375
805
|
refuse "$token" "$source_path" "$(relative_path "$token")"
|
|
376
806
|
fi
|
|
377
807
|
done <<EOF
|
|
378
|
-
$
|
|
379
|
-
grep -Eo ">>?\|?[[:space:]]*['\"]?[^ |;&'\"]+|tee([[:space:]]+-[a-zA-Z]+)*[[:space:]]+['\"]?[^ |;&'\"]+|sed[[:space:]]+[^|;&]*-i[^|;&]*[[:space:]]['\"]?[^ |;&'\"]+" 2>/dev/null |
|
|
380
|
-
sed -E "s/^(>>?\|?|tee([[:space:]]+-[a-zA-Z]+)*|sed[[:space:]]+[^|;&]*-i[^|;&]*)[[:space:]]*//" |
|
|
381
|
-
tr -d "\"'" || true)
|
|
808
|
+
$write_targets
|
|
382
809
|
EOF
|
|
383
810
|
;;
|
|
384
811
|
esac
|