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[-_.]+latticeis 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, orsrc/doc_lattice_shell_lint/and still certify. That is the lint working, not a bug. This repository's own workflows reach those paths throughenv: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 at38f00d6and 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 after38f00d6is 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.
- Bump
versioninpyproject.tomland__version__insrc/doc_lattice_shell_lint/__init__.py, and promote the changelog's unreleased section to## [X.Y.Z] - YYYY-MM-DD. - Run
uv lockand commit the refresheduv.lock. - Open a pull request, let every check pass, and merge it to
main. - Tag the merged commit
vX.Y.Zand 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
needsis 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 debtcheck onmainis 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.ymlworkflow, and thepypienvironment. - Create the
pypiGitHub environment and scope it to the release ref. - Require
Guard debtandCorpus differentialas status checks onmain. The differential deliberately stays out of the release job'sneeds, 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7bbba98c4ebff9d45fb49714c61269dae8f893ac7f6c4a02b36415f664bf0afa
|
|
| MD5 |
923bed36d37625407ab33022366f5d59
|
|
| BLAKE2b-256 |
2ac5b373b50185855724228b5e860af6001cb05d1cc40ccf58d679bf8c6682be
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
doc_lattice_shell_lint-0.1.0.tar.gz -
Subject digest:
7bbba98c4ebff9d45fb49714c61269dae8f893ac7f6c4a02b36415f664bf0afa - Sigstore transparency entry: 2352159793
- Sigstore integration time:
-
Permalink:
Guardantix/doc-lattice-shell-lint@f869e14f22d5edc195f1a780444663d5bd846ed9 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Guardantix
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@f869e14f22d5edc195f1a780444663d5bd846ed9 -
Trigger Event:
push
-
Statement type:
File details
Details for the file doc_lattice_shell_lint-0.1.0-py3-none-any.whl.
File metadata
- Download URL: doc_lattice_shell_lint-0.1.0-py3-none-any.whl
- Upload date:
- Size: 216.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
057fed92532251098633f8f3adf5525a0d9b8b66f152e926dd9736bda9ac2cee
|
|
| MD5 |
262a52ab829bf0470afd3b82bd101936
|
|
| BLAKE2b-256 |
05b9cf3cda324d5fb4e02156f7a1f09747bc5e9230b9cf60df4e8d91ef63a900
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
doc_lattice_shell_lint-0.1.0-py3-none-any.whl -
Subject digest:
057fed92532251098633f8f3adf5525a0d9b8b66f152e926dd9736bda9ac2cee - Sigstore transparency entry: 2352159946
- Sigstore integration time:
-
Permalink:
Guardantix/doc-lattice-shell-lint@f869e14f22d5edc195f1a780444663d5bd846ed9 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Guardantix
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@f869e14f22d5edc195f1a780444663d5bd846ed9 -
Trigger Event:
push
-
Statement type: