@danielblomma/cortex-mcp 1.7.1 → 2.0.2
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/bin/cortex.mjs +679 -32
- package/bin/style.mjs +349 -0
- package/package.json +4 -3
- package/scaffold/mcp/package-lock.json +834 -671
- package/scaffold/mcp/package.json +1 -1
- package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
- package/scaffold/mcp/src/cli/govern.ts +987 -0
- package/scaffold/mcp/src/cli/run.ts +306 -0
- package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
- package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
- package/scaffold/mcp/src/core/audit/query.ts +81 -0
- package/scaffold/mcp/src/core/audit/writer.ts +68 -0
- package/scaffold/mcp/src/core/config.ts +329 -0
- package/scaffold/mcp/src/core/index.ts +34 -0
- package/scaffold/mcp/src/core/license.ts +202 -0
- package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
- package/scaffold/mcp/src/core/policy/injection.ts +229 -0
- package/scaffold/mcp/src/core/policy/store.ts +197 -0
- package/scaffold/mcp/src/core/rbac/check.ts +40 -0
- package/scaffold/mcp/src/core/telemetry/collector.ts +234 -0
- package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
- package/scaffold/mcp/src/core/validators/config.ts +47 -0
- package/scaffold/mcp/src/core/validators/engine.ts +199 -0
- package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
- package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
- package/scaffold/mcp/src/daemon/client.ts +155 -0
- package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
- package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
- package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
- package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
- package/scaffold/mcp/src/daemon/main.ts +300 -0
- package/scaffold/mcp/src/daemon/paths.ts +41 -0
- package/scaffold/mcp/src/daemon/protocol.ts +101 -0
- package/scaffold/mcp/src/daemon/server.ts +227 -0
- package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
- package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
- package/scaffold/mcp/src/embed.ts +1 -1
- package/scaffold/mcp/src/embeddings.ts +1 -1
- package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
- package/scaffold/mcp/src/enterprise/index.ts +415 -0
- package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
- package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
- package/scaffold/mcp/src/enterprise/privacy/boundary.ts +212 -0
- package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
- package/scaffold/mcp/src/enterprise/telemetry/sync.ts +72 -0
- package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
- package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
- package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
- package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
- package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
- package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
- package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
- package/scaffold/mcp/src/hooks/session-end.ts +73 -0
- package/scaffold/mcp/src/hooks/session-start.ts +78 -0
- package/scaffold/mcp/src/hooks/shared.ts +134 -0
- package/scaffold/mcp/src/hooks/stop.ts +60 -0
- package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
- package/scaffold/mcp/src/plugin.ts +150 -0
- package/scaffold/mcp/src/server.ts +218 -7
- package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
- package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
- package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
- package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
- package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
- package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
- package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
- package/scaffold/mcp/tests/govern.test.mjs +74 -0
- package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
- package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
- package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
- package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
- package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
- package/scaffold/mcp/tests/run.test.mjs +109 -0
- package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
- package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
- package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
- package/scaffold/scripts/bootstrap.sh +0 -11
- package/scaffold/scripts/doctor.sh +24 -4
- package/types.js +5 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
isCortexShim,
|
|
9
|
+
findRealBinary,
|
|
10
|
+
buildDarwinSandboxProfile,
|
|
11
|
+
buildLinuxBwrapArgs,
|
|
12
|
+
SHIM_MARKER,
|
|
13
|
+
RUN_CLIS,
|
|
14
|
+
runAiCli,
|
|
15
|
+
} from "../dist/cli/run.js";
|
|
16
|
+
|
|
17
|
+
function makeBinDir() {
|
|
18
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "cortex-run-"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("RUN_CLIS lists exactly claude, codex, copilot", () => {
|
|
22
|
+
assert.deepEqual(RUN_CLIS.sort(), ["claude", "codex", "copilot"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("isCortexShim: detects shim marker", () => {
|
|
26
|
+
const dir = makeBinDir();
|
|
27
|
+
try {
|
|
28
|
+
const shim = path.join(dir, "fake-shim");
|
|
29
|
+
fs.writeFileSync(shim, `#!/bin/sh\n${SHIM_MARKER}\nexec real "$@"\n`, { mode: 0o755 });
|
|
30
|
+
assert.equal(isCortexShim(shim), true);
|
|
31
|
+
|
|
32
|
+
const notShim = path.join(dir, "real-bin");
|
|
33
|
+
fs.writeFileSync(notShim, "#!/bin/sh\necho hi\n");
|
|
34
|
+
assert.equal(isCortexShim(notShim), false);
|
|
35
|
+
|
|
36
|
+
assert.equal(isCortexShim(path.join(dir, "missing")), false);
|
|
37
|
+
} finally {
|
|
38
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("findRealBinary: returns path to executable; skips cortex shims and exclusion list", () => {
|
|
43
|
+
const dir1 = makeBinDir();
|
|
44
|
+
const dir2 = makeBinDir();
|
|
45
|
+
try {
|
|
46
|
+
const shimPath = path.join(dir1, "copilot");
|
|
47
|
+
fs.writeFileSync(shimPath, `#!/bin/sh\n${SHIM_MARKER}\nexec /opt/copilot "$@"\n`, { mode: 0o755 });
|
|
48
|
+
const realPath = path.join(dir2, "copilot");
|
|
49
|
+
fs.writeFileSync(realPath, "#!/bin/sh\necho real\n", { mode: 0o755 });
|
|
50
|
+
|
|
51
|
+
const origPath = process.env.PATH;
|
|
52
|
+
process.env.PATH = `${dir1}:${dir2}`;
|
|
53
|
+
try {
|
|
54
|
+
assert.equal(findRealBinary("copilot"), realPath);
|
|
55
|
+
assert.equal(findRealBinary("copilot", [shimPath]), realPath);
|
|
56
|
+
assert.equal(findRealBinary("copilot", [shimPath, realPath]), null);
|
|
57
|
+
assert.equal(findRealBinary("nonexistent"), null);
|
|
58
|
+
} finally {
|
|
59
|
+
process.env.PATH = origPath;
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
fs.rmSync(dir1, { recursive: true, force: true });
|
|
63
|
+
fs.rmSync(dir2, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("buildDarwinSandboxProfile: denies writes to copilot config locations", () => {
|
|
68
|
+
const profile = buildDarwinSandboxProfile("/Users/dan");
|
|
69
|
+
assert.match(profile, /\(version 1\)/);
|
|
70
|
+
assert.match(profile, /\(allow default\)/);
|
|
71
|
+
assert.match(profile, /\(deny file-write\* \(subpath "\/Users\/dan\/\.copilot"\)\)/);
|
|
72
|
+
assert.match(profile, /\(deny file-write\* \(subpath "\/Users\/dan\/\.copilot\.local"\)\)/);
|
|
73
|
+
assert.match(profile, /\(deny file-write\* \(regex #"\^\/etc\/copilot"\)\)/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("buildLinuxBwrapArgs: tmpfs-mounts copilot config dirs and binds home", () => {
|
|
77
|
+
const args = buildLinuxBwrapArgs("/home/dan", "/usr/local/bin/copilot", ["--prompt", "hi"]);
|
|
78
|
+
assert.ok(args.includes("--die-with-parent"), "should die with parent");
|
|
79
|
+
const tmpfsCount = args.filter((a) => a === "--tmpfs").length;
|
|
80
|
+
assert.equal(tmpfsCount, 2, "should tmpfs both ~/.copilot and ~/.copilot.local");
|
|
81
|
+
assert.ok(args.includes("/home/dan/.copilot"));
|
|
82
|
+
assert.ok(args.includes("/home/dan/.copilot.local"));
|
|
83
|
+
// Real binary + args after `--`
|
|
84
|
+
const dashIdx = args.indexOf("--");
|
|
85
|
+
assert.ok(dashIdx > 0);
|
|
86
|
+
assert.equal(args[dashIdx + 1], "/usr/local/bin/copilot");
|
|
87
|
+
assert.deepEqual(args.slice(dashIdx + 2), ["--prompt", "hi"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("runAiCli: claude/codex passthrough exec with provided realBinary", async () => {
|
|
91
|
+
// Use /bin/echo as a safe stand-in: it exists everywhere, prints args, exits 0.
|
|
92
|
+
const exit = await runAiCli({
|
|
93
|
+
cli: "claude",
|
|
94
|
+
args: ["hello", "world"],
|
|
95
|
+
realBinary: "/bin/echo",
|
|
96
|
+
});
|
|
97
|
+
assert.equal(exit, 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("runAiCli: missing binary returns 127", async () => {
|
|
101
|
+
const orig = process.env.PATH;
|
|
102
|
+
process.env.PATH = "";
|
|
103
|
+
try {
|
|
104
|
+
const exit = await runAiCli({ cli: "claude", args: [] });
|
|
105
|
+
assert.equal(exit, 127);
|
|
106
|
+
} finally {
|
|
107
|
+
process.env.PATH = orig;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import http from "node:http";
|
|
7
|
+
|
|
8
|
+
import { checkSyncForCli, runSyncCheckOnce } from "../dist/daemon/sync-checker.js";
|
|
9
|
+
|
|
10
|
+
function startMockServer(handlers) {
|
|
11
|
+
const server = http.createServer((req, res) => {
|
|
12
|
+
const u = new URL(req.url, `http://${req.headers.host}`);
|
|
13
|
+
const handler = handlers[`${req.method} ${u.pathname}`];
|
|
14
|
+
if (!handler) {
|
|
15
|
+
res.statusCode = 404;
|
|
16
|
+
res.end();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
let body = "";
|
|
20
|
+
req.on("data", (c) => (body += c));
|
|
21
|
+
req.on("end", () => handler(req, res, u, body));
|
|
22
|
+
});
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
server.listen(0, "127.0.0.1", () => {
|
|
25
|
+
const addr = server.address();
|
|
26
|
+
resolve({ server, baseUrl: `http://127.0.0.1:${addr.port}` });
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeProject({ baseUrl, installVersion }) {
|
|
32
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-sync-"));
|
|
33
|
+
const ctx = path.join(root, ".context");
|
|
34
|
+
fs.mkdirSync(ctx, { recursive: true });
|
|
35
|
+
fs.writeFileSync(
|
|
36
|
+
path.join(ctx, "enterprise.yml"),
|
|
37
|
+
[
|
|
38
|
+
"enterprise:",
|
|
39
|
+
" api_key: ent_test_key_12345678",
|
|
40
|
+
` base_url: ${baseUrl}`,
|
|
41
|
+
"compliance:",
|
|
42
|
+
" frameworks: [iso27001]",
|
|
43
|
+
"",
|
|
44
|
+
].join("\n"),
|
|
45
|
+
);
|
|
46
|
+
if (installVersion) {
|
|
47
|
+
fs.writeFileSync(
|
|
48
|
+
path.join(ctx, "govern.local.json"),
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
installs: {
|
|
51
|
+
claude: {
|
|
52
|
+
path: "/managed",
|
|
53
|
+
version: installVersion,
|
|
54
|
+
frameworks: [{ id: "iso27001", version: "0.1" }],
|
|
55
|
+
installed_at: new Date().toISOString(),
|
|
56
|
+
mode: "advisory",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return { root, ctx };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("checkSyncForCli: 304 maps to unchanged", async () => {
|
|
66
|
+
const { server, baseUrl } = await startMockServer({
|
|
67
|
+
"GET /api/v1/govern/config": (req, res) => {
|
|
68
|
+
assert.equal(req.headers["if-none-match"], '"v123"', "should send If-None-Match");
|
|
69
|
+
res.statusCode = 304;
|
|
70
|
+
res.end();
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
const { root } = makeProject({ baseUrl, installVersion: "v123" });
|
|
74
|
+
try {
|
|
75
|
+
const outcome = await checkSyncForCli({ cwd: root, cli: "claude" });
|
|
76
|
+
assert.equal(outcome.kind, "unchanged");
|
|
77
|
+
assert.equal(outcome.version, "v123");
|
|
78
|
+
} finally {
|
|
79
|
+
server.close();
|
|
80
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("checkSyncForCli: 200 with new ETag maps to available", async () => {
|
|
85
|
+
const { server, baseUrl } = await startMockServer({
|
|
86
|
+
"GET /api/v1/govern/config": (req, res) => {
|
|
87
|
+
res.setHeader("ETag", '"v999"');
|
|
88
|
+
res.setHeader("Content-Type", "application/json");
|
|
89
|
+
res.end(JSON.stringify({ cli: "claude", managed_settings: {}, deny_rules: [], tamper_config: {}, frameworks: [] }));
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const { root } = makeProject({ baseUrl, installVersion: "v123" });
|
|
93
|
+
try {
|
|
94
|
+
const outcome = await checkSyncForCli({ cwd: root, cli: "claude" });
|
|
95
|
+
assert.equal(outcome.kind, "available");
|
|
96
|
+
assert.equal(outcome.latest_version, "v999");
|
|
97
|
+
assert.equal(outcome.current_version, "v123");
|
|
98
|
+
} finally {
|
|
99
|
+
server.close();
|
|
100
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("checkSyncForCli: 500 maps to failed", async () => {
|
|
105
|
+
const { server, baseUrl } = await startMockServer({
|
|
106
|
+
"GET /api/v1/govern/config": (req, res) => {
|
|
107
|
+
res.statusCode = 500;
|
|
108
|
+
res.end();
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const { root } = makeProject({ baseUrl, installVersion: "v1" });
|
|
112
|
+
try {
|
|
113
|
+
const outcome = await checkSyncForCli({ cwd: root, cli: "claude" });
|
|
114
|
+
assert.equal(outcome.kind, "failed");
|
|
115
|
+
assert.match(outcome.error, /HTTP 500/);
|
|
116
|
+
} finally {
|
|
117
|
+
server.close();
|
|
118
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("checkSyncForCli: errors when enterprise not configured", async () => {
|
|
123
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-sync-bare-"));
|
|
124
|
+
fs.mkdirSync(path.join(root, ".context"));
|
|
125
|
+
try {
|
|
126
|
+
const outcome = await checkSyncForCli({ cwd: root, cli: "claude" });
|
|
127
|
+
assert.equal(outcome.kind, "failed");
|
|
128
|
+
assert.match(outcome.error, /enterprise not configured/);
|
|
129
|
+
} finally {
|
|
130
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("runSyncCheckOnce: writes update notification when new version is available", async () => {
|
|
135
|
+
const { server, baseUrl } = await startMockServer({
|
|
136
|
+
"GET /api/v1/govern/config": (req, res) => {
|
|
137
|
+
res.setHeader("ETag", '"newer"');
|
|
138
|
+
res.end(JSON.stringify({ cli: "claude" }));
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
const { root, ctx } = makeProject({ baseUrl, installVersion: "older" });
|
|
142
|
+
try {
|
|
143
|
+
const outcomes = await runSyncCheckOnce(root, ["claude"]);
|
|
144
|
+
assert.equal(outcomes.length, 1);
|
|
145
|
+
assert.equal(outcomes[0].kind, "available");
|
|
146
|
+
|
|
147
|
+
const notif = path.join(ctx, ".govern-update-available.json");
|
|
148
|
+
assert.equal(fs.existsSync(notif), true);
|
|
149
|
+
const parsed = JSON.parse(fs.readFileSync(notif, "utf8"));
|
|
150
|
+
assert.equal(parsed.cli, "claude");
|
|
151
|
+
assert.equal(parsed.latest_version, "newer");
|
|
152
|
+
|
|
153
|
+
// Audit jsonl should contain a govern_config_available event
|
|
154
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
155
|
+
const audit = path.join(ctx, "audit", `host-events-${date}.jsonl`);
|
|
156
|
+
assert.equal(fs.existsSync(audit), true);
|
|
157
|
+
const lines = fs.readFileSync(audit, "utf8").trim().split("\n").map(JSON.parse);
|
|
158
|
+
const availableLine = lines.find((l) => l.event_type === "govern_config_available");
|
|
159
|
+
assert.ok(availableLine, "expected govern_config_available event");
|
|
160
|
+
assert.equal(typeof availableLine.host_id, "string");
|
|
161
|
+
assert.ok(availableLine.host_id.length > 0, "host_id should be non-empty");
|
|
162
|
+
} finally {
|
|
163
|
+
server.close();
|
|
164
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("runSyncCheckOnce: writes govern_config_unchanged event on 304", async () => {
|
|
169
|
+
const { server, baseUrl } = await startMockServer({
|
|
170
|
+
"GET /api/v1/govern/config": (req, res) => {
|
|
171
|
+
res.statusCode = 304;
|
|
172
|
+
res.end();
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
const { root, ctx } = makeProject({ baseUrl, installVersion: "current" });
|
|
176
|
+
try {
|
|
177
|
+
await runSyncCheckOnce(root, ["claude"]);
|
|
178
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
179
|
+
const audit = path.join(ctx, "audit", `host-events-${date}.jsonl`);
|
|
180
|
+
const lines = fs.readFileSync(audit, "utf8").trim().split("\n").map(JSON.parse);
|
|
181
|
+
assert.ok(lines.some((l) => l.event_type === "govern_config_unchanged"));
|
|
182
|
+
// No notification file written for unchanged
|
|
183
|
+
assert.equal(fs.existsSync(path.join(ctx, ".govern-update-available.json")), false);
|
|
184
|
+
} finally {
|
|
185
|
+
server.close();
|
|
186
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
detectUngoverned,
|
|
6
|
+
enforceFinding,
|
|
7
|
+
isCortexAncestor,
|
|
8
|
+
parseProcessLine,
|
|
9
|
+
DEFAULT_AI_BINARIES,
|
|
10
|
+
} from "../dist/cli/ungoverned-detector.js";
|
|
11
|
+
|
|
12
|
+
function p(pid, ppid, user, comm, args = "") {
|
|
13
|
+
return { pid, ppid, user, comm, args: args || comm };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("DEFAULT_AI_BINARIES covers known agentic CLIs", () => {
|
|
17
|
+
for (const cli of ["claude", "codex", "copilot", "gemini-cli", "aider", "cursor"]) {
|
|
18
|
+
assert.ok(DEFAULT_AI_BINARIES.includes(cli), `expected ${cli} in defaults`);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("isCortexAncestor recognises common cortex invocations", () => {
|
|
23
|
+
assert.equal(isCortexAncestor("cortex run copilot --prompt hi"), true);
|
|
24
|
+
assert.equal(isCortexAncestor("cortex enterprise ent_xxx"), true);
|
|
25
|
+
assert.equal(isCortexAncestor("cortex daemon"), true);
|
|
26
|
+
assert.equal(isCortexAncestor("cortex hook pre-tool-use"), true);
|
|
27
|
+
assert.equal(isCortexAncestor("/usr/bin/cortex run claude"), true);
|
|
28
|
+
assert.equal(isCortexAncestor("node /Users/dan/.npm-global/lib/node_modules/@x/bin/cortex.mjs"), true);
|
|
29
|
+
assert.equal(isCortexAncestor("/usr/bin/zsh"), false);
|
|
30
|
+
assert.equal(isCortexAncestor("npm run dev"), false);
|
|
31
|
+
assert.equal(isCortexAncestor(""), false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("parseProcessLine handles ps -axo output with multi-word args", () => {
|
|
35
|
+
const line = " 1234 100 alice claude claude --prompt hello world";
|
|
36
|
+
const proc = parseProcessLine(line);
|
|
37
|
+
assert.deepEqual(proc, {
|
|
38
|
+
pid: 1234,
|
|
39
|
+
ppid: 100,
|
|
40
|
+
user: "alice",
|
|
41
|
+
comm: "claude",
|
|
42
|
+
args: "claude --prompt hello world",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("parseProcessLine returns null for malformed lines", () => {
|
|
47
|
+
assert.equal(parseProcessLine(""), null);
|
|
48
|
+
assert.equal(parseProcessLine("garbage"), null);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("detectUngoverned: AI CLI with shell parent is flagged", () => {
|
|
52
|
+
const procs = [
|
|
53
|
+
p(1, 0, "root", "/sbin/launchd", "/sbin/launchd"),
|
|
54
|
+
p(100, 1, "alice", "zsh", "-zsh"),
|
|
55
|
+
p(200, 100, "alice", "claude", "claude --prompt hi"),
|
|
56
|
+
];
|
|
57
|
+
const findings = detectUngoverned({ processes: procs, hostId: "test-host" });
|
|
58
|
+
assert.equal(findings.length, 1);
|
|
59
|
+
assert.equal(findings[0].cli, "claude");
|
|
60
|
+
assert.equal(findings[0].pid, 200);
|
|
61
|
+
assert.equal(findings[0].host_id, "test-host");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("detectUngoverned: AI CLI spawned via 'cortex run' is NOT flagged", () => {
|
|
65
|
+
const procs = [
|
|
66
|
+
p(1, 0, "root", "/sbin/launchd", "/sbin/launchd"),
|
|
67
|
+
p(100, 1, "alice", "zsh", "-zsh"),
|
|
68
|
+
p(150, 100, "alice", "node", "node /usr/local/bin/cortex run copilot --prompt hi"),
|
|
69
|
+
p(160, 150, "alice", "sandbox-exec", "sandbox-exec -f /tmp/copilot.sb /usr/local/bin/copilot --prompt hi"),
|
|
70
|
+
p(170, 160, "alice", "copilot", "copilot --prompt hi"),
|
|
71
|
+
];
|
|
72
|
+
const findings = detectUngoverned({ processes: procs });
|
|
73
|
+
assert.equal(findings.length, 0, "copilot should be governed via cortex run ancestor");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("detectUngoverned: AI CLI with shim invocation in own args is recognised", () => {
|
|
77
|
+
// The shim execs cortex run; after exec the process args show 'cortex run copilot ...'
|
|
78
|
+
const procs = [
|
|
79
|
+
p(100, 1, "alice", "cortex", "/usr/bin/cortex run copilot --prompt hi"),
|
|
80
|
+
p(110, 100, "alice", "copilot", "/path/to/copilot --prompt hi"),
|
|
81
|
+
];
|
|
82
|
+
const findings = detectUngoverned({ processes: procs });
|
|
83
|
+
assert.equal(findings.length, 0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("detectUngoverned: ignores non-AI binaries", () => {
|
|
87
|
+
const procs = [
|
|
88
|
+
p(100, 1, "alice", "zsh", "-zsh"),
|
|
89
|
+
p(200, 100, "alice", "node", "node app.js"),
|
|
90
|
+
p(300, 100, "alice", "python", "python script.py"),
|
|
91
|
+
];
|
|
92
|
+
const findings = detectUngoverned({ processes: procs });
|
|
93
|
+
assert.equal(findings.length, 0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("detectUngoverned: handles deep parent chain without exploding", () => {
|
|
97
|
+
// 50-deep chain ending in shell. Should be flagged once.
|
|
98
|
+
const procs = [];
|
|
99
|
+
for (let i = 1; i <= 50; i++) {
|
|
100
|
+
procs.push(p(i, i - 1, "alice", `intermediate${i}`, `intermediate${i}`));
|
|
101
|
+
}
|
|
102
|
+
procs.push(p(999, 50, "alice", "claude", "claude --prompt hi"));
|
|
103
|
+
const findings = detectUngoverned({ processes: procs });
|
|
104
|
+
assert.equal(findings.length, 1);
|
|
105
|
+
assert.equal(findings[0].pid, 999);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("detectUngoverned: custom knownBinaries list", () => {
|
|
109
|
+
const procs = [
|
|
110
|
+
p(100, 1, "alice", "claude", "claude --prompt hi"),
|
|
111
|
+
p(200, 1, "alice", "myllm", "myllm --prompt hi"),
|
|
112
|
+
];
|
|
113
|
+
const findings = detectUngoverned({
|
|
114
|
+
processes: procs,
|
|
115
|
+
knownBinaries: ["myllm"],
|
|
116
|
+
});
|
|
117
|
+
assert.equal(findings.length, 1);
|
|
118
|
+
assert.equal(findings[0].cli, "myllm");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("enforceFinding: advisory mode never sends signal", () => {
|
|
122
|
+
let signalled = null;
|
|
123
|
+
const action = enforceFinding(
|
|
124
|
+
{
|
|
125
|
+
pid: 100,
|
|
126
|
+
ppid: 1,
|
|
127
|
+
user: "alice",
|
|
128
|
+
cli: "claude",
|
|
129
|
+
binary: "claude",
|
|
130
|
+
args: "claude",
|
|
131
|
+
parent_chain: [100],
|
|
132
|
+
detected_at: new Date().toISOString(),
|
|
133
|
+
host_id: "h",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
mode: "advisory",
|
|
137
|
+
sendSignal: (pid, sig) => {
|
|
138
|
+
signalled = [pid, sig];
|
|
139
|
+
},
|
|
140
|
+
currentUser: "alice",
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
assert.equal(action, "logged");
|
|
144
|
+
assert.equal(signalled, null);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("enforceFinding: enforced mode SIGTERMs same-user processes only", () => {
|
|
148
|
+
let signalled = null;
|
|
149
|
+
const send = (pid, sig) => {
|
|
150
|
+
signalled = [pid, sig];
|
|
151
|
+
};
|
|
152
|
+
const action = enforceFinding(
|
|
153
|
+
{
|
|
154
|
+
pid: 100,
|
|
155
|
+
ppid: 1,
|
|
156
|
+
user: "alice",
|
|
157
|
+
cli: "claude",
|
|
158
|
+
binary: "claude",
|
|
159
|
+
args: "claude",
|
|
160
|
+
parent_chain: [100],
|
|
161
|
+
detected_at: new Date().toISOString(),
|
|
162
|
+
host_id: "h",
|
|
163
|
+
},
|
|
164
|
+
{ mode: "enforced", sendSignal: send, currentUser: "alice" },
|
|
165
|
+
);
|
|
166
|
+
assert.equal(action, "sigterm");
|
|
167
|
+
assert.deepEqual(signalled, [100, "SIGTERM"]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("enforceFinding: cross-user finding is skipped (would require root)", () => {
|
|
171
|
+
let signalled = null;
|
|
172
|
+
const send = (pid, sig) => {
|
|
173
|
+
signalled = [pid, sig];
|
|
174
|
+
};
|
|
175
|
+
const action = enforceFinding(
|
|
176
|
+
{
|
|
177
|
+
pid: 100,
|
|
178
|
+
ppid: 1,
|
|
179
|
+
user: "bob",
|
|
180
|
+
cli: "claude",
|
|
181
|
+
binary: "claude",
|
|
182
|
+
args: "claude",
|
|
183
|
+
parent_chain: [100],
|
|
184
|
+
detected_at: new Date().toISOString(),
|
|
185
|
+
host_id: "h",
|
|
186
|
+
},
|
|
187
|
+
{ mode: "enforced", sendSignal: send, currentUser: "alice" },
|
|
188
|
+
);
|
|
189
|
+
assert.equal(action, "skipped_cross_user");
|
|
190
|
+
assert.equal(signalled, null);
|
|
191
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { runScanOnce, writeHostAuditEvent, startUngovernedScanner } from "../dist/daemon/ungoverned-scanner.js";
|
|
8
|
+
|
|
9
|
+
function makeWorkspace(governMode, installs = null) {
|
|
10
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-ungoverned-"));
|
|
11
|
+
const ctx = path.join(root, ".context");
|
|
12
|
+
fs.mkdirSync(ctx, { recursive: true });
|
|
13
|
+
if (governMode) {
|
|
14
|
+
const defaultInstalls = {
|
|
15
|
+
claude: { mode: governMode, path: "/x", version: "v", frameworks: [], installed_at: "now" },
|
|
16
|
+
};
|
|
17
|
+
fs.writeFileSync(
|
|
18
|
+
path.join(ctx, "govern.local.json"),
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
installs: installs ?? defaultInstalls,
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return { root, ctx };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("writeHostAuditEvent appends one JSONL line per call", async () => {
|
|
28
|
+
const { root } = makeWorkspace();
|
|
29
|
+
try {
|
|
30
|
+
await writeHostAuditEvent(root, { event_type: "ungoverned_ai_session_detected", pid: 100 });
|
|
31
|
+
await writeHostAuditEvent(root, { event_type: "ungoverned_ai_session_detected", pid: 200 });
|
|
32
|
+
|
|
33
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
34
|
+
const file = path.join(root, ".context", "audit", `host-events-${date}.jsonl`);
|
|
35
|
+
const content = fs.readFileSync(file, "utf8").trim().split("\n");
|
|
36
|
+
assert.equal(content.length, 2);
|
|
37
|
+
assert.equal(JSON.parse(content[0]).pid, 100);
|
|
38
|
+
assert.equal(JSON.parse(content[1]).pid, 200);
|
|
39
|
+
} finally {
|
|
40
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("runScanOnce: writes audit event with action=logged in advisory mode", async () => {
|
|
45
|
+
const { root } = makeWorkspace("advisory");
|
|
46
|
+
try {
|
|
47
|
+
const fakeProcs = [
|
|
48
|
+
{ pid: 1, ppid: 0, user: "root", comm: "init", args: "init" },
|
|
49
|
+
{ pid: 100, ppid: 1, user: os.userInfo().username, comm: "claude", args: "claude --prompt hi" },
|
|
50
|
+
];
|
|
51
|
+
const findings = await runScanOnce({
|
|
52
|
+
cwd: root,
|
|
53
|
+
detectorOptions: { processes: fakeProcs, hostId: "test-host" },
|
|
54
|
+
});
|
|
55
|
+
assert.equal(findings.length, 1);
|
|
56
|
+
|
|
57
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
58
|
+
const file = path.join(root, ".context", "audit", `host-events-${date}.jsonl`);
|
|
59
|
+
const events = fs.readFileSync(file, "utf8").trim().split("\n").map(JSON.parse);
|
|
60
|
+
assert.equal(events.length, 1);
|
|
61
|
+
assert.equal(events[0].event_type, "ungoverned_ai_session_detected");
|
|
62
|
+
assert.equal(events[0].cli, "claude");
|
|
63
|
+
assert.equal(events[0].mode, "advisory");
|
|
64
|
+
assert.equal(events[0].action, "logged");
|
|
65
|
+
assert.equal(events[0].host_id, "test-host");
|
|
66
|
+
} finally {
|
|
67
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("runScanOnce: enforced mode marks action=sigterm but our mock doesn't actually signal real procs", async () => {
|
|
72
|
+
const { root } = makeWorkspace("enforced");
|
|
73
|
+
try {
|
|
74
|
+
const me = os.userInfo().username;
|
|
75
|
+
const fakeProcs = [
|
|
76
|
+
{ pid: 99999, ppid: 1, user: me, comm: "claude", args: "claude --prompt hi" },
|
|
77
|
+
];
|
|
78
|
+
let killed = null;
|
|
79
|
+
// Monkey-patch process.kill for the test (the enforce function uses it as default).
|
|
80
|
+
const origKill = process.kill;
|
|
81
|
+
process.kill = (pid, sig) => {
|
|
82
|
+
killed = [pid, sig];
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
const findings = await runScanOnce({
|
|
86
|
+
cwd: root,
|
|
87
|
+
detectorOptions: { processes: fakeProcs, hostId: "test-host" },
|
|
88
|
+
});
|
|
89
|
+
assert.equal(findings.length, 1);
|
|
90
|
+
} finally {
|
|
91
|
+
process.kill = origKill;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
95
|
+
const file = path.join(root, ".context", "audit", `host-events-${date}.jsonl`);
|
|
96
|
+
const events = fs.readFileSync(file, "utf8").trim().split("\n").map(JSON.parse);
|
|
97
|
+
assert.equal(events[0].mode, "enforced");
|
|
98
|
+
assert.equal(events[0].action, "sigterm");
|
|
99
|
+
assert.deepEqual(killed, [99999, "SIGTERM"]);
|
|
100
|
+
} finally {
|
|
101
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("runScanOnce: emits onFinding callback per detection", async () => {
|
|
106
|
+
const { root } = makeWorkspace("advisory");
|
|
107
|
+
try {
|
|
108
|
+
const fakeProcs = [
|
|
109
|
+
{ pid: 200, ppid: 1, user: "alice", comm: "codex", args: "codex --prompt hi" },
|
|
110
|
+
{ pid: 300, ppid: 1, user: "alice", comm: "copilot", args: "copilot --prompt hi" },
|
|
111
|
+
];
|
|
112
|
+
const seen = [];
|
|
113
|
+
await runScanOnce({
|
|
114
|
+
cwd: root,
|
|
115
|
+
mode: "advisory",
|
|
116
|
+
detectorOptions: { processes: fakeProcs },
|
|
117
|
+
onFinding: (f) => seen.push({ cli: f.cli, action: f.action }),
|
|
118
|
+
});
|
|
119
|
+
assert.equal(seen.length, 2);
|
|
120
|
+
assert.deepEqual(seen.map((s) => s.cli).sort(), ["codex", "copilot"]);
|
|
121
|
+
for (const s of seen) assert.equal(s.action, "logged");
|
|
122
|
+
} finally {
|
|
123
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("runScanOnce: skips Tier 1 CLIs that already have managed installs", async () => {
|
|
128
|
+
const { root } = makeWorkspace("enforced");
|
|
129
|
+
const managedClaudePath = path.join(root, "managed-settings.json");
|
|
130
|
+
fs.writeFileSync(managedClaudePath, "{}\n");
|
|
131
|
+
const managedCodexPath = path.join(root, "requirements.toml");
|
|
132
|
+
fs.writeFileSync(managedCodexPath, "# managed\n");
|
|
133
|
+
fs.writeFileSync(
|
|
134
|
+
path.join(root, ".context", "govern.local.json"),
|
|
135
|
+
JSON.stringify({
|
|
136
|
+
installs: {
|
|
137
|
+
claude: {
|
|
138
|
+
mode: "enforced",
|
|
139
|
+
path: managedClaudePath,
|
|
140
|
+
version: "v1",
|
|
141
|
+
frameworks: [],
|
|
142
|
+
installed_at: "now",
|
|
143
|
+
},
|
|
144
|
+
codex: {
|
|
145
|
+
mode: "enforced",
|
|
146
|
+
path: managedCodexPath,
|
|
147
|
+
version: "v2",
|
|
148
|
+
frameworks: [],
|
|
149
|
+
installed_at: "now",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
try {
|
|
155
|
+
const fakeProcs = [
|
|
156
|
+
{ pid: 200, ppid: 1, user: os.userInfo().username, comm: "claude", args: "claude --prompt hi" },
|
|
157
|
+
{ pid: 300, ppid: 1, user: os.userInfo().username, comm: "codex", args: "codex exec hi" },
|
|
158
|
+
{ pid: 400, ppid: 1, user: os.userInfo().username, comm: "copilot", args: "copilot suggest" },
|
|
159
|
+
];
|
|
160
|
+
const findings = await runScanOnce({
|
|
161
|
+
cwd: root,
|
|
162
|
+
mode: "enforced",
|
|
163
|
+
detectorOptions: { processes: fakeProcs, hostId: "test-host" },
|
|
164
|
+
});
|
|
165
|
+
assert.equal(findings.length, 1);
|
|
166
|
+
assert.equal(findings[0].cli, "copilot");
|
|
167
|
+
|
|
168
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
169
|
+
const file = path.join(root, ".context", "audit", `host-events-${date}.jsonl`);
|
|
170
|
+
const events = fs.readFileSync(file, "utf8").trim().split("\n").map(JSON.parse);
|
|
171
|
+
assert.equal(events.length, 1);
|
|
172
|
+
assert.equal(events[0].cli, "copilot");
|
|
173
|
+
} finally {
|
|
174
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("startUngovernedScanner: stop() halts further ticks", async () => {
|
|
179
|
+
const { root } = makeWorkspace("advisory");
|
|
180
|
+
try {
|
|
181
|
+
let calls = 0;
|
|
182
|
+
const handle = startUngovernedScanner({
|
|
183
|
+
cwd: root,
|
|
184
|
+
intervalMs: 50,
|
|
185
|
+
mode: "advisory",
|
|
186
|
+
detectorOptions: { processes: [] },
|
|
187
|
+
onFinding: () => {
|
|
188
|
+
calls += 1;
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
// Wait a moment to allow at least the immediate tick.
|
|
192
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
193
|
+
handle.stop();
|
|
194
|
+
assert.equal(handle.isRunning(), false);
|
|
195
|
+
} finally {
|
|
196
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
});
|