vigiles 2.5.0 → 3.0.0
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.
- package/.claude-plugin/plugin.json +9 -0
- package/README.md +73 -434
- package/action.yml +143 -7
- package/dist/action-gate.d.ts +1 -1
- package/dist/action-gate.js +1 -1
- package/dist/adapter-conformance.d.ts +30 -0
- package/dist/adapter-conformance.js +153 -0
- package/dist/adapter-registry.d.ts +42 -0
- package/dist/adapter-registry.js +55 -0
- package/dist/adapter.d.ts +26 -0
- package/dist/adapter.js +16 -0
- package/dist/adapters/claude-code/adapter.d.ts +3 -0
- package/dist/adapters/claude-code/adapter.js +46 -0
- package/dist/{agent-result.d.ts → adapters/claude-code/agent-result.d.ts} +1 -1
- package/dist/adapters/claude-code/dialect.d.ts +13 -0
- package/dist/adapters/claude-code/dialect.js +51 -0
- package/dist/adapters/claude-code/egress-entry.d.ts +2 -0
- package/dist/adapters/claude-code/egress-entry.js +115 -0
- package/dist/adapters/claude-code/egress-proxy.d.ts +2 -0
- package/dist/adapters/claude-code/egress-proxy.js +60 -0
- package/dist/adapters/claude-code/egress.d.ts +114 -0
- package/dist/adapters/claude-code/egress.js +276 -0
- package/dist/adapters/claude-code/eval-baseline.d.ts +68 -0
- package/dist/adapters/claude-code/eval-baseline.js +173 -0
- package/dist/{eval-cache.d.ts → adapters/claude-code/eval-cache.d.ts} +1 -1
- package/dist/{eval-cache.js → adapters/claude-code/eval-cache.js} +1 -1
- package/dist/{eval.d.ts → adapters/claude-code/eval.d.ts} +28 -4
- package/dist/{eval.js → adapters/claude-code/eval.js} +61 -25
- package/dist/{harness-test.d.ts → adapters/claude-code/harness-test.d.ts} +33 -37
- package/dist/{harness-test.js → adapters/claude-code/harness-test.js} +124 -43
- package/dist/adapters/claude-code/hook-protocol.d.ts +10 -0
- package/dist/adapters/claude-code/hook-protocol.js +10 -0
- package/dist/adapters/claude-code/layout.d.ts +8 -0
- package/dist/adapters/claude-code/layout.js +18 -0
- package/dist/{mock-model.d.ts → adapters/claude-code/mock-model.d.ts} +2 -24
- package/dist/adapters/claude-code/model-mock.d.ts +11 -0
- package/dist/adapters/claude-code/model-mock.js +10 -0
- package/dist/adapters/claude-code/plugin-loader.d.ts +25 -0
- package/dist/adapters/claude-code/plugin-loader.js +19 -0
- package/dist/adapters/claude-code/run-hook.d.ts +204 -0
- package/dist/adapters/claude-code/run-hook.js +418 -0
- package/dist/adapters/claude-code/run-scripts.d.ts +52 -0
- package/dist/adapters/claude-code/run-scripts.js +150 -0
- package/dist/adapters/claude-code/runtime.d.ts +16 -0
- package/dist/adapters/claude-code/runtime.js +39 -0
- package/dist/{sandbox.d.ts → adapters/claude-code/sandbox.d.ts} +45 -4
- package/dist/{sandbox.js → adapters/claude-code/sandbox.js} +76 -8
- package/dist/{skill-driver.d.ts → adapters/claude-code/skill-driver.d.ts} +1 -1
- package/dist/adapters/codex/adapter.d.ts +3 -0
- package/dist/adapters/codex/adapter.js +49 -0
- package/dist/adapters/codex/dialect.d.ts +10 -0
- package/dist/adapters/codex/dialect.js +30 -0
- package/dist/adapters/codex/driver.d.ts +20 -0
- package/dist/adapters/codex/driver.js +89 -0
- package/dist/adapters/codex/hook-protocol.d.ts +10 -0
- package/dist/adapters/codex/hook-protocol.js +18 -0
- package/dist/adapters/codex/layout.d.ts +16 -0
- package/dist/adapters/codex/layout.js +18 -0
- package/dist/adapters/codex/mock-model.d.ts +52 -0
- package/dist/adapters/codex/mock-model.js +210 -0
- package/dist/adapters/codex/model-mock.d.ts +11 -0
- package/dist/adapters/codex/model-mock.js +10 -0
- package/dist/adapters/codex/runtime.d.ts +38 -0
- package/dist/adapters/codex/runtime.js +62 -0
- package/dist/adapters/opencode/adapter.d.ts +3 -0
- package/dist/adapters/opencode/adapter.js +48 -0
- package/dist/adapters/opencode/dialect.d.ts +10 -0
- package/dist/adapters/opencode/dialect.js +36 -0
- package/dist/adapters/opencode/layout.d.ts +9 -0
- package/dist/adapters/opencode/layout.js +25 -0
- package/dist/adapters/opencode/model-mock.d.ts +10 -0
- package/dist/adapters/opencode/model-mock.js +10 -0
- package/dist/adapters/opencode/runtime.d.ts +9 -0
- package/dist/adapters/opencode/runtime.js +21 -0
- package/dist/claude-code.d.ts +16 -0
- package/dist/claude-code.js +32 -0
- package/dist/cli-flags.d.ts +22 -0
- package/dist/cli-flags.js +38 -0
- package/dist/cli.js +406 -206
- package/dist/codex.d.ts +20 -0
- package/dist/codex.js +36 -0
- package/dist/community-skills.d.ts +1 -1
- package/dist/community-skills.js +2 -2
- package/dist/core/adapter.d.ts +88 -0
- package/dist/core/adapter.js +3 -0
- package/dist/{compile.d.ts → core/compile.d.ts} +11 -2
- package/dist/{compile.js → core/compile.js} +68 -53
- package/dist/core/compose.d.ts +79 -0
- package/dist/core/compose.js +145 -0
- package/dist/core/dialect.d.ts +51 -0
- package/dist/core/dialect.js +3 -0
- package/dist/core/harness-driver.d.ts +134 -0
- package/dist/core/harness-driver.js +3 -0
- package/dist/core/hook-protocol.d.ts +28 -0
- package/dist/core/hook-protocol.js +3 -0
- package/dist/core/layout.d.ts +43 -0
- package/dist/core/layout.js +3 -0
- package/dist/core/model-mock.d.ts +27 -0
- package/dist/core/model-mock.js +3 -0
- package/dist/core/refs.d.ts +62 -0
- package/dist/{refs.js → core/refs.js} +49 -23
- package/dist/core/runtime.d.ts +38 -0
- package/dist/core/runtime.js +3 -0
- package/dist/{types.d.ts → core/types.d.ts} +29 -0
- package/dist/{validate.d.ts → core/validate.d.ts} +1 -1
- package/dist/{validate.js → core/validate.js} +41 -4
- package/dist/e2e.d.ts +19 -0
- package/dist/e2e.js +39 -0
- package/dist/harness-assert.d.ts +60 -11
- package/dist/harness-assert.js +111 -9
- package/dist/integration.d.ts +16 -0
- package/dist/integration.js +32 -0
- package/dist/leaderboard.d.ts +33 -0
- package/dist/leaderboard.js +107 -0
- package/dist/linting.d.ts +9 -0
- package/dist/linting.js +25 -0
- package/dist/plugin-loader.d.ts +6 -3
- package/dist/plugin-loader.js +101 -49
- package/dist/scan.d.ts +49 -0
- package/dist/scan.js +177 -0
- package/dist/setup-plan.d.ts +51 -0
- package/dist/setup-plan.js +85 -0
- package/dist/skill-test.d.ts +1 -1
- package/dist/skill-test.js +1 -1
- package/dist/test-coverage.d.ts +71 -0
- package/dist/test-coverage.js +228 -0
- package/dist/testing.d.ts +12 -0
- package/dist/testing.js +28 -0
- package/dist/unit.d.ts +17 -0
- package/dist/unit.js +36 -0
- package/hooks/refs-nudge.sh +24 -0
- package/package.json +35 -16
- package/skills/audit-feedback-loop/SKILL.md +76 -0
- package/skills/edit-spec/SKILL.md +131 -0
- package/skills/enforce-rules-format/SKILL.md +71 -0
- package/skills/generate-logo/SKILL.md +103 -0
- package/skills/generate-rule/SKILL.md +64 -0
- package/skills/linter-docs/clippy.md +241 -0
- package/skills/linter-docs/eslint.md +384 -0
- package/skills/linter-docs/pylint.md +288 -0
- package/skills/linter-docs/rubocop.md +277 -0
- package/skills/linter-docs/ruff.md +187 -0
- package/skills/linter-docs/stylelint.md +247 -0
- package/skills/migrate-to-spec/SKILL.md +126 -0
- package/skills/pr-to-lint-rule/SKILL.md +97 -0
- package/skills/strengthen/SKILL.md +168 -0
- package/skills/test-harness/SKILL.md +157 -0
- package/dist/action.d.ts +0 -7
- package/dist/action.js +0 -180
- package/dist/refs.d.ts +0 -44
- package/dist/run-hook.d.ts +0 -77
- package/dist/run-hook.js +0 -80
- package/dist/run-scripts.d.ts +0 -20
- package/dist/run-scripts.js +0 -70
- /package/dist/{agent-result.js → adapters/claude-code/agent-result.js} +0 -0
- /package/dist/{agent-runtime.d.ts → adapters/claude-code/agent-runtime.d.ts} +0 -0
- /package/dist/{agent-runtime.js → adapters/claude-code/agent-runtime.js} +0 -0
- /package/dist/{judge.d.ts → adapters/claude-code/judge.d.ts} +0 -0
- /package/dist/{judge.js → adapters/claude-code/judge.js} +0 -0
- /package/dist/{mock-entry.d.ts → adapters/claude-code/mock-entry.d.ts} +0 -0
- /package/dist/{mock-entry.js → adapters/claude-code/mock-entry.js} +0 -0
- /package/dist/{mock-model.js → adapters/claude-code/mock-model.js} +0 -0
- /package/dist/{skill-driver.js → adapters/claude-code/skill-driver.js} +0 -0
- /package/dist/{skill-runtime.d.ts → adapters/claude-code/skill-runtime.d.ts} +0 -0
- /package/dist/{skill-runtime.js → adapters/claude-code/skill-runtime.js} +0 -0
- /package/dist/{stats.d.ts → adapters/claude-code/stats.d.ts} +0 -0
- /package/dist/{stats.js → adapters/claude-code/stats.js} +0 -0
- /package/dist/{compile-generator.d.ts → core/compile-generator.d.ts} +0 -0
- /package/dist/{compile-generator.js → core/compile-generator.js} +0 -0
- /package/dist/{coverage.d.ts → core/coverage.d.ts} +0 -0
- /package/dist/{coverage.js → core/coverage.js} +0 -0
- /package/dist/{doc-refs.d.ts → core/doc-refs.d.ts} +0 -0
- /package/dist/{doc-refs.js → core/doc-refs.js} +0 -0
- /package/dist/{evolve.d.ts → core/evolve.d.ts} +0 -0
- /package/dist/{evolve.js → core/evolve.js} +0 -0
- /package/dist/{frontmatter.d.ts → core/frontmatter.d.ts} +0 -0
- /package/dist/{frontmatter.js → core/frontmatter.js} +0 -0
- /package/dist/{generate-schema.d.ts → core/generate-schema.d.ts} +0 -0
- /package/dist/{generate-schema.js → core/generate-schema.js} +0 -0
- /package/dist/{generate-types.d.ts → core/generate-types.d.ts} +0 -0
- /package/dist/{generate-types.js → core/generate-types.js} +0 -0
- /package/dist/{hash.d.ts → core/hash.d.ts} +0 -0
- /package/dist/{hash.js → core/hash.js} +0 -0
- /package/dist/{inline.d.ts → core/inline.d.ts} +0 -0
- /package/dist/{inline.js → core/inline.js} +0 -0
- /package/dist/{integrity.d.ts → core/integrity.d.ts} +0 -0
- /package/dist/{integrity.js → core/integrity.js} +0 -0
- /package/dist/{linters.d.ts → core/linters.d.ts} +0 -0
- /package/dist/{linters.js → core/linters.js} +0 -0
- /package/dist/{mcp.d.ts → core/mcp.d.ts} +0 -0
- /package/dist/{mcp.js → core/mcp.js} +0 -0
- /package/dist/{orphans.d.ts → core/orphans.d.ts} +0 -0
- /package/dist/{orphans.js → core/orphans.js} +0 -0
- /package/dist/{proofs.d.ts → core/proofs.d.ts} +0 -0
- /package/dist/{proofs.js → core/proofs.js} +0 -0
- /package/dist/{session.d.ts → core/session.d.ts} +0 -0
- /package/dist/{session.js → core/session.js} +0 -0
- /package/dist/{sidecar.d.ts → core/sidecar.d.ts} +0 -0
- /package/dist/{sidecar.js → core/sidecar.js} +0 -0
- /package/dist/{spec.d.ts → core/spec.d.ts} +0 -0
- /package/dist/{spec.js → core/spec.js} +0 -0
- /package/dist/{symbols.d.ts → core/symbols.d.ts} +0 -0
- /package/dist/{symbols.js → core/symbols.js} +0 -0
- /package/dist/{test-utils.d.ts → core/test-utils.d.ts} +0 -0
- /package/dist/{test-utils.js → core/test-utils.js} +0 -0
- /package/dist/{types.js → core/types.js} +0 -0
- /package/{.claude-plugin/hooks → hooks}/post-edit.sh +0 -0
- /package/{.claude-plugin/hooks → hooks}/pre-edit.sh +0 -0
- /package/{.claude-plugin/hooks → hooks}/session-start.sh +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* vigiles — the allowlisted-egress orchestrator (in-process subprocess entry).
|
|
5
|
+
*
|
|
6
|
+
* `runHook` is synchronous (`spawnSync`), but allowlisted egress needs TWO
|
|
7
|
+
* processes alive at once: `bwrap` (which creates the netns and runs the hook)
|
|
8
|
+
* and `slirp4netns` (which, from the PARENT netns, attaches a tap to bwrap's netns
|
|
9
|
+
* to give it controlled egress). So the parent `spawnSync`s THIS entry, which runs
|
|
10
|
+
* the concurrent dance and writes a result file the parent reads back — keeping
|
|
11
|
+
* `runHook` synchronous while the handoff happens here.
|
|
12
|
+
*
|
|
13
|
+
* The dance (proven in `research/spikes/sandbox-network-allowlist.sh`):
|
|
14
|
+
* 1. spawn bwrap with `--info-fd` → learn the sandboxed child's PID;
|
|
15
|
+
* 2. `slirp4netns --configure --ready-fd N <pid> tap0` → tap up inside the netns;
|
|
16
|
+
* 3. touch the netready file → the in-sandbox wrapper (blocked on it) proceeds:
|
|
17
|
+
* it loads the nft allowlist, runs the hook, then dumps the nft counters to a
|
|
18
|
+
* bound file BEFORE exiting (the netns dies with the child, so the read-back
|
|
19
|
+
* must happen inside);
|
|
20
|
+
* 4. capture bwrap's stdout/stderr/exit + the counters → write the result file.
|
|
21
|
+
*
|
|
22
|
+
* Run as: `node dist/egress-entry.js <config.json>`. v8-ignored: it spawns real
|
|
23
|
+
* bwrap + slirp4netns and is exercised only by the bwrap/slirp-gated integration
|
|
24
|
+
* test; the testable logic (ruleset, counter parse) lives in `src/egress.ts`.
|
|
25
|
+
*/
|
|
26
|
+
/* v8 ignore start */
|
|
27
|
+
const node_child_process_1 = require("node:child_process");
|
|
28
|
+
const node_fs_1 = require("node:fs");
|
|
29
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30
|
+
/** Is a binary on PATH? (probe via `--version`; ENOENT sets `.error`.) */
|
|
31
|
+
function hasBinary(name) {
|
|
32
|
+
return !(0, node_child_process_1.spawnSync)(name, ["--version"], { stdio: "ignore" }).error;
|
|
33
|
+
}
|
|
34
|
+
/** Poll a file until it has content (the info / ready fd targets), or time out. */
|
|
35
|
+
async function waitForFile(path, timeoutMs) {
|
|
36
|
+
const deadline = Date.now() + timeoutMs;
|
|
37
|
+
while (Date.now() < deadline) {
|
|
38
|
+
try {
|
|
39
|
+
if ((0, node_fs_1.readFileSync)(path, "utf-8").trim().length > 0)
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* not written yet */
|
|
44
|
+
}
|
|
45
|
+
await sleep(50);
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
async function main() {
|
|
50
|
+
const cfg = JSON.parse((0, node_fs_1.readFileSync)(process.argv[2], "utf-8"));
|
|
51
|
+
// bwrap writes the sandboxed child's PID (init-namespace view) to fd 3.
|
|
52
|
+
const infoFd = (0, node_fs_1.openSync)(cfg.infoFile, "w");
|
|
53
|
+
const bwrap = (0, node_child_process_1.spawn)("bwrap", cfg.bwrapArgv, {
|
|
54
|
+
stdio: ["ignore", "pipe", "pipe", infoFd],
|
|
55
|
+
});
|
|
56
|
+
(0, node_fs_1.closeSync)(infoFd);
|
|
57
|
+
let stdout = "";
|
|
58
|
+
let stderr = "";
|
|
59
|
+
bwrap.stdout?.on("data", (d) => (stdout += d.toString()));
|
|
60
|
+
bwrap.stderr?.on("data", (d) => (stderr += d.toString()));
|
|
61
|
+
let connector;
|
|
62
|
+
const timer = setTimeout(() => bwrap.kill("SIGKILL"), cfg.timeoutMs);
|
|
63
|
+
const exit = new Promise((resolve) => {
|
|
64
|
+
bwrap.on("close", (status, signal) => {
|
|
65
|
+
resolve({ status, signal });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
// Learn the child PID, attach a rootless egress connector to its netns, release
|
|
69
|
+
// the wrapper. Prefer pasta (passt): it routes on hosted runners where
|
|
70
|
+
// slirp4netns's tap-attach silently fails (the netns ends up with only `lo`).
|
|
71
|
+
// Fall back to slirp4netns where pasta isn't installed. nft (the allowlist wall)
|
|
72
|
+
// matches on destination, not interface name, so it's connector-agnostic. See
|
|
73
|
+
// research/egress-sandbox-tooling.md.
|
|
74
|
+
if (await waitForFile(cfg.infoFile, 5_000)) {
|
|
75
|
+
const info = (0, node_fs_1.readFileSync)(cfg.infoFile, "utf-8");
|
|
76
|
+
const m = /"child-pid":\s*(\d+)/.exec(info);
|
|
77
|
+
if (m) {
|
|
78
|
+
if (cfg.connector === "pasta" && hasBinary("pasta")) {
|
|
79
|
+
// pasta configures the netns then forks to background; no ready-fd, so
|
|
80
|
+
// give it a moment to bring the interface + routes up. (Experimental:
|
|
81
|
+
// pasta's attach-to-existing-netns path is fragile in nested-namespace
|
|
82
|
+
// sandboxes — see research/egress-sandbox-tooling.md. The connector
|
|
83
|
+
// defaults to slirp4netns; both need /dev/net/tun + privilege, which is
|
|
84
|
+
// why the e2e CI job runs privileged.)
|
|
85
|
+
connector = (0, node_child_process_1.spawn)("pasta", [...cfg.pastaArgs, m[1]], {
|
|
86
|
+
stdio: "ignore",
|
|
87
|
+
});
|
|
88
|
+
await sleep(800);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const readyFd = (0, node_fs_1.openSync)(cfg.readyFile, "w");
|
|
92
|
+
connector = (0, node_child_process_1.spawn)("slirp4netns", [...cfg.slirpArgs, "--ready-fd", "4", m[1], "tap0"], { stdio: ["ignore", "ignore", "ignore", "ignore", readyFd] });
|
|
93
|
+
(0, node_fs_1.closeSync)(readyFd);
|
|
94
|
+
// slirp writes "1" to the ready fd once tap0 is configured; fall back to a
|
|
95
|
+
// short sleep if it never signals (older builds).
|
|
96
|
+
if (!(await waitForFile(cfg.readyFile, 4_000)))
|
|
97
|
+
await sleep(800);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
(0, node_fs_1.writeFileSync)(cfg.netreadyFile, "1");
|
|
102
|
+
const { status, signal } = await exit;
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
connector?.kill("SIGKILL");
|
|
105
|
+
const counters = (0, node_fs_1.existsSync)(cfg.countersFile)
|
|
106
|
+
? (0, node_fs_1.readFileSync)(cfg.countersFile, "utf-8")
|
|
107
|
+
: "";
|
|
108
|
+
(0, node_fs_1.writeFileSync)(cfg.resultFile, JSON.stringify({ status, signal, stdout, stderr, counters }));
|
|
109
|
+
}
|
|
110
|
+
main().catch((e) => {
|
|
111
|
+
(0, node_fs_1.writeFileSync)(process.argv[2] + ".error", e instanceof Error ? (e.stack ?? e.message) : String(e));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
});
|
|
114
|
+
/* v8 ignore stop */
|
|
115
|
+
//# sourceMappingURL=egress-entry.js.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* Recording egress proxy — runs INSIDE the sandbox netns (on loopback), so a
|
|
5
|
+
* confined hook configured with `HTTP(S)_PROXY` routes its network attempts here.
|
|
6
|
+
* It RECORDS each target (`host:port`) to an ndjson log and BLOCKS it (responds
|
|
7
|
+
* 502 / closes) — the netns already has no external route, so nothing actually
|
|
8
|
+
* leaves; this just turns "silently blocked" into "blocked AND recorded", so a
|
|
9
|
+
* test can assert what a hook/skill tried to reach (phone-home / which registry
|
|
10
|
+
* an install would hit).
|
|
11
|
+
*
|
|
12
|
+
* Honest limit: this records what PROXY-honoring tools (npm, pip, curl, fetch)
|
|
13
|
+
* attempt. Raw-socket egress bypasses the proxy — but the netns still blocks it
|
|
14
|
+
* hard, so it can't get out; it just won't appear in the record. The block is the
|
|
15
|
+
* boundary; the record is best-effort observability over it.
|
|
16
|
+
*
|
|
17
|
+
* Run as: `node dist/egress-proxy.js <egress-log-path> <port-file-path>`.
|
|
18
|
+
*/
|
|
19
|
+
/* v8 ignore start -- a standalone subprocess run only inside the sandbox netns;
|
|
20
|
+
exercised by the bwrap-gated end-to-end test, not the unit gate. The pure
|
|
21
|
+
parser (parseEgressLog) carries the testable logic. */
|
|
22
|
+
const node_http_1 = require("node:http");
|
|
23
|
+
const node_fs_1 = require("node:fs");
|
|
24
|
+
const [, , logPath, portPath] = process.argv;
|
|
25
|
+
function record(host, port) {
|
|
26
|
+
try {
|
|
27
|
+
(0, node_fs_1.appendFileSync)(logPath, JSON.stringify({ host, port, ts: Date.now() }) + "\n");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* best-effort: a recording failure must not crash the hook under test */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
34
|
+
// Plain HTTP via a proxy: req.url is absolute, e.g. http://host:port/path.
|
|
35
|
+
try {
|
|
36
|
+
const u = new URL(req.url ?? "");
|
|
37
|
+
record(u.hostname, Number(u.port) || 80);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* unparseable target — skip */
|
|
41
|
+
}
|
|
42
|
+
res.writeHead(502, { "content-type": "text/plain" });
|
|
43
|
+
res.end("blocked by vigiles egress recorder\n");
|
|
44
|
+
});
|
|
45
|
+
// HTTPS via a proxy: the client sends `CONNECT host:port`. Record + refuse.
|
|
46
|
+
server.on("connect", (req, socket) => {
|
|
47
|
+
const [host, port] = (req.url ?? "").split(":");
|
|
48
|
+
record(host, Number(port) || 443);
|
|
49
|
+
socket.write("HTTP/1.1 502 Blocked\r\n\r\n");
|
|
50
|
+
socket.end();
|
|
51
|
+
});
|
|
52
|
+
server.on("clientError", (_e, socket) => socket.destroy());
|
|
53
|
+
server.listen(0, "127.0.0.1", () => {
|
|
54
|
+
const addr = server.address();
|
|
55
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
56
|
+
// Hand the chosen port back to the wrapper, which exports HTTP(S)_PROXY.
|
|
57
|
+
(0, node_fs_1.writeFileSync)(portPath, String(port));
|
|
58
|
+
});
|
|
59
|
+
/* v8 ignore stop */
|
|
60
|
+
//# sourceMappingURL=egress-proxy.js.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { type EgressAttempt } from "./sandbox.js";
|
|
2
|
+
/** A host plus the IPs it resolved to, split by family (nft needs them apart). */
|
|
3
|
+
export interface ResolvedHost {
|
|
4
|
+
readonly host: string;
|
|
5
|
+
readonly v4: readonly string[];
|
|
6
|
+
readonly v6: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
/** Per-host and aggregate nftables counters read back after a confined run. */
|
|
9
|
+
export interface EgressCounters {
|
|
10
|
+
/** Allowlisted hosts that saw traffic, with the nft counter for each. */
|
|
11
|
+
readonly allowed: readonly {
|
|
12
|
+
readonly host: string;
|
|
13
|
+
readonly packets: number;
|
|
14
|
+
readonly bytes: number;
|
|
15
|
+
}[];
|
|
16
|
+
/** The catch-all drop rule: traffic to anything OFF the allowlist. */
|
|
17
|
+
readonly dropped: {
|
|
18
|
+
readonly packets: number;
|
|
19
|
+
readonly bytes: number;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether this host can run the allowlisted-egress sandbox: it needs the same
|
|
24
|
+
* bubblewrap confinement the other tiers use PLUS `slirp4netns` (the rootless
|
|
25
|
+
* gateway) and `nft` (the packet-layer allowlist). Cached — the answer can't
|
|
26
|
+
* change within a run. `available` is injected so the bwrap probe isn't repeated.
|
|
27
|
+
*/
|
|
28
|
+
export declare function probeEgressAvailable(available: boolean): boolean;
|
|
29
|
+
/** Cached {@link probeEgressAvailable}. `available` = the bwrap-sandbox probe. */
|
|
30
|
+
export declare function egressAvailable(available: boolean): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Parse `getent ahosts <host>` output into the unique IPs, split by family. The
|
|
33
|
+
* first whitespace token of each line is an address; `:` marks IPv6. Pure, so the
|
|
34
|
+
* resolution-parsing is unit-tested without touching DNS.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseGetent(stdout: string): {
|
|
37
|
+
v4: string[];
|
|
38
|
+
v6: string[];
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Resolve every allowlisted host to IPs. The lookup is injectable so the
|
|
42
|
+
* empty-result / family-split behaviour is unit-tested with a fake resolver.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveAllow(hosts: readonly string[], lookup?: (host: string) => {
|
|
45
|
+
v4: string[];
|
|
46
|
+
v6: string[];
|
|
47
|
+
}): ResolvedHost[];
|
|
48
|
+
/**
|
|
49
|
+
* Nameservers usable from INSIDE the egress netns. Parses a resolv.conf body but
|
|
50
|
+
* drops loopback stubs (see {@link isLoopbackResolver}) — they can't be reached
|
|
51
|
+
* across the namespace — and falls back to a public resolver (8.8.8.8, which
|
|
52
|
+
* slirp4netns NATs out) when nothing routable remains.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseResolvers(resolvConf: string): string[];
|
|
55
|
+
/**
|
|
56
|
+
* Build the nftables ruleset that enforces the allowlist INSIDE the netns: a
|
|
57
|
+
* `policy drop` output chain that accepts loopback, established replies, DNS to
|
|
58
|
+
* the resolvers, and each allowlisted host's resolved IPs (a per-host `counter` +
|
|
59
|
+
* `comment "allow:<host>"` so the read-back maps traffic to a name), then a
|
|
60
|
+
* catch-all `log`+`counter`+`drop`. v4 and v6 are separate rules (an `inet` set
|
|
61
|
+
* can't mix families) sharing the host's comment. Pure → the ruleset is asserted
|
|
62
|
+
* in a unit test.
|
|
63
|
+
*/
|
|
64
|
+
export declare function buildEgressNft(opts: {
|
|
65
|
+
allow: readonly ResolvedHost[];
|
|
66
|
+
resolvers: readonly string[];
|
|
67
|
+
}): string;
|
|
68
|
+
/** The bound paths the in-netns wrapper and the orchestrator hand back through. */
|
|
69
|
+
export interface EgressFiles {
|
|
70
|
+
readonly ioDir: string;
|
|
71
|
+
readonly netready: string;
|
|
72
|
+
readonly nft: string;
|
|
73
|
+
readonly event: string;
|
|
74
|
+
readonly counters: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Assemble the full bwrap argv for an allowlisted-egress run: the shared
|
|
78
|
+
* confinement args, the hook's added-back env, `CAP_NET_ADMIN` (so the in-netns
|
|
79
|
+
* wrapper can load nft), `--info-fd 3` (so the orchestrator learns the child PID
|
|
80
|
+
* to hand slirp4netns), the `VIG_*` paths the wrapper reads, and the trailing
|
|
81
|
+
* `sh -c <wrapper>`. Pure (the bwrap/setenv args are computed by the caller), so
|
|
82
|
+
* the assembled shape — caps, info-fd, the VIG_* env, the wrapper payload — is
|
|
83
|
+
* asserted in a unit test.
|
|
84
|
+
*/
|
|
85
|
+
export declare function buildEgressBwrapArgv(opts: {
|
|
86
|
+
base: readonly string[];
|
|
87
|
+
setenv: readonly string[];
|
|
88
|
+
files: EgressFiles;
|
|
89
|
+
command: string;
|
|
90
|
+
wrapper: string;
|
|
91
|
+
/** Host path to the generated resolv.conf bound over /etc/resolv.conf in-netns,
|
|
92
|
+
* so the hook resolves via the routable resolvers, not the host's loopback stub. */
|
|
93
|
+
resolvConf: string;
|
|
94
|
+
}): string[];
|
|
95
|
+
/**
|
|
96
|
+
* Parse `nft list chain inet vig output` back into per-host allowed counters and
|
|
97
|
+
* the aggregate drop counter. Sums multiple rules that share a host comment (the
|
|
98
|
+
* v4 + v6 split). Pure, so the read-back is unit-tested without a sandbox.
|
|
99
|
+
*/
|
|
100
|
+
export declare function parseNftCounters(nftText: string): EgressCounters;
|
|
101
|
+
/**
|
|
102
|
+
* Map parsed counters to the run result: one {@link EgressAttempt} per
|
|
103
|
+
* allowlisted host that saw traffic (`allowed: true`, with its packet/byte
|
|
104
|
+
* counts), plus the aggregate dropped counter. `ts` is stamped once per run (the
|
|
105
|
+
* counters are end-of-run totals, not per-connection events). Pure.
|
|
106
|
+
*/
|
|
107
|
+
export declare function countersToResult(counters: EgressCounters, now: number): {
|
|
108
|
+
egress: EgressAttempt[];
|
|
109
|
+
egressDropped: {
|
|
110
|
+
packets: number;
|
|
111
|
+
bytes: number;
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
//# sourceMappingURL=egress.d.ts.map
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.probeEgressAvailable = probeEgressAvailable;
|
|
4
|
+
exports.egressAvailable = egressAvailable;
|
|
5
|
+
exports.parseGetent = parseGetent;
|
|
6
|
+
exports.resolveAllow = resolveAllow;
|
|
7
|
+
exports.parseResolvers = parseResolvers;
|
|
8
|
+
exports.buildEgressNft = buildEgressNft;
|
|
9
|
+
exports.buildEgressBwrapArgv = buildEgressBwrapArgv;
|
|
10
|
+
exports.parseNftCounters = parseNftCounters;
|
|
11
|
+
exports.countersToResult = countersToResult;
|
|
12
|
+
/**
|
|
13
|
+
* vigiles — allowlisted, recorded egress for a confined hook (`egress: { allow }`).
|
|
14
|
+
*
|
|
15
|
+
* The deny-all wall (`--unshare-all`) and the `recordEgress` proxy both BLOCK; a
|
|
16
|
+
* hook whose setup needs a real `npm install` can't be tested under either. This
|
|
17
|
+
* is the in-between: let traffic actually reach the network, but **only to an
|
|
18
|
+
* allowlist**, and **record** it — with the boundary at the packet layer so it
|
|
19
|
+
* can't be bypassed by a raw socket (an `HTTP_PROXY` allowlist can).
|
|
20
|
+
*
|
|
21
|
+
* How it works (proven in `research/spikes/sandbox-network-allowlist.sh`):
|
|
22
|
+
* `slirp4netns --configure` attaches a `tap` to the bwrap netns (rootless egress
|
|
23
|
+
* via a userspace TCP/IP stack), then an `nft` ruleset INSIDE the netns — a
|
|
24
|
+
* `policy drop` output chain that `accept`s only the resolved allowlist IPs (plus
|
|
25
|
+
* loopback + the DNS resolver) and `log`+`drop`s the rest — is the hard wall. The
|
|
26
|
+
* per-rule `counter`s read back which allowlisted hosts were actually reached and
|
|
27
|
+
* how much was dropped.
|
|
28
|
+
*
|
|
29
|
+
* This module holds the PURE seams — resolution parsing, ruleset generation,
|
|
30
|
+
* counter parsing, result mapping — so the enforcement logic is unit-tested
|
|
31
|
+
* without a sandbox. The orchestration (`src/egress-entry.ts`) needs real bwrap +
|
|
32
|
+
* slirp4netns + nft and is covered by the gated integration test.
|
|
33
|
+
*
|
|
34
|
+
* Honest limits (see `docs/sandboxing.md`): the allowlist is resolved to IPs at
|
|
35
|
+
* launch (a host whose DNS rotates outside the run's window could miss an IP —
|
|
36
|
+
* the dynamic resolver-pinned set is the documented next layer). The record names
|
|
37
|
+
* the allowlisted hosts that were reached and counts what was dropped, but does
|
|
38
|
+
* not yet name the DROPPED hosts (that needs the in-netns DNS-query log).
|
|
39
|
+
*/
|
|
40
|
+
const node_child_process_1 = require("node:child_process");
|
|
41
|
+
let cachedEgressAvailable;
|
|
42
|
+
/** Does a binary resolve on PATH? (probe via `command -v`, no output kept.) */
|
|
43
|
+
function hasBinary(name) {
|
|
44
|
+
try {
|
|
45
|
+
return ((0, node_child_process_1.spawnSync)("sh", ["-c", `command -v ${name}`], {
|
|
46
|
+
stdio: "ignore",
|
|
47
|
+
timeout: 5_000,
|
|
48
|
+
}).status === 0);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* v8 ignore next -- spawnSync only throws on a fork failure */
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Whether this host can run the allowlisted-egress sandbox: it needs the same
|
|
57
|
+
* bubblewrap confinement the other tiers use PLUS `slirp4netns` (the rootless
|
|
58
|
+
* gateway) and `nft` (the packet-layer allowlist). Cached — the answer can't
|
|
59
|
+
* change within a run. `available` is injected so the bwrap probe isn't repeated.
|
|
60
|
+
*/
|
|
61
|
+
function probeEgressAvailable(available) {
|
|
62
|
+
/* v8 ignore next -- non-Linux has no bwrap/slirp; CI/coverage runs on Linux */
|
|
63
|
+
if (process.platform !== "linux")
|
|
64
|
+
return false;
|
|
65
|
+
return available && hasBinary("slirp4netns") && hasBinary("nft");
|
|
66
|
+
}
|
|
67
|
+
/** Cached {@link probeEgressAvailable}. `available` = the bwrap-sandbox probe. */
|
|
68
|
+
function egressAvailable(available) {
|
|
69
|
+
if (cachedEgressAvailable === undefined) {
|
|
70
|
+
cachedEgressAvailable = probeEgressAvailable(available);
|
|
71
|
+
}
|
|
72
|
+
return cachedEgressAvailable;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Parse `getent ahosts <host>` output into the unique IPs, split by family. The
|
|
76
|
+
* first whitespace token of each line is an address; `:` marks IPv6. Pure, so the
|
|
77
|
+
* resolution-parsing is unit-tested without touching DNS.
|
|
78
|
+
*/
|
|
79
|
+
function parseGetent(stdout) {
|
|
80
|
+
const v4 = new Set();
|
|
81
|
+
const v6 = new Set();
|
|
82
|
+
for (const line of stdout.split("\n")) {
|
|
83
|
+
const ip = line.trim().split(/\s+/)[0];
|
|
84
|
+
if (!ip)
|
|
85
|
+
continue;
|
|
86
|
+
if (ip.includes(":"))
|
|
87
|
+
v6.add(ip);
|
|
88
|
+
else if (/^\d+\.\d+\.\d+\.\d+$/.test(ip))
|
|
89
|
+
v4.add(ip);
|
|
90
|
+
}
|
|
91
|
+
return { v4: [...v4], v6: [...v6] };
|
|
92
|
+
}
|
|
93
|
+
/** Resolve a host to IPs via the system resolver (`getent`, synchronous). */
|
|
94
|
+
function getentLookup(host) {
|
|
95
|
+
/* v8 ignore start -- shells out to the real resolver; the parse is parseGetent */
|
|
96
|
+
const res = (0, node_child_process_1.spawnSync)("getent", ["ahosts", host], {
|
|
97
|
+
encoding: "utf-8",
|
|
98
|
+
timeout: 10_000,
|
|
99
|
+
});
|
|
100
|
+
return parseGetent(res.stdout ?? "");
|
|
101
|
+
/* v8 ignore stop */
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolve every allowlisted host to IPs. The lookup is injectable so the
|
|
105
|
+
* empty-result / family-split behaviour is unit-tested with a fake resolver.
|
|
106
|
+
*/
|
|
107
|
+
function resolveAllow(hosts, lookup = getentLookup) {
|
|
108
|
+
return hosts.map((host) => {
|
|
109
|
+
const { v4, v6 } = lookup(host);
|
|
110
|
+
return { host, v4, v6 };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** A loopback nameserver (127.0.0.0/8 or ::1) — e.g. systemd-resolved's
|
|
114
|
+
* 127.0.0.53 stub. Unreachable from inside the slirp4netns netns (which runs
|
|
115
|
+
* with `--disable-host-loopback`), so it must never be used as the in-netns
|
|
116
|
+
* resolver. GitHub-hosted runners ship exactly this stub, which is why the
|
|
117
|
+
* egress tests saw zero packets there. */
|
|
118
|
+
function isLoopbackResolver(ip) {
|
|
119
|
+
return ip === "::1" || /^127\./.test(ip);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Nameservers usable from INSIDE the egress netns. Parses a resolv.conf body but
|
|
123
|
+
* drops loopback stubs (see {@link isLoopbackResolver}) — they can't be reached
|
|
124
|
+
* across the namespace — and falls back to a public resolver (8.8.8.8, which
|
|
125
|
+
* slirp4netns NATs out) when nothing routable remains.
|
|
126
|
+
*/
|
|
127
|
+
function parseResolvers(resolvConf) {
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const line of resolvConf.split("\n")) {
|
|
130
|
+
const m = /^\s*nameserver\s+(\S+)/.exec(line);
|
|
131
|
+
if (m && !isLoopbackResolver(m[1]))
|
|
132
|
+
out.push(m[1]);
|
|
133
|
+
}
|
|
134
|
+
return out.length > 0 ? out : ["8.8.8.8"];
|
|
135
|
+
}
|
|
136
|
+
/** Escape an nft set comment label so a hostname can't break the ruleset. */
|
|
137
|
+
function nftLabel(host) {
|
|
138
|
+
return host.replace(/[^A-Za-z0-9._:-]/g, "_");
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Build the nftables ruleset that enforces the allowlist INSIDE the netns: a
|
|
142
|
+
* `policy drop` output chain that accepts loopback, established replies, DNS to
|
|
143
|
+
* the resolvers, and each allowlisted host's resolved IPs (a per-host `counter` +
|
|
144
|
+
* `comment "allow:<host>"` so the read-back maps traffic to a name), then a
|
|
145
|
+
* catch-all `log`+`counter`+`drop`. v4 and v6 are separate rules (an `inet` set
|
|
146
|
+
* can't mix families) sharing the host's comment. Pure → the ruleset is asserted
|
|
147
|
+
* in a unit test.
|
|
148
|
+
*/
|
|
149
|
+
function buildEgressNft(opts) {
|
|
150
|
+
const lines = [
|
|
151
|
+
"table inet vig {",
|
|
152
|
+
" chain output {",
|
|
153
|
+
" type filter hook output priority 0; policy drop;",
|
|
154
|
+
' oifname "lo" accept',
|
|
155
|
+
" ct state established,related accept",
|
|
156
|
+
];
|
|
157
|
+
for (const r of opts.resolvers) {
|
|
158
|
+
const fam = r.includes(":") ? "ip6" : "ip";
|
|
159
|
+
lines.push(` ${fam} daddr ${r} udp dport 53 counter accept comment "dns"`);
|
|
160
|
+
lines.push(` ${fam} daddr ${r} tcp dport 53 counter accept comment "dns"`);
|
|
161
|
+
}
|
|
162
|
+
for (const h of opts.allow) {
|
|
163
|
+
const label = nftLabel(h.host);
|
|
164
|
+
if (h.v4.length > 0) {
|
|
165
|
+
lines.push(` ip daddr { ${h.v4.join(", ")} } counter accept comment "allow:${label}"`);
|
|
166
|
+
}
|
|
167
|
+
if (h.v6.length > 0) {
|
|
168
|
+
lines.push(` ip6 daddr { ${h.v6.join(", ")} } counter accept comment "allow:${label}"`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
lines.push(' log prefix "vig-drop " counter drop');
|
|
172
|
+
lines.push(" }", "}");
|
|
173
|
+
return lines.join("\n") + "\n";
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Assemble the full bwrap argv for an allowlisted-egress run: the shared
|
|
177
|
+
* confinement args, the hook's added-back env, `CAP_NET_ADMIN` (so the in-netns
|
|
178
|
+
* wrapper can load nft), `--info-fd 3` (so the orchestrator learns the child PID
|
|
179
|
+
* to hand slirp4netns), the `VIG_*` paths the wrapper reads, and the trailing
|
|
180
|
+
* `sh -c <wrapper>`. Pure (the bwrap/setenv args are computed by the caller), so
|
|
181
|
+
* the assembled shape — caps, info-fd, the VIG_* env, the wrapper payload — is
|
|
182
|
+
* asserted in a unit test.
|
|
183
|
+
*/
|
|
184
|
+
function buildEgressBwrapArgv(opts) {
|
|
185
|
+
return [
|
|
186
|
+
...opts.base,
|
|
187
|
+
// Override the host's /etc/resolv.conf (often a 127.0.0.53 stub) with one
|
|
188
|
+
// listing only netns-routable resolvers — placed after `base` so it shadows
|
|
189
|
+
// the `--ro-bind / /` copy.
|
|
190
|
+
"--ro-bind",
|
|
191
|
+
opts.resolvConf,
|
|
192
|
+
"/etc/resolv.conf",
|
|
193
|
+
"--cap-add",
|
|
194
|
+
"CAP_NET_ADMIN",
|
|
195
|
+
...opts.setenv,
|
|
196
|
+
"--setenv",
|
|
197
|
+
"VIG_NETREADY",
|
|
198
|
+
opts.files.netready,
|
|
199
|
+
"--setenv",
|
|
200
|
+
"VIG_NFT",
|
|
201
|
+
opts.files.nft,
|
|
202
|
+
"--setenv",
|
|
203
|
+
"VIG_EVENT",
|
|
204
|
+
opts.files.event,
|
|
205
|
+
"--setenv",
|
|
206
|
+
"VIG_COUNTERS",
|
|
207
|
+
opts.files.counters,
|
|
208
|
+
"--setenv",
|
|
209
|
+
"VIG_IODIR",
|
|
210
|
+
opts.files.ioDir,
|
|
211
|
+
"--setenv",
|
|
212
|
+
"VIG_HOOK",
|
|
213
|
+
opts.command,
|
|
214
|
+
"--info-fd",
|
|
215
|
+
"3",
|
|
216
|
+
"sh",
|
|
217
|
+
"-c",
|
|
218
|
+
opts.wrapper,
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
const COUNTER = /counter packets (\d+) bytes (\d+)/;
|
|
222
|
+
const ALLOW_COMMENT = /comment "allow:([^"]+)"/;
|
|
223
|
+
/**
|
|
224
|
+
* Parse `nft list chain inet vig output` back into per-host allowed counters and
|
|
225
|
+
* the aggregate drop counter. Sums multiple rules that share a host comment (the
|
|
226
|
+
* v4 + v6 split). Pure, so the read-back is unit-tested without a sandbox.
|
|
227
|
+
*/
|
|
228
|
+
function parseNftCounters(nftText) {
|
|
229
|
+
const allowed = new Map();
|
|
230
|
+
let dropped = { packets: 0, bytes: 0 };
|
|
231
|
+
for (const line of nftText.split("\n")) {
|
|
232
|
+
const c = COUNTER.exec(line);
|
|
233
|
+
if (!c)
|
|
234
|
+
continue;
|
|
235
|
+
const packets = Number(c[1]);
|
|
236
|
+
const bytes = Number(c[2]);
|
|
237
|
+
const a = ALLOW_COMMENT.exec(line);
|
|
238
|
+
if (a) {
|
|
239
|
+
const prev = allowed.get(a[1]) ?? { packets: 0, bytes: 0 };
|
|
240
|
+
allowed.set(a[1], {
|
|
241
|
+
packets: prev.packets + packets,
|
|
242
|
+
bytes: prev.bytes + bytes,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
else if (line.includes("vig-drop") || /\bdrop\b/.test(line)) {
|
|
246
|
+
dropped = {
|
|
247
|
+
packets: dropped.packets + packets,
|
|
248
|
+
bytes: dropped.bytes + bytes,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
allowed: [...allowed.entries()].map(([host, v]) => ({ host, ...v })),
|
|
254
|
+
dropped,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Map parsed counters to the run result: one {@link EgressAttempt} per
|
|
259
|
+
* allowlisted host that saw traffic (`allowed: true`, with its packet/byte
|
|
260
|
+
* counts), plus the aggregate dropped counter. `ts` is stamped once per run (the
|
|
261
|
+
* counters are end-of-run totals, not per-connection events). Pure.
|
|
262
|
+
*/
|
|
263
|
+
function countersToResult(counters, now) {
|
|
264
|
+
const egress = counters.allowed
|
|
265
|
+
.filter((a) => a.packets > 0)
|
|
266
|
+
.map((a) => ({
|
|
267
|
+
host: a.host,
|
|
268
|
+
port: 0,
|
|
269
|
+
ts: now,
|
|
270
|
+
allowed: true,
|
|
271
|
+
packets: a.packets,
|
|
272
|
+
bytes: a.bytes,
|
|
273
|
+
}));
|
|
274
|
+
return { egress, egressDropped: counters.dropped };
|
|
275
|
+
}
|
|
276
|
+
//# sourceMappingURL=egress.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { EvalReport } from "./eval.js";
|
|
2
|
+
import { type Comparison } from "./stats.js";
|
|
3
|
+
/** Bumped only on a breaking change to the on-disk shape. */
|
|
4
|
+
export declare const BASELINE_VERSION = 1;
|
|
5
|
+
/** The committed baseline: the recorded `EvalReport`s, keyed by report name. */
|
|
6
|
+
export interface BaselineFile {
|
|
7
|
+
readonly version: number;
|
|
8
|
+
/** ISO-8601 timestamp the baseline was recorded (provenance / future trend). */
|
|
9
|
+
readonly recordedAt: string;
|
|
10
|
+
/** Recorded reports, keyed by `report.name` (so multiple eval files coexist). */
|
|
11
|
+
readonly reports: Record<string, EvalReport>;
|
|
12
|
+
}
|
|
13
|
+
/** How a metric moved between baseline and current run. */
|
|
14
|
+
export type DiffStatus = "regressed" | "improved" | "unchanged";
|
|
15
|
+
/** One arm×metric comparison of a current run against the baseline. */
|
|
16
|
+
export interface MetricDiff {
|
|
17
|
+
/** The `report.name` this entry belongs to. */
|
|
18
|
+
readonly report: string;
|
|
19
|
+
readonly arm: string;
|
|
20
|
+
readonly metric: string;
|
|
21
|
+
readonly status: DiffStatus;
|
|
22
|
+
/** Welch comparison, current vs. baseline (`delta = current − baseline`). */
|
|
23
|
+
readonly comparison: Comparison;
|
|
24
|
+
}
|
|
25
|
+
export interface BaselineDiff {
|
|
26
|
+
/** Every arm×metric present in BOTH the baseline and the current run. */
|
|
27
|
+
readonly entries: readonly MetricDiff[];
|
|
28
|
+
/** The subset that regressed (significant move in the bad direction). */
|
|
29
|
+
readonly regressions: readonly MetricDiff[];
|
|
30
|
+
/** The subset that improved (significant move in the good direction). */
|
|
31
|
+
readonly improvements: readonly MetricDiff[];
|
|
32
|
+
/** True when there are no regressions — the gate. */
|
|
33
|
+
readonly passed: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface DiffOptions {
|
|
36
|
+
/** Significance level for the Welch test. Default 0.05. */
|
|
37
|
+
readonly alpha?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Metrics where a DECREASE is the improvement (e.g. `cost`, `latency`,
|
|
40
|
+
* `turns`). For these, a significant increase is the regression. Everything
|
|
41
|
+
* else is treated as higher-is-better.
|
|
42
|
+
*/
|
|
43
|
+
readonly lowerIsBetter?: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
/** Build a `BaselineFile` envelope from a run's reports (keyed by name). */
|
|
46
|
+
export declare function toBaselineFile(reports: readonly EvalReport[], recordedAt?: string): BaselineFile;
|
|
47
|
+
/** Parse + validate a baseline JSON string (throws on a bad version/shape). */
|
|
48
|
+
export declare function parseBaselineFile(json: string): BaselineFile;
|
|
49
|
+
/**
|
|
50
|
+
* Diff a current run against a committed baseline. Compares every arm×metric
|
|
51
|
+
* present in both (by report name), flagging a *significant* move in the
|
|
52
|
+
* undesired direction as a regression. Metrics absent from one side are skipped
|
|
53
|
+
* (a new arm/metric is not a regression).
|
|
54
|
+
*/
|
|
55
|
+
export declare function diffReports(baseline: BaselineFile, current: readonly EvalReport[], opts?: DiffOptions): BaselineDiff;
|
|
56
|
+
/** Format a baseline diff as a compact console report. */
|
|
57
|
+
export declare function formatBaselineDiff(diff: BaselineDiff): string;
|
|
58
|
+
/**
|
|
59
|
+
* Render a baseline diff as JUnit XML — one `<testcase>` per arm×metric, a
|
|
60
|
+
* `<failure>` for each regression. Lets a CI provider show eval regressions in
|
|
61
|
+
* the same place as unit-test failures.
|
|
62
|
+
*/
|
|
63
|
+
export declare function diffToJUnit(diff: BaselineDiff): string;
|
|
64
|
+
/** Read + parse a baseline file, or null if it doesn't exist yet. */
|
|
65
|
+
export declare function readBaseline(path: string): BaselineFile | null;
|
|
66
|
+
/** Write reports as the committed baseline (pretty JSON, parent dirs created). */
|
|
67
|
+
export declare function writeBaseline(path: string, reports: readonly EvalReport[]): void;
|
|
68
|
+
//# sourceMappingURL=eval-baseline.d.ts.map
|