Track tracefs fd and cwd state

ober

cd9a7862595b4f9db6807fc32ad4ff61251c4281

diff --git a/docs/limits-followup.md b/docs/limits-followup.md
index d0c11f1..2dfbc90 100644
--- a/docs/limits-followup.md
+++ b/docs/limits-followup.md
@@ -1,7 +1,7 @@
 # Limits Follow-Up: Missing Enforcement and Test Work
 
 Reviewed: 2026-05-21
-Status updated: 2026-05-21 (post child-status handshake phase)
+Status updated: 2026-05-22 (tracefs fd/cwd state phase)
 
 This document is a follow-up to `docs/limits.md` after reviewing the new
 limits/sandbox/audit module set added around commit `97eea41`.
@@ -10,9 +10,8 @@ The short version after the child-status and tracefs-normalization phases: the
 new modules are wired far enough for callers to choose safe fail-closed
 behavior based on actual pre-exec child setup status. The focused primitive
 tests are part of `make test`. Some backend work remains: Linux cgroup
-enforcement is not implemented, tracefs still lacks fd/cwd state tracking and
-native backends, and deeper backend-specific filesystem and network denial
-tests are still needed.
+enforcement is not implemented, tracefs still lacks native backends and deeper
+backend-specific filesystem and network denial tests are still needed.
 
 ## Status After Follow-Up Fixes
 
@@ -22,7 +21,7 @@ tests are still needed.
 | 2  | Process supervision    | DONE — timeout/capture handling, `128 + signal` statuses, output caps, and pre-exec go/no-go handshakes are covered by focused tests |
 | 3  | Resource limits        | SAFE PARTIAL — requested-limit plans and child install reports are per-kind; `require: '(limits)` fails closed before exec; parent-side time/output markers exist; **GAP**: cgroup v2 |
 | 4  | Executable identity    | DONE — path search, realpath/stat identity, comparison helper, and TOCTOU caveat are present |
-| 5  | Filesystem tracing     | SAFE PARTIAL — `tracefs-capabilities`, fail-closed wrapper, normalized `fs-event` output, and generic read/write/exec suggestions exist; **GAP**: fd/cwd tracking and native backends |
+| 5  | Filesystem tracing     | SAFE PARTIAL — `tracefs-capabilities`, fail-closed wrapper, normalized `fs-event` output, best-effort fd/cwd tracking, and generic read/write/exec suggestions exist; **GAP**: native backends and deeper Linux traced-run tests |
 | 6  | Network allowlist      | DONE for decision layer — IP literals and localnet ranges are denied before wildcard matching; **GAP**: DNS recheck + child network sandbox/proxy handoff |
 | 7  | Environment/secrets    | DONE — default deny policy, argv validation, env construction, and redaction helpers exist |
 | 8  | Temp HOME/cache        | DONE for helper layer — fake HOME, scratch/cache grants, env overrides, cleanup, and sandbox grant helper exist |
@@ -268,6 +267,9 @@ Implemented:
 - summary by syscall/path.
 - normalized `fs-event` records with operation classes such as `read`, `write`,
   `create`, `delete`, `rename`, `metadata`, `readlink`, `symlink`, and `exec`.
+- best-effort cwd/fd state reconstruction from complete `strace` lines,
+  including relative `openat`, fd annotations from `strace -y`, directory fd
+  mappings, `chdir`/`fchdir`, `close`, `dup*`, and fork/clone inheritance.
 - generic policy suggestion grouping as `((read . paths) (write . paths)
   (exec . paths))`, reusable by `jsh` or other callers.
 
@@ -276,22 +278,23 @@ Missing relative to `docs/limits.md`:
 - On macOS this reports `unavailable`; no fallback tracing is implemented.
 - No native ptrace/eBPF backend.
 - No dtrace, EndpointSecurity, ktrace, truss, or Capsicum-aware backend.
-- The parser does not maintain enough process state for full path resolution:
-  cwd per process, fd table, fd inheritance, openat directory fd mappings, and
-  exec identity changes are still missing.
+- The parser reconstructs cwd/fd state best-effort from complete trace lines,
+  but it is still limited by missing/unfinished `strace` entries and by paths
+  that never appear in either path arguments or fd annotations.
 - `tracefs-strace-cmd` silently returns the raw target command when tracing is
   unavailable. That can look like success unless the caller separately checks
   `tracefs-mode`.
 
 Required next work:
 
-1. Add an explicit `tracefs-capabilities` report.
-2. Make unavailable tracing impossible to confuse with successful empty traces.
-3. Track cwd and fd state for Linux `strace`.
+1. DONE — add an explicit `tracefs-capabilities` report.
+2. DONE — make unavailable tracing impossible to confuse with successful empty traces when callers use the fail-closed wrapper.
+3. DONE — track cwd and fd state for Linux `strace` on a best-effort basis.
 4. DONE — normalize events into the design's `fs-event` format.
-5. Add tests for stat, rename, relative paths, openat directory-fd state, and
-   forked children. Basic open/create/unlink/exec normalization and policy
-   suggestions are covered by focused primitive tests.
+5. Add tests for stat, rename, and forked traced children. Basic
+   open/create/unlink/exec normalization, relative cwd paths, fd annotations,
+   openat directory-fd state, and policy suggestions are covered by focused
+   primitive tests.
 
 ## 6. Network Allowlist Proxy
 
diff --git a/lib/std/os/tracefs.ss b/lib/std/os/tracefs.ss
index 762637c..57c6d07 100644
--- a/lib/std/os/tracefs.ss
+++ b/lib/std/os/tracefs.ss
@@ -143,7 +143,7 @@
               [installed?
                '("strace -f imposes ~10x overhead on syscall-heavy workloads"
                  "parser only handles complete lines; partial <unfinished> entries are dropped"
-                 "path resolution does not track per-process cwd or fd table; relative paths render as-written")]
+                 "fd/cwd state is reconstructed best-effort from complete trace lines and strace -y annotations")]
               [(platform-linux?)
                '("strace binary not on PATH; install it (e.g. apt install strace, dnf install strace)")]
               [(platform-macos?)
@@ -698,6 +698,157 @@
                (string=? sc "creat"))
            rc)))
 
+  (def (tracefs-absolute-path? p)
+    (and (string? p)
+         (> (string-length p) 0)
+         (char=? (string-ref p 0) #\/)))
+
+  (def (tracefs-path-join base rel)
+    (cond
+      [(not (string? rel)) rel]
+      [(tracefs-absolute-path? rel) rel]
+      [(or (not base) (= (string-length base) 0)) rel]
+      [(string=? base "/") (string-append "/" rel)]
+      [(char=? (string-ref base (- (string-length base) 1)) #\/)
+       (string-append base rel)]
+      [else (string-append base "/" rel)]))
+
+  (def (tracefs-at-syscall? syscall)
+    (or (string=? syscall "openat")
+        (string=? syscall "openat2")
+        (string=? syscall "fstatat")
+        (string=? syscall "newfstatat")
+        (string=? syscall "faccessat")
+        (string=? syscall "faccessat2")
+        (string=? syscall "unlinkat")
+        (string=? syscall "mkdirat")
+        (string=? syscall "mknodat")
+        (string=? syscall "readlinkat")
+        (string=? syscall "fchmodat")
+        (string=? syscall "fchownat")
+        (string=? syscall "utimensat")
+        (string=? syscall "execveat")
+        (string=? syscall "renameat")
+        (string=? syscall "renameat2")
+        (string=? syscall "linkat")
+        (string=? syscall "symlinkat")))
+
+  (def (tracefs-fd-key pid fd)
+    (cons pid fd))
+
+  (def (tracefs-fd-table-ref fd-table pid fd)
+    (and (integer? fd)
+         (hashtable-ref fd-table (tracefs-fd-key pid fd) #f)))
+
+  (def (tracefs-fd-table-set! fd-table pid fd path)
+    (when (and (integer? fd) path)
+      (hashtable-set! fd-table (tracefs-fd-key pid fd) path)))
+
+  (def (tracefs-fd-table-delete! fd-table pid fd)
+    (when (integer? fd)
+      (hashtable-delete! fd-table (tracefs-fd-key pid fd))))
+
+  (def (tracefs-copy-process-state! cwd-table fd-table parent child)
+    (let ([cwd (hashtable-ref cwd-table parent #f)])
+      (when cwd (hashtable-set! cwd-table child cwd)))
+    (for-each
+     (lambda (key)
+       (when (and (pair? key) (equal? (car key) parent))
+         (hashtable-set! fd-table
+                         (tracefs-fd-key child (cdr key))
+                         (hashtable-ref fd-table key #f))))
+     (vector->list (hashtable-keys fd-table))))
+
+  (def (tracefs-parse-leading-fd raw)
+    (and raw
+         (let ([s raw] [n (string-length raw)])
+           (let lp ([i 0])
+             (cond
+               [(= i n)
+                (and (> i 0) (string->number (substring s 0 i)))]
+               [(char-numeric? (string-ref s i))
+                (lp (+ i 1))]
+               [(= i 0) #f]
+               [else (string->number (substring s 0 i))])))))
+
+  (def (tracefs-fd-annotation-path raw)
+    (and raw
+         (let ([start (find-char raw #\<)])
+           (and start
+                (let ([rest (substring raw (+ start 1) (string-length raw))])
+                  (let ([end (find-char rest #\>)])
+                    (and end
+                         (let ([p (substring rest 0 end)])
+                           (and (tracefs-absolute-path? p) p)))))))))
+
+  (def (tracefs-dirfd-base pid syscall args cwd-table fd-table)
+    (cond
+      [(not (tracefs-at-syscall? syscall))
+       (hashtable-ref cwd-table pid #f)]
+      [else
+       (let ([raw (list-ref/default args 0 #f)])
+         (cond
+           [(or (not raw) (string=? raw "AT_FDCWD"))
+            (hashtable-ref cwd-table pid #f)]
+           [(tracefs-fd-annotation-path raw) =>
+            (lambda (p) p)]
+           [else
+            (let ([fd (tracefs-parse-leading-fd raw)])
+              (or (tracefs-fd-table-ref fd-table pid fd)
+                  (hashtable-ref cwd-table pid #f)))]))]))
+
+  (def (tracefs-resolve-event-path pid syscall args path cwd-table fd-table)
+    (cond
+      [(not path) #f]
+      [(tracefs-absolute-path? path) path]
+      [else
+       (tracefs-path-join
+        (tracefs-dirfd-base pid syscall args cwd-table fd-table)
+        path)]))
+
+  (def (tracefs-fd-only-ops syscall args)
+    (cond
+      [(or (string=? syscall "read")
+           (string=? syscall "readv")
+           (string=? syscall "pread64"))
+       '(read)]
+      [(or (string=? syscall "write")
+           (string=? syscall "writev")
+           (string=? syscall "pwrite64"))
+       '(write)]
+      [(string=? syscall "fstat")
+       '(metadata)]
+      [(or (string=? syscall "ftruncate")
+           (string=? syscall "fchmod")
+           (string=? syscall "fchown")
+           (string=? syscall "fsync")
+           (string=? syscall "fdatasync"))
+       '(write)]
+      [(or (string=? syscall "getdents")
+           (string=? syscall "getdents64"))
+       '(readdir)]
+      [else '()]))
+
+  (def (tracefs-fd-event-path pid args fd-table)
+    (let* ([raw (list-ref/default args 0 #f)]
+           [annotated (tracefs-fd-annotation-path raw)]
+           [fd (tracefs-parse-leading-fd raw)])
+      (or annotated
+          (tracefs-fd-table-ref fd-table pid fd))))
+
+  (def (make-fs-event-from-trace/fd e op path fd)
+    (make-fs-event-rec
+     (trace-event-pid e)
+     #f
+     #f
+     op
+     path
+     #f
+     fd
+     (tracefs-result-symbol e)
+     (trace-event-errno e)
+     (trace-event-ts e)))
+
   (def (make-fs-event-from-trace e op path)
     (make-fs-event-rec
      (trace-event-pid e)
@@ -711,6 +862,89 @@
      (trace-event-errno e)
      (trace-event-ts e)))
 
+  (def (trace-event->fs-events/state e cwd-table fd-table)
+    (let* ([pid (trace-event-pid e)]
+           [syscall (trace-event-syscall e)]
+           [args (trace-event-args e)]
+           [fd-ops (tracefs-fd-only-ops syscall args)])
+      (cond
+        [(pair? fd-ops)
+         (let* ([path (tracefs-fd-event-path pid args fd-table)]
+                [fd (tracefs-parse-leading-fd (list-ref/default args 0 #f))])
+           (if path
+               (map (lambda (op)
+                      (make-fs-event-from-trace/fd e op path fd))
+                    fd-ops)
+               '()))]
+        [else
+         (map (lambda (ev)
+                (make-fs-event-rec
+                 (fs-event-pid ev)
+                 (fs-event-ppid ev)
+                 (fs-event-exe ev)
+                 (fs-event-op ev)
+                 (tracefs-resolve-event-path pid syscall args
+                                             (fs-event-path ev)
+                                             cwd-table fd-table)
+                 (hashtable-ref cwd-table pid #f)
+                 (fs-event-fd ev)
+                 (fs-event-result ev)
+                 (fs-event-errno ev)
+                 (fs-event-timestamp-ms ev)))
+              (trace-event->fs-events e))])))
+
+  (def (tracefs-update-state! e cwd-table fd-table)
+    (let* ([pid (trace-event-pid e)]
+           [syscall (trace-event-syscall e)]
+           [args (trace-event-args e)]
+           [rc (trace-event-result e)])
+      (when (tracefs-success? e)
+        (cond
+          [(or (string=? syscall "fork")
+               (string=? syscall "vfork")
+               (string=? syscall "clone"))
+           (when (and (integer? rc) (> rc 0))
+             (tracefs-copy-process-state! cwd-table fd-table pid rc))]
+          [(string=? syscall "chdir")
+           (let ([p (tracefs-resolve-event-path
+                     pid syscall args
+                     (tracefs-arg->path (list-ref/default args 0 #f))
+                     cwd-table fd-table)])
+             (when p (hashtable-set! cwd-table pid p)))]
+          [(string=? syscall "fchdir")
+           (let* ([fd (tracefs-parse-leading-fd (list-ref/default args 0 #f))]
+                  [p (tracefs-fd-table-ref fd-table pid fd)])
+             (when p (hashtable-set! cwd-table pid p)))]
+          [(or (string=? syscall "open")
+               (string=? syscall "openat")
+               (string=? syscall "openat2")
+               (string=? syscall "creat"))
+           (let ([fd (tracefs-fd-result e)])
+             (when fd
+               (let* ([paths (tracefs-event-paths syscall args
+                                                  (trace-event-path e))]
+                      [p (and (pair? paths)
+                              (tracefs-resolve-event-path
+                               pid syscall args (car paths)
+                               cwd-table fd-table))])
+                 (tracefs-fd-table-set! fd-table pid fd p))))]
+          [(string=? syscall "close")
+           (tracefs-fd-table-delete!
+            fd-table pid
+            (tracefs-parse-leading-fd (list-ref/default args 0 #f)))]
+          [(or (string=? syscall "dup")
+               (string=? syscall "dup2")
+               (string=? syscall "dup3"))
+           (let* ([old-fd (tracefs-parse-leading-fd
+                           (list-ref/default args 0 #f))]
+                  [new-fd (if (string=? syscall "dup")
+                              rc
+                              (tracefs-parse-leading-fd
+                               (list-ref/default args 1 #f)))]
+                  [p (tracefs-fd-table-ref fd-table pid old-fd)])
+             (tracefs-fd-table-set! fd-table pid new-fd p))]
+          [else (void)]))))
+
   (def (trace-event->fs-events e)
     (let* ([syscall (trace-event-syscall e)]
            [args (trace-event-args e)]
@@ -732,17 +966,23 @@
                                 out2))]))]))])))
 
   (def (tracefs-normalize-events events)
-    (let lp ([xs events] [out '()])
-      (cond
-        [(null? xs) (reverse out)]
-        [(fs-event? (car xs))
-         (lp (cdr xs) (cons (car xs) out))]
-        [(trace-event? (car xs))
-         (let add ([ys (trace-event->fs-events (car xs))] [acc out])
-           (cond
-             [(null? ys) (lp (cdr xs) acc)]
-             [else (add (cdr ys) (cons (car ys) acc))]))]
-        [else (lp (cdr xs) out)])))
+    (let ([cwd-table (make-eqv-hashtable)]
+          [fd-table (make-hashtable equal-hash equal?)])
+      (let lp ([xs events] [out '()])
+        (cond
+          [(null? xs) (reverse out)]
+          [(fs-event? (car xs))
+           (lp (cdr xs) (cons (car xs) out))]
+          [(trace-event? (car xs))
+           (let ([e (car xs)])
+             (let add ([ys (trace-event->fs-events/state e cwd-table fd-table)]
+                       [acc out])
+               (cond
+                 [(null? ys)
+                  (tracefs-update-state! e cwd-table fd-table)
+                  (lp (cdr xs) acc)]
+                 [else (add (cdr ys) (cons (car ys) acc))])))]
+          [else (lp (cdr xs) out)]))))
 
   (def (unique-sorted-strings xs)
     (let lp ([rest xs] [seen '()] [out '()])
diff --git a/tests/test-limits-primitives.ss b/tests/test-limits-primitives.ss
index bb83bb5..6f04632 100644
--- a/tests/test-limits-primitives.ss
+++ b/tests/test-limits-primitives.ss
@@ -435,6 +435,17 @@
        #t]
       [else (lp (cdr xs))])))
 
+(define (has-fs-event-with-fd? events op path fd)
+  (let lp ([xs events])
+    (cond
+      [(null? xs) #f]
+      [(and (fs-event? (car xs))
+            (eq? (fs-event-op (car xs)) op)
+            (equal? (fs-event-path (car xs)) path)
+            (equal? (fs-event-fd (car xs)) fd))
+       #t]
+      [else (lp (cdr xs))])))
+
 (define (has-path? xs path)
   (let lp ([rest xs])
     (cond
@@ -489,6 +500,30 @@
     (cdr (assq 'read suggest))
     (lambda (xs) (not (has-path? xs "/repo/missing")))))
 
+(let* ([raw (tracefs-parse-strace
+             (open-input-string
+              (string-append
+               "100 chdir(\"/repo\") = 0\n"
+               "100 openat(AT_FDCWD, \"package.json\", O_RDONLY|O_CLOEXEC) = 3\n"
+               "100 read(3</repo/package.json>, \"{}\", 2) = 2\n"
+               "100 openat(AT_FDCWD, \"out.txt\", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4\n"
+               "100 write(4</repo/out.txt>, \"x\", 1) = 1\n"
+               "100 openat(AT_FDCWD, \"/repo/subdir\", O_RDONLY|O_DIRECTORY) = 5\n"
+               "100 openat(5</repo/subdir>, \"child.txt\", O_RDONLY) = 6\n")))]
+       [events (tracefs-normalize-events raw)])
+  (test-pred "tracefs cwd tracking resolves relative openat"
+    events
+    (lambda (xs) (has-fs-event? xs 'read "/repo/package.json")))
+  (test-pred "tracefs fd annotation emits fd read event"
+    events
+    (lambda (xs) (has-fs-event-with-fd? xs 'read "/repo/package.json" 3)))
+  (test-pred "tracefs fd annotation emits fd write event"
+    events
+    (lambda (xs) (has-fs-event-with-fd? xs 'write "/repo/out.txt" 4)))
+  (test-pred "tracefs dirfd tracking resolves relative openat"
+    events
+    (lambda (xs) (has-fs-event? xs 'read "/repo/subdir/child.txt"))))
+
 ;; ===== temp-home: sandbox integration =====
 (printf "[temp-home]~%")