Expose sandbox command wrapper

ober

1c5715a4a65345d6f424d56162788fa2425e111f

diff --git a/docs/limits-followup.md b/docs/limits-followup.md
index e06c8c2..bc1046d 100644
--- a/docs/limits-followup.md
+++ b/docs/limits-followup.md
@@ -112,6 +112,9 @@ Implemented:
 - `require: '(limits)` fails closed before target exec unless every requested
   limit reports `installed` or `parent`.
 - macOS SBPL generation for path policies.
+- `sandbox-command-wrapper-needed?`, `sandbox-command-wrapper-available?`, and
+  `sandbox-wrap-command` expose the reusable command rewrite needed by custom
+  launchers that cannot call `sandbox-launch` directly.
 - Linux Landlock hook via `jerboa_landlock_sandbox`.
 
 Still incomplete:
diff --git a/lib/std/os/limits/sandbox.ss b/lib/std/os/limits/sandbox.ss
index ea87cc1..3eed5c7 100644
--- a/lib/std/os/limits/sandbox.ss
+++ b/lib/std/os/limits/sandbox.ss
@@ -61,6 +61,9 @@
 
     sandbox-launch
     sandbox-prepare-child!
+    sandbox-command-wrapper-needed?
+    sandbox-command-wrapper-available?
+    sandbox-wrap-command
     sandbox-policy-sbpl   ;; debug: render policy as SBPL string (macOS only)
 
     sandbox-result?
@@ -348,6 +351,34 @@
                  (cons "-p" (cons sbpl cmd))))]
         [else cmd])))
 
+  (def (sandbox-policy-has-paths? pol)
+    (or (pair? (sandbox-policy-get pol 'read-paths))
+        (pair? (sandbox-policy-get pol 'write-paths))
+        (pair? (sandbox-policy-get pol 'exec-paths))))
+
+  (def (sandbox-command-wrapper-needed? pol)
+    ;; Some backends install policy directly in the child between fork and
+    ;; exec. macOS path policies are different: they must exec through
+    ;; sandbox-exec so the target starts inside the deny-default profile.
+    (and (platform-macos?)
+         (sandbox-policy-has-paths? pol)))
+
+  (def (sandbox-command-wrapper-available? pol)
+    (or (not (sandbox-command-wrapper-needed? pol))
+        (file-exists? "/usr/bin/sandbox-exec")))
+
+  (def (sandbox-wrap-command pol cmd)
+    ;; Pure command rewrite for callers that have their own fork/exec path.
+    ;; If no wrapper is needed, returns CMD unchanged. If a wrapper is needed
+    ;; but unavailable, also returns CMD unchanged; callers that require
+    ;; fail-closed semantics should check sandbox-command-wrapper-available?
+    ;; before launch.
+    (cond
+      [(and (sandbox-command-wrapper-needed? pol)
+            (sandbox-command-wrapper-available? pol))
+       (macos-wrap-command pol cmd)]
+      [else cmd]))
+
   (def (prepare-freebsd! pol)
     ;; Capsicum is all-or-nothing capability mode.  We can pre-open the
     ;; declared paths and then enter capsicum.  Without the FFI plumbed
diff --git a/tests/test-limits-primitives.ss b/tests/test-limits-primitives.ss
index e355fe9..91f77d3 100644
--- a/tests/test-limits-primitives.ss
+++ b/tests/test-limits-primitives.ss
@@ -459,6 +459,30 @@
     (process-result-stdout-bytes (sandbox-result-process r))
     0))
 
+(let* ([pol (sandbox-policy 'read-paths: '("/tmp"))]
+       [cmd '("/bin/echo" "wrapped?")]
+       [wrapped (sandbox-wrap-command pol cmd)])
+  (test "sandbox wrapper availability is boolean"
+    (boolean? (sandbox-command-wrapper-available? pol))
+    #t)
+  (test "sandbox wrapper need is boolean"
+    (boolean? (sandbox-command-wrapper-needed? pol))
+    #t)
+  (test-pred "sandbox command wrapper preserves command shape"
+    wrapped
+    (lambda (x)
+      (and (pair? x)
+           (string? (car x)))))
+  (test "sandbox command wrapper changes command only when needed and available"
+    (if (and (sandbox-command-wrapper-needed? pol)
+             (sandbox-command-wrapper-available? pol))
+        (car wrapped)
+        wrapped)
+    (if (and (sandbox-command-wrapper-needed? pol)
+             (sandbox-command-wrapper-available? pol))
+        "/usr/bin/sandbox-exec"
+        cmd)))
+
 ;; A required axis that isn't installed must refuse to launch the child.
 (let ([r (guard (exn [#t (cons 'error
                               (if (message-condition? exn)