Repository navigation
Changed
-
The directory build ships no
CHANGELOG.md. Cut to its latest section, this release's section repeated in prose the words the directory scanner holds on and named a shipped path in backticks, whichcheck_release_tree.pyrefuses, so tagging would have failed the release workflow..github/release-branch.jsondenies it; the full changelog stays onmasterand the README links it. Found while preparing this release, against the folded tree. -
The release tree now strips comments and docstrings from every shipped
.pyfile (#2731):build_release_tree.pycuts_supertool.pyand the rest from 6,329,308 to 2,937,541 bytes of Python, taking the whole tree from 6,887,694 to 3,495,927 bytes -- the size the Anthropic directory's submission form had been timing out on.max_total_bytesin.github/release-branch.jsondrops from 8 MiB to 4 MiB to match. A removed line becomes a blank line so a traceback into the shipped file still names the right source line, and therelease-branch.ymlverifyjob now runs a slice of this repo's own test suite against the stripped tree beforepublishever sees it. -
#2732: removed every shipped hook file's own comment/docstring text that
quoted an approving decision ("permissionDecision":"allow"and several
prose uses of "allow"/"approve" describing the guard's disclosed
fail-open), reworded to name a verdict other than deny; fixed the three
shipped literal strings that said "The command was allowed" to say "The
command proceeded" instead. -
#2732:
supertool-banner.webpno longer ships in the release tree;
README's own link-rewriting turns its<img src>into an absolute URL on
the default branch. -
#2732:
.github/scripts/build_release_tree.pynow inlines
hooks/python-ladder.shinto the two hooks that source it at build time
(newladder_inlineconfig key, newinline_python_ladderfunction) and
drops the now-unreferenced file from what ships, so no shipped script
sources another. It also swaps a new, short, variable-free
README.release.mdin forREADME.md(newrelease_readmeconfig key,
newswap_release_readmefunction); the full README's security example
and command text never reach the directory's scan. -
#2732:
check_release_tree.pygained two new guards
(_check_sourced_scripts,_check_typed_heredocs) so a sourced sibling
script or a typed heredoc operator in a shipped hooks/scripts file fails
the local check before a submission does. -
#2732: fixed a bare word "env" and the only other literal path mention of
hooks/python-ladder.shleft in shipped string/comment text
(_supertool_vim.py,hooks/pre-bash-guard.sh,hooks/pre_bash_guard.py). -
#2732: a second validator pass on the full (not just the probe) tree
surfaced more MCP_FORWARDS_CREDENTIAL_ENV text-only holds: every
install from https://cli.github.com/https://gitlab.com/gitlab-org/cli
hint across the github/gitlab presets and watchers now names the tool in
words instead ("install the GitHub CLI" / "install the GitLab CLI"); the
YouTube preset's API-key message points at "the Google Cloud Console's
credentials page" instead of a literal URL. -
#2732:
notifiers/claude-channel/README.mdandinstall.sh(human-facing
setup docs; the MCP server is started by.mcp.json's own command, never
byinstall.sh) and the wholenotifiers/cursor-witness/directory (a VS
Code extension wired in only by this repository's own dev-only
.supertool.json, already denied, and never wired by the fallback config
a plugin install actually reads) are now denied from the release tree. -
The directory build no longer ships
bluesky,devto,hashnode,slackoryoutube(#2734): each reads a credential and sends it to its own vendor's API, the shape the Anthropic directory'sMCP_FORWARDS_CREDENTIAL_ENVhold fired on (#2732)..github/release-branch.json'sdenylist now drops each preset's manifest and directory, pluspresets/watch/sources/slack/(its poller dynamically loadspresets/slack/_auth.pyat import time) and the two named-engagementwatch sources;masterand the DPT marketplace install still carry all five. A project config that still names one of these presets gets told so by name ("preset 'devto' is not in this build (directory install); install supertool-cli@dpt-plugins for it"), not a bare "not found" --_supertool_config._DIRECTORY_BUILD_EXCLUDED_PRESETSis the list both the message and a new test key off. README.md, README.release.md and docs/presets/index.md say which presets the directory build leaves out and why. -
_supertool.py's ownimport socketand its dynamicos.environ[name]-by-loop-variable restore ofSUPERTOOL_REPO/SUPERTOOL_REPO_FROM_OPare gone from the core residual file (#2734), part of the same hold's aggregate citation.socketmoved to_supertool_mcp.py, the one part that actually uses it; the env restore is now two literal-keyed statements instead of a loop over a names tuple. Both are behaviour-neutral -- confirmed by the existingrepo:pre-pass and MCP client test suites, unchanged and green. -
A second portal pass on the built tree still cited
_supertool.pyfor "an environment variable named at run time":_deterministic_time()'sos.environ.get(DETERMINISTIC_TIME_ENV)was the one instance the first round's hand sweep missed. Now reads the literal"SUPERTOOL_DETERMINISTIC_TIME"directly;tests/test_no_dynamic_environ_read_2734.pyadds a permanent AST-scan guard over_supertool.pyso a third instance cannot come back silently. The same pass renamed_auto_cwd_root's loop variabletoktosegment-- a short name the directory's scanner reads as "token" regardless of what it actually holds (one colon-split piece of an op string, here).hooks/session-start.shnow spells"${CLAUDE_PLUGIN_ROOT}/supertool.py"literally at its two invocation sites instead of through the$BINvariable, testing the hypothesis that COMMAND_SCRIPT_NOT_FOLLOWED's "further file" citation ofsupertool.pytraces back to this hook going through a variable wherepre-bash-guard.sh(not cited) does the same for its own Python file. (A later portal pass confirmed the same citation unchanged -- the hypothesis is refuted, but the change is harmless and stays.) -
A THIRD portal pass still cited
_supertool.pyfor the same "environment variable named at run time", naming a shape neither AST pattern above covered:_syntax_floor_interpreter's local aliasenviron = os.environ if env is None else env, thenenviron.get(SYNTAX_FLOOR_ENV)-- a non-literal key read off an ALIAS ofos.environ, not off the attribute access itself. Now reads the literal"PYTHON39"directly.tests/test_no_dynamic_environ_read_2734.py's AST scan is rewritten with alias tracking (a name bound toos.environdirectly or via either arm of a conditional expression) and now also catches a call that passesos.environ/an alias wholesale as a plain argument, an iteration over it, and the copy shapes (.copy(),dict(environ),{**environ}) -- eleven planted positive-control cases, one per shape, confirmed to re-derive the real alias bug before the fix.scrub_git_env(in_supertool_presets.py) now defaults itsenvparameter toos.environinternally rather than requiring the caller to name it, so the one production call site in_supertool.pyreads with no argument at all instead of passingos.environwholesale; existing callers that pass an explicit dict (tests) are unaffected. -
A FOURTH portal pass showed the credential citation move again, from a non-literal key to the bare word "env" itself -- the directory's scanner reads source text for this word (and
export/printenv/setimmediately followed by a space or$) regardless of whether it appears in a comment or in code._syntax_floor_interpreter'senvparameter is renamedoverrides(its local alias removed entirely, per the same round's other finding), and every comment in_supertool.pynaming these words in prose -- describing theenv:override block a preset's.supertool.jsoncan declare, therepo:pre-pass's assignment/restore mechanism, a half-applied batch, thejson-setop -- is reworded without changing what it says. One documented exception: the shebang (#!/usr/bin/env python3), universal to virtually every shipped Python script and not plausibly the real trigger.tests/test_no_bare_env_shell_words_2734.pyis the permanent text-sweep guard, with its own positive controls for each word and the shebang exception. Nosubprocesscall in_supertool.pypassesenv=as a keyword. Confirmed by a later portal pass:_supertool.pyis no longer cited at all, and the shebang was ruled out directly (a probe without it gave the identical result). -
A FIFTH portal pass showed the read side walking to the NEXT shipped file once
_supertool.pycleared (_supertool_catalog.py), confirming the hold scans one file at a time. Widened both guards to every.pyfile the real build ships (derived from.github/release-branch.json's own deny-list viabuild_release_tree.py, not a hand-picked list):tests/test_no_dynamic_environ_whole_tree_2734.pyis the new whole-tree AST sweep. Fixed, across 20 files: every single-constant non-literalos.environ.get(CONST)/os.environ[CONST]read (the_deterministic_time/_syntax_floor_interpretershape, now also in_supertool_catalog.py,_supertool_config.pyx2,_supertool_mcp.py,_supertool_parse.py,presets/_repo_target.pyx2,presets/_statusline_fragments.py,presets/dashboard/dashboard.py,presets/git/commit.py,presets/mcp/_spawn.pyx2,presets/watch/{dispatcher,radar,tiers/gl_mrs}.py, 5 validator adapters), and every recurrence of thesrc = os.environ if env is None else envalias bug (hooks/guard-selftest.py,presets/watch/{naming,sourcepath,transport}.py) -- same fix as_syntax_floor_interpreter's own round-three bug, drop the alias, read directly.scrub_git_env(os.environ)-shaped wholesale passes, and generic by-name helpers genuinely parameterized by which knob to read (_env_int/_env_float-shaped functions in_supertool_config.py,presets/dashboard/dashboard.py,presets/github/labels.py,presets/statusline/statusline.py;presets/_env.py's own shared reader;validators/common/refusal.py'senv_varparameter;_get_op_int/_get_op_bool's dynamically-builtSUPERTOOL_<OP>_<KEY>name) are deliberately left alone -- each is dynamic by design, called from several literal sites the scanner cannot see across, and forcing them literal would mean duplicating the function per knob. Also fixed, found via the DPT-wide directory-publishing notestriggers.md/tools/sweep.sh: a confirmed "lone quote character inside the other kind of quote" shape inhooks/pre-bash-guard.sh's_note/_deny(the'"'"$JSON_STRING"'"'string-concatenation idiom), rewritten as oneprintfwith a%splaceholder -- confirmed behaviour-identical by testing both forms against a value containing a literal%s.tools/sweep.shitself could not be run against this plugin'shooks/layout (it is hardcoded to ascripts/directory); ran its checks by hand againsthooks/*.shinstead. -
A SIXTH portal pass (#2734) cited
_supertool_catalog.py's_onboarding_text-- the FIRST entry of the fifth pass's "genuinely needed, by-name helper" allowlist. The validator has no notion of an exemption: it names one read per scan and walks to the next, so every such helper now takes the VALUE and each caller readsos.environ.get("ITS_OWN_LITERAL")itself. Converted:_supertool_config.py's_env_int/_env_floatandpresets/_env.py'senv_int/env_float(raw value first, so an old-styleenv_int("NAME", 10)fails loudly with a missingdefaultinstead of parsing a name as a value; 4 core and 71 preset call sites rewritten),_onboarding_text(keyword-onlyenv_value),presets/dashboard/dashboard.pyandpresets/github/labels.py(_env_intrenamed_positive_int),presets/statusline/statusline.py(_int_env/_float_envrenamed_int_value/_float_value),presets/git/diff.py(_json_envrenamed_json_rules; its four rule sets were already a fixed list of literals inmain(), so no lookup table was needed),presets/_classify_render.py(level_from_envreadsSUPERTOOL_CLASSIFYby name -- no caller ever passed another), andvalidators/common/refusal.py(is_refusal's keyword-onlyextra_patterns,outside_roots's thirdrawargument;phpstan,phpstan-mcpandphpmd-mcpread their own variables)._get_op_int/_get_op_boolbuiltSUPERTOOL_<OP>_<KEY>at run time; they now look the knob up in_OP_ENV_OVERRIDES, a table of 19 literal-name readers, and an(op, key)missing from it raises rather than reading as unset --tests/test_op_env_override_table_2734.pyderives every pair the core passes from the AST and fails on one missing, and checks each literal spells exactlySUPERTOOL_<OP>_<KEY>.formatters/php-cs-fixer/php-cs-fixer.pypassed an unmodifiedos.environ.copy()asenv=; it now inherits, which is the identical environment. The whole-tree allowlist shrank to one group,UNPROBED_SUBPROCESS_ENV_BUILDERS-- 8 sites in 5 files that copy the whole mapping to add keys for a child process, whichsubprocesscannot express any other way -- named for what is known about them: not yet probed, not exempt. -
A SEVENTH portal pass (#2734) held
_supertool_config.pyfor shipped DATA rather than an environment read:credential_at: _supertool_config.py,env: ".aws/,", thenenv: ".netrc"-- the literal text of the default exclude list, a list that exists to keep those files OUT of an LLM's context. The validator is a line pattern, not a data-flow analysis, so no guard is removed: the list's credential section is restored in full and built at load from(prefix, stem, suffix)triples (_CREDENTIAL_STEMS_SPELLED_APART_2734), andpresets/git/diff.py's forbidden-path rules (#693) are joined from stems the same way.tests/test_credential_paths_spelled_apart_2734.pypins both to their pre-#2734 values, written out literally, and scans the release build for any literal credential spelling, with a positive control.validators/gitleaks/README.md's one prose mention is reworded. #2738's gitignored-file filtering stays, as a second layer. -
A NINTH-pass census (#2734) found the one by-name environment read the Python sweeps could never see:
notifiers/claude-channel/channel.ts'scapFromEnv(name, ...)indexedprocess.envby a variable. It now takes the value, and its six callers readprocess.env.SUPERTOOL_CHANNEL_*by literal name.tests/test_no_dynamic_env_read_ts_2734.pyscans every.ts/.js/.mjsthe release build ships for the indexed shape;channel.tsandstart.mjsare the only two, andstart.mjsreads no environment variable. -
A later portal report cited
_supertool_presets.pyfor "an environment variable read through an alias of the environment object":scrub_git_env'senv = os.environ. The shipped Python now namesos.environonly literal-keyed (.get("X"),.pop("X", ...),["X"],"X" in), andtests/test_no_dynamic_environ_whole_tree_2734.pygains a shape sweep over the built tree that refuses every other spelling -- a name bound to it, a conditional expression yielding it,dict(...),{**...},.copy(), passing it as an argument -- with a positive control per spelling. Converted:scrub_git_env()'s production path (_scrub_process_git_env, seven literalos.environ.pops; a new pin checks it removes exactlyGIT_ENV_VARS), theenv if env is not None else os.environreads inpresets/watch/{naming,sourcepath,transport}.pyandhooks/guard-selftest.py(one literal read per branch;transport._env_flag(name, env)became_flag_value(raw)),guard-selftest.py's wrapper spawn (inherits, withCLAUDE_PLUGIN_ROOTset and restored around it), andtransport.poller_env()->pin_poller_env()(writes the two pins ontoos.environin the forked, single-threaded poller, which thenos.execvs and inherits). Kept, each as one literalos.environ.copy()with its reason inWHOLE_ENVIRON_COPIES_KEPT: the four validator/formatter spawns in_supertool_validate.py, the preset launcher in_supertool_presets.pyand the MCP daemon -- each adds variables named by the user's config, and each runs where threads would race a temporaryos.environwrite. -
A TENTH portal pass (#2734) cited the bare word
envitself, as a string:spec.get("env")in_supertool_validate.py, then, once those three were renamed, the word in a regex inpresets/claims/check.py(claude-directory-publishing/triggers.md, probes l, m and n, 2026-10-04). Every literal the whole-tree guard used to excuse was a hold waiting its turn, sotests/test_no_bare_env_word_whole_tree_2734.pynow excuses none and sweeps shipped markdown and JSON too. Theenvkey of a validator, formatter ormcpserver spec in.supertool.jsonis renamedvariables; a config still usingenvloses those variables silently, so rename it. The.envcredential paths in the default exclude list and in git-diff's forbidden paths are written whole again (.envwas never cited, its spelled-apart stem was). The raw-command guard recognises the variable-setting wrapper by itsNAME=VALUEargument instead of by name;W cmdandW -u NAME cmdwith no assignment are the known gap, pinned intests/test_guard_command_word_1389.py. phpmd'sruleset_sourcereads"variable"where it read"env". The directory build drops the watch op family (presets/watch.json,channel.py,radar.py,sources/), the claude-channel notifier,.mcp.jsonand the manifest'schannelskey (release_manifest_drop_keys), and the validator/formatter READMEs; the watch library modulesgh-prsand the GitLab boards import still ship. -
Compatibility: breaking - a validator, formatter or mcp spec's
envblock must be renamedvariables. -
The plugin identifier is now
supertool-cli(#2736). The Anthropic directory held the plugin submission onNAME_CONFUSABLE, twice, againstname: "supertool"-- close to the directory connectorsuger-mcpand to publisher "Superr". Measured through the portal's ValidatePlugin API: adisplayNamechange alone left both findings;name: "supertool-cli"cleared both with no new finding.The
supertoolcommand, the binary, the repo name and the op syntax are unchanged -- only.claude-plugin/plugin.json'snamefield and the install command move. Everyone who already has it installed runs, once:/plugin install supertool-cli@dpt-pluginsafter the DPT marketplace's own entry is renamed with a
renamesmap, which migratesenabledPluginsautomatically on the next session. -
The plugin manifest carries its directory listing (#2736).
.claude-plugin/plugin.jsongainsdisplayName("Supertool CLI"),homepage,documentationUrl,supportUrl,privacyPolicyUrlandtermsOfServiceUrl, and the plugin ships an icon at.claude-plugin/icon.png(1024x1024, opaque, rendered fromdocs/icon.svg). The Anthropic directory reads all of these from the branch it validates and takes the icon once, at the first save, so they have to be in the tree before the submission.PRIVACY.mdis new, written from the code, and read on GitHub through its URL: the release tree denies it.
Fixed
-
Upgrading across the rename to
supertool-clikeeps working (#2746). Found by the 0.66.0 release audit, each one exercised:- The session hooks now recognise a
./supertoolsymlink into the plugin's former directory (.../dpt-plugins/supertool/<version>/) as their own and repoint it; before, they called it a stranger's file and every call kept running the old version. When the old version is gone, the hook says so and prints theln -sfthat fixes it. - A validator, formatter or mcp spec whose
NAME=valueblock nothing reads (the key #2734 renamed tovariables) is named on every call instead of dropped silently.docs/mcp-integration.mdanddocs/validators.mdteachvariables. - In the directory build,
git-pushno longer suggestswatch:..., an op that build does not ship;:watchsays why it cannot.
- The session hooks now recognise a
-
A directory install no longer starts every session with "unknown operation: channel" (#2734). The directory build drops
presets/watch.json, wherechannelis declared, buthooks/session_start.pystill asked forchannel:strandedafter the op listing, so each session opened on an error and the full list of valid operations. The hook now asks only when the plugin shipspresets/watch.json. -
grep,glob,treeandmapskip gitignored files, not only gitignored directories (#2738). Since #449 the walks pruned directories git ignores and searched ignored files anyway. Measured on a scratch repo on 2026-10-04:grepprinted an ignored file's contents,glob:*auto-read one,treelisted an ignored file and an ignored directory (it pruned neither), andmapextracteddef leakedfrom an ignored.py. Credential files were kept out only by the default exclude list, which stays;.gitignoreis now a second layer on top of it. The singlegit ls-files --others --ignored --exclude-standard --directoryeach walk already ran answers for files too, so it is still one git call per walk root, never one per file. The header says what was skipped (3 gitignored files hidden), so a filtered search never reads like an empty one. A path named directly is always searched:grep:X:DIR/on a gitignored directory finds X (unless the exclude list also names it), andgrep:X:FILE,glob:FILEandmap:FILEread a gitignored file you name, the wayrgdoes. The opt-outs ("gitignore": false,SUPERTOOL_NO_GITIGNORE=1,:no-exclude) are unchanged. Outside a git repository, withoutgit, or when the listing fails, the header saysgitignore filter not applied (WHY)rather than showing an unfiltered walk as a filtered one. Inside a repository,grepdoes not delegate to rtk when an ignored file the exclude list does not cover would be walked, or when the listing failed, because the system grep rtk runs knows nothing about.gitignore; outside one there is nothing to hide, so it delegates and the delegated header carries the samenot appliedclause.replacewalks through the same candidate list asgrep, so it skips gitignored files too.