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
102 changes: 97 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,67 @@ jobs:
timeout-minutes: 3
run: sudo apt-get update && sudo apt-get install -y tmux

- name: Install sops and age
# internal/sops' integration tests drive the real sops binary: they
# mint an age identity, encrypt a fixture, and assert the measured
# behaviour `env set --sops` is built on — the exit status for an
# unchanged file, the absence of a trailing newline from
# `--extract --output`, and that untouched values keep byte-identical
# ciphertext. None of that is checkable without the binary.
#
# Installed from the release tarballs rather than apt, because Ubuntu's
# archive carries neither at a usable version.
#
# Bounded for the same reason the tmux step is: a download step that
# stops making progress leaves a PR with no Linux signal while reading
# as "CI is slow".
# Both downloads are CHECKSUM-VERIFIED before anything is installed.
# `sudo install` runs the asset as root's problem thereafter, so a
# swapped or compromised release would execute in CI with nothing
# between it and the runner. Pinning the tag alone does not help: a tag
# can be moved.
timeout-minutes: 3
run: |
set -euo pipefail
curl -fsSL -o /tmp/sops \
https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.linux.amd64
echo 'e5bec3346a873ae91d871550f3e698c1aad962aff462a080e40f25fde17fef6b /tmp/sops' | sha256sum -c -
sudo install -m 0755 /tmp/sops /usr/local/bin/sops

curl -fsSL -o /tmp/age.tgz \
https://github.com/FiloSottile/age/releases/download/v1.2.1/age-v1.2.1-linux-amd64.tar.gz
echo '7df45a6cc87d4da11cc03a539a7470c15b1041ab2b396af088fe9990f7c79d50 /tmp/age.tgz' | sha256sum -c -
tar -xzf /tmp/age.tgz -C /tmp
sudo install -m 0755 /tmp/age/age /usr/local/bin/age
sudo install -m 0755 /tmp/age/age-keygen /usr/local/bin/age-keygen

sops --version --disable-version-check
age-keygen --version

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

- name: Test
# FORGECTL_REQUIRE_TMUX turns those tests' skips into failures: on a
# runner where they are meant to run, a skip and a pass must not look
# alike.
# Each FORGECTL_REQUIRE_* turns a tool-absent skip into a failure: on a
# runner where these tests are meant to run, a skip and a pass must not
# look alike. The env gate is what enforces that, not the install step
# above — a step is a file anyone can edit in a PR, and deleting it
# would otherwise just make the tests quietly stop running.
env:
FORGECTL_REQUIRE_TMUX: '1'
FORGECTL_REQUIRE_SOPS_INTEGRATION: '1'
run: go test ./...

- name: Real-sops integration tests (named)
# Same tests, -v: the run log naming each Integration test as PASS is
# the readable proof they executed rather than skipped.
env:
FORGECTL_REQUIRE_SOPS_INTEGRATION: '1'
run: go test -v -count=1 -run Integration ./internal/sops

- name: Real-tmux grammar tests (named)
# Same tests, -v: the run log naming each *Isolated test as PASS is the
# readable proof they executed rather than skipped.
Expand Down Expand Up @@ -146,12 +193,57 @@ jobs:
timeout-minutes: 5
run: brew list tmux || brew install tmux

- name: Install sops and age
# Darwin is where sops' behaviour was measured — the exit status for an
# unchanged file, the absent trailing newline from `--extract`, and the
# unbounded editor re-invocation loop — so it is the platform whose
# integration run is worth the most.
#
# NOT brew, unlike the tmux step above. This is a self-hosted runner
# whose Homebrew prefix is owned by another user: `brew install sops`
# fails with "The following directories are not writable by your user:
# /opt/homebrew". The tmux step survives only because tmux is already in
# the image, so its `brew list` short-circuits and `brew install` never
# runs. Anything NOT already installed is unreachable through brew here.
#
# So the release assets are installed into a runner-owned directory and
# that directory is prepended to PATH for later steps. Same
# checksum-before-install discipline as the ubuntu job, and the same
# reason: pinning the tag alone does not help, because a tag can move.
#
# The sops digest is from the publisher's own
# sops-v3.13.3.checksums.txt. age publishes no checksums file (only
# sigstore .proof files), so its digest was computed locally from the
# release asset — the same basis as the linux digest in the ubuntu job.
timeout-minutes: 5
run: |
set -euo pipefail
mkdir -p "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

curl -fsSL -o /tmp/sops \
https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.darwin.arm64
echo 'b97c0d434aab577dc40310e8d22ff9e45eef4c80638ab978daae9b4681c59286 /tmp/sops' | shasum -a 256 -c -
install -m 0755 /tmp/sops "$HOME/.local/bin/sops"

curl -fsSL -o /tmp/age.tgz \
https://github.com/FiloSottile/age/releases/download/v1.2.1/age-v1.2.1-darwin-arm64.tar.gz
echo 'cf79875bd5970dc2dac60c87fa50cee1ff1f9a41b0eb273f65e174aff37c367a /tmp/age.tgz' | shasum -a 256 -c -
rm -rf /tmp/age
tar -xzf /tmp/age.tgz -C /tmp
install -m 0755 /tmp/age/age "$HOME/.local/bin/age"
install -m 0755 /tmp/age/age-keygen "$HOME/.local/bin/age-keygen"

"$HOME/.local/bin/sops" --version --disable-version-check
"$HOME/.local/bin/age-keygen" --version

- name: Test (Darwin-gated coverage)
# -v is deliberate: the run log naming each formerly-skipped test as
# PASS is the proof this coverage exists. FORGECTL_REQUIRE_TMUX makes a
# missing tmux a failure rather than a silent skip.
# PASS is the proof this coverage exists. Each FORGECTL_REQUIRE_* makes
# a missing tool a failure rather than a silent skip.
env:
FORGECTL_REQUIRE_TMUX: '1'
FORGECTL_REQUIRE_SOPS_INTEGRATION: '1'
run: go test -v ./...

helper:
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ forgectl env get KEY --clipboard [--file .env] # value to clipboar
forgectl env check [--file .env] [--example .env.example] # missing/extra keys, names only (see docs/commands/env.md for exit codes)
forgectl env redact [--file .env] # print file with values masked ****
# --file must name an env file (.env, .env.*, *.env); --any-file overrides, TTY-confirmed only
forgectl env set a.b.key --sops [--file secrets.sops.yaml] # one key into a SOPS-encrypted YAML file
# --sops takes a dotted path, defaults to secrets.sops.yaml at the repo root, and
# requires sops on PATH. The target must BOTH be named *.sops.yaml / *.sops.yml /
# *.enc.yaml / *.enc.yml / secrets.yaml / secrets.yml / secrets.*.yaml /
# secrets.*.yml AND carry a top-level sops: block; there is no --any-file escape.
# Untouched values keep byte-identical ciphertext, so the diff is the one key you set
# plus sops' own lastmodified and mac. Add *.sops.yaml.lock to .gitignore — the lock
# helper leaves a non-secret sibling behind by design.

# branch — prune stale/orphaned git branches (alias: br)
forgectl branch # dry-run report: local + remote branches, classified against
Expand Down
40 changes: 40 additions & 0 deletions docs/commands/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,47 @@ forgectl env get KEY --clipboard [--file .env] # value to clipboar
forgectl env check [--file .env] [--example .env.example] # missing/extra keys, names only
forgectl env redact [--file .env] # print file with values masked ****
# --file must name an env file (.env, .env.*, *.env); --any-file overrides, TTY-confirmed only

forgectl env set a.b.key --sops [--file secrets.sops.yaml] # one key into a SOPS-encrypted YAML file
# dotted path, arbitrary depth; defaults to secrets.sops.yaml at the REPO ROOT
# requires `sops` on PATH (`forgectl doctor` reports its version)
```

## `--sops` — writing into a SOPS-encrypted file

The same guarantee as the `.env` path, for SOPS YAML: the value arrives on piped stdin, a no-echo prompt, or `--clipboard`, and never enters an argv, terminal output, or a transcript.

The two obvious alternatives both fail that. `sops set file '["a"]["b"]' '"value"'` puts the plaintext in argv — visible in `ps`, left in shell history. `sops file` opens `$EDITOR` on the whole decrypted document, which is a lot of exposed plaintext to paste one line into.

**How it works.** `sops <file>` decrypts to a temp file, runs `$EDITOR`, and re-encrypts whatever comes back. forgectl sets `EDITOR` to itself (a hidden `__sops-edit` subcommand), passes the key path in the environment, and passes the value as a **file whose path** is in the environment. The value itself never enters an environment or an argv.

**The diff is reviewable, deliberately.** The edit is line-wise text, not a YAML round-trip: re-emitting the document would reflow every block and reorder keys, and in an encrypted file every reflowed line is a ciphertext change. Untouched values keep byte-identical ciphertext, so a replace changes 3 lines — the value plus sops' own `lastmodified` and `mac` — and an add changes 2 and removes 1.

**Success means it landed encrypted.** After the write, forgectl decrypts the value back and compares it byte-exactly, *and* re-parses the ciphertext to confirm the scalar at that exact path carries an `ENC[AES256_GCM,` marker. The second check is not redundant: a value stored in cleartext round-trips through a decrypt perfectly well, so a round-trip alone cannot detect it.

**Target rules — both must hold, and there is no escape hatch:**

- the filename matches `*.sops.yaml`, `*.sops.yml`, `*.enc.yaml`, `*.enc.yml`, `secrets.yaml`, `secrets.yml`, or `secrets.*.yaml`/`.yml`
- the file content carries a top-level `sops:` mapping

`--any-file` is refused with `--sops` rather than silently ignored. A SOPS file under some other name is unreachable — that is a deliberate refusal, not a gap: the alternative is an interactive confirmation, and the confirmation path is where a time-of-check/time-of-use defect lived. Renaming the file costs less than that surface.

**What it refuses, and why refusing is the right answer:**

| Refusal | Reason |
|---|---|
| A missing block, at any depth | A block forgectl invented would encrypt fine and the consumer would read nothing from it |
| A path whose key *or any ancestor* falls outside the file's encryption rules | sops would write the value in **cleartext** beside its encrypted siblings — measured live with `unencrypted_suffix` in force |
| A path naming a block rather than a scalar | Writing a scalar over a mapping header strands its children |
| A dotted key *name* | `a.b.c` cannot distinguish `{a, b.c}` from `{a, b, c}`; escaping is a surface for a case no estate file has |
| The top-level `sops` block | It holds the file's own recipients, MAC, and rules |
| A value with a newline, a C0 control byte other than tab, or invalid UTF-8 | YAML forbids these in a scalar, and the resulting unparseable document makes sops re-invoke its editor **without bound** |
| A document shape the line model cannot bound | A sequence where a mapping was expected, tab indentation, a multi-document stream, a header with a trailing comment — each would mis-place the key and corrupt the file silently |

**Out of scope:** reading or listing SOPS values, creating a missing file or block, non-scalar values, and key rotation or recipient management.

**Gitignore `*.sops.yaml.lock`.** The lock helper leaves a non-secret sibling beside whatever it locked, by design.

**`env check`'s exit codes are part of its contract, not incidental:** exit `1` means the file and its example both exist but disagree — missing and/or extra keys (drift); exit `2` means either the env file or the `--example` file is absent, so no comparison could run at all. `env check --json` emits the drift as a single object on stdout, `{"missing":[...],"extra":[...]}`, for scripted callers.

**Blessed value producers** for `env set`, non-inline patterns first:
Expand All @@ -35,6 +74,7 @@ forgectl env set API_KEY # interactive, no ech
Two things changed. Resolution happens exactly once, and its result travels as a value rather than a boolean, so the path a human confirms is the path that gets written. And that value carries an **open descriptor on the containing directory**, pinned at resolution, with every read, write, and rename performed relative to it — so no later operation re-walks the path by name. That second half is what closes the interesting case: a fix that carried only the path still let an *intermediate directory* be swapped during the confirmation, which redirected the write exactly as the original bug did.

**What remains:** the directory is pinned by path immediately after resolution, so its own components are walked once more at that instant — a window of microseconds rather than of operator think-time, and the same ordinary same-uid local race that predates this command. Closing even that would need a component-by-component walk from the repository root.
- **`--sops` widens the authority `env set` grants, and the paragraph below predates it.** Granting a session `env set` now also grants write authority over repo-contained **SOPS documents** — a materially larger thing than a `.env`, because a SOPS file typically holds production credentials rather than local development ones. The bounds are the same in shape (repo containment, a filename allowlist, a content check) and there is no `--any-file` override on that route, but the *blast radius* of the authority is bigger. Grant it deliberately.
- **Agent-write threat model, one line:** running `env set`/`env get` under an agent grants that agent write authority over repo-contained **env files** for the duration of the session — containment (refuses outside the git repo), the env-file-name rule (below), 0600 permissions, and atomic writes bound the blast radius, but they don't remove the authority itself. The two subcommands grant distinct authorities: `env set` is **write** authority (the agent can create or overwrite a key in the file); `env get --clipboard` is **read/exfil** authority (the agent can copy an existing secret to the clipboard, where — see the residual-risk note above — any local process or clipboard manager can then read it too). Granting one does not imply granting the other.

**Safety notes:**
Expand Down
Loading
Loading