dsh-eval-guard 0.7.2 → 0.7.3

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 (70) hide show
  1. package/ISSUES.md +235 -0
  2. package/README.md +46 -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 +39 -32
  7. package/assets/neural-pendulum/TASK.md +137 -32
  8. package/assets/neural-pendulum/workspace.zip +0 -0
  9. package/eval/README.md +19 -9
  10. package/eval/__pycache__/bomberman.cpython-310.pyc +0 -0
  11. package/eval/__pycache__/commit0.cpython-310.pyc +0 -0
  12. package/eval/__pycache__/common.cpython-310.pyc +0 -0
  13. package/eval/__pycache__/ctf.cpython-310.pyc +0 -0
  14. package/eval/__pycache__/featurebench.cpython-310.pyc +0 -0
  15. package/eval/__pycache__/land.cpython-310.pyc +0 -0
  16. package/eval/__pycache__/neural-cifar.cpython-310.pyc +0 -0
  17. package/eval/__pycache__/neural-cpp.cpython-310.pyc +0 -0
  18. package/eval/__pycache__/neural-fp32.cpython-310.pyc +0 -0
  19. package/eval/__pycache__/neural-pendulum.cpython-310.pyc +0 -0
  20. package/eval/__pycache__/swe-bench-pro.cpython-310.pyc +0 -0
  21. package/eval/__pycache__/tank.cpython-310.pyc +0 -0
  22. package/eval/__pycache__/warehouse.cpython-310.pyc +0 -0
  23. package/eval/bomberman.py +5 -3
  24. package/eval/commit0.py +10 -3
  25. package/eval/common.py +169 -5
  26. package/eval/ctf.py +4 -2
  27. package/eval/featurebench.py +12 -8
  28. package/eval/land.py +4 -2
  29. package/eval/legacy/__pycache__/bomberman.cpython-310.pyc +0 -0
  30. package/eval/legacy/__pycache__/commit0.cpython-310.pyc +0 -0
  31. package/eval/legacy/__pycache__/ctf.cpython-310.pyc +0 -0
  32. package/eval/legacy/__pycache__/featurebench.cpython-310.pyc +0 -0
  33. package/eval/legacy/__pycache__/land.cpython-310.pyc +0 -0
  34. package/eval/legacy/__pycache__/swe_bench_pro.cpython-310.pyc +0 -0
  35. package/eval/legacy/__pycache__/tank.cpython-310.pyc +0 -0
  36. package/eval/legacy/__pycache__/warehouse.cpython-310.pyc +0 -0
  37. package/eval/legacy/bomberman.py +5 -2
  38. package/eval/legacy/ctf.py +5 -2
  39. package/eval/legacy/featurebench.py +55 -15
  40. package/eval/legacy/land.py +6 -3
  41. package/eval/legacy/swe_bench_pro.py +57 -16
  42. package/eval/legacy/tank.py +2 -2
  43. package/eval/legacy/warehouse.py +4 -4
  44. package/eval/neural-cpp.py +9 -6
  45. package/eval/neural-fp32.py +17 -5
  46. package/eval/swe-bench-pro.py +11 -7
  47. package/eval/tank.py +11 -2
  48. package/eval/warehouse.py +17 -1
  49. package/lib/client/index.js +56 -29
  50. package/lib/client/index.js.map +1 -1
  51. package/lib/index.js +878 -297
  52. package/native/dsh-eval-supervisor +0 -0
  53. package/native/supervisor.c +190 -26
  54. package/neural-cifar-eval/README.md +5 -0
  55. package/neural-cifar-eval/__pycache__/cifar_cache.cpython-310.pyc +0 -0
  56. package/neural-cifar-eval/__pycache__/common.cpython-310.pyc +0 -0
  57. package/neural-cifar-eval/__pycache__/fetch_cifar.cpython-310.pyc +0 -0
  58. package/neural-cifar-eval/__pycache__/ops_eval.cpython-310.pyc +0 -0
  59. package/neural-cifar-eval/__pycache__/run_eval.cpython-310.pyc +0 -0
  60. package/neural-cifar-eval/common.py +13 -0
  61. package/neural-cifar-eval/ops_eval.py +17 -5
  62. package/neural-cifar-eval/run_eval.py +42 -32
  63. package/neural-pendulum-eval/README.md +62 -0
  64. package/neural-pendulum-eval/__pycache__/ops_eval.cpython-310.pyc +0 -0
  65. package/neural-pendulum-eval/__pycache__/run_eval.cpython-310.pyc +0 -0
  66. package/neural-pendulum-eval/ops_eval.py +603 -0
  67. package/neural-pendulum-eval/run_eval.py +218 -137
  68. package/package.json +7 -11
  69. package/assets/neural-pendulum/hidden/test_infra.cpp +0 -631
  70. package/assets/neural-pendulum/include/nn/nn.hpp +0 -88
Binary file
@@ -57,6 +57,17 @@
57
57
  #ifndef SYS_open_by_handle_at
58
58
  #define SYS_open_by_handle_at 304
59
59
  #endif
60
+ #ifndef SYS_fchmodat2
61
+ #define SYS_fchmodat2 452
62
+ #endif
63
+
64
+ #if defined(__x86_64__)
65
+ #define SUPERVISOR_AUDIT_ARCH AUDIT_ARCH_X86_64
66
+ #elif defined(__aarch64__)
67
+ #define SUPERVISOR_AUDIT_ARCH AUDIT_ARCH_AARCH64
68
+ #else
69
+ #error "dsh-eval-supervisor: unsupported architecture"
70
+ #endif
60
71
 
61
72
  #define MAX_RULES 512
62
73
  #define MAX_PATH 4096
@@ -77,13 +88,31 @@ static Rule g_rules[MAX_RULES];
77
88
  static int g_rule_count;
78
89
  static int g_audit_fd = -1;
79
90
  static volatile sig_atomic_t g_stop;
91
+ /* The sandbox mounted a private tmpfs at /tmp (workspace under host /tmp). */
92
+ static int g_tmpfs_tmp;
93
+ static char g_requested_cwd[MAX_PATH];
80
94
  /* bwrap itself needs AF_UNIX and AF_NETLINK sockets during setup. The target
81
95
  * command is allowed no sockets at all once bwrap has exec'd it. */
82
96
  static int g_command_started;
83
97
  static pid_t g_current_pid;
84
98
  static int g_tmpbind;
85
99
 
86
- /** Whether the notifying process is bubblewrap itself (setup phase). */
100
+ /**
101
+ * Whether the notifying process is bubblewrap itself (setup phase).
102
+ *
103
+ * The bwrap command may be a package-manager shim that re-execs several times,
104
+ * so identification is by executable name (as upstream did). The setup-only
105
+ * exemptions it enables are restricted to paths that do not exist after
106
+ * pivot_root, and the command-started gate closes them for the target.
107
+ */
108
+ static int is_bwrap_name(const char *name) {
109
+ if (name == NULL || name[0] == '\0') return 0;
110
+ /* Debian/Fedora ship /usr/bin/bwrap; the project/package is also commonly
111
+ * called bubblewrap. Accept both spellings so bwrap's own setup writes
112
+ * (uid_map/gid_map under /proc) are recognized regardless of binary name. */
113
+ return strstr(name, "bwrap") != NULL || strstr(name, "bubblewrap") != NULL;
114
+ }
115
+
87
116
  static int is_bwrap(pid_t pid) {
88
117
  char proc[64];
89
118
  char exe[MAX_PATH];
@@ -93,7 +122,7 @@ static int is_bwrap(pid_t pid) {
93
122
  exe[n] = '\0';
94
123
  const char *base = strrchr(exe, '/');
95
124
  base = base == NULL ? exe : base + 1;
96
- return strstr(base, "bwrap") != NULL;
125
+ return is_bwrap_name(base);
97
126
  }
98
127
 
99
128
  static void on_signal(int sig) {
@@ -302,6 +331,9 @@ static int rules_override_read(const char *path, int initial) {
302
331
 
303
332
  static int path_allowed(const char *path, int write_access) {
304
333
  if (g_workspace[0] != '\0' && starts_with_seg(path, g_workspace)) return 1;
334
+ /* A private tmpfs at /tmp is ephemeral by construction: it never exposes
335
+ * host files, so both read and write are safe. */
336
+ if (g_tmpfs_tmp && starts_with_seg(path, "/tmp")) return 1;
305
337
  /* bubblewrap builds its new root under /tmp and /newroot before pivot_root.
306
338
  * Those transient paths exist only during setup and are removed on exit. */
307
339
  if ((!g_command_started || (g_current_pid > 0 && is_bwrap(g_current_pid))) &&
@@ -393,6 +425,17 @@ __attribute__((unused)) static int should_notify(long nr) {
393
425
  case SYS_faccessat2:
394
426
  case SYS_readlink:
395
427
  case SYS_readlinkat:
428
+ case SYS_symlink:
429
+ case SYS_symlinkat:
430
+ case SYS_link:
431
+ case SYS_linkat:
432
+ case SYS_mknod:
433
+ case SYS_mknodat:
434
+ case SYS_utimensat:
435
+ case SYS_statx:
436
+ #ifdef SYS_fchmodat2
437
+ case SYS_fchmodat2:
438
+ #endif
396
439
  case SYS_socket:
397
440
  case SYS_connect:
398
441
  case SYS_bind:
@@ -460,6 +503,17 @@ static const char *syscall_name(long nr) {
460
503
  case SYS_faccessat2: return "faccessat2";
461
504
  case SYS_readlink: return "readlink";
462
505
  case SYS_readlinkat: return "readlinkat";
506
+ case SYS_symlink: return "symlink";
507
+ case SYS_symlinkat: return "symlinkat";
508
+ case SYS_link: return "link";
509
+ case SYS_linkat: return "linkat";
510
+ case SYS_mknod: return "mknod";
511
+ case SYS_mknodat: return "mknodat";
512
+ case SYS_utimensat: return "utimensat";
513
+ case SYS_statx: return "statx";
514
+ #ifdef SYS_fchmodat2
515
+ case SYS_fchmodat2: return "fchmodat2";
516
+ #endif
463
517
  case SYS_socket: return "socket";
464
518
  case SYS_connect: return "connect";
465
519
  case SYS_bind: return "bind";
@@ -507,7 +561,7 @@ static int check_notification(pid_t pid, const struct seccomp_notif *req, int *w
507
561
  (void)read_remote_string(pid, nr == SYS_execve ? a0 : a1, executable, sizeof(executable));
508
562
  const char *base = strrchr(executable, '/');
509
563
  base = base == NULL ? executable : base + 1;
510
- if (strstr(base, "bwrap") == NULL) g_command_started = 1;
564
+ if (!is_bwrap_name(base)) g_command_started = 1;
511
565
  *write_access = 0;
512
566
  snprintf(path_out, MAX_PATH, "<exec>");
513
567
  return 1;
@@ -530,20 +584,10 @@ static int check_notification(pid_t pid, const struct seccomp_notif *req, int *w
530
584
  return 1;
531
585
  }
532
586
  if (nr == SYS_io_uring_setup || nr == SYS_ptrace || nr == SYS_open_by_handle_at) {
587
+ /* io_uring worker threads bypass a process's seccomp filter, so it can never
588
+ * be allowed inside the sandbox (libuv stays on epoll via UV_USE_IO_URING=0). */
533
589
  *write_access = 0;
534
590
  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
591
  return 0;
548
592
  }
549
593
 
@@ -606,15 +650,59 @@ static int check_notification(pid_t pid, const struct seccomp_notif *req, int *w
606
650
  case SYS_readlinkat:
607
651
  case SYS_fchmodat:
608
652
  case SYS_fchownat:
653
+ case SYS_fchmodat2:
654
+ dirfd = (int)a0;
655
+ path_ptr = a1;
656
+ write = (nr == SYS_unlinkat || nr == SYS_mkdirat || nr == SYS_fchmodat || nr == SYS_fchownat || nr == SYS_fchmodat2);
657
+ break;
658
+ case SYS_symlink:
659
+ path_ptr = a1;
660
+ write = 1;
661
+ break;
662
+ case SYS_symlinkat:
663
+ dirfd = (int)a1;
664
+ path_ptr = a2;
665
+ write = 1;
666
+ break;
667
+ case SYS_link:
668
+ path_ptr = a0;
669
+ write = 0;
670
+ break;
671
+ case SYS_linkat:
672
+ dirfd = (int)a0;
673
+ path_ptr = a1;
674
+ write = 0;
675
+ break;
676
+ case SYS_mknod:
677
+ path_ptr = a0;
678
+ write = 1;
679
+ break;
680
+ case SYS_mknodat:
681
+ dirfd = (int)a0;
682
+ path_ptr = a1;
683
+ write = 1;
684
+ break;
685
+ case SYS_utimensat:
686
+ dirfd = (int)a0;
687
+ path_ptr = a1;
688
+ write = 1;
689
+ break;
690
+ case SYS_statx:
609
691
  dirfd = (int)a0;
610
692
  path_ptr = a1;
611
- write = (nr == SYS_unlinkat || nr == SYS_mkdirat || nr == SYS_fchmodat || nr == SYS_fchownat);
693
+ write = 0;
612
694
  break;
613
695
  default:
614
696
  *write_access = 0;
615
697
  return 1;
616
698
  }
617
699
 
700
+ if (path_ptr == 0) {
701
+ /* fd-only form (for example utimensat with AT_EMPTY_PATH): no new path is touched. */
702
+ snprintf(path_out, MAX_PATH, "<fd>");
703
+ *write_access = write;
704
+ return 1;
705
+ }
618
706
  if (read_remote_string(pid, path_ptr, raw, sizeof(raw)) != 0) {
619
707
  snprintf(path_out, MAX_PATH, "<unreadable>");
620
708
  *write_access = write;
@@ -641,13 +729,38 @@ static int check_notification(pid_t pid, const struct seccomp_notif *req, int *w
641
729
  }
642
730
  snprintf(path_out, MAX_PATH, "%s", path);
643
731
  *write_access = write;
732
+ /* renames and hard links also name a second, mutated path; it must pass the
733
+ * same write policy even though only the first name was resolved above. */
734
+ if (nr == SYS_rename || nr == SYS_renameat || nr == SYS_renameat2 || nr == SYS_link || nr == SYS_linkat) {
735
+ unsigned long second_ptr;
736
+ int second_dirfd = AT_FDCWD;
737
+ if (nr == SYS_rename || nr == SYS_link) {
738
+ second_ptr = a1;
739
+ } else {
740
+ second_dirfd = (int)a2;
741
+ second_ptr = a3;
742
+ }
743
+ char raw2[MAX_PATH];
744
+ char path2[MAX_PATH];
745
+ int existed2 = 0;
746
+ raw2[0] = '\0';
747
+ if (read_remote_string(pid, second_ptr, raw2, sizeof(raw2)) == 0 && raw2[0] != '\0') {
748
+ if (resolve_path(pid, second_dirfd, raw2, &existed2, path2, sizeof(path2)) != 0
749
+ || !path_allowed(path2, 1)) {
750
+ snprintf(path_out, MAX_PATH, "%s", path2);
751
+ *write_access = 1;
752
+ return 0;
753
+ }
754
+ }
755
+ }
644
756
  /* `mkdir -p` walks every existing ancestor and calls mkdir on each one.
645
757
  * Creating an already-existing workspace ancestor is a kernel no-op, not an
646
758
  * out-of-workspace write. */
647
759
  if (write && existed && (nr == SYS_mkdir || nr == SYS_mkdirat) && is_exact_policy_path(path)) return 1;
648
760
  /* bwrap writes the user-namespace uid/gid maps under /proc before it execs
649
761
  * 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;
762
+ if (write && starts_with_seg(path, "/proc") &&
763
+ (!g_command_started || (g_current_pid > 0 && is_bwrap(g_current_pid)))) return 1;
651
764
  return path_allowed(path, write) ? 1 : 0;
652
765
  }
653
766
 
@@ -704,6 +817,14 @@ static void reply_loop(int fd) {
704
817
  char syscall_buf[64];
705
818
  snprintf(syscall_buf, sizeof(syscall_buf), "%s", syscall_name((long)req.data.nr));
706
819
  audit_record((pid_t)req.pid, syscall_buf, path, write_access);
820
+ /* io_uring is a soft denial: libuv probes it despite UV_USE_IO_URING=0
821
+ * and must receive EPERM and fall back to epoll instead of taking the
822
+ * whole command group down. No io_uring instance can exist, so the
823
+ * path-check bypass this syscall would otherwise enable stays closed. */
824
+ if ((long)req.data.nr == SYS_io_uring_setup) {
825
+ respond(fd, &req, 0);
826
+ continue;
827
+ }
707
828
  char debug_exe[512]; char debug_proc[64];
708
829
  snprintf(debug_proc, sizeof(debug_proc), "/proc/%d/exe", (int)req.pid);
709
830
  ssize_t debug_n = readlink(debug_proc, debug_exe, sizeof(debug_exe) - 1);
@@ -747,6 +868,8 @@ static void parse_policy(const char *file) {
747
868
  if (g_dev_count < MAX_RULES) snprintf(g_dev[g_dev_count++], MAX_PATH, "%s", value);
748
869
  } else if (strcmp(kind, "tmpbind") == 0) {
749
870
  if (strcmp(value, g_workspace) != 0) g_tmpbind = 1;
871
+ } else if (strcmp(kind, "tmpfs") == 0) {
872
+ if (strcmp(value, "/tmp") == 0) g_tmpfs_tmp = 1;
750
873
  } else if (strcmp(kind, "rule") == 0) {
751
874
  char *sub = value;
752
875
  char *arg = strchr(sub, ' ');
@@ -772,6 +895,10 @@ int main(int argc, char **argv) {
772
895
  if (strcmp(argv[i], "--") == 0) { i++; break; }
773
896
  if (strcmp(argv[i], "--policy") == 0 && i + 1 < argc) { policy = argv[++i]; continue; }
774
897
  if (strcmp(argv[i], "--audit") == 0 && i + 1 < argc) { audit = argv[++i]; continue; }
898
+ if (strcmp(argv[i], "--cwd") == 0 && i + 1 < argc) {
899
+ snprintf(g_requested_cwd, sizeof(g_requested_cwd), "%s", argv[++i]);
900
+ continue;
901
+ }
775
902
  if (strcmp(argv[i], "--label") == 0 && i + 1 < argc) { i++; continue; }
776
903
  fprintf(stderr, "dsh-eval-supervisor: unknown argument %s\n", argv[i]);
777
904
  return 125;
@@ -825,7 +952,7 @@ int main(int argc, char **argv) {
825
952
 
826
953
  /* arch gate */
827
954
  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);
955
+ filter[n++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SUPERVISOR_AUDIT_ARCH, 1, 0);
829
956
  filter[n++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS);
830
957
  filter[n++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr));
831
958
  /* One comparison per intercepted syscall; long but far below the 4096 limit. */
@@ -861,6 +988,17 @@ int main(int argc, char **argv) {
861
988
  ADD_SYSCALL(SYS_faccessat2);
862
989
  ADD_SYSCALL(SYS_readlink);
863
990
  ADD_SYSCALL(SYS_readlinkat);
991
+ ADD_SYSCALL(SYS_symlink);
992
+ ADD_SYSCALL(SYS_symlinkat);
993
+ ADD_SYSCALL(SYS_link);
994
+ ADD_SYSCALL(SYS_linkat);
995
+ ADD_SYSCALL(SYS_mknod);
996
+ ADD_SYSCALL(SYS_mknodat);
997
+ ADD_SYSCALL(SYS_utimensat);
998
+ ADD_SYSCALL(SYS_statx);
999
+ #ifdef SYS_fchmodat2
1000
+ ADD_SYSCALL(SYS_fchmodat2);
1001
+ #endif
864
1002
  ADD_SYSCALL(SYS_socket);
865
1003
  ADD_SYSCALL(SYS_connect);
866
1004
  ADD_SYSCALL(SYS_bind);
@@ -908,19 +1046,45 @@ int main(int argc, char **argv) {
908
1046
  /* bwrap pivots into a fresh root and would leave the target at /. Preserve
909
1047
  * the cwd the consumer chose (the tool's workdir) with bwrap --chdir. */
910
1048
  {
911
- char cwd[MAX_PATH];
1049
+ char inherited[MAX_PATH];
912
1050
  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 *));
1051
+ if (g_requested_cwd[0] != '\0' && starts_with_seg(g_requested_cwd, g_workspace)) use_cwd = g_requested_cwd;
1052
+ else if (getcwd(inherited, sizeof(inherited)) != NULL && starts_with_seg(inherited, g_workspace)) use_cwd = inherited;
1053
+ char **next = (char **)calloc((size_t)(argc + 16), sizeof(char *));
918
1054
  if (next == NULL) _exit(125);
919
1055
  int out = 0;
920
- for (int k = i; k < sep; k++) next[out++] = argv[k];
1056
+ next[out++] = argv[i]; /* bwrap */
921
1057
  next[out++] = (char *)"--chdir";
922
1058
  next[out++] = (char *)use_cwd;
923
- for (int k = sep; k < argc; k++) next[out++] = argv[k];
1059
+ int env_restored = 0;
1060
+ for (int k = i + 1; k < argc; k++) {
1061
+ next[out++] = argv[k];
1062
+ /* The plugin clears the inherited environment with --clearenv. Re-add
1063
+ * the private launch markers the subprocess provider passed to THIS
1064
+ * process (never to arbitrary children) so run_code's control pipe and
1065
+ * packaged-PTC heap flag survive bwrap. */
1066
+ if (!env_restored && strcmp(argv[k], "--clearenv") == 0) {
1067
+ env_restored = 1;
1068
+ const char *control = getenv("DSH_SUBPROCESS_CONTROL");
1069
+ if (control != NULL && strcmp(control, "pipe") == 0) {
1070
+ next[out++] = (char *)"--setenv";
1071
+ next[out++] = (char *)"DSH_SUBPROCESS_CONTROL";
1072
+ next[out++] = (char *)"pipe";
1073
+ }
1074
+ const char *ptc = getenv("DSH_PTC_RUNTIME_NODE");
1075
+ if (ptc != NULL && ptc[0] != '\0') {
1076
+ next[out++] = (char *)"--setenv";
1077
+ next[out++] = (char *)"DSH_PTC_RUNTIME_NODE";
1078
+ next[out++] = (char *)ptc;
1079
+ const char *node_options = getenv("NODE_OPTIONS");
1080
+ if (node_options != NULL && node_options[0] != '\0') {
1081
+ next[out++] = (char *)"--setenv";
1082
+ next[out++] = (char *)"NODE_OPTIONS";
1083
+ next[out++] = (char *)node_options;
1084
+ }
1085
+ }
1086
+ }
1087
+ }
924
1088
  next[out] = NULL;
925
1089
  execvp(next[0], next);
926
1090
  }
@@ -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,
@@ -0,0 +1,62 @@
1
+ # neural-pendulum-eval
2
+
3
+ Standalone evaluator for the **Neural-Pendulum** task. Copy this whole folder to
4
+ a more performance-stable Linux machine to rerun the evaluation without the DSH
5
+ plugin UI.
6
+
7
+ ## Contents
8
+
9
+ - `run_eval.py` — builds the latest submission, runs the operator oracle, then
10
+ drives `build/control` against the fixed-initial-state simulator and emits a
11
+ staged result (`no_submission` / `compile` / `infra` / `controller` /
12
+ `simulation` / `scoring` / `completed`).
13
+ - `ops_eval.py` — operator oracle: PyTorch references computed in-process,
14
+ weighted mean relative error compared with per-operation thresholds. Same
15
+ operations, cases, seeds and thresholds as Neural-FP32 / Neural-CIFAR.
16
+ - `sim_eval` — trusted simulator binary for the evaluation initial state.
17
+ Identical to the workspace `tools/sim`.
18
+
19
+ ## Requirements
20
+
21
+ - Python 3 with `numpy` and `torch` (CPU) for the operator oracle.
22
+ - `cmake`, `g++`, `unzip`.
23
+
24
+ ## Run
25
+
26
+ ```bash
27
+ python3 run_eval.py --workspace /path/to/staged/workspace
28
+ ```
29
+
30
+ The workspace must contain a host-staged `.eval/` directory:
31
+
32
+ ```
33
+ .eval/
34
+ meta.json # {"started_at": <unix seconds>, "t_tgt": 10}
35
+ events.jsonl # host submit/test events, used for the time integral
36
+ submissions/<id>/ # snapshot with CMakeLists.txt, src/, ...
37
+ ```
38
+
39
+ When `EVAL_RESULT_FILE` is set, the JSON result is written there; otherwise it
40
+ is printed to stdout. The first stdout lines always summarize the operator
41
+ oracle:
42
+
43
+ ```
44
+ all_operations_executable = ...
45
+ all_operations_passed = ...
46
+ max_weighted_mean_relative_error = ...
47
+ speed_geomean = ...
48
+ ```
49
+
50
+ followed by one row per operation. Only when every operation is executable and
51
+ within threshold does the evaluator run the controller. Operator speed is
52
+ reported but never gates completion.
53
+
54
+ Budgets: build 8 min, operator oracle 4 min total (60 s per CLI invocation),
55
+ controller 6 min. The host-side `eval_test` tool kills the evaluator after
56
+ 20 minutes.
57
+
58
+ The evaluator reads simulator/controller responses through a buffered binary
59
+ line reader with per-read deadlines (a text wrapper can hide already-buffered
60
+ lines from a selector). Simulator/controller stderr is discarded so a verbose
61
+ controller cannot deadlock on a full pipe, and those children never inherit the
62
+ host `EVAL_*` variables.