8000 feat(client): user policy callbacks by evanspearman · Pull Request #847 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

feat(client): user policy callbacks - #847

Merged
evanspearman merged 2 commits into
mainfrom
evan/callbacks
Jul 20, 2026
Merged

evanspearman merged 2 commits into
mainfrom
evan/callbacks

Conversation

@evanspearman
@evanspearman evanspearman commented Jul 20, 2026 •
Copy link
Copy Markdown
Member

The client's activate flow now prompts the operator (or emits a paste-ready user_policy.toml snippet in --no-prompt/non-TTY mode) whenever Phase 3 hits a project- or package-declared var/patch the user's UserPolicy can't auto-decide, instead of aborting the composition. This closes the loop the callbacks feature has been building toward: daemon-side project/package composables can now contribute vars and patches without the operator having to hand-craft their user_policy.toml up front.

What's in

  • crates/minimal/src/prompt.rs — two PolicyHooks impls: InteractivePrompt (dialoguer-driven, six choices per item, permanent choices mutate a RefCell-held policy that save_user_policy writes atomically via write-tmp+rename with a RemoveOnDrop guard and .bak) and NoPromptHook (fake-approves so both var and patch hooks fire and accumulate every pending item into a summary; the caller checks the summary before submitting).
  • carries_user_data short-circuit on both gate_vars (loadout side) and classify_var (client-side gate). Vars whose value is a Specified literal or an InheritWithDefault fallback skip the policy check — policy exists to gate user-env crossings into the sandbox, not to referee what packages hardcode. The bit crosses the wire via WireResolvedVar.carries_user_data.
  • ItemDecision::IgnoreOnce joins AllowOnce / UseRule so IgnorePermanent and IgnoreOnce have a clean expression.
  • WirePatchVerdict::Approved grows a per-file destination so dir mappings (~/.claude/** etc.) fan out to unique per-file destinations instead of collapsing every file onto the pending's shared base. Consumed on the daemon side after SandboxRelPath::try_new rejects both absolute paths and .. components, so a malicious client can't traverse.
  • mfile::PackageFsMapping::{File, Dir} replaces the stringly-typed "path ends in /** = dir" convention. PackageComposable::contribute owns the walk-pattern shaping, so no caller can accidentally construct a bare-dir mapping the client walker would drop.
  • compute_verdict + submit_verdict_and_wait split out of drive_pending_to_active so cmd_activate's non-interactive branch can inspect the NoPromptHook summary before actually submitting (fake approvals never reach the daemon).
  • Ordering: min activate is now CreateSession → WorkspaceFilesTarZst → ConfigureLoadout → SubmitVerdict. The workspace upload sits between create and configure so ConfigureLoadout's graph_from_all_packages sees the project's minimal.toml (upstream layers still come through the daemon's own SourceProvider/VCS side channel).
  • Per-name graph resolution in build_composables: one unresolvable package no longer wipes out every other package's contribution. Failures land as warn! with the missing name(s).

Other correctness / UX fixes

  • paths::RelPath::try_new rejects .. components (new Error::ContainsParentDir).
  • can_prompt_interactively() requires both stdin and stderr to be terminals — the old stdin_is_tty was misnamed (checked only stderr).
  • save_user_policy uses a RemoveOnDrop guard so a failed re 8000 name doesn't leave .tmp behind. The --no-prompt snippet renders through toml_edit so paths containing " or \ escape correctly.
  • apply_var_verdicts dedupes by PendingId up front (mirrors apply_patch_verdicts); duplicate entries surface as duplicate, not unknown.
  • Client-side missing-walk-root emits an actionable InvalidWireItem instead of shipping an incomplete verdict.
  • DenyPermanent glob-fail warning wording differs from the allow/ignore downgrade so the operator isn't told a rule "applies to this activation only" when the activation is actually aborting.
  • Compose error messages carry the offending project_path.
  • is_writable walks every missing ancestor (not just the immediate parent) and treats an empty relative-path parent as ..
  • extract_fs_mappings trims trailing slashes and drops empty/root paths with a warn.

Docs

crates/sessions/docs/COMPOSITION.md rewritten to match the current shape: three-RPC flow, carries_user_data short-circuit as a first-class invariant, PackageFsMapping file/dir distinction, NoPromptHook's fake-approve+summary-check pattern, and the daemon's use of SourceProvider for upstream layers (workspace tar only carries the mfile itself in the common case).

Summary by CodeRabbit

  • New Features
    • Added interactive policy prompts during activation with per-item allow/ignore/deny decisions, including saved policy updates when possible.
    • Added --no-prompt for non-interactive activation; if approvals are still required, it aborts with a paste-ready user_policy.toml snippet.
    • Improved package filesystem mapping with typed file/dir behavior and correct directory globbing/trailing-slash handling.
  • Bug Fixes
    • Rejected unsafe relative paths containing parent-directory traversal.
    • Prevented Draft pending contributions from being overwritten by subsequent loadout configuration.
    • Improved provenance-aware gating and stricter verdict validation (including duplicate/invalid approval safeguards).
  • Documentation
    • Updated session composition docs and the example minimal.toml to reflect the new activation/prompt flow.

@coderabbitai
coderabbitai Bot commented Jul 20, 2026 •
Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3ca30fd1-bb8f-4909-97fb-ca18942c4c32

📥 Commits

Reviewing files that changed from the base of the PR and between ad8b414 and c99fcd8.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • crates/minimald/src/sessions.rs
  • crates/sessions/docs/COMPOSITION.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/sessions/docs/COMPOSITION.md

📝 Walkthrough

Walkthrough

The PR adds interactive and non-interactive activation policy hooks, persists updated policies, uploads workspace files before loadout configuration, introduces typed package filesystem mappings, propagates variable provenance through composition, and adds pending-session and patch-validation safeguards.

Changes

Activation and composition

Layer / File(s) Summary
Typed mappings and path contracts
crates/mfile/*, crates/paths/src/lib.rs
File validation, traversal rejection, and typed file/directory package mappings are added.
Prompted activation and policy persiste 8000 nce
crates/minimal/*, Cargo.toml
min activate gains prompt and no-prompt flows, uploads workspace files before configuration, and persists policy changes atomically.
Daemon composition and session safeguards
crates/minimald/*, crates/minimald-rpc/*
Package resolution tolerates missing names, typed mappings reach composition, and pending sessions reject reconfiguration.
Provenance-aware gating and verdicts
crates/sessions/src/core/*, crates/sessions/src/client/*, crates/sessions/src/wire/*
User-data provenance crosses the wire, ignore-once decisions are supported, policies are returned, unmatched patches produce ignored verdicts, and approved destinations are applied.
Documentation, examples, and validation coverage
crates/sessions/docs/*, crates/sessions/example_project/*, crates/sessions/tests/*
Composition flow, prompt behavior, mapping semantics, wire fixtures, and client-flow tests are updated.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers: twitchyliquid64

Poem

A rabbit hops through prompts so bright,
Policies settle safe and right.
Files bloom into mappings wide,
Safe paths keep thorns outside.
Verdicts cross the wire with care—
Then carrots celebrate the share!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: adding client-side user policy callbacks to the activation flow.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minimald/src/sessions.rs`:
- Around line 288-291: Correct the create-flow documentation around
create_session to state that it only allocates the session record and spawns the
Draft actor. Remove claims that CreateSession performs inline configure_loadout,
composes the loadout, or finalizes it; document that cmd_activate uploads files
and sends a separate ConfigureLoadout RPC afterward.

In `@crates/sessions/docs/COMPOSITION.md`:
- Around line 410-424: Update the documentation describing
ResolvedVar::carries_user_data to include successful InheritWithDefault lookups
alongside successful Inherit lookups, while keeping fallback behavior and the
existing policy-gating explanation accurate.
- Around line 223-234: Update the “Takes the matching stash entry” documentation
in the resume_from_verdict flow to state that Session::handle_verdict retains
the cloned pending stash when resumption fails, allowing the client to correct
and resubmit; describe stash removal only after successful finalization.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0dce03fd-408e-4bf9-bc23-b8999bedcd5b

📥 Commits

Reviewing files that changed from the base of the PR and between a77b788 and 10e2539.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (31)
  • Cargo.toml
  • crates/mfile/src/lib.rs
  • crates/mfile/src/package_composable.rs
  • crates/minimal/Cargo.toml
  • crates/minimal/src/config.rs
  • crates/minimal/src/lib.rs
  • crates/minimal/src/loadouts.rs
  • crates/minimal/src/prompt.rs
  • crates/minimal/tests/cli.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/minimald/src/rpc.rs
  • crates/minimald/src/session.rs
  • crates/minimald/src/sessions.rs
  • crates/minimald/src/sessions/composables.rs
  • crates/minimald/src/test_harness.rs
  • crates/paths/src/lib.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/example_project/minimal.toml
  • crates/sessions/src/client/composer.rs
  • crates/sessions/src/client/handler.rs
  • crates/sessions/src/core/compose.rs
  • crates/sessions/src/core/decision.rs
  • crates/sessions/src/core/hooks.rs
  • crates/sessions/src/core/loadout.rs
  • crates/sessions/src/core/primitives.rs
  • crates/sessions/src/daemon/composer.rs
  • crates/sessions/src/wire/policy.rs
  • crates/sessions/src/wire/primitives.rs
  • crates/sessions/src/wire/request.rs
  • crates/sessions/tests/client_flow1.rs
  • crates/sessions/tests/client_flow2.rs

Comment thread crates/minimald/src/sessions.rs Resolved
Comment thread crates/sessions/docs/COMPOSITION.md Resolved
Comment thread crates/sessions/docs/COMPOSITION.md Resolved
@twitchyliquid64 twitchyliquid64 left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized I never got you an RPC for uploading to the homedir: the WorkspaceFilesTarZst unpacks to what gets mapped to /workbench and not /home. Do we need an RPC for uploading to the home dir?

@evanspearman
Copy link
Copy Markdown
Member Author

I just realized I never got you an RPC for uploading to the homedir: the WorkspaceFilesTarZst unpacks to what gets mapped to /workbench and not /home. Do we need an RPC for uploading to the home dir?

I'm just planning out that work now. I can just add it as part of that change.

@norrietaylor norrietaylor left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@norrietaylor
Copy link
Copy Markdown
Member

I did have claude run an ultraview as it is pretty big. It came back very very clean but claude did raise one thing:

🟠 cmd_activate leaks a Draft session on transient failure — crates/minimal/src/lib.rs:1017–1034

After CreateSession succeeds, three failure paths propagate via ?/bail! without send_abort, leaking the daemon-side Draft slot until manual min destroy or restart (no reaper exists):

  • tar upload failure — lib.rs:1021–1024
  • ConfigureLoadout transport failure — lib.rs:1032–1038
  • the Errorable::Err arm — lib.rs:1039–1044

These are ordinary failure modes (network hiccup, permission error, compose fault). Fix is mechanical: mirror the send_abort-on-error pattern already used by submit_verdict_and_wait (lib.rs:800–828) and drive_pending_to_active — the invariant is even documented at lib.rs:797–799.

I leave it at your discretion if this is something to fix right now!

@evanspearman
evanspearman enabled auto-merge (squash) July 20, 2026 19:47
@evanspearman
Copy link
Copy Markdown
Member Author

I did have claude run an ultraview as it is pretty big. It came back very very clean but claude did raise one thing:

🟠 cmd_activate leaks a Draft session on transient failure — crates/minimal/src/lib.rs:1017–1034
After CreateSession succeeds, three failure paths propagate via ?/bail! without send_abort, leaking the daemon-side Draft slot until manual min destroy or restart (no reaper exists):

  • tar upload failure — lib.rs:1021–1024
  • ConfigureLoadout transport failure — lib.rs:1032–1038
  • the Errorable::Err arm — lib.rs:1039–1044

These are ordinary failure modes (network hiccup, permission error, compose fault). Fix is mechanical: mirror the send_abort-on-error pattern already used by submit_verdict_and_wait (lib.rs:800–828) and drive_pending_to_active — the invariant is even documented at lib.rs:797–799.

I leave it at your discretion if this is something to fix right now!

I think I'll defer these as I think there are going to be some related changes in the next PR.

@coderabbitai coderabbitai Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/sessions/src/daemon/composer.rs (1)

284-296: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add duplicate-var verdict regression coverage.

Add a test with two verdict entries for one PendingId—including mixed variants—to ensure this remains an InvalidWireItem rather than regressing into an unknown-id or denial path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/daemon/composer.rs` around lines 284 - 296, Add
regression coverage for the verdict validation around the duplicate-detection
loop, constructing two var verdicts with the same PendingId using different
WireVarVerdict variants. Assert composition returns
ComposeError::InvalidWireItem and does not classify the input as an unknown-id
or denial error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minimald/src/session.rs`:
- Around line 541-546: Update the session operation around the
SessionInner::Active match branch to distinguish state-rejection failures from
compose failures: an AlreadyExists error must preserve the Active state, while
compose-related failures must continue leaving the session in Draft as
previously guaranteed. Revise the surrounding failure-state handling or
documentation to state these separate guarantees accurately.

In `@crates/sessions/src/daemon/composer.rs`:
- Around line 368-392: Update the nearby documentation for
PendingComposeState::pending_patches to remove the outdated claim that it
supplies an omitted destination. Describe that the map retains source provenance
and validates patch IDs used by WirePatchVerdict::Approved, while destination
now comes from the verdict.

---

Nitpick comments:
In `@crates/sessions/src/daemon/composer.rs`:
- Around line 284-296: Add regression coverage for the verdict validation around
the duplicate-detection loop, constructing two var verdicts with the same
PendingId using different WireVarVerdict variants. Assert composition returns
ComposeError::InvalidWireItem and does not classify the input as an unknown-id
or denial error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 90e47219-b396-48ab-a288-ae918e3e5c72

📥 Commits

Reviewing files that changed from the base of the PR and between be44d68 and ad8b414.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (31)
  • Cargo.toml
  • crates/mfile/src/lib.rs
  • crates/mfile/src/package_composable.rs
  • crates/minimal/Cargo.toml
  • crates/minimal/src/config.rs
  • crates/minimal/src/lib.rs
  • crates/minimal/src/loadouts.rs
  • crates/minimal/src/prompt.rs
  • crates/minimal/tests/cli.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/minimald/src/rpc.rs
  • crates/minimald/src/session.rs
  • crates/minimald/src/sessions.rs
  • crates/minimald/src/sessions/composables.rs
  • crates/minimald/src/test_harness.rs
  • crates/paths/src/lib.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/example_project/minimal.toml
  • crates/sessions/src/client/composer.rs
  • crates/sessions/src/client/handler.rs
  • crates/sessions/src/core/compose.rs
  • crates/sessions/src/core/decision.rs
  • crates/sessions/src/core/hooks.rs
  • crates/sessions/src/core/loadout.rs
  • crates/sessions/src/core/primitives.rs
  • crates/sessions/src/daemon/composer.rs
  • crates/sessions/src/wire/policy.rs
  • crates/sessions/src/wire/primitives.rs
  • crates/sessions/src/wire/request.rs
  • crates/sessions/tests/client_flow1.rs
  • crates/sessions/tests/client_flow2.rs
🚧 Files skipped from review as they are similar to previous changes (28)
  • crates/sessions/src/core/decision.rs
  • crates/sessions/src/core/hooks.rs
  • crates/minimal/src/config.rs
  • crates/minimal/src/loadouts.rs
  • crates/sessions/src/wire/policy.rs
  • Cargo.toml
  • crates/minimald/src/test_harness.rs
  • crates/minimald/src/rpc.rs
  • crates/minimal/tests/cli.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/minimal/Cargo.toml
  • crates/mfile/src/lib.rs
  • crates/sessions/example_project/minimal.toml
  • crates/sessions/src/core/loadout.rs
  • crates/sessions/src/core/primitives.rs
  • crates/paths/src/lib.rs
  • crates/sessions/src/client/composer.rs
  • crates/sessions/src/wire/primitives.rs
  • crates/minimald/src/sessions/composables.rs
  • crates/sessions/src/core/compose.rs
  • crates/sessions/tests/client_flow1.rs
  • crates/minimal/src/prompt.rs
  • crates/mfile/src/package_composable.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/minimald/src/sessions.rs
  • crates/sessions/src/client/handler.rs
  • crates/minimal/src/lib.rs
  • crates/sessions/tests/client_flow2.rs

Comment on lines +541 to +546
match &self.inner {
SessionInner::Active { .. } => {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"session loadout is already configured",
));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the failure-state guarantee.

This new AlreadyExists path leaves the session Active, so the preceding claim that every failure leaves it Draft is no longer true. Distinguish compose failures from state-rejection failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/session.rs` around lines 541 - 546, Update the session
operation around the SessionInner::Active match branch to distinguish
state-rejection failures from compose failures: an AlreadyExists error must
preserve the Active state, while compose-related failures must continue leaving
the session in Draft as previously guaranteed. Revise the surrounding
failure-state handling or documentation to state these separate guarantees
accurately.

Comment on lines +368 to 392
WirePatchVerdict::Approved {
id,
host_path,
destination,
} => {
let pp = lookup_patch(pending_patches, id)?;
touched_ids.insert(id);
let destination = pp.patch().dest().as_sandbox_path().clone();
// Use the client-computed `destination`, not
// `pp.patch().dest()`. The pending's dest is the
// base (e.g. `~/.claude` for a `~/.claude/**` dir
// mapping) — reusing it would collapse every file's
// dest to that base and trigger the composer's
// patch-destination-conflict check on the second
// file. See the wire-side rationale on
// `WirePatchVerdict::Approved::destination`.
//
// Safe against sandbox-escape via a `..` traversal:
// `destination` deserialized through
// `SandboxRelPath::try_new`, which rejects both
// absolute paths and any `..` component. A malicious
// client crafting `"../../etc/foo"` never gets past
// wire parsing.
let source = pp.source().clone();
accepted.push(SessionPatch::new(
ResolvedPatch::new(host_path, destination),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the pending-patch provenance documentation.

WirePatchVerdict::Approved now carries destination, so PendingComposeState::pending_patches no longer supplies a destination omitted by the verdict. Update the nearby field comment to describe its remaining source-provenance and ID-validation role.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/daemon/composer.rs` around lines 368 - 392, Update the
nearby documentation for PendingComposeState::pending_patches to remove the
outdated claim that it supplies an omitted destination. Describe that the map
retains source provenance and validates patch IDs used by
WirePatchVerdict::Approved, while destination now comes from the verdict.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

0