Files
alkssh/tasks/implementation/w1-host-key-policy.md
T
glm-5.3-flash 149f11d4b2 feat(host-key): land the HostKeyPolicy seam (OQ-05, policy-blind)
- HostKeyPolicy trait: async, dyn-compatible (Arc<dyn> handle, the
  alkcall injected-handle pattern) — decide(&self, &PresentedHostKey)
  -> bool with host/port/fingerprint as the decision input
- Verdict type pinned to the russh 0.65 check_server_key shape:
  bool is the trust decision; decline reasons stay with the policy
  (declines are dispositions, not errors). Flagged for the wave-1 gate
- Built-in default RejectAll: fail-closed reject-all (russh's default,
  kept); no accept-all option exists in the crate
- async-trait 0.1 added for the dyn-compatible async handle
  (alkcall-precedented; tree-deduped). Flagged for the wave-1 gate
- Tests: default rejects; custom verdict honored; both through the dyn
  handle; the whole presented context reaches the policy

Verified: cargo test (14), cargo test --all-features, cargo clippy
--all-targets -- -D warnings, cargo fmt --check, taskgraph validate
2026-10-28 06:40:40 +00:00

5.6 KiB

id, name, status, depends_on, scope, risk, impact, level
id name status depends_on scope risk impact level
w1-host-key-policy Wave 1 — the HostKeyPolicy seam (OQ-05, policy-blind) completed
w1-manifest
narrow low project implementation

Description

Land the client-side host-key policy seam (auth.md §Host-key policy, OQ-05 resolution) — the trust-decision contract the dial surface calls:

  • The HostKeyPolicy trait — the crate surfaces the presented host key (host, port, fingerprint) to an assembly-provided policy that decides accept/reject. The two OQ-05 cases are the implementor's: case-1 pins (known beforehand — hard-decline on mismatch) and case-2 policies (unknown beforehand — prompt-and-record, learn-and-record, provider-metadata match, accept-and-continue for ephemeral fleets). The crate is policy-blind: it never decides trust, never hard-codes a trust model.
  • The crate default is fail-closed: reject unknown (russh's own reject-all default, kept as the built-in default policy). No accept-all built-in crate option — a permissive case-2 policy is the assembly's to write.
  • The trait receives the fingerprint string (the wave-1 fingerprint computation is the caller's input path, wave-4 dial); the exact decision type (bool vs a richer verdict carrying a reason) is the task's to pin against the russh check_server_key hook shape at this pin — flagged for the wave-1 gate.
  • The russh client-handler consumption (check_server_key wiring) is wave-4 dial-surface material — this task lands the seam and its default only.

Acceptance Criteria

  • HostKeyPolicy trait in place; the presented-key context (host, port, fingerprint) is the decision input
  • Built-in default policy rejects unknown keys (fail-closed); no accept-all option exists in the crate
  • Doc comments carry the two-case model and the policy-blind rule
  • Unit tests: default policy rejects; a custom policy's verdict is honored
  • cargo test, cargo clippy --all-targets -- -D warnings, cargo fmt --check pass

References

  • docs/architecture/auth.md §Host-key policy (OQ-05 resolution)
  • docs/architecture/client.md §The dial surface, §What the crate does not include (trust decisions)
  • docs/architecture/decisions/005-at-rest-key-custody.md (decision 6 — the client host-pin is public config, never vault material)
  • docs/research/auth-bridge.md §Host keys and check_server_key

Notes

Landed src/host_key.rs (HostKeyPolicy, PresentedHostKey, RejectAll) + lib.rs re-exports. Design decisions pinned against the russh 0.65 hook shape (Handler::check_server_key -> Result<bool, Self::Error>, async via impl Future, default Ok(false) — verified in the russh 0.65.0 source):

  • The verdict type is bool (the flagged pin): the hook's bool is the trust decision and its Err channel is the handler's wire/IO-failure territory — a trust decline has no typed reason channel on this surface. A decline is a disposition, not an error (src/error.rs contract); decline reasons are the policy's own record (case-2 policies log their decisions), not a crate surface. Wave-4 wiring maps false → russh's hard, clean Error::UnknownKey decline (mismatch = hard decline, per auth.md).
  • The trait is async and dyn-compatible: #[async_trait], decide(&self, &PresentedHostKey) -> bool, Send + Sync + 'static — the family injected-handle pattern (Arc<dyn HostKeyPolicy>, the alkcall IdentityProvider/OperationEnv precedent), which w1-config's dial-config handle and w1-test-doubles' inspectable double both require. State-holding (learn-and-record) policies record by interior mutability behind the shared &self handle.
  • New dep async-trait = "0.1" (MIT OR Apache-2.0; the only way to get a dyn-compatible async trait at this toolchain — native AFIT is not object-safe, and russh 0.65 removed its own async-trait feature, per ADR-002's context). Precedented and deduped: alkcall already carries async-trait 0.1, so the tree adds no new transitive package. Flagged for the wave-1 gate (manifest addition outside w1-manifest, same class as the verdict-type pin).
  • RejectAll is the built-in default (russh's reject-all default, kept); doc comments carry the two-case model, the policy-blind rule, the no-accept-all rule, the fail-closed disposition (an undecided policy declines), and the host-pin-is- public-config note (ADR-005 decision 6).
  • The PresentedHostKey.fingerprint doc pins exact string equality against assembly pins; the string is the single fingerprint path's format, computed at wave-4 wiring.

Summary

Seam landed: src/host_key.rs with the HostKeyPolicy trait (async fn decide(&self, &PresentedHostKey) -> bool, dyn-compatible via #[async_trait]), the PresentedHostKey (host/port/fingerprint) decision input, and the fail-closed RejectAll built-in default; no accept-all option exists in the crate. Verdict type pinned to russh 0.65's check_server_key shape (bool = the trust decision; decline reasons live with the policy, not the crate). async-trait = "0.1" added for the dyn-compatible async handle (alkcall-precedented, tree-deduped). Tests: default rejects; custom verdict honored; both honored through an Arc<dyn Handle>; the whole presented context reaches the policy. Verified: cargo test (14), cargo test --all-features, cargo clippy --all-targets -- -D warnings, cargo fmt --check all pass. taskgraph validate clean.