The admission gate ran the install scanner (dangerous fails) but nothing refused
a compiled binary, committed bytecode, or an archive hiding either — the shapes
the scanner cannot read. This adds that enforcement to pinned-source-validate.
- scripts/scan_plugin_payloads.py (stdlib-only, so the no-install validator and
third-party CI can run it): flags compiled binaries by magic header (ELF,
Mach-O, PE/MZ, WebAssembly — not by extension, which a rename defeats),
.pyc/.pyo bytecode (incl. sourceless/hash-forged caches that run instead of
the .py beside them), and archives whose members are not reviewable source.
Plain-text source bundles and inert assets (fonts, images) pass.
- plugin-catalog-ci.yml: after `hermes plugins validate`, each changed entry is
scanned; a finding fails it. An entry may list `attested_binaries:` paths,
each verified with `gh attestation verify --repo <repo> --source-digest <sha>`
against the pinned commit; verified paths are dropped before the scan, any
other binary still fails. Repo-controlled filenames are newline-stripped in
the log so none can forge a workflow command.
- validate_plugin_catalog.py: `attested_binaries` is a known key, shape-checked
as a list of relative paths.
- Tests: unit tests for the detector and two new workflow-driven bypass cases
(bundled .exe, committed .pyc) in the confinement harness, which runs the real
`run:` block.
Full-content re-sweep of all 543 pins: only tabro carries a native binary (it is
delisted in the companion PR). Three entries (crawl4ai, hermes-vision-inline,
mermail) commit __pycache__/*.pyc build litter and would fail rule 17 on their
next pin bump — pre-existing, not touched here.
The prompt-surface-review ruleset was meant to require one other reviewer;
it is now set to one hermes-agent-core approval. CODEOWNERS, the CI comment,
the staleness-test message and the developer docs said two.
A one-line code change can rewrite what every session sends to the model -- a
guidance constant moving tiers, a toolset gaining a tool, a dynamic rewriter
editing a description -- without the diff showing it. The rendered prompt
surface is now committed and reviewed like code.
- scripts/ci/prompt_surface.py renders the system prompt
(build_system_prompt_parts) and the agent's tools[] through the production
code for nine surfaces (cli x claude/gpt/gemini/hermes, cli in a code
workspace, desktop, telegram, discord, cron) into
tests/fixtures/prompt_surface/. A fresh home in a child process; clock,
host OS, network, tool availability, env probe and machine paths pinned,
so a laptop and CI produce the same bytes. Each tool schema is stored once,
with a per-surface copy only where a surface serves a different schema.
- tests/ci/test_prompt_surface.py fails when the committed snapshot is stale,
so a surface change must carry the regenerated snapshot in the same PR.
- prompt-surface-diff.yml (advisory, PR-only, stdlib-only, no PR code runs)
posts per-surface token deltas, prompt hunks and per-tool
description/param changes into the unified review comment.
- CODEOWNERS requests hermes-agent-core on the snapshot; a new
prompt-surface-review ruleset requires two of their approvals.
- Docs: prompt-assembly.md (how it works), contributing.md (what to do when
the test fails).
publish runs the same install-stamp and boundary scripts on the same
runner matrix, including arm64 whose system python3 is 3.10 and lacks
datetime.UTC. Without this, PR checks pass and main-push publish breaks.
A release that skipped bundles never writes the stable baseline pointer,
so its absence is normal at any point, not only before the first bundle
release. Drop the final-tag scan. A 404 on the default pointer now always
plans no upgrade arm. A supplied baseline-manifest that does not exist
still blocks, because that is a bad input.
No stable release has shipped signed packages yet. Every final tag so far
skipped bundles or predates the claim pipeline. So
releases/stable/release-candidates.json does not exist, and the three
"Pin actual OLD and NEW signed packages" jobs refused with "No published
stable package baseline".
transitions now treats a 404 as a first release only when no final stable
tag records a candidate manifest and no baseline-manifest input was given.
It still validates the receipt, emits empty matrices and baseline=none.
The packaged upgrade arms skip on that output. The gate accepts their skip
only when their own planner succeeded with baseline=none, so a missing
baseline after a real bundle release still blocks.
The native smoke only ran inside desktop-bundled-release, and it checked out
its test scripts at the same commit as the artifact it fetched. Changing a
test therefore meant a new commit, and a new commit meant a full signed
build (about an hour per platform) just to rerun the smoke.
Add workflow_dispatch: `sha` picks the staged artifact to fetch, and the
scripts come from the ref the dispatch runs on. Release callers are
unchanged.
Pull requests and pushes to main no longer run any E2E suite: ci.yaml's detect gate
forces every e2e* lane off unless the run is a stable-release run (inputs.release) or a
manual dispatch, whatever the diff, the update-path classifier or the run-e2e label
selects. That covers the Python e2e and e2e-upgrade jobs, the Windows E2E and Windows
install + update journey in tests-os, and the Desktop core/update suites.
install-e2e.yml drops its pull_request (path-filtered `pr` route) and 12-hourly
schedule triggers; it runs from the stable-release gate (workflow_call) or by dispatch.
live-providers drops its nightly schedule and keeps the release-tag push + dispatch.
Why: a one-row change to left_core_migration.py held a PR for 40 minutes on the
Windows install + update E2E; the suites cost most of the larger-runner minutes and
the release gate already runs all of them strictly.
The ordered publication pass moves nousresearch/hermes-agent:stable/latest
only after it reads the R2 stable channel head. When that read fails
(v0.21.6: "Channel repository authority mismatch"), the release publishes
and the aliases never move.
`python -m scripts.releases.docker promote --version X.Y.Z` reads the
published vX.Y.Z receipt tag and runs the existing promote_stable with the
digest it binds (slim checked against the receipt, desktop from its own
attempt tag). docker-promote-stable.yml dispatches it with the Docker Hub
credentials only CI holds.
Two stable-release failures on rc.3-v0.21.6 (run 37753841932):
- nix: the stamped release source is a `path:` flake with no git metadata,
so self.rev is null, install-stamp.json carries no commit and the packaged
`hermes --version` reports "unknown". Pass the commit as `?rev=$GITHUB_SHA`
(verified on Nix 2.35: path:<tree>?rev=<sha> sets self.rev).
- docker: the versioned images were pushed under the final vX.Y.Z, which does
not exist until publish. rc.1 created `:v0.21.6`, and every later attempt hit
"versioned Docker manifest differs" against it. scripts/releases/docker.py
(published_digest, promote_stable) already reads the attempt-ref tag, so pass
claim-tag to both docker.yml calls.
Two stable-release identity checks broke on the first pipeline cuts of v0.21.6:
- nix.yml "Verify release package runtime identity" took the LAST token of the
whole `hermes --version` output. That output ends with the OpenAI SDK line, so
the check compared 0.21.6 against 2.24.0 and failed every release. Parse the
first line ("Hermes Agent v<version> ...") instead.
- msix-shared.mjs releaseEpoch() still required the retired `vX.Y.Z-rc` claim
name. Attempt refs are `rc.<N>-vX.Y.Z` (versioning.parse_attempt_ref), so the
Windows universal bundle threw "Stable claim tag differs from its payload tag".
Accept exactly `rc.<N>-<tag>`; a different version (rc.3-v0.27.10 for v0.27.1)
still refuses.
The release workflow built release-source#hermes-agent, but the flake
only exposes packages.<system>.default (plus minimal/messaging/etc.),
so the step failed with 'does not provide attribute'.
The PM long-path steps do not use anything the earlier live lanes set up.
Gating them on those lanes hid the proof whenever an unrelated lane went
red. The test step still requires the long-paths-off step to succeed, so
it never runs with the runner image's setting.
Drives a real Store through scratch, the bundled-copy copytree, tree_digest,
publish and removal over a tree with files and a directory beyond MAX_PATH,
then checks the facts record and composed child env carry no \\?\ prefix.
The test first asserts RtlAreLongPathsEnabled() is false so it cannot pass
as a false proof. The wine2e lane sets LongPathsEnabled=0 in a separate,
final step and selects the test with -m 'platforms and integration'.
`hermes_cli/_early_recovery_zip.py` (split out of `_early_recovery.py` on the
commit-point PR) is imported by the update path, so a change to it must start
the update pipeline and install E2E suites. Both lists and the install-e2e path
filter now match the `_early_recovery` prefix, which also routes any later
sibling. `remote-lifecycle-v2-marker.test.ts` reads the update-marker corpus
and is now a listed consumer; `remote-update-marker-programs.test.ts` exists in
this tree, so its sibling-PR allowance is gone.
run_tests.sh runs one pytest per file and reports a file whose tests were
all deselected (pytest exit 5) or all skipped as passed. 8ce4ce35fc2 made
the -m selection and platforms('windows') marker a tests/ci check, but any
other zero-execution path (the HERMES_E2E_WINDOWS_INSTALL opt-in lost from
the step env, a new skip condition) still ran zero crash cells green.
The crash journey now writes crash-cells-ran.txt (each journey step and
ok/failed) into HERMES_E2E_ARTIFACTS once it has run, and a new required
step "Every crash cell ran" after the run step throws when the manifest
is missing. The manifest is uploaded with the other artifacts.
Test (tests/ci, every PR): the step exists after the run step, is neither
disabled nor advisory (workflow_steps.required), reads the same
HERMES_E2E_ARTIFACTS and names the module's manifest constant; and
_record_cells_ran writes exactly the steps that ran, nothing without the
env. Red on the start workflow (no step) and with the step made
continue-on-error.
Review 5411223855 (F52: collected > 0 / executed-cells assertion).
test_crash_cells.py runs one module-scoped journey (install, the shared
gateway phase, six crash cells) inside a single file. It took 1,750-2,090 s
on normal runners (87% of the 2,400 s per-file budget) and was killed at
2,400 s on a runner that was uniformly 1.3-1.8x slower (run 37243653456:
32 passed, 0 failed, crash cells SIGKILL'd mid-journey). Raise the per-file
budget to 3,600 s and the job to 75 min so runner variance is not a verdict.
Route the exercised subprocess and provider-migration dependencies to both
real-update journeys. Keep backup routing under an explicit dispatch test.
Use the aggregate's existing detect outputs to reject skipped or missing
update consumers while retaining legitimate unselected PR skips.
Verified with the real classifier and aggregate subprocesses against
b9dec76a042, composite ff5c8f1a64b, and fixed code. Canonical runner:
571 passed, 0 failed, 1 native-Windows skip across CI and marker corpora.
(cherry picked from commit a082ca3c62ed49382f173a07882d4280d0ba7c9c)
(cherry picked from commit ed9f2028256b8b1d6af81ef97cd472f30d2cb042)
(cherry picked from commit 6d5cc740b6a75d476802222c2f4754855a5712b0)
known_failure XFAILs a gap while its fix is an open PR, which is right
on a PR and wrong on the acceptance run of the integrated batch: there
it accepts exactly the gap the batch claims to close (the final
composite's Windows run passed with `test_crash_cells.py (3✓ 1xf)`).
HERMES_E2E_STRICT_ACCEPTANCE=<owner>[,<owner>] (or 1 = every gap) makes
the matching known_failure / known_gate blocks re-raise the gap's own
assertion. It is set only from the `strict_acceptance` dispatch input of
ci.yaml (threaded through tests.yml / tests-os.yml to every E2E step)
and of windows-install-update-e2e.yml; pull requests never set it.
run_tests.sh forwards it through its env -i allowlist.
Red on 8cc78279a36: test_pending_fixes_strict.py (a real pytest process
per mode) — the batch-owned gap XFAILs under strict mode.
(cherry picked from commit ca1e7e749a61263181e89aa687dec366d95fda18)
(cherry picked from commit 7768bf3ef8e15fb7f228ebad619d6a9a85ad4100)
The classifier missed modules the update entry points import
(_subprocess_compat, desktop_build_lock, memory_provider_migration, the
scripts/build compilers) and never sent source_build/source_completion to
the Desktop update suite. ci.yaml gated tests-os on `python` alone, so a
TypeScript-only change to the Electron marker/result readers set
desktop_updater and still never reached the Windows hand-off tests.
- classify_changes.py: _UPDATE_ENTRY_POINTS / _DESKTOP_BUILD_ENTRY_POINTS,
an owner list of what they import, and an explicit _SHARED_HUBS set.
- tests/ci/test_update_ci_routing.py reads the entry points' real imports
(AST incl. lazy imports; ESM import/step() closure of the build scripts)
and fails until each is routed or is a real hub (>= 25 importers).
- It also replays the REAL classifier's output through the composite
action, ci.yaml detect outputs, job if:/with:, the called workflows' job
if: and the rendered Windows step, for every combination of the gated
lanes (tests/ci/_gha_expr.py models the expression subset and raises on
anything else).
- ci.yaml: tests runs for python|e2e|e2e_upgrade; tests-os for
python|desktop_updater|e2e|e2e_upgrade.
Red on 8cc78279a36: 30 of the new routing tests fail (R15 table cells,
16 lane combinations, ownership/closure tests).
(cherry picked from commit 3b17b5d2ad8081534bbeefcf5d8334cd79a7e6fd)
(cherry picked from commit 594f8b0c4ba32324da922d5289b0932299ffe7a9)
e2e-desktop-update ran with continue-on-error, so a PR touching the
Desktop update path could merge on a red "Update now" hand-off. It has
passed every main-push shard on record (48/48 over the last 12 main CI
runs), so it now gates the merge whenever the e2e_desktop_update lane
fires, like the Linux e2e-upgrade shards and the Windows install +
update journey already do for e2e_upgrade (skipped = success otherwise).
windows-venv-e2e.yml listed
tests/hermes_cli/test_runtime_repair.py::TestWindowsRuntimeSelfLock, a
file that never existed on main; run_tests.sh dropped it silently. The
in-use-runtime replacement it meant to prove now lives in pm and is
tests/pm/test_held_entry_windows_live.py.
Tests pin both: every real-update suite reaches all-checks-pass, is
gated on its lane and is never continue-on-error; every path the
wine2e lane runs is an existing test file.
(cherry picked from commit f4791428341afaf67cc53775653dde197591fa4f)
(cherry picked from commit 5d3a4b505fe5c3858a6bb7f3554c97eb272231ca)
The protect-main ruleset requires a check by NAME, and a green check run on a
PR head never expires. After the code-health ratchet joined the required set,
32 PRs whose last CI run predated it merged on their old "All required checks
pass" green without the ratchet ever running; 20 of them would have been
blocked (62 findings, incl. 15 new blind `except Exception`).
Renaming the gate to "All required checks pass (v2)" and pointing the ruleset
at the new name voids every older green: each open PR needs one fresh CI run,
which now includes the ratchet, before it can merge, whoever merges it.
The ci.yaml comment documents the bump as the standard step whenever the
required set gains a job every PR must pass.
live_comment.py keeps both names in its infra list (old runs still render),
and a test pins that the gate's current display name never shows as a job.
pm-bundle, install-e2e and termux-verify trigger on pm/**, so adding pm/AGENTS.md
(and every later edit to it) queued darwin-x64 / win32-arm64 bundle builds for a doc
change. Nothing reads .md files under pm/, so exclude them from the path filters.
scripts/ci/check_agents_md_size.py (in scripts/check and lint.yml): root <= 12k, every
AGENTS.md plus its ancestors <= 30k (the joined root->cwd block on a 128k window, minus
the per-file labels), every area file <= _MAX_HINT_CHARS read from the source so the two
cannot drift. On origin/main it reports the root and all 11 root-to-area chains over.
- `ENFORCEMENT` in scripts/code_health/config.py (blocking / advisory / off)
turns the ratchet down or off with a one-line commit to main. It is read
from the BASE revision, so it applies to fork PRs (repo variables are not
passed to fork-triggered workflows), CI and local hooks alike, and a PR
cannot relax its own check.
- `scripts/check --install-hook pre-push` judges each pushed commit the way
CI judges the PR (base = that commit's merge-base); `--commit REV` is the
underlying mode, `--uninstall-hook` removes our hooks and leaves foreign
ones. Pre-commit and pre-push share one tree snapshot that never touches
the user's worktree or index.
- A failing report names the local commands; CONTRIBUTING, AGENTS.md and the
PR template point at `python scripts/check`.
Fourth review round (F11-F18, N1-N4, kvnloo, Adolanium):
- Hits are old only where the base occurrence survives: a line the comment- and
whitespace-blind line diff keeps (blame semantics), or anything inside a unit whose
body is unchanged. An identical violation moved past unrelated code is new; removing
an allow comment or re-indenting is not. Hit fingerprints exclude comments (N4).
- Inline allows cover their own line; only a comment-only line covers the next (N3).
- Rules read a copy of the AST with import aliases resolved, so `sp.run`,
`from subprocess import run as execute`, aliased threading/asyncio/load_config are
seen (F12); deadline helpers must resolve to the real asyncio API, and
communicate()/wait() are judged only on receivers holding a spawned process (F13);
infinite deadlines are unbounded (F14).
- HERMES_* writes introduce a name too (N1); bool(os.environ[...]) is HX010 (N2);
HX009 needs the isinstance subject to come from gather(return_exceptions=True).
- Recursive self-references are normalised out of body hashes, so a recursive rename
keeps its cap, py and ts (F15). Decorators belong to their function's scope and
classes are matchable units, so their hits follow proven moves (F16).
- An over-cap file may not grow (F17, Teknium: strict improvement-only).
- A file the checker cannot parse or measure (ast/ruff/TS parse error) fails.
- scripts/check: --staged always lints an index snapshot (ignored files are invisible
to git status, not to guards) (F18); create the --prefix dir for Git for Windows
(F11); --only builds just the selected jobs, so it needs no ruff for non-ruff checks.
- CODEOWNERS covers scripts/check + scripts/code_health; frontend-only PRs skip the
Python-only lint jobs again and run only the ratchet.
`python scripts/check` runs ruff, the Windows-footgun/shebang/no-tmp/
config-writer/OS-marker guards and the code-health ratchet in parallel
with CI's pinned ruff (read from pyproject's dev group; a different local
ruff is never used silently, `uvx ruff@<pin>` is the fallback).
`--install-hook` makes it the pre-commit hook; the hook skips branches
that predate scripts/check, since hooks are shared by every worktree.
lint.yml gains the blocking `code health ratchet` job: it compares the PR
merge commit with its first parent (the base branch tip), so a PR is held
to main as it is now. The lint lane also runs for frontend-only changes
because the ratchet covers TypeScript, and the existing ruff installs read
the same pin.
pinned-source-validate checked out depth 2. Hermes resolves its own
version from release tags merged into HEAD, so the shallow checkout
reported 'unknown' and plugins that gate on the host version refused to
load (omh, #122578: requires Hermes >=0.21.1). A blobless full clone
carries the commit graph and tags for about the cost of the shallow one.
Adds package.json and pyproject.toml at any depth, flake.lock, every
*package-lock.json (incl. nix/node-gyp-11-4-0-package-lock.json), .npmrc
(the min-release-age quarantine), pm/artifact-mirror.json, the shell
installers and their pin generator, scripts/ci/, SECURITY.md and
plugin-catalog/. Drops the header claim that the ruleset's
required-reviewers rule already covers the manifests.
uv.lock, package-lock.json (any depth, incl. pm/uv.lock), pm/lock.json
and the root Dockerfile now request core review directly instead of
relying only on the ruleset's required-reviewers rule.
Path-based review (workflows, composite actions, eslint/prettier config,
the MCP catalog, install-hook files) moves to .github/CODEOWNERS. GitHub
requests and enforces that review, so CI no longer classifies those paths,
checks a label, or reruns failed jobs when the label is added.
Removes review-labels.yml, emit_review_status.py, the rerun-review-labels
job, and the ci_review / ci_review_files / mcp_catalog classifier outputs.
The run-e2e rerun job in label-rerun.yml stays.
The supply-chain scan keeps its content patterns (base64 decode + exec,
obfuscated subprocess), which CODEOWNERS cannot express. It fails on its
own again, as it did before #68833 routed it through the label gate. Its
.pth and install-hook path checks are dropped in favour of CODEOWNERS.
stable-release.yml runs `jobs: win32-bundle` as its own call after the
per-arch candidate calls. In that call validate selects only win32-bundle, so
assemble-win32-bundle's gate (win32-x64/win32-arm64 selected AND their build
jobs succeeded) was always false and the job skipped, which failed the stable
bundle phase with `assemble-win32-bundle=skipped`.
A build selected in the same call must still succeed; an unselected one is
fetched from the R2 handoff, as the assemble steps already do.
The desktop DMG's volume icon defaulted to the packager's icns, which is
actool's 256px fallback whenever mac.icon is the Icon Composer package, and
dmgbuild's badge option can only paste onto the stock removable-drive icon.
Ship our own drive-with-the-girl artwork instead: assets/dmg-volume.png
(hand-made, one artwork since volume icons have no appearance variants) is
emitted as apps/desktop/packaging/dmg-volume.icns, beside the DMG background
so the `files` whitelist keeps it out of the app bundle, and dmg.icon points
at it.
Tauri always uses the app icns as the volume icon and offers no override, so
the bootstrap workflow runs scripts/bundles/set_dmg_volume_icon.sh on the
built DMG before signing (the same .VolumeIcon.icns + custom-icon flag
dmgbuild sets), and the verify step checks the icon is on the mounted image.
Verified on macOS 26.6.2: both DMGs render the identical volume icon.
Tauri's bundler (cli 2.11.4) compiles an Icon Composer package listed in
bundle.icon into Assets.car with actool >= 26 and sets CFBundleIconName, so
the bootstrap installer gets an unbranded twin of the desktop package under
src-tauri/icons/icon.icon (generator target; the bundle stays the plain
brand because flavours only apply under apps/desktop/). Tauri only logs when
actool is too old, so the macOS job selects Xcode 26 and the verify step
asserts Assets.car and the plist key are in the built .app.
The desktop DMG's volume icon defaulted to the packager's icns, which is
actool's 256px fallback whenever mac.icon is the .icon package; point
dmg.icon at the full-resolution assets/icon.icns. Tauri's DMG already takes
--volicon from the bundle's .icns, which the regenerated outputs cover.
electron-builder (app-builder-lib 27.0.0-alpha.6) compiles a `.icon` given as
`mac.icon` into Contents/Resources/Assets.car and sets CFBundleIconName, but
only with actool >= 26 (Xcode 26); on any other host it throws and the build
fails. scripts/mac-icon.cjs picks the Icon Composer package when that actool
is present and the legacy .icns otherwise, so a dev Mac on Xcode 16 keeps
building the app it always built.
The release runners keep Xcode 16.4 selected although 26.x is installed, so
the darwin job now selects the newest Xcode 26 via DEVELOPER_DIR and asserts
the actool version before packaging; a silent fallback cannot ship from CI.
electron-builder's `.icon` path also replaces the bundled icon.icns with
actool's 256px fallback. macOS <= 15 shows that file, so after-pack restores
our 16..1024 assets/icon.icns.
The full suite was the last job to finish in 33 of 43 CI runs since it
moved from a 96-core to one 32-core runner (p50 666 s). That runner is
~90% packed, so only more machines help, and one bigger box is poor
value: on 96 cores the same files took 25-50% longer each and the suite
ran ~1.8x faster for 3x the price. Two 32-core slices scale linearly
(simulated from per-file CI timings: 262 s / 252 s vs 514 s on one box).
macOS (free 3-core runner, ~7.5 min) would become the slowest required
job once Linux splits, so it runs as two slices on the same free runner.
--slice now partitions from the checkout alone (sorted round-robin):
each slice job restores its own duration cache, and a main push saving
a newer cache between the two restores made timed LPT compute different
partitions per job, silently dropping or double-running files.
Round-robin lands within 5 s of timed LPT at this suite size. Each slice
saves its own cache key on main; the runner keeps the restored entries
for files outside its slice, so the cache stays complete.
Adding ci-reviewed starts a job that waits for the PR's CI run, then reruns
its failed jobs. A push during the wait cancels that run, the waiter sees
"completed", reruns it, and the rerun rejoins the per-PR concurrency group
and cancels the new head's run. The PR is left testing a stale commit (seen
on #131236 and #131246). Skip the rerun when the head moved: the new run's
own event already carries the label. Same guard on the run-e2e job.
- Run the data-only guard once, inside the changed-entries step of the
pinned-source job, and only when an entry YAML changed. README, policy,
docs and CI PRs that touch plugin-catalog/ without an entry no longer go
red.
- Allow contributors/emails/** beside plugin-catalog/**: the attribution
fix we push onto catalog PR branches is data the gate never executes
(29 of the last 134 merged catalog PRs carried one).
- Diff GitHub's merge commit against its first parent with fetch-depth: 2
instead of a full-history clone plus merge-base. The structural job,
which only validates the whole directory, goes back to a shallow
checkout with no diff at all.
- `subdir:` with no value means the repo root in both the parse step and
the structural validator, matching how the runtime loader has always
read it.
- Scan the whole clone for escaping symlinks, not just PLUGIN_DIR:
find does not descend into a symlinked directory, so an allowed
in-clone link (plugin/ext -> ../lib) hid a nested escape.
- Require catalog admission PRs to be data-only in both jobs; the
gate's enforcement code resolves from the PR checkout, so a diff
that also touches tooling could neuter the gate vetting it.
- persist-credentials: false on both checkouts: `hermes plugins
validate` runs pinned-repo code and must not see the job token.
- Broaden the self-updater patterns: releases/download and codeload
fetches, and the Node fs write sinks (writeFileSync, renameSync,
copyFileSync, cpSync, createWriteStream).
Follow-up to the admission-gate confinement:
- tests/ci: test_parser_imports_cannot_be_shadowed_by_checkout_files planted
`re.py`, but `re` is imported during interpreter startup, so a cwd copy never
shadows it and the test passed even with `-I` stripped from the workflow.
Plant `shlex.py` instead (the parser is its first importer): with a shadow
whose quote() swallows the rejection, the gate returns rc 0 / PASS without
`-I` and rc 1 with it, so the test now actually guards the fix.
- workflow: `timeout 120 git clone` -> 600. The clone is deliberately full
(the pin may not be the tip) and listed repos exceed 700 MB
(hermes-jackal-verified 1.43 GB, hindsight 741 MB); 120s would false-fail a
legitimate re-pin on a slow runner while 600s still bounds a hostile URL
well under the 30-minute job cap.
Attribution: beardthelion@users.noreply.github.com is already in the legacy
AUTHOR_MAP, so no mapping file is needed.