@codyswann/lisa 4.35.1 → 4.35.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/apply.js +8 -8
- package/dist/cli/apply.js.map +1 -1
- package/dist/core/apply-receipt.d.ts +17 -12
- package/dist/core/apply-receipt.d.ts.map +1 -1
- package/dist/core/apply-receipt.js +3 -3
- package/dist/core/apply-receipt.js.map +1 -1
- package/dist/core/config.d.ts +10 -6
- 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 +2 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa.d.ts +34 -24
- package/dist/core/lisa.d.ts.map +1 -1
- package/dist/core/lisa.js +49 -41
- 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 +25 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +5 -1
- package/plugins/lisa/.codex-plugin/hooks.json +4 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/block-host-name-leak.agy.sh +61 -0
- package/plugins/lisa/hooks/block-host-name-leak.mjs +412 -0
- package/plugins/lisa/hooks/block-host-name-leak.sh +61 -0
- package/plugins/lisa-agy/hooks/block-host-name-leak.agy.sh +61 -0
- package/plugins/lisa-agy/hooks/block-host-name-leak.mjs +412 -0
- package/plugins/lisa-agy/hooks/block-host-name-leak.sh +61 -0
- package/plugins/lisa-agy/hooks.json +13 -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-copilot/.claude-plugin/plugin.json +5 -1
- package/plugins/lisa-copilot/hooks/block-host-name-leak.mjs +412 -0
- package/plugins/lisa-copilot/hooks/block-host-name-leak.sh +61 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-host-name-leak.mjs +412 -0
- package/plugins/lisa-cursor/hooks/block-host-name-leak.sh +61 -0
- package/plugins/lisa-cursor/hooks/hooks.json +4 -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-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/.claude-plugin/plugin.json +4 -0
- package/plugins/src/base/hooks/block-host-name-leak.agy.sh +61 -0
- package/plugins/src/base/hooks/block-host-name-leak.mjs +412 -0
- package/plugins/src/base/hooks/block-host-name-leak.sh +61 -0
- package/scripts/downstream-name-digest.mjs +128 -0
- package/scripts/generate-agy-plugin-artifacts.mjs +12 -0
- package/scripts/reconcile-release-tags.mjs +331 -0
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +132 -39
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Antigravity (agy) PreToolUse adapter for Lisa's host-name leak guard.
|
|
3
|
+
#
|
|
4
|
+
# agy sends `{toolCall:{name:"run_command",args:{CommandLine:"..."}}}` and
|
|
5
|
+
# expects an allow/deny JSON object on stdout. The canonical hook consumes the
|
|
6
|
+
# Claude Bash-hook envelope and communicates denial with exit status 2. This
|
|
7
|
+
# adapter only translates protocols; all classification stays in the canonical
|
|
8
|
+
# block-host-name-leak.sh beside it (same delegation pattern as
|
|
9
|
+
# block-direct-issue-create.agy.sh).
|
|
10
|
+
#
|
|
11
|
+
# No scope gap: the canonical guard is Bash-only by construction, and
|
|
12
|
+
# `run_command` is exactly the surface it needs. agy receives the whole guard.
|
|
13
|
+
#
|
|
14
|
+
# Fail-open on missing runtimes, for the reason the canonical hook states — and
|
|
15
|
+
# note the cost here is higher than the filing guard's. A missed refusal is a
|
|
16
|
+
# host identity published to a public repository and shipped to npm, which
|
|
17
|
+
# cannot be quietly repaired later the way an under-declared ticket can. It is
|
|
18
|
+
# still the right default: failing closed would block every command on a machine
|
|
19
|
+
# without jq, which takes the guard out of service entirely.
|
|
20
|
+
set -uo pipefail
|
|
21
|
+
|
|
22
|
+
allow() {
|
|
23
|
+
printf '%s\n' '{"decision":"allow"}'
|
|
24
|
+
exit 0
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
deny() {
|
|
28
|
+
local reason="$1"
|
|
29
|
+
if command -v jq >/dev/null 2>&1; then
|
|
30
|
+
jq -cn --arg reason "$reason" '{decision:"deny",reason:$reason}'
|
|
31
|
+
else
|
|
32
|
+
printf '%s\n' '{"decision":"allow"}'
|
|
33
|
+
fi
|
|
34
|
+
exit 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
input="$(cat 2>/dev/null || true)"
|
|
38
|
+
[ -z "$input" ] && allow
|
|
39
|
+
command -v jq >/dev/null 2>&1 || allow
|
|
40
|
+
|
|
41
|
+
tool_name="$(printf '%s' "$input" | jq -r '.toolCall.name // empty' 2>/dev/null || true)"
|
|
42
|
+
[ "$tool_name" != "run_command" ] && allow
|
|
43
|
+
command_str="$(printf '%s' "$input" | jq -r '.toolCall.args.CommandLine // empty' 2>/dev/null || true)"
|
|
44
|
+
[ -z "$command_str" ] && allow
|
|
45
|
+
|
|
46
|
+
hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
47
|
+
canonical_hook="$hook_dir/block-host-name-leak.sh"
|
|
48
|
+
[ -r "$canonical_hook" ] || allow
|
|
49
|
+
|
|
50
|
+
canonical_input="$(jq -cn --arg command "$command_str" '{tool_name:"Bash",tool_input:{command:$command}}')"
|
|
51
|
+
canonical_output=""
|
|
52
|
+
canonical_status=0
|
|
53
|
+
if canonical_output="$(printf '%s' "$canonical_input" | /bin/bash "$canonical_hook" 2>&1)"; then
|
|
54
|
+
canonical_status=0
|
|
55
|
+
else
|
|
56
|
+
canonical_status=$?
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
[ "$canonical_status" -eq 0 ] && allow
|
|
60
|
+
[ -n "$canonical_output" ] || canonical_output="Blocked: this tracker write names a host project. Write the evidence, not the identity."
|
|
61
|
+
deny "$canonical_output"
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuse an outbound tracker write whose title or body names a host project.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS AS A HOOK AND NOT AS MORE PROSE
|
|
5
|
+
*
|
|
6
|
+
* The rule lives in `.claude/rules/PROJECT_RULES.md`, it is repeated in every
|
|
7
|
+
* agent brief, and agents break it anyway. Measured across the full tracker on
|
|
8
|
+
* 2026-09-03 — 193 open issues, 1,461 closed, 1,991 merged pull requests — 513
|
|
9
|
+
* items carried a host identity. The daily rate over the preceding week ran
|
|
10
|
+
* between 1.8% and 28.5% and never reached zero, and the items filed on the day
|
|
11
|
+
* of the count were filed by agents whose briefs carried the rule. Instruction
|
|
12
|
+
* is not reaching the moment of writing, so the check belongs on the write path.
|
|
13
|
+
*
|
|
14
|
+
* This repository is public and `dist/` is in the npm `files` allowlist, so a
|
|
15
|
+
* name written into an issue body is published on github.com AND shipped to
|
|
16
|
+
* every consumer of the package. There is no quiet version of this mistake.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT IS NOT
|
|
19
|
+
*
|
|
20
|
+
* **This reduces the rate. It does not close the class.** The detector is a
|
|
21
|
+
* curated denylist: a name variant nobody has added — a new spelling, a legal
|
|
22
|
+
* entity, a tracker-site slug, an email domain, an acronym — is invisible to it.
|
|
23
|
+
* One organisation's name in running prose reached three separate issue bodies
|
|
24
|
+
* and was matched by no pattern used during the audit; it is caught today only
|
|
25
|
+
* because a person put that spelling in the list by hand. Read a green result as
|
|
26
|
+
* "none of the known names appeared", never as "this text is clean".
|
|
27
|
+
*
|
|
28
|
+
* WHERE IT LOOKS, AND WHY THAT MATTERS MORE THAN THE MATCHER
|
|
29
|
+
*
|
|
30
|
+
* The convention in this repository is `--body-file <path>`; the text never
|
|
31
|
+
* appears in the command string. A guard that inspects only argv therefore sees
|
|
32
|
+
* nothing and reports clean on every real call — that is #3484's defect, and
|
|
33
|
+
* repeating it here would ship a guard that runs and never bites. So this reads
|
|
34
|
+
* the referenced file, and the title, which is what appears in every listing and
|
|
35
|
+
* notification.
|
|
36
|
+
*
|
|
37
|
+
* WHY THIS IS NOT MATERIALIZED INTO HOST PROJECTS
|
|
38
|
+
*
|
|
39
|
+
* The five sibling PreToolUse guards are copied into host projects through
|
|
40
|
+
* `all/copy-overwrite/scripts/lisa-hooks/`. This one deliberately is not, and
|
|
41
|
+
* the omission is a scope decision rather than an oversight.
|
|
42
|
+
*
|
|
43
|
+
* The denylist holds the names of host projects — the very repositories that
|
|
44
|
+
* install Lisa. Shipped into host project X, this guard would refuse every
|
|
45
|
+
* issue in which X writes its own name, which is normal and correct writing
|
|
46
|
+
* there. The rule it enforces is Lisa's: **this** repository is public and
|
|
47
|
+
* `dist/` ships to npm, so **this** repository may not name its hosts. A host
|
|
48
|
+
* naming itself carries none of that.
|
|
49
|
+
*
|
|
50
|
+
* So the parity obligation is satisfied across every agent surface OF THIS
|
|
51
|
+
* REPOSITORY — Claude, Codex, Cursor, Antigravity, Copilot all register it —
|
|
52
|
+
* and stops at the boundary where the rule itself stops applying. Anyone
|
|
53
|
+
* tempted to add it to `plugins/materialized-artifacts.json` should have a
|
|
54
|
+
* reason why a host project should be refused its own name.
|
|
55
|
+
*
|
|
56
|
+
* FAILING OPEN, LOUDLY
|
|
57
|
+
*
|
|
58
|
+
* When the denylist cannot be loaded this permits the write and says so on
|
|
59
|
+
* stderr. Failing closed would block every tracker write on a machine without a
|
|
60
|
+
* built `dist/`; failing open silently is worse than either, because a guard
|
|
61
|
+
* that is quietly absent is indistinguishable from a guard that passes. Same
|
|
62
|
+
* reasoning as `block-direct-issue-create.sh`'s interpreter probe.
|
|
63
|
+
* @module hooks/block-host-name-leak
|
|
64
|
+
*/
|
|
65
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
66
|
+
import path from "node:path";
|
|
67
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
68
|
+
|
|
69
|
+
/** Claude Code's refusal code. Every other non-zero exit is a hook error. */
|
|
70
|
+
const EXIT_BLOCKED = 2;
|
|
71
|
+
/** Permit the command. */
|
|
72
|
+
const EXIT_ALLOWED = 0;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Where the compiled detector may live, relative to the governed project.
|
|
76
|
+
*
|
|
77
|
+
* Inside this repository it is the local build; inside a host project it is the
|
|
78
|
+
* installed package. The plugin directory itself is NOT a candidate — it ships
|
|
79
|
+
* `hooks/`, `skills/` and `scripts/` and no `dist/`, so a hook that looked
|
|
80
|
+
* beside itself would find nothing and stand down everywhere.
|
|
81
|
+
*/
|
|
82
|
+
const DETECTOR_PATHS = [
|
|
83
|
+
"dist/core/downstream-references.js",
|
|
84
|
+
"node_modules/@codyswann/lisa/dist/core/downstream-references.js",
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/** Sub-commands that publish text people read. */
|
|
88
|
+
const WRITE_VERBS = new Set(["create", "comment", "edit"]);
|
|
89
|
+
/** The `gh` nouns whose writes carry prose. */
|
|
90
|
+
const WRITE_NOUNS = new Set(["issue", "pr"]);
|
|
91
|
+
/** HTTP methods that change tracker state. */
|
|
92
|
+
const MUTATING_METHODS = new Set(["POST", "PATCH", "PUT"]);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Flags whose value is a path to read.
|
|
96
|
+
*
|
|
97
|
+
* `-F` is `--body-file`'s short form on `gh issue`/`gh pr`, and on `gh api` it
|
|
98
|
+
* is `--field`, which may itself carry `@path`. Both are handled — reading a
|
|
99
|
+
* file that turns out to be a literal costs nothing, missing one costs the
|
|
100
|
+
* guard.
|
|
101
|
+
*/
|
|
102
|
+
const FILE_FLAGS = new Set(["--body-file", "-F", "--input"]);
|
|
103
|
+
/** Flags whose value is literal text people will read. */
|
|
104
|
+
const TEXT_FLAGS = new Set([
|
|
105
|
+
"--title",
|
|
106
|
+
"-t",
|
|
107
|
+
"--body",
|
|
108
|
+
"-b",
|
|
109
|
+
"--message",
|
|
110
|
+
"-m",
|
|
111
|
+
]);
|
|
112
|
+
/** `gh api` field flags: `key=value`, or `key=@path` to read a file. */
|
|
113
|
+
const FIELD_FLAGS = new Set(["-f", "--field", "--raw-field"]);
|
|
114
|
+
/** Field names whose value is published prose rather than metadata. */
|
|
115
|
+
const PROSE_FIELDS = new Set(["body", "title", "text", "message"]);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Vendor endpoints quoted from guard source, which are not host identities.
|
|
119
|
+
*
|
|
120
|
+
* These appear in this repository's own prose when documenting the access
|
|
121
|
+
* layer. Left unguarded they would be a standing false positive, and a guard
|
|
122
|
+
* with false positives is a guard someone turns off.
|
|
123
|
+
*/
|
|
124
|
+
const ENDPOINT_ALLOWANCES = [
|
|
125
|
+
"api.linear.app",
|
|
126
|
+
"mcp.linear.app",
|
|
127
|
+
"api.github.com",
|
|
128
|
+
"api.atlassian.com",
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Split a command line into argv, honouring single and double quotes.
|
|
133
|
+
*
|
|
134
|
+
* Deliberately not a shell: it does not expand variables, globs or command
|
|
135
|
+
* substitution. Those would let the guard's own reading diverge from what the
|
|
136
|
+
* shell will actually run, and a guard that models the shell imperfectly is
|
|
137
|
+
* worse than one that reads tokens plainly.
|
|
138
|
+
* @param command - The raw command string.
|
|
139
|
+
* @returns The tokens, quotes stripped.
|
|
140
|
+
*/
|
|
141
|
+
export function tokenize(command) {
|
|
142
|
+
const tokens = [];
|
|
143
|
+
let current = "";
|
|
144
|
+
let quote = "";
|
|
145
|
+
let started = false;
|
|
146
|
+
for (const char of command) {
|
|
147
|
+
if (quote !== "") {
|
|
148
|
+
if (char === quote) quote = "";
|
|
149
|
+
else current += char;
|
|
150
|
+
started = true;
|
|
151
|
+
} else if (char === '"' || char === "'") {
|
|
152
|
+
quote = char;
|
|
153
|
+
started = true;
|
|
154
|
+
} else if (/\s/u.test(char)) {
|
|
155
|
+
if (started) tokens.push(current);
|
|
156
|
+
current = "";
|
|
157
|
+
started = false;
|
|
158
|
+
} else {
|
|
159
|
+
current += char;
|
|
160
|
+
started = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (started) tokens.push(current);
|
|
164
|
+
return tokens;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Whether these tokens are a `gh` call that publishes tracker text.
|
|
169
|
+
*
|
|
170
|
+
* Reads (`view`, `list`, `status`) and non-tracker writes (`gh label create`)
|
|
171
|
+
* are not writes of prose and do not fire.
|
|
172
|
+
* @param tokens - Command tokens.
|
|
173
|
+
* @returns True when the command publishes text.
|
|
174
|
+
*/
|
|
175
|
+
export function isOutboundTrackerWrite(tokens) {
|
|
176
|
+
const gh = tokens.indexOf("gh");
|
|
177
|
+
if (gh === -1) return false;
|
|
178
|
+
const rest = tokens.slice(gh + 1).filter(token => !token.startsWith("-"));
|
|
179
|
+
const [noun, verb] = rest;
|
|
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));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The value a flag carries, in either `--flag value` or `--flag=value` form.
|
|
204
|
+
* @param tokens - Command tokens.
|
|
205
|
+
* @param index - Index of the flag token.
|
|
206
|
+
* @returns The value, or an empty string.
|
|
207
|
+
*/
|
|
208
|
+
function valueAt(tokens, index) {
|
|
209
|
+
const token = tokens[index] ?? "";
|
|
210
|
+
const equals = token.indexOf("=");
|
|
211
|
+
if (equals !== -1 && token.startsWith("-")) return token.slice(equals + 1);
|
|
212
|
+
return tokens[index + 1] ?? "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Read a file the command names, if it is readable.
|
|
217
|
+
* @param candidate - Path as written on the command line.
|
|
218
|
+
* @param cwd - Directory the command runs in.
|
|
219
|
+
* @returns The contents, or an empty string.
|
|
220
|
+
*/
|
|
221
|
+
function readIfPresent(candidate, cwd) {
|
|
222
|
+
if (candidate === "" || candidate === "-") return "";
|
|
223
|
+
const resolved = path.isAbsolute(candidate)
|
|
224
|
+
? candidate
|
|
225
|
+
: path.join(cwd, candidate);
|
|
226
|
+
if (!existsSync(resolved)) return "";
|
|
227
|
+
try {
|
|
228
|
+
return readFileSync(resolved, "utf-8");
|
|
229
|
+
} catch {
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Every piece of text this command would publish, labelled by where it came
|
|
236
|
+
* from so the refusal can point at the thing to edit.
|
|
237
|
+
* @param tokens - Command tokens.
|
|
238
|
+
* @param cwd - Directory the command runs in.
|
|
239
|
+
* @returns Sources of publishable text.
|
|
240
|
+
*/
|
|
241
|
+
export function publishableText(tokens, cwd) {
|
|
242
|
+
return tokens.flatMap((token, index) => {
|
|
243
|
+
const bare = token.startsWith("-") ? token.split("=")[0] : token;
|
|
244
|
+
if (TEXT_FLAGS.has(bare)) {
|
|
245
|
+
return [{ origin: bare, text: valueAt(tokens, index) }];
|
|
246
|
+
}
|
|
247
|
+
if (FILE_FLAGS.has(bare)) {
|
|
248
|
+
const file = valueAt(tokens, index).replace(/^@/u, "");
|
|
249
|
+
return [{ origin: file, text: readIfPresent(file, cwd) }];
|
|
250
|
+
}
|
|
251
|
+
if (FIELD_FLAGS.has(bare))
|
|
252
|
+
return [fieldSource(valueAt(tokens, index), cwd)];
|
|
253
|
+
return [];
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One `gh api` field, which may be literal or a file reference.
|
|
259
|
+
* @param assignment - The raw `key=value` token.
|
|
260
|
+
* @param cwd - Directory the command runs in.
|
|
261
|
+
* @returns A text source; empty when the field is not prose.
|
|
262
|
+
*/
|
|
263
|
+
function fieldSource(assignment, cwd) {
|
|
264
|
+
const split = assignment.indexOf("=");
|
|
265
|
+
if (split === -1) return { origin: "", text: "" };
|
|
266
|
+
const key = assignment.slice(0, split);
|
|
267
|
+
const value = assignment.slice(split + 1);
|
|
268
|
+
if (!PROSE_FIELDS.has(key)) return { origin: "", text: "" };
|
|
269
|
+
if (value.startsWith("@")) {
|
|
270
|
+
const file = value.slice(1);
|
|
271
|
+
return { origin: file, text: readIfPresent(file, cwd) };
|
|
272
|
+
}
|
|
273
|
+
return { origin: key, text: value };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Whether a matched span sits inside a vendor endpoint rather than a name.
|
|
278
|
+
* @param line - The line the match came from.
|
|
279
|
+
* @param match - The matched span.
|
|
280
|
+
* @returns True when the match is an allowed endpoint.
|
|
281
|
+
*/
|
|
282
|
+
function isEndpoint(line, match) {
|
|
283
|
+
return ENDPOINT_ALLOWANCES.some(
|
|
284
|
+
endpoint =>
|
|
285
|
+
endpoint.includes(match.toLowerCase()) && line.includes(endpoint)
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Load the compiled detector from the governed project.
|
|
291
|
+
* @param projectDir - The project the command runs in.
|
|
292
|
+
* @returns The detector module, or undefined when it cannot be found.
|
|
293
|
+
*/
|
|
294
|
+
async function loadDetector(projectDir) {
|
|
295
|
+
for (const candidate of DETECTOR_PATHS) {
|
|
296
|
+
const resolved = path.join(projectDir, candidate);
|
|
297
|
+
if (!existsSync(resolved)) continue;
|
|
298
|
+
try {
|
|
299
|
+
return await import(pathToFileURL(resolved).href);
|
|
300
|
+
} catch {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Every host-name violation in one text source.
|
|
309
|
+
* @param source - The labelled text.
|
|
310
|
+
* @param detector - The loaded detector module.
|
|
311
|
+
* @returns Findings, each naming its origin and line.
|
|
312
|
+
*/
|
|
313
|
+
function findingsIn(source, detector) {
|
|
314
|
+
if (source.text === "") return [];
|
|
315
|
+
const lines = source.text.split("\n");
|
|
316
|
+
return detector
|
|
317
|
+
.findDownstreamReferences(source.text)
|
|
318
|
+
.filter(hit => !isEndpoint(lines[hit.line - 1] ?? "", hit.match))
|
|
319
|
+
.map(hit => ({ ...hit, origin: source.origin }));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Render the refusal an agent will act on.
|
|
324
|
+
* @param findings - Every violation found.
|
|
325
|
+
* @returns The message body.
|
|
326
|
+
*/
|
|
327
|
+
export function formatRefusal(findings) {
|
|
328
|
+
const located = findings
|
|
329
|
+
.map(hit => ` ${hit.origin}:${hit.line} — "${hit.match}" (${hit.reason})`)
|
|
330
|
+
.join("\n");
|
|
331
|
+
return `BLOCKED: this tracker write names a host project.
|
|
332
|
+
|
|
333
|
+
${located}
|
|
334
|
+
|
|
335
|
+
WHY: this repository is public and \`dist/\` ships to npm, so a host identity in
|
|
336
|
+
an issue body is published twice — on github.com and to every consumer of the
|
|
337
|
+
package. See the "Never name a downstream project" rule.
|
|
338
|
+
|
|
339
|
+
FIX: write the evidence, not the identity. "a caller repo in the portfolio",
|
|
340
|
+
"repo A" / "repo B" where two are contrasted, \`<ticket>\` for a real ticket id.
|
|
341
|
+
The argument never depends on which named client proved it — if it seems to,
|
|
342
|
+
the argument is the thing to fix.
|
|
343
|
+
|
|
344
|
+
SCOPE: this checks a curated list of known names. It is a rate reduction, not a
|
|
345
|
+
closed class — a spelling nobody has added is invisible to it, so a clean result
|
|
346
|
+
means "no known name appeared", not "this text is safe".`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Evaluate one command.
|
|
351
|
+
* @param command - The command string.
|
|
352
|
+
* @param projectDir - The project the command runs in.
|
|
353
|
+
* @returns The exit code and any message.
|
|
354
|
+
*/
|
|
355
|
+
export async function evaluate(command, projectDir) {
|
|
356
|
+
const tokens = tokenize(command);
|
|
357
|
+
if (!isOutboundTrackerWrite(tokens)) return { code: EXIT_ALLOWED };
|
|
358
|
+
const detector = await loadDetector(projectDir);
|
|
359
|
+
if (detector === undefined) {
|
|
360
|
+
return {
|
|
361
|
+
code: EXIT_ALLOWED,
|
|
362
|
+
warning:
|
|
363
|
+
"block-host-name-leak: host-name denylist not found (no built dist/ or installed package); host-identity enforcement is NOT active",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const findings = publishableText(tokens, projectDir).flatMap(source =>
|
|
367
|
+
findingsIn(source, detector)
|
|
368
|
+
);
|
|
369
|
+
if (findings.length === 0) return { code: EXIT_ALLOWED };
|
|
370
|
+
return { code: EXIT_BLOCKED, message: formatRefusal(findings) };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Entry point: `node block-host-name-leak.mjs <command> [projectDir]`. */
|
|
374
|
+
async function main() {
|
|
375
|
+
const [, , command = "", projectDir = process.cwd()] = process.argv;
|
|
376
|
+
if (command === "") process.exit(EXIT_ALLOWED);
|
|
377
|
+
const result = await evaluate(command, projectDir);
|
|
378
|
+
if (result.warning !== undefined) process.stderr.write(`${result.warning}\n`);
|
|
379
|
+
if (result.message !== undefined) process.stderr.write(`${result.message}\n`);
|
|
380
|
+
process.exit(result.code);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Whether this module is the process entry point.
|
|
385
|
+
*
|
|
386
|
+
* Both sides are realpath'd: comparing `fileURLToPath(import.meta.url)` against
|
|
387
|
+
* `process.argv[1]` disagrees whenever the checkout is reached through a
|
|
388
|
+
* symlink — a git worktree, or any `/tmp` path on macOS, `/tmp` being a symlink
|
|
389
|
+
* to `/private/tmp` — because `import.meta.url` is the REAL path while
|
|
390
|
+
* `argv[1]` is whatever the caller typed. Comparing basenames, which an earlier
|
|
391
|
+
* draft of this file did, matches any file that happens to share a name.
|
|
392
|
+
*
|
|
393
|
+
* The one implementation lives at `scripts/lib/invoked-as-script.mjs`. This
|
|
394
|
+
* file cannot import it: it is materialized into every plugin payload, where
|
|
395
|
+
* there is no `./lib/` to import from — the same accommodation
|
|
396
|
+
* `inject-resolved-config.mjs` and `threshold-ratchet.mjs` make.
|
|
397
|
+
* @param {string} moduleUrl The caller's own `import.meta.url`.
|
|
398
|
+
* @param {string | undefined} [argv1] Entry path; defaults to `process.argv[1]`.
|
|
399
|
+
* @returns {boolean} Whether the caller should run its CLI body.
|
|
400
|
+
*/
|
|
401
|
+
export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
|
|
402
|
+
if (!argv1) return false;
|
|
403
|
+
try {
|
|
404
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
405
|
+
} catch {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (invokedAsScript(import.meta.url)) {
|
|
411
|
+
await main();
|
|
412
|
+
}
|