Skip to content

policy_fn: approve held syscalls asynchronously - #302

Open
congwang-mk wants to merge 6 commits into
mainfrom
issue-289-approval
Open

congwang-mk wants to merge 6 commits into
mainfrom
issue-289-approval

Conversation

@congwang-mk

Copy link
Copy Markdown
Contributor

Fixes #289. Replaces #298, reworked from scratch around the argv freeze.

Problem

The supervisor loop awaited every held policy verdict inline, so one pending approval (exec, open, connect/bind/send) stopped supervision of the whole sandbox. The argv freeze for execve was also taken before the verdict and held across it, stopping every sandboxed task for as long as an approver took to answer.

Design

  • Approvals park off the loop. Notification handling is split into prepare and finish. A held notification parks in a JoinSet the loop owns and is finished on the loop when its verdict lands. Each sandbox thread has at most one pending notification, so no extra bound is needed; dropping the set on exit aborts every pending wait, and shutdown cancellation still denies them.
  • The freeze never spans an approval. policy_fn judges an execve on argv read without freezing. After an allow the supervisor freezes, re-reads argv and the path, and continues only if they still match (otherwise EPERM). Freeze, re-check, response and release happen with no .await in between, since the tracer is the loop's thread.
  • Only the loop sends responses. The freeze skips tasks parked in seccomp, so releasing one mid-freeze would let it rewrite argv already judged. Deferred handler results are now sent by the loop too, never while a freeze is held.
  • Release after an allowed exec. Siblings killed by de_thread while still traced wait for their tracer to reap them, and de_thread waits for that. The supervisor reaps them before detaching peers, which also keeps peers stopped until the kernel has copied argv.
  • Verdicts are authoritative. A verdict decides only the event it answers; policy tightened while an approval is pending is not re-applied to it.

API changes

  • Rust: PolicyFn::deferred(|event, ctx, approval: Option<Approval>|); Approval::{resolve, allow, deny, is_cancelled}. Dropping an unresolved approval denies. PolicyFn::new is built on it and unchanged.
  • C: sandlock_sandbox_builder_policy_fn_deferred, sandlock_approval_resolve, sandlock_approval_is_cancelled, sandlock_approval_free.
  • Python: PolicyFn(cb, deferred=True) calls cb(event, ctx, approval); Approval.allow/deny/resolve/close and cancelled. Closing, collecting or raising denies.
  • Go: PolicyFn.Deferred; (*Approval).Resolve/Close/IsCancelled, ErrApprovalAnswered. Exactly one of Callback and Deferred must be set.

Known limits (not introduced here)

  • An allowed exec from a multi-threaded process that fails before de_thread can stall the loop up to 2s while its siblings are released.
  • With no sibling threads there is no signal that argv was copied, so peers are released right after the response, as on main.
  • The freeze assumes one tracer thread; moving it into the session reaper is a possible follow-up.

Testing

Rust core (922 unit, 554 integration), FFI and C smoke tests, Python (450) and Go suites pass locally. New tests cover a pending approval not stalling other syscalls, argv rewritten during an approval being refused, exec with a live sibling thread, approvals answered from another thread or by a later event, dropped/closed/panicking approvals denying, and timeout cancellation.

🤖 Generated with Claude Code

The notification loop awaited every held policy verdict inline, so one
pending approval stopped supervision of the whole sandbox. The argv
freeze for execve was also taken before the verdict and held across it.

Held notifications now park in a set owned by the loop and are finished
there once their verdict lands. policy_fn judges an execve on argv read
without freezing; after an allow the supervisor freezes, re-reads argv
and the path, and continues only if they still match. The freeze is
taken, used and released with no await in between: its tracer is the
loop's thread, and a task parked in seccomp is skipped by the freeze, so
deferred handler results are also sent by the loop rather than by their
workers, never while a freeze is held.

Siblings frozen for an allowed execve are killed by de_thread while
still traced, and de_thread waits until their tracer reaps them. Reap
them before releasing peers, which also keeps peers stopped until the
kernel has copied argv.

Signed-off-by: Cong Wang <cwang@multikernel.io>
A policy callback had to return its verdict, so an embedder waiting on a
human or another service blocked the callback thread, and every later
event queued behind that one decision.

PolicyFn::deferred hands each held syscall an Approval the embedder may
keep and resolve later from any thread, while the callback returns and
the next event is delivered. Dropping it unresolved denies, and
is_cancelled tells a pending approver that shutdown or the verdict
timeout already decided. PolicyFn::new is built on top of it and keeps
its behaviour.

Signed-off-by: Cong Wang <cwang@multikernel.io>
A C policy callback could only return its verdict, so an embedder that
answers through a UI or another service had to block the callback thread
until the decision arrived.

sandlock_sandbox_builder_policy_fn_deferred passes each held syscall an
owned sandlock_approval_t the caller resolves later from any thread with
sandlock_approval_resolve, or abandons with sandlock_approval_free,
which denies. sandlock_approval_is_cancelled reports a shutdown or an
expired verdict timeout. Both callback flavours share the event
conversion, and the existing entry point is unchanged.

Signed-off-by: Cong Wang <cwang@multikernel.io>
A Python policy callback had to return its verdict, so an application
asking a user or another service held the callback thread until the
answer came back.

PolicyFn(callback, deferred=True) calls callback(event, ctx, approval),
where approval is an Approval for held syscalls that may be answered
later from any thread with allow, deny or resolve. Closing it, dropping
it, or raising from the callback denies, and cancelled reports a
shutdown or an expired verdict timeout. Synchronous callbacks keep their
return value encoding, now shared with resolve.

Signed-off-by: Cong Wang <cwang@multikernel.io>
A Go PolicyFunc had to return its decision, so a program that asks a
user or another service blocked the callback goroutine, and with it the
next policy event, until the answer arrived.

PolicyFn.Deferred receives an Approval for each held syscall that any
goroutine may Resolve later. Close denies it, a panicking callback or
an abandoned Approval is denied, IsCancelled reports a shutdown or an
expired verdict timeout, and a second Resolve returns
ErrApprovalAnswered. A PolicyFn must set exactly one of Callback and
Deferred.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The policy guide only showed approvals as a callback blocking on a queue
and still described the argv freeze as held while the callback decides.

Document deferred approval handles across Python, Rust, Go and C, that
a verdict decides only the event it answers, and that argv is judged
unfrozen and re-checked under a freeze after an allow. Point the Python
and Go READMEs at the new section.

Signed-off-by: Cong Wang <cwang@multikernel.io>

This branch has not been deployed

No deployments
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.

policy_fn: allow asynchronous approval without stalling supervision

1 participant