Skip to content

feat!: bind provider tools locally instead of in the tracked scope file - #19

Merged
philband merged 2 commits into
mainfrom
feat/local-tool-binding
Aug 2, 2026
Merged

philband merged 2 commits into
mainfrom
feat/local-tool-binding

Conversation

@philband

@philband philband commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Why

.dotenv-sec.yaml is tracked and trust-hashed, yet schema 1 stored three machine-specific values in it: sops_executable (absolute path), sops_sha256 (per OS/arch/version), and plugin_path (absolute PATH list, typically containing a home directory).

That produced the two failures reported:

  • Upgrades break. brew upgrade sops → sops checksum mismatch on allow. The only fix was editing a tracked file, which then broke the scope for everyone else.
  • Cross-platform is unrepresentable. One committed file cannot describe macOS-arm64 and Linux-amd64 at once — the schema allows one string per key. A colleague resolving the conflict can only paste whatever checksum their machine produced, which is a merge conflict with a hash in it, not an integrity property.

It also contradicted the project's own documented invariant. docs/provider-protocol.md states repository configuration cannot provide executable paths or inherited environment, but run.go fed the repo's plugin_path into the provider's PATH and the SOPS provider took the repo's sops_executable as the binary to run. docs/threat-model.md anchors trust in the per-user registry and explicitly excludes a compromised SOPS from scope — so the checksum's real job is stopping the repository from selecting an executable. Storing it in the repository inverted that.

What changed

Scope schema 2 holds portable policy only, plus an optional sops_min_version floor. The binding moves to the local provider registry:

providers:
  sops:
    executable: /opt/homebrew/Cellar/dotenvsec/0.7.0/bin/dotenvsec-provider-sops
    sha256: ...
    plugin_path: /Users/example/.cargo/bin:/opt/homebrew/bin
    tools:
      sops: {executable: /opt/homebrew/Cellar/sops/3.13.3/bin/sops, sha256: ...}

Protocol v2 carries tools and identity_path as dedicated fields the parent overwrites from the registry immediately before dispatch, so a repository-supplied value cannot reach them. The provider fails closed instead of falling back to a PATH lookup.

Tool checksums are verified before every launch but deliberately stay out of the approval hash — re-approving after a routine brew upgrade sops adds friction without review value. Drift surfaces in doctor.

Three defects found while implementing

  • Homebrew age plugins reported missing. Plugin discovery rejected every symlink — which is how Homebrew publishes all binaries — while doctor used a weaker os.Stat check that followed them. So doctor passed while loading failed. Both now share one resolver that canonicalizes and checks the target. This is the issue reported by the user on a Homebrew-installed age-plugin-yubikey.
  • Re-registering a provider silently dropped its tool bindings. Any upgrade moving the bundled executable would have unbound SOPS and stranded every scope on that machine.
  • ValidateExactEnvironment did not name the offending variable, so diagnosing provider output names do not match declared environment meant reading the encrypted document's keys. Those names are already cleartext in the scope file and the ciphertext keys; values still never appear.

Migration

dotenvsec migrate            # per scope, from the main worktree
git diff .dotenv-sec.yaml    # review, then commit in repository mode
dotenvsec provider retool sops sops "$(command -v sops)"   # per machine
dotenvsec allow

migrate refuses to run twice. The trust hash changes, so every machine re-approves once.

Verification

go build, go vet, gofmt clean; all 10 packages pass. New coverage: migration (incl. non-idempotence), machine-local key rejection, version floor, Homebrew symlink resolution, unsafe-target rejection, binding preservation across re-registration, mismatch-naming without value leakage.

Exercised end-to-end against a copy of a real 6-recipient scope in an isolated HOME: schema 1 refused → migrate → allow → doctor all-OK. A simulated SOPS upgrade (corrupted pin) fails closed with tool sops checksum mismatch: … re-pin with dotenvsec provider retool, recovers via one local command, and leaves the scope file and approval untouched.

One behaviour was corrected mid-review: verifying tools inside Verify broke status and doctor themselves under a stale binding — the very commands needed to diagnose it. Tools are now verified at dispatch, so doctor reports FAIL SOPS <reason> and read-only commands keep working.

BREAKING CHANGE: scope schema 2 and provider protocol v2.

Schema 1 stored sops_executable, sops_sha256, and plugin_path in
.dotenv-sec.yaml, a tracked and trust-hashed file. Three consequences: a
routine sops upgrade failed the checksum and required editing a tracked
file, which then broke the scope for every other machine; one committed
file could not describe more than one OS or architecture, so cross-platform
use was unrepresentable; and a colleague resolving the conflict could only
paste in whatever checksum their own machine produced, which is not an
integrity property.

It also contradicted the documented protocol invariant that repository
configuration cannot provide executable paths or inherited environment:
run.go fed the repository's plugin_path into the provider's PATH, and the
SOPS provider took the repository's sops_executable as the binary to run.
The threat model anchors trust in the per-user registry and excludes a
compromised SOPS from scope, so the checksum's real job was preventing the
repository from selecting an executable. Recording it in the repository
inverted that.

Scope schema 2 keeps portable policy only, with an optional sops_min_version
floor. The binding moves to the local provider registry as plugin_path plus
tools{executable,sha256}. Protocol v2 carries tools and identity_path as
dedicated fields that the parent overwrites from the registry immediately
before dispatch, so a repository-supplied value can never reach them; the
provider fails closed rather than falling back to a PATH lookup. Existing
scopes convert with dotenvsec migrate, then re-approve.

Fix three defects found while implementing this:

Homebrew-installed age plugins were reported missing. Plugin discovery
rejected every symlink, which is how Homebrew publishes all binaries, while
doctor used a weaker os.Stat check that followed them, so doctor passed
while loading failed. Resolution now canonicalizes and checks the target,
shared by both paths. Tool bindings canonicalize on write for the same
reason, keeping the checksummed file and the executed file identical.

Re-registering a provider silently discarded its tool bindings, so any
upgrade that moved the bundled executable would have unbound SOPS and
stranded every scope on the machine.

ValidateExactEnvironment reported a name mismatch without naming it,
requiring the encrypted document's keys to be read to find the offender.
The names are already cleartext in both the scope file and those keys, so
they are now reported; values still never appear.

BREAKING CHANGE: scope schema 2 and provider protocol v2. Run dotenvsec
migrate per scope, then dotenvsec allow on every machine.
Version comparison discarded the fmt.Sscanf error return, which errcheck
rejected. Parsing with strconv and surfacing the failure also removes the
silent-zero behaviour: unparsable input is now an error rather than a value
that could satisfy a version floor by accident.
@philband
philband merged commit 1f01f71 into main Aug 2, 2026
7 checks passed
@philband
philband deleted the feat/local-tool-binding branch August 2, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant