Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe("AcsPlugin's load-time gate", () => {
// Sanity: the gate below does not false-positive on the real, shipped
// opencode.hookmap.yaml, which declares `refuse.denied: { value: true }`
// on deny/ask/defer precisely so this passes.
const shipped = new URL("../opencode.hookmap.yaml", import.meta.url).pathname;
const shipped = fileURLToPath(new URL("../opencode.hookmap.yaml", import.meta.url));
await expect(runPlugin(shipped)).resolves.toBeUndefined();
});

Expand Down
8 changes: 6 additions & 2 deletions reference-implementations/agt/packages/agt-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ An `annotator` option supplies the function AGT calls for every `annotators.<nam

## OPA

AGT's native core spawns `opa`. The SDK ships it as a per-platform optional dependency and tries to put it on `PATH` by assigning `process.env`, which under Bun changes nothing a spawned process can see. This package resolves the binary the SDK's own way, publishes it into the real process environment as `ACS_OPA_PATH` through libc, and reads it back. If the readback disagrees, construction throws. A process that cannot reach OPA never gets a bridge, so a Guardian refuses to start rather than starting and denying everything.
AGT's native core spawns `opa`. The SDK ships it as a per-platform optional dependency and tries to put it on `PATH` by assigning `process.env`, which under Bun on Linux and macOS changes nothing a spawned process can see. This package resolves the binary the SDK's own way and publishes it into the real process environment as `ACS_OPA_PATH`.

On Linux and macOS it publishes through libc and reads the value back. If the readback disagrees, construction throws. A process that cannot reach OPA never gets a bridge, so a Guardian refuses to start rather than starting and denying everything.

On Windows a Bun `process.env` write reaches the real environment, so this package writes `ACS_OPA_PATH` there and loads no C library. Nothing reads the value back. If that write were ever lost, the Guardian would still start, and every evaluation would deny on `runtime_error:policy_invocation_failed`.

| Variable | What it does |
|---|---|
Expand All @@ -48,7 +52,7 @@ AGT's native core spawns `opa`. The SDK ships it as a per-platform optional depe
| File | What it holds |
|---|---|
| `src/index.ts` | `createBridge`, the two roles, and the verdict, evidence and annotator types |
| `src/opa-path.ts` | The OPA resolution, the libc publish, and the readback |
| `src/opa-path.ts` | The OPA resolution, the publish, and the Linux and macOS readback |

## Tests

Expand Down
63 changes: 44 additions & 19 deletions reference-implementations/agt/packages/agt-bridge/src/opa-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,43 @@
* prepending its directory to `process.env.PATH`. The Rust core then spawns
* `opa` by name, reading PATH from the real process environment. Under Node
* those are one environment: a `process.env` assignment calls `setenv`.
* Under Bun they are not. `process.env` is a JavaScript-side copy, and
* writing to it changes nothing libc's `getenv` can see -- measured on Bun
* 1.3.11 by reading the variable back through `bun:ffi` immediately after
* the write and getting the old value. So in every Bun process the SDK's own
* wiring is a silent no-op, the core spawns whatever `opa` the shell that
* launched Bun already had -- none, on a fresh GitHub runner -- and EVERY
* evaluation answers deny on `runtime_error:policy_invocation_failed`.
* Under Bun on Linux and macOS they are not. `process.env` is a
* JavaScript-side copy, and writing to it changes nothing libc's `getenv` can
* see -- measured on Bun 1.3.11 by reading the variable back through
* `bun:ffi` immediately after the write and getting the old value. So in
* those Bun processes the SDK's own wiring is a silent no-op, the core spawns
* whatever `opa` the shell that launched Bun already had -- none, on a fresh
* GitHub runner -- and EVERY evaluation answers deny on
* `runtime_error:policy_invocation_failed`.
* Fail-closed, which is the right direction, and also sixty-eight red tests
* for a reason with nothing to do with the commit under test.
*
* What this does about it. Resolve OPA through the SDK's own resolver, in
* the SDK's own documented order (an explicit `$ACS_OPA_PATH`, then the
* bundled optional dependency, then `opa` on PATH), then publish the
* resolved executable into the REAL environment as `ACS_OPA_PATH` through
* libc's `setenv`. `ACS_OPA_PATH` rather than PATH, for two reasons: the
* core treats it as authoritative -- "explicit OPA paths do not fall back to
* resolved executable into the REAL environment as `ACS_OPA_PATH`. Linux and
* macOS publish through libc's `setenv`. Windows publishes through a
* `process.env` write, which there reaches the real environment and needs no
* C library. `ACS_OPA_PATH` rather than PATH, for two reasons. The core
* treats it as authoritative -- "explicit OPA paths do not fall back to
* PATH" is its own error text -- so a wrong value fails loudly instead of
* quietly picking up some other `opa`; and rewriting PATH would reorder
* quietly picking up some other `opa`. Rewriting PATH would also reorder
* lookups for every other subprocess the Guardian ever spawns.
*
* Then read it back. A `setenv` that returned 0 but did not land -- a libc
* this file did not know how to open, or a Bun that one day grows a real
* `process.env` bridge with different semantics -- would put this process
* straight back into the silent case above. The readback is the check, and
* a mismatch throws. `createBridge` calls this before constructing the SDK's
* runtime, so a process that cannot reach OPA never gets a bridge at all:
* the Guardian refuses to start, rather than starting and denying everything.
* Then, on Linux and macOS, read it back. A `setenv` that returned 0 but did
* not land -- a libc this file did not know how to open, or a Bun that one
* day grows a real `process.env` bridge with different semantics -- would put
* this process straight back into the silent case above. The readback is the
* check, and a mismatch throws. `createBridge` calls this before constructing
* the SDK's runtime, so a process that cannot reach OPA never gets a bridge
* at all: the Guardian refuses to start, rather than starting and denying
* everything.
*
* The one path that skips FFI entirely: a libc this file cannot open, on a
* Windows has no readback, so a lost write there does not stop the Guardian.
* It starts, and every evaluation denies on
* `runtime_error:policy_invocation_failed`. publishOpaPath says why.
*
* On Linux and macOS one path skips FFI: a libc this file cannot open, on a
* process whose real environment already names the resolved binary. Bun
* seeds `process.env` from the real environment at startup, so
* `ACS_OPA_PATH=<binary> bun test` is the escape hatch for such a platform,
Expand Down Expand Up @@ -77,6 +85,23 @@ export function publishOpaPath(): string {
// stops here, before any setenv, and never falls back to another opa.
const resolved = ensureOpaResolvable();

// On Windows a Bun `process.env` write calls SetEnvironmentVariableW, so it
// reaches the real environment and this platform needs no C library. Linux
// and macOS keep the JavaScript-side copy the header describes. Return here,
// because a probe for libc.so would make the DLL loader search the working
// directory and PATH for a file of that name.
//
// Nothing reads the native environment back here. `process.env` answers from
// the copy this line just wrote, so reading it would compare the value with
// itself. The real read is kernel32 through FFI, and no runner in this
// project is Windows, so nobody could run that code. If the write is ever
// lost, nothing throws: the native core spawns opa without it and every
// evaluation denies on runtime_error:policy_invocation_failed.
if (process.platform === "win32") {
process.env[OPA_PATH_ENV] = resolved;
return resolved;
}

const libc = openLibc();
if (libc === undefined) {
if (process.env[OPA_PATH_ENV] === resolved) return resolved;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,43 @@ describe("publishOpaPath -- the opa AGT's native core spawns, made reachable fro
}
});

// Linux and macOS can reach the Windows branch only with a stubbed platform.
// The stub shows the branch's own logic: the path lands in `process.env` and
// the C library is never touched. It shows nothing about Windows itself.
it.skipIf(process.platform === "win32")("on win32 publishes through process.env and leaves the C library alone", async () => {
const opa = publishOpaPath();
const source = fileURLToPath(new URL("../src/opa-path.ts", import.meta.url));
const script = `
import { dlopen, FFIType } from "bun:ffi";
Object.defineProperty(process, "platform", { value: "win32" });
const { publishOpaPath } = await import(${JSON.stringify(source)});
const returned = publishOpaPath();
const js = process.env.ACS_OPA_PATH;
let real = "";
for (const name of ["libc.so.6", "libSystem.B.dylib"]) {
try {
const lib = dlopen(name, { getenv: { args: [FFIType.cstring], returns: FFIType.cstring } });
real = lib.symbols.getenv(Buffer.from("ACS_OPA_PATH\\0"))?.toString() ?? "";
break;
} catch {}
}
console.log(JSON.stringify({ returned, js, real }));
`;
// A directory hint, so the real environment and `process.env` both hold
// something other than the resolved file until a write reaches them.
const hint = opa.slice(0, opa.lastIndexOf("/"));
// The stubbed platform names a bundled opa package this machine does not
// have, so a digest stands in for any comparison with the bundled binary.
const digest = new Bun.CryptoHasher("sha256").update(await Bun.file(opa).arrayBuffer()).digest("hex");
const child = Bun.spawnSync([process.execPath, "--no-install", "-e", script], {
env: { PATH: "/nonexistent", ACS_OPA_PATH: hint, ACS_OPA_SHA256: digest },
stdout: "pipe",
stderr: "pipe",
});
expect({ exitCode: child.exitCode, stderr: child.stderr.toString() }).toEqual({ exitCode: 0, stderr: "" });
expect(JSON.parse(child.stdout.toString())).toEqual({ returned: opa, js: opa, real: hint });
});

it("answers the path of an opa that actually runs", () => {
const opa = publishOpaPath();
const child = Bun.spawnSync([opa, "version"], { stdout: "pipe", stderr: "pipe" });
Expand Down
66 changes: 54 additions & 12 deletions reference-implementations/agt/packages/guardian/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,17 +134,59 @@ const LOOPBACK_ONLY = "127.0.0.1";
// validate-envelope.ts relies on. Used only to redact it out of error text
// before that text leaves the Guardian; see toRepoRelativeMessage below.
const REPO_ROOT = fileURLToPath(new URL("../../../../../", import.meta.url)).replace(/[/\\]+$/, "");
// The lookahead requires the root to be followed by a path separator or the
// end of the string, before the optional `[/\\]?` consumes one such
// separator. Without it, a *sibling* directory whose name merely extends
// the root (`<root>_old`) matched too: the literal
// text of REPO_ROOT is a prefix of that name, so it stripped, leaving a
// misleading `_old/reference-implementations/agt` behind -- not a disclosure of this tree's
// own location, since it's a different directory entirely, but a
// diagnostic that then reads as if it were one. The lookahead makes that
// prefix match fail outright, so a message naming the sibling is left
// alone, in full.
const REPO_ROOT_PATTERN = new RegExp(`${REPO_ROOT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=[/\\\\]|$)[/\\\\]?`, "g");
// The root must be followed by a path separator or the end of the string.
// Without that rule, a *sibling* directory whose name merely extends the root
// (`<root>_old`) matched too: the literal text of REPO_ROOT is a prefix of
// that name, so it stripped, leaving a misleading
// `_old/reference-implementations/agt` behind -- not a disclosure of this
// tree's own location, since it's a different directory entirely, but a
// diagnostic that then reads as if it were one. A message naming the sibling
// is left alone, in full.
//
// The pattern matches the root and nothing after it, with no repeated group.
// JavaScriptCore stops matching a repeated group that holds a lookahead once it
// has run about 900 KB, and it reports no match instead of an error, so such a
// pattern hands the whole message back with the root still in it. The stripper
// splits on this pattern, which removes every copy of the root.
//
// A root that ends a path, as in `scandir '<root>'`, counts too, so it stops at
// the delimiters that can close a path. Anything else after the root is a
// sibling, and stays.
//
// On Windows a path can come back in a spelling the root does not have: `c:\`
// for `C:\`, forward slashes, doubled backslashes from JSON, or a `/C:/` or
// `file:///C:/` lead-in from a file URL. The lead-in goes with the root, so
// what remains is relative. Any platform's file URL writes a space as `%20`.
//
// Only a Windows root rewrites backslashes, and only in the path remainder. On
// Linux and macOS a backslash is an ordinary file name character, so rewriting
// it names a different file. After the remainder, a backslash belongs to
// whatever text comes next: a regex, or another drive's path.
const BARE_REMAINDER = /^[^'"\s:;,=)\]]+/;
// A quote says where the path closes, so a space inside it is part of the path.
// Bare text gives no such signal, so a bare remainder stops at the first space.
const QUOTED_REMAINDER = /^[^'"\t\n\v\f\r:;,=)\]]+/;

export function repoRootStripper(root: string, windows = process.platform === "win32"): (message: string) => string {
const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/ /g, "(?: |%20)");
const separator = windows ? "[/\\\\]+" : "[/\\\\]";
const source = windows ? root.split(/[/\\]+/).map(escape).join(separator) : escape(root);
const leadIn = windows ? "(?:file:/+|/)?" : "";
const pattern = new RegExp(`${leadIn}${source}(?:${separator}|(?=['"\\s:;,=)\\]]|$))`, windows ? "gi" : "g");
return (message) => {
const parts = message.split(pattern);
if (!windows) return parts.join("");
return parts
.map((part, index) => {
if (index === 0) return part;
const previous = parts[index - 1] as string;
const remainder = previous.endsWith("'") || previous.endsWith('"') ? QUOTED_REMAINDER : BARE_REMAINDER;
return part.replace(remainder, (rel) => rel.replace(/\\+/g, "/"));
})
.join("");
};
}
const stripRepoRoot = repoRootStripper(REPO_ROOT);

/**
* Both catches in this module -- the outer net in handleAcsRequest and the
Expand Down Expand Up @@ -191,7 +233,7 @@ const REPO_ROOT_PATTERN = new RegExp(`${REPO_ROOT.replace(/[.*+?^${}()|[\]\\]/g,
export function toRepoRelativeMessage(error: unknown): string {
try {
const message = String(error instanceof Error ? error.message : error);
return message.replace(REPO_ROOT_PATTERN, "");
return stripRepoRoot(message);
} catch {
return "<unprintable error>";
}
Expand Down
Loading
Loading