dsh-eval-guard 0.7.2 → 0.7.4

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.
Files changed (63) hide show
  1. package/ISSUES.md +239 -0
  2. package/README.md +48 -18
  3. package/assets/neural-cifar/workspace.zip +0 -0
  4. package/assets/neural-cpp/workspace.zip +0 -0
  5. package/assets/neural-fp32/workspace.zip +0 -0
  6. package/assets/neural-pendulum/SPEC.md +43 -35
  7. package/assets/neural-pendulum/TASK.md +139 -33
  8. package/assets/neural-pendulum/rl-feasibility/README.md +60 -0
  9. package/assets/neural-pendulum/rl-feasibility/RESULTS.md +370 -0
  10. package/assets/neural-pendulum/rl-feasibility/evaluate.py +107 -0
  11. package/assets/neural-pendulum/rl-feasibility/finalize_run.py +168 -0
  12. package/assets/neural-pendulum/rl-feasibility/models.py +124 -0
  13. package/assets/neural-pendulum/rl-feasibility/obs.py +205 -0
  14. package/assets/neural-pendulum/rl-feasibility/plot_selected.py +55 -0
  15. package/assets/neural-pendulum/rl-feasibility/reference_controller.py +112 -0
  16. package/assets/neural-pendulum/rl-feasibility/results/curves_selected.png +0 -0
  17. package/assets/neural-pendulum/rl-feasibility/results/reference_controller.json +417 -0
  18. package/assets/neural-pendulum/rl-feasibility/results/summary.json +1599 -0
  19. package/assets/neural-pendulum/rl-feasibility/results/table.md +39 -0
  20. package/assets/neural-pendulum/rl-feasibility/sim.py +324 -0
  21. package/assets/neural-pendulum/rl-feasibility/summarize.py +175 -0
  22. package/assets/neural-pendulum/rl-feasibility/task.py +342 -0
  23. package/assets/neural-pendulum/rl-feasibility/test_contract.py +188 -0
  24. package/assets/neural-pendulum/rl-feasibility/train_ppo.py +403 -0
  25. package/assets/neural-pendulum/rl-feasibility/train_sac.py +406 -0
  26. package/assets/neural-pendulum/sim.cpp +13 -2
  27. package/assets/neural-pendulum/sim_eval +0 -0
  28. package/assets/neural-pendulum/workspace.zip +0 -0
  29. package/eval/README.md +19 -9
  30. package/eval/bomberman.py +5 -3
  31. package/eval/commit0.py +10 -3
  32. package/eval/common.py +169 -5
  33. package/eval/ctf.py +4 -2
  34. package/eval/featurebench.py +12 -8
  35. package/eval/land.py +4 -2
  36. package/eval/legacy/bomberman.py +5 -2
  37. package/eval/legacy/ctf.py +5 -2
  38. package/eval/legacy/featurebench.py +55 -15
  39. package/eval/legacy/land.py +6 -3
  40. package/eval/legacy/swe_bench_pro.py +57 -16
  41. package/eval/legacy/tank.py +2 -2
  42. package/eval/legacy/warehouse.py +4 -4
  43. package/eval/neural-cpp.py +9 -6
  44. package/eval/neural-fp32.py +17 -5
  45. package/eval/swe-bench-pro.py +11 -7
  46. package/eval/tank.py +11 -2
  47. package/eval/warehouse.py +17 -1
  48. package/lib/client/index.js +56 -29
  49. package/lib/client/index.js.map +1 -1
  50. package/lib/index.js +883 -298
  51. package/native/dsh-eval-supervisor +0 -0
  52. package/native/supervisor.c +232 -36
  53. package/neural-cifar-eval/README.md +5 -0
  54. package/neural-cifar-eval/common.py +13 -0
  55. package/neural-cifar-eval/ops_eval.py +17 -5
  56. package/neural-cifar-eval/run_eval.py +42 -32
  57. package/neural-pendulum-eval/README.md +62 -0
  58. package/neural-pendulum-eval/ops_eval.py +603 -0
  59. package/neural-pendulum-eval/run_eval.py +218 -137
  60. package/neural-pendulum-eval/sim_eval +0 -0
  61. package/package.json +7 -11
  62. package/assets/neural-pendulum/hidden/test_infra.cpp +0 -631
  63. package/assets/neural-pendulum/include/nn/nn.hpp +0 -88
Binary file
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Installs a seccomp user-notification filter before exec'ing bwrap and enforces
5
5
  * a path policy on file-access and network syscalls. Normal syscalls are not
6
- * logged; only denied operations are appended to the audit file and cause the
7
- * whole process group to be killed.
6
+ * logged. File-policy violations are audited and kill the process group;
7
+ * AF_INET/AF_INET6 connect/bind/sendto are audited and soft-denied with EPERM
8
+ * so local software checks (pip version probe, NSS fallback) can continue.
8
9
  *
9
10
  * Usage:
10
11
  * dsh-eval-supervisor --policy <policy-file> --audit <audit-file> -- <program> [args...]
@@ -57,6 +58,17 @@
57
58
  #ifndef SYS_open_by_handle_at
58
59
  #define SYS_open_by_handle_at 304
59
60
  #endif
61
+ #ifndef SYS_fchmodat2
62
+ #define SYS_fchmodat2 452
63
+ #endif
64
+
65
+ #if defined(__x86_64__)
66
+ #define SUPERVISOR_AUDIT_ARCH AUDIT_ARCH_X86_64
67
+ #elif defined(__aarch64__)
68
+ #define SUPERVISOR_AUDIT_ARCH AUDIT_ARCH_AARCH64
69
+ #else
70
+ #error "dsh-eval-supervisor: unsupported architecture"
71
+ #endif
60
72
 
61
73
  #define MAX_RULES 512
62
74
  #define MAX_PATH 4096
@@ -76,14 +88,33 @@ static int g_dev_count;
76
88
  static Rule g_rules[MAX_RULES];
77
89
  static int g_rule_count;
78
90
  static int g_audit_fd = -1;
91
+ static int g_soft_deny; /* network-family denial: EPERM, do not kill the group */
79
92
  static volatile sig_atomic_t g_stop;
93
+ /* The sandbox mounted a private tmpfs at /tmp (workspace under host /tmp). */
94
+ static int g_tmpfs_tmp;
95
+ static char g_requested_cwd[MAX_PATH];
80
96
  /* bwrap itself needs AF_UNIX and AF_NETLINK sockets during setup. The target
81
97
  * command is allowed no sockets at all once bwrap has exec'd it. */
82
98
  static int g_command_started;
83
99
  static pid_t g_current_pid;
84
100
  static int g_tmpbind;
85
101
 
86
- /** Whether the notifying process is bubblewrap itself (setup phase). */
102
+ /**
103
+ * Whether the notifying process is bubblewrap itself (setup phase).
104
+ *
105
+ * The bwrap command may be a package-manager shim that re-execs several times,
106
+ * so identification is by executable name (as upstream did). The setup-only
107
+ * exemptions it enables are restricted to paths that do not exist after
108
+ * pivot_root, and the command-started gate closes them for the target.
109
+ */
110
+ static int is_bwrap_name(const char *name) {
111
+ if (name == NULL || name[0] == '\0') return 0;
112
+ /* Debian/Fedora ship /usr/bin/bwrap; the project/package is also commonly
113
+ * called bubblewrap. Accept both spellings so bwrap's own setup writes
114
+ * (uid_map/gid_map under /proc) are recognized regardless of binary name. */
115
+ return strstr(name, "bwrap") != NULL || strstr(name, "bubblewrap") != NULL;
116
+ }
117
+
87
118
  static int is_bwrap(pid_t pid) {
88
119
  char proc[64];
89
120
  char exe[MAX_PATH];
@@ -93,7 +124,7 @@ static int is_bwrap(pid_t pid) {
93
124
  exe[n] = '\0';
94
125
  const char *base = strrchr(exe, '/');
95
126
  base = base == NULL ? exe : base + 1;
96
- return strstr(base, "bwrap") != NULL;
127
+ return is_bwrap_name(base);
97
128
  }
98
129
 
99
130
  static void on_signal(int sig) {
@@ -302,6 +333,9 @@ static int rules_override_read(const char *path, int initial) {
302
333
 
303
334
  static int path_allowed(const char *path, int write_access) {
304
335
  if (g_workspace[0] != '\0' && starts_with_seg(path, g_workspace)) return 1;
336
+ /* A private tmpfs at /tmp is ephemeral by construction: it never exposes
337
+ * host files, so both read and write are safe. */
338
+ if (g_tmpfs_tmp && starts_with_seg(path, "/tmp")) return 1;
305
339
  /* bubblewrap builds its new root under /tmp and /newroot before pivot_root.
306
340
  * Those transient paths exist only during setup and are removed on exit. */
307
341
  if ((!g_command_started || (g_current_pid > 0 && is_bwrap(g_current_pid))) &&
@@ -393,6 +427,17 @@ __attribute__((unused)) static int should_notify(long nr) {
393
427
  case SYS_faccessat2:
394
428
  case SYS_readlink:
395
429
  case SYS_readlinkat:
430
+ case SYS_symlink:
431
+ case SYS_symlinkat:
432
+ case SYS_link:
433
+ case SYS_linkat:
434
+ case SYS_mknod:
435
+ case SYS_mknodat:
436
+ case SYS_utimensat:
437
+ case SYS_statx:
438
+ #ifdef SYS_fchmodat2
439
+ case SYS_fchmodat2:
440
+ #endif
396
441
  case SYS_socket:
397
442
  case SYS_connect:
398
443
  case SYS_bind:
@@ -460,6 +505,17 @@ static const char *syscall_name(long nr) {
460
505
  case SYS_faccessat2: return "faccessat2";
461
506
  case SYS_readlink: return "readlink";
462
507
  case SYS_readlinkat: return "readlinkat";
508
+ case SYS_symlink: return "symlink";
509
+ case SYS_symlinkat: return "symlinkat";
510
+ case SYS_link: return "link";
511
+ case SYS_linkat: return "linkat";
512
+ case SYS_mknod: return "mknod";
513
+ case SYS_mknodat: return "mknodat";
514
+ case SYS_utimensat: return "utimensat";
515
+ case SYS_statx: return "statx";
516
+ #ifdef SYS_fchmodat2
517
+ case SYS_fchmodat2: return "fchmodat2";
518
+ #endif
463
519
  case SYS_socket: return "socket";
464
520
  case SYS_connect: return "connect";
465
521
  case SYS_bind: return "bind";
@@ -501,49 +557,63 @@ static int check_notification(pid_t pid, const struct seccomp_notif *req, int *w
501
557
 
502
558
  path_out[0] = '\0';
503
559
  g_current_pid = pid;
560
+ g_soft_deny = 0;
504
561
  if (nr == SYS_execve || nr == SYS_execveat) {
505
562
  char executable[MAX_PATH];
506
563
  executable[0] = '\0';
507
564
  (void)read_remote_string(pid, nr == SYS_execve ? a0 : a1, executable, sizeof(executable));
508
565
  const char *base = strrchr(executable, '/');
509
566
  base = base == NULL ? executable : base + 1;
510
- if (strstr(base, "bwrap") == NULL) g_command_started = 1;
567
+ if (!is_bwrap_name(base)) g_command_started = 1;
511
568
  *write_access = 0;
512
569
  snprintf(path_out, MAX_PATH, "<exec>");
513
570
  return 1;
514
571
  }
515
572
  if (nr == SYS_socket) {
516
- int domain = (int)a0;
573
+ /* Creating a socket does not transmit anything. bwrap already put the
574
+ * command in a private network namespace; tools such as pip create an
575
+ * AF_INET socket for local/version checks without connecting. All the
576
+ * operations that can actually reach a peer (connect/bind/listen/accept/
577
+ * sendto/recvfrom) remain denied below. */
517
578
  *write_access = 0;
518
579
  snprintf(path_out, MAX_PATH, "<network:socket>");
519
- /* AF_UNIX/AF_NETLINK stay inside the private network namespace and are
520
- * needed by NSS, Python and the dynamic loader. AF_INET/AF_INET6 and raw
521
- * sockets are still blocked. */
522
- if (domain == AF_UNIX || domain == AF_NETLINK) return 1;
523
- return 0;
580
+ return 1;
524
581
  }
525
582
  if (is_network_syscall(nr)) {
526
583
  *write_access = 0;
527
584
  snprintf(path_out, MAX_PATH, "<network:%s>", syscall_name(nr));
528
- /* Only AF_UNIX/AF_NETLINK sockets can exist now; their traffic is local
529
- * to the private namespace and cannot reach the host network. */
585
+ /* bwrap itself needs AF_UNIX bind/listen/connect while it sets up the
586
+ * namespace; only the target's traffic is checked. */
587
+ if (!g_command_started || (g_current_pid > 0 && is_bwrap(g_current_pid))) return 1;
588
+ if (nr == SYS_listen || nr == SYS_accept || nr == SYS_accept4 || nr == SYS_recvfrom) {
589
+ /* No peer can appear outside the private netns; these operate on an
590
+ * already-authorized local socket. */
591
+ return 1;
592
+ }
593
+ if (nr == SYS_connect || nr == SYS_bind || nr == SYS_sendto) {
594
+ unsigned long addr_ptr = nr == SYS_sendto ? (unsigned long)req->data.args[4] : a1;
595
+ unsigned short family = 0;
596
+ if (addr_ptr != 0 && read_remote(pid, addr_ptr, &family, sizeof(family)) == 0) {
597
+ if (family == AF_UNIX || family == AF_NETLINK) return 1;
598
+ if (family == AF_INET || family == AF_INET6) {
599
+ /* A version check or NSS probe may create the socket and attempt a
600
+ * connection; return EPERM instead of killing the whole group. The
601
+ * private netns is the hard boundary; this is the graceful denial. */
602
+ g_soft_deny = 1;
603
+ snprintf(path_out, MAX_PATH, "<network:%s:inet>", syscall_name(nr));
604
+ return 0;
605
+ }
606
+ }
607
+ g_soft_deny = 1;
608
+ return 0;
609
+ }
530
610
  return 1;
531
611
  }
532
612
  if (nr == SYS_io_uring_setup || nr == SYS_ptrace || nr == SYS_open_by_handle_at) {
613
+ /* io_uring worker threads bypass a process's seccomp filter, so it can never
614
+ * be allowed inside the sandbox (libuv stays on epoll via UV_USE_IO_URING=0). */
533
615
  *write_access = 0;
534
616
  snprintf(path_out, MAX_PATH, "<forbidden:%s>", syscall_name(nr));
535
- if (nr == SYS_io_uring_setup) {
536
- char proc[64];
537
- char exe[MAX_PATH];
538
- snprintf(proc, sizeof(proc), "/proc/%d/exe", (int)pid);
539
- ssize_t exe_n = readlink(proc, exe, sizeof(exe) - 1);
540
- if (exe_n > 0) {
541
- exe[exe_n] = '\0';
542
- const char *base = strrchr(exe, '/');
543
- base = base == NULL ? exe : base + 1;
544
- if (strncmp(base, "node", 4) == 0) return 1;
545
- }
546
- }
547
617
  return 0;
548
618
  }
549
619
 
@@ -606,15 +676,59 @@ static int check_notification(pid_t pid, const struct seccomp_notif *req, int *w
606
676
  case SYS_readlinkat:
607
677
  case SYS_fchmodat:
608
678
  case SYS_fchownat:
679
+ case SYS_fchmodat2:
680
+ dirfd = (int)a0;
681
+ path_ptr = a1;
682
+ write = (nr == SYS_unlinkat || nr == SYS_mkdirat || nr == SYS_fchmodat || nr == SYS_fchownat || nr == SYS_fchmodat2);
683
+ break;
684
+ case SYS_symlink:
685
+ path_ptr = a1;
686
+ write = 1;
687
+ break;
688
+ case SYS_symlinkat:
689
+ dirfd = (int)a1;
690
+ path_ptr = a2;
691
+ write = 1;
692
+ break;
693
+ case SYS_link:
694
+ path_ptr = a0;
695
+ write = 0;
696
+ break;
697
+ case SYS_linkat:
609
698
  dirfd = (int)a0;
610
699
  path_ptr = a1;
611
- write = (nr == SYS_unlinkat || nr == SYS_mkdirat || nr == SYS_fchmodat || nr == SYS_fchownat);
700
+ write = 0;
701
+ break;
702
+ case SYS_mknod:
703
+ path_ptr = a0;
704
+ write = 1;
705
+ break;
706
+ case SYS_mknodat:
707
+ dirfd = (int)a0;
708
+ path_ptr = a1;
709
+ write = 1;
710
+ break;
711
+ case SYS_utimensat:
712
+ dirfd = (int)a0;
713
+ path_ptr = a1;
714
+ write = 1;
715
+ break;
716
+ case SYS_statx:
717
+ dirfd = (int)a0;
718
+ path_ptr = a1;
719
+ write = 0;
612
720
  break;
613
721
  default:
614
722
  *write_access = 0;
615
723
  return 1;
616
724
  }
617
725
 
726
+ if (path_ptr == 0) {
727
+ /* fd-only form (for example utimensat with AT_EMPTY_PATH): no new path is touched. */
728
+ snprintf(path_out, MAX_PATH, "<fd>");
729
+ *write_access = write;
730
+ return 1;
731
+ }
618
732
  if (read_remote_string(pid, path_ptr, raw, sizeof(raw)) != 0) {
619
733
  snprintf(path_out, MAX_PATH, "<unreadable>");
620
734
  *write_access = write;
@@ -641,13 +755,38 @@ static int check_notification(pid_t pid, const struct seccomp_notif *req, int *w
641
755
  }
642
756
  snprintf(path_out, MAX_PATH, "%s", path);
643
757
  *write_access = write;
758
+ /* renames and hard links also name a second, mutated path; it must pass the
759
+ * same write policy even though only the first name was resolved above. */
760
+ if (nr == SYS_rename || nr == SYS_renameat || nr == SYS_renameat2 || nr == SYS_link || nr == SYS_linkat) {
761
+ unsigned long second_ptr;
762
+ int second_dirfd = AT_FDCWD;
763
+ if (nr == SYS_rename || nr == SYS_link) {
764
+ second_ptr = a1;
765
+ } else {
766
+ second_dirfd = (int)a2;
767
+ second_ptr = a3;
768
+ }
769
+ char raw2[MAX_PATH];
770
+ char path2[MAX_PATH];
771
+ int existed2 = 0;
772
+ raw2[0] = '\0';
773
+ if (read_remote_string(pid, second_ptr, raw2, sizeof(raw2)) == 0 && raw2[0] != '\0') {
774
+ if (resolve_path(pid, second_dirfd, raw2, &existed2, path2, sizeof(path2)) != 0
775
+ || !path_allowed(path2, 1)) {
776
+ snprintf(path_out, MAX_PATH, "%s", path2);
777
+ *write_access = 1;
778
+ return 0;
779
+ }
780
+ }
781
+ }
644
782
  /* `mkdir -p` walks every existing ancestor and calls mkdir on each one.
645
783
  * Creating an already-existing workspace ancestor is a kernel no-op, not an
646
784
  * out-of-workspace write. */
647
785
  if (write && existed && (nr == SYS_mkdir || nr == SYS_mkdirat) && is_exact_policy_path(path)) return 1;
648
786
  /* bwrap writes the user-namespace uid/gid maps under /proc before it execs
649
787
  * the target. Nothing the target runs may write under /proc. */
650
- if (write && g_current_pid > 0 && is_bwrap(g_current_pid) && starts_with_seg(path, "/proc")) return 1;
788
+ if (write && starts_with_seg(path, "/proc") &&
789
+ (!g_command_started || (g_current_pid > 0 && is_bwrap(g_current_pid)))) return 1;
651
790
  return path_allowed(path, write) ? 1 : 0;
652
791
  }
653
792
 
@@ -704,6 +843,20 @@ static void reply_loop(int fd) {
704
843
  char syscall_buf[64];
705
844
  snprintf(syscall_buf, sizeof(syscall_buf), "%s", syscall_name((long)req.data.nr));
706
845
  audit_record((pid_t)req.pid, syscall_buf, path, write_access);
846
+ if (g_soft_deny) {
847
+ /* A software check (pip version probe, NSS lookup fallback) gets a
848
+ * normal EPERM and can continue; file-policy violations still kill. */
849
+ respond(fd, &req, 0);
850
+ continue;
851
+ }
852
+ /* io_uring is a soft denial: libuv probes it despite UV_USE_IO_URING=0
853
+ * and must receive EPERM and fall back to epoll instead of taking the
854
+ * whole command group down. No io_uring instance can exist, so the
855
+ * path-check bypass this syscall would otherwise enable stays closed. */
856
+ if ((long)req.data.nr == SYS_io_uring_setup) {
857
+ respond(fd, &req, 0);
858
+ continue;
859
+ }
707
860
  char debug_exe[512]; char debug_proc[64];
708
861
  snprintf(debug_proc, sizeof(debug_proc), "/proc/%d/exe", (int)req.pid);
709
862
  ssize_t debug_n = readlink(debug_proc, debug_exe, sizeof(debug_exe) - 1);
@@ -747,6 +900,8 @@ static void parse_policy(const char *file) {
747
900
  if (g_dev_count < MAX_RULES) snprintf(g_dev[g_dev_count++], MAX_PATH, "%s", value);
748
901
  } else if (strcmp(kind, "tmpbind") == 0) {
749
902
  if (strcmp(value, g_workspace) != 0) g_tmpbind = 1;
903
+ } else if (strcmp(kind, "tmpfs") == 0) {
904
+ if (strcmp(value, "/tmp") == 0) g_tmpfs_tmp = 1;
750
905
  } else if (strcmp(kind, "rule") == 0) {
751
906
  char *sub = value;
752
907
  char *arg = strchr(sub, ' ');
@@ -772,6 +927,10 @@ int main(int argc, char **argv) {
772
927
  if (strcmp(argv[i], "--") == 0) { i++; break; }
773
928
  if (strcmp(argv[i], "--policy") == 0 && i + 1 < argc) { policy = argv[++i]; continue; }
774
929
  if (strcmp(argv[i], "--audit") == 0 && i + 1 < argc) { audit = argv[++i]; continue; }
930
+ if (strcmp(argv[i], "--cwd") == 0 && i + 1 < argc) {
931
+ snprintf(g_requested_cwd, sizeof(g_requested_cwd), "%s", argv[++i]);
932
+ continue;
933
+ }
775
934
  if (strcmp(argv[i], "--label") == 0 && i + 1 < argc) { i++; continue; }
776
935
  fprintf(stderr, "dsh-eval-supervisor: unknown argument %s\n", argv[i]);
777
936
  return 125;
@@ -825,7 +984,7 @@ int main(int argc, char **argv) {
825
984
 
826
985
  /* arch gate */
827
986
  filter[n++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch));
828
- filter[n++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0);
987
+ filter[n++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SUPERVISOR_AUDIT_ARCH, 1, 0);
829
988
  filter[n++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS);
830
989
  filter[n++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr));
831
990
  /* One comparison per intercepted syscall; long but far below the 4096 limit. */
@@ -861,6 +1020,17 @@ int main(int argc, char **argv) {
861
1020
  ADD_SYSCALL(SYS_faccessat2);
862
1021
  ADD_SYSCALL(SYS_readlink);
863
1022
  ADD_SYSCALL(SYS_readlinkat);
1023
+ ADD_SYSCALL(SYS_symlink);
1024
+ ADD_SYSCALL(SYS_symlinkat);
1025
+ ADD_SYSCALL(SYS_link);
1026
+ ADD_SYSCALL(SYS_linkat);
1027
+ ADD_SYSCALL(SYS_mknod);
1028
+ ADD_SYSCALL(SYS_mknodat);
1029
+ ADD_SYSCALL(SYS_utimensat);
1030
+ ADD_SYSCALL(SYS_statx);
1031
+ #ifdef SYS_fchmodat2
1032
+ ADD_SYSCALL(SYS_fchmodat2);
1033
+ #endif
864
1034
  ADD_SYSCALL(SYS_socket);
865
1035
  ADD_SYSCALL(SYS_connect);
866
1036
  ADD_SYSCALL(SYS_bind);
@@ -908,19 +1078,45 @@ int main(int argc, char **argv) {
908
1078
  /* bwrap pivots into a fresh root and would leave the target at /. Preserve
909
1079
  * the cwd the consumer chose (the tool's workdir) with bwrap --chdir. */
910
1080
  {
911
- char cwd[MAX_PATH];
1081
+ char inherited[MAX_PATH];
912
1082
  const char *use_cwd = g_workspace;
913
- if (getcwd(cwd, sizeof(cwd)) != NULL && starts_with_seg(cwd, g_workspace)) use_cwd = cwd;
914
- int sep = -1;
915
- for (int k = i + 1; k < argc; k++) if (strcmp(argv[k], "--") == 0) sep = k;
916
- if (sep < 0) sep = argc;
917
- char **next = (char **)calloc((size_t)(argc + 3), sizeof(char *));
1083
+ if (g_requested_cwd[0] != '\0' && starts_with_seg(g_requested_cwd, g_workspace)) use_cwd = g_requested_cwd;
1084
+ else if (getcwd(inherited, sizeof(inherited)) != NULL && starts_with_seg(inherited, g_workspace)) use_cwd = inherited;
1085
+ char **next = (char **)calloc((size_t)(argc + 16), sizeof(char *));
918
1086
  if (next == NULL) _exit(125);
919
1087
  int out = 0;
920
- for (int k = i; k < sep; k++) next[out++] = argv[k];
1088
+ next[out++] = argv[i]; /* bwrap */
921
1089
  next[out++] = (char *)"--chdir";
922
1090
  next[out++] = (char *)use_cwd;
923
- for (int k = sep; k < argc; k++) next[out++] = argv[k];
1091
+ int env_restored = 0;
1092
+ for (int k = i + 1; k < argc; k++) {
1093
+ next[out++] = argv[k];
1094
+ /* The plugin clears the inherited environment with --clearenv. Re-add
1095
+ * the private launch markers the subprocess provider passed to THIS
1096
+ * process (never to arbitrary children) so run_code's control pipe and
1097
+ * packaged-PTC heap flag survive bwrap. */
1098
+ if (!env_restored && strcmp(argv[k], "--clearenv") == 0) {
1099
+ env_restored = 1;
1100
+ const char *control = getenv("DSH_SUBPROCESS_CONTROL");
1101
+ if (control != NULL && strcmp(control, "pipe") == 0) {
1102
+ next[out++] = (char *)"--setenv";
1103
+ next[out++] = (char *)"DSH_SUBPROCESS_CONTROL";
1104
+ next[out++] = (char *)"pipe";
1105
+ }
1106
+ const char *ptc = getenv("DSH_PTC_RUNTIME_NODE");
1107
+ if (ptc != NULL && ptc[0] != '\0') {
1108
+ next[out++] = (char *)"--setenv";
1109
+ next[out++] = (char *)"DSH_PTC_RUNTIME_NODE";
1110
+ next[out++] = (char *)ptc;
1111
+ const char *node_options = getenv("NODE_OPTIONS");
1112
+ if (node_options != NULL && node_options[0] != '\0') {
1113
+ next[out++] = (char *)"--setenv";
1114
+ next[out++] = (char *)"NODE_OPTIONS";
1115
+ next[out++] = (char *)node_options;
1116
+ }
1117
+ }
1118
+ }
1119
+ }
924
1120
  next[out] = NULL;
925
1121
  execvp(next[0], next);
926
1122
  }
@@ -55,3 +55,8 @@ gate. The 4-thread 0.02 s/image requirement is reported as
55
55
  - PyTorch is required on the evaluation machine for the operator oracle.
56
56
  - The 4-thread and all-thread model runs use the same trained model.
57
57
  - The validation data and labels never enter the candidate workspace.
58
+ - The label file is read into the evaluator's memory; `infer` receives a copy of the
59
+ images in an isolated temporary directory, so it cannot derive the labels path
60
+ from its `--images` argument. Candidate/spawned processes also lose the host
61
+ `EVAL_*` handle variables.
62
+ - FPS and mean seconds/image include process startup and model loading.
@@ -12,6 +12,19 @@ from pathlib import Path
12
12
 
13
13
  EVAL_ROOT = Path(__file__).resolve().parent
14
14
 
15
+ HOST_EVAL_KEYS = frozenset({
16
+ 'EVAL_RESULT_FILE', 'EVAL_JOB_DIR', 'EVAL_INSTALL_TOKEN',
17
+ 'EVAL_BASELINE_DIR', 'EVAL_VENV_MANIFEST',
18
+ })
19
+
20
+
21
+ def child_env() -> dict[str, str]:
22
+ """Environment for child processes that may run candidate code."""
23
+ return {
24
+ key: value for key, value in os.environ.items()
25
+ if key not in HOST_EVAL_KEYS and key not in {'PYTHONPATH', 'PYTHONHOME'}
26
+ }
27
+
15
28
 
16
29
  def parse_workspace() -> Path:
17
30
  parser = argparse.ArgumentParser(description='single-workspace evaluator')
@@ -20,7 +20,7 @@ import numpy as np
20
20
  import torch
21
21
  import torch.nn.functional as F
22
22
 
23
- from common import EVAL_ROOT, clean_copy, emit, fail, parse_workspace
23
+ from common import EVAL_ROOT, child_env, clean_copy, emit, fail, parse_workspace
24
24
 
25
25
  torch.set_num_threads(1)
26
26
  torch.set_float32_matmul_precision('highest')
@@ -29,6 +29,9 @@ torch.backends.mkldnn.deterministic = True
29
29
  torch.manual_seed(1234)
30
30
 
31
31
  SEEDS = (11, 22, 33)
32
+ # A single CLI invocation budget and a total budget for the whole oracle run.
33
+ INVOCATION_TIMEOUT_SECONDS = 60
34
+ OPS_DEADLINE_SECONDS = 1800
32
35
 
33
36
 
34
37
  def write_tensor(path: Path, t: torch.Tensor) -> None:
@@ -65,11 +68,12 @@ def build_candidate(workspace: Path, root: Path) -> Path:
65
68
  shutil.rmtree(build, ignore_errors=True)
66
69
  build.mkdir(parents=True)
67
70
  cfg = subprocess.run(['cmake', '-S', '.', '-B', 'build', '-DCMAKE_BUILD_TYPE=Release'],
68
- cwd=str(source), capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=180)
71
+ cwd=str(source), capture_output=True, text=True, encoding='utf-8', errors='replace',
72
+ timeout=180, env=child_env())
69
73
  if cfg.returncode != 0:
70
74
  raise RuntimeError('cmake configure failed\n' + (cfg.stdout + cfg.stderr)[-4000:])
71
75
  made = subprocess.run(['cmake', '--build', 'build', '-j4'], cwd=str(source), capture_output=True,
72
- text=True, encoding='utf-8', errors='replace', timeout=1800)
76
+ text=True, encoding='utf-8', errors='replace', timeout=1800, env=child_env())
73
77
  if made.returncode != 0:
74
78
  raise RuntimeError('build failed\n' + (made.stdout + made.stderr)[-4000:])
75
79
  binary = build / 'nn_task'
@@ -87,7 +91,8 @@ class Case:
87
91
  self.flops = flops
88
92
 
89
93
 
90
- def run_operation(name: str, threshold: float, cases: list[Case], binary: Path, root: Path) -> dict:
94
+ def run_operation(name: str, threshold: float, cases: list[Case], binary: Path, root: Path,
95
+ invocation_timeout: float = INVOCATION_TIMEOUT_SECONDS) -> dict:
91
96
  op_dir = root / 'ops' / name
92
97
  op_dir.mkdir(parents=True, exist_ok=True)
93
98
  executable = True
@@ -111,7 +116,8 @@ def run_operation(name: str, threshold: float, cases: list[Case], binary: Path,
111
116
  started = time.perf_counter()
112
117
  try:
113
118
  proc = subprocess.run(cmd, cwd=str(case_dir), capture_output=True, text=True,
114
- encoding='utf-8', errors='replace', timeout=300)
119
+ encoding='utf-8', errors='replace', timeout=invocation_timeout,
120
+ env=child_env())
115
121
  except subprocess.TimeoutExpired:
116
122
  executable = False
117
123
  first_error = first_error or 'timeout'
@@ -459,7 +465,13 @@ def main() -> int:
459
465
  except Exception as exc:
460
466
  return fail({'task': 'neural-fp32', 'ok': False, 'error': str(exc)})
461
467
  rows = []
468
+ deadline = time.monotonic() + OPS_DEADLINE_SECONDS
462
469
  for name, threshold, builder in OPERATIONS:
470
+ if time.monotonic() > deadline:
471
+ rows.append({'name': name, 'executable': False, 'passed': False,
472
+ 'weighted_mean_relative_error': None, 'threshold': threshold,
473
+ 'speed_gflops': None, 'error': 'evaluation deadline exceeded'})
474
+ continue
463
475
  try:
464
476
  cases = builder()
465
477
  row = run_operation(name, threshold, cases, binary, root)
@@ -25,25 +25,22 @@ import numpy as np
25
25
 
26
26
  HERE = Path(__file__).resolve().parent
27
27
  sys.path.insert(0, str(HERE))
28
- from common import clean_copy
28
+ from common import child_env, clean_copy
29
29
 
30
30
  HIDDEN = HERE / 'hidden'
31
31
  OPS = HERE / 'ops_eval.py'
32
32
 
33
33
 
34
34
  def run(cmd, cwd, timeout=1800):
35
- env = dict(os.environ)
36
- env.pop('PYTHONPATH', None)
37
- env.pop('PYTHONHOME', None)
38
35
  return subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True,
39
- encoding='utf-8', errors='replace', env=env, timeout=timeout)
36
+ encoding='utf-8', errors='replace', env=child_env(), timeout=timeout)
40
37
 
41
38
 
42
39
  def build_candidate(workspace: Path, root: Path):
43
40
  candidate = root / 'candidate'
44
41
  clean_copy(workspace, candidate, ignore_names=(
45
42
  'data', 'build', 'work', '.tmp', '.cache', '.home', '.config', '.local',
46
- '.git', '.venv', 'node_modules',
43
+ '.git', '.venv', 'node_modules', '.eval', '.submissions',
47
44
  ))
48
45
  source = candidate
49
46
  if not (source / 'CMakeLists.txt').is_file() and (candidate / 'workspace' / 'CMakeLists.txt').is_file():
@@ -82,33 +79,48 @@ def model_metrics(source: Path, threads: int, budget: float | None = None) -> di
82
79
  return {'executable': False, 'accuracy': None, 'fps': None,
83
80
  'mean_seconds_per_image': None, 'error': 'hidden validation data missing; run fetch_cifar.py',
84
81
  'meets_4thread_budget': None}
85
- n = labels.stat().st_size
86
- out = source / f'predictions_{threads}.bin'
87
- if out.exists():
88
- out.unlink()
89
- cmd = [str(infer), '--model', str(source / 'model'),
90
- '--images', str(images), '--out', str(out), '--threads', str(threads)]
91
- started = time.perf_counter()
92
- try:
93
- proc = run(cmd, source, 1200)
94
- except subprocess.TimeoutExpired:
95
- return {'executable': False, 'accuracy': None, 'fps': None,
96
- 'mean_seconds_per_image': None, 'error': 'inference timeout',
97
- 'meets_4thread_budget': None}
98
- elapsed = time.perf_counter() - started
99
- if proc.returncode != 0:
100
- return {'executable': False, 'accuracy': None, 'fps': None,
101
- 'mean_seconds_per_image': None,
102
- 'error': (proc.stderr or proc.stdout or 'infer failed')[-1000:],
103
- 'meets_4thread_budget': None}
82
+ # Read the truth labels into memory and hand the candidate a COPY of the
83
+ # images in an isolated directory: the labels file must never be reachable
84
+ # by deriving it from the --images argument.
104
85
  try:
105
- pred = read_preds(out, n)
106
- true = np.frombuffer(labels.read_bytes(), dtype=np.uint8)
107
- acc = float((pred == true).mean() * 100.0)
108
- except Exception as exc:
86
+ labels_bytes = labels.read_bytes()
87
+ n = len(labels_bytes)
88
+ isolated = Path(tempfile.mkdtemp(prefix='cifar-images-'))
89
+ isolated_images = isolated / 'images.bin'
90
+ shutil.copyfile(images, isolated_images)
91
+ except OSError as exc:
109
92
  return {'executable': False, 'accuracy': None, 'fps': None,
110
93
  'mean_seconds_per_image': None, 'error': str(exc),
111
94
  'meets_4thread_budget': None}
95
+ out = source / f'predictions_{threads}.bin'
96
+ try:
97
+ if out.exists():
98
+ out.unlink()
99
+ cmd = [str(infer), '--model', str(source / 'model'),
100
+ '--images', str(isolated_images), '--out', str(out), '--threads', str(threads)]
101
+ started = time.perf_counter()
102
+ try:
103
+ proc = run(cmd, source, 1200)
104
+ except subprocess.TimeoutExpired:
105
+ return {'executable': False, 'accuracy': None, 'fps': None,
106
+ 'mean_seconds_per_image': None, 'error': 'inference timeout',
107
+ 'meets_4thread_budget': None}
108
+ elapsed = time.perf_counter() - started
109
+ if proc.returncode != 0:
110
+ return {'executable': False, 'accuracy': None, 'fps': None,
111
+ 'mean_seconds_per_image': None,
112
+ 'error': (proc.stderr or proc.stdout or 'infer failed')[-1000:],
113
+ 'meets_4thread_budget': None}
114
+ try:
115
+ pred = read_preds(out, n)
116
+ true = np.frombuffer(labels_bytes, dtype=np.uint8)
117
+ acc = float((pred == true).mean() * 100.0)
118
+ except Exception as exc:
119
+ return {'executable': False, 'accuracy': None, 'fps': None,
120
+ 'mean_seconds_per_image': None, 'error': str(exc),
121
+ 'meets_4thread_budget': None}
122
+ finally:
123
+ shutil.rmtree(isolated, ignore_errors=True)
112
124
  fps = n / elapsed if elapsed > 0 else None
113
125
  mean_s = elapsed / n if n else None
114
126
  result = {'executable': True, 'accuracy': acc, 'fps': fps,
@@ -133,10 +145,8 @@ def main() -> int:
133
145
  # Reuse the existing FP32 operator evaluator, but run it in a temp
134
146
  # copy so its build output does not affect the model workspace.
135
147
  ops_result = root / 'ops.json'
136
- env = dict(os.environ)
148
+ env = child_env()
137
149
  env['EVAL_RESULT_FILE'] = str(ops_result)
138
- env.pop('PYTHONPATH', None)
139
- env.pop('PYTHONHOME', None)
140
150
  try:
141
151
  proc = subprocess.run([sys.executable, '-X', 'utf8', str(OPS), '--workspace', str(workspace)],
142
152
  cwd=str(HERE), capture_output=True, text=True,