@codyswann/lisa 4.84.4 → 4.84.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/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +4 -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 +20 -6
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- 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-host-name-leak.mjs +118 -40
- package/plugins/lisa/hooks/block-host-name-leak.sh +5 -0
- package/plugins/lisa/hooks/host-name-command-context.mjs +397 -0
- package/plugins/lisa-agy/hooks/block-host-name-leak.mjs +118 -40
- package/plugins/lisa-agy/hooks/block-host-name-leak.sh +5 -0
- package/plugins/lisa-agy/hooks/host-name-command-context.mjs +397 -0
- 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-cdk-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-devin/.devin-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/block-host-name-leak.mjs +118 -40
- package/plugins/lisa-copilot/hooks/block-host-name-leak.sh +5 -0
- package/plugins/lisa-copilot/hooks/host-name-command-context.mjs +397 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-host-name-leak.mjs +118 -40
- package/plugins/lisa-cursor/hooks/block-host-name-leak.sh +5 -0
- package/plugins/lisa-cursor/hooks/host-name-command-context.mjs +397 -0
- package/plugins/lisa-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-devin/.devin-plugin/plugin.json +1 -1
- package/plugins/lisa-devin/hooks/block-host-name-leak.mjs +118 -40
- package/plugins/lisa-devin/hooks/block-host-name-leak.sh +5 -0
- package/plugins/lisa-devin/hooks/host-name-command-context.mjs +397 -0
- 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-expo-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-devin/.devin-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-harper-fabric-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-devin/.devin-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-nestjs-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-devin/.devin-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-openclaw-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-devin/.devin-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-phaser-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-devin/.devin-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-rails-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-devin/.devin-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-typescript-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-devin/.devin-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/lisa-wiki-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-devin/.devin-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/block-host-name-leak.mjs +118 -40
- package/plugins/src/base/hooks/block-host-name-leak.sh +5 -0
- package/plugins/src/base/hooks/host-name-command-context.mjs +397 -0
- package/scripts/generate-agy-plugin-artifacts.mjs +7 -3
- package/scripts/lib/host-name-review-cases.mjs +354 -0
- package/scripts/lib/host-name-target-cases.mjs +298 -0
- package/scripts/two-channel-couplings.json +29 -6
- package/scripts/verify-host-name-target-scope.mjs +347 -0
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +139 -48
- package/typescript/create-only/.github/workflows/review-evidence.yml +2 -2
package/package.json
CHANGED
|
@@ -189,7 +189,7 @@
|
|
|
189
189
|
"qs": "^6.16.0"
|
|
190
190
|
},
|
|
191
191
|
"name": "@codyswann/lisa",
|
|
192
|
-
"version": "4.84.
|
|
192
|
+
"version": "4.84.6",
|
|
193
193
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
194
194
|
"main": "dist/index.js",
|
|
195
195
|
"exports": {
|
|
@@ -347,7 +347,7 @@
|
|
|
347
347
|
"test": "tests"
|
|
348
348
|
},
|
|
349
349
|
"types": "./dist/index.d.ts",
|
|
350
|
-
"lisaReleaseCommit": "
|
|
351
|
-
"gitHead": "
|
|
352
|
-
"lisaReleaseTag": "v4.84.
|
|
350
|
+
"lisaReleaseCommit": "26506db550db7f036466d2978c8a6448fa1f7f89",
|
|
351
|
+
"gitHead": "26506db550db7f036466d2978c8a6448fa1f7f89",
|
|
352
|
+
"lisaReleaseTag": "v4.84.6"
|
|
353
353
|
}
|
|
@@ -53,6 +53,24 @@
|
|
|
53
53
|
* tempted to add it to `plugins/materialized-artifacts.json` should have a
|
|
54
54
|
* reason why a host project should be refused its own name.
|
|
55
55
|
*
|
|
56
|
+
* WHERE IT APPLIES: THE TARGET, NOT THE CHECKOUT (#4462)
|
|
57
|
+
*
|
|
58
|
+
* Not materializing it was not enough. The guard is registered by the PLUGIN,
|
|
59
|
+
* so it runs in every project the plugin is enabled in, and every host has the
|
|
60
|
+
* denylist at `node_modules/@codyswann/lisa/dist/`. Inside a host it refused
|
|
61
|
+
* that host's own tracker writes — including the `Work-Item: <owner>/<repo>#<n>`
|
|
62
|
+
* line the work-item gate REQUIRES in every pull-request body, so the two Lisa
|
|
63
|
+
* guards contradicted each other and every agent-opened host PR failed
|
|
64
|
+
* traceability.
|
|
65
|
+
*
|
|
66
|
+
* So the guard resolves the active gh call's explicit repository or API
|
|
67
|
+
* endpoint, then GH_REPO, the locally selected gh default, or one unambiguous
|
|
68
|
+
* Git remote. Literal cd and environment assignments use the command's actual
|
|
69
|
+
* directory; the project root still locates the detector. Body text is never
|
|
70
|
+
* routing. Unsupported shell syntax, unresolved expansions and ambiguous
|
|
71
|
+
* repositories earn no host exemption. This is bounded command inspection,
|
|
72
|
+
* not shell execution or a claim to close every shell/identity leak class.
|
|
73
|
+
*
|
|
56
74
|
* FAILING OPEN, LOUDLY
|
|
57
75
|
*
|
|
58
76
|
* When the denylist cannot be loaded this permits the write and says so on
|
|
@@ -65,6 +83,13 @@
|
|
|
65
83
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
66
84
|
import path from "node:path";
|
|
67
85
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
86
|
+
import {
|
|
87
|
+
commandRepository,
|
|
88
|
+
ghCommands,
|
|
89
|
+
repositoryFromSpec,
|
|
90
|
+
} from "./host-name-command-context.mjs";
|
|
91
|
+
|
|
92
|
+
export { repositoryFromSpec };
|
|
68
93
|
|
|
69
94
|
/** Claude Code's refusal code. Every other non-zero exit is a hook error. */
|
|
70
95
|
const EXIT_BLOCKED = 2;
|
|
@@ -110,10 +135,17 @@ const TEXT_FLAGS = new Set([
|
|
|
110
135
|
"-m",
|
|
111
136
|
]);
|
|
112
137
|
/** `gh api` field flags: `key=value`, or `key=@path` to read a file. */
|
|
113
|
-
const FIELD_FLAGS = new Set(["-f", "--field", "--raw-field"]);
|
|
138
|
+
const FIELD_FLAGS = new Set(["-f", "-F", "--field", "--raw-field"]);
|
|
114
139
|
/** Field names whose value is published prose rather than metadata. */
|
|
115
140
|
const PROSE_FIELDS = new Set(["body", "title", "text", "message"]);
|
|
116
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The repositories this guard protects, lowercased `owner/repo`.
|
|
144
|
+
*
|
|
145
|
+
* Only this one: it is public and ships `dist/` to npm. A host's own tracker
|
|
146
|
+
* is not on the list, which is the point (#4462).
|
|
147
|
+
*/
|
|
148
|
+
const GUARDED_REPOSITORIES = new Set(["codyswanngt/lisa"]);
|
|
117
149
|
/**
|
|
118
150
|
* Vendor endpoints quoted from guard source, which are not host identities.
|
|
119
151
|
*
|
|
@@ -173,30 +205,10 @@ export function tokenize(command) {
|
|
|
173
205
|
* @returns True when the command publishes text.
|
|
174
206
|
*/
|
|
175
207
|
export function isOutboundTrackerWrite(tokens) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (noun === "api") return isMutatingApiCall(tokens);
|
|
181
|
-
return WRITE_NOUNS.has(noun ?? "") && WRITE_VERBS.has(verb ?? "");
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Whether a `gh api` call mutates.
|
|
186
|
-
*
|
|
187
|
-
* `gh api` defaults to GET, and to POST when any field flag is present — so a
|
|
188
|
-
* field flag alone makes it a write even with no explicit method.
|
|
189
|
-
* @param tokens - Command tokens.
|
|
190
|
-
* @returns True when the call changes state.
|
|
191
|
-
*/
|
|
192
|
-
function isMutatingApiCall(tokens) {
|
|
193
|
-
const method = tokens.findIndex(
|
|
194
|
-
token => token === "-X" || token === "--method"
|
|
195
|
-
);
|
|
196
|
-
if (method !== -1) {
|
|
197
|
-
return MUTATING_METHODS.has((tokens[method + 1] ?? "").toUpperCase());
|
|
198
|
-
}
|
|
199
|
-
return tokens.some(token => FIELD_FLAGS.has(token));
|
|
208
|
+
return ghCommands(
|
|
209
|
+
tokens.map(token => JSON.stringify(token)).join(" "),
|
|
210
|
+
process.cwd()
|
|
211
|
+
).some(context => context.writes);
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
/**
|
|
@@ -212,6 +224,32 @@ function valueAt(tokens, index) {
|
|
|
212
224
|
return tokens[index + 1] ?? "";
|
|
213
225
|
}
|
|
214
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Resolve the target of one tokenized command without treating prose as routing.
|
|
229
|
+
* @param tokens Command tokens.
|
|
230
|
+
* @param cwd Actual command working directory.
|
|
231
|
+
* @returns Verified owner/repository, or unknown.
|
|
232
|
+
*/
|
|
233
|
+
export function targetRepository(tokens, cwd) {
|
|
234
|
+
const context = ghCommands(
|
|
235
|
+
tokens.map(token => JSON.stringify(token)).join(" "),
|
|
236
|
+
cwd
|
|
237
|
+
)[0];
|
|
238
|
+
return context === undefined ? undefined : commandRepository(context);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Whether the guard applies to a write landing in this repository.
|
|
243
|
+
*
|
|
244
|
+
* An undetermined target is guarded: not knowing where text is going is no
|
|
245
|
+
* reason to stop protecting the one place it must not go.
|
|
246
|
+
* @param target - `owner/repo`, or undefined.
|
|
247
|
+
* @returns True when the write must be checked.
|
|
248
|
+
*/
|
|
249
|
+
export function isGuardedTarget(target) {
|
|
250
|
+
return target === undefined || GUARDED_REPOSITORIES.has(target);
|
|
251
|
+
}
|
|
252
|
+
|
|
215
253
|
/**
|
|
216
254
|
* Read a file the command names, if it is readable.
|
|
217
255
|
* @param candidate - Path as written on the command line.
|
|
@@ -236,16 +274,22 @@ function readIfPresent(candidate, cwd) {
|
|
|
236
274
|
* from so the refusal can point at the thing to edit.
|
|
237
275
|
* @param tokens - Command tokens.
|
|
238
276
|
* @param cwd - Directory the command runs in.
|
|
277
|
+
* @param noun - The active gh noun, so api fields retain their own meaning.
|
|
239
278
|
* @returns Sources of publishable text.
|
|
240
279
|
*/
|
|
241
|
-
export function publishableText(tokens, cwd) {
|
|
280
|
+
export function publishableText(tokens, cwd, noun) {
|
|
242
281
|
return tokens.flatMap((token, index) => {
|
|
243
282
|
const bare = token.startsWith("-") ? token.split("=")[0] : token;
|
|
244
|
-
if (TEXT_FLAGS.has(bare)) {
|
|
283
|
+
if (noun !== "api" && TEXT_FLAGS.has(bare)) {
|
|
245
284
|
return [{ origin: bare, text: valueAt(tokens, index) }];
|
|
246
285
|
}
|
|
247
286
|
if (FILE_FLAGS.has(bare)) {
|
|
248
|
-
const
|
|
287
|
+
const value = valueAt(tokens, index);
|
|
288
|
+
// On `gh api`, `-F` is `--field`: `key=@path` or `key=value`. Read as a
|
|
289
|
+
// bare path, `body=@file` named no file and published unchecked (#4462).
|
|
290
|
+
if (noun === "api" && /^[\w.[\]-]+=/u.test(value))
|
|
291
|
+
return [fieldSource(value, cwd)];
|
|
292
|
+
const file = value.replace(/^@/u, "");
|
|
249
293
|
return [{ origin: file, text: readIfPresent(file, cwd) }];
|
|
250
294
|
}
|
|
251
295
|
if (FIELD_FLAGS.has(bare))
|
|
@@ -353,19 +397,53 @@ means "no known name appeared", not "this text is safe".`;
|
|
|
353
397
|
* @returns The exit code and any message.
|
|
354
398
|
*/
|
|
355
399
|
export async function evaluate(command, projectDir) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
400
|
+
let detector;
|
|
401
|
+
const findings = [];
|
|
402
|
+
for (const context of ghCommands(command, process.cwd())) {
|
|
403
|
+
if (!context.writes || !isGuardedTarget(commandRepository(context)))
|
|
404
|
+
continue;
|
|
405
|
+
detector ??= await loadDetector(projectDir);
|
|
406
|
+
if (detector === undefined) {
|
|
407
|
+
return {
|
|
408
|
+
code: EXIT_ALLOWED,
|
|
409
|
+
warning:
|
|
410
|
+
"block-host-name-leak: host-name denylist not found (no built dist/ or installed package); host-identity enforcement is NOT active",
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
// Without a known cwd a relative file cannot be inspected honestly. Keep
|
|
414
|
+
// protection, and name the unread input instead of claiming the text clean.
|
|
415
|
+
if (
|
|
416
|
+
context.cwd === undefined &&
|
|
417
|
+
[...context.flags].some(([flag, item]) => {
|
|
418
|
+
const field =
|
|
419
|
+
context.noun === "api" && FIELD_FLAGS.has(flag)
|
|
420
|
+
? /^([^=]+)=@(.+)$/u.exec(item.value)
|
|
421
|
+
: null;
|
|
422
|
+
const file =
|
|
423
|
+
field !== null
|
|
424
|
+
? PROSE_FIELDS.has(field[1])
|
|
425
|
+
? field[2]
|
|
426
|
+
: undefined
|
|
427
|
+
: FILE_FLAGS.has(flag)
|
|
428
|
+
? item.value.replace(/^@/, "")
|
|
429
|
+
: undefined;
|
|
430
|
+
return file !== undefined && !path.isAbsolute(file);
|
|
431
|
+
})
|
|
432
|
+
) {
|
|
433
|
+
return {
|
|
434
|
+
code: EXIT_BLOCKED,
|
|
435
|
+
message:
|
|
436
|
+
"BLOCKED: the working directory for this tracker write's body file could not be resolved. Use a literal directory and readable body file so the host-name guard can inspect it.",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
findings.push(
|
|
440
|
+
...publishableText(
|
|
441
|
+
context.tokens,
|
|
442
|
+
context.cwd ?? projectDir,
|
|
443
|
+
context.noun
|
|
444
|
+
).flatMap(source => findingsIn(source, detector))
|
|
445
|
+
);
|
|
365
446
|
}
|
|
366
|
-
const findings = publishableText(tokens, projectDir).flatMap(source =>
|
|
367
|
-
findingsIn(source, detector)
|
|
368
|
-
);
|
|
369
447
|
if (findings.length === 0) return { code: EXIT_ALLOWED };
|
|
370
448
|
return { code: EXIT_BLOCKED, message: formatRefusal(findings) };
|
|
371
449
|
}
|
|
@@ -56,6 +56,11 @@ if [ ! -r "$classifier" ]; then
|
|
|
56
56
|
exit 0
|
|
57
57
|
fi
|
|
58
58
|
|
|
59
|
+
if [ ! -r "$hook_dir/host-name-command-context.mjs" ]; then
|
|
60
|
+
printf 'block-host-name-leak: command-context helper missing; host-identity enforcement is NOT active\n' >&2
|
|
61
|
+
exit 0
|
|
62
|
+
fi
|
|
63
|
+
|
|
59
64
|
status=0
|
|
60
65
|
node "$classifier" "$command_str" "$project_dir" || status=$?
|
|
61
66
|
exit "$status"
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/** Bounded literal command/target resolution for the host-name guard; never a shell. */
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { statSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const VALUE_FLAGS = new Set([
|
|
7
|
+
"--repo",
|
|
8
|
+
"-R",
|
|
9
|
+
"--body",
|
|
10
|
+
"-b",
|
|
11
|
+
"--title",
|
|
12
|
+
"-t",
|
|
13
|
+
"--message",
|
|
14
|
+
"-m",
|
|
15
|
+
"--body-file",
|
|
16
|
+
"--input",
|
|
17
|
+
"-f",
|
|
18
|
+
"-F",
|
|
19
|
+
"--field",
|
|
20
|
+
"--raw-field",
|
|
21
|
+
"-X",
|
|
22
|
+
"--method",
|
|
23
|
+
"-H",
|
|
24
|
+
"--header",
|
|
25
|
+
"--hostname",
|
|
26
|
+
"-q",
|
|
27
|
+
"--jq",
|
|
28
|
+
"--template",
|
|
29
|
+
"-p",
|
|
30
|
+
"--preview",
|
|
31
|
+
"--base",
|
|
32
|
+
"--head",
|
|
33
|
+
"--assignee",
|
|
34
|
+
"--label",
|
|
35
|
+
"--milestone",
|
|
36
|
+
"--project",
|
|
37
|
+
"--reviewer",
|
|
38
|
+
]);
|
|
39
|
+
const FIELD_FLAGS = new Set(["-f", "-F", "--field", "--raw-field"]);
|
|
40
|
+
const METHODS = new Set(["POST", "PATCH", "PUT"]);
|
|
41
|
+
const SHORT_VALUES = /^(-[RbBtTmMfFXHqp])(.+)$/u;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read literal words and command separators, retaining expansion uncertainty.
|
|
45
|
+
* @param {string} command Raw Bash command.
|
|
46
|
+
* @returns {object[]} Literal words and separators.
|
|
47
|
+
*/
|
|
48
|
+
function words(command) {
|
|
49
|
+
const result = [];
|
|
50
|
+
let value = "",
|
|
51
|
+
quote = "",
|
|
52
|
+
literal = true,
|
|
53
|
+
started = false;
|
|
54
|
+
const flush = () => {
|
|
55
|
+
if (started) result.push({ value, literal });
|
|
56
|
+
value = "";
|
|
57
|
+
literal = true;
|
|
58
|
+
started = false;
|
|
59
|
+
};
|
|
60
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
61
|
+
const char = command[i];
|
|
62
|
+
if (char === "\\" && quote !== "'") {
|
|
63
|
+
const next = command[++i];
|
|
64
|
+
if (next === undefined) literal = false;
|
|
65
|
+
else if (next !== "\n") {
|
|
66
|
+
if (quote === '"' && !/[$`"\\]/u.test(next)) value += "\\";
|
|
67
|
+
value += next;
|
|
68
|
+
}
|
|
69
|
+
if (next !== "\n") started = true;
|
|
70
|
+
} else if (quote !== "") {
|
|
71
|
+
if (char === quote) quote = "";
|
|
72
|
+
else {
|
|
73
|
+
value += char;
|
|
74
|
+
if (quote === '"' && /[$`]/u.test(char)) literal = false;
|
|
75
|
+
}
|
|
76
|
+
started = true;
|
|
77
|
+
} else if (char === '"' || char === "'") {
|
|
78
|
+
quote = char;
|
|
79
|
+
started = true;
|
|
80
|
+
} else if (/[;&|\n()<>]/u.test(char)) {
|
|
81
|
+
flush();
|
|
82
|
+
const operator =
|
|
83
|
+
char === command[i + 1] && /[&|]/u.test(char)
|
|
84
|
+
? char + command[++i]
|
|
85
|
+
: char;
|
|
86
|
+
result.push({ operator });
|
|
87
|
+
} else if (/\s/u.test(char)) flush();
|
|
88
|
+
else {
|
|
89
|
+
value += char;
|
|
90
|
+
started = true;
|
|
91
|
+
if (/[$`*?[\]~]/u.test(char)) literal = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (quote !== "") literal = false;
|
|
95
|
+
flush();
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Split option values without treating body text as a second option/endpoint.
|
|
101
|
+
* @param {object[]} input Active gh arguments only.
|
|
102
|
+
* @returns {object} Parsed flags, positional words and normalized tokens.
|
|
103
|
+
*/
|
|
104
|
+
function argumentsOf(input) {
|
|
105
|
+
const flags = new Map(),
|
|
106
|
+
positional = [],
|
|
107
|
+
tokens = [];
|
|
108
|
+
// Any expansion can add/replace routing arguments after shell word splitting.
|
|
109
|
+
// Never evaluate it or certify a target from the remaining literal words.
|
|
110
|
+
let certain = input.every(word => word.literal);
|
|
111
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
112
|
+
const word = input[i];
|
|
113
|
+
if (word.value === "--") {
|
|
114
|
+
positional.push(...input.slice(i + 1));
|
|
115
|
+
tokens.push(...input.slice(i).map(item => item.value));
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
const equals = word.value.indexOf("=");
|
|
119
|
+
const directFlag = equals > 0 ? word.value.slice(0, equals) : word.value;
|
|
120
|
+
const attached = VALUE_FLAGS.has(directFlag)
|
|
121
|
+
? null
|
|
122
|
+
: SHORT_VALUES.exec(word.value);
|
|
123
|
+
const flag =
|
|
124
|
+
attached?.[1] ??
|
|
125
|
+
(equals > 0 && word.value.startsWith("-") ? directFlag : word.value);
|
|
126
|
+
tokens.push(flag);
|
|
127
|
+
if (VALUE_FLAGS.has(flag)) {
|
|
128
|
+
const value = attached
|
|
129
|
+
? { ...word, value: attached[2] }
|
|
130
|
+
: equals > 0
|
|
131
|
+
? { ...word, value: word.value.slice(equals + 1) }
|
|
132
|
+
: input[++i];
|
|
133
|
+
if (value === undefined) {
|
|
134
|
+
certain = false;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Alias spellings share one Cobra option; the last occurrence wins.
|
|
138
|
+
flags.set(
|
|
139
|
+
flag === "-R" ? "--repo" : flag === "-X" ? "--method" : flag,
|
|
140
|
+
value
|
|
141
|
+
);
|
|
142
|
+
tokens.push(value.value);
|
|
143
|
+
} else if (!word.value.startsWith("-")) positional.push(word);
|
|
144
|
+
else if (!word.literal) certain = false;
|
|
145
|
+
}
|
|
146
|
+
const noun = positional[0]?.value,
|
|
147
|
+
verb = positional[1]?.value;
|
|
148
|
+
const method = flags.get("--method");
|
|
149
|
+
const writes =
|
|
150
|
+
noun === "api"
|
|
151
|
+
? method
|
|
152
|
+
? !method.literal || METHODS.has(method.value.toUpperCase())
|
|
153
|
+
: [...FIELD_FLAGS].some(flag => flags.has(flag)) ||
|
|
154
|
+
Boolean(flags.get("--input")?.value)
|
|
155
|
+
: ["issue", "pr"].includes(noun) &&
|
|
156
|
+
["create", "comment", "edit"].includes(verb);
|
|
157
|
+
return {
|
|
158
|
+
flags,
|
|
159
|
+
positional,
|
|
160
|
+
tokens: ["gh", ...tokens],
|
|
161
|
+
noun,
|
|
162
|
+
writes,
|
|
163
|
+
certain,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Normalize only repository shapes gh/git actually accept; unresolved syntax is unknown.
|
|
169
|
+
* @param {string} spec Repository argument, URL or Git remote.
|
|
170
|
+
* @returns {string|undefined} Lowercased owner/repository, or unknown.
|
|
171
|
+
*/
|
|
172
|
+
export function repositoryFromSpec(spec) {
|
|
173
|
+
if (typeof spec !== "string" || /[\s${}`*?#[\]]/u.test(spec))
|
|
174
|
+
return undefined;
|
|
175
|
+
const normalized = spec
|
|
176
|
+
.trim()
|
|
177
|
+
.replace(/\.git\/?$/u, "")
|
|
178
|
+
.replace(/\/$/u, "");
|
|
179
|
+
const matched =
|
|
180
|
+
/^(?:(?:https?:\/\/|ssh:\/\/git@)[^/]+\/|git@[^:]+:|[^/:]+\/)?([\w.-]+)\/([\w.-]+)$/u.exec(
|
|
181
|
+
normalized
|
|
182
|
+
);
|
|
183
|
+
return matched ? `${matched[1]}/${matched[2]}`.toLowerCase() : undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolve literal cd and leading environment assignments across simple command lists.
|
|
188
|
+
* Pipelines, conditionals and other shell syntax never earn a target exemption.
|
|
189
|
+
* @param {string} command Raw Bash command.
|
|
190
|
+
* @param {string} initialCwd The actual hook process cwd, separate from detector root.
|
|
191
|
+
* @param {object} env The actual inherited environment.
|
|
192
|
+
* @returns {object[]} Active gh calls with their bounded command contexts.
|
|
193
|
+
*/
|
|
194
|
+
export function ghCommands(command, initialCwd, env = process.env) {
|
|
195
|
+
const calls = [];
|
|
196
|
+
const inherited = { ...env };
|
|
197
|
+
let cwd = initialCwd,
|
|
198
|
+
segment = [],
|
|
199
|
+
uncertain = false;
|
|
200
|
+
const consume = () => {
|
|
201
|
+
if (segment.length === 0) return;
|
|
202
|
+
const local = { ...inherited };
|
|
203
|
+
let index = segment[0].value === "env" ? 1 : 0;
|
|
204
|
+
while (/^[A-Za-z_][A-Za-z_0-9]*=/u.test(segment[index]?.value ?? "")) {
|
|
205
|
+
const item = segment[index++],
|
|
206
|
+
split = item.value.indexOf("=");
|
|
207
|
+
local[item.value.slice(0, split)] = item.literal
|
|
208
|
+
? item.value.slice(split + 1)
|
|
209
|
+
: undefined;
|
|
210
|
+
if (!item.literal) uncertain = true;
|
|
211
|
+
}
|
|
212
|
+
const executable = segment[index]?.value;
|
|
213
|
+
if (executable === "cd") {
|
|
214
|
+
const args = segment.slice(index + 1).filter(item => item.value !== "--");
|
|
215
|
+
const directory =
|
|
216
|
+
args.length === 1 && args[0].literal && !args[0].value.startsWith("-")
|
|
217
|
+
? args[0].value
|
|
218
|
+
: undefined;
|
|
219
|
+
try {
|
|
220
|
+
const resolved =
|
|
221
|
+
directory === undefined ||
|
|
222
|
+
(!path.isAbsolute(directory) &&
|
|
223
|
+
!/^\.\.?(?:\/|$)/u.test(directory) &&
|
|
224
|
+
(uncertain || local.CDPATH))
|
|
225
|
+
? undefined
|
|
226
|
+
: path.isAbsolute(directory)
|
|
227
|
+
? directory
|
|
228
|
+
: cwd === undefined
|
|
229
|
+
? undefined
|
|
230
|
+
: path.resolve(cwd, directory);
|
|
231
|
+
cwd =
|
|
232
|
+
resolved !== undefined && statSync(resolved).isDirectory()
|
|
233
|
+
? resolved
|
|
234
|
+
: undefined;
|
|
235
|
+
} catch {
|
|
236
|
+
cwd = undefined;
|
|
237
|
+
}
|
|
238
|
+
} else if (executable === "export") {
|
|
239
|
+
for (const item of segment.slice(index + 1)) {
|
|
240
|
+
const split = item.value.indexOf("=");
|
|
241
|
+
if (split < 1 || !item.literal) {
|
|
242
|
+
uncertain = true;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
inherited[item.value.slice(0, split)] = item.value.slice(split + 1);
|
|
246
|
+
}
|
|
247
|
+
} else if (executable !== undefined && path.basename(executable) === "gh") {
|
|
248
|
+
calls.push({
|
|
249
|
+
...argumentsOf(segment.slice(index + 1)),
|
|
250
|
+
cwd,
|
|
251
|
+
env: local,
|
|
252
|
+
certainContext: !uncertain,
|
|
253
|
+
});
|
|
254
|
+
} else {
|
|
255
|
+
// A function/source/pushd or unsupported launcher may change the shell
|
|
256
|
+
// context. Do not grant later writes an exemption based on the old cwd.
|
|
257
|
+
uncertain = true;
|
|
258
|
+
cwd = undefined;
|
|
259
|
+
const nested = segment.findIndex(item => item.value === "gh");
|
|
260
|
+
if (nested !== -1)
|
|
261
|
+
calls.push({
|
|
262
|
+
...argumentsOf(segment.slice(nested + 1)),
|
|
263
|
+
cwd,
|
|
264
|
+
env: local,
|
|
265
|
+
certainContext: false,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
segment = [];
|
|
269
|
+
};
|
|
270
|
+
for (const word of words(command)) {
|
|
271
|
+
if (word.operator === undefined) segment.push(word);
|
|
272
|
+
else {
|
|
273
|
+
consume();
|
|
274
|
+
if (!["&&", ";", "\n"].includes(word.operator)) {
|
|
275
|
+
uncertain = true;
|
|
276
|
+
cwd = undefined;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
consume();
|
|
281
|
+
return calls;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Read gh's local selected default, falling back only to one unambiguous remote.
|
|
286
|
+
* All reads share a five-second ceiling and execute no shell or network query.
|
|
287
|
+
* @param {object} context One gh command context.
|
|
288
|
+
* @returns {string|undefined} Verified repository, or unknown.
|
|
289
|
+
*/
|
|
290
|
+
function localRepository(context) {
|
|
291
|
+
if (!context.certainContext) return undefined;
|
|
292
|
+
if (context.env.GH_REPO) return repositoryFromSpec(context.env.GH_REPO);
|
|
293
|
+
if (context.cwd === undefined) return undefined;
|
|
294
|
+
const deadline = Date.now() + 5000;
|
|
295
|
+
const read = (command, args) => {
|
|
296
|
+
const remaining = deadline - Date.now();
|
|
297
|
+
if (remaining <= 0) throw new Error("Repository lookup deadline expired");
|
|
298
|
+
return execFileSync(command, args, {
|
|
299
|
+
cwd: context.cwd,
|
|
300
|
+
env: { ...context.env, GH_PROMPT_DISABLED: "1" },
|
|
301
|
+
timeout: remaining,
|
|
302
|
+
encoding: "utf8",
|
|
303
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
304
|
+
}).trim();
|
|
305
|
+
};
|
|
306
|
+
try {
|
|
307
|
+
const selected = read("gh", ["repo", "set-default", "--view"]);
|
|
308
|
+
if (selected !== "") return repositoryFromSpec(selected);
|
|
309
|
+
const remotes = read("git", ["remote"]).split("\n").filter(Boolean);
|
|
310
|
+
if (remotes.length !== 1) return undefined;
|
|
311
|
+
return repositoryFromSpec(read("git", ["remote", "get-url", remotes[0]]));
|
|
312
|
+
} catch {
|
|
313
|
+
// probe-direction: fail-closed — failed lookup grants no host exemption;
|
|
314
|
+
// the caller retains its inspection path for an undetermined target.
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Resolve only active command arguments; prose cannot masquerade as routing.
|
|
321
|
+
* @param {object} context One parsed gh call.
|
|
322
|
+
* @returns {string|undefined} Verified owner/repository, or unknown.
|
|
323
|
+
*/
|
|
324
|
+
export function commandRepository(context) {
|
|
325
|
+
if (!context.certain || !context.certainContext) return undefined;
|
|
326
|
+
if (context.noun !== "api") {
|
|
327
|
+
// gh 2.88.1 issue comment/edit and PR finder resolve a URL selector before
|
|
328
|
+
// BaseRepo (--repo/GH_REPO/local default). Body option values are not here.
|
|
329
|
+
const selectors = context.positional.slice(2);
|
|
330
|
+
const batch =
|
|
331
|
+
context.noun === "issue" && context.positional[1]?.value === "edit";
|
|
332
|
+
if (
|
|
333
|
+
(batch && selectors.length > 1) ||
|
|
334
|
+
(["comment", "edit"].includes(context.positional[1]?.value) &&
|
|
335
|
+
selectors.some(item => /^https?:\/\//u.test(item.value)))
|
|
336
|
+
) {
|
|
337
|
+
if (!batch && selectors.length !== 1) return undefined;
|
|
338
|
+
let repository, identity;
|
|
339
|
+
for (const item of selectors) {
|
|
340
|
+
// ParseIssuesFromArgs lets numeric selectors inherit the common URL repo.
|
|
341
|
+
if (batch && /^#?[+-]?\d+$/u.test(item.value)) {
|
|
342
|
+
const number = BigInt(item.value.replace(/^#/u, ""));
|
|
343
|
+
if (BigInt.asIntN(64, number) !== number) return undefined;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const selected =
|
|
347
|
+
/^https?:\/\/[^/]+\/([\w.-]+)\/([\w.-]+)\/(issues|pull)\/\d+(?:[/?#].*)?$/u.exec(
|
|
348
|
+
item.value
|
|
349
|
+
);
|
|
350
|
+
if (!selected || (context.noun === "pr" && selected[3] !== "pull"))
|
|
351
|
+
return undefined;
|
|
352
|
+
try {
|
|
353
|
+
const host = new URL(item.value).hostname
|
|
354
|
+
.replace(/^www\./u, "")
|
|
355
|
+
.toLowerCase();
|
|
356
|
+
const repo = batch
|
|
357
|
+
? `${selected[1]}/${selected[2]}`.toLowerCase()
|
|
358
|
+
: repositoryFromSpec(`${selected[1]}/${selected[2]}`);
|
|
359
|
+
const current = `${host}/${repo}`;
|
|
360
|
+
if (
|
|
361
|
+
!host ||
|
|
362
|
+
!repo ||
|
|
363
|
+
(identity !== undefined && identity !== current)
|
|
364
|
+
)
|
|
365
|
+
return undefined;
|
|
366
|
+
identity = current;
|
|
367
|
+
repository = repo;
|
|
368
|
+
} catch {
|
|
369
|
+
// probe-direction: fail-closed — invalid URL grants no host exemption;
|
|
370
|
+
// the caller retains its inspection path for an undetermined target.
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (repository !== undefined) return repository;
|
|
375
|
+
}
|
|
376
|
+
const explicit = context.flags.get("--repo") ?? context.flags.get("-R");
|
|
377
|
+
return explicit
|
|
378
|
+
? explicit.literal
|
|
379
|
+
? repositoryFromSpec(explicit.value)
|
|
380
|
+
: undefined
|
|
381
|
+
: localRepository(context);
|
|
382
|
+
}
|
|
383
|
+
const endpoint = context.positional[1];
|
|
384
|
+
if (!endpoint?.literal) return undefined;
|
|
385
|
+
let value = endpoint.value;
|
|
386
|
+
if (/\{(?:owner|repo)\}/u.test(value)) {
|
|
387
|
+
const repository = localRepository(context);
|
|
388
|
+
if (repository === undefined) return undefined;
|
|
389
|
+
const [owner, repo] = repository.split("/");
|
|
390
|
+
value = value.replaceAll("{owner}", owner).replaceAll("{repo}", repo);
|
|
391
|
+
}
|
|
392
|
+
const found =
|
|
393
|
+
/^(?:https?:\/\/[^/]+\/)?\/?repos\/([\w.-]+)\/([\w.-]+)(?:\/|$)/u.exec(
|
|
394
|
+
value
|
|
395
|
+
);
|
|
396
|
+
return found ? repositoryFromSpec(`${found[1]}/${found[2]}`) : undefined;
|
|
397
|
+
}
|