diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d9227..6fce9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,33 +4,149 @@ All notable changes to this crate are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this crate adheres to [Semantic Versioning](https://semver.org/). -## [Unreleased] +## [0.1.0] - 2026-10-30 + +The founding entry — the crate's first release. `alkssh` is the alk +family's SSH adapter (family ADR-003): a thin wrapper around russh +0.65 terminating the vanilla-ssh wire and bridging to/from alkcall +channels — the two-direction door for vanilla clients. The wire is +russh's; the crate's substance is the channel-type mappings +(session/pty → alktty, direct/forwarded-tcpip → alktunnels, git exec → +alkgit), each behind its cargo feature in a flat, independent graph +(ADR-003): **a default build has no capabilities and serves nothing +but clean declines** — every unfeatured or unimplemented request is +declined fail-closed, never half-served or silently fallen back, and +never advertised. Identity is the family's: a verified key's +fingerprint → alkcall `IdentityProvider`; keys the crate holds come +from alkvault-backed sources, encrypted at rest, decrypted at use, +never env vars (ADR-005). The crate is a door, not a backend — no +binary, no deployment policy; a downstream app embeds it, enables +features, and brings identity/ACL configuration. ### Added -- Initialized: docs-only phase 0 base — SDD process (`docs/sdd_process.md`), - opencode agent definitions, rust crate manifest (lib only, no - dependencies yet), MIT OR Apache-2.0 licensing. The russh pin, the - capability feature graph, and the channel-type mapping table are - phase 0 research output (family ADR-003, OQ-08 remaining scope). -- Phase 0 research set under `docs/research/`: `vision.md` (scope - questions), `russh.md` (0.64.1-era inventory + pin considerations), - `auth-bridge.md` (fingerprint → identity server path; vault-backed - at-rest client key storage — the vault as secret store, not key - derivation), `mapping-table.md` (channel type/request → family seam - + feature), `pocs.md` (POC-1..3 plan). -- Phase 0 completed — all three POCs passed against the current - published russh with vanilla tooling (121 checks total); findings - in `poc-1-auth-bridge.md` (31), `poc-2-vault-keys.md` (39), - `poc-3-mapping-bakeoff.md` (51, three mapping-table corrections - recorded), plus the reverse-runner client-direction extraction - (`reverse-runner.md`). POC source is disposable, never merged. -- Phase 1 architecture under `docs/architecture/`: the index + - open-questions tracker (OQ-01…13), the spec docs - (`overview.md`, `mapping.md`, `client.md`, `auth.md`), and ADRs - 001–006 (Accepted) — the `alk/ssh` ALPN pin; the russh 0.65.0 pin - (aws-lc-rs, no compression, `rsa` off); the capability feature - graph (flat, two-direction, `dynamic-forward` retired); auth - dispositions; vault-key custody; and the mapping/dispatch - decisions (route-1 bridge, exec-only git, parse-only session - dispatch). \ No newline at end of file +**Phase 0/1 — research and architecture** (the contract the +implementation lands): + +- SDD process, phase 0 research set (`docs/research/`), and the three + passed POCs (auth bridge, vault keys, mapping bake-off; findings in + `poc-1..3` + `reverse-runner.md`; POC source never merged). +- Phase 1 architecture under `docs/architecture/`: the spec docs and + ADRs 001–006 (Accepted) — the `alk/ssh` ALPN pin; the russh 0.65.0 + pin (aws-lc-rs, no compression, rsa off); the capability feature + graph; auth dispositions; vault-key custody; the mapping/bridge/ + dispatch decisions. + +**Wave 1 — foundations:** + +- The manifest: the russh 0.65 pin (`aws-lc-rs`, default features + off), the flat feature graph (`session` / `git` / `tunnel` / + `sftp`, `default = []`). +- The fail-closed error taxonomy (`thiserror`): `AdapterError`, + `DialError`, `KeySourceError`, `OpError`, `OpenDeclineReason` — + declines are dispositions, never panics; no `unwrap` outside tests. +- Vault-backed key custody in code: the `KeySource` / + `HostKeySource` seams and the ADR-005 implementations + (`VaultKeySource`, `VaultHostKeySource`) — decrypt-at-use / + decrypt-at-construction, the `Zeroizing` seam, one + `decode_secret_key` conversion. +- The `HostKeyPolicy` seam (OQ-05, policy-blind) with the fail-closed + default `RejectAll` — no accept-all built-in exists in the crate. +- The contract-invariant fingerprint computation (the crate's single + fingerprint path, the `SHA256:` format both the auth bridge and the + host-key policy consume). +- The config surface: `ServeConfig`, `DialConfig`, `Limits` (the + bounded-resources budget model — wall clock always set, channel + caps, keepalive posture) — unset fields decline at request time + (OQ-08), construction never validates. + +**Wave 2 — the server stack:** + +- `SshAdapter` — the mount point: `SshAdapter::new(serve)` builds the + russh server from the config (fail-closed on key-source failure); + `adapter.handle(stream)` serves one connection. +- The auth bridge: russh auth hooks → ADR-004 dispositions — + verified-key fingerprint → `IdentityProvider::resolve_from_fingerprint`, + fail-closed on every path; bounded auth attempts. +- Parse-only dispatch + the fail-closed decline core: every unroutable + or unfeatured request answers a clean, channel-scoped decline; the + spawned serving-path shapes (`channel_failure` then close). +- Spawn-and-return plumbing (ADR-006 decision 4): serving paths run + spawned (russh handlers return fast at this pin), with the + exit-status-before-close mechanics. +- Bounded resources at the handler surface: per-connection channel + caps (refuse-never-queue) and listener caps. +- The in-process loopback harness (the contract-test vehicle). + +**Wave 3 — the server mappings (the spine):** + +- Per-feature seam handles (`SessionSeam`, `GitSeam`, + `TunnelListenSeam`): unset seam → decline even with the feature on. +- Decline-path tracing (`tracing` events on every decline class). +- The git mapping (exec-only, ADR-006): the full exec grammar — + `git-{upload,receive}-pack '/'` parsed and dispatched to + alkgit; a subsystem request can never carry the repository and + declines; `1` is honestly reported for push `ng`. +- The session mapping: pty/shell/exec through the alktty seam over a + translation bridge (chunk-framed both directions), OQ-44's reserved + ttymodes JSON, the exit-chunk → SSH exit derivation (rules 1–6), + control rows (window-change, signal) on a bounded per-channel + surface. +- Tunnel leg 1: the route-1 bridge — `direct-tcpip` / + `direct-streamlocal` opens ride the assembly's `TunnelSession` over + the family fabric; fabric failure is a clean `ConnectFailed` reject. +- Tunnel leg 2: the `-R` listen flow — `tcpip_forward` / + `streamlocal_forward` dial the fabric, arrivals correlate through + the token router (miss → teardown-at-birth), the data leg opens + `forwarded-tcpip`/`forwarded-streamlocal` back toward the client, + port-0 mints echo the actually-bound port. + +**Wave 4 — the client direction:** + +- The dial surface: `dial(DialConfig)` — key load (decrypt-at-dial), + bounded russh client config, the host-key policy call, exactly one + publickey attempt; `SshConnection` the handle. +- The op rows: `exec` / `pty_exec` / `shell` (the alktty chunk plane), + `tunnel_direct` / `tunnel_direct_unix` (byte duplex), + `git_service` (pack duplex + consumed exit) — plus the `sftp` + decline row (always declines, every build). +- The `-R` leg: `reverse_listen` / `reverse_listen_unix` + the + `AcceptorRegistry` (refuse-never-replace) and the acceptor traits — + the hub asks the vanilla server to listen and serves the accepted + channels. +- Lifecycle primitives (OQ-12): explicitly cancellable listener + handles, clean `close()` (`Disconnect::ByApplication`) — drop is + never the clean path at this pin; no reconnect/restore policy (the + crate ships primitives, the assembly composes). +- Hardening riders: the git row's status-before-close by construction, + the channel-not-held guard wire rows, tty control-row coverage, the + `ListenRouter` token-collision pre-check. + +**Wave 5 — validation and release:** + +- The decline-matrix contract suite: every mapping-decline row × every + build it can appear in × both directions (the matrix runner + `scripts/matrix.sh` — one command over all feature builds). +- Real-tool e2e both directions: OpenSSH (pty/shell/exec, `-L`, `-R`, + `-D` + curl), git clone and push through the door, and a real + `sshd` against the client direction (exec, direct-tcpip dial, `-R` + listen + acceptor) — check-gated, skipped rows carry reasons. +- The `fuzz/` sidecar: the ingress surfaces (exec parse grammar, + fingerprint format, key decode, `TerminalParamsWire`) with the + committed-seed corpus gate (`cargo test --manifest-path + fuzz/shared/Cargo.toml`). +- Release readiness: warning-free crate docs with the known-limits + records riding the published rustdoc, this README, and the publish + dry-run posture. + +### Notes + +- `sftp` is a feature of decline: enabling it adds no capability — + the `sftp` subsystem request still declines in every build until + the alkfs mapping lands (OQ-13; the decline stays contract + territory). +- The `env` request declines unconditionally (OQ-09): session cwd/env + are assembly-supplied, never wire-derived. +- The russh pin (0.65, `aws-lc-rs`, `des`/`dsa` never enabled) is a + wire-relevant one-way door: bumps are deliberate, tested, and + recorded (ADR-002). \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..284d5c3 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# alkssh + +SSH adapter for the alk family: a thin wrapper around +[russh](https://docs.rs/russh) terminating the vanilla-ssh wire and +bridging to/from alkcall channels — the SSH counterpart of the +alkhttp adapter (`family` ADR-003). Vanilla clients terminate vanilla +ssh; family protocol halves (alktty, alktunnels, alkgit) receive their +own chunk planes; the door itself speaks no family protocol and owns +no deployment policy. + +The crate is a **door, not a backend**: no binary, no front doors — a +downstream app (alkforge) embeds it, enables the features it wants, +and brings identity/ACL configuration and the family fabric handles. +It is two-directional: a server mount serves vanilla ssh into the +family, and a dial points family ops at a vanilla `sshd`. + +## The feature graph — fail-closed + +`default = []` by design: **a default build has no capabilities and +can serve nothing but clean declines.** Every channel-type mapping is +a cargo feature; each feature is flat and independent (no feature +implies another): + +| Feature | Maps | Family protocol | +|---|---|---| +| `session` | pty / shell / exec (pipe) rows → chunk-framed session | alktty | +| `git` | the exec-only git rows (`git-{upload,receive}-pack`) | alkgit | +| `tunnel` | `direct-tcpip` / `direct-streamlocal`, the `-R` legs | alktunnels | +| `sftp` | reserved decline placeholder (OQ-13) — always declines until the alkfs mapping lands | — | + +Non-mapped surface (x11, `auth-agent`, the `env` request, git *subsystem* +requests, `sftp` until alkfs) declines fail-closed in every build: +never a half-capability, never a silent fallback, and never advertised +— an unfeatured channel type is declined, not advertised. A request +whose config fields are unset declines too (an unset seam or shell +argv configures nothing — the feature alone is not a capability). + +## Usage + +The assembly injects the seams: an alkcall +`IdentityProvider` (verified fingerprint → identity), key sources, and +the per-feature family handles. The examples below are the crate's +compile-checked doctests (see the crate docs in `src/lib.rs`). + +A server mount — per accepted TCP connection, run the door: + +```rust,no_run +use std::sync::Arc; + +use async_trait::async_trait; + +struct Identity; + +impl alkcall::core::auth::IdentityProvider for Identity { + fn resolve_from_fingerprint(&self, _: &str) -> Option { + None // the assembly's registry decides + } + + fn resolve_from_token(&self, _: &alkcall::core::auth::AuthToken) -> Option { + None + } +} + +struct HostKeys; + +#[async_trait] +impl alkssh::HostKeySource for HostKeys { + async fn load(&self) -> Result, alkssh::KeySourceError> { + Err(alkssh::KeySourceError::Absent) // sketch; vault-backed in prod + } +} + +let serve = alkssh::ServeConfig::new(Arc::new(Identity), Arc::new(HostKeys)); +let adapter = alkssh::SshAdapter::new(serve).await?; + +// per accepted TCP connection from the assembly's listener: +// adapter.handle(stream).await?; +``` + +A dial — family ops pointed upstream at a vanilla server: + +```rust,no_run +use std::sync::Arc; + +use async_trait::async_trait; + +struct DialKey; + +#[async_trait] +impl alkssh::KeySource for DialKey { + async fn load(&self) -> Result { + Err(alkssh::KeySourceError::Absent) // sketch; vault-backed in prod + } +} + +let conn = alkssh::dial(alkssh::DialConfig::new( + "hub.example:22", + "runner", + Arc::new(DialKey), + Arc::new(alkssh::RejectAll), +)) +.await?; + +// op rows (under their features): conn.exec, conn.tunnel_direct, +// conn.git_service, conn.reverse_listen — see the crate docs. +conn.close().await; // clean disconnect — drop is never the clean path +``` + +Minimal builds compile and doctest with no features enabled — + +```console +$ cargo build +$ cargo test +``` + +— and every capability row additionally rides its feature build (the +matrix: no features / `git` / `session` / `tunnel` / `sftp` / +`--all-features`). + +## Key custody and security posture + +- **Keys are sources, never static material** (alknet-main's `main` + key model, ADR-005): the dial/host key seams are + decrypt-at-use handles over an injected + [`alkvault`](https://docs.rs/alkvault) service — keys held + encrypted at rest, decrypted at use, zeroized through the seam. + The vault is a secret store on this path, not a key generator. +- **No env vars, no config-file key material, no secrets on the + wire.** The standing family posture (family ADR-002 decision 5). +- **Publickey-only authentication**, exactly one attempt — no + password, keyboard-interactive, cert, or fallback methods exist on + the surface. +- **Host-key trust is assembly policy**: the crate ships a + `HostKeyPolicy` seam with a fail-closed default (`RejectAll` — a + dial with it connects to nothing) and **no accept-all built-in**; + any permissive policy is the assembly's to write. +- **Injected identity**: every wire-exposed op is checked against + alkcall's `AccessControl::check(peer_identity)`; internal ops are + never wire-callable; there is no unauthenticated capability. +- **Bounded resources everywhere** (`Limits` — wall clock always + set, channel caps, listener caps, window/packet budgets, auth + attempt limit): the crate never constructs an unbounded session. + Known, recorded limits ride the published docs (the crate-root + rustdoc §Known limits). +- **Every disabled feature declines**: the decline matrix is contract + territory tested in every build. + +## Documentation + +- Crate docs: `cargo doc --open` (the crate-root docs carry the + feature graph and the known-limits records). +- `docs/architecture/` — the authoritative contract: the spec + (`overview.md`, `mapping.md`, `client.md`, `auth.md`), ADRs + 001–006, and the open-questions tracker. + +## License + +MIT OR Apache-2.0 — see `LICENSE-MIT` / `LICENSE-APACHE`. \ No newline at end of file diff --git a/src/client_accept.rs b/src/client_accept.rs index 0bdd9af..563a393 100644 --- a/src/client_accept.rs +++ b/src/client_accept.rs @@ -35,7 +35,7 @@ //! `ForwardedAcceptor` vocabulary is grown, not forked: the payload //! fields are the same shapes (the open's originator fields + the //! channel duplex), the duplex now family-typed -//! ([`TunnelChannel`](crate::TunnelChannel) — the `-L` rows' byte +//! ([`TunnelChannel`] — the `-L` rows' byte //! duplex, one type both legs ride). //! - **Install is refuse-never-replace** ([`AcceptorRegistry:: //! install_tcpip`]'s held-slot error): the same posture recorded @@ -119,7 +119,7 @@ //! ## The data leg //! //! Per accepted open: the hook accepts the channel and hands the -//! assembly the open — the [`TunnelChannel`](crate::TunnelChannel) +//! assembly the open — the [`TunnelChannel`] //! duplex + the open's echo/originator fields (the payload types //! below) — on a spawned task (the spawn-and-return rule's client //! mirror: the hook runs in the packet loop and returns fast). The @@ -221,7 +221,7 @@ impl std::fmt::Debug for ForwardedStreamlocal { /// packet loop): the assembly's pump/home of the open lives inside /// it; the crate releases its handoff counter when the handoff task /// ends — the call returning *or the task unwinding* -/// ([`HandoffCapGuard`]) — so the family shape (hand off to a queue, +/// (`HandoffCapGuard`) — so the family shape (hand off to a queue, /// return) keeps the counter's meaning "open accepted, handoff in /// flight". #[async_trait] diff --git a/src/client_handler.rs b/src/client_handler.rs index cd29252..f6fc959 100644 --- a/src/client_handler.rs +++ b/src/client_handler.rs @@ -25,7 +25,7 @@ //! through the **acceptor registry** ([`crate::client_accept`] — //! the `w4-reverse-listen` surface): served only under a registered //! acceptor for that type and under the `tunnel` feature (the -//! [`with_tunnel_surface`](ClientHandler::with_tunnel_surface) +//! [`with_tunnel_surface`]'s tunnel surface //! wiring; a build without `tunnel` has no registry — every hook //! declines); unregistered/unfeatured opens answer //! [`ChannelOpenFailure::AdministrativelyProhibited`] — a clean diff --git a/src/client_ops.rs b/src/client_ops.rs index 5825900..31ec29a 100644 --- a/src/client_ops.rs +++ b/src/client_ops.rs @@ -221,7 +221,7 @@ impl SshConnection { /// A remote **session** — the family session plane over the runner /// (the session rows' surface): the alktty chunk framing in both /// directions, exactly the plane the server direction's session -/// bridge speaks ([`crate::tty_map`]), reversed. Two duplex halves +/// bridge speaks (`tty_map`), reversed. Two duplex halves /// the hub drives; the pumps are spawned at the op's launch (§the /// family plane in the module docs for the full chunk map). Dropping /// the session (both halves) is wire-clean: the input pump's @@ -269,7 +269,8 @@ impl SshConnection { } /// The pty-mode exec row (`ssh -t host "cmd"`): the pty request - /// carries the family [`TerminalParamsWire`](alktty::negotiation:: + /// carries the family + /// [`alktty::negotiation::TerminalParamsWire`](alktty::negotiation:: /// TerminalParamsWire) first (its acceptance drained), then the /// exec. **A refused pty refuses the whole row** — never a /// half-capability, never a silent pty-less fall. @@ -683,17 +684,17 @@ impl std::fmt::Debug for GitPackSession { impl SshConnection { /// The upstream git row: the shared exec grammar's command /// (`git-{upload,receive}-pack '/'` — built through - /// [`crate::dispatch`]'s construction arm, the parse arm's exact + /// `crate::dispatch`'s construction arm, the parse arm's exact /// inverse; no forked grammar), pointed at the vanilla `sshd`: /// typed open + `exec`, the pack duplex pumped, the exit report /// consumed. /// /// **The pre-wire gates, riding the shared grammar's verdicts:** /// the repo id validates against the ADR-008 grammar (the git - /// row's server-direction gate mirrored, - /// [`crate::git_map`]) → [`OpError::InvalidRepoId`](crate::OpError); + /// row's server-direction gate mirrored, `crate::git_map`) → + /// [`OpError::InvalidRepoId`](crate::OpError); /// the constructed command is checked against the exec-length - /// budget ([`Limits::max_exec_command_len`]) → + /// budget ([`crate::Limits::max_exec_command_len`]) → /// [`OpError::CommandTooLong`](crate::OpError) — both **before any /// wire op**. (The grammar's other decline classes — /// `unknown-service` / `service-only` — are structurally diff --git a/src/error.rs b/src/error.rs index 103dbc0..5877b00 100644 --- a/src/error.rs +++ b/src/error.rs @@ -92,7 +92,7 @@ pub enum KeySourceError { /// ADR-005 semantics: the vault decrypted *something* /// successfully and the plaintext is not an OpenSSH /// private-key PEM (decision 1's inner format). Distinct from - /// [`Decryption`](KeySourceError::Decryption) on purpose — the + /// [`KeySourceError::Decryption`] on purpose — the /// custody boundary held, what was guarded is not a key. The /// underlying parse error's message is deliberately *not* /// carried — the machinery's text can echo plaintext-shaped diff --git a/src/lib.rs b/src/lib.rs index 9852615..400ca56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,170 @@ //! lifecycle primitives — are landed through wave 4; the validation //! suite and the release are wave 5). See also `docs/research/`, //! `docs/plans/implementation.md`, and family ADR-003. +//! +//! # The feature graph (ADR-003 — flat, independent, fail-closed) +//! +//! Every channel-type mapping is a cargo feature; a session opening an +//! unfeatured or unimplemented channel type is declined fail-closed — +//! never a half-capability, never a silent fallback. **A default build +//! (no features) has no capabilities**: it can serve nothing but clean +//! declines. No feature implies another; the graph is flat. +//! +//! - (default, no features) — serves declines only: every open, exec, +//! listen request answers a clean, channel-scoped refusal. +//! - `session` — pty/shell/exec rows → the `RemoteSession` chunk +//! plane (alktty). +//! - `git` — the exec-only git rows (`git-{upload,receive}-pack`) → +//! alkgit; a git *subsystem* request always declines (the repository +//! can never ride a subsystem channel). +//! - `tunnel` — `direct-tcpip` / `direct-streamlocal` and the `-R` +//! legs (listen, `forwarded-tcpip` / `forwarded-streamlocal`) → +//! alktunnels. +//! - `sftp` — a reserved, currently-empty placeholder (OQ-13): the +//! `sftp` subsystem request still declines in every build until the +//! alkfs mapping lands; the feature exists so the decline stays +//! contract territory (tested in every matrix build). +//! +//! Advertisement is honest: an unfeatured channel type is never +//! advertised, only declined (`docs/architecture/mapping.md` §Decline +//! matrix — the fail-closed invariant is contract territory on both +//! sides of every feature). +//! +//! # Known limits (recorded dispositions riding the published docs) +//! +//! The records below are deliberate dispositions — documented limits +//! with their reasons, mirrored in the module docs and the +//! architecture spec, not defects awaiting a fix: +//! +//! - **The session rows' stdout `write_all` carries no deadline.** +//! Backpressure is bounded (the bounded bridging duplex, russh's +//! per-channel window, the client's own flow control) and liveness +//! is covered by the always-set +//! [`Limits::inactivity_timeout`]; a deadline here would be a +//! behavior change (a slow client may stall its stream — vanilla +//! sshd's posture included). (`tty_map.rs` module docs.) +//! - **The PTY path's exit-signal fidelity is limited by upstream** +//! (exit-currency rule 5): signal-terminated PTY sessions surface as +//! `exit-status(1)` because the backend's reap surface loses the +//! signal number; the pipe path encodes negatives faithfully. An +//! alktty/portable-pty ask — the contract adapts automatically +//! when upstream conforms. +//! - **The port-0 mint is the only bound-port echo the wire carries** +//! (russh/sshd parity): a `tcpip_forward` success reply carries the +//! allocated port only for a `port = 0` request; an explicit-port +//! request's bound port is by definition the requested one — the +//! crate surfaces it as such, never a fabricated number. +//! - **No reconnect/restore policy.** The client direction ships +//! lifecycle *primitives* (bound ports, cancellable handles, clean +//! [`SshConnection::close`]); reconnect-and-restore (rebinding +//! persisted tunnel ports) is assembly composition over them — the +//! crate never owns the policy. Drop is never the clean path at +//! this russh pin: only an explicit `close` sends the clean +//! disconnect. +//! - **No accept-all host-key policy exists in the crate.** The +//! built-in default is [`RejectAll`] (a dial with it can never +//! connect — by design); the crate is policy-blind and any +//! permissive trust policy is the assembly's to write. A +//! fail-closed policy posture: when a policy cannot decide, it must +//! decline. +//! +//! # Usage +//! +//! The crate is a door, not a deployment: an assembly embeds it, +//! enables the features it wants, and injects the seams (identity, +//! key sources, the per-feature family handles). The examples are +//! compile-checked (`no_run`) doctests — honest sketches, not +//! executable code. +//! +//! A server mount (the default build — a decline-only door; the +//! assembly's real sources are the vault-backed [`VaultKeySource`] / +//! [`VaultHostKeySource`] shapes): +//! +//! ```rust,no_run +//! use std::sync::Arc; +//! +//! use async_trait::async_trait; +//! +//! struct Identity; +//! +//! impl alkcall::core::auth::IdentityProvider for Identity { +//! fn resolve_from_fingerprint( +//! &self, +//! _: &str, +//! ) -> Option { +//! None // the assembly's registry decides +//! } +//! +//! fn resolve_from_token( +//! &self, +//! _: &alkcall::core::auth::AuthToken, +//! ) -> Option { +//! None +//! } +//! } +//! +//! struct HostKeys; +//! +//! #[async_trait] +//! impl alkssh::HostKeySource for HostKeys { +//! async fn load( +//! &self, +//! ) -> Result, alkssh::KeySourceError> { +//! Err(alkssh::KeySourceError::Absent) // sketch; vault-backed in prod +//! } +//! } +//! +//! # fn main() -> Result<(), Box> { +//! # tokio::runtime::Runtime::new()?.block_on(async { +//! let serve = alkssh::ServeConfig::new(Arc::new(Identity), Arc::new(HostKeys)); +//! let adapter = alkssh::SshAdapter::new(serve).await?; +//! +//! // per accepted TCP connection from the assembly's listener: +//! // adapter.handle(stream).await?; +//! # Ok::<(), alkssh::AdapterError>(()) +//! # })?; +//! # Ok(()) +//! # } +//! ``` +//! +//! A dial (the client direction; the built-in [`RejectAll`] +//! host-key policy connects to nothing — by design, the assembly's +//! policy replaces it): +//! +//! ```rust,no_run +//! use std::sync::Arc; +//! +//! use async_trait::async_trait; +//! +//! struct DialKey; +//! +//! #[async_trait] +//! impl alkssh::KeySource for DialKey { +//! async fn load(&self) -> Result { +//! Err(alkssh::KeySourceError::Absent) // sketch; vault-backed in prod +//! } +//! } +//! +//! # fn main() -> Result<(), Box> { +//! # tokio::runtime::Runtime::new()?.block_on(async { +//! let conn = alkssh::dial(alkssh::DialConfig::new( +//! "hub.example:22", +//! "runner", +//! Arc::new(DialKey), +//! Arc::new(alkssh::RejectAll), +//! )) +//! .await?; +//! +//! # #[cfg(feature = "session")] +//! # { +//! # let _ = conn.exec("uptime"); // the op rows (feature-gated) +//! # } +//! conn.close().await; // clean disconnect — drop is never the clean path +//! # Ok::<(), alkssh::DialError>(()) +//! # })?; +//! # Ok(()) +//! # } +//! ``` mod adapter; mod channel_cap; @@ -71,11 +235,11 @@ pub use client_accept::{ #[doc(hidden)] pub use client_handler::ClientHandler; /// The client op rows' surfaces (`w4-op-rows`): -/// [`RemoteSession`](client_ops::RemoteSession) — the exec/pty rows' +/// [`RemoteSession`] — the exec/pty rows' /// family chunk plane (under `session`), -/// [`TunnelChannel`](client_ops::TunnelChannel) — the direct +/// [`TunnelChannel`] — the direct /// rows' byte duplex (under `tunnel`), -/// [`GitPackSession`](client_ops::GitPackSession) — the upstream git +/// [`GitPackSession`] — the upstream git /// row's pack duplex + consumed exit (under `git`). The op methods /// live on [`SshConnection`] (the op-row surface). Not a re-export /// here: the `sftp` row is [`SshConnection::sftp`] itself — the diff --git a/src/tty_map.rs b/src/tty_map.rs index 13df7db..9e68f38 100644 --- a/src/tty_map.rs +++ b/src/tty_map.rs @@ -152,9 +152,6 @@ //! The report is sent **before** close, from the spawned serving //! task (ADR-006 decision 4, via the cloned `Session::handle()`). //! -//! The report is sent **before** close, from the spawned serving -//! task (ADR-006 decision 4, via the cloned `Session::handle()`). -//! //! ## Cancellation (bounded resources + alktty's kill-on-drop) //! //! The serving task watches two signals: the **per-channel cancel** diff --git a/tasks/implementation/w5-release-readiness.md b/tasks/implementation/w5-release-readiness.md index 9bd4c36..9ab8a54 100644 --- a/tasks/implementation/w5-release-readiness.md +++ b/tasks/implementation/w5-release-readiness.md @@ -1,7 +1,7 @@ --- id: w5-release-readiness name: Wave 5 — release readiness (docs, feature matrix, CHANGELOG/README, dry-run) -status: pending +status: completed depends_on: [w5-decline-matrix, w5-e2e-server, w5-e2e-client, w5-fuzz-harness] scope: moderate risk: low @@ -52,18 +52,18 @@ bullet; the alkgit `w5-release-readiness` shape): ## Acceptance Criteria -- [ ] `cargo doc --no-deps` (+ `--all-features`) warning-free or +- [x] `cargo doc --no-deps` (+ `--all-features`) warning-free or every residual dispositioned; the docs-honesty pass complete (the known-limits records ride the published docs) -- [ ] README.md landed (what/why, feature graph + fail-closed, +- [x] README.md landed (what/why, feature graph + fail-closed, compile-checked examples, custody posture, license) -- [ ] CHANGELOG.md's 0.1.0 entry landed -- [ ] The feature matrix green (default / each feature / +- [x] CHANGELOG.md's 0.1.0 entry landed +- [x] The feature matrix green (default / each feature / `--all-features`: test + clippy + doc) -- [ ] `cargo publish --dry-run --allow-dirty` clean; the packaged +- [x] `cargo publish --dry-run --allow-dirty` clean; the packaged file list verified against the package policy; manifest sanity recorded -- [ ] The freeze inventory recorded for the gate +- [x] The freeze inventory recorded for the gate ## References @@ -79,8 +79,153 @@ bullet; the alkgit `w5-release-readiness` shape): ## Notes -> To be filled by implementation agent +**Crate docs.** `cargo doc --no-deps` is warning-free on both the +default build and `--all-features`. The pre-existing doc-link debt is +resolved (not dispositioned): 9 warnings (1 duplicate) at +`--all-features` — the `client_ops.rs` private-link class +(`crate::tty_map` / `crate::dispatch` / `crate::git_map` — private +modules; de-linked to backtick prose), the two +`rustdoc::redundant_explicit_links` spans (the `(client_ops::…)` +destinations on the `lib.rs` re-export docs and two +`[`TunnelChannel`](crate::TunnelChannel)` + `with_tunnel_surface` / +`KeySourceError::Decryption` same-item links in +`client_accept.rs`/`client_handler.rs`/`error.rs`), the broken +`TerminalParamsWire` path (now a resolution-correct target through +its wrapped re-export form) and the unresolved `Limits::…` +intra-doc link (now `crate::Limits::…`); plus the twin +duplicated "report is sent before close" paragraph in `tty_map.rs` +removed one. **Docs honesty:** the five recorded limits now ride the +*published* docs (the crate-root rustdoc §Known limits — the +`write_all` no-deadline record, the PTY rule-5 upstream fidelity +limit, the port-0-only mint echo wire fact, the no-reconnect-policy +boundary, the no-accept-all policy posture) with their module-doc +mirrors verified still in place (`tty_map.rs`, `exit_code.rs` +rule 5, `client_accept.rs`/`tunnel_map.rs`, `client.rs` +§Lifecycle posture, `host_key.rs`); crate-level docs state the +feature graph (each feature's row + the `default = []` decline-only +fact + no-feature-implies-another + honest advertisement) and the +fail-closed invariant. +**Compile-checked examples.** Two `no_run` doctests in the +crate-root docs (they run in every matrix build — the publish task's +verification set keeps them compile-checked with zero extra +vehicle): a server mount (`ServeConfig::new` → `SshAdapter::new` → +`adapter.handle`, on the default build — sketch key/identity seams +returning `Absent`/`None`, prose points at the vault-backed shapes) +and a dial (`dial(DialConfig)` → `close()`, the op rows referenced in +prose with the feature gate noted; `RejectAll` named as +connects-to-nothing by design). README mirrors them. + +**README.md.** Landed (none existed): what the crate is (the +two-direction door, russh owns the wire, door-not-backend), the +feature matrix table + the fail-closed posture (a default build serves +nothing but declines; unset-config declines; never advertised), the +two usage sketches (mirroring the doctests), the custody/security +posture summary (vault-backed sources — encrypted at rest, decrypted +at use, zeroized; no env vars, no secrets on the wire; publickey-only +single attempt; fail-closed host-key default with no accept-all; +ACL-gated surface; bounded resources + the known-limits pointer; the +decline matrix as contract), the docs pointers, the license note. + +**CHANGELOG.md.** The 0.1.0 entry landed (founding entry, dated +2026-10-30): the crate story (the two-direction door, the flat +fail-closed graph, ADR-003) + the posture statement, then the +landed surface wave by wave — the phase 0/1 contract, waves 1–5 +(foundations, server stack, server mappings/spine, client direction, +validation+release) — plus Notes (the sftp decline-only feature fact, +the OQ-09 env posture, the russh-pin one-way-door record). Only +served surface is listed; the never-features +(x11/`auth-agent`/subsystem/`env`) are named as the declines they +are. + +**Feature matrix** (`scripts/matrix.sh`, the one command): all six +builds' `cargo test` green (none / git / session / tunnel / sftp-only +/ `--all-features` — 108 result-ok reports across the matrix, +including both doctests per build), clippy ×2 (`--all-targets -D +warnings`, with and without `--all-features`) clean, `cargo fmt +--check` clean, `cargo doc --no-deps` clean; plus the fuzz corpus +gate (`cargo test --manifest-path fuzz/shared/Cargo.toml` — 11 +invariants green). + +**Publish dry-run.** `cargo publish --dry-run --allow-dirty` clean — +packaged 76 files, verified (the packaged tree compiles), upload +aborted by dry run as expected. The packaged file list verified +against the package policy (`cargo package --list`): exactly `src/` +(20 files), `tests/` (+ `common/` doubles + `vectors/`), the +architecture docs (`docs/architecture/` — the authoritative +contract), `README.md`, `CHANGELOG.md`, `LICENSE-MIT` / +`LICENSE-APACHE`, the manifests (`Cargo.toml`/`.orig`, +`Cargo.lock`, `.gitignore`, `.cargo_vcs_info.json`) and +`scripts/matrix.sh` (the verification tooling, not process surface); +none of the excluded surface appears (`.opencode/`, `AGENTS.md`, +`docs/reviews|plans|research/` / `docs/sdd_process.md`, `tasks/`, +`fuzz/` — none listed). **Manifest sanity recorded:** version 0.1.0 +(no bump — `w5-publish` verifies), `rust-version` 1.89, edition +2024, `license = "MIT OR Apache-2.0"` with both license files +present, the pins as ADR-002 records them (russh 0.65 +`default-features = false` + `aws-lc-rs`; alkcall 0.10; alkvault +0.1; the per-feature optional siblings alktty 0.8.1 / alkgit 0.1.0 / +alktunnels 0.4.1), the feature graph exactly ADR-003's, the +package-policy `exclude` list in place. + +**The freeze inventory (for `review-wave-5` and `w5-publish` — the +surfaces the 0.1 publish makes one-way; further wire-relevant change +is a new ADR + a semver event):** + +1. **The mapping contracts** — the channel-type → family-protocol + row semantics as landed (`docs/architecture/mapping.md` executed + by `dispatch.rs`/`tty_map.rs`/`git_map.rs`/`tunnel_map.rs`/ + `client_ops.rs`): the route-1 bridge flow (open-direct → + accept-after-fabric-success), the exec-only git grammar + its + verdicts, the parse-only dispatch + the translation bridge + (chunk framing, the zero-length stdin sentinel, the + negotiation-error frame → 255), the exit derivation rules 1–6, + the client op-row planes (the family chunk/duplex surfaces, the + acceptor registry + payload types, the lifecycle primitives). +2. **The honest-advertisement posture** — the fail-closed invariant + as behavior: an unfeatured/unimplemented channel type is declined + (never advertised, never half-served); a default build serves + declines only; the `sftp` placeholder stays a decline in every + build (OQ-13). +3. **The decline-matrix behavior** — the decline classes and shapes: + the `OpenDeclineReason` vocabulary as exposed, the + spawned-`channel_failure`-then-close serving-path shape, the + `ConnectFailed` fabric reject, teardown-at-birth, the + token-collision refusal, the config-driven + (`seam-unconfigured`/unset-field) declines, the always-`sftp` + decline row — as pinned by the matrix suite. +4. **The fingerprint string format** — the `SHA256:` contract + format of `crate::fingerprint()` (the crate's single fingerprint + path): both the auth bridge's identity resolution and the + host-key policy's exact-string pin comparison consume it; + changing the format breaks every pin store and is a one-way door. +5. **The russh pin's wire posture** — russh 0.65.0 with + `aws-lc-rs` (no compression, `rsa` off, `des`/`dsa` never): the + kex/algorithm negotiation surface crosses the crate boundary, so + the pin is wire-relevant (ADR-002) — a bump is a deliberate, + tested, recorded event. (The `alk/ssh` ALPN pin, ADR-001, is + already its own ledger one-way door; the auth surface ships as + publickey-only, exactly one attempt.) + ## Summary -> To be filled on completion \ No newline at end of file +Release readiness landed. Crate docs: warning-free × builds +(default + `--all-features`), the 9-warning doc-link debt resolved in +place (`client_ops.rs`'s private-link class among them), the +docs-honesty pass riding the published crate-root rustdoc (§Known +limits: the five recorded dispositions — `write_all` no-deadline, PTY +rule-5, port-0-only mint echo, no-reconnect policy, no-accept-all — +with module-doc mirrors verified) and crate-level docs stating the +feature graph + fail-closed invariant; two `no_run` usage doctests +(server mount + dial) keep the README examples compile-checked in +every matrix build. README.md landed; CHANGELOG.md carries the 0.1.0 +founding entry (wave-by-wave, honest advertisement). The feature +matrix is green end to end (six test builds, clippy ×2, fmt, doc) + +the fuzz corpus gate (11 invariants). `cargo publish --dry-run +--allow-dirty` clean (76 files packaged + verified; the file list +checked against the package policy — architecture docs ship, the +process surface does not; manifest sanity recorded). The freeze +inventory (mapping contracts, honest advertisement, decline-matrix +behavior, fingerprint format, russh pin posture) is recorded above +for the gate's read. Ready for `review-wave-5`; `w5-publish` +executes only over a green gate. \ No newline at end of file