KIT

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


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:

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 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:

  1. Per-key in-process drop test (libkit::config::tests::repo_*_is_rejected and the meta-test every_forbidden_key_is_actually_dropped_from_repo_scope). The meta-test iterates the entire REPO_FORBIDDEN_KEYS array, plants each key with a sentinel value in a repo-scoped file, and asserts the sentinel never appears in the merged Config. Adding a key to REPO_FORBIDDEN_KEYS without extending the meta-test causes a compile-fail-style panic with an actionable error message.
  2. Per-key end-to-end CLI test (crates/kit-bin/tests/repo_config_forbidden_keys.rs). For every UNSAFE key, plants .kit/config, runs kit config <key>, and asserts the value did NOT propagate to stdout and that stderr emitted the named warning.
  3. 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.
  4. Trusted-remote runtime-gate tests (kit_bin::config::tests::repo_*_remote_*requires_user_trust and trusted_http_remote_is_allowed). Cover the enforce_trusted_remote_endpoint semantics 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:

  1. Decide whether the key is SAFE or UNSAFE per §2. The default answer is UNSAFE — only reclassify after writing down why on this list.
  2. 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.
  3. 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.
  4. If the key carries a credential, file path, or process-spawn primitive, also write a kit config command path that routes the value to user-scoped storage (see write_user_scoped in commands/config_cmd.rs).

6. Non-goals


7. References


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).