microsandbox-rb 0.5.8 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,12 +15,15 @@ use microsandbox::logs::{
15
15
  LogCursor, LogEntry, LogOptions, LogSource, LogStreamOptions, LogStreamStart,
16
16
  };
17
17
  use microsandbox::sandbox::{
18
- FsEntry, FsEntryKind, FsMetadata, PullPolicy, RlimitResource, SandboxFilter, SandboxHandle,
19
- SandboxMetrics, SandboxStatus, SandboxStopResult, SecurityProfile,
18
+ AttachOptionsBuilder, FsEntry, FsEntryKind, FsMetadata, Patch, PullPolicy, RlimitResource,
19
+ SandboxFilter, SandboxHandle, SandboxMetrics, SandboxStatus, SandboxStopResult,
20
+ SecurityProfile,
20
21
  };
21
22
  use microsandbox::LogLevel;
22
23
  use microsandbox::RegistryAuth;
23
- use microsandbox_network::policy::NetworkPolicy;
24
+ use microsandbox_network::policy::{
25
+ Action, Destination, DestinationGroup, Direction, NetworkPolicy, PortRange, Protocol, Rule,
26
+ };
24
27
 
25
28
  use crate::conv;
26
29
  use crate::error;
@@ -108,6 +111,12 @@ impl Sandbox {
108
111
  }
109
112
  });
110
113
  }
114
+ // patches: rootfs modifications applied before boot. The Ruby layer
115
+ // normalizes each `Microsandbox::Patch.*` into a string-keyed Hash with
116
+ // a `kind` discriminator; mirrors the Python binding's `apply_patch`.
117
+ for patch in parse_patches(opts)? {
118
+ b = b.add_patch(patch);
119
+ }
111
120
  if let Some(net) = conv::opt_string(opts, "network")? {
112
121
  match net.as_str() {
113
122
  "none" | "disabled" | "disable" | "airgapped" => b = b.disable_network(),
@@ -123,6 +132,13 @@ impl Sandbox {
123
132
  }
124
133
  }
125
134
  }
135
+ // Custom network policy: an ordered allow/deny rule list with per-direction
136
+ // defaults and bulk domain denials. The Ruby layer routes bare presets to
137
+ // the `network` key above and full policies here; mirrors the Python
138
+ // binding's `apply_network`.
139
+ if let Some(policy) = parse_network_policy(opts)? {
140
+ b = b.network(move |n| n.policy(policy));
141
+ }
126
142
  if let Some(level) = conv::opt_string(opts, "log_level")? {
127
143
  b = b.log_level(log_level_from_str(&level)?);
128
144
  }
@@ -434,6 +450,120 @@ impl Sandbox {
434
450
  let fs = self.inner.fs();
435
451
  block_on(fs.copy_to_host(&guest_path, &host_path)).map_err(error::to_ruby)
436
452
  }
453
+
454
+ //----------------------------------------------------------------------
455
+ // SSH (mirror SandboxSshOps)
456
+ //----------------------------------------------------------------------
457
+
458
+ /// Open a native in-process SSH client to this sandbox. `opts`: user, term,
459
+ /// sftp (bool, default true).
460
+ fn ssh_open_client(&self, opts: RHash) -> Result<crate::ssh::SshClient, Error> {
461
+ let user = conv::opt_string(opts, "user")?;
462
+ let term = conv::opt_string(opts, "term")?;
463
+ let sftp = conv::opt::<bool>(opts, "sftp")?.unwrap_or(true);
464
+ let ssh = self.inner.ssh();
465
+ let client = block_on(ssh.open_client_with(move |mut b| {
466
+ if let Some(u) = user {
467
+ b = b.user(u);
468
+ }
469
+ if let Some(t) = term {
470
+ b = b.term(t);
471
+ }
472
+ b.sftp(sftp)
473
+ }))
474
+ .map_err(error::to_ruby)?;
475
+ Ok(crate::ssh::SshClient::from_core(client))
476
+ }
477
+
478
+ /// Prepare a reusable SSH server endpoint. `opts`: host_key_path,
479
+ /// authorized_keys_path, user, sftp (bool, default true).
480
+ fn ssh_prepare_server(&self, opts: RHash) -> Result<crate::ssh::SshServer, Error> {
481
+ let host_key_path = conv::opt_string(opts, "host_key_path")?;
482
+ let authorized_keys_path = conv::opt_string(opts, "authorized_keys_path")?;
483
+ let user = conv::opt_string(opts, "user")?;
484
+ let sftp = conv::opt::<bool>(opts, "sftp")?.unwrap_or(true);
485
+ let ssh = self.inner.ssh();
486
+ let server = block_on(ssh.prepare_server_with(move |mut b| {
487
+ if let Some(p) = host_key_path {
488
+ b = b.host_key_path(p);
489
+ }
490
+ if let Some(p) = authorized_keys_path {
491
+ b = b.authorized_keys_path(p);
492
+ }
493
+ if let Some(u) = user {
494
+ b = b.user(u);
495
+ }
496
+ b.sftp(sftp)
497
+ }))
498
+ .map_err(error::to_ruby)?;
499
+ Ok(crate::ssh::SshServer::from_core(server))
500
+ }
501
+
502
+ //----------------------------------------------------------------------
503
+ // Interactive attach (host-TTY coupled)
504
+ //----------------------------------------------------------------------
505
+
506
+ /// Attach an interactive terminal to a command in the sandbox; returns its
507
+ /// exit code. Puts the host terminal in raw mode (requires a real tty) and
508
+ /// blocks until the command exits or the detach sequence is typed. `opts`:
509
+ /// cwd, user, env, detach_keys, rlimits.
510
+ fn attach(&self, cmd: String, args: Vec<String>, opts: RHash) -> Result<i32, Error> {
511
+ let parsed = AttachOpts::parse(args, opts)?;
512
+ block_on(self.inner.attach_with(cmd, move |b| parsed.apply(b))).map_err(error::to_ruby)
513
+ }
514
+
515
+ /// Attach an interactive terminal running the sandbox's default shell.
516
+ fn attach_shell(&self) -> Result<i32, Error> {
517
+ block_on(self.inner.attach_shell()).map_err(error::to_ruby)
518
+ }
519
+ }
520
+
521
+ //--------------------------------------------------------------------------------------------------
522
+ // Attach option parsing
523
+ //--------------------------------------------------------------------------------------------------
524
+
525
+ struct AttachOpts {
526
+ args: Vec<String>,
527
+ cwd: Option<String>,
528
+ user: Option<String>,
529
+ env: Vec<(String, String)>,
530
+ detach_keys: Option<String>,
531
+ rlimits: Vec<(RlimitResource, u64, u64)>,
532
+ }
533
+
534
+ impl AttachOpts {
535
+ fn parse(args: Vec<String>, opts: RHash) -> Result<Self, Error> {
536
+ Ok(Self {
537
+ args,
538
+ cwd: conv::opt_string(opts, "cwd")?,
539
+ user: conv::opt_string(opts, "user")?,
540
+ env: conv::opt_string_map(opts, "env")?,
541
+ detach_keys: conv::opt_string(opts, "detach_keys")?,
542
+ rlimits: parse_rlimits(opts)?,
543
+ })
544
+ }
545
+
546
+ fn apply(self, mut b: AttachOptionsBuilder) -> AttachOptionsBuilder {
547
+ if !self.args.is_empty() {
548
+ b = b.args(self.args);
549
+ }
550
+ if let Some(cwd) = self.cwd {
551
+ b = b.cwd(cwd);
552
+ }
553
+ if let Some(user) = self.user {
554
+ b = b.user(user);
555
+ }
556
+ for (k, v) in self.env {
557
+ b = b.env(k, v);
558
+ }
559
+ if let Some(keys) = self.detach_keys {
560
+ b = b.detach_keys(keys);
561
+ }
562
+ for (resource, soft, hard) in self.rlimits {
563
+ b = b.rlimit_range(resource, soft, hard);
564
+ }
565
+ b
566
+ }
437
567
  }
438
568
 
439
569
  //--------------------------------------------------------------------------------------------------
@@ -511,6 +641,358 @@ fn parse_rlimits(opts: RHash) -> Result<Vec<(RlimitResource, u64, u64)>, Error>
511
641
  Ok(out)
512
642
  }
513
643
 
644
+ //--------------------------------------------------------------------------------------------------
645
+ // Patch parsing (mirrors the Python binding's `apply_patch`)
646
+ //--------------------------------------------------------------------------------------------------
647
+
648
+ /// Read a required string field from a per-patch Hash.
649
+ fn patch_str(h: RHash, key: &str) -> Result<String, Error> {
650
+ conv::opt_string(h, key)?
651
+ .ok_or_else(|| error::base_error(format!("patch is missing required key :{key}")))
652
+ }
653
+
654
+ /// Read a required field as raw bytes (for the binary `file` patch content).
655
+ fn patch_bytes(h: RHash, key: &str) -> Result<Vec<u8>, Error> {
656
+ let s = conv::opt::<RString>(h, key)?
657
+ .ok_or_else(|| error::base_error(format!("patch is missing required key :{key}")))?;
658
+ // Copy out while the GVL is held; the buffer is consumed synchronously here.
659
+ Ok(unsafe { s.as_slice() }.to_vec())
660
+ }
661
+
662
+ /// Parse the `patches` option into core `Patch` operations. The Ruby layer
663
+ /// normalizes each `Microsandbox::Patch.*` into a string-keyed Hash carrying a
664
+ /// `kind` discriminator plus the variant-specific fields.
665
+ fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
666
+ let mut out = Vec::new();
667
+ for h in conv::opt_hash_vec(opts, "patches")? {
668
+ let kind = patch_str(h, "kind")?;
669
+ let mode = conv::opt_u32(h, "mode")?;
670
+ let replace = conv::opt_bool(h, "replace")?;
671
+ let patch = match kind.as_str() {
672
+ "text" => Patch::Text {
673
+ path: patch_str(h, "path")?,
674
+ content: patch_str(h, "content")?,
675
+ mode,
676
+ replace,
677
+ },
678
+ "file" => Patch::File {
679
+ path: patch_str(h, "path")?,
680
+ content: patch_bytes(h, "content")?,
681
+ mode,
682
+ replace,
683
+ },
684
+ "append" => Patch::Append {
685
+ path: patch_str(h, "path")?,
686
+ content: patch_str(h, "content")?,
687
+ },
688
+ "copy_file" => Patch::CopyFile {
689
+ src: patch_str(h, "src")?.into(),
690
+ dst: patch_str(h, "dst")?,
691
+ mode,
692
+ replace,
693
+ },
694
+ "copy_dir" => Patch::CopyDir {
695
+ src: patch_str(h, "src")?.into(),
696
+ dst: patch_str(h, "dst")?,
697
+ replace,
698
+ },
699
+ "symlink" => Patch::Symlink {
700
+ target: patch_str(h, "target")?,
701
+ link: patch_str(h, "link")?,
702
+ replace,
703
+ },
704
+ "mkdir" => Patch::Mkdir {
705
+ path: patch_str(h, "path")?,
706
+ mode,
707
+ },
708
+ "remove" => Patch::Remove {
709
+ path: patch_str(h, "path")?,
710
+ },
711
+ other => {
712
+ return Err(error::base_error(format!(
713
+ "unknown patch kind {other:?} (expected one of \
714
+ text/file/append/copy_file/copy_dir/symlink/mkdir/remove)"
715
+ )))
716
+ }
717
+ };
718
+ out.push(patch);
719
+ }
720
+ Ok(out)
721
+ }
722
+
723
+ //--------------------------------------------------------------------------------------------------
724
+ // Network policy parsing (mirrors the Python binding's `apply_network`)
725
+ //--------------------------------------------------------------------------------------------------
726
+
727
+ /// Parse the `network_policy` option (a Hash normalized by the Ruby layer) into
728
+ /// a core `NetworkPolicy`. Returns `None` when the option is absent (bare
729
+ /// presets travel via the separate `network` key handled in `create`).
730
+ ///
731
+ /// Composition (mirrors the Go SDK's `NetworkConfig`): bulk domain-deny rules
732
+ /// come first (so they outrank later allow rules), then a preset's rules (if a
733
+ /// preset base is given), then the caller's explicit `rules`. Per-direction
734
+ /// defaults come from the explicit `default_egress`/`default_ingress` when set,
735
+ /// else the preset's defaults, else the asymmetric default (deny egress / allow
736
+ /// ingress).
737
+ fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
738
+ let Some(np) = conv::opt::<RHash>(opts, "network_policy")? else {
739
+ return Ok(None);
740
+ };
741
+
742
+ // Bulk domain denials → prepended deny-egress rules.
743
+ let mut rules: Vec<Rule> = Vec::new();
744
+ for d in conv::opt_string_vec(np, "deny_domains")? {
745
+ let domain = d
746
+ .parse()
747
+ .map_err(|e| error::base_error(format!("deny_domains {d:?}: {e}")))?;
748
+ rules.push(Rule::deny_egress(Destination::Domain(domain)));
749
+ }
750
+ for s in conv::opt_string_vec(np, "deny_domain_suffixes")? {
751
+ let suffix = s
752
+ .parse()
753
+ .map_err(|e| error::base_error(format!("deny_domain_suffixes {s:?}: {e}")))?;
754
+ rules.push(Rule::deny_egress(Destination::DomainSuffix(suffix)));
755
+ }
756
+
757
+ // Optional preset base (its rules and defaults seed the policy).
758
+ let (preset_egress, preset_ingress) = match conv::opt_string(np, "preset")? {
759
+ Some(p) => {
760
+ let mut base = network_preset(&p)?;
761
+ rules.append(&mut base.rules);
762
+ (Some(base.default_egress), Some(base.default_ingress))
763
+ }
764
+ None => (None, None),
765
+ };
766
+
767
+ // Caller's explicit rules come after preset rules.
768
+ for rd in conv::opt_hash_vec(np, "rules")? {
769
+ rules.push(parse_rule(rd)?);
770
+ }
771
+
772
+ let default_egress = match conv::opt_string(np, "default_egress")? {
773
+ Some(s) => action_from_str(&s)?,
774
+ None => preset_egress.unwrap_or(Action::Deny),
775
+ };
776
+ let default_ingress = match conv::opt_string(np, "default_ingress")? {
777
+ Some(s) => action_from_str(&s)?,
778
+ None => preset_ingress.unwrap_or(Action::Allow),
779
+ };
780
+
781
+ Ok(Some(NetworkPolicy {
782
+ default_egress,
783
+ default_ingress,
784
+ rules,
785
+ }))
786
+ }
787
+
788
+ fn network_preset(p: &str) -> Result<NetworkPolicy, Error> {
789
+ Ok(match p {
790
+ "none" | "disabled" | "disable" | "airgapped" => NetworkPolicy::none(),
791
+ "public" | "public_only" | "public-only" | "default" => NetworkPolicy::public_only(),
792
+ "all" | "allow_all" | "allow-all" => NetworkPolicy::allow_all(),
793
+ "non_local" | "non-local" | "nonlocal" => NetworkPolicy::non_local(),
794
+ other => {
795
+ return Err(error::base_error(format!(
796
+ "unknown network preset {other:?} (expected one of \
797
+ public_only/none/allow_all/non_local)"
798
+ )))
799
+ }
800
+ })
801
+ }
802
+
803
+ fn action_from_str(s: &str) -> Result<Action, Error> {
804
+ match s {
805
+ "allow" => Ok(Action::Allow),
806
+ "deny" => Ok(Action::Deny),
807
+ other => Err(error::base_error(format!(
808
+ "unknown network action {other:?} (expected allow/deny)"
809
+ ))),
810
+ }
811
+ }
812
+
813
+ fn direction_from_str(s: &str) -> Result<Direction, Error> {
814
+ match s {
815
+ "egress" => Ok(Direction::Egress),
816
+ "ingress" => Ok(Direction::Ingress),
817
+ "any" => Ok(Direction::Any),
818
+ other => Err(error::base_error(format!(
819
+ "unknown rule direction {other:?} (expected egress/ingress/any)"
820
+ ))),
821
+ }
822
+ }
823
+
824
+ fn protocol_from_str(s: &str) -> Result<Protocol, Error> {
825
+ match s {
826
+ "tcp" => Ok(Protocol::Tcp),
827
+ "udp" => Ok(Protocol::Udp),
828
+ "icmpv4" => Ok(Protocol::Icmpv4),
829
+ "icmpv6" => Ok(Protocol::Icmpv6),
830
+ other => Err(error::base_error(format!(
831
+ "unknown protocol {other:?} (expected tcp/udp/icmpv4/icmpv6)"
832
+ ))),
833
+ }
834
+ }
835
+
836
+ /// Parse a single rule Hash into a core `Rule`.
837
+ fn parse_rule(rd: RHash) -> Result<Rule, Error> {
838
+ let action = action_from_str(&patch_str(rd, "action")?)?;
839
+ let direction = match conv::opt_string(rd, "direction")? {
840
+ Some(s) => direction_from_str(&s)?,
841
+ None => Direction::Egress,
842
+ };
843
+ let kind = conv::opt_string(rd, "destination_kind")?;
844
+ let raw = conv::opt_string(rd, "destination")?;
845
+ let destination = parse_destination(kind.as_deref(), raw.as_deref())?;
846
+
847
+ let mut protocols = Vec::new();
848
+ for p in conv::opt_string_vec(rd, "protocols")? {
849
+ let proto = protocol_from_str(&p)?;
850
+ if !protocols.contains(&proto) {
851
+ protocols.push(proto);
852
+ }
853
+ }
854
+
855
+ let mut ports = Vec::new();
856
+ for p in conv::opt_string_vec(rd, "ports")? {
857
+ let range = parse_port_range(&p)?;
858
+ if !ports.contains(&range) {
859
+ ports.push(range);
860
+ }
861
+ }
862
+
863
+ Ok(Rule {
864
+ direction,
865
+ destination,
866
+ protocols,
867
+ ports,
868
+ action,
869
+ })
870
+ }
871
+
872
+ /// Parse a port string into a `PortRange`. Accepts a single port (`"443"`) or
873
+ /// an inclusive range (`"8000-9000"`).
874
+ fn parse_port_range(raw: &str) -> Result<PortRange, Error> {
875
+ let invalid = || error::base_error(format!("invalid port {raw:?} (expected N or N-M)"));
876
+ if let Some((lo, hi)) = raw.split_once('-') {
877
+ let lo: u16 = lo.trim().parse().map_err(|_| invalid())?;
878
+ let hi: u16 = hi.trim().parse().map_err(|_| invalid())?;
879
+ if lo > hi {
880
+ return Err(error::base_error(format!(
881
+ "invalid port range {raw:?}: low {lo} exceeds high {hi}"
882
+ )));
883
+ }
884
+ Ok(PortRange::range(lo, hi))
885
+ } else {
886
+ let p: u16 = raw.trim().parse().map_err(|_| invalid())?;
887
+ Ok(PortRange::single(p))
888
+ }
889
+ }
890
+
891
+ /// Resolve a destination from an explicit `kind` + raw value, or — when `kind`
892
+ /// is absent — classify the raw shorthand string. Mirrors the Python binding's
893
+ /// `parse_network_destination` / `parse_shorthand_destination`.
894
+ fn parse_destination(kind: Option<&str>, raw: Option<&str>) -> Result<Destination, Error> {
895
+ let required = |raw: Option<&str>, kind: &str| -> Result<String, Error> {
896
+ raw.map(str::to_string).ok_or_else(|| {
897
+ error::base_error(format!(
898
+ "destination is required for destination kind {kind:?}"
899
+ ))
900
+ })
901
+ };
902
+ match kind {
903
+ Some("any") => Ok(Destination::Any),
904
+ Some("ip") => parse_ip_destination(&required(raw, "ip")?),
905
+ Some("cidr") => parse_cidr_destination(&required(raw, "cidr")?),
906
+ Some("domain") => parse_domain_destination(&required(raw, "domain")?),
907
+ Some("domain_suffix") | Some("domain-suffix") => {
908
+ parse_domain_suffix_destination(&required(raw, "domain_suffix")?)
909
+ }
910
+ Some("group") => parse_group_destination(&required(raw, "group")?),
911
+ Some(other) => Err(error::base_error(format!(
912
+ "unknown destination kind {other:?}"
913
+ ))),
914
+ None => parse_shorthand_destination(raw),
915
+ }
916
+ }
917
+
918
+ fn parse_shorthand_destination(raw: Option<&str>) -> Result<Destination, Error> {
919
+ let Some(raw) = raw else {
920
+ return Ok(Destination::Any);
921
+ };
922
+ if raw == "*" {
923
+ return Ok(Destination::Any);
924
+ }
925
+ if let Some(rest) = raw.strip_prefix("domain=") {
926
+ return parse_domain_destination(rest);
927
+ }
928
+ if let Some(rest) = raw.strip_prefix("suffix=") {
929
+ return parse_domain_suffix_destination(rest);
930
+ }
931
+ if let Some(dest) = maybe_group_destination(raw) {
932
+ return Ok(dest);
933
+ }
934
+ if raw.starts_with('.') {
935
+ return parse_domain_suffix_destination(raw);
936
+ }
937
+ if raw.contains('/') {
938
+ return parse_cidr_destination(raw);
939
+ }
940
+ if raw.parse::<std::net::IpAddr>().is_ok() {
941
+ return parse_ip_destination(raw);
942
+ }
943
+ parse_domain_destination(raw)
944
+ }
945
+
946
+ fn parse_ip_destination(raw: &str) -> Result<Destination, Error> {
947
+ let ip: std::net::IpAddr = raw
948
+ .parse()
949
+ .map_err(|e| error::base_error(format!("invalid IP address {raw:?}: {e}")))?;
950
+ let prefix = if ip.is_ipv4() { 32 } else { 128 };
951
+ let cidr = ipnetwork::IpNetwork::new(ip, prefix)
952
+ .map_err(|e| error::base_error(format!("invalid IP address {raw:?}: {e}")))?;
953
+ Ok(Destination::Cidr(cidr))
954
+ }
955
+
956
+ fn parse_cidr_destination(raw: &str) -> Result<Destination, Error> {
957
+ let cidr: ipnetwork::IpNetwork = raw
958
+ .parse()
959
+ .map_err(|e| error::base_error(format!("invalid CIDR {raw:?}: {e}")))?;
960
+ Ok(Destination::Cidr(cidr))
961
+ }
962
+
963
+ fn parse_domain_destination(raw: &str) -> Result<Destination, Error> {
964
+ let name = raw
965
+ .parse()
966
+ .map_err(|e| error::base_error(format!("invalid domain {raw:?}: {e}")))?;
967
+ Ok(Destination::Domain(name))
968
+ }
969
+
970
+ fn parse_domain_suffix_destination(raw: &str) -> Result<Destination, Error> {
971
+ let name = raw
972
+ .parse()
973
+ .map_err(|e| error::base_error(format!("invalid domain suffix {raw:?}: {e}")))?;
974
+ Ok(Destination::DomainSuffix(name))
975
+ }
976
+
977
+ fn parse_group_destination(raw: &str) -> Result<Destination, Error> {
978
+ maybe_group_destination(raw)
979
+ .ok_or_else(|| error::base_error(format!("unknown destination group {raw:?}")))
980
+ }
981
+
982
+ fn maybe_group_destination(raw: &str) -> Option<Destination> {
983
+ let group = match raw {
984
+ "public" => DestinationGroup::Public,
985
+ "loopback" => DestinationGroup::Loopback,
986
+ "private" => DestinationGroup::Private,
987
+ "link-local" | "link_local" => DestinationGroup::LinkLocal,
988
+ "metadata" => DestinationGroup::Metadata,
989
+ "multicast" => DestinationGroup::Multicast,
990
+ "host" => DestinationGroup::Host,
991
+ _ => return None,
992
+ };
993
+ Some(Destination::Group(group))
994
+ }
995
+
514
996
  //--------------------------------------------------------------------------------------------------
515
997
  // Registry option parsing
516
998
  //--------------------------------------------------------------------------------------------------
@@ -876,5 +1358,14 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
876
1358
  class.define_method("fs_copy_from_host", method!(Sandbox::fs_copy_from_host, 2))?;
877
1359
  class.define_method("fs_copy_to_host", method!(Sandbox::fs_copy_to_host, 2))?;
878
1360
 
1361
+ class.define_method("ssh_open_client", method!(Sandbox::ssh_open_client, 1))?;
1362
+ class.define_method(
1363
+ "ssh_prepare_server",
1364
+ method!(Sandbox::ssh_prepare_server, 1),
1365
+ )?;
1366
+
1367
+ class.define_method("attach", method!(Sandbox::attach, 3))?;
1368
+ class.define_method("attach_shell", method!(Sandbox::attach_shell, 0))?;
1369
+
879
1370
  Ok(())
880
1371
  }