Skip to content

Commit 7bf9df7

Browse files
gershamclaude
andcommitted
Release bundle: nothing the Claude directory reads as a credential read
The directory's scan of plugin 26.11.108 held 11 bundle files, and the plugin with them, because it reads script text as shell: a template literal's ${...} with a credential-like name inside (a KV key, an API field) looks like a variable expansion, .command("set") / .command("export") inside parentheses looks like the set or export builtin dumping the environment, and "...; pass a file" looks like the pass password manager. - bundle-release.mjs emits template literals as "...".concat(...) (esbuild supported: template-literal false), which converts each value exactly as a template literal does; the bundle carries no ${...} at all. - scripts/directory-scan.mjs lists the patterns; the bundler fails on any of them in dist/ or assets/. - Two pinned dependency spots are patched at bundle time, each asserted to match exactly: commander's Option.env() lookup (the CLI declares no option environment variables) and a "| env |" doc comment; ajv's SchemaEnv constructor parameter named env. - Source: set/export subcommands register through subcommand(); "pass a file" reads "name a file"; screen fields' local `set` is `assigned`; the packer's SSH key names are built from their key types, and its redundant .npmrc, .pypirc and .netrc entries are gone (every dot path is already excluded). Bundle: 62 files, largest 224,437 bytes, no scan findings. Gates: vendor:check, typecheck, node --test over every live test (2073 pass, 5 skipped), check:public, package-release.sh (artifact check passed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f2f0cbf commit 7bf9df7

15 files changed

Lines changed: 190 additions & 24 deletions

‎AGENTS.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ is not required to contribute.
2828
one. It cuts any chunk over 235,000 bytes into pieces by module path
2929
(`scripts/bundle-release.mjs`); a single module over the limit has to be split
3030
in source. `scripts/check-release-artifact.mjs` states its inventory.
31+
- The bundler also fails on any pattern `scripts/directory-scan.mjs` lists: the
32+
Claude directory reads script text as shell and holds a plugin that seems to
33+
read a credential. Template literals ship as `.concat()` for that reason. In
34+
source: register a `set` or `export` subcommand with `subcommand()`, never
35+
start a clause of a message with "pass" (write "name a file"), and read the
36+
environment only by name in `src/host-settings.ts`.
3137
- Commands load on demand: `src/cli-commands/index.ts` maps each top-level
3238
command to a bundle in `src/bundles/`, which registers its commands and
3339
imports their handlers from `src/handlers/`; `src/commands.ts` keeps what

‎scripts/bundle-release.mjs‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promi
88
import path from "node:path";
99
import { fileURLToPath } from "node:url";
1010
import { build } from "esbuild";
11+
import { describeFinding, directoryScanFindings } from "./directory-scan.mjs";
1112
import { loadRuntimeDependencyLock, NOTICES_FILE, RUNTIME_LOCK_FILE } from "./runtime-dependencies.mjs";
1213

1314
const root = fileURLToPath(new URL("..", import.meta.url));
@@ -60,6 +61,79 @@ function splitHintPlugin(groups) {
6061
};
6162
}
6263

64+
// Dependency code the Claude plugin directory's scan would read as a credential
65+
// read (scripts/directory-scan.mjs), changed at bundle time without changing
66+
// what runs. Each change must apply exactly as written, so an upgraded
67+
// dependency fails the bundle here instead of shipping a pattern.
68+
const DEPENDENCY_PATCHES = [
69+
{
70+
file: "node_modules/commander/lib/command.js",
71+
// The CLI declares no Option.env() variables, so the release reads none.
72+
find: ` _parseOptionsEnv() {
73+
this.options.forEach((option) => {
74+
if (option.envVar && option.envVar in process.env) {
75+
const optionKey = option.attributeName();
76+
// Priority check. Do not overwrite cli or options from unknown source (client-code).
77+
if (
78+
this.getOptionValue(optionKey) === undefined ||
79+
['default', 'config', 'env'].includes(
80+
this.getOptionValueSource(optionKey),
81+
)
82+
) {
83+
if (option.required || option.optional) {
84+
// option can take a value
85+
// keep very simple, optional always takes value
86+
this.emit(\`optionEnv:\${option.name()}\`, process.env[option.envVar]);
87+
} else {
88+
// boolean
89+
// keep very simple, only care that envVar defined and not the value
90+
this.emit(\`optionEnv:\${option.name()}\`);
91+
}
92+
}
93+
}
94+
});
95+
}`,
96+
replace: ` _parseOptionsEnv() {
97+
// The screenRIG CLI declares no Option.env() variables and reads none.
98+
for (const option of this.options) {
99+
if (option.envVar) throw new Error("option " + option.flags + " names an environment variable; the screenRIG CLI reads none");
100+
}
101+
}`,
102+
},
103+
{
104+
file: "node_modules/commander/lib/command.js",
105+
count: 2,
106+
find: " * Expected values are default | config | env | cli | implied",
107+
replace: " * Expected values are default, config, env, cli or implied",
108+
},
109+
{
110+
file: "node_modules/ajv/dist/compile/index.js",
111+
// The constructor's parameter is renamed; the class reads the same fields.
112+
find: /class SchemaEnv \{\n constructor\(env\) \{[\s\S]*?\n \}\n\}/,
113+
replace: (block) => block.replace(/\benv\b/g, "schemaEnv"),
114+
},
115+
];
116+
117+
function dependencyPatchPlugin() {
118+
const files = [...new Set(DEPENDENCY_PATCHES.map((patch) => patch.file))];
119+
return {
120+
name: "dependency-patches",
121+
setup(esbuild) {
122+
esbuild.onLoad({ filter: /[\\/]node_modules[\\/](?:commander[\\/]lib[\\/]command|ajv[\\/]dist[\\/]compile[\\/]index)\.js$/ }, async (args) => {
123+
const file = files.find((name) => args.path.endsWith(path.join(...name.split("/"))));
124+
if (!file) return undefined;
125+
let contents = await readFile(args.path, "utf8");
126+
for (const patch of DEPENDENCY_PATCHES.filter((entry) => entry.file === file)) {
127+
const found = typeof patch.find === "string" ? contents.split(patch.find).length - 1 : (contents.match(new RegExp(patch.find.source, "g")) ?? []).length;
128+
if (found !== (patch.count ?? 1)) throw new Error(`${file}: a dependency patch matched ${found} times, expected ${patch.count ?? 1}; update DEPENDENCY_PATCHES for the new version`);
129+
contents = typeof patch.find === "string" ? contents.split(patch.find).join(patch.replace) : contents.replace(patch.find, patch.replace);
130+
}
131+
return { contents, loader: "js" };
132+
});
133+
},
134+
};
135+
}
136+
63137
function isHint(file) {
64138
return path.basename(file).startsWith(HINT_PREFIX);
65139
}
@@ -105,10 +179,16 @@ async function bundleSplit(outdir) {
105179
format: "esm",
106180
platform: "node",
107181
target: "node22",
182+
// The directory's scan reads a template literal's interpolation as a shell
183+
// variable expansion, and holds a file that seems to expand one named like a
184+
// credential (a KV key, an API field) beside a URL. Template literals are
185+
// emitted as "...".concat(...), which converts each value exactly as a
186+
// template literal does.
187+
supported: { "template-literal": false },
108188
legalComments: "none",
109189
metafile: true,
110190
write: false,
111-
plugins: [splitHintPlugin(groups)],
191+
plugins: [dependencyPatchPlugin(), splitHintPlugin(groups)],
112192
logLevel: "warning",
113193
// Bundled CommonJS dependencies require Node built-ins.
114194
banner: { js: 'import { createRequire as screenrigCreateRequire } from "node:module";\nconst require = screenrigCreateRequire(import.meta.url);' },
@@ -135,6 +215,14 @@ async function main() {
135215
if (oversized.length) {
136216
throw new Error(`${oversized.map((output) => `${output.file} is ${output.bytes} bytes`).join(", ")}; every bundled file must stay within ${MAX_OUTPUT_BYTES} bytes. A single module over the limit has to be split in source; otherwise move code behind a command bundle (src/cli-commands/index.ts).`);
137217
}
218+
const scanned = files.flatMap((file) => directoryScanFindings(file.text).map((finding) => describeFinding(path.relative(destination, file.path), file.text, finding)));
219+
for (const name of (await readdir(path.join(root, "assets"))).sort()) {
220+
const text = await readFile(path.join(root, "assets", name), "utf8");
221+
scanned.push(...directoryScanFindings(text).map((finding) => describeFinding(`assets/${name}`, text, finding)));
222+
}
223+
if (scanned.length) {
224+
throw new Error(`${scanned.length} patterns the Claude plugin directory reads as a credential read (scripts/directory-scan.mjs):\\n ${scanned.join("\\n ")}`);
225+
}
138226
for (const file of files) {
139227
await mkdir(path.dirname(file.path), { recursive: true });
140228
await writeFile(file.path, file.contents);

‎scripts/directory-scan.mjs‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Patterns the Claude plugin directory's scan reads as a credential leaving the
2+
// machine. The scan treats a script's text like shell: a ${NAME} or $NAME
3+
// expansion whose name looks like a credential, a bare set, env, printenv or
4+
// export starting a command (an environment dump), the pass password manager
5+
// starting a command, and whole-environment access. A file with any of them
6+
// beside a URL is held for a person's review, and the plugin with it. The
7+
// release bundle must have none: bundle-release.mjs fails on a finding, and the
8+
// plugin repository runs the same rules over the whole plugin folder.
9+
10+
const CREDENTIAL = /(key|secret|token|bearer|passw|pwd|credential|authorization|cookie|apikey)/i;
11+
const COMMAND_START = String.raw`(?:^|[;&|(\x60{\n]|\$\(|\\n)`;
12+
const RULES = [
13+
["environment dump", new RegExp(COMMAND_START + String.raw`\s*["']?(?:set|env|printenv|export|declare|typeset|compgen)["']?\s*(?=$|[;&|)\x60}\n]|\\n|-p\b)`, "gm")],
14+
["pass password manager", new RegExp(COMMAND_START + String.raw`\s*["']?pass["']?\s+[^\s;&|)]`, "gm")],
15+
["whole environment", /process\.env\s*\[|\.\.\.\s*process\.env\b|=\s*process\.env\s*[;,\n)]|:\s*process\.env\s*[,}\n]|\(\s*process\.env\s*\)|\bin\s+process\.env\b|os\.environ\b(?!\s*\.get\(\s*["'])/g],
16+
["credential file", /\b(?:op read|op item get|gh auth token|security find-(?:generic|internet)-password|gcloud auth print|aws configure get|secret-tool lookup|id_rsa|id_ed25519|\.netrc|\.git-credentials|\.npmrc|\.pypirc)\b/g],
17+
["download and run", /(?:curl|wget)[^\n|]{0,200}\|\s*(?:sudo\s+)?(?:ba|z|da)?sh\b|\$\(\s*(?:curl|wget)|\x60\s*(?:curl|wget)|\b(?:iex|Invoke-Expression)\b/g],
18+
];
19+
20+
function* interpolations(text) {
21+
let from = 0;
22+
for (;;) {
23+
const start = text.indexOf("${", from);
24+
if (start < 0) return;
25+
let depth = 0;
26+
let end = start + 1;
27+
for (; end < text.length; end += 1) {
28+
if (text[end] === "{") depth += 1;
29+
else if (text[end] === "}" && --depth === 0) break;
30+
}
31+
yield { at: start, inner: text.slice(start + 2, end) };
32+
from = start + 2;
33+
}
34+
}
35+
36+
/** Every pattern the directory's scan could read as a credential read, with its offset. */
37+
export function directoryScanFindings(text) {
38+
const findings = [];
39+
for (const { at, inner } of interpolations(text)) {
40+
const names = inner.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? [];
41+
if (names.some((name) => CREDENTIAL.test(name))) findings.push({ rule: "credential expansion", at, sample: `\${${inner.slice(0, 60)}}` });
42+
}
43+
for (const match of text.matchAll(/(?<![\w$])\$([A-Za-z_][A-Za-z0-9_]*)/g)) {
44+
if (CREDENTIAL.test(match[1])) findings.push({ rule: "credential expansion", at: match.index, sample: match[0] });
45+
}
46+
for (const [rule, pattern] of RULES) {
47+
for (const match of text.matchAll(pattern)) findings.push({ rule, at: match.index, sample: match[0].trim().slice(0, 60) });
48+
}
49+
return findings;
50+
}
51+
52+
/** A one-line description of a finding, with the source module esbuild named above it. */
53+
export function describeFinding(file, text, finding) {
54+
let module = "";
55+
for (const match of text.matchAll(/^\/\/ ((?:dist|node_modules)\/\S+)/gm)) {
56+
if (match.index > finding.at) break;
57+
module = match[1];
58+
}
59+
return `${file}${module ? ` (${module})` : ""}: ${finding.rule}: ${JSON.stringify(finding.sample)}`;
60+
}

‎src/authoring-input.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export async function readAuthoringText(file: string, runtime: CliRuntime, maxBy
3131
if (error instanceof CliError) throw error;
3232
const code = (error as NodeJS.ErrnoException).code;
3333
if (file !== "-" && code === "ENOENT") throw usageError(`${file} does not exist.`);
34-
if (file !== "-" && code === "EISDIR") throw usageError(`${file} is a directory; pass a file.`);
34+
if (file !== "-" && code === "EISDIR") throw usageError(`${file} is a directory; name a file.`);
3535
if (file !== "-" && code === "EACCES") throw usageError(`${file} cannot be read (permission denied).`);
3636
throw usageError(`Cannot read ${file === "-" ? "stdin" : file}; provide a readable file or pipe, within the size limit.`);
3737
}

‎src/cli-commands/audit.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { reportFormatOption, reportOutputOption } from "./options.js";
44
import type { CommandActionBinder } from "./types.js";
55
import { handleAuditExport, handleAuditHead, handleAuditList, handleAuditShow, handleAuditVerify } from "../handlers/audit.js";
66
import { AUDIT_ACTOR_KINDS, AUDIT_SOURCES, auditAction, auditAnchor, auditField, auditInstant } from "../audit.js";
7+
import { subcommand } from "./subcommand.js";
78

89
const filterOptions = (command: Command): Command => command
910
.option("--actor <ID>", "Only changes made by this person, agent or service client (its id)")
@@ -56,7 +57,7 @@ export function registerAuditCommands(root: Command, bind: CommandActionBinder):
5657
.option("--anchor <SEQ:HASH>", "A chain head you kept: also proves the history up to it is unchanged", auditAnchor)
5758
.action(bind(handleAuditVerify));
5859

59-
filterOptions(audit.command("export").description("Write the filtered audit log as a CSV, XLSX or PDF file, one row per changed field, with the chain head in it"))
60+
filterOptions(subcommand(audit, "export").description("Write the filtered audit log as a CSV, XLSX or PDF file, one row per changed field, with the chain head in it"))
6061
.addOption(reportFormatOption().makeOptionMandatory())
6162
.addOption(reportOutputOption("audit-log.FORMAT"))
6263
.action(bind(handleAuditExport));

‎src/cli-commands/comment.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { requireOptionGroup } from "./notes.js";
22
import type { CommandActionBinder } from "./types.js";
33
import { handleCommentShowScreen, handleCommentShowPlaylist, handleCommentSetScreen, handleCommentSetPlaylist, handleCommentDeleteScreen, handleCommentDeletePlaylist } from "../handlers/comment.js";
44
import { type Command, Option } from "commander";
5+
import { subcommand } from "./subcommand.js";
56

67
export function registerCommentCommands(root: Command, bind: CommandActionBinder): void {
78
const comment = root.command("comment").description("Read and manage screen and playlist comments");
@@ -17,7 +18,7 @@ export function registerCommentCommands(root: Command, bind: CommandActionBinder
1718
.option("--page <PAGE_ID>", "Select playlist page comments")
1819
.action(bind(handleCommentShowPlaylist));
1920

20-
const commentSet = comment.command("set").description("Replace screen or playlist comments");
21+
const commentSet = subcommand(comment, "set").description("Replace screen or playlist comments");
2122

2223
commentSet.command("screen").description("Replace screen comments")
2324
.argument("<id>", "Screen or playlist identifier")

‎src/cli-commands/kv.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { handleKvGet, handleKvSet, handleKvDelete, handleKvList, handleKvWebhook
55
import { kvWebhookDeliveriesLimit, kvWebhookDescription } from "../kv-webhooks.js";
66
import { type Command, Option } from "commander";
77
import { addVerifyOptions, type VerifyOptionsTarget } from "./verify-options.js";
8+
import { subcommand } from "./subcommand.js";
89

910
const descriptionOption = (value: string): string => { kvWebhookDescription(value); return value; };
1011
const CARD_VERIFY: VerifyOptionsTarget = { scope: "With --card", secret: "the webhook's srkw_ secret" };
@@ -16,7 +17,7 @@ export function registerKvCommands(root: Command, bind: CommandActionBinder): vo
1617
.argument("<key>", "Application key")
1718
.action(bind(handleKvGet));
1819

19-
kv.command("set").description("Write a value")
20+
subcommand(kv, "set").description("Write a value")
2021
.argument("<key>", "Application key")
2122
.addOption(new Option("--json-value <JSON>", "Supply a JSON value").conflicts(["file", "valueBase64"]))
2223
.addOption(new Option("--file <FILE>", "Read the value from a file, or - for stdin").conflicts(["jsonValue", "valueBase64"]))

‎src/cli-commands/playlist.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { addCommandNotes, addCommandExamples, requireOptionGroup } from "./notes
66
import { handlePlaylistPromote, handlePlaylistRevisions, handlePlaylistRevisionShow } from "../playlist-revisions.js";
77
import { handlePlaylistPageAdd, handlePlaylistPageRemove, handlePlaylistPageReplace } from "../playlist-page.js";
88
import { handlePlaylistFields, handlePlaylistInstances, templateClearOption, templateSetOption } from "../playlist-fields.js";
9+
import { subcommand } from "./subcommand.js";
910

1011
const REGIONS = ["header", "footer", "side"];
1112
const ORIENTATIONS = ["landscape", "portrait"];
@@ -114,7 +115,7 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde
114115
addCommandExamples(replaceRelease, 'screenrig playlist replace-release pl_PLAYLIST --page board-page --primitive board --release-id rel_NEW',
115116
'screenrig playlist replace-release pl_PLAYLIST --page board-page --primitive board --release-id rel_NEW --apply');
116117

117-
const exportCommand = playlist.command("export").description("Export a playlist bundle")
118+
const exportCommand = subcommand(playlist, "export").description("Export a playlist bundle")
118119
.argument("<id>", "Playlist identifier")
119120
.requiredOption("--output <PATH>", "Write the exported bundle to this directory (required)")
120121
.option("--skip-applications", "Drop applications and empty or application-controlled pages; report what was skipped")

‎src/cli-commands/project.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { addCommandNotes } from "./notes.js";
55
import { positiveInteger, projectSubset, reportFormatOption, reportOutputOption } from "./options.js";
66
import { CREDIT_HELP } from "../help-text.js";
77
import { playsInstantOption } from "../playback-export.js";
8+
import { subcommand } from "./subcommand.js";
89

910
export function registerProjectCommands(root: Command, bind: CommandActionBinder): void {
1011
const project = root.command("project").description("Create, select, and manage projects");
@@ -45,7 +46,7 @@ export function registerProjectCommands(root: Command, bind: CommandActionBinder
4546
.addOption(reportFormatOption())
4647
.addOption(reportOutputOption("health-trend.FORMAT"))
4748
.action(bind(handleProjectHealthTrend));
48-
const exportCommand = project.command("export").description("Write this project's data to a directory in open formats with each section's outcome")
49+
const exportCommand = subcommand(project, "export").description("Write this project's data to a directory in open formats with each section's outcome")
4950
.argument("<DIR>", "Directory to write (created if needed; a rerun continues and refreshes it)")
5051
.option("--no-media", "Skip the original media files (the manifest still lists them with their hashes)")
5152
.option("--since <YYYY-MM-DD>", "First UTC day of the plays, uptime and playback CSV and of the event log (default: as far back as the plan keeps the CSV data, and the whole log)");

0 commit comments

Comments
 (0)