Skip to content

Wiring: several outgoing transforms in a fixed order instead of one slot #893

Closed

Description

@smart-ssh-planner

Goal

An embedding app can register several outgoing transforms through Wiring. The core runs them in a fixed, documented order on the approved text, collects the secret values all of them inserted, and stops at the first rejection, so independent extensions no longer have to chain each other by hand.

Context

  • crates/app-shell/src/wiring.rs:66-69: pub outgoing_transform: Option<Arc<dyn OutgoingTransform>> – one slot.
  • Trait and types: crates/core/src/outgoing.rs (OutgoingTransform::prepare_outgoing(scope, kind, text) -> Result<Prepared, Rejection>, Prepared { text: SecretString, secrets: Vec<SecretString> } with a redacting Debug).
  • The single caller: prepare_for_send in crates/app-logic/src/orchestration/outgoing.rs:29-80. It runs after filter, risk, second opinion and confirmation, re-checks a transformed command against the hard blacklist only if the original did not match (Outgoing transform: a confirmed hard-blacklist command is rejected after preparation #697), adds the inserted secrets to the session redactor before the command starts, and redacts a rejection message.
  • Plumbing keeps one Option<Arc<dyn OutgoingTransform>>: crates/app-shell/src/lib.rs:120,151,194,408,556, crates/app-logic/src/state.rs:203, crates/app-logic/src/session.rs:888, crates/app-shell/src/commands/connect.rs:972.
  • Spec 0038, section "Umwandlung ausgehender Texte" describes one transform.

Requirements

  • Wiring gets pub outgoing_transforms: Vec<Arc<dyn OutgoingTransform>> (Community: empty). The old outgoing_transform: Option<…> stays for now (additive); when set it runs first. Its doc comment says it is superseded and will be removed in a later release.
  • New CompositeOutgoingTransform in crates/core/src/outgoing.rs implementing OutgoingTransform:
    • runs the transforms in registration order; transform n+1 receives the text produced by transform n (same scope and kind)
    • the result's secrets are the union of all inserted secrets (order kept)
    • the first Rejection stops the chain; nothing of the partial result is returned or logged
    • Debug of the composite and of intermediate results never shows text or secret values
    • requires_confirmation (added by Outgoing transform: confirmation query for texts it changes, and reject a prepared command with a different segment count #815, blocks this issue): true if any transform answers true (fail-closed); every further trait method gets a fail-closed combination as well, and the spec states the rule
  • app-shell builds the composite only when two or more transforms are registered; with zero the slot stays None (Community path unchanged: no group-chain lookup, text sent as is); with one, that transform is passed through.
  • prepare_for_send is not changed: the hard-blacklist re-check, the redactor update and the rejection redaction apply to the final text of the chain, exactly once.
  • Spec 0038: "eine Umwandlung" becomes a list; document order, chaining, secret union, first rejection wins, re-check on the final text.

Acceptance Criteria

  • Wiring::outgoing_transforms exists; Wiring::community() leaves it empty and the session gets None (test).
  • Unit tests for CompositeOutgoingTransform (in core):
    • two transforms: the second sees the first one's output; final text is the second one's output
    • secrets of both transforms are in Prepared::secrets()
    • first transform rejects → second is never called, the rejection message is returned
    • second rejects → its rejection is returned
    • format!("{:?}", …) of the composite result contains neither text nor secret values
  • Test in app-logic through prepare_for_send with two transforms: both inserted secrets are masked in the output of that very command; a chain whose final text matches the hard blacklist (original did not) is rejected with PREPARED_BLOCKED_MESSAGE.
  • requires_confirmation of the composite: false only if all transforms answer false (test with two transforms, each answering true alone).
  • Setting only the old field behaves exactly as before (existing outgoing-transform tests unchanged and green, including Outgoing transform: a confirmed hard-blacklist command is rejected after preparation #697).
  • Old field + one list entry: old one runs first (test).
  • Spec 0038 updated.
  • Relevant automated tests pass (cargo test --workspace, clippy with -D warnings).

Technical Notes

  • Security-critical: execution path and redaction. Read .claude/skills/smart-ssh-coder/references/security-invariants.md. Filter, risk and confirmation keep seeing only the original text; the transformed text must not reach events, history, ledger or logs; nothing here may weaken the hard-blacklist re-check. Review priority is elevated.
  • Files: crates/core/src/outgoing.rs, crates/app-shell/src/wiring.rs, crates/app-shell/src/lib.rs (:556), tests in crates/core and crates/app-logic/src/orchestration/, docs/specs/0038-repository-app-shell-ci.md.
  • Do not touch FilterEngine::evaluate() or the risk classifier.

Spec impact: update 0038

Open questions

  • none

Related Work

Activity

  1. added this to the 0.8.0 milestone on Oct 26, 2026
  2. smart-ssh-coder commented on Oct 27, 2026

    @smart-ssh-coder
    Contributor

    Implementation ready for review. PR #993. Wiring::outgoing_transforms and CompositeOutgoingTransform implemented with tests and Spec 0038 update; PR #993 CI is green.

  3. smart-ssh-reviewer commented on Oct 27, 2026

    @smart-ssh-reviewer

    Review passed – moving on to merge. Approved: composite outgoing transform and Wiring::outgoing_transforms verified; tests run locally and CI green, counter-proof done, spec 0038 updated.

  4. smart-ssh-coordinator commented on Oct 27, 2026

    @smart-ssh-coordinator
    Contributor

    Merged and branch deleted: PR #993 (squash) in develop

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    Priority

    P2 - Normal

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions