Reference snapshot · source revision 7a2e36f2b250934a095e542ae20e62dcfb0d5f70
SPEC-CONFIG-SECURITY — repo-vs-user config trust split
Status: Normative. Companion to docs/THREAT-MODEL.md §4 and
docs/specs/SPEC-KEYSTORE.md §8.2. Audience: anyone adding a new config
key, anyone reviewing a change that touches libkit/src/config.rs,
and anyone writing a transport that consumes a config-derived
endpoint, key path, or credential.
This spec defines the per-key trust posture for every config knob
kit reads, and the enforcement contract that keeps a hostile cloned
repo from escalating into the user's signing identity, ambient
network credentials, or arbitrary process execution. It exists
because the underlying constant — REPO_FORBIDDEN_KEYS — is easy to
forget to extend when new keys are added; this document gives the
review test "is the new key on this list?" a single canonical answer.
1. Threat
The attacker is a hostile remote repo author (THREAT-MODEL §3.1).
They control every byte in the cloned tree, including
<repo>/.kit/config. They do NOT control
$XDG_CONFIG_HOME/kit/config.
Without enforcement, any repo-scoped knob that names a file path, selects a process to spawn, points at a network endpoint that will carry ambient credentials, or selects a private signing key would be attacker-controlled. This is the same confused-deputy shape closed in GHSA-001.
The defense is structural, not per-call: at the config READ site,
every key is classified as either repo-safe (SAFE) or user-only
(UNSAFE). UNSAFE keys appearing in <repo>/.kit/config are
dropped, and the loader reports each drop as an event. The kit command
prints the event as a warning on stderr. They are only honored when read from
$XDG_CONFIG_HOME/kit/config.
The user config directory is always an absolute path. The library reads no
environment variable. The program gives it an environment that holds the
XDG config directory, the home directory and the -c overrides. The kit
command fills them from XDG_CONFIG_HOME and HOME. The XDG config
directory must be absolute when set and non-empty. If it is unset or empty,
kit uses <home>/.config, and the home directory must be absolute.
Otherwise the command fails with an error. kit never falls back to a
relative path, so no working directory can supply user-scoped config or
trust roots.
The trust boundary is anchored in
libkit/src/config.rs::REPO_FORBIDDEN_KEYS. That constant is the
single source of truth — libkit is the only crate that performs
config-file I/O, so there is exactly one place to fence.
2. Per-key audit
The table below covers every key that apply_kv in
rust/crates/libkit/src/config.rs recognizes — the flat keys (in
source order), the dotted-section families (remote.<name>.*,
branch.<name>.*) handled via
apply_section_kv, and the _url-suffixed
slot. Rows marked "not yet implemented" classify keys
that a specification defines before apply_kv reads them; the patch
that implements such a key adds it to REPO_FORBIDDEN_KEYS per §5,
including a matcher for a remote.<name>.* pattern key.
| Key | Scope | Why this classification |
|---|---|---|
user.identity |
UNSAFE | Author identity bytes for commit objects. If repo-controlled, the attacker can spoof the author of a victim-signed commit (the victim's key still signs; only the name changes). |
trusted_remote_endpoint |
UNSAFE | The trust selector for ambient HTTP/S3 credentials. If repo-controlled, every other defense in this list collapses — the attacker would self-trust their own exfil endpoint. |
signer |
UNSAFE | Selects raw-file vs keystore commit signing. A flip from file to keystore routes signing through a user-scoped key reference the attacker did not pick — same shape as the GHSA-001 confused-deputy. |
key.backend |
UNSAFE | Selects the keystore backend family (for example, software vs yubikey). Could redirect signing to a hostile backend or surface a user-presence prompt for attacker-chosen content. |
key.default_ref |
UNSAFE | Selects the default private signing key reference. |
key.ed25519_ref |
UNSAFE | Selects the Ed25519 signing key reference. |
key.secp256k1_ref |
UNSAFE | Selects the secp256k1 signing key reference. |
key.p256_ref |
UNSAFE | Selects the P-256 signing key reference. |
signing_key |
UNSAFE | Raw-file key path. If repo-controlled, doubles as an arbitrary-file overwrite primitive when paired with auto-keygen (auto-keygen has since been removed; the path is still UNSAFE for the read direction). |
default_branch |
SAFE | UX default. No security weight — the victim still verifies signed history regardless of which ref the repo nominates as default. |
durability.objects |
SAFE | Object-store durability/fsync mode. A pure local-performance/safety tunable with no credential-routing, signing-selection, or process-spawning behavior; round-tripped and serialized in repo config. |
remote_endpoint |
SAFE with runtime gate | A pure address. Repo-scoped endpoints are accepted, but enforce_trusted_remote_endpoint refuses to send ambient KIT_API_TOKEN / KIT_S3_* credentials unless the user has explicitly listed the same endpoint under trusted_remote_endpoint. |
remote_bucket |
SAFE | Inert bucket-name slot. Not currently consumed by any transport; round-tripped only. |
remote_type |
SAFE | Dispatch hint (file / http / s3 / ssh). The exfil channel is the endpoint URL, not the dispatch label. |
ssh.strict_host_key_checking |
UNSAFE | Letting the repo disable host-key checking opens kit push to MITM. |
ssh.user_known_hosts_file |
UNSAFE | The source of trust for SSH host-key verification. |
ssh.identity_file |
UNSAFE | Selects which private key SSH presents. Same shape as signing_key. |
http.sslcainfo |
UNSAFE | Adds certificate authorities to the HTTPS trust roots. A repo-controlled value lets an attacker-run CA vouch for a server and intercept the connection. Same class as ssh.user_known_hosts_file. |
admission_helper |
UNSAFE | Names an executable the client runs when a remote answers with an admission challenge (SPEC-TRANSPORT-CONNECT §5.1). It spawns a process and can spend the user's money, so it is RCE plus a payment primitive if repo-controlled. Repository config MUST NOT set it. The client runs it only for a remote that passes the user-scoped trusted_remote_endpoint gate (§3.4). |
remote.<name>.admission_headers |
UNSAFE | Adds header names to the admission-helper allowlist of remote <name>. If repo-controlled, it could widen what a helper may attach to requests. It can never add a hard-reserved header (the auth v2 envelope headers, X-Write-Grant, X-Kit-*, X-Forwarded-*, Host, Content-*, Transfer-Encoding, Connect-*, Cookie, Idempotency-Key, hop-by-hop headers; full list in SPEC-TRANSPORT-CONNECT §5.1); an entry naming one has no effect. kit config SHOULD refuse to write a reserved name, and the load-time warning for one SHOULD use the fixed wording of SPEC-TRANSPORT-CONNECT §5.1. Unlike remote.<name>.url, this member of the remote.<name>.* family is user-scoped only. |
transport_auth |
UNSAFE | Selects whether network requests invoke the ambient signing identity. Repository config MUST NOT enable it. User-scoped envelope signing additionally requires exact trusted_remote_endpoint approval before signer resolution. |
attest.default_algorithm |
UNSAFE | Selector. Flipping from ed25519 to secp256k1 / p256 routes attestation signing to whichever non-Ed25519 key the user happens to have set up (confused-deputy). |
attest.signer |
UNSAFE | Selector. Flipping from repo-key to external or keystore weaponizes a user-scoped binary/keystore against attacker-chosen content. |
attest.external_signer_path |
UNSAFE | Arbitrary executable path → RCE under the user's UID. |
attest.external_signer_args |
UNSAFE | Argv for the spawned signer. Combined with the path, gives the attacker full control of the subprocess. |
attest.external_signer_timeout_secs |
UNSAFE | Timeout for the spawned external signer. Listed in REPO_FORBIDDEN_KEYS alongside the other attest.external_signer_* keys: a repo-controlled value could keep a hostile signer subprocess alive (or starve a benign one) and only makes sense under the same trust scope as the path/argv it governs. |
attest.secp256k1_key_path |
UNSAFE | Raw-file path for a secp256k1 signing key. |
attest.p256_key_path |
UNSAFE | Raw-file path for a P-256 signing key. |
grant.webauthn_rp |
UNSAFE | Pins the WebAuthn relying parties (<rp_id> <origin>..., repeatable) whose assertions kit grant create --webauthn-assertion and the user grant store accept as an owner signature (SPEC-WRITE-GRANTS §4.3). If repo-controlled, the attacker would choose which relying party vouches for an owner. Without a pin a webauthn-p256 import is refused, so this key fails closed. |
| user grant store | not a key | $XDG_CONFIG_HOME/kit/grants/<grant id>.grant (directory 0700, files 0600). Its location derives from the XDG base directory alone: there is no config key, flag or repository path that relocates it, and the store never reads a repository path. Every file is re-parsed and its owner signature re-verified on load, at most 1,024 files of at most 16 KiB each; a bad file costs one warning and is skipped. A planted file therefore cannot forge a grant, only fail to load. |
remote.<name>.url |
SAFE with runtime gate | Named-remote address. Same classification as remote_endpoint: each endpoint still flows through enforce_trusted_remote_endpoint, so a repo-controlled named remote cannot smuggle ambient KIT_API_TOKEN / KIT_S3_* credentials. |
remote.<name>.type |
SAFE | Dispatch hint for a named remote (file / http / s3 / ssh); same reasoning as remote_type. |
branch.<name>.remote |
SAFE | Per-branch upstream remote name. A UX/tracking pointer with no credential-routing or signing weight; the named remote it points at is still gated per-endpoint. |
branch.<name>.merge |
SAFE | Per-branch upstream ref. Inert tracking metadata; the victim still verifies signed history regardless of the recorded upstream. |
| Any other key | rejected | An unknown key is a load error, not a dropped line. See §3.5. |
2.1 Borderline cases
remote_type: borderline because a hostile flip fromfiletos3could change the transport's credential-handling surface given a user-trusted endpoint. The defense rests on the endpoint itself being the credential carrier —enforce_trusted_remote_endpointfences onremote_endpoint, not onremote_type. If a future transport ever decides credential routing fromremote_typeindependent ofremote_endpoint, this key MUST be reclassified as UNSAFE and added toREPO_FORBIDDEN_KEYS.remote_bucket: currently inert and SAFE. The same caveat asremote_typeapplies — if a future S3 transport ever signs requests usingremote_bucketindependent ofremote_endpoint, the key must be promoted to UNSAFE.default_branch: SAFE because kit verifies signed history regardless of which ref is nominated as default. A hostile clone pointingdefault_branch = mainat attacker-controlled commits does not bypass signature verification.transport_auth: UNSAFE. Signing request authorization is a distinct capability from signing repository objects. Cryptographic domain separation does not authorize a repository to invoke the user's ambient signer.
3. Enforcement contract
The fence is implemented at the file-read site, not at the field-consumer site, so every consumer gets the same guarantee without needing to remember to check.
3.1 Read-site rule
In libkit::config::apply_text:
if scope == ConfigScope::Repo && REPO_FORBIDDEN_KEYS.contains(key) {
report a ConfigEvent to the observer;
skip the key (continue);
}
The key never reaches apply_kv, so the matching field on Config
stays at its default (empty string / Vec::new() / the documented
fallback). Higher-priority layers (user-scoped, defaults) are
untouched.
3.2 Write-site rule
In libkit::config::write, only an explicit allow-list of
repo-safe keys is emitted:
default_branch
durability.objects
remote_endpoint
remote_bucket
remote_type
remote.<name>.url / remote.<name>.type (per named remote)
branch.<name>.remote / branch.<name>.merge (per-branch upstream tracking)
Any other field on the in-memory Config is suppressed when
serializing <repo>/.kit/config. The kit config <key> <value>
command intercepts UNSAFE keys (REPO_FORBIDDEN_KEYS.contains(key))
and writes them to the user-scoped file instead.
3.3 Warning shape
warning: ignoring `<key>` from per-repo config at <path> \
(security-sensitive keys are user-scoped only — see \
<user-config-path> and docs/THREAT-MODEL.md)
The kit command prints this text from the event. The exact text is snapshot-tested
(crates/kit-bin/tests/snapshots/repo_config_forbidden_keys__repo_config_forbidden_warning.snap).
Any wording change requires a reviewable cargo insta snapshot diff.
3.4 Runtime credential and request-signing gates
remote_endpoint is repo-safe to read, but kit push, kit pull,
and kit fetch call enforce_trusted_remote_endpoint before
attaching ambient credentials. The gate fires when ALL of:
- the merged endpoint is non-empty,
- the repo layer's
remote_endpointequals the merged endpoint (that is, it came from<repo>/.kit/config, not user config), - the user layer's
trusted_remote_endpointdoes NOT equal the merged endpoint, - the relevant ambient credential env var is set
(
KIT_API_TOKENfor HTTP,KIT_S3_*for S3).
Independently, selecting user-scoped transport_auth = envelope for any
HTTP(S) endpoint MUST require exact equality with user-scoped
trusted_remote_endpoint, before loading a key or constructing a signer.
This applies whether or not a bearer token is present, and to explicit clone
URLs as well as configured remotes. Repository and CLI-supplied destination
selection cannot confer signing trust on that destination.
When the gate fires, the command exits with a typed error directing
the user to kit config trusted_remote_endpoint <endpoint>, which
writes to the user-scoped config. The repo-scoped knob can therefore
NEVER unilaterally trust a remote — the user's hand is always
required.
The admission helper has a gate of its own. The client MUST NOT run a
user-scoped admission_helper
(SPEC-TRANSPORT-CONNECT §5.1)
for a remote unless the remote's endpoint equals the user-scoped
trusted_remote_endpoint. It applies a remote's user-scoped
remote.<name>.admission_headers only under the same condition. A
repository-configured or CLI-supplied remote therefore cannot trigger the
helper without the user's exact trust entry. Every header the helper returns
passes the allowlist and the hard-reserved set of SPEC-TRANSPORT-CONNECT §5.1
before the client attaches it, and the client never logs the helper's output.
3.5 Parse failures
Config fails closed. The loader returns an error, with the file and the line number, for each of these:
- an unknown key, including every removed or misspelled key;
- a line that has no
=; - a malformed value: a control character in any value, a
pull.require_signedvalue that is not a boolean word, adurability.objects,transport_auth,signer,attest.signer,attest.default_algorithmorssh.strict_host_key_checkingvalue outside its list, a non-numericattest.external_signer_timeout_secs, and an invalid remote or branch name; - a file larger than 1 MiB, or text that is not UTF-8.
An empty value is allowed. It leaves the key unset.
The same rules apply to the user file and to -c overrides. One case is
not an error: a forbidden key in the repo file is dropped with the §3.3
warning, and the loader does not parse its value. A typo in a security key
such as pull.require_signd is therefore an error. It never turns the
check off.
3.6 Absolute key paths
A relative signing_key path must stay under .kit/keys/. An absolute
path must lie under the home directory of the environment. That home
directory must be absolute and must not be the filesystem root. If it is
unset, relative or the root, every absolute key path is refused. A path
with a .. component is always refused.
4. Test coverage
The fence is multiply-tested so a regression in any one layer cannot hide:
- Per-key in-process drop test
(
libkit::config::tests::repo_*_is_rejectedand the meta-testevery_forbidden_key_is_actually_dropped_from_repo_scope). The meta-test iterates the entireREPO_FORBIDDEN_KEYSarray, plants each key with a sentinel value in a repo-scoped file, and asserts the sentinel never appears in the mergedConfig. Adding a key toREPO_FORBIDDEN_KEYSwithout extending the meta-test causes a compile-fail-style panic with an actionable error message. - Per-key end-to-end CLI test
(
crates/kit-bin/tests/repo_config_forbidden_keys.rs). For every UNSAFE key, plants.kit/config, runskit config <key>, and asserts the value did NOT propagate to stdout and that stderr emitted the named warning. - Warning-shape snapshot
(
tests/snapshots/repo_config_forbidden_keys__repo_config_forbidden_warning.snap). Pins the exact stderr wording so cosmetic drift surfaces as a reviewable diff. - Trusted-remote runtime-gate tests
(
kit_bin::config::tests::repo_*_remote_*requires_user_trustandtrusted_http_remote_is_allowed). Cover theenforce_trusted_remote_endpointsemantics for both HTTP and S3, plus the safe-path where the user has trusted the same endpoint.
5. Adding a new config key
When you add a key to Config:
- Decide whether the key is SAFE or UNSAFE per §2. The default answer is UNSAFE — only reclassify after writing down why on this list.
- If UNSAFE:
- Add the key to
REPO_FORBIDDEN_KEYS. - Add an arm to the meta-test's field-accessor
match(the test panics with a descriptive error if you forget). - Add a per-key integration test in
tests/repo_config_forbidden_keys.rs. - Update this spec's §2 table.
- Update
docs/THREAT-MODEL.md§4 if the key changes the user-vs-repo scope landscape.
- Add the key to
- If SAFE:
- Document the reasoning in §2 (especially for borderline cases where the key names a destination or transport).
- Add a repo-scoped acceptance test alongside any existing
roundtrip_repo_safe_keys-style fixture.
- If the key carries a credential, file path, or process-spawn
primitive, also write a
kit configcommand path that routes the value to user-scoped storage (seewrite_user_scopedincommands/config_cmd.rs).
6. Non-goals
- This spec does not defend against an attacker who already runs as
the user's UID (THREAT-MODEL §3.3). Once code runs as the user,
it can edit
$XDG_CONFIG_HOME/kit/configdirectly and the scope split is moot. - This spec does not protect against a user who manually opts in to
a hostile remote by running
kit config trusted_remote_endpoint <attacker>after cloning. Local choice is the user's responsibility (THREAT-MODEL §3.1). - This spec does not cover trust-roots file selection — see THREAT-MODEL §5. The trust-roots path is implicitly user-scoped-only because there is no repo config knob that sets it.
7. References
rust/crates/libkit/src/config.rs— single source of truth forREPO_FORBIDDEN_KEYSand the enforcement code path.rust/crates/kit-bin/src/config.rs— the process binding: it prints the events and applies the runtime credential gate.rust/crates/kit-bin/tests/repo_config_forbidden_keys.rs— per-key CLI regression suite.docs/THREAT-MODEL.md§4 — wider trust-boundary discussion.docs/specs/SPEC-KEYSTORE.md§8.2 — the keystore-side requirement that every keystore selector be repo-forbidden.- The credential-exfiltration channel that the runtime gate on
remote_endpointcloses.
8. Invariants
§3 is the enforcement contract; this table restates what it guarantees and where each guarantee is anchored.
| Invariant | Enforced by |
|---|---|
An UNSAFE key in <repo>/.kit/config never reaches Config |
read-site drop in apply_text before apply_kv; the field stays at its default (§3.1) |
| Serializing repo config never emits an UNSAFE key | explicit write-site allow-list in config::write (§3.2) |
kit config <key> <value> never writes an UNSAFE key repo-scoped |
the command intercepts REPO_FORBIDDEN_KEYS and routes to the user-scoped file (§3.2) |
| A repo can NEVER unilaterally attach ambient credentials to its own endpoint | enforce_trusted_remote_endpoint requires the user-scoped trusted_remote_endpoint to match (§3.4) |
| A repo can NEVER make the client run the admission helper or widen its header allowlist | both keys are UNSAFE, and the helper runs only for a remote matching trusted_remote_endpoint (§2, §3.4) |
| The classification cannot silently drift from the code | REPO_FORBIDDEN_KEYS is the single source of truth (§1); the meta-test iterates the whole array and panics on a missing arm (§4) |
| Every drop is user-visible with pinned wording | stderr warning, snapshot-tested (§3.3) |
A config file never loads with an unknown key, a line without = or a malformed value |
the loader returns an error that names the line (§3.5); the config_parse fuzz target covers the parser |
| A hostile repo cannot authorize itself as an encrypted-transport peer or swap identity keys | no repo config knob exists for those values — CLI flags, env var, or user-scoped path only (§6) |
Every new key defaults to UNSAFE until argued SAFE on the §2 table (§5); a SAFE key that later gains credential-routing or process-spawn behavior MUST be promoted to UNSAFE in the same patch that introduces the consumer (§2, §2.1).