Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
2ca44c1
docs: task breakdown for the gate and the credential store
Sep 5, 2026
97823f1
feat: canonical config-path grammar (parse/render)
vjsrinath Sep 5, 2026
7b09516
fix: escape backslash in quoted path keys, not just the quote
vjsrinath Sep 5, 2026
af6291d
feat: repository policy loader for .stackward.toml
vjsrinath Sep 5, 2026
686021b
fix: validate stack_models shape; prove sensitive_parents replace wiring
vjsrinath Sep 5, 2026
8c2ef8d
feat: heuristic credential-detection net and check-config command
vjsrinath Sep 5, 2026
279e0d1
fix: propagate sensitive_keys matches to their subtree
vjsrinath Sep 5, 2026
e934848
fix: stop a YAML parse error leaking the credential, and an uncaught
vjsrinath Sep 5, 2026
8735725
fix: redact quoted fragments PyYAML's %r interpolates into error mess…
vjsrinath Sep 5, 2026
db48ff9
fix: redact both repr() delimiter forms, and test the property by shape
vjsrinath Sep 5, 2026
391fd71
feat: pre-commit gate over staged content, plus describe_yaml_error r…
vjsrinath Sep 5, 2026
95874f0
fix: close two fail-open gaps found in review of the pre-commit gate
vjsrinath Sep 5, 2026
8df33b7
feat: hooks install — write the pre-commit gate into a repo's real ho…
vjsrinath Sep 5, 2026
5321d3f
fix: close the fail-open gap in _is_tracked, and stop the e2e tests s…
vjsrinath Sep 5, 2026
ab567ca
feat: profile-scoped credential store, encrypted at rest
vjsrinath Sep 5, 2026
c1e238e
fix: raise the cryptography floor to 44.0, and refuse an empty password
vjsrinath Sep 5, 2026
de4e841
feat: login, exec, shell -- run pulumi with a profile's credentials
vjsrinath Sep 5, 2026
ee36dc8
fix: scrub the store password from children, and stop leaking a backe…
vjsrinath Sep 5, 2026
f929f6a
fix: close two test-isolation gaps found in re-review
vjsrinath Sep 5, 2026
d3869c9
feat: set-secrets -- publish declared stack secrets into pulumi config
vjsrinath Sep 5, 2026
fcdacd0
fix: publish plaintext entries, and collapse [required] to one shape
vjsrinath Sep 5, 2026
5ba2954
fix: reject secret/plaintext overlap, cover source-file resolution, f…
vjsrinath Sep 5, 2026
0ba3353
fix: stop a variable-name collision from crashing the second drift pair
vjsrinath Sep 5, 2026
4ba9f6b
feat: add check-passphrase command (Task 9)
vjsrinath Sep 5, 2026
393c2d7
feat: the model net -- declared secrets as a graph, checked against t…
vjsrinath Sep 5, 2026
351fae5
fix: self-review -- validate the graph before writing, and guard the …
vjsrinath Sep 5, 2026
06eacb6
test: cover the out-of-repo contributor and lockfile pinning path
vjsrinath Sep 5, 2026
84bfe83
feat: run the model net in the hook, refuse RootModel, verify the bundle
vjsrinath Sep 5, 2026
a3f551f
fix: close three fail-opens that let check-config exit 0 on a credential
vjsrinath Sep 5, 2026
b2fde4b
feat: provider interface for credentials and secrets (Task 11)
vjsrinath Sep 5, 2026
8b2696f
fix: close the gate's fail-opens — index policy, missing policy, isol…
vjsrinath Sep 5, 2026
f249c40
docs: scope the LC_ALL assertion to the call site this wave owns
vjsrinath Sep 5, 2026
2b86044
test: one leak assertion that sees partial and re-encoded disclosure
vjsrinath Sep 5, 2026
9e9c6cd
test: execute the path grammar's refusals, and fix the model net's lo…
vjsrinath Sep 5, 2026
429e4c2
test: prove the stdin-not-argv rule for every shape, and see partial …
vjsrinath Sep 5, 2026
e6e54a5
test: the artifact's own bytes are checked by nothing but this parser
vjsrinath Sep 5, 2026
1ff3b50
docs: two absolute claims in the README were false
vjsrinath Sep 5, 2026
cc73125
test: carry the leak-check respell across to the provider suite
vjsrinath Sep 5, 2026
cf92973
fix: pin what the credential layer's tests only appeared to check
vjsrinath Sep 5, 2026
2faf415
test: catch a truncated encoding, not only a complete one
vjsrinath Sep 5, 2026
a5729f3
feat: ship `credentials`, and fix the dispatch that hid every lazy co…
vjsrinath Sep 6, 2026
511ee6b
fix: clear eight minor items, two of which were prose authorising a r…
vjsrinath Sep 6, 2026
e79921e
test: make the TypedDict fixture portable below 3.12
vjsrinath Sep 6, 2026
63acaaa
chore: never commit agent scratch directories
vjsrinath Sep 6, 2026
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
51 changes: 40 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,39 @@ jobs:
set -euo pipefail
python -m pip install --quiet --upgrade pip
python -m pip install --quiet . pyinstaller
# `bootstrap/regen.py` is shipped as DATA, not as a module: it is
# piped to the consuming repository's own interpreter, and nothing
# in stackward imports it — which is what keeps pydantic out of the
# bundle. PyInstaller's import analysis therefore never sees it, so
# it has to be added explicitly.
python -m PyInstaller --noconfirm --clean --onedir \
--name stackward --paths src --distpath dist --workpath build entry.py
--name stackward --paths src --distpath dist --workpath build \
--add-data "src/stackward/bootstrap/regen.py:stackward/bootstrap" \
entry.py

- name: It must actually run
# A bundle that builds is not a bundle that works. doctor performs a
# real Argon2id + AES-GCM round trip, so this catches a native
# extension that failed to freeze — the classic failure of this route.
# A bundle that builds is not a bundle that works. The checks, and
# the reasoning behind each, live in `scripts/check-bundle.sh` --
# one definition, run here against what this job just built and
# again in `publish` against the artifact actually being released.
# Two copies of this is how `publish` came to test only `--version`
# and `doctor`, the two commands a missing lazily-dispatched module
# cannot break.
run: |
set -euo pipefail
./dist/stackward/stackward --version
./dist/stackward/stackward doctor
scripts/check-bundle.sh ./dist/stackward/stackward

# Not in the shared script: this one inspects the PyInstaller
# output tree, which only this job has. `regen.py` ships as data
# precisely so nothing imports it and pydantic stays test-only. If
# it ever became an import, the bundle would still build, doctor
# would still pass, and the only visible symptom would be a fatter
# download -- so the absence has to be asserted, not assumed.
if find dist -iname '*pydantic*' -print -quit | grep -q .; then
echo "::error::pydantic was bundled; it is a test-only dependency" >&2
find dist -iname '*pydantic*' | head -20 >&2
exit 1
fi

- name: Sign for macOS
if: runner.os == 'macOS'
Expand Down Expand Up @@ -110,16 +132,23 @@ jobs:

- name: Verify the install path before publishing it
# Install exactly as a user would, from the artifacts about to be
# released, and run the smoke test. A release that cannot be installed
# is deleted, not patched: the tag is already immutable in people's
# lockfiles and instructions.
# released, and run the same check the build job ran -- against the
# bytes actually being published, which is the only artifact whose
# correctness this job can still act on. A release that cannot be
# installed is deleted, not patched: the tag is already immutable in
# people's lockfiles and instructions.
#
# `scripts/check-bundle.sh`, not a second smoke test written here:
# this step used to run `--version` and `doctor` alone, and those are
# exactly the two commands that stayed green through a bundle that
# was missing every lazily dispatched one.
run: |
set -euo pipefail
cd artifacts
sha256sum --check stackward-Linux-x86_64.tar.gz.sha256
mkdir -p /tmp/probe && tar -xzf stackward-Linux-x86_64.tar.gz -C /tmp/probe
/tmp/probe/stackward/stackward --version
/tmp/probe/stackward/stackward doctor
# Absolute: the working directory is `artifacts`, not the checkout.
"$GITHUB_WORKSPACE/scripts/check-bundle.sh" /tmp/probe/stackward/stackward

- name: Publish
env:
Expand Down
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,17 @@ state.json
.env
.env.*
!.env.example

# Agent and tooling scratch directories. These hold working notes, review
# transcripts and plan files — prose about how the work was done, written
# for a private audience. On a public repository that is a worse exposure
# than a stray build artefact: the neutrality gate greps for known terms,
# and cannot catch a novel detail described in a sentence.
#
# Listed here rather than relying on an ignore file the tooling drops
# inside its own directory. That file is untracked, so it protects only the
# checkout that happened to create it — a fresh clone gets nothing, and
# anything written outside the subdirectory it covers was never protected
# at all.
.superpowers/
.claude/
54 changes: 46 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,45 @@ at the top of the file is what makes it safe. What makes it *un*safe is a value
hand-edited in as plaintext, which looks exactly like every other line in the
diff. `stackward` exists to make that mistake hard.

> **Status: early.** `v0.1.0` ships `--version` and `doctor` only. It exists to
> prove the release and install path before features are built on top of it.
> **Status: early.** `v0.1.0` shipped `--version` and `doctor` alone, to prove
> the release and install path before anything was built on top of it. That
> path held, and every command in the table below is now implemented.
> Interfaces may change.

## What it will do
## What it does

| Command | Purpose |
|---|---|
| `check-config` | Refuse plaintext credentials in a stack config file |
| `pre-commit` | The above, over staged content, as a git hook |
| `hooks install` | Install that hook, honouring `core.hooksPath` |
| `credentials` | Keep backend credentials in one encrypted, profile-scoped store |
| `exec` / `shell` | Run `pulumi` with those credentials injected into the child process |
| `credentials` | Create the encrypted store, seal a profile's credentials into it, list what is there, rotate the password |
| `login` | Point Pulumi at a profile's backend |
| `exec` / `shell` | Run `pulumi` with a profile's credentials injected into the child process |
| `set-secrets` | Publish stack secrets from a declared manifest, values on stdin |
| `check-passphrase` | Verify a passphrase actually decrypts a stack, rather than merely exiting 0 |
| `sync-declared-secrets` | Regenerate the committed graph of secrets a repo's own models declare |
| `doctor` | Report the resolved configuration and environment |

`credentials` is what fills the encrypted, profile-scoped store the three
session commands read from. A value never reaches it as an argument:
`credentials set` reads `KEY=VALUE` lines on stdin — the format of the `.env`
file it exists to replace — or prompts for each name without echo. Nothing it
prints is ever a value: `list` prints profile names, `show` prints the
credential *names* an envelope carries, and neither prints what is beside
them.

`set` seals **every** name it is given, while `exec`/`shell` inject only the
three they need — so a `.env` carrying more than those three has the extras
sealed too. That is deliberate: a real `.env` holds more, and refusing it
would break the very workflow this command exists for. `show` names any such
extra, so it is visible rather than merely true.

```sh
stackward credentials init # once, per machine
stackward credentials set --profile NAME < .env # every name in it; then rm .env
stackward credentials show --profile NAME # names only, never values
```

## Design commitments

Expand All @@ -38,9 +62,23 @@ These are constraints, not aspirations — each has a test.
mechanism; which keys are sensitive, which paths are managed and which model
declares them are all read from the repo's `.stackward.toml`. The tool ships
no defaults about anyone's infrastructure.
- **No backend assumptions.** A profile carries a Pulumi backend URL and it is
passed through, so `s3://`, `gs://`, `azblob://` and `file://` all work. No
storage vendor is named anywhere in this codebase.
- **A backend URL is passed through, not interpreted.** A profile carrying
`backend_url` gets exactly that string handed to Pulumi, so `s3://`, `gs://`,
`azblob://` and `file://` all work without this tool knowing which is which.
The exception is the optional *component* form (`bucket`, `prefix`,
`endpoint`, `region`), which is a convenience for S3-compatible storage and
can only ever compose an `s3://` URL — use `backend_url` for anything else.
- **Nobody's own infrastructure is named here.** No bucket, region, endpoint,
account, domain, namespace or model class appears anywhere in this
repository, and the hook and CI job described under *Development* below are
what enforce that. A few de-facto-standard names do appear, and none of them
is a choice this tool makes: `exec` and `shell` inject `AWS_ACCESS_KEY_ID`
and `AWS_SECRET_ACCESS_KEY`, because that is the vocabulary Pulumi's own DIY
backend reads out of a child process's environment; they also *remove*
`AWS_SESSION_TOKEN` and `AWS_PROFILE` from the child, because either one
sitting beside a freshly injected key pair contradicts it and the resulting
failure names neither; and the component form above composes an `s3://` URL,
because that is the scheme Pulumi uses for every S3-compatible store.
- **Values reach the Pulumi CLI on stdin**, never in a command line, so they do
not appear in `ps` or in shell history.
- **Failures are closed.** If the tool cannot determine whether a file is safe,
Expand Down
Loading
Loading