From a9c80f45a2d892a651c6ce963eb9927f21d4d4e4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 24 Oct 2026 14:02:12 -0400 Subject: [PATCH 1/3] fix(agents): refuse git send-email command options and other Git porcelain command runners (HACK-1438) The task-catalog inline-command denylist accepted `git send-email --to-cmd/--cc-cmd/--header-cmd/--sendmail-cmd` and `--smtp-server=/path`, each of which runs a command. send-email reads options with Perl's Getopt::Long defaults, so the one-dash and `+` prefixes, any letter case and unique prefixes are refused too, scoped to the send-email subcommand (dashed, absolute or mixed-case). Exact `--to`, `--cc` and `-h` still pass. A sweep of Git's porcelain adds the remaining command runners, each scoped to its subcommand: `grep -O`/`--open-files-in-pager`, `daemon --access-hook`, `instaweb -d`/`--httpd`, `svn --authors-prog`, plus the `remote-ext` helper, `ext::` URLs and `archimport` (which pastes its operands into a shell pipeline). Subcommand-scoped tables are now matched case-insensitively because a case-insensitive filesystem runs `git-send-email` for `git Send-Email`. Refs HACK-1438 Co-Authored-By: Claude Opus 5.5 --- docs/reference/ax-task-catalog.md | 45 ++++++++- src/lib/task-catalog-recipe.ts | 132 +++++++++++++++++++++++--- tests/project-task-catalog.test.ts | 144 +++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+), 16 deletions(-) diff --git a/docs/reference/ax-task-catalog.md b/docs/reference/ax-task-catalog.md index 43b1c4160..63987e797 100644 --- a/docs/reference/ax-task-catalog.md +++ b/docs/reference/ax-task-catalog.md @@ -76,7 +76,22 @@ is not implemented here. `doas -s`, `git -c`/`--config`/`--exec`/`--upload-pack`/ `--receive-pack`/`clone -u`/`submodule foreach`/`bisect`/ `filter-branch`, a Git `!` alias value such as - `git config alias.x '!cmd'`, `make --eval`, `nix eval`). A dashed Git + `git config alias.x '!cmd'`, `make --eval`, `nix eval`). Git porcelain + options that run a command are refused under their own subcommand: + `send-email --to-cmd`/`--cc-cmd`/`--header-cmd`/`--sendmail-cmd` and + `--smtp-server` (an absolute path there is a sendmail program), + `grep -O`/`--open-files-in-pager`, `daemon --access-hook`, + `instaweb -d`/`--httpd` and `svn --authors-prog`. So are the + `remote-ext` helper, an `ext::` remote URL (Git runs it when + `protocol.ext.allow` permits) and `archimport`, which pastes its + operands into a shell pipeline. `git send-email` reads options with + Perl's Getopt::Long, which also takes a one-dash or `+` prefix, any + letter case and any unique prefix, so `-to-cmd=cmd`, `+to-cmd=cmd`, + `--CC-CMD=cmd` and `--cc-cm=cmd` count too. The `sendemail.*Cmd` config + equivalents are refused through `git -c`/`--config-env`, whatever the + key. A subcommand name is matched in any letter case, because Git finds + a script subcommand as a file and a case-insensitive filesystem runs + `git-send-email` for `git Send-Email`. A dashed Git builtin, by basename or absolute path (`git-clone`, `/usr/lib/git-core/git-config`), is read as `git `, so `git-clone -u cmd`, `git-config alias.x '!cmd'`, @@ -106,8 +121,10 @@ is not implemented here. `duckdb -storage-version v1.2.0 data.duckdb`, `python -m pytest`, `python -mpytest`, `python -m unittest discover -s tests`, `psql -o out.txt -f script.sql`, `git-clone url dir`, - `git clone url dir`, `git push -u origin main`, `git add -u` and - `git config alias.st status`. + `git clone url dir`, `git push -u origin main`, `git add -u`, + `git config alias.st status`, + `git send-email --to=a@b.example --cc c@d.example 0001.patch`, + `git rev-list --header HEAD` and `git branch -d topic`. 3. **Repository and log text grant no authority.** A recipe that passes every check is `reviewed`: its pins still match what a reviewer approved. Results still say `authority: "none"` and `execution: "unavailable"`. A recipe that @@ -358,6 +375,8 @@ until its recipes are re-reviewed. | `psql -o`/`--output` piped to a command (`-o '\|cmd'`, `-o\|cmd`, `-qo\|cmd`, `--output=\|cmd`, `--out=\|cmd`) | `inline_command`; `psql -o out.txt`, `psql -F'\|'`, `psql -L '\|log'` (a file name) still pass | domain test | | Dashed Git builtins (`/usr/lib/git-core/git-clone -u cmd`, `git-config alias.x '!cmd'`, `git-ls-remote --upload-pack=cmd`, `git-fetch-pack --exec=cmd`) | `inline_command`; `git-clone url dir`, `git-push -u origin main`, `git-config alias.st status` still pass | domain test | | Git plumbing helpers read as their porcelain (`git-bisect--helper --bisect-run cmd`, `/usr/lib/git-core/git-bisect--helper.exe run cmd`, `git bisect--helper --bisect-run cmd`, `git-submodule--helper foreach cmd`, `git-config alias.x bisect--helper`, older Git's `git rebase--helper --add-exec-commands cmd` in any letter case) | `inline_command`; `git-submodule--helper status`, `git-difftool--helper a b`, `git add bisect--helper.txt`, `git config --add alias.st status` still pass | domain test | +| `git send-email --to-cmd`/`--cc-cmd`/`--header-cmd`/`--sendmail-cmd`/`--smtp-server` in every Getopt::Long spelling (`--opt cmd`, `--opt=cmd`, `--cc-cm`, `--CC-CMD`, `-to-cmd`, `+to-cmd`), dashed or mixed-case (`git-send-email`, `git Send-Email`), and `-c sendemail.ccCmd=…`/`toCmd`/`headerCmd`/`sendmailCmd` in any key case | `inline_command`; `git send-email --to=… --cc …`, `--suppress-cc=all`, `--no-header-cmd`, `--smtp-server-port=587`, `git rev-list --header`, `git status -s`, `git format-patch --to=…` still pass | domain test | +| Other Git porcelain command runners (`git grep -Ocmd`/`--open-files-in-pager=cmd`, `git daemon --access-hook=cmd`, `git instaweb -d`/`--httpd`, `git svn --authors-prog`, `git remote-ext`, `ext::` URLs, `git archimport`) | `inline_command`; `git grep -n`, `git branch -d`, `git svn fetch --authors-file=…`, `git daemon --export-all`, an `https://` remote still pass | domain test | | Detached linked worktree with no named instance, on CLI `task list`/`task inspect` and MCP `hack.project.task.list`/`inspect` | `detached_checkout`; naming the instance (`--branch`, MCP `branch`) passes | paired fixture, domain test | | `duckdb -storage-version VER db` | accepted; a SQL operand after the database is still refused | domain test | | `git clone -u` (also after `--attr-source ` or an unknown global option), abbreviated or attached `--upload-pack`/`--receive-pack`/`--exec`, `clone --config`, and `!` alias values (`git config alias.x '!cmd'`, `git -c alias.x=!cmd`) | `inline_command`; `git clone url dir`, `git push -u origin main`, `git add -u`, `git config alias.st status` still pass | domain test | @@ -426,8 +445,24 @@ until its recipes are re-reviewed. `git push -u origin clone--helper` (a branch of that name) and `git add bisect--helper` (a file of that name) are refused. The `rebase`-scoped flag counts when any operand is `rebase`, and Git abbreviates, so - `git config --add alias.r rebase` is refused too. Git config writes other than `!` aliases (for example - `git config core.pager cmd` or `include.path`) are not detected. Nor are + `git config --add alias.r rebase` is refused too. Any prefix of a refused + `send-email` option is refused, even one Getopt::Long finds ambiguous + and passes on to `format-patch` (`git send-email -s`, `--smtp`), and + `--smtp-server` is refused whatever its value, so a host name too. + `grep -O` is case-folded like every flag, so `git grep -o` is refused; + use `--only-matching`. A subcommand name is matched at any operand and + in any letter case, so a path or branch named `Grep` or `send-email` + scopes those flags to it. A repository alias still hides a + subcommand from its scoped flags (`git se --cc-cmd cmd` with + `alias.se = send-email`). Git config writes other than `!` aliases (for example + `git config core.pager cmd`, `git config sendemail.ccCmd cmd` or + `include.path`) are not detected. Nor are Git options that run a + program file rather than a string: `git --exec-path=dir` (non-builtin + subcommands and remote helpers are looked up there), + `git clone --template=dir` (its hooks, such as `post-checkout`, run + during the clone), `git merge -s name` (runs `git-merge-name` from + `PATH`), `git hook run` and a `transport::address` URL (runs + `git-remote-transport` from `PATH`). Nor are `mysql`/`mariadb` option files named in argv (`--defaults-extra-file`, which can set `init-command`) or `--pager`, which runs a command only in interactive mode. diff --git a/src/lib/task-catalog-recipe.ts b/src/lib/task-catalog-recipe.ts index 4aa589b23..982c003aa 100644 --- a/src/lib/task-catalog-recipe.ts +++ b/src/lib/task-catalog-recipe.ts @@ -372,15 +372,46 @@ type Evaluator = { * `git push -u` sets an upstream. They count when any operand names the * subcommand, not only the first, so a global option missing from * `valueOptions` (whose value would be taken as the subcommand) cannot - * hide it (fail-closed). + * hide it (fail-closed). Keys are lowercase and operands are lowercased + * before lookup, because Git finds a non-builtin subcommand + * (`git-send-email`, `git-instaweb`) as a file in its exec path, and on a + * case-insensitive filesystem `git Send-Email` runs it. */ readonly subcommandInline: Readonly>; + /** + * Inline options of a subcommand that is a Perl script reading its + * options with Getopt::Long's defaults (`git send-email`), keyed like + * `subcommandInline` and scoped the same way. Getopt::Long's grammar + * differs from Git's, so these are matched by `getoptLongSelects`. + */ + readonly subcommandGetoptLong: Readonly>; /** * True when a value starting with `!` (a whole argument, or after `=`) * is a shell command: a Git alias defined as `!cmd`, through * `git config alias.x '!cmd'` or `git -c alias.x=!cmd`. */ readonly bangCommands: boolean; + /** + * True when an `ext::` remote URL (a whole argument, or after `=`) is a + * command: Git's `ext` remote helper runs the rest of the URL + * (`git ls-remote 'ext::sh -c cmd'`) when `protocol.ext.allow` permits. + */ + readonly extUrls: boolean; +}; + +/** + * Inline options of a Getopt::Long subcommand, as option names without a + * prefix, lowercase. + */ +type GetoptLongOptions = { + /** Options whose value is a command the script runs. */ + readonly inline: readonly string[]; + /** + * Other options of the same script whose whole name is a prefix of an + * inline option (`to` for `to-cmd`). Getopt::Long takes an exact name + * as that option, so `--to` is not an abbreviation of `--to-cmd`. + */ + readonly exact: readonly string[]; }; function evaluator(spec: Partial): Evaluator { @@ -404,7 +435,9 @@ function evaluator(spec: Partial): Evaluator { trampolineModules: [], pipeOptions: [], subcommandInline: {}, + subcommandGetoptLong: {}, bangCommands: false, + extUrls: false, ...spec, }; } @@ -668,6 +701,13 @@ const EVALUATORS: Readonly> = { // so older Git's `rebase--helper --add-exec-commands cmd` meets a // `rebase`-scoped flag. That flag is scoped because, as a `git` inline // flag, it would also match `git config --add` (Git abbreviates options). + // HACK-1438 adds the remaining porcelain options that run a command, each + // scoped to its subcommand: `send-email --to-cmd`/`--cc-cmd`/ + // `--header-cmd`/`--sendmail-cmd` and `--smtp-server` (an absolute path + // there is a sendmail program), `grep -O`/`--open-files-in-pager`, + // `daemon --access-hook`, `instaweb -d`/`--httpd` and + // `svn --authors-prog`; the `remote-ext` helper and its `ext::` URLs; + // and `archimport`, which pastes its operands into a shell pipeline. git: evaluator({ inline: [ "-c", @@ -679,7 +719,7 @@ const EVALUATORS: Readonly> = { "--upload-pack", "--receive-pack", ], - words: ["foreach", "bisect", "filter-branch"], + words: ["foreach", "bisect", "filter-branch", "remote-ext", "archimport"], // Global options before the subcommand that consume the next argument. valueOptions: [ "-C", @@ -697,8 +737,33 @@ const EVALUATORS: Readonly> = { // cluster: `git log -Scache` and `git commit -mcleanup` are refused // (fail-closed); pass the value as its own argument instead. shortValueOptions: [], - subcommandInline: { clone: ["-u"], rebase: ["--add-exec-commands"] }, + subcommandInline: { + clone: ["-u"], + rebase: ["--add-exec-commands"], + // `-O` and `--open-files-in-pager=` run the pager + // through the shell with the matching file names. + grep: ["-O", "--open-files-in-pager"], + // Run through the shell for each connection. + daemon: ["--access-hook"], + // The HTTP daemon command line instaweb starts. + instaweb: ["-d", "--httpd"], + // git-svn runs it in backticks for each unknown SVN author. + svn: ["--authors-prog"], + }, + subcommandGetoptLong: { + "send-email": { + inline: [ + "to-cmd", + "cc-cmd", + "header-cmd", + "sendmail-cmd", + "smtp-server", + ], + exact: ["to", "cc", "h"], + }, + }, bangCommands: true, + extUrls: true, }), // SQL clients given a statement string. A statement can also reach the // host shell (`psql` `\!`, `mysql` `system`, the `sqlite3` `.shell`). @@ -1239,6 +1304,51 @@ function operands(opts: { /** A value that is a shell command to Git: `!cmd` or `key=!cmd`. */ const BANG_COMMAND = /(?:^|=)\s*!/; +/** An `ext::` remote URL, whole or after `=`, in any letter case. */ +const EXT_URL = /(?:^|=)ext::/i; +/** Getopt::Long's default option prefixes (without `POSIXLY_CORRECT`). */ +const GETOPT_LONG_PREFIX = /^(?:--|-|\+)/; + +/** + * True when an argument selects one of `options.inline` the way + * Getopt::Long reads it with its defaults, as `git send-email` does: the + * name may follow `--`, `-` or `+` (`-to-cmd`, `+to-cmd`), letter case is + * ignored (`--CC-CMD`), a value may follow `=`, and any prefix of a name + * is accepted as an abbreviation (`--cc-cm`). A prefix that names more + * than one option is ambiguous to Getopt::Long, but it is refused here + * (fail-closed), unless it is the whole name of another option listed in + * `options.exact` (`--to`, `--cc`). + */ +function getoptLongSelects(opts: { + readonly arg: string; + readonly options: GetoptLongOptions; +}): boolean { + const prefix = GETOPT_LONG_PREFIX.exec(opts.arg)?.[0]; + if (prefix === undefined) { + return false; + } + const body = opts.arg.slice(prefix.length); + const name = (body.split("=", 1)[0] ?? body).toLowerCase(); + if (name === "" || opts.options.exact.includes(name)) { + return false; + } + return opts.options.inline.some((option) => option.startsWith(name)); +} + +/** + * The entries of a subcommand-keyed table for every operand that names a + * subcommand, compared lowercased. + */ +function scopedEntries( + table: Readonly>, + positional: readonly string[] +): T[] { + return positional.flatMap((word) => { + const key = word.toLowerCase(); + const entry = Object.hasOwn(table, key) ? table[key] : undefined; + return entry === undefined ? [] : [entry]; + }); +} function isShellSyntax(arg: string): boolean { return ( @@ -1262,19 +1372,18 @@ function evaluatesCode(argv: readonly string[], index: number): boolean { } const positional = operands({ rest, spec }); const operand = positional[0] ?? null; - const scoped = positional.flatMap((word) => - Object.hasOwn(spec.subcommandInline, word) - ? (spec.subcommandInline[word] ?? []) - : [] - ); + const scoped = scopedEntries(spec.subcommandInline, positional).flat(); + const getoptLong = scopedEntries(spec.subcommandGetoptLong, positional); const flags = [...spec.inline, ...spec.stdin, ...scoped]; if ( rest.some( (arg) => matchesFlag({ arg, flags, spec }) || + getoptLong.some((options) => getoptLongSelects({ arg, options })) || spec.words.includes(arg.toLowerCase()) || (spec.dataUrlModules && DATA_URL.test(arg)) || - (spec.bangCommands && BANG_COMMAND.test(arg)) + (spec.bangCommands && BANG_COMMAND.test(arg)) || + (spec.extUrls && EXT_URL.test(arg)) ) || pipesToCommand({ rest, spec }) ) { @@ -1316,8 +1425,9 @@ function evaluatesCode(argv: readonly string[], index: number): boolean { * (`python -m code`, also behind a trampoline: `python -m runpy code`); a * SQL client given a statement string (`psql -c`, `sqlite3 db 'SQL'`) or * an output pipe (`psql -o '|cmd'`); a package runner, privilege wrapper - * or tool given its string-to-shell option, word or `!` alias value (also - * as a dashed Git builtin, `git-clone -u cmd`); + * or tool given its string-to-shell option, word, `!` alias value or + * `ext::` URL (also as a dashed Git builtin, `git-clone -u cmd`, and in + * Getopt::Long spellings for `git send-email -to-cmd=cmd`); * `eval`, `env`, `xargs`, `su`, `ssh`, awk, sed or another always-refused * program anywhere; or bare shell syntax copied from a log line. Recipes * must run a reviewed script file instead; there is no shell endpoint. diff --git a/tests/project-task-catalog.test.ts b/tests/project-task-catalog.test.ts index 6d886fdf4..79741f3b6 100644 --- a/tests/project-task-catalog.test.ts +++ b/tests/project-task-catalog.test.ts @@ -1698,6 +1698,132 @@ describe("recipe grammar", () => { ["git", "rebase--helper", "--continue"], ], }, + { + // `git send-email` is a Perl script that reads its options with + // Getopt::Long's defaults: `--`, `-` or `+` before a name, any letter + // case, `=` or a separate value, and unique prefixes. Each spelling + // here ran a marker command under Git 2.56. + issue: "HACK-1438", + rule: "git send-email options that run a command", + refused: [ + ["git", "send-email", "--to-cmd", "id", "0001.patch"], + ["git", "send-email", "--to-cmd=id", "0001.patch"], + ["git", "send-email", "--cc-cmd", "id", "0001.patch"], + ["git", "send-email", "--cc-cmd=id", "0001.patch"], + ["git", "send-email", "--header-cmd", "id", "0001.patch"], + ["git", "send-email", "--header-cmd=id", "0001.patch"], + ["git", "send-email", "--sendmail-cmd", "id", "0001.patch"], + ["git", "send-email", "--sendmail-cmd=id", "0001.patch"], + // An absolute path is a sendmail-like program, not a host. + ["git", "send-email", "--smtp-server", "/tmp/x.sh", "0001.patch"], + ["git", "send-email", "--smtp-server=/tmp/x.sh", "0001.patch"], + // Abbreviations. + ["git", "send-email", "--cc-cm=id", "0001.patch"], + ["git", "send-email", "--to-cm", "id", "0001.patch"], + ["git", "send-email", "--hea=id", "0001.patch"], + ["git", "send-email", "--sendm=id", "0001.patch"], + // Letter case, and the one-dash and `+` prefixes. + ["git", "send-email", "--CC-CMD=id", "0001.patch"], + ["git", "send-email", "-to-cmd=id", "0001.patch"], + ["git", "send-email", "-header-cmd", "id", "0001.patch"], + ["git", "send-email", "-sendmail-cmd=id", "0001.patch"], + ["git", "send-email", "-smtp-server=/tmp/x.sh", "0001.patch"], + ["git", "send-email", "+to-cmd=id", "0001.patch"], + // The dashed form, by name or absolute path, and a global option + // or mixed-case subcommand (a case-insensitive filesystem finds + // `git-send-email` for `git Send-Email`). + ["git-send-email", "--to-cmd=id", "0001.patch"], + ["/usr/lib/git-core/git-send-email", "--cc-cmd", "id", "0001.patch"], + ["git", "--git-dir", "x", "send-email", "--header-cmd=id", "p"], + ["git", "Send-Email", "--sendmail-cmd=id", "0001.patch"], + // The `sendemail.*` config equivalents (keys are case-insensitive; + // `-c` and `--config-env` are refused whatever the key). + ["git", "-c", "sendemail.ccCmd=id", "send-email", "0001.patch"], + ["git", "-c", "sendemail.toCmd=id", "send-email", "0001.patch"], + ["git", "-c", "sendemail.headerCmd=id", "send-email", "0001.patch"], + ["git", "-c", "sendemail.sendmailCmd=id", "send-email", "0001.patch"], + ["git", "-c", "SENDEMAIL.CCCMD=id", "send-email", "0001.patch"], + ["git", "-c", "sendemail.work.ccCmd=id", "send-email", "p"], + ["git", "-c", "sendemail.smtpServer=/tmp/x.sh", "send-email", "p"], + ["git", "--config-env=sendemail.toCmd=CMD", "send-email", "p"], + // The `-c` rule alone refuses it when an alias hides `send-email` + // (the Getopt::Long rule also reads `-c` as a prefix of `cc-cmd`). + [ + "git", + "-c", + "sendemail.ccCmd=id", + "-c", + "alias.se=send-email", + "se", + "p", + ], + ], + accepted: [ + [ + "git", + "send-email", + "--dry-run", + "--confirm=never", + "--to=a@b.example", + "--cc", + "c@d.example", + "0001.patch", + ], + ["git", "send-email", "--from=a@b.example", "--suppress-cc=all", "p"], + ["git", "send-email", "--sender=a@b.example", "--no-header-cmd", "p"], + ["git", "send-email", "--smtp-server-port=587", "--no-to", "p"], + ["git", "send-email", "-h"], + // Scoped to `send-email`: the same names elsewhere pass. + ["git", "rev-list", "--header", "HEAD"], + ["git", "status", "-s"], + ["git", "diff-tree", "-t", "HEAD"], + ["git", "format-patch", "--to=a@b.example", "-1"], + ["git", "log", "--cc"], + ], + }, + { + // HACK-1438 sweep: other porcelain options that run a command, each + // scoped to its subcommand. + issue: "HACK-1438", + rule: "Git porcelain options that run a command", + refused: [ + ["git", "grep", "-Oid", "x"], + ["git", "grep", "-iOid", "x"], + ["git", "grep", "--open-files-in-pager=id", "x"], + ["git", "grep", "--open-files=id", "x"], + ["git", "daemon", "--inetd", "--access-hook=id"], + ["git-daemon", "--access-hook=/tmp/x.sh", "--export-all"], + ["git", "instaweb", "--httpd=/tmp/httpd", "--start"], + ["git", "instaweb", "--htt=/tmp/httpd", "--start"], + ["git", "instaweb", "-d", "/tmp/httpd", "--start"], + ["git-instaweb", "-d/tmp/httpd"], + ["git", "svn", "clone", "--authors-prog=id", "url"], + ["git", "svn", "fetch", "--authors-p", "id"], + // The `ext` remote helper runs its URL; Git refuses the URL unless + // `protocol.ext.allow` permits it, which config can. + ["git", "remote-ext", "origin", "id"], + ["git-remote-ext", "origin", "id"], + ["git", "ls-remote", "ext::sh -c id"], + ["git", "clone", "EXT::id", "dir"], + ["git", "remote", "add", "origin", "ext::id"], + ["git", "fetch", "--negotiation-tip=x", "--", "ext::id"], + // archimport pastes each operand into a `tla abrowse` shell + // pipeline, so `x/y;cmd #` runs `cmd` even without tla. + ["git", "archimport", "x/y"], + ["git-archimport", "-v", "x/y"], + ], + accepted: [ + ["git", "grep", "-n", "pattern"], + ["git", "grep", "--only-matching", "--or", "pattern"], + ["git", "branch", "-d", "topic"], + ["git", "log", "--author", "me"], + ["git", "svn", "fetch", "--authors-file=authors.txt"], + ["git", "daemon", "--export-all", "--base-path=/srv/git"], + ["git", "instaweb", "--stop"], + ["git", "remote", "add", "origin", "https://example.test/x.git"], + ["git", "add", "extension.txt"], + ], + }, ]; for (const gap of gapCases) { @@ -1759,6 +1885,24 @@ describe("recipe grammar", () => { ).toBe(true); }); + test("HACK-1438: documented send-email and grep fail-closed collateral stays refused", () => { + // Any prefix of a refused send-email option is refused, even one that + // Getopt::Long finds ambiguous and passes to format-patch (`-s`, `-t`). + expect(isInlineCommandArgv(["git", "send-email", "-s", "p"])).toBe(true); + expect(isInlineCommandArgv(["git", "send-email", "--smtp", "h"])).toBe( + true + ); + // `--smtp-server` is refused whatever its value, so a host name too. + expect( + isInlineCommandArgv(["git", "send-email", "--smtp-server=mail", "p"]) + ).toBe(true); + // `-O` is case-folded like every flag, so `git grep -o` is refused. + expect(isInlineCommandArgv(["git", "grep", "-o", "x"])).toBe(true); + // A subcommand is matched in any letter case and at any operand, so a + // path named `Grep` scopes `-O` to it. + expect(isInlineCommandArgv(["git", "log", "-o", "--", "Grep"])).toBe(true); + }); + test("effects must be covered by required capabilities", () => { const base = recipe({ id: "ok" }); const pinAccepted = (path: string): boolean => From 7483db37c0bd52956bff394e3e7dfb4fdfd65ad6 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 24 Oct 2026 17:22:19 -0400 Subject: [PATCH 2/3] fix(agents): refuse task argv names that Unicode case folding maps to a denylisted program (HACK-1461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A case-insensitive filesystem (APFS, NTFS) looks names up with Unicode case folding, so `git ſend-email --to-cmd=cmd` and `git filter-branch` ran the denylisted Git programs while the catalog's `toLowerCase()` comparison accepted them. Program, subcommand and module names are now compared after NFKC, removal of default-ignorable code points and upper-then-lower case folding, and a listed program or a Git subcommand spelled with any non-ASCII character is refused whatever follows. Docs describe the folding and the `help.autocorrect` limit next to the alias limit; the HACK-1438 collateral test comment no longer claims a subcommand counts at any operand. Refs HACK-1461 Refs HACK-1438 Co-Authored-By: Claude Opus 5.5 --- docs/reference/ax-task-catalog.md | 21 ++++++- src/lib/task-catalog-recipe.ts | 98 ++++++++++++++++++++++-------- tests/project-task-catalog.test.ts | 66 +++++++++++++++++++- 3 files changed, 154 insertions(+), 31 deletions(-) diff --git a/docs/reference/ax-task-catalog.md b/docs/reference/ax-task-catalog.md index e26aeddd5..72ea4d914 100644 --- a/docs/reference/ax-task-catalog.md +++ b/docs/reference/ax-task-catalog.md @@ -91,7 +91,15 @@ is not implemented here. equivalents are refused through `git -c`/`--config-env`, whatever the key. A subcommand name is matched in any letter case, because Git finds a script subcommand as a file and a case-insensitive filesystem runs - `git-send-email` for `git Send-Email`. A dashed Git + `git-send-email` for `git Send-Email`. Such a filesystem folds Unicode + too: on APFS `git ſend-email` (long s) runs `git-send-email` and + `git filter-branch` (the `fi` ligature) runs `git-filter-branch`. So + program, subcommand and module names are compared after NFKC + normalization, removal of invisible (default-ignorable) characters and + full case folding (the Kelvin sign is `k`, dotless `ı` is `i`), and a + listed program or a Git subcommand spelled with any non-ASCII character + (`ſh`, `git-ſend-email`, `git ſtatus`) is refused whatever follows. + A dashed Git builtin, by basename or absolute path (`git-clone`, `/usr/lib/git-core/git-config`), is read as `git `, so `git-clone -u cmd`, `git-config alias.x '!cmd'`, @@ -379,6 +387,7 @@ until its recipes are re-reviewed. | Dashed Git builtins (`/usr/lib/git-core/git-clone -u cmd`, `git-config alias.x '!cmd'`, `git-ls-remote --upload-pack=cmd`, `git-fetch-pack --exec=cmd`) | `inline_command`; `git-clone url dir`, `git-push -u origin main`, `git-config alias.st status` still pass | domain test | | Git plumbing helpers read as their porcelain (`git-bisect--helper --bisect-run cmd`, `/usr/lib/git-core/git-bisect--helper.exe run cmd`, `git bisect--helper --bisect-run cmd`, `git-submodule--helper foreach cmd`, `git-config alias.x bisect--helper`, older Git's `git rebase--helper --add-exec-commands cmd` in any letter case) | `inline_command`; `git-submodule--helper status`, `git-difftool--helper a b`, `git add bisect--helper.txt`, `git config --add alias.st status` still pass | domain test | | `git send-email --to-cmd`/`--cc-cmd`/`--header-cmd`/`--sendmail-cmd`/`--smtp-server` in every Getopt::Long spelling (`--opt cmd`, `--opt=cmd`, `--cc-cm`, `--CC-CMD`, `-to-cmd`, `+to-cmd`), dashed or mixed-case (`git-send-email`, `git Send-Email`), and `-c sendemail.ccCmd=…`/`toCmd`/`headerCmd`/`sendmailCmd` in any key case | `inline_command`; `git send-email --to=… --cc …`, `--suppress-cc=all`, `--no-header-cmd`, `--smtp-server-port=587`, `git rev-list --header`, `git status -s`, `git format-patch --to=…` still pass | domain test | +| Names a case-insensitive filesystem folds to a listed one (`git ſend-email --to-cmd=cmd`, `git-ſend-email`, `git inſtaweb --httpd=cmd`, `git ſvn clone --authors-prog=cmd`, `git filter-branch`, `git log bıſect`, `git log filter-branch`, the Kelvin sign in `ksh -c`, dotless `ı` in `iex`), and any listed program or Git subcommand with a non-ASCII character (`ſh script.sh`, `git-ſtatus`, `git ſtatus`) | `inline_command`; `git status`, `sh scripts/x.sh`, `git commit -m 'café'`, `git log --author=Zoë`, `cat ſend-email` still pass | domain test | | Other Git porcelain command runners (`git grep -Ocmd`/`--open-files-in-pager=cmd`, `git daemon --access-hook=cmd`, `git instaweb -d`/`--httpd`, `git svn --authors-prog`, `git remote-ext`, `ext::` URLs, `git archimport`) | `inline_command`; `git grep -n`, `git branch -d`, `git svn fetch --authors-file=…`, `git daemon --export-all`, an `https://` remote still pass | domain test | | Git 2.20-2.33's `rebase--interactive --cmd cmd` (`git rebase--interactive --cmd=cmd`, `git-rebase--interactive --cmd cmd`, `--cm cmd`, a repository alias such as `git ri --cmd cmd`) and its `--add-exec-commands` mode | `inline_command`; `git rebase--interactive --continue`, `git-rebase--interactive --edit-todo` still pass | domain test | | A subcommand-scoped Git flag before the subcommand's name (`git push -u origin clone`, `git remote set-branches --add origin rebase`, `git config --add alias.r rebase`, `git rev-list --header send-email`, `git push -d origin instaweb`, `git log -o -- Grep`) | accepted; the flag after the name (`git clone -u cmd url`, `git --unknown v clone -u cmd url`, `git rebase--helper --add cmd`, `git log -- Grep -o`) is still refused | domain test | @@ -454,7 +463,8 @@ until its recipes are re-reviewed. argument is read as `` (fail-closed), so `git add bisect--helper` (a file of that name) is refused. A subcommand-scoped flag (`clone -u`, `rebase --add-exec-commands`, `send-email --to-cmd`, `grep -O`) counts - after any operand that names the subcommand, in any letter case, and Git + after any operand that names the subcommand, in any letter case or + Unicode spelling that folds to it (`git ſend-email --to-cmd`), and Git abbreviates, so an earlier word `clone` or `rebase` scopes it even when that word is another option's value: `git commit -m rebase --add` and `git log -S clone -u` (`-u` is `--patch`) are refused, and so is @@ -467,7 +477,12 @@ until its recipes are re-reviewed. `--smtp-server` is refused whatever its value, so a host name too. `grep -O` is case-folded like every flag, so `git grep -o` is refused; use `--only-matching`. A repository alias still hides a subcommand from - its scoped flags (`git se --cc-cmd cmd` with `alias.se = send-email`). + its scoped flags (`git se --cc-cmd cmd` with `alias.se = send-email`), + and so does Git's `help.autocorrect` setting: with it set to run the + correction (`immediate`, a delay, or `prompt` answered yes), a mistyped + subcommand such as `git send-emai --to-cmd=cmd` runs `send-email` with + the flag, but the catalog sees an unknown subcommand and does not scope + the flag to it. The check does not read Git config. Git config writes other than `!` aliases (for example `git config core.pager cmd`, `git config sendemail.ccCmd cmd` or `include.path`) are not detected. Nor are Git options that run a diff --git a/src/lib/task-catalog-recipe.ts b/src/lib/task-catalog-recipe.ts index 12d4884bc..279ba1056 100644 --- a/src/lib/task-catalog-recipe.ts +++ b/src/lib/task-catalog-recipe.ts @@ -268,7 +268,8 @@ type Evaluator = { readonly stdin: readonly string[]; /** * Words that make the program run a string as a command wherever they - * appear after it (`git submodule foreach 'cmd'`). Matched lowercased. + * appear after it (`git submodule foreach 'cmd'`). Matched folded + * (`foldName`), so `git filter-branch` counts. */ readonly words: readonly string[]; /** @@ -375,10 +376,11 @@ type Evaluator = { * hide it (fail-closed). They do not count before it: Git reads a * subcommand's options only after the subcommand's name and rejects an * unknown option before it, so `git push -u origin clone` (a branch named - * `clone`) passes. Keys are lowercase and operands are lowercased before - * lookup, because Git finds a non-builtin subcommand (`git-send-email`, - * `git-instaweb`) as a file in its exec path, and on a case-insensitive - * filesystem `git Send-Email` runs it. + * `clone`) passes. Keys are lowercase and operands are folded + * (`foldName`) before lookup, because Git finds a non-builtin subcommand + * (`git-send-email`, `git-instaweb`) as a file in its exec path, and on a + * case-insensitive filesystem `git Send-Email` and `git ſend-email` run + * it. */ readonly subcommandInline: Readonly>; /** @@ -913,14 +915,47 @@ const MY_GETOPT_LOOSE = /^loose[-_]/i; /** A `data:` URL argument or option value (`--import=data:…`). */ const DATA_URL = /(?:^|=)data:/i; +/** A character outside ASCII. */ +const NON_ASCII = /[^\u0000-\u007f]/; +/** Code points that render as nothing, so a name can hide one inside. */ +const DEFAULT_IGNORABLE = /\p{Default_Ignorable_Code_Point}/gu; + +/** + * A program, subcommand or module name folded for comparison with the + * lowercase ASCII tables (HACK-1461). The default macOS filesystem (APFS) + * and NTFS look names up case-insensitively with Unicode case folding, not + * JavaScript's `toLowerCase()`: on APFS `git ſend-email` (U+017F LONG S) + * runs `git-send-email` and `git filter-branch` (U+FB01 LIGATURE FI) runs + * `git-filter-branch`. Rather than reproduce one filesystem's table, this + * over-folds (fail-closed): NFKC (`fi` is `fi`, `ſ` is `s`, the Kelvin + * sign is `K`), default-ignorable code points removed + * (`send​-email`), then upper case before lower case, which also folds + * `ß` to `ss` and dotless `ı` to `i`. Folding can only turn more names into + * listed ones, so it only refuses more. A non-ASCII spelling of a listed + * program or of a Git subcommand is also refused outright (see + * `evaluatesCode`), for any folding this does not predict. Option names + * are not folded this way: Git's parse-options and Getopt::Long compare + * them bytewise apart from ASCII letter case. + */ +function foldName(name: string): string { + return name + .normalize("NFKC") + .replace(DEFAULT_IGNORABLE, "") + .toUpperCase() + .toLowerCase(); +} + +/** The last path segment of an argument (a program's file name). */ +function baseName(arg: string): string { + return arg.split("/").at(-1) ?? arg; +} + /** - * Program basename, lowercased, without `.exe`, a build-variant suffix or a - * version suffix. + * Program basename, folded (`foldName`), without `.exe`, a build-variant + * suffix or a version suffix. */ function programName(arg: string): string { - const base = arg.split("/").at(-1) ?? arg; - const name = base - .toLowerCase() + const name = foldName(baseName(arg)) .replace(EXE_SUFFIX, "") .replace(VARIANT_SUFFIX, ""); return name.replace(VERSION_SUFFIX, "") || name; @@ -942,14 +977,14 @@ const GIT_HELPER = /^([a-z0-9][a-z0-9-]*?)--(?:helper|interactive)$/i; * and `rebase--interactive --add-exec-commands` the `rebase` rule. Any other argument is * unchanged. A matching argument starts with a letter or digit and holds no * `=` or `!`, so no inline flag, word or `!` value is lost by the rewrite. - * The porcelain is lowercased, like a program name, so a mixed-case helper - * (`git Rebase--Helper`) still meets the subcommand-scoped flags, which are - * keyed by lowercase name. Git itself matches case-sensitively, so this - * only refuses more (fail-closed). + * The porcelain is folded like a program name (`foldName`), so a + * mixed-case helper (`git Rebase--Helper`) still meets the + * subcommand-scoped flags, which are keyed by lowercase name. Git itself + * matches case-sensitively, so this only refuses more (fail-closed). */ function gitSubcommand(arg: string): string { const helper = GIT_HELPER.exec(arg); - return helper === null ? arg : (helper[1] ?? arg).toLowerCase(); + return helper === null ? arg : foldName(helper[1] ?? arg); } /** @@ -1191,9 +1226,9 @@ function pythonClusterEnd( return "flags"; } -/** True when a module's first dotted segment, lowercased, is in `list`. */ +/** True when a module's first dotted segment, folded, is in `list`. */ function isListedModule(module: string, list: readonly string[]): boolean { - return list.includes((module.split(".", 1)[0] ?? module).toLowerCase()); + return list.includes(foldName(module.split(".", 1)[0] ?? module)); } /** @@ -1352,9 +1387,9 @@ function getoptLongSelects(opts: { /** * The entries of a subcommand-keyed table in force at argument `at`: one - * for every operand before it that names a subcommand, compared - * lowercased (`git clone -u`, `git Send-Email --to-cmd`, not - * `git push -u origin clone`). + * for every operand before it that names a subcommand, compared folded + * (`git clone -u`, `git Send-Email --to-cmd`, `git ſend-email --to-cmd`, + * not `git push -u origin clone`). */ function scopedEntries(opts: { readonly table: Readonly>; @@ -1365,7 +1400,7 @@ function scopedEntries(opts: { if (index >= opts.at) { return []; } - const key = word.toLowerCase(); + const key = foldName(word); const entry = Object.hasOwn(opts.table, key) ? opts.table[key] : undefined; return entry === undefined ? [] : [entry]; }); @@ -1377,9 +1412,16 @@ function isShellSyntax(arg: string): boolean { ); } -/** True when argv[index], taken as a program, could evaluate code. */ +/** + * True when argv[index], taken as a program, could evaluate code. A listed + * program spelled with a non-ASCII character (`ſh`, `git-ſend-email`) and + * a Git subcommand with one (`git ſtatus`) are refused whatever follows, + * because a case-insensitive filesystem may run them as an ASCII name + * (HACK-1461), even one `foldName` does not predict. + */ function evaluatesCode(argv: readonly string[], index: number): boolean { - const name = programName(argv[index] ?? ""); + const raw = argv[index] ?? ""; + const name = programName(raw); if (ALWAYS_EVALUATES.has(name)) { return true; } @@ -1388,11 +1430,17 @@ function evaluatesCode(argv: readonly string[], index: number): boolean { if (spec === undefined) { return false; } + if (NON_ASCII.test(baseName(raw))) { + return true; + } if (spec.evaluatesOperands) { return rest.length > 0; } const positional = operands({ rest, spec }); const operand = positional[0]?.word ?? null; + if (key === "git" && operand !== null && NON_ASCII.test(operand)) { + return true; + } const flags = [...spec.inline, ...spec.stdin]; if ( rest.some( @@ -1414,7 +1462,7 @@ function evaluatesCode(argv: readonly string[], index: number): boolean { positional, at, }).some((options) => getoptLongSelects({ arg, options })) || - spec.words.includes(arg.toLowerCase()) || + spec.words.includes(foldName(arg)) || (spec.dataUrlModules && DATA_URL.test(arg)) || (spec.bangCommands && BANG_COMMAND.test(arg)) || (spec.extUrls && EXT_URL.test(arg)) @@ -1448,7 +1496,7 @@ function evaluatesCode(argv: readonly string[], index: number): boolean { !(spec.replModules.length > 0 && selectsModule({ rest, spec })) ); } - return operand === "-" || spec.subcommands.includes(operand.toLowerCase()); + return operand === "-" || spec.subcommands.includes(foldName(operand)); } /** diff --git a/tests/project-task-catalog.test.ts b/tests/project-task-catalog.test.ts index c5de2783f..9a8d0e6d9 100644 --- a/tests/project-task-catalog.test.ts +++ b/tests/project-task-catalog.test.ts @@ -1978,13 +1978,73 @@ describe("recipe grammar", () => { ).toBe(true); // `-O` is case-folded like every flag, so `git grep -o` is refused. expect(isInlineCommandArgv(["git", "grep", "-o", "x"])).toBe(true); - // A subcommand is matched in any letter case and at any operand, so a - // path named `Grep` scopes `-O` to the arguments after it (HACK-1423); - // the same flag before it passes. + // A subcommand name is case-folded and scopes its flags from any + // operand that names it, not only the first, so a path named `Grep` + // scopes `-O` to the arguments after it (HACK-1423); the same flag + // before that operand passes. expect(isInlineCommandArgv(["git", "log", "--", "Grep", "-o"])).toBe(true); expect(isInlineCommandArgv(["git", "log", "-o", "--", "Grep"])).toBe(false); }); + test("HACK-1461: names that a case-insensitive filesystem folds to a listed name are refused", () => { + const refused = (argv: string[]) => ({ + argv, + refused: isInlineCommandArgv(argv), + }); + const cases: string[][] = [ + // U+017F LONG S folds to `s`: APFS runs `git-send-email` for these. + ["git", "ſend-email", "--to-cmd=id", "p"], + ["git-ſend-email", "--to-cmd=id", "p"], + ["git", "inſtaweb", "--httpd=id"], + ["git", "ſvn", "clone", "--authors-prog=id", "u"], + // U+FB01 LIGATURE FI folds to `fi`. + ["git", "filter-branch", "--tree-filter", "id", "HEAD"], + // U+212A KELVIN SIGN folds to `k`. + ["Ksh", "-c", "id"], + ["awK", "BEGIN{}"], + // U+0131 DOTLESS I is folded to `i` too (fail-closed). + ["git", "ınstaweb", "--httpd=id"], + ["ıex"], + // A listed name after the first operand is folded too: the `words` + // check, the subcommand-scoped flags and Python's REPL modules. + ["git", "log", "filter-branch"], + ["git", "log", "bıſect"], + ["git", "help", "ſend-email", "--to-cmd=id"], + ["git", "log", "filter​-branch"], + ["python3", "-m", "code"], + // A listed program or a Git subcommand spelled with any non-ASCII + // character is refused whatever follows. + ["ſh", "scripts/x.sh"], + ["git-ſtatus"], + ["git", "ſtatus"], + ["git", "--work-tree", "dir", "pack-refſ"], + ]; + expect(cases.map(refused)).toEqual( + cases.map((argv) => ({ argv, refused: true })) + ); + // Their ASCII controls behave as before, and non-ASCII operands and + // programs outside the tables pass. + const accepted: string[][] = [ + ["git", "status"], + ["git-status"], + ["sh", "scripts/x.sh"], + ["git", "commit", "-m", "café ſ"], + ["git", "log", "--author=Zoë"], + ["git", "--work-tree", "répertoire", "status"], + ["cat", "ſend-email"], + ["python3", "-m", "pytest", "tests/ünï"], + ]; + expect(accepted.map(refused)).toEqual( + accepted.map((argv) => ({ argv, refused: false })) + ); + expect( + isInlineCommandArgv(["git", "filter-branch", "--tree-filter", "id"]) + ).toBe(true); + expect(isInlineCommandArgv(["git", "send-email", "--to-cmd=id"])).toBe( + true + ); + }); + test("effects must be covered by required capabilities", () => { const base = recipe({ id: "ok" }); const pinAccepted = (path: string): boolean => From 312de3091c5ea55721dd8cc8423e8fb087101299 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 24 Oct 2026 18:42:50 -0400 Subject: [PATCH 3/3] fix(agents): fold capital sharp s and refuse any non-ASCII program a task runs (HACK-1461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The G109 verifier showed U+1E9E (capital sharp s) still slipped through: APFS folds it to ss, so a program named ẞh runs ssh, but upper-then-lower case folding turned it into ß. Fold lower, upper, then lower so ẞ becomes ss, and refuse any program run as argv[0] whose file name has a non-ASCII character, listed or not, so a folding this check does not predict cannot reach a listed program. Later arguments keep the folded list check. Tests for ẞh (bare, absolute, as an operand) and unlisted non-ASCII programs; both changes proven by mutation. Refs HACK-1461 Co-Authored-By: Claude Opus 5.5 --- docs/reference/ax-task-catalog.md | 13 +++++++++---- src/lib/task-catalog-recipe.ts | 25 ++++++++++++++++--------- tests/project-task-catalog.test.ts | 12 ++++++++++-- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/docs/reference/ax-task-catalog.md b/docs/reference/ax-task-catalog.md index 72ea4d914..cfdf156fb 100644 --- a/docs/reference/ax-task-catalog.md +++ b/docs/reference/ax-task-catalog.md @@ -96,9 +96,14 @@ is not implemented here. `git filter-branch` (the `fi` ligature) runs `git-filter-branch`. So program, subcommand and module names are compared after NFKC normalization, removal of invisible (default-ignorable) characters and - full case folding (the Kelvin sign is `k`, dotless `ı` is `i`), and a - listed program or a Git subcommand spelled with any non-ASCII character - (`ſh`, `git-ſend-email`, `git ſtatus`) is refused whatever follows. + full case folding (the Kelvin sign is `k`, dotless `ı` is `i`, capital + sharp `ẞ` is `ss`, so `ẞh` is `ssh`). The program a recipe runs + (argv[0]) is refused when its file name has any non-ASCII character, + listed or not (`ſh`, `ẞh`, `./ünïcode.sh`): a folding table this check + does not predict could still map it to a listed program, so reviewed + scripts must use ASCII file names. A listed program or a Git subcommand + spelled with a non-ASCII character later in argv (`git-ſend-email` after + a wrapper, `git ſtatus`) is refused whatever follows. A dashed Git builtin, by basename or absolute path (`git-clone`, `/usr/lib/git-core/git-config`), is read as `git `, so @@ -387,7 +392,7 @@ until its recipes are re-reviewed. | Dashed Git builtins (`/usr/lib/git-core/git-clone -u cmd`, `git-config alias.x '!cmd'`, `git-ls-remote --upload-pack=cmd`, `git-fetch-pack --exec=cmd`) | `inline_command`; `git-clone url dir`, `git-push -u origin main`, `git-config alias.st status` still pass | domain test | | Git plumbing helpers read as their porcelain (`git-bisect--helper --bisect-run cmd`, `/usr/lib/git-core/git-bisect--helper.exe run cmd`, `git bisect--helper --bisect-run cmd`, `git-submodule--helper foreach cmd`, `git-config alias.x bisect--helper`, older Git's `git rebase--helper --add-exec-commands cmd` in any letter case) | `inline_command`; `git-submodule--helper status`, `git-difftool--helper a b`, `git add bisect--helper.txt`, `git config --add alias.st status` still pass | domain test | | `git send-email --to-cmd`/`--cc-cmd`/`--header-cmd`/`--sendmail-cmd`/`--smtp-server` in every Getopt::Long spelling (`--opt cmd`, `--opt=cmd`, `--cc-cm`, `--CC-CMD`, `-to-cmd`, `+to-cmd`), dashed or mixed-case (`git-send-email`, `git Send-Email`), and `-c sendemail.ccCmd=…`/`toCmd`/`headerCmd`/`sendmailCmd` in any key case | `inline_command`; `git send-email --to=… --cc …`, `--suppress-cc=all`, `--no-header-cmd`, `--smtp-server-port=587`, `git rev-list --header`, `git status -s`, `git format-patch --to=…` still pass | domain test | -| Names a case-insensitive filesystem folds to a listed one (`git ſend-email --to-cmd=cmd`, `git-ſend-email`, `git inſtaweb --httpd=cmd`, `git ſvn clone --authors-prog=cmd`, `git filter-branch`, `git log bıſect`, `git log filter-branch`, the Kelvin sign in `ksh -c`, dotless `ı` in `iex`), and any listed program or Git subcommand with a non-ASCII character (`ſh script.sh`, `git-ſtatus`, `git ſtatus`) | `inline_command`; `git status`, `sh scripts/x.sh`, `git commit -m 'café'`, `git log --author=Zoë`, `cat ſend-email` still pass | domain test | +| Names a case-insensitive filesystem folds to a listed one (`git ſend-email --to-cmd=cmd`, `git-ſend-email`, `git inſtaweb --httpd=cmd`, `git ſvn clone --authors-prog=cmd`, `git filter-branch`, `git log bıſect`, `git log filter-branch`, the Kelvin sign in `ksh -c`, dotless `ı` in `iex`), capital sharp `ẞ` in `ẞh` (runs `ssh`), any program run as argv[0] with a non-ASCII file name, listed or not (`ſh script.sh`, `./ünïcode.sh`), and any listed program or Git subcommand with a non-ASCII character (`git-ſtatus`, `git ſtatus`) | `inline_command`; `git status`, `sh scripts/x.sh`, `git commit -m 'café'`, `git log --author=Zoë`, `cat ſend-email` still pass | domain test | | Other Git porcelain command runners (`git grep -Ocmd`/`--open-files-in-pager=cmd`, `git daemon --access-hook=cmd`, `git instaweb -d`/`--httpd`, `git svn --authors-prog`, `git remote-ext`, `ext::` URLs, `git archimport`) | `inline_command`; `git grep -n`, `git branch -d`, `git svn fetch --authors-file=…`, `git daemon --export-all`, an `https://` remote still pass | domain test | | Git 2.20-2.33's `rebase--interactive --cmd cmd` (`git rebase--interactive --cmd=cmd`, `git-rebase--interactive --cmd cmd`, `--cm cmd`, a repository alias such as `git ri --cmd cmd`) and its `--add-exec-commands` mode | `inline_command`; `git rebase--interactive --continue`, `git-rebase--interactive --edit-todo` still pass | domain test | | A subcommand-scoped Git flag before the subcommand's name (`git push -u origin clone`, `git remote set-branches --add origin rebase`, `git config --add alias.r rebase`, `git rev-list --header send-email`, `git push -d origin instaweb`, `git log -o -- Grep`) | accepted; the flag after the name (`git clone -u cmd url`, `git --unknown v clone -u cmd url`, `git rebase--helper --add cmd`, `git log -- Grep -o`) is still refused | domain test | diff --git a/src/lib/task-catalog-recipe.ts b/src/lib/task-catalog-recipe.ts index 279ba1056..1a2558aa4 100644 --- a/src/lib/task-catalog-recipe.ts +++ b/src/lib/task-catalog-recipe.ts @@ -929,8 +929,8 @@ const DEFAULT_IGNORABLE = /\p{Default_Ignorable_Code_Point}/gu; * `git-filter-branch`. Rather than reproduce one filesystem's table, this * over-folds (fail-closed): NFKC (`fi` is `fi`, `ſ` is `s`, the Kelvin * sign is `K`), default-ignorable code points removed - * (`send​-email`), then upper case before lower case, which also folds - * `ß` to `ss` and dotless `ı` to `i`. Folding can only turn more names into + * (`send​-email`), then lower, upper and lower case again, which also + * folds `ß` and capital `ẞ` (U+1E9E) to `ss` and dotless `ı` to `i`. Folding can only turn more names into * listed ones, so it only refuses more. A non-ASCII spelling of a listed * program or of a Git subcommand is also refused outright (see * `evaluatesCode`), for any folding this does not predict. Option names @@ -941,6 +941,7 @@ function foldName(name: string): string { return name .normalize("NFKC") .replace(DEFAULT_IGNORABLE, "") + .toLowerCase() .toUpperCase() .toLowerCase(); } @@ -1413,11 +1414,11 @@ function isShellSyntax(arg: string): boolean { } /** - * True when argv[index], taken as a program, could evaluate code. A listed - * program spelled with a non-ASCII character (`ſh`, `git-ſend-email`) and - * a Git subcommand with one (`git ſtatus`) are refused whatever follows, - * because a case-insensitive filesystem may run them as an ASCII name - * (HACK-1461), even one `foldName` does not predict. + * True when argv[index], taken as a program, could evaluate code. Names + * are compared through `foldName`, and a listed program with a non-ASCII + * spelling (`env ſh`), like a Git subcommand with one (`git ſtatus`), is + * refused whatever follows. (`isInlineCommandArgv` also refuses any argv[0] + * with a non-ASCII file name, HACK-1461.) */ function evaluatesCode(argv: readonly string[], index: number): boolean { const raw = argv[index] ?? ""; @@ -1513,11 +1514,17 @@ function evaluatesCode(argv: readonly string[], index: number): boolean { * `eval`, `env`, `xargs`, `su`, `ssh`, awk, sed or another always-refused * program anywhere; or bare shell syntax copied from a log line. Recipes * must run a reviewed script file instead; there is no shell endpoint. - * Programs missing from the tables are not inspected (a denylist). + * Programs missing from the tables are not inspected (a denylist), but a + * program run as argv[0] with a non-ASCII file name is refused, listed or + * not, since a case-insensitive filesystem may run it as a listed one + * (HACK-1461). */ export function isInlineCommandArgv(argv: readonly string[]): boolean { return argv.some( - (arg, index) => isShellSyntax(arg) || evaluatesCode(argv, index) + (arg, index) => + isShellSyntax(arg) || + (index === 0 && NON_ASCII.test(baseName(arg))) || + evaluatesCode(argv, index) ); } diff --git a/tests/project-task-catalog.test.ts b/tests/project-task-catalog.test.ts index 9a8d0e6d9..caf799c50 100644 --- a/tests/project-task-catalog.test.ts +++ b/tests/project-task-catalog.test.ts @@ -2012,9 +2012,17 @@ describe("recipe grammar", () => { ["git", "help", "ſend-email", "--to-cmd=id"], ["git", "log", "filter​-branch"], ["python3", "-m", "code"], - // A listed program or a Git subcommand spelled with any non-ASCII - // character is refused whatever follows. + // U+1E9E CAPITAL SHARP S folds to `ss` on APFS, so `ẞh` runs ssh; + // lower case alone gives `ß`. + ["ẞh", "host", "id"], + ["/usr/bin/ẞh", "-o", "ProxyCommand=id", "x"], + // As a later argument it is caught by folding alone, like `cat ssh`. + ["cat", "ẞh"], + // Any program spelled with a non-ASCII character is refused whatever + // follows, listed or not, and so is a Git subcommand with one. ["ſh", "scripts/x.sh"], + ["./scripts/ünïcode.sh"], + ["café", "--version"], ["git-ſtatus"], ["git", "ſtatus"], ["git", "--work-tree", "dir", "pack-refſ"],