Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/linux-gates-research.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Linux attribution research gates

on:
pull_request:
paths:
- '.github/workflows/linux-gates-research.yml'
- 'package.json'
- 'package-lock.json'
- 'experimental/126-linux-gates/**'
- 'experimental/126-guard-liveness/**'
Comment thread
abruption marked this conversation as resolved.
workflow_dispatch:

permissions:
contents: read

jobs:
research-models-and-kernel:
name: Models and synthetic Linux lock observations
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
# The import-graph parser uses the existing TypeScript dev dependency.
# Skip lifecycle scripts: synthetic snapshot tests do not load the
# product or a Codex binary and establish no unlock eligibility.
- run: npm ci --ignore-scripts --no-audit --no-fund
- run: >-
node --test experimental/126-linux-gates/*/*.test.mjs
experimental/126-guard-liveness/f2-linux-nondumpable/build.test.mjs
experimental/126-guard-liveness/f2-linux-nondumpable/build-binding.test.mjs
experimental/126-guard-liveness/f2-linux-nondumpable/build-snapshot.test.mjs
490 changes: 490 additions & 0 deletions experimental/126-linux-gates/README.md

Large diffs are not rendered by default.

119 changes: 119 additions & 0 deletions experimental/126-linux-gates/fixtures/codex.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Synthetic native holder named `codex` (#126 research, Linux gates G1).
*
* SYNTHETIC. This is not Codex and says nothing about a deployed Codex
* binary. It exists only so that the product's executable/argv checks see a
* native image whose argv[0] basename is `codex`, with no arguments.
*
* Behaviour mirrors ../../126-guard-liveness/f2-linux-nondumpable/codex:
* refuse root and differing real/effective UIDs, set and verify the requested
* dumpable value with prctl, take `.coordination.lock` then the thread lock
* with bounded nonblocking polling, release coordination, print one ready
* JSON line, then hold until stdin EOF (exit 0) or the lifetime (exit 3).
*
* Environment: FIXTURE_LOCK_PATH, FIXTURE_DUMPABLE (0 or 1),
* FIXTURE_LIFETIME_SECONDS (1-120, default 20).
*
* It sends no signal to any process. Build: cc -std=c11 -O1 -Wall -Wextra.
*/
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/file.h>
#include <time.h>
#include <unistd.h>
#ifdef __linux__
#include <sys/prctl.h>
#endif

static double now_s(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}

static int fail(const char *what) {
fprintf(stderr, "codex fixture: %s: %s\n", what, errno ? strerror(errno) : "invalid");
return 1;
}

static int acquire(int fd, double deadline) {
double until = now_s() + 2.0;
if (until > deadline) until = deadline;
for (;;) {
if (flock(fd, LOCK_EX | LOCK_NB) == 0) return 0;
if (errno != EWOULDBLOCK && errno != EAGAIN && errno != EINTR) return -1;
if (now_s() >= until) { errno = ETIMEDOUT; return -1; }
struct timespec pause = { 0, 10 * 1000 * 1000 };
nanosleep(&pause, NULL);
}
}

static long parse_long(const char *text, long lo, long hi, long fallback) {
if (!text) return fallback;
char *end = NULL;
errno = 0;
long value = strtol(text, &end, 10);
if (errno || !end || *end || end == text || value < lo || value > hi) return LONG_MIN;
return value;
}

int main(void) {
#ifndef __linux__
errno = 0;
return fail("Linux only");
#else
if (getuid() == 0 || getuid() != geteuid()) { errno = 0; return fail("unprivileged same-UID process required"); }
const char *lock_path = getenv("FIXTURE_LOCK_PATH");
long want = parse_long(getenv("FIXTURE_DUMPABLE"), 0, 1, LONG_MIN);
long lifetime = parse_long(getenv("FIXTURE_LIFETIME_SECONDS"), 1, 120, 20);
if (!lock_path || lock_path[0] != '/' || want == LONG_MIN || lifetime == LONG_MIN) { errno = 0; return fail("environment"); }
double deadline = now_s() + (double)lifetime;
if (prctl(PR_SET_DUMPABLE, (unsigned long)want, 0UL, 0UL, 0UL) != 0) return fail("prctl(PR_SET_DUMPABLE)");
int dumpable = prctl(PR_GET_DUMPABLE, 0UL, 0UL, 0UL, 0UL);
if (dumpable < 0) return fail("prctl(PR_GET_DUMPABLE)");
if (dumpable != (int)want) { errno = 0; return fail("dumpable mismatch"); }

char guard_path[PATH_MAX];
const char *slash = strrchr(lock_path, '/');
size_t dir_len = (size_t)(slash - lock_path);
if (dir_len == 0 || dir_len + sizeof("/.coordination.lock") > sizeof(guard_path)) { errno = 0; return fail("lock path"); }
memcpy(guard_path, lock_path, dir_len);
memcpy(guard_path + dir_len, "/.coordination.lock", sizeof("/.coordination.lock"));

int guard = open(guard_path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
if (guard < 0) return fail("open coordination");
if (acquire(guard, deadline) != 0) { int saved = errno; close(guard); errno = saved; return fail("acquire coordination"); }
int thread = open(lock_path, O_RDWR | O_CLOEXEC | O_NOFOLLOW);
if (thread < 0) { int saved = errno; close(guard); errno = saved; return fail("open thread lock"); }
if (acquire(thread, deadline) != 0) { int saved = errno; close(thread); close(guard); errno = saved; return fail("acquire thread lock"); }
flock(guard, LOCK_UN);
close(guard);
printf("{\"ready\":true,\"pid\":%ld,\"fd\":%d,\"dumpable\":%d,\"uid\":%ld,\"euid\":%ld,\"lifetime_seconds\":%ld,\"fixture\":\"native_c_synthetic\"}\n",
(long)getpid(), thread, dumpable, (long)getuid(), (long)geteuid(), lifetime);
fflush(stdout);

int eof = 0;
char buffer[4096];
while (!eof) {
double left = deadline - now_s();
if (left <= 0) break;
struct pollfd p = { STDIN_FILENO, POLLIN, 0 };
int ready = poll(&p, 1, (int)(left * 1000.0) + 1);
if (ready < 0 && errno == EINTR) continue;
if (ready < 0) break;
if (ready == 0) continue;
ssize_t count = read(STDIN_FILENO, buffer, sizeof(buffer));
if (count == 0) eof = 1;
else if (count < 0 && errno != EINTR && errno != EAGAIN) break;
}
close(thread);
/* Exit 0 on stdin EOF, 3 when the lifetime ended first or stdin failed. */
return eof ? 0 : 3;
#endif
}
54 changes: 54 additions & 0 deletions experimental/126-linux-gates/fixtures/node/codex
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env node
// Synthetic Node holder named `codex` (#126 research, Linux gates G1).
//
// SYNTHETIC, not Codex. It is started as `<node> <this file>`, the one
// interpreter form the product's argv grammar accepts for its own test
// fixture (basename(argv[0]) is node, basename(argv[1]) is codex). Node cannot
// call prctl, so this holder never changes dumpability; the runner infers the
// inherited value from /proc ownership and records it as inferred.
//
// Takes `.coordination.lock` then the thread lock with bounded nonblocking
// polling, releases coordination, prints one ready JSON line and holds until
// stdin EOF (exit 0) or FIXTURE_LIFETIME_SECONDS (exit 3). Sends no signal.
import fs from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import process from 'node:process'
import { clearTimeout, setTimeout } from 'node:timers'

if (process.platform !== 'linux' || process.getuid() === 0 || process.getuid() !== process.geteuid()) {
throw new Error('fixture requires an unprivileged same-user Linux process')
}
const require = createRequire(process.env.FIXTURE_REQUIRE_FROM)
const { flockSync } = require('fs-ext-extra-prebuilt')
const lifetime = Number(process.env.FIXTURE_LIFETIME_SECONDS ?? '20')
if (!Number.isInteger(lifetime) || lifetime < 1 || lifetime > 120) throw new Error('FIXTURE_LIFETIME_SECONDS must be 1-120')
const sleeper = new Int32Array(new SharedArrayBuffer(4))

function acquire(fd) {
const until = Date.now() + 2000
while (true) {
try {
flockSync(fd, 'exnb')
return
} catch (error) {
if (error.code !== 'EAGAIN' && error.code !== 'EWOULDBLOCK') throw error
}
if (Date.now() > until) throw new Error('fixture lock acquisition timed out')
Atomics.wait(sleeper, 0, 0, 10)
}
}

const lockPath = process.env.FIXTURE_LOCK_PATH
const flags = fs.constants.O_CLOEXEC | fs.constants.O_NOFOLLOW
const guard = fs.openSync(path.join(path.dirname(lockPath), '.coordination.lock'), fs.constants.O_RDONLY | flags)
acquire(guard)
const thread = fs.openSync(lockPath, fs.constants.O_RDWR | flags)
acquire(thread)
flockSync(guard, 'un')
fs.closeSync(guard)
process.stdout.write(JSON.stringify({ ready: true, pid: process.pid, fd: thread, dumpable: null,
uid: process.getuid(), euid: process.geteuid(), lifetime_seconds: lifetime, fixture: 'node_synthetic' }) + '\n')
const timer = setTimeout(() => { fs.closeSync(thread); process.exitCode = 3; process.stdin.destroy() }, lifetime * 1000)
process.stdin.on('end', () => { clearTimeout(timer); fs.closeSync(thread); process.exitCode = 0 })
process.stdin.resume()
48 changes: 48 additions & 0 deletions experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env node
// Cross-platform check of the G1 argv predictions against the product's
// pure `reconcileArguments` (#126 research).
//
// G1_BUILD=<build.mjs output> node argv-check.mjs
//
// It imports only the build's process.js (verified against its manifest
// first) and calls one pure function with each variant's expected kernel
// argv and an equal `ps -o args=` string. No process is inspected or
// signalled. This checks the argv part of the prediction only; real ps/proc
// behaviour is measured by run.mjs on Linux.
import { log } from 'node:console'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { reachableModules, verifyBuild } from '../../126-guard-liveness/f2-linux-nondumpable/build.mjs'
import { assertPinnedUnpatchedBuild, expectedArgv, PRODUCT_ARGV_ERROR, VARIANT_ORDER, VARIANTS } from './variants.mjs'
import { createPinnedBuildSnapshot } from './build-input.mjs'

if (!process.env.G1_BUILD) throw new Error('G1_BUILD is required')
const build = fs.realpathSync(process.env.G1_BUILD)
const startup = verifyBuild(build)
if (!startup.ok) throw new Error(`build does not match its manifest: ${startup.faults.join(',')}`)
const snapshot = createPinnedBuildSnapshot(build, startup.manifestSha256)
const { verified } = snapshot
assertPinnedUnpatchedBuild(verified)
const dist = snapshot.dist
if (reachableModules(dist, ['process.js']).includes('unlock.js')) throw new Error('unlock.js reachable')
const { reconcileArguments } = await import(pathToFileURL(path.join(dist, 'process.js')).href)

// Example paths with the same shape as the run host's.
const paths = { scriptPath: '/srv/research/126-guard-liveness/f2-linux-nondumpable/codex',
nativePath: '/srv/scratch/codex-126-g1-x/native/codex', nodeExec: '/usr/bin/node',
nodeFixture: '/srv/research/126-linux-gates/fixtures/node/codex' }
const results = VARIANT_ORDER.map((variant) => {
const argv = expectedArgv(variant, paths)
const evidence = reconcileArguments(argv.join(' '), { status: 'present', argv })
const expectedError = VARIANTS[variant].argvClean ? undefined : PRODUCT_ARGV_ERROR
return { variant, argv, evidence, ok: evidence.error === expectedError && evidence.isSharedService === false &&
(expectedError ? evidence.arguments === null : evidence.arguments === argv.join(' ')) }
})
// The product accepts only node/nodejs as an interpreter basename.
const renamedNode = reconcileArguments('/usr/bin/node24 /x/codex', { status: 'present', argv: ['/usr/bin/node24', '/x/codex'] })
results.push({ variant: 'node_renamed_binary', evidence: renamedNode, ok: renamedNode.error === PRODUCT_ARGV_ERROR })
const ok = results.every((r) => r.ok)
log(JSON.stringify({ build: { manifestSha256: verified.manifestSha256, commit: verified.source.commit }, ok, results }, null, 2))
process.exitCode = ok ? 0 : 1
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Keep the source pin and startup manifest binding on the copied bytes used
// by every G1 build-code consumer, rather than on a mutable input directory.
import path from 'node:path'
import { createBuildSnapshot } from '../../126-guard-liveness/f2-linux-nondumpable/build-snapshot.mjs'
import { assertPinnedUnpatchedBuild } from './variants.mjs'

export function createPinnedBuildSnapshot(build, expectedManifestSha256) {
const snapshot = createBuildSnapshot(build, expectedManifestSha256)
if (!snapshot.verified.ok) throw new Error(`G1_snapshot_unverified:${path.basename(snapshot.directory)}:${snapshot.verified.faults.join(',')}`)
assertPinnedUnpatchedBuild(snapshot.verified)
return snapshot
}
Loading
Loading