Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
with:
go-version-file: dev/tools/actionlint/go.mod
cache-dependency-path: dev/tools/actionlint/go.sum
- run: make format-check typecheck verify-rules recipe-lint masters-check workflow-lint spell
- run: make format-check typecheck verify-rules recipe-lint masters-check eval-expected-check workflow-lint spell

crosscheck:
name: crosscheck (Python ${{ matrix.python }})
Expand Down
20 changes: 19 additions & 1 deletion .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ make lint # read-only lint
make typecheck # mypy, strict, on audit/tools, implement/tools and dev, and the engine's build.py
make test # the engine's, the audit tools', the implement tools' and the dev checks' suites (at once on Linux)
make check # THE gate: format:check, typecheck, test, the engine's --verify-rules, the schema
# cross-check, recipe-lint, masters-check, workflow-lint (actionlint, zizmor, check-jsonschema), spell (typos)
# cross-check, recipe-lint, masters-check, eval-expected-check, workflow-lint (actionlint,
# zizmor, check-jsonschema), spell (typos)
make security # secrets scan, then the dependency audit (osv-scanner; needs Go and the network)
make commit-lint # Conventional Commits subjects on this branch (RANGE=origin/dev..HEAD); CI runs it on each PR
make update-masters # regenerate docs/reference/audit-master.md and implement-master.md (check runs masters-check)
Expand Down
12 changes: 9 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,13 @@ endif
ifndef TEST_SUITES_SUBMAKE
.NOTPARALLEL:
endif
.PHONY: help setup format format-check format\:check lint typecheck test $(TEST_SUITES) test-engine-shard test-audit-tools-shard test-dev-shard test-part coverage coverage-engine coverage-engine-shard coverage-audit-tools-shard coverage-part coverage-audit-tools coverage-implement-tools coverage-combine verify-rules crosscheck recipe-lint masters-check update-masters check security security-secrets security-deps update-secrets-baseline self-audit update-audit-baseline validate-skills docs-lint docs-lint-all docs-prose docs-mermaid changelog-fix workflow-lint commit-lint spell spell-fix
.PHONY: help setup format format-check format\:check lint typecheck test $(TEST_SUITES) test-engine-shard test-audit-tools-shard test-dev-shard test-part coverage coverage-engine coverage-engine-shard coverage-audit-tools-shard coverage-part coverage-audit-tools coverage-implement-tools coverage-combine verify-rules crosscheck recipe-lint masters-check eval-expected-check update-masters check security security-secrets security-deps update-secrets-baseline self-audit update-audit-baseline validate-skills docs-lint docs-lint-all docs-prose docs-mermaid changelog-fix workflow-lint commit-lint spell spell-fix

help:
@echo "verbs: format format:check (format-check) lint typecheck test check security"
@echo "parts: test-engine test-audit-tools test-implement-tools test-dev coverage-engine coverage-audit-tools coverage-implement-tools coverage-combine"
@echo "ci: test-part SHARD=<i> coverage-part SHARD=<i> coverage-combine SHARDED=1 (and test-<suite>-shard, coverage-<suite>-shard)"
@echo "extra: setup (checks prerequisites, installs nothing) coverage self-audit update-audit-baseline verify-rules crosscheck recipe-lint masters-check update-masters workflow-lint commit-lint spell spell-fix update-secrets-baseline validate-skills"
@echo "extra: setup (checks prerequisites, installs nothing) coverage self-audit update-audit-baseline verify-rules crosscheck recipe-lint masters-check eval-expected-check update-masters workflow-lint commit-lint spell spell-fix update-secrets-baseline validate-skills"
@echo "docs: docs-lint docs-lint-all docs-prose [FILES=...] docs-mermaid FILES=... changelog-fix"

# There is nothing to install into the project: the runtime code is standard-library Python and the dev tools run
Expand Down Expand Up @@ -300,6 +300,12 @@ masters-check:
update-masters:
$(PYTHON) dev/build_masters.py --write

# The eval-expected files (dev/eval/expected/fixtures/) are generated from the engine's fixture matrix by running the
# engine on each fixture (a few seconds). eval-expected-check fails when one is stale; run the script without --check
# to rewrite them.
eval-expected-check:
$(PYTHON) dev/eval/generate_fixture_expected.py --check

# Lints the GitHub Actions workflows and checks .github/dependabot.yml. All three tools run offline.
# Read-only: report each misspelling with its file and line. `make spell-fix` writes the suggested fixes; review them.
spell:
Expand All @@ -320,7 +326,7 @@ RANGE ?= origin/dev..HEAD
commit-lint:
$(PYTHON) dev/commit_lint.py --range $(RANGE)

check: format-check typecheck test verify-rules crosscheck recipe-lint masters-check workflow-lint spell
check: format-check typecheck test verify-rules crosscheck recipe-lint masters-check eval-expected-check workflow-lint spell

# No third-party runtime dependencies exist to audit, so the gate is a secrets scan. The baseline
# lists the deliberate key-shaped strings in the fixtures and tests; a new hit fails the scan.
Expand Down
11 changes: 8 additions & 3 deletions audit/checks/05-tooling.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Tooling (`TOOL`)

> Follows the anatomy in [`README.md`](README.md). Seven checks, all `method: automated`. See

Check notice on line 3 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 33 words (limit 25)
> [Why These Checks §6](../engine/docs/architecture/user-overview/03-why-these-checks.md#6-tooling-tool)
> for the consequence behind this family: a tool that is installed but not reachable through the command
> surface, or configured but not strict, gives an agent false confidence. TOOL-01 to TOOL-04 are remediated
Expand All @@ -10,7 +10,7 @@

## TOOL-01 — Formatter configured

Why it matters Formatting must be free and non-negotiable, or every diff mixes style with substance and

Check notice on line 13 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 37 words (limit 25)

Check notice on line 13 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 31 words (limit 25)

Check notice on line 13 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 27 words (limit 25)

Check warning on line 13 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 46 words (limit 25)

Check warning on line 13 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 42 words (limit 25)
review spends attention on noise
([`docs/framework/phased-approach.md` § Activities](../../docs/framework/phased-approach.md#activities)).
Severity DEGRADER
Expand Down Expand Up @@ -44,7 +44,7 @@

## TOOL-02 — Linter configured

Why it matters A linter catches classes of defect before tests exist to catch them, including

Check warning on line 47 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 44 words (limit 25)

Check notice on line 47 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 31 words (limit 25)

Check notice on line 47 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 27 words (limit 25)

Check notice on line 47 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 28 words (limit 25)

Check warning on line 47 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 44 words (limit 25)

Check notice on line 47 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 27 words (limit 25)
regressions an agent introduces that no test yet covers
([`docs/framework/phased-approach.md` § Activities](../../docs/framework/phased-approach.md#activities)).
Severity DEGRADER
Expand Down Expand Up @@ -76,7 +76,7 @@

## TOOL-03 — Type checker configured, strict

Why it matters A strict type checker is the cheapest correctness gate available; without it, interface

Check warning on line 79 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL002

vague word 'just'

Check warning on line 79 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 58 words (limit 25)

Check notice on line 79 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 31 words (limit 25)

Check warning on line 79 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 43 words (limit 25)

Check notice on line 79 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 35 words (limit 25)
drift between an agent's change and its callers is discovered at runtime instead of at
review time
([`docs/framework/phased-approach.md` § Activities](../../docs/framework/phased-approach.md#activities)).
Expand Down Expand Up @@ -119,7 +119,7 @@

## TOOL-04 — Dependency / security scan verb

Why it matters Third-party risk is real and agents add dependencies; without a scan, a vulnerability can

Check warning on line 122 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 49 words (limit 25)

Check warning on line 122 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 41 words (limit 25)

Check warning on line 122 in audit/checks/05-tooling.md

View workflow job for this annotation

GitHub Actions / docs

PL001

sentence has 49 words (limit 25)
enter through the very change an agent just made
([`docs/framework/phased-approach.md` § Activities](../../docs/framework/phased-approach.md#activities)).
Severity DEGRADER
Expand Down Expand Up @@ -263,16 +263,21 @@
globs are not required. With no declared rules, any guard counts. A `.husky/` guard is
wired when a `package.json` script runs `husky`; another hook directory is wired when
`core.hooksPath` is set. Distrust a `FAIL` when the hook calls a script that holds the guard
(`.husky/pre-commit` running `node scripts/check-branch.js`) — read the script. The engine
does not read whether the guard exits non-zero.
(`.husky/pre-commit` running `node scripts/check-branch.js`) — read the script. A guard
that reads the branch must also exit non-zero (`exit 1`, `process.exit(1)`, a bare `false`)
in its lefthook command block or anywhere in its hook script; `no-commit-to-branch` always
does. The exit is not tied to the branch test, so read a script whose only `exit 1` is for
another reason.
Method automated
Look for `.pre-commit-config.yaml` for `no-commit-to-branch` and its `args`; the `pre-commit` and
`commit-msg` stages of `lefthook.yml`; the hook scripts named `pre-commit` and `commit-msg`;
the declared rules' branch list (`HYG-11`'s evidence).
Rubric PASS: a guard in a wired hook chain names every branch the declared rules protect, or any
guard is wired and no rules are declared.
PARTIAL: a guard exists but its hook chain is not wired (an unreferenced `.husky/`, a
`.githooks/` with no `core.hooksPath`); or a wired guard does not name a protected branch.
`.githooks/` with no `core.hooksPath`); or no wired guard exits non-zero (it reads the
branch, prints a warning and lets the commit go ahead); or a wired guard does not name a
protected branch.
FAIL: a hook chain exists (wired or configured) and no hook in it is a guard.
NOT_APPLICABLE: never.
UNKNOWN when: no hook chain exists at all; `TOOL-05` owns that finding.
Expand Down
15 changes: 10 additions & 5 deletions audit/checks/09-execution-determinism.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@ Method automated
Look for Each detected pinnable ecosystem's own pin file or declaration, named above; absent any
of them, `.tool-versions` for a matching line.
Rubric PASS: every detected pinnable ecosystem has at least one of its own pin files, or a
matching `.tool-versions` line, or (node/python/go) the manifest-level constraint.
PARTIAL: not used by this check — any one ecosystem left unpinned fails the whole check.
matching `.tool-versions` line, or (node/python/go) the manifest-level constraint, and a
pin reaches every module manifest of each pinned ecosystem.
PARTIAL: every ecosystem is pinned, but a module manifest of a pinned ecosystem has no pin
of its own and no pin in its folder or a parent folder. An ecosystem left unpinned still
fails the whole check.
FAIL: at least one detected pinnable ecosystem has none of its pin evidence.
NOT_APPLICABLE: never.
UNKNOWN when: no ecosystem from the pinnable set (node, python, jvm, dotnet, rust, ruby,
Expand All @@ -51,9 +54,11 @@ Remediation `pin-execution-environment`
Pitfalls A monorepo where only one of several ecosystems is pinned still reads `FAIL` overall —
the engine fails the whole check on the first unpinned ecosystem, so a `FAIL` summary
naming one ecosystem does not mean the others are also unpinned; read which ecosystem
the finding actually names before writing remediation. The reverse holds within one
ecosystem: one pinned nested module passes it even when a sibling module has no pin, so
read each module's manifest before you accept a `PASS` in a monorepo.
the finding actually names before writing remediation. Within one ecosystem, each module
manifest (`package.json`, `pyproject.toml`, `go.mod`) must be reached by a pin: its own
manifest, or a pin file or (node, python) a pinning manifest in its folder or a parent
folder; one unreached module reads `PARTIAL` and is named in the evidence. A nested
`.tool-versions` is not read, and a parent `go.mod` does not pin a nested Go module.

## EXEC-02 — Lockfile committed and consistent

Expand Down
2 changes: 1 addition & 1 deletion audit/engine/audit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
#: Bumped whenever a rule pack, a check, or the instruction-variant table changes.
#: Recorded in every report's provenance block so a report can be attributed to an
#: exact catalogue revision (docs: Scan Engine & Tiering, § 7 Determinism).
__ruleset_revision__ = "2026-11-10.2"
__ruleset_revision__ = "2026-11-10.4"

#: Highest framework phase this build evaluates. Phase 1 only in v1.
__phase_ceiling__ = 1
86 changes: 83 additions & 3 deletions audit/engine/audit/rules/checks/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
* **EXEC-01** — a toolchain pin appropriate to each **detected** ecosystem (``.nvmrc``, ``engines``,
``.python-version``/``requires-python``, ``gradlew``/``mvnw``, ``global.json``,
``rust-toolchain.toml``, the ``go`` directive). Pin files and manifests count at any depth, so a nested
``go.mod``'s ``go`` directive, a nested ``requires-python`` or a nested ``engines.node`` pins the ecosystem; one
pin is enough. No recognised ecosystem ⇒ ``UNKNOWN``; an ecosystem with no pin in the table (php) is not
``go.mod``'s ``go`` directive, a nested ``requires-python`` or a nested ``engines.node`` pins the ecosystem. A
pinned ecosystem with a module manifest that no pin reaches (:func:`_unpinned_modules`: not its own manifest, nor
a pin file or a node/python pinning manifest in its folder or a parent) ⇒ ``PARTIAL``. No recognised ecosystem ⇒ ``UNKNOWN``; an ecosystem with no pin in the table (php) is not
asserted against.
* **EXEC-02** — the ecosystem's lockfile is **tracked** (``audit.ignore.tracked_files``) and its
declared dependencies agree with the manifest. ``tracked_files`` returning ``None`` (not a git
Expand Down Expand Up @@ -168,18 +169,94 @@ def _pin_evidence(inv: Inventory, ecosystem: str) -> list[str]:
return sorted(dict.fromkeys(evidence))


#: The manifest that makes a module of the ecosystem, and what in it pins the toolchain.
_MODULE_MANIFESTS = {"node": "package.json", "python": "pyproject.toml", "go": "go.mod"}

#: At most this many unpinned modules are named in a summary; the finding's evidence lists them all.
_MODULES_NAMED = 5


def _dir_of(rel: str) -> str:
return rel.rsplit("/", 1)[0] if "/" in rel else ""


def _manifest_pins(inv: Inventory, ecosystem: str, rel: str) -> bool:
text = inv.read(rel) or ""
if ecosystem == "node":
try:
engines = json.loads(text or "{}").get("engines")
except (json.JSONDecodeError, AttributeError):
return False
return isinstance(engines, dict) and bool(engines.get("node"))
if ecosystem == "python":
return re.search(r"(?m)^\s*requires-python\s*=", text) is not None
return re.search(r"(?m)^\s*go\s+\d", text) is not None


def _unpinned_modules(inv: Inventory, ecosystem: str) -> list[str]:
"""The module manifests of ``ecosystem`` that no pin reaches. A pin reaches a module when the module's own
manifest pins, or when its folder or a parent folder holds a pin file (``.nvmrc``, ``.python-version``, a
``.tool-versions`` line) or, for node and python, a pinning manifest (a workspace root's ``engines.node`` or
``requires-python``). A Go module is its own toolchain unit, so a parent ``go.mod`` does not reach it."""
name = _MODULE_MANIFESTS.get(ecosystem)
if name is None:
return []
manifests = _manifests(inv, name)
pin_dirs: set[str] = set()
pin_names = set(_PIN_FILES.get(ecosystem, ()))
pattern = _TOOL_VERSIONS.get(ecosystem)
for entry in inv.files:
base = entry.rel.rsplit("/", 1)[-1]
if base in pin_names or (
base == ".tool-versions" and pattern is not None and pattern.search(inv.read(entry.rel) or "")
):
pin_dirs.add(_dir_of(entry.rel))
pinning = {rel for rel in manifests if _manifest_pins(inv, ecosystem, rel)}
if ecosystem != "go":
pin_dirs |= {_dir_of(rel) for rel in pinning}

def reached(folder: str) -> bool:
return any(pin == "" or folder == pin or folder.startswith(pin + "/") for pin in pin_dirs)

return [rel for rel in manifests if rel not in pinning and not reached(_dir_of(rel))]


def check_exec01(*, spec, target, inventory, stack, components, session) -> CheckOutcome:
ecosystems = sorted(stack.ecosystems & _PINNABLE)
if not ecosystems:
return _unknown(spec, "no recognised ecosystem, so no toolchain pin can be chosen")

findings: list[Finding] = []
partial: list[Finding] = []
pinned: list[str] = []
unpinned: list[str] = []
gaps: list[str] = []
for ecosystem in ecosystems:
evidence = _pin_evidence(inventory, ecosystem)
if evidence:
pinned.append(f"{ecosystem} ({', '.join(evidence)})")
modules = _unpinned_modules(inventory, ecosystem)
if modules:
named = ", ".join(modules[:_MODULES_NAMED])
more = f" and {len(modules) - _MODULES_NAMED} more" if len(modules) > _MODULES_NAMED else ""
gaps.append(f"{ecosystem} ({named}{more})")
partial.append(
Finding(
check=spec.id,
severity=spec.severity,
phase=spec.phase,
verdict=Verdict.PARTIAL,
statement=statement(
f"reproduce the {ecosystem} toolchain in every module",
f"{len(modules)} {ecosystem} module(s) carry no pin and sit under no pinned folder",
),
remediation=f"Pin each {ecosystem} module: a pin in its own manifest, or a "
f"{', '.join(_PIN_FILES.get(ecosystem) or ('.tool-versions',))} in its folder or a "
"parent folder.",
path=ecosystem,
evidence=[Evidence(rel) for rel in modules],
)
)
continue
unpinned.append(ecosystem)
findings.append(
Expand All @@ -200,7 +277,10 @@ def check_exec01(*, spec, target, inventory, stack, components, session) -> Chec
)

if findings:
return _outcome(spec, Verdict.FAIL, f"unpinned: {', '.join(unpinned)}", findings)
return _outcome(spec, Verdict.FAIL, f"unpinned: {', '.join(unpinned)}", findings + partial)
if partial:
summary = "pinned: " + "; ".join(pinned) + "; modules no pin reaches: " + "; ".join(gaps)
return _outcome(spec, Verdict.PARTIAL, summary, partial)
return _outcome(spec, Verdict.PASS, "pinned: " + "; ".join(pinned))


Expand Down
Loading
Loading