Skip to main content

doc-lattice-shell-lint

A conservative accident lint for doc-lattice invocations in shell.

It reads one Bash body at a time and answers a single question: under a bounded, deterministic model of Bash, can this body be certified as not invoking doc-lattice? Anything the model cannot certify is reported, with the identity of the guard that stopped the scan. The subject is the ASCII marker doc[-_.]+lattice, matched case-insensitively, and the intended use is a CI job over a repository's own workflow run bodies.

It is not a security boundary

This is a best-effort lint that catches accidental or naive doc-lattice invocations in workflows that have not yet been reviewed. Soundness against a deliberate adversary is an explicit non-goal. An author who can add or edit a workflow already executes arbitrary code in CI and steps around a shell-layer marker analysis trivially, through another interpreter or by editing the audited state directly. The control that holds that line is human review of workflow changes, enforced by the adopting repository's own branch governance.

Known certify-anyway and over-refusal corner cases inside the disclosed model are accepted behavior, not defects. AD-23 owns that scope and the three classes of change that remain in it.

Install

uvx doc-lattice-shell-lint --help

Or as a project dependency:

uv add doc-lattice-shell-lint

Python 3.14 or later. The only runtime dependency is ruamel.yaml, which the workflow reader uses. Releases publish to PyPI from the tag pipeline described under Releasing; 0.1.0 is the extraction release.

CLI

scan FILE... treats each file as a shell script; a single - reads the script from stdin and reports it as <stdin>. scan-workflow FILE... lifts every jobs.*.steps[*].run body out of each workflow and scans each one.

doc-lattice-shell-lint scan script.sh
doc-lattice-shell-lint scan-workflow .github/workflows/*.yml
echo 'doc-lattice check' | doc-lattice-shell-lint scan -

Exit codes:

Code Meaning
0 Every scanned body certified.
1 An input could not be read, was not UTF-8 text, or was refused as a workflow document.
2 At least one body did not certify.

Each finding is one line on stdout, LOCATION: VERDICT GUARD REASON, where LOCATION is the path for scan and path:job:step for scan-workflow, and GUARD is - when the verdict carries no guard identity. Input errors go to stderr. Usage errors exit with argparse's own status, which is also 2.

Exit 1 means nothing was scanned, so a clean exit was never available. It is distinct from exit 2 on purpose: a CI job that treats an unreadable path as a passing scan is worse than one that reports the read failure.

Run-body extraction is deliberately minimal

The extractor is a safe-YAML reader, not a workflow model. It yields every run scalar under jobs.*.steps[*] and resolves nothing else: no shell: key, no workflow or job defaults, no matrices, no expressions. Every body it yields is scanned as Bash, so a shell: python step is scanned as Bash too. For an accident lint the loud direction is the safe one, and this is a disclosed limitation rather than a gap to be closed by growing the reader.

Anything that is not a mapping where the workflow schema requires one is refused with the offending path named, so a file handed to scan-workflow by mistake reports itself instead of certifying vacuously. A workflow has to have a jobs table, so a document with no jobs key, or with an explicit jobs: whose value is null, is refused on the same ground: an action.yml or a compose file would otherwise scan clean and exit 0. A jobs mapping with no entries is a table that is present and empty, and it yields nothing, as does a job without steps.

What the lint checks

The lint recognizes direct Bash and sh invocations, including supported uv run and uvx forms. Active brace or glob expansion in an executable or subcommand word, and unsupported active extglob syntax anywhere in a command, do not certify because the resulting argv cannot be certified statically. ANSI-C quoted words that decode to NUL after Bash's eight-bit octal conversion do not certify because Bash discards the suffix after NUL instead of placing that byte in an argument. A resolved doc-lattice executable with no effective command, including bare doc-lattice, doc-lattice --help, and doc-lattice --version, produces no finding. Launcher help and version forms that leave a retained doc-lattice marker under an unresolved command do not certify.

For each simple command, the lint decodes retained assignment-prefix and argv words and applies the ASCII marker doc[-_.]+lattice case-insensitively. If the resolver classifies the effective executable as doc-lattice, its launcher, subcommand, and fail-closed checks apply. Otherwise any retained marker fails closed, regardless of the apparent command head or whether that concrete spelling would execute on one host. Consequently forms such as echo doc-lattice reconcile, command -v doc-lattice, bash --help -c 'doc-lattice reconcile', nohup bash ./doc-lattice-runner.sh, and cmds=(doc-lattice reconcile) are not certified as non-invocations; array assignment element words are marker-checked like scalar assignment values. Comments and discarded redirection targets are not retained command words.

Executable classification is syntactic basename resolution, not proof of runtime identity. Within each individual body, the lint also evaluates authored marker flow after the command-local resolver pass. Certification means no authored fragments compose the ASCII doc[-_.]+lattice marker along a modeled content flow and reach an execution sink in that body. Modeled flows include variable assignment and append, producer stdout, pipes, heredocs, herestrings, command and process substitutions, static file writes and reads, shell script, stdin and -c source selection, eval, source and ., bounded parameter alternatives and brace argv fan-out, structured stream scopes, loop binding and repetition, and descriptor-aware final bindings. Resolved doc-lattice invocations and the retained-word refusals above keep their outcomes.

This contract is body-local and marker-anchored, not a general proof that dynamic shell execution is safe. The lint does not aggregate across bodies, steps, jobs, uses: actions, or reusable workflows. AD-18 owns the modeled-flow boundary and records which shell constructs the analysis interprets, which it deliberately does not, and where the absence of evidence is a disclosed gap rather than a safety claim; AD-23 owns that scope and the disposition of corner cases outside it.

In practice this means marker-free dynamic execution still certifies. curl ... | bash, eval "$EXTERNAL", a marker-free generated script, and doc${EXTERNAL}lattice are all clean, because no authored fragment composes the marker. Oversized or cap-exhausting input does not certify.

Four constructs fail closed for the whole body rather than being modeled. A bare exec that rebinds standard input, output, or error fails closed, because the descriptor belongs to the shell from that point on rather than to the exec itself; a redirection attached to a command or compound is modeled instead. A read that uses -a, -d, -n, -N, or -u, and every mapfile or readarray, fails closed, since a stream supplies no per-element content for an array target and a bounded prefix can compose a marker the full stream does not contain. An array literal element spelled [subscript]=value fails closed, because literal order is then no longer index order while a joined read concatenates by index; an array literal whose elements carry their content directly is modeled instead. A set -- or shift that rewrites the positional parameters outside a function body fails closed, because positional binding is modeled for function contexts only.

Every one of those outcomes carries the identity of the guard that produced it, which is what makes a finding actionable and what the guard inventory in AD-20 holds the package to.

Provenance

This package was extracted verbatim from doc-lattice at commit 38f00d6 on 2026-08-05, where it lived as doc_lattice.github_ci. Full history for the analysis, including the pull requests and review rounds behind the inherited decision records, remains in that repository; the history here starts at the extraction. The two repositories are severed: neither depends on the other at runtime, at build time, or in CI.

Three properties of the extraction are worth knowing before reading anything else:

  • The marker is hardcoded and stays that way. doc[-_.]+lattice is not configurable. The frozen corpus, the fuzz baseline, the guard debt snapshot, and every guard witness are keyed to it, so a configurable subject would invalidate the artifacts that make the frozen scope checkable. Generalizing the marker would be a different tool under a different name.
  • This package's own name matches the marker it hunts. A workflow run body here cannot spell doc-lattice-shell-lint, doc_lattice_shell_lint, or src/doc_lattice_shell_lint/ and still certify. That is the lint working, not a bug. This repository's own workflows reach those paths through env: bindings, whose values are not extracted or scanned.
  • The doc-lattice CLI grammar is frozen at that commit. The scanner's option tables are pinned to tests/fixtures/doc_lattice_cli_grammar.json, a snapshot taken from doc-lattice's live CLI at 38f00d6 and carrying its own provenance block. In doc-lattice those tables were verified against the running CLI on every test run; that check cannot survive severance. Drift in doc-lattice's CLI after 38f00d6 is disclosed rather than tracked, and the root-option half of the snapshot check is subset shaped, so it cannot see doc-lattice adding a new root option.

SL-1 records the extraction in full, including the flat module layout, the one frozen artifact that was regenerated and the identity proof for it, and the release-pipeline deviations described below.

Releasing

Releases are tag triggered. Pushing vX.Y.Z runs every gate against the tagged tree and then publishes it. PyPI Trusted Publishing trusts the Guardantix/doc-lattice-shell-lint repository, the ci.yml workflow, and the pypi environment; no PyPI API token is stored.

  1. Bump version in pyproject.toml and __version__ in src/doc_lattice_shell_lint/__init__.py, and promote the changelog's unreleased section to ## [X.Y.Z] - YYYY-MM-DD.
  2. Run uv lock and commit the refreshed uv.lock.
  3. Open a pull request, let every check pass, and merge it to main.
  4. Tag the merged commit vX.Y.Z and push the tag.

The pipeline then, in order: refuses the tag if its commit is not an ancestor of origin/main; refuses it if the tag does not name the packaged version in pyproject.toml; creates the GitHub Release idempotently; builds and validates the wheel and source distribution in an unprivileged job; and publishes the validated artifact to PyPI through OIDC from the pypi environment.

Two of those steps deserve their reasons stated, because both compensate for the tag trigger:

  • The ancestry gate is load bearing. A tag is a newly created ref, so the guard-debt job resolves an all-zero predecessor, compares nothing, and goes green having done nothing. Its slot in the release job's needs is satisfied by that no-op. Without the ancestry check a tag pushed at a branch that never merged would release that branch.
  • Branch protection requiring the Guard debt check on main is load bearing for the same reason. The tag-time run of that job cannot enforce debt, so the enforcement has to have happened on the pull request that landed the code. That setting is not assertable from inside the tree, and a release gate resting on it is only as strong as the setting.

Manual, out-of-tree steps a maintainer owns:

  • Configure the PyPI trusted publisher for this repository, the ci.yml workflow, and the pypi environment.
  • Create the pypi GitHub environment and scope it to the release ref.
  • Require Guard debt and Corpus differential as status checks on main. The differential deliberately stays out of the release job's needs, because a job skipped for push events skips every dependent with it, so required-checks configuration is the only thing enforcing it.

After a release, confirm the index serves it. doc-lattice's automated post-publication smoke step was not ported: its body would have to name this distribution, and this distribution's name is the marker, so the step cannot be spelled in a certified run body and hiding it behind an env: binding would defeat what the lint is for. Verify by hand instead:

uvx --refresh doc-lattice-shell-lint --help

Never move a release tag, and never delete or replace files already published to PyPI. If the release source is wrong, fix it and cut the next version.

Development

uv sync --group dev
uv run --group dev pytest
uv run --group dev ruff check src tests
uv run --group dev ty check src

CLAUDE.md owns the full contributor command set, including the fuzzer, the guard inventory gate, the witness sweep, and the corpus differential.

Documentation

Document Purpose
ARCHITECTURE.md Inherited decisions, and SL-1 on the extraction
CHANGELOG.md Release history
CLAUDE.md Contributor commands and enforced repository rules
roadmap.md Future direction, such as it is
build-log.md Development timeline
procedures/code-conventions.md Coding conventions

Project Structure

doc-lattice-shell-lint/
├── src/doc_lattice_shell_lint/   # Flat package: scanner, taint, guards, reader, CLI
├── tests/                        # Suite, guard witnesses, and frozen fixtures
├── scripts/                      # Guard inventory, witness sweep, corpus differential, fuzzer
├── docs/                         # Dated research artifacts, not maintained
├── procedures/                   # Coding conventions
└── pyproject.toml                # Project configuration

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

doc_lattice_shell_lint-0.1.0.tar.gz (771.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

doc_lattice_shell_lint-0.1.0-py3-none-any.whl (216.2 kB view details)

Uploaded Python 3

File details

Details for the file doc_lattice_shell_lint-0.1.0.tar.gz.

File metadata

  • Download URL: doc_lattice_shell_lint-0.1.0.tar.gz
  • Upload date:
  • Size: 771.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for doc_lattice_shell_lint-0.1.0.tar.gz
Algorithm Hash digest
SHA256 7bbba98c4ebff9d45fb49714c61269dae8f893ac7f6c4a02b36415f664bf0afa
MD5 923bed36d37625407ab33022366f5d59
BLAKE2b-256 2ac5b373b50185855724228b5e860af6001cb05d1cc40ccf58d679bf8c6682be

See more details on using hashes here.

Provenance

The following attestation bundles were made for doc_lattice_shell_lint-0.1.0.tar.gz:

Publisher: ci.yml on Guardantix/doc-lattice-shell-lint

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file doc_lattice_shell_lint-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for doc_lattice_shell_lint-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 057fed92532251098633f8f3adf5525a0d9b8b66f152e926dd9736bda9ac2cee
MD5 262a52ab829bf0470afd3b82bd101936
BLAKE2b-256 05b9cf3cda324d5fb4e02156f7a1f09747bc5e9230b9cf60df4e8d91ef63a900

See more details on using hashes here.

Provenance

The following attestation bundles were made for doc_lattice_shell_lint-0.1.0-py3-none-any.whl:

Publisher: ci.yml on Guardantix/doc-lattice-shell-lint

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page