@codyswann/lisa 4.73.1 → 4.73.2
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/lib/history-secret-git.mjs +158 -5
- package/all/copy-overwrite/scripts/lib/npm-update-contract.mjs +25 -1
- package/all/copy-overwrite/scripts/lisa-history-secrets.mjs +17 -3
- package/all/copy-overwrite/scripts/lisa-hooks/block-blind-automerge.sh +86 -7
- package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +7 -1
- package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +62 -7
- package/all/copy-overwrite/scripts/lisa-rails-prepush.mjs +4 -1
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +191 -5
- package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +5 -5
- package/all/deletions.json +19 -2
- package/dist/codex/scripts/block-no-verify.sh +62 -7
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.d.ts.map +1 -1
- 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 +78 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa.d.ts +31 -0
- package/dist/core/lisa.d.ts.map +1 -1
- package/dist/core/lisa.js +69 -4
- package/dist/core/lisa.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/retire-unmodified.d.ts +66 -0
- package/dist/core/retire-unmodified.d.ts.map +1 -0
- package/dist/core/retire-unmodified.js +244 -0
- package/dist/core/retire-unmodified.js.map +1 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +22 -16
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/block-managed-file-edits.sh +7 -1
- package/dist/opencode/plugin-templates/block-no-verify.sh +62 -7
- 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/auto-update.mjs +69 -1
- package/plugins/lisa/hooks/block-blind-automerge.sh +86 -7
- package/plugins/lisa/hooks/block-managed-file-edits.sh +7 -1
- package/plugins/lisa/hooks/block-no-verify.agy.sh +62 -7
- package/plugins/lisa/hooks/block-no-verify.sh +62 -7
- package/plugins/lisa-agy/hooks/block-blind-automerge.sh +86 -7
- package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +7 -1
- package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +62 -7
- 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/auto-update.mjs +69 -1
- package/plugins/lisa-copilot/hooks/block-blind-automerge.sh +86 -7
- package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +7 -1
- package/plugins/lisa-copilot/hooks/block-no-verify.sh +62 -7
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/auto-update.mjs +69 -1
- package/plugins/lisa-cursor/hooks/block-blind-automerge.sh +86 -7
- package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +7 -1
- package/plugins/lisa-cursor/hooks/block-no-verify.sh +62 -7
- 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/auto-update.mjs +69 -1
- package/plugins/src/base/hooks/block-blind-automerge.sh +86 -7
- package/plugins/src/base/hooks/block-managed-file-edits.sh +7 -1
- package/plugins/src/base/hooks/block-no-verify.agy.sh +62 -7
- package/plugins/src/base/hooks/block-no-verify.sh +62 -7
- package/scripts/check-shipped-surface-removals.mjs +22 -2
|
@@ -44,12 +44,15 @@ export const gitEnvironment = () => {
|
|
|
44
44
|
};
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
/** Replacement refs must never redirect which objects a proof reads. */
|
|
48
|
+
const NO_REPLACE_OBJECTS = "--no-replace-objects";
|
|
49
|
+
|
|
47
50
|
/** Git errors can contain arbitrary payloads; only authored guidance leaves here. */
|
|
48
|
-
export const gitRead = (args, cwd) => {
|
|
51
|
+
export const gitRead = (args, cwd, input) => {
|
|
49
52
|
const result = spawnSync(
|
|
50
53
|
"git",
|
|
51
54
|
[
|
|
52
|
-
|
|
55
|
+
NO_REPLACE_OBJECTS,
|
|
53
56
|
"-c",
|
|
54
57
|
"diff.external=",
|
|
55
58
|
"-c",
|
|
@@ -60,6 +63,7 @@ export const gitRead = (args, cwd) => {
|
|
|
60
63
|
cwd,
|
|
61
64
|
env: gitEnvironment(),
|
|
62
65
|
encoding: "utf8",
|
|
66
|
+
input,
|
|
63
67
|
timeout: 120000,
|
|
64
68
|
maxBuffer: 64 * 1024 * 1024,
|
|
65
69
|
}
|
|
@@ -151,8 +155,143 @@ export const eventPairs = (event, name, width) => {
|
|
|
151
155
|
);
|
|
152
156
|
};
|
|
153
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Git configuration exactly as the push itself sees it.
|
|
160
|
+
*
|
|
161
|
+
* gitEnvironment pins configuration so history reads cannot be steered, which
|
|
162
|
+
* is right for walking objects and wrong for naming a destination: the push
|
|
163
|
+
* resolved its URL, `pushurl`, `pushInsteadOf` and credentials through user,
|
|
164
|
+
* system and environment configuration, and a destination resolved under any
|
|
165
|
+
* other configuration may be a different repository.
|
|
166
|
+
*/
|
|
167
|
+
const remoteEnvironment = () => {
|
|
168
|
+
const environment = gitEnvironment();
|
|
169
|
+
for (const key of Object.keys(environment))
|
|
170
|
+
if (key.startsWith("GIT_CONFIG")) delete environment[key];
|
|
171
|
+
for (const [key, value] of Object.entries(process.env))
|
|
172
|
+
if (key.startsWith("GIT_CONFIG") && value !== undefined)
|
|
173
|
+
environment[key] = value;
|
|
174
|
+
// Never wait on a person: no terminal prompt and no askpass program, so a
|
|
175
|
+
// destination that needs an unavailable credential fails at once.
|
|
176
|
+
return { ...environment, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "" };
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Run one destination-resolution read under the push's own configuration.
|
|
181
|
+
* @param {string[]} args Git arguments.
|
|
182
|
+
* @param {string} cwd Repository directory.
|
|
183
|
+
* @returns {string | null} Trimmed stdout, or null when Git refused or failed.
|
|
184
|
+
*/
|
|
185
|
+
const remoteRead = (args, cwd) => {
|
|
186
|
+
const result = spawnSync("git", [NO_REPLACE_OBJECTS, ...args], {
|
|
187
|
+
cwd,
|
|
188
|
+
env: remoteEnvironment(),
|
|
189
|
+
encoding: "utf8",
|
|
190
|
+
timeout: 60000,
|
|
191
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
192
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
193
|
+
});
|
|
194
|
+
// probe-direction: fail-closed — every caller treats null as "the
|
|
195
|
+
// destination is unproved", which subtracts nothing from the scan.
|
|
196
|
+
if (result.error || result.signal || result.status !== 0) return null;
|
|
197
|
+
return result.stdout.trim();
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The repository a push is actually going to, from Git's pre-push arguments.
|
|
202
|
+
*
|
|
203
|
+
* Git calls the hook with the remote's name (or the URL, when the push named
|
|
204
|
+
* no remote) and the URL it pushes to, already rewritten by `pushInsteadOf`.
|
|
205
|
+
* That URL is the destination, so it wins when present. A wrapper that
|
|
206
|
+
* forwards only a remote NAME gets that remote's push URL, resolved under the
|
|
207
|
+
* push's own configuration; a remote with several push URLs, or a bare URL
|
|
208
|
+
* with no `$2`, cannot be pinned to one destination and returns null. A value
|
|
209
|
+
* that looks like an option is never used.
|
|
210
|
+
* @param {string[]} remoteArgs Git's pre-push `$1` and `$2`, as forwarded.
|
|
211
|
+
* @param {string} cwd Repository directory.
|
|
212
|
+
* @returns {string | null} The destination, or null when it cannot be proved.
|
|
213
|
+
*/
|
|
214
|
+
export const pushDestination = (remoteArgs, cwd) => {
|
|
215
|
+
const [remote, url] = remoteArgs ?? [];
|
|
216
|
+
const usable = value =>
|
|
217
|
+
typeof value === "string" && value !== "" && !value.startsWith("-");
|
|
218
|
+
if (usable(url)) return url;
|
|
219
|
+
if (!usable(remote)) return null;
|
|
220
|
+
const names = (remoteRead(["remote"], cwd) ?? "").split("\n");
|
|
221
|
+
if (!names.includes(remote)) return null;
|
|
222
|
+
const urls = (
|
|
223
|
+
remoteRead(["remote", "get-url", "--push", "--all", "--", remote], cwd) ??
|
|
224
|
+
""
|
|
225
|
+
)
|
|
226
|
+
.split("\n")
|
|
227
|
+
.filter(Boolean);
|
|
228
|
+
return urls.length === 1 ? urls[0] : null;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Commit IDs the push destination advertises right now that exist locally.
|
|
233
|
+
*
|
|
234
|
+
* Asked of the destination itself (`git ls-remote`), not read from local
|
|
235
|
+
* tracking refs: a tracking ref can outlive a branch deleted or reset on the
|
|
236
|
+
* remote, and with a separate pushurl it describes a different repository, so
|
|
237
|
+
* either would exclude commits the destination lacks. An advertised ID this
|
|
238
|
+
* repository does not have cannot bound a local walk and is dropped, which
|
|
239
|
+
* only ever widens the scan.
|
|
240
|
+
* @param {string | null} destination The push destination from pushDestination.
|
|
241
|
+
* @param {string} cwd Repository directory.
|
|
242
|
+
* @returns {string[]} IDs to exclude; empty when nothing could be proved.
|
|
243
|
+
*/
|
|
244
|
+
export const advertisedTips = (destination, cwd) => {
|
|
245
|
+
if (destination === null) return [];
|
|
246
|
+
// An `insteadOf` rule can rewrite even a URL Git already rewrote for the
|
|
247
|
+
// push, and then the probe would describe another repository. Trust the
|
|
248
|
+
// advertisement only when ls-remote would contact the destination itself.
|
|
249
|
+
// probe-direction: fail-closed — a URL that cannot be confirmed subtracts
|
|
250
|
+
// nothing.
|
|
251
|
+
if (
|
|
252
|
+
remoteRead(["ls-remote", "--get-url", "--", destination], cwd) !==
|
|
253
|
+
destination
|
|
254
|
+
)
|
|
255
|
+
return [];
|
|
256
|
+
const listed = spawnSync(
|
|
257
|
+
"git",
|
|
258
|
+
[NO_REPLACE_OBJECTS, "ls-remote", "--", destination],
|
|
259
|
+
{
|
|
260
|
+
cwd,
|
|
261
|
+
env: remoteEnvironment(),
|
|
262
|
+
encoding: "utf8",
|
|
263
|
+
timeout: 60000,
|
|
264
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
265
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
266
|
+
}
|
|
267
|
+
);
|
|
268
|
+
// probe-direction: fail-closed — a destination that cannot be asked
|
|
269
|
+
// advertises nothing, so nothing is subtracted and the complete history is
|
|
270
|
+
// scanned.
|
|
271
|
+
if (listed.error || listed.signal || listed.status !== 0) return [];
|
|
272
|
+
const advertised = [
|
|
273
|
+
...new Set(
|
|
274
|
+
listed.stdout
|
|
275
|
+
.split("\n")
|
|
276
|
+
.map(line => line.split("\t")[0])
|
|
277
|
+
.filter(id => /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(id))
|
|
278
|
+
),
|
|
279
|
+
];
|
|
280
|
+
if (advertised.length === 0) return [];
|
|
281
|
+
const present = gitRead(
|
|
282
|
+
["cat-file", "--batch-check=%(objectname) %(objecttype)"],
|
|
283
|
+
cwd,
|
|
284
|
+
advertised.map(id => `${id}\n`).join("")
|
|
285
|
+
);
|
|
286
|
+
return present
|
|
287
|
+
.split("\n")
|
|
288
|
+
.map(line => line.split(" "))
|
|
289
|
+
.filter(([, type]) => type === "commit" || type === "tag")
|
|
290
|
+
.map(([id]) => id);
|
|
291
|
+
};
|
|
292
|
+
|
|
154
293
|
/** Pairwise subtraction prevents one ref's old tip from hiding another update. */
|
|
155
|
-
export const introducedCommits = (pairs, cwd, width) => {
|
|
294
|
+
export const introducedCommits = (pairs, cwd, width, remoteArgs = []) => {
|
|
156
295
|
const oid = new RegExp(`^[a-f0-9]{${width}}$`, "u");
|
|
157
296
|
for (const pair of pairs) {
|
|
158
297
|
if (!oid.test(pair.before ?? "") || !oid.test(pair.after ?? ""))
|
|
@@ -175,6 +314,16 @@ export const introducedCommits = (pairs, cwd, width) => {
|
|
|
175
314
|
"Legacy Git grafts prevent complete history proof. Use an unmodified complete repository checkout."
|
|
176
315
|
);
|
|
177
316
|
const commits = new Set();
|
|
317
|
+
// A new remote ref (zero `before`) has no old tip to subtract, and scanning
|
|
318
|
+
// everything reachable from it counts the remote's own published history as
|
|
319
|
+
// introduced: every new branch of an old repository then re-litigates its
|
|
320
|
+
// root commit. What a push introduces is what the destination does not
|
|
321
|
+
// already hold, so a new ref subtracts what the push destination advertises.
|
|
322
|
+
// Without a destination, or one that cannot be asked, nothing is subtracted
|
|
323
|
+
// and the complete reachable history is scanned as before.
|
|
324
|
+
const published = updates.some(pair => /^0+$/u.test(pair.before))
|
|
325
|
+
? advertisedTips(pushDestination(remoteArgs, cwd), cwd)
|
|
326
|
+
: [];
|
|
178
327
|
for (const { before, after } of updates) {
|
|
179
328
|
const next = gitRead(["rev-parse", "--verify", `${after}^{commit}`], cwd);
|
|
180
329
|
const previous = /^0+$/u.test(before)
|
|
@@ -190,9 +339,13 @@ export const introducedCommits = (pairs, cwd, width) => {
|
|
|
190
339
|
"Required Git trees or blobs are missing. Fetch complete objects, repair the checkout and retry."
|
|
191
340
|
);
|
|
192
341
|
}
|
|
342
|
+
// Exclusions go through stdin: a remote can hold more refs than one
|
|
343
|
+
// command line carries.
|
|
344
|
+
const exclusions = previous ? [previous] : published;
|
|
193
345
|
const list = gitRead(
|
|
194
|
-
["rev-list",
|
|
195
|
-
cwd
|
|
346
|
+
["rev-list", "--stdin", next],
|
|
347
|
+
cwd,
|
|
348
|
+
exclusions.map(oid => `^${oid}\n`).join("")
|
|
196
349
|
);
|
|
197
350
|
for (const commit of list.split("\n").filter(Boolean)) commits.add(commit);
|
|
198
351
|
}
|
|
@@ -109,7 +109,31 @@ export function validateLock(lock, manifest, updates) {
|
|
|
109
109
|
!item.link,
|
|
110
110
|
"unsupported lock node"
|
|
111
111
|
);
|
|
112
|
-
|
|
112
|
+
// A bundled dependency ships inside its parent's tarball, so npm records
|
|
113
|
+
// no source or integrity of its own: the parent's registry integrity is
|
|
114
|
+
// what covers its bytes. Accept it only nested under a parent this loop
|
|
115
|
+
// also validates, and never when it claims a source of its own.
|
|
116
|
+
if (item.inBundle === true) {
|
|
117
|
+
required(
|
|
118
|
+
item.resolved === undefined && item.integrity === undefined,
|
|
119
|
+
"bundled lock node declares its own source"
|
|
120
|
+
);
|
|
121
|
+
const boundary = name.lastIndexOf("/node_modules/");
|
|
122
|
+
const parent = boundary < 0 ? "" : name.slice(0, boundary);
|
|
123
|
+
required(
|
|
124
|
+
parent.startsWith("node_modules/") &&
|
|
125
|
+
Object.hasOwn(lock.packages, parent),
|
|
126
|
+
"bundled lock node has no registry parent"
|
|
127
|
+
);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
required(typeof item.resolved === "string", "registry source missing");
|
|
131
|
+
let url;
|
|
132
|
+
try {
|
|
133
|
+
url = new URL(item.resolved);
|
|
134
|
+
} catch {
|
|
135
|
+
required(false, "registry source unparseable");
|
|
136
|
+
}
|
|
113
137
|
required(
|
|
114
138
|
url.protocol === "https:" &&
|
|
115
139
|
url.hostname === "registry.npmjs.org" &&
|
|
@@ -41,9 +41,23 @@ export const main = async (
|
|
|
41
41
|
const scannerIndex = args.indexOf("--scanner");
|
|
42
42
|
const scanner = scannerIndex < 0 ? undefined : args[scannerIndex + 1];
|
|
43
43
|
const mode = args[0];
|
|
44
|
+
// Git hands a pre-push hook the remote's name (or URL) and URL as $1/$2.
|
|
45
|
+
// Both are optional so an older wrapper that forwards neither keeps the
|
|
46
|
+
// complete-history scan; neither may look like an option.
|
|
47
|
+
const remoteArgs =
|
|
48
|
+
mode === "pre-push"
|
|
49
|
+
? args.slice(1, scannerIndex < 0 ? undefined : scannerIndex)
|
|
50
|
+
: [];
|
|
44
51
|
const accepted =
|
|
45
52
|
mode === "pre-push"
|
|
46
|
-
? [
|
|
53
|
+
? [
|
|
54
|
+
"pre-push",
|
|
55
|
+
...(remoteArgs.length <= 2 &&
|
|
56
|
+
remoteArgs.every(value => value !== "" && !value.startsWith("-"))
|
|
57
|
+
? remoteArgs
|
|
58
|
+
: []),
|
|
59
|
+
...(scanner ? ["--scanner", scanner] : []),
|
|
60
|
+
]
|
|
47
61
|
: [
|
|
48
62
|
"ci",
|
|
49
63
|
...(args[1] === "--event"
|
|
@@ -56,7 +70,7 @@ export const main = async (
|
|
|
56
70
|
!["pre-push", "ci"].includes(mode)
|
|
57
71
|
)
|
|
58
72
|
throw new HistorySecretError(
|
|
59
|
-
"Invalid scanner command. Use pre-push with actual stdin or ci --event <event-json> --event-name <push|pull_request>; optional --scanner <pinned-executable>."
|
|
73
|
+
"Invalid scanner command. Use pre-push [<remote> [<url>]] with actual stdin or ci --event <event-json> --event-name <push|pull_request>; optional --scanner <pinned-executable>."
|
|
60
74
|
);
|
|
61
75
|
const width = objectWidth(cwd);
|
|
62
76
|
let pairs;
|
|
@@ -87,7 +101,7 @@ export const main = async (
|
|
|
87
101
|
width
|
|
88
102
|
);
|
|
89
103
|
}
|
|
90
|
-
const commits = introducedCommits(pairs, cwd, width);
|
|
104
|
+
const commits = introducedCommits(pairs, cwd, width, remoteArgs);
|
|
91
105
|
if (commits.length === 0) {
|
|
92
106
|
console.log("No introduced history. Scanner was not run.");
|
|
93
107
|
return 0;
|
|
@@ -211,6 +211,7 @@ if [ -z "$command_str" ]; then
|
|
|
211
211
|
fi
|
|
212
212
|
|
|
213
213
|
if ! BLOCK_BLIND_AUTOMERGE_COMMAND="$command_str" python3 - <<'PY'
|
|
214
|
+
import io
|
|
214
215
|
import json
|
|
215
216
|
import os
|
|
216
217
|
import re
|
|
@@ -244,7 +245,41 @@ MERGE_SEPARATE_VALUE = {
|
|
|
244
245
|
|
|
245
246
|
COMMAND_SEPARATORS = {
|
|
246
247
|
";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
|
|
248
|
+
# `|&` pipes stdout AND stderr into the next command, so it ends a command
|
|
249
|
+
# exactly as `|` does. shlex emits it as one token, and before it was listed
|
|
250
|
+
# here `echo hi |& git commit --no-verify` read as a single `echo` command
|
|
251
|
+
# whose arguments happened to include git. The case terminators end a
|
|
252
|
+
# command the same way.
|
|
253
|
+
"|&", ";;", ";&", ";;&",
|
|
247
254
|
}
|
|
255
|
+
# shlex also GLUES adjacent punctuation into one token across distinct
|
|
256
|
+
# operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
|
|
257
|
+
# test can recognise. An operator-only token is therefore re-split into the
|
|
258
|
+
# operators it is made of, longest first. Redirections are kept whole and are
|
|
259
|
+
# deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
|
|
260
|
+
# commit's argv scope.
|
|
261
|
+
OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
|
|
262
|
+
SHELL_OPERATOR = re.compile("|".join(
|
|
263
|
+
re.escape(operator) for operator in sorted(
|
|
264
|
+
COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
|
|
265
|
+
key=lambda operator: (-len(operator), operator),
|
|
266
|
+
)
|
|
267
|
+
))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def split_operators(token):
|
|
271
|
+
"""Split a glued operator-only token into the shell operators it holds.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
token: One shlex token that was not read inside a quote or escape.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
The operators, longest first, or the token itself when it is not
|
|
278
|
+
made only of shell punctuation.
|
|
279
|
+
"""
|
|
280
|
+
if OPERATOR_ONLY.match(token):
|
|
281
|
+
return SHELL_OPERATOR.findall(token)
|
|
282
|
+
return [token]
|
|
248
283
|
|
|
249
284
|
# Shell wrappers whose `-c` argument is a command in a string rather than argv.
|
|
250
285
|
# The outer argv names bash, so the arming is invisible without recursing once.
|
|
@@ -422,12 +457,40 @@ def shell_tokens(text):
|
|
|
422
457
|
Returns:
|
|
423
458
|
The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
|
|
424
459
|
"""
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
)
|
|
460
|
+
stream = OperatorStream(line_boundaries_as_separators(text))
|
|
461
|
+
lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
|
|
428
462
|
lexer.whitespace_split = True
|
|
429
463
|
lexer.commenters = ""
|
|
430
|
-
|
|
464
|
+
stream.lexer = lexer
|
|
465
|
+
tokens = []
|
|
466
|
+
while True:
|
|
467
|
+
stream.literal_operator = False
|
|
468
|
+
token = lexer.get_token()
|
|
469
|
+
if token is None:
|
|
470
|
+
return tokens
|
|
471
|
+
# Quoted punctuation is an argument, never a boundary: a branch can be
|
|
472
|
+
# named `|&`, and `gh pr merge '|&' --auto` must keep `--auto` in the
|
|
473
|
+
# merge's own argv. Only real operator runs are split.
|
|
474
|
+
if stream.literal_operator:
|
|
475
|
+
tokens.append(
|
|
476
|
+
chr(0) + token if token in COMMAND_SEPARATORS else token
|
|
477
|
+
)
|
|
478
|
+
else:
|
|
479
|
+
tokens.extend(split_operators(token))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class OperatorStream(io.StringIO):
|
|
483
|
+
"""Remember punctuation read while shlex is inside a quote or escape."""
|
|
484
|
+
|
|
485
|
+
lexer = None
|
|
486
|
+
literal_operator = False
|
|
487
|
+
|
|
488
|
+
def read(self, size=-1):
|
|
489
|
+
value = super().read(size)
|
|
490
|
+
if (self.lexer is not None and self.lexer.state in {"'", '"', chr(92)}
|
|
491
|
+
and any(char in "();|&<>" for char in value)):
|
|
492
|
+
self.literal_operator = True
|
|
493
|
+
return value
|
|
431
494
|
|
|
432
495
|
|
|
433
496
|
def is_gh(token):
|
|
@@ -493,6 +556,22 @@ def arms_auto_merge(argv):
|
|
|
493
556
|
return False
|
|
494
557
|
|
|
495
558
|
|
|
559
|
+
def literal_value(token):
|
|
560
|
+
"""The argument a token stands for, without the quoted-separator marker.
|
|
561
|
+
|
|
562
|
+
`shell_tokens` prefixes a QUOTED separator with NUL so no scan stops at it.
|
|
563
|
+
That marker is for boundary checks only: a value handed to a gh probe
|
|
564
|
+
(a selector, a base ref, a repo) must be the argument the shell passes.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
token: One token from `shell_tokens`.
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
The token as the shell would pass it.
|
|
571
|
+
"""
|
|
572
|
+
return token[1:] if token.startswith(chr(0)) else token
|
|
573
|
+
|
|
574
|
+
|
|
496
575
|
def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
|
|
497
576
|
"""The PR the command names, as gh itself would read it.
|
|
498
577
|
|
|
@@ -518,7 +597,7 @@ def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
|
|
|
518
597
|
index += 1
|
|
519
598
|
continue
|
|
520
599
|
if not token.startswith("-"):
|
|
521
|
-
return token
|
|
600
|
+
return literal_value(token)
|
|
522
601
|
return None
|
|
523
602
|
|
|
524
603
|
|
|
@@ -538,7 +617,7 @@ def retarget_target(argv):
|
|
|
538
617
|
if token in COMMAND_SEPARATORS:
|
|
539
618
|
return None
|
|
540
619
|
if token in BASE_FLAGS and index < len(argv):
|
|
541
|
-
return argv[index]
|
|
620
|
+
return literal_value(argv[index])
|
|
542
621
|
if token.startswith("--base="):
|
|
543
622
|
return token.split("=", 1)[1]
|
|
544
623
|
if token in EDIT_SEPARATE_VALUE:
|
|
@@ -562,7 +641,7 @@ def repo_flag(tokens, start):
|
|
|
562
641
|
if token in COMMAND_SEPARATORS:
|
|
563
642
|
break
|
|
564
643
|
if token in {"-R", "--repo"} and index + 1 < len(tokens):
|
|
565
|
-
return ["--repo", tokens[index + 1]]
|
|
644
|
+
return ["--repo", literal_value(tokens[index + 1])]
|
|
566
645
|
if token.startswith("--repo="):
|
|
567
646
|
return ["--repo", token.split("=", 1)[1]]
|
|
568
647
|
index += 1
|
|
@@ -597,7 +597,13 @@ ENV_SPLIT_UNRESOLVED = 42
|
|
|
597
597
|
BACKSLASH = chr(92)
|
|
598
598
|
NEWLINE = chr(10)
|
|
599
599
|
|
|
600
|
-
|
|
600
|
+
# `|&` pipes stdout and stderr into the next command, and the case terminators
|
|
601
|
+
# end a statement as `;` does. Without them `printf x |& tee <managed>` stayed
|
|
602
|
+
# one statement whose command word was `printf`, so the `tee` write target was
|
|
603
|
+
# never examined.
|
|
604
|
+
STATEMENT_SEPARATORS = {
|
|
605
|
+
";", "|", "||", "&", "&&", "(", ")", "&|", "|&", ";;", ";&", ";;&",
|
|
606
|
+
}
|
|
601
607
|
# Ordinary output, noclobber override, and combined stdout/stderr output.
|
|
602
608
|
# shlex separates fd prefixes (e.g. 10>>) from the operator automatically.
|
|
603
609
|
REDIRECT_OUT = {">", ">>", ">|", "&>", "&>>", ">&"}
|
|
@@ -271,7 +271,41 @@ def disables_verification(token):
|
|
|
271
271
|
# tokens, so the invocation's boundary is a token the scan can stop at.
|
|
272
272
|
COMMAND_SEPARATORS = {
|
|
273
273
|
";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
|
|
274
|
+
# `|&` pipes stdout AND stderr into the next command, so it ends a command
|
|
275
|
+
# exactly as `|` does. shlex emits it as one token, and before it was listed
|
|
276
|
+
# here `echo hi |& git commit --no-verify` read as a single `echo` command
|
|
277
|
+
# whose arguments happened to include git. The case terminators end a
|
|
278
|
+
# command the same way.
|
|
279
|
+
"|&", ";;", ";&", ";;&",
|
|
274
280
|
}
|
|
281
|
+
# shlex also GLUES adjacent punctuation into one token across distinct
|
|
282
|
+
# operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
|
|
283
|
+
# test can recognise. An operator-only token is therefore re-split into the
|
|
284
|
+
# operators it is made of, longest first. Redirections are kept whole and are
|
|
285
|
+
# deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
|
|
286
|
+
# commit's argv scope.
|
|
287
|
+
OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
|
|
288
|
+
SHELL_OPERATOR = re.compile("|".join(
|
|
289
|
+
re.escape(operator) for operator in sorted(
|
|
290
|
+
COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
|
|
291
|
+
key=lambda operator: (-len(operator), operator),
|
|
292
|
+
)
|
|
293
|
+
))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def split_operators(token):
|
|
297
|
+
"""Split a glued operator-only token into the shell operators it holds.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
token: One shlex token that was not read inside a quote or escape.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
The operators, longest first, or the token itself when it is not
|
|
304
|
+
made only of shell punctuation.
|
|
305
|
+
"""
|
|
306
|
+
if OPERATOR_ONLY.match(token):
|
|
307
|
+
return SHELL_OPERATOR.findall(token)
|
|
308
|
+
return [token]
|
|
275
309
|
|
|
276
310
|
SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
|
|
277
311
|
MAX_SHELL_NESTING = 8
|
|
@@ -425,14 +459,28 @@ def shell_tokens(text):
|
|
|
425
459
|
Returns:
|
|
426
460
|
The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
|
|
427
461
|
"""
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
)
|
|
462
|
+
stream = OperatorStream(line_boundaries_as_separators(text))
|
|
463
|
+
lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
|
|
431
464
|
lexer.whitespace_split = True
|
|
432
465
|
# shlex treats `#` as a comment introducer by default and would silently
|
|
433
466
|
# truncate the rest of the line; `shlex.split` disables it, and so must this.
|
|
434
467
|
lexer.commenters = ""
|
|
435
|
-
|
|
468
|
+
stream.lexer = lexer
|
|
469
|
+
tokens = []
|
|
470
|
+
while True:
|
|
471
|
+
stream.literal_operator = False
|
|
472
|
+
token = lexer.get_token()
|
|
473
|
+
if token is None:
|
|
474
|
+
return tokens
|
|
475
|
+
# Quoted punctuation is an argument, never a boundary: it stays whole,
|
|
476
|
+
# and a quoted separator is marked so no scan stops at it (a path or
|
|
477
|
+
# branch can be named `|&`). Only real operator runs are split.
|
|
478
|
+
if stream.literal_operator:
|
|
479
|
+
tokens.append(
|
|
480
|
+
chr(0) + token if token in COMMAND_SEPARATORS else token
|
|
481
|
+
)
|
|
482
|
+
else:
|
|
483
|
+
tokens.extend(split_operators(token))
|
|
436
484
|
|
|
437
485
|
|
|
438
486
|
def cluster_skips_verification(cluster):
|
|
@@ -1048,6 +1096,10 @@ def resolve_script(token):
|
|
|
1048
1096
|
"""
|
|
1049
1097
|
if COMPUTED_VALUE.search(token):
|
|
1050
1098
|
return (None, "a computed path the guard cannot resolve before the shell does")
|
|
1099
|
+
# A quoted separator carries a NUL marker from shell_tokens; the path the
|
|
1100
|
+
# shell passes is the argument without it.
|
|
1101
|
+
if token.startswith(chr(0)):
|
|
1102
|
+
token = token[1:]
|
|
1051
1103
|
text = os.path.expanduser(token.strip().strip("'\""))
|
|
1052
1104
|
# `bash -` and a bare `-` read the script from stdin; there is no file.
|
|
1053
1105
|
if not text or text == "-":
|
|
@@ -1998,9 +2050,12 @@ def flat_tokens(text):
|
|
|
1998
2050
|
# shlex otherwise makes quoted ";" indistinguishable from a real
|
|
1999
2051
|
# command boundary. Keep literal destinations as data, so a setter
|
|
2000
2052
|
# cannot masquerade as a one-operand configuration read.
|
|
2001
|
-
if
|
|
2002
|
-
token
|
|
2003
|
-
|
|
2053
|
+
if stream.literal_operator:
|
|
2054
|
+
if token in COMMAND_SEPARATORS:
|
|
2055
|
+
token = "\x00" + token
|
|
2056
|
+
tokens.append(token)
|
|
2057
|
+
else:
|
|
2058
|
+
tokens.extend(split_operators(token))
|
|
2004
2059
|
return [
|
|
2005
2060
|
token
|
|
2006
2061
|
for index, token in enumerate(tokens)
|
|
@@ -68,7 +68,10 @@ export const main = async (remoteArgs = process.argv.slice(2)) => {
|
|
|
68
68
|
// Import/call inside this boundary instead of forwarding raw Node bootstrap
|
|
69
69
|
// stderr. The scanner receives exactly the buffered Git stream.
|
|
70
70
|
const { main: scan } = await import("./lisa-history-secrets.mjs");
|
|
71
|
-
|
|
71
|
+
// Git's remote arguments bound a new ref's range by what that remote
|
|
72
|
+
// already holds; without them a new branch rescans the whole history.
|
|
73
|
+
if ((await scan(["pre-push", ...remoteArgs], process.cwd(), input)) !== 0)
|
|
74
|
+
failed = true;
|
|
72
75
|
} catch {
|
|
73
76
|
console.error(
|
|
74
77
|
"Managed history scanner is unavailable or damaged. Run full Lisa apply and retry; raw bootstrap errors are withheld."
|