From 91496007ca9d3e711993441c23170bd1470e1665 Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 16 Jul 2026 23:51:17 -0400 Subject: [PATCH] harkaq-trace: la traza POSITIVA de la clausura (plan-freebsd T1.1-T1.2, prior art filemon/META MODE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - harkaq-trace.c: fanotify FAN_MARK_FILESYSTEM — agnóstico de mount-ns, el tracer en el HOST ve los open() de dentro del bwrap (ptrace lo deniega el seccomp D4; audit sólo emite denegaciones). Crudo y tonto a propósito (reparto Q1c); D9: sin fanotify ⇒ exit 3 SinEvidencia, jamás traza vacía. - harkaq-trace-norm.py: canoniza (drop /proc /sys /dev /tmp /out /src, dedupe, orden estable — la lección nº1 de META MODE) + atribución por dep con --resumen = la señal de PODA (T1.4): dep declarada con 0 toques = grasa. - VALIDADO: modo --fs como root (worker, 5s, sin tocar colas): capturó exactamente zlib.h + 3×libz.a de un cat/head; laptop sin privilegio ⇒ SinEvidencia honesto. Piloto completo (traza de un hammer build real + resumen de poda) pendiente de un rebuild natural en el worker. Co-Authored-By: Claude Fable 5 --- scripts/harkaq/harkaq-trace-norm.py | 71 +++++++++++ scripts/harkaq/harkaq-trace.c | 184 ++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 scripts/harkaq/harkaq-trace-norm.py create mode 100644 scripts/harkaq/harkaq-trace.c diff --git a/scripts/harkaq/harkaq-trace-norm.py b/scripts/harkaq/harkaq-trace-norm.py new file mode 100644 index 00000000..53249cb2 --- /dev/null +++ b/scripts/harkaq/harkaq-trace-norm.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""harkaq-trace-norm — canoniza la traza cruda de harkaq-trace (plan-freebsd T1.2). + + harkaq-trace-norm.py [--store ] [--drop ]... [--resumen] + +Las reglas de normalización son la parte que FreeBSD tardó años en iterar (los `.meta` de +META MODE): el ruido no se CALLA, se clasifica con reglas explícitas — D3 aplica acá igual +que en el verdict. Reglas: + + 1. DROP por prefijo: /proc /sys /dev /tmp /out /src (el build escribe /out y lee /src por + contrato; lo interesante es lo demás). Ampliable con --drop. + 2. DEDUPE + ORDEN ESTABLE: la traza cruda trae un evento por open(); el mismo path N veces + no es señal (el orden de llegada tampoco: readdir no garantiza orden — la lección + número uno de META MODE). Salida: paths únicos, ordenados. + 3. ATRIBUCIÓN por dep (--store): un path store/-/resto se atribuye a la dep + . Con --resumen emite el conteo por dep — la señal de PODA (T1.4): una dep + declarada con 0 toques es grasa de la clausura. + +El sellado (T1.3) NO es de este script: la salida canónica es determinista a propósito +(orden estable, sin timestamps) para que `b3sum` del worker la selle tal cual y viaje junto +al of_tree. Un script canoniza, el store sella — cada pieza tonta en lo suyo. +""" +import argparse +import re +import sys +from collections import Counter +from pathlib import Path + +DROP_DEFAULT = ["/proc", "/sys", "/dev", "/tmp", "/out", "/src", "/run"] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("traza") + ap.add_argument("--store", default=None, + help="raíz del store para atribuir paths a deps (store/-/…)") + ap.add_argument("--drop", action="append", default=[], + help="prefijo adicional a descartar (repetible)") + ap.add_argument("--resumen", action="store_true", + help="emite conteo por dep en vez de la lista canónica") + args = ap.parse_args() + + drops = DROP_DEFAULT + args.drop + store = str(Path(args.store).resolve()) if args.store else None + # store/-/… — el nombre puede traer guiones; el hash no. + re_dep = re.compile(re.escape(store) + r"/[0-9a-f]{16,}-([^/]+)/") if store else None + + unicos = set() + por_dep = Counter() + with open(args.traza) as f: + for linea in f: + p = linea.strip() + if not p or any(p == d or p.startswith(d + "/") for d in drops): + continue + if p not in unicos and re_dep: + m = re_dep.match(p) + if m: + por_dep[m.group(1)] += 1 + unicos.add(p) + + if args.resumen: + for dep, n in sorted(por_dep.items()): + print(f"{n:6d} {dep}") + else: + for p in sorted(unicos): + print(p) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/harkaq/harkaq-trace.c b/scripts/harkaq/harkaq-trace.c new file mode 100644 index 00000000..966c820e --- /dev/null +++ b/scripts/harkaq/harkaq-trace.c @@ -0,0 +1,184 @@ +// harkaq-trace — la traza POSITIVA de la clausura (plan-freebsd T1.1; prior art: el +// filemon/META MODE de FreeBSD, 12 años de builds midiendo "qué tocó este target"). +// +// harkaq-audit lee lo DENEGADO (evidencia negativa bajo política=clausura). Esto lee lo +// ABIERTO (evidencia positiva). Juntas cierran el bucle en las dos direcciones que el SDD 16 +// sólo cierra en una: la deuda (usó sin declarar → audit) y la GRASA (declaró y jamás tocó → +// esta traza). La grasa hoy es invisible y se hereda a la política runtime del cierre §3. +// +// harkaq-trace [--fs ] [--dir ]... [--prefix ]... [-o ] -- ... +// +// --fs marca el FILESYSTEM que contiene (FAN_MARK_FILESYSTEM; pide +// CAP_SYS_ADMIN — el modo del piloto en el worker, que corre como root). +// La marca de filesystem es AGNÓSTICA de mount-namespaces: el tracer vive +// en el HOST y ve los open() que el build hace DENTRO del bwrap sin cruzar +// ningún ns — la razón por la que fanotify le gana a ptrace acá (ptrace +// además lo deniega el seccomp D4 del propio sandbox) y a audit (que sólo +// emite DENEGACIONES, nunca concesiones usadas). +// --dir marca un directorio suelto, sólo hijos directos (modo self-test sin +// privilegios — suficiente para probar el ciclo, NO para un build real). +// --prefix

filtra la salida a paths bajo

(repetible). En el worker: el store — +// exactamente la pregunta "¿qué parte de la clausura concedida se usó?". +// +// Salida: un path por línea, CRUDO y con duplicados. Clasificar, deduplicar y canonizar es +// del normalizador (harkaq-trace-norm.py) — el mismo reparto que harkaq-audit/harkaq-verdict: +// el proceso con privilegio se mantiene lo más tonto posible (argumento Q1c). +// +// D9 (canario): si fanotify no arranca (sin privilegio en --fs, kernel sin soporte), el exit +// es 3 = SinEvidencia. Jamás una traza vacía que se lea como "el build no usó nada". + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_MARKS 32 +#define MAX_PREFIJOS 32 + +static const char *marcas_fs[MAX_MARKS]; +static const char *marcas_dir[MAX_MARKS]; +static const char *prefijos[MAX_PREFIJOS]; +static int n_fs = 0, n_dir = 0, n_pref = 0; + +static int pasa_filtro(const char *path) { + if (n_pref == 0) + return 1; + for (int i = 0; i < n_pref; i++) + if (strncmp(path, prefijos[i], strlen(prefijos[i])) == 0) + return 1; + return 0; +} + +int main(int argc, char **argv) { + const char *salida = NULL; + int i = 1; + for (; i < argc; i++) { + if (strcmp(argv[i], "--fs") == 0 && i + 1 < argc && n_fs < MAX_MARKS) + marcas_fs[n_fs++] = argv[++i]; + else if (strcmp(argv[i], "--dir") == 0 && i + 1 < argc && n_dir < MAX_MARKS) + marcas_dir[n_dir++] = argv[++i]; + else if (strcmp(argv[i], "--prefix") == 0 && i + 1 < argc && n_pref < MAX_PREFIJOS) + prefijos[n_pref++] = argv[++i]; + else if (strcmp(argv[i], "-o") == 0 && i + 1 < argc) + salida = argv[++i]; + else if (strcmp(argv[i], "--") == 0) { + i++; + break; + } else { + fprintf(stderr, "harkaq-trace: arg desconocido: %s\n", argv[i]); + return 2; + } + } + if (i >= argc || (n_fs + n_dir) == 0) { + fprintf(stderr, + "uso: harkaq-trace [--fs p] [--dir p]... [--prefix p]... [-o out] -- cmd...\n"); + return 2; + } + char **cmd = &argv[i]; + + FILE *out = stdout; + if (salida) { + out = fopen(salida, "w"); + if (!out) { + perror("harkaq-trace: fopen salida"); + return 2; + } + } + + // FAN_CLASS_NOTIF: sólo observamos, no autorizamos — la autorización es de Landlock + // (harkaq-exec). FAN_CLOEXEC: el hijo no hereda el grupo (se auto-trazaría el exec). + int fan = fanotify_init(FAN_CLASS_NOTIF | FAN_CLOEXEC, O_RDONLY | O_CLOEXEC); + if (fan < 0) { + // D9: sin grupo fanotify no hay evidencia. 3 = SinEvidencia, mismo código que audit. + fprintf(stderr, "harkaq-trace: SinEvidencia — fanotify_init: %s\n", strerror(errno)); + return 3; + } + for (int m = 0; m < n_fs; m++) + if (fanotify_mark(fan, FAN_MARK_ADD | FAN_MARK_FILESYSTEM, FAN_OPEN, AT_FDCWD, + marcas_fs[m]) < 0) { + fprintf(stderr, "harkaq-trace: SinEvidencia — mark fs %s: %s\n", marcas_fs[m], + strerror(errno)); + return 3; + } + for (int m = 0; m < n_dir; m++) + if (fanotify_mark(fan, FAN_MARK_ADD, FAN_OPEN | FAN_EVENT_ON_CHILD, AT_FDCWD, + marcas_dir[m]) < 0) { + fprintf(stderr, "harkaq-trace: SinEvidencia — mark dir %s: %s\n", marcas_dir[m], + strerror(errno)); + return 3; + } + + pid_t hijo = fork(); + if (hijo < 0) { + perror("harkaq-trace: fork"); + return 2; + } + if (hijo == 0) { + execvp(cmd[0], cmd); + perror("harkaq-trace: execvp"); + _exit(127); + } + + // Bucle: drenar eventos mientras el hijo vive, y un último drenado tras su muerte + // (la cola puede traer eventos rezagados del propio build). + int rc_hijo = -1; + int vivo = 1; + char buf[16384], path[PATH_MAX + 1], link[64]; + while (1) { + if (vivo) { + int st; + pid_t w = waitpid(hijo, &st, WNOHANG); + if (w == hijo) { + vivo = 0; + rc_hijo = WIFEXITED(st) ? WEXITSTATUS(st) : 128 + WTERMSIG(st); + } + } + struct pollfd p = {.fd = fan, .events = POLLIN}; + int pr = poll(&p, 1, vivo ? 200 : 150); + if (pr < 0 && errno != EINTR) { + perror("harkaq-trace: poll"); + break; + } + if (pr <= 0) { + if (!vivo) + break; // hijo muerto y cola seca: terminamos + continue; + } + ssize_t n = read(fan, buf, sizeof(buf)); + if (n <= 0) + continue; + for (struct fanotify_event_metadata *ev = (void *)buf; FAN_EVENT_OK(ev, n); + ev = FAN_EVENT_NEXT(ev, n)) { + if (ev->vers != FANOTIFY_METADATA_VERSION) { + fprintf(stderr, "harkaq-trace: version fanotify no casa\n"); + return 3; + } + if (ev->fd < 0) + continue; + // El propio tracer (y su fichero de salida) generan eventos: fuera. + if (ev->pid == getpid()) { + close(ev->fd); + continue; + } + snprintf(link, sizeof(link), "/proc/self/fd/%d", ev->fd); + ssize_t l = readlink(link, path, PATH_MAX); + close(ev->fd); + if (l <= 0) + continue; + path[l] = '\0'; + if (pasa_filtro(path)) + fprintf(out, "%s\n", path); + } + } + if (out != stdout) + fclose(out); + // El rc del build viaja intacto: la traza es un side-channel, no un juez. + return rc_hijo < 0 ? 2 : rc_hijo; +}