sneak/secret now carries its own copy of go-bip39
(sneak/secret#130), so requiring it at that
commit, ef0ae90, lets go mod tidy drop go-bip39 from go.sum. Earlier,
secret renamed its module to sneak.berlin/go/secret
(sneak/secret#43), so the requirement, the
two imports of its bip85 package and the README sentence naming that
package move to the new path. The bip85 functions keyfunc calls are
unchanged, and no other requirement moves.
Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
go-bip39's repository no longer exists, so keyfunc now carries the
part of v1.1.0 it uses, with the upstream LICENSE beside it, and
imports it from internal/bip39. Upstream's tests and test vectors for
the kept code come along unchanged; where they called a removed
function, crypto/rand stands in for NewEntropy and EntropyFromMnemonic
for MnemonicToByteArray.
Changes beyond the trimming are what the linter asked for: the
package-level variables moved into the functions that use them, three
error strings were lower-cased, and the unknown-word error now wraps
ErrInvalidMnemonic.
go.mod no longer requires go-bip39. go mod tidy keeps its two go.sum
lines, because a test in sneak/secret's bip85 package imports it, and
drops six lines that only go-bip39's own requirements needed.
Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
Every file keyfunc copies from sneak/prompts now matches its next branch at commit dd4027b907ef99cdc3187c215cc4d610b7a11efc. REPO_POLICIES.md is the current copy; .gitignore and .dockerignore now ignore id_ecdsa_sk and id_ed25519_sk, the private key files ssh-keygen writes for hardware-backed keys, and .dockerignore keeps out a .git/config at any depth. The other copied files already matched; no adapted file needed a change.
Model: opus-5-5
package.json now carries "license": "MIT", as the sneak/prompts template does since 2026-10-04, so yarn install in script/bootstrap no longer warns about a missing license field on every image build.
Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
.dockerignore no longer keeps .gitea out of the build context. The development image gets .git, so without the workflow directory git status in /src reported the CI workflow as deleted, make build stamped a -dirty version, and git commit -a would have deleted the workflow. Now /src is a clean checkout and make build there stamps the short commit. The comment above /keyfunc now names only the binary.
Model: opus-5-5
ssh install now takes the host alone before --: any other word there, or a second argument without --, is refused before the mnemonic is read or sftp runs, so keyfunc ssh install alice@host frank@host no longer installs the key for frank@host. The first listing of ~/.ssh is now ls -n, which shows the file type, so a symlinked authorized_keys is refused before any upload instead of being replaced by a regular file; the README says so.
Judgement calls: install -- host is refused; a symlinked authorized_keys is refused even when its target already holds the key.
Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
age encrypt -o and age decrypt -o now look at the -o path before writing. A path that is the same file as the tool's standard output or standard error, under any name, is written to that stream, so a redirected file keeps its contents, inode and mode. A new path or a regular file is written beside it and renamed over it, as before, with the signal handling of #48. A symlink gets the same rule for what it points at, so the link survives; a dangling one is refused. A named pipe or a device is written directly. The README says a replaced file gets mode 0600.
Rule suppressed: gosec G304 on the direct open of the -o path.
Model: opus-5-5
A signal during ssh install now sends sftp a SIGTERM instead of killing it, as ssh to already does for ssh, so sftp stops the ssh it started. The sftp command also has a WaitDelay of a quarter second, so a child that still holds sftp's output cannot keep the tool waiting on a host that does not answer: it ends with status 1 and removes its working directory. The test's sftp stand-in now leaves such a child.
Judgement call: exec.ErrWaitDelay counts as success, so a run that used ControlPersist does not report a failure for a key it added.
Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
SIGINT, SIGTERM and SIGHUP are no longer caught for the whole run, so they end any command at once, the mnemonic prompt included. One package, internal/cli/signals, catches them only where cleanup is needed, and only those not ignored at start, so nohup still works: ssh to and ssh install while their child runs, and age encrypt -o and age decrypt -o while they write. A signal received by the time the input ends leaves no new file and exits 1; otherwise the whole file is put in place, never an unfinished one.
Judgement call: the guarantee is stated for a signal keyfunc has received, as Go cannot promise more; the main goroutine stays on the main thread so a Ctrl-C on a pipeline is seen first on Linux. Unverified on macOS.
Model: opus-5-5 (implementation); fable-5-1 (design)
Brings keyfunc to the current sneak/prompts templates: REPO_POLICIES.md and .golangci.yml are the template copies, the lint phase runs golangci-lint v2.14.0 on the template digest (its one new finding fixed), and .dockerignore gains the template line for submodule configs. The stage that compiles keyfunc marks /src safe for git, so a context sent as a tar stream still stamps the tag or short commit. The last stage is now a development environment, as the policy asks of a non-server repo: run the tool as docker run IMAGE keyfunc .... The CI checkout fetches tags.
Deviation: .gitea/workflows/check.yml differs from the template copy by fetch-depth: 0, which REPO_POLICIES.md requires.
Model: opus-5-5
The README now gives the child mnemonic keyfunc prints for the abandon ... about test mnemonic at index 0, checked by the README vectors test; the BIP-85 specification vector stays, marked as starting from a master key keyfunc cannot take. It also says ssh install needs the host key in known_hosts already, and how to get round that. ssh install now also lists ~/.ssh/. on its first connection and refuses, before any upload, a ~/.ssh it can read but not enter, which sftp shows as empty; a file where ~/.ssh belongs is refused the same way.
Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
The BIP-39 seed was computed over the mnemonic string as given, with only its ends trimmed, so the same words one per line, tab-separated or double-spaced passed the checksum but gave different keys with no warning. The words are now joined by single spaces before the checksum and the seed, for every source: the mnemonic command, both environment variables and the prompt. Single-spaced input gives the same keys as before; a test checks the README vector for each spacing.
Model: opus-5-5
make fmt and make fmt-check now cover the Markdown files with prettier as well as the Go source: prettier 3.8.1 pinned in package.json and yarn.lock, four-space indents and proseWrap always in .prettierrc, all three copied unchanged from the sneak/prompts templates. script/bootstrap adds pinned node (through nvm from a hash-checked archive when none is installed), yarn and prettier after the pinned Go. README.md is reformatted by make fmt and its Entrypoints section says what each step needs.
Model: opus-5-5
script/cibuild runs script/bootstrap on the Gitea runner, which has Docker and git but no Go, and bootstrap could not install it: its apt path never ran apt-get update. Bootstrap now installs Go at the version in the Dockerfile's golang image from go.dev, checked against sha256 values in the script, into ~/.local/go whenever the go first on PATH reports another version; the Makefile and the fmt, fmt-check and precommit scripts put ~/.local/go/bin first on their PATH. apt-get update runs once before the first apt install. Bumping the golang digest now means bumping GO_VERSION and its four hashes.
Partially verified: checked in a clean ubuntu:24.04 container, not yet on the Gitea runner.
Model: opus-5-5
Lint and test are now phases of the one Dockerfile, as the current repo policy requires: a lint phase on the pinned golangci-lint image and a test phase on the pinned Go image, and the build stage depends on both, so a plain docker build . fails when either fails. Dockerfile.lint is gone. REPO_POLICIES.md and script/lint, test, docker and cibuild are byte-identical to the current sneak/prompts copies, so every docker build in script/ is uncached and tagged. make test now needs Docker on the host; formatting is checked on the host only.
Judgement calls: the test phase installs gcc and musl-dev unpinned for -race; no -count=1, since a build stage holds no earlier result.
Model: opus-5-5
.golangci.yml is now a byte-identical copy of the current template: depguard is on with the test-support rule, and the gomodguard_v2 block list is in. .gitignore and .dockerignore are the current templates with this repo's own entries added at the end; the .dockerignore secret-file patterns now keep key files and .env files out of the build context, while .git stays in for the version stamp. No Go source needed changes.
Model: opus-5-5
The module path becomes sneak.berlin/go/keyfunc, as the repo policy sets for Go modules: go.mod, every import and the -X path in the Makefile. The old path gets no alias. The README gives a go install line for the new path, which resolves with @latest only once main carries the move, and its TODO list now names the open 1.0 issues.
Model: opus-5-5
Adds LICENSE with the standard MIT text and "Copyright (c) 2026 sneak", the license sneak chose. The README now calls keyfunc MIT-licensed in its first paragraph, its License section points at LICENSE, and the license line leaves the TODO list.
Model: opus-5-5
.dockerignore left out .git, so make build inside the image fell back to
"dev". The build context now carries .git, without its config, which can
hold a credential in the remote URL. The build stage takes the VERSION
build argument when one is given, otherwise git describe --tags --always,
and fails if the context carries .git and no version comes out.
Model: opus-5-5
Every example in the README was run as written with the published test mnemonic and behaved as the README says, so no sentence changed. The only edit removes the landed work from the TODO section, which now lists the two open owner decisions.
Disclosures:
- `ssh install` and `ssh to` were run by the implementer against a throwaway local `sshd`; the reviewer could not repeat that run and checked those sections by reading the code.
- The child mnemonic vector is reachable only through the test suite and was confirmed there.
Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
golangci-lint 2.12 deprecated `gomodguard` in favour of `gomodguard_v2` and printed a warning on every `make check`. `.golangci.yml` now disables the old name, the same way it already handles `wsl` and `wsl_v5`. With `linters.default: all` the replacement was already enabled, so what is checked does not change; only the warning goes.
Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
`cli.Main` ran the command tree on a background context, so SIGINT, SIGTERM or SIGHUP killed the process before deferred cleanup ran: `ssh to` left its agent socket and directory behind, and `ssh install` left a copy of the host's `authorized_keys` in its working directory. `Main` now runs the tree on a `signal.NotifyContext` for those signals; the cancelled context ends the child `ssh` or `sftp` and the cleanup runs. `ssh to` stops its child with SIGTERM, not a kill, so `ssh` restores the terminal. Exit status after a signal is 1 unless `ssh` reported its own.
The test re-runs the test binary as the tool, waits for the agent socket, sends each signal and checks the directory is gone.
Disclosure: the repeated `"uptime"` test literal became a `remoteCommand` constant because `goconst` required it.
Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
The README gains the sections REPO_POLICIES.md requires: a first sentence naming the category and author, Getting Started, Entrypoints (one line per `script/` file), Rationale, Design, TODO (the open issues between the tree and 1.0), License and Author. It also publishes test vectors for age and child mnemonics, copied from the tests.
Disclosures:
- No license is named; the choice is open on the tracker and the README says so.
- The 12-word child mnemonic is the BIP-85 specification vector, the only one the test asserts, and is labelled as such.
- Markdown is hand-wrapped; `make fmt` here formats Go only.
Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
`keyfunc ssh to` and `keyfunc ssh install` started the system `ssh` and `sftp` with the tool's whole environment, so a mnemonic given in `KEYFUNC_MNEMONIC` stayed readable in the child's environment and could be forwarded to the host by a `SendEnv` line. Both children now get the environment with `KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` removed, through one helper, `childEnv`, in the ssh cli package. The mnemonic command still runs with the full environment. Two tests drive the real commands against the stand-in `ssh` and `sftp` and check that a third variable still arrives.
Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
The first sftp session now lists .ssh before fetching authorized_keys. The file reads as empty only when sftp reports .ssh itself as missing, or the listing succeeded and the file is reported missing. A directory or file that is there but cannot be read fails the run and nothing is written, so no existing authorized_keys is replaced by content that was not built from what was read. An .ssh that already exists keeps its mode; the directory is made and set to 0700 only when none was found. The README describes the rule and states batch mode's limit: a key or an agent must authenticate.
Model: opus-4-8 (implementation); fable-5-1 (summary)
keyfunc --version printed dev for any binary not built with make build. When no version was stamped at build time, the tool now reports the module version recorded in the binary's build info, which go install fills in. A stamped version still wins, and a local build with neither still prints dev.
Model: opus-4-8 (implementation); fable-5-1 (summary)
Every direct dependency moves to its current release, golang.org/x/crypto first: keyfunc ssh to serves keys through its ssh/agent package, which has had security fixes since the pinned 2025-05 version. go.mod and go.sum only; no code changed and the SSH, age and child mnemonic test vectors pass unedited, so no derived key moves.
Model: opus-4-8 (implementation); fable-5-1 (summary)
main.go moves unchanged to cmd/keyfunc/main.go, where REPO_POLICIES.md puts Go entrypoints, and the Makefile build target builds ./cmd/keyfunc. The binary is still written to ./keyfunc and still carries the stamped version.
Model: opus-4-8 (implementation); fable-5-1 (summary)
ssh install no longer runs a command on the host. It reads .ssh/authorized_keys over sftp, takes the empty reading only from sftp's own message about that path, appends the derived key locally when it is not already present, uploads the result beside the file with mode 0600 and renames it over the original. Any other failure prints what sftp said, writes nothing and exits 1. sftp batch mode disables password prompts, so a key or agent is required; a directory the owner cannot enter reads as a host with no file, which README.md states.
Model: opus-5 (implementation); fable-5-1 (landing)
keyfunc ssh install appends the public line on a host through the system ssh, only when absent, feeding the line on standard input; keyfunc ssh to serves the derived key from an in-process agent on a private socket and runs the system ssh with it, the private key never on disk. Two review rounds; the second passed with no findings.
Model: opus-5 (implementation and review); fable-5-1 (landing)
keyfunc age derives an age identity at the generic path the way secret's agehd does, prints the recipient or the identity, and encrypts to or decrypts with it, the derived recipient always among encrypt's recipients. Two review rounds; the second passed with no findings, the clamping step now pinned by a fixed identity test.
Model: opus-5 (implementation and review); fable-5-1 (landing)
keyfunc mnemonic derives a 12, 18 or 24 word child mnemonic through BIP-85's own mnemonic application, with the specification's test vectors as tests. One review round, passed with no findings; the reviewer reproduced the vectors from an implementation written from the specification alone.
Model: opus-5 (implementation and review); fable-5-1 (landing)
The module, the script entrypoints and Makefile, Docker-only linting, the mnemonic sources in the specified order with their refusals, the BIP-85 derivation, and keyfunc ssh pub and priv with the README test vectors as tests. Two review rounds; the second passed with no findings.
Model: opus-5 (implementation and review); fable-5-1 (landing)
The README is the specification sneak approved on 2026-09-07, moved
here from the hacks repository: deterministic SSH keys, age identities
with encrypt and decrypt, and child mnemonics, all derived from one
BIP-39 mnemonic and stored nowhere.
Model: fable-5-1