@shieldaai/agent-map 0.0.0-stage → 0.1.0-alpha.3
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/LICENSE +202 -0
- package/README.md +312 -2
- package/dist/assets.d.ts +5 -0
- package/dist/assets.js +43 -0
- package/dist/boundary.d.ts +7 -0
- package/dist/boundary.js +123 -0
- package/dist/cli-entry.d.ts +2 -0
- package/dist/cli-entry.js +18 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +93 -0
- package/dist/http.d.ts +28 -0
- package/dist/http.js +233 -0
- package/dist/identity-cli.d.ts +9 -0
- package/dist/identity-cli.js +103 -0
- package/dist/identity-http.d.ts +44 -0
- package/dist/identity-http.js +394 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +31 -0
- package/dist/oidc.d.ts +27 -0
- package/dist/oidc.js +311 -0
- package/dist/policy-reader.d.ts +28 -0
- package/dist/policy-reader.js +394 -0
- package/dist/service.d.ts +28 -0
- package/dist/service.js +683 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.js +466 -0
- package/dist/transport.d.ts +11 -0
- package/dist/transport.js +70 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.js +47 -0
- package/package.json +59 -4
package/dist/boundary.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { MapQueryError } from './types.js';
|
|
2
|
+
export function fail(code) { throw new MapQueryError(code); }
|
|
3
|
+
// Configuration selects credential destinations. Viewer assets run in authenticated browsers.
|
|
4
|
+
// Check the descriptor after open so the test applies to the bytes that will be used.
|
|
5
|
+
export function trustedHostFile(info) {
|
|
6
|
+
return typeof process.geteuid === 'function' && info.isFile() &&
|
|
7
|
+
(info.uid === 0 || info.uid === process.geteuid()) && (info.mode & 0o022) === 0;
|
|
8
|
+
}
|
|
9
|
+
export function fields(value, allowed, required = []) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
11
|
+
![null, Object.prototype].includes(Object.getPrototypeOf(value)) || Object.getOwnPropertySymbols(value).length)
|
|
12
|
+
fail('MAP_CONFIG_INVALID');
|
|
13
|
+
const result = Object.create(null);
|
|
14
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
15
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
16
|
+
if (!allowed.includes(key) || !descriptor.enumerable || !('value' in descriptor))
|
|
17
|
+
fail('MAP_CONFIG_INVALID');
|
|
18
|
+
result[key] = descriptor.value;
|
|
19
|
+
}
|
|
20
|
+
if (required.some(key => !Object.hasOwn(result, key)))
|
|
21
|
+
fail('MAP_CONFIG_INVALID');
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
export function integer(value, fallback, min, max) {
|
|
25
|
+
const result = value === undefined ? fallback : value;
|
|
26
|
+
if (typeof result !== 'number' || !Number.isSafeInteger(result) || result < min || result > max)
|
|
27
|
+
fail('MAP_CONFIG_INVALID');
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
/** Bounded strict JSON, including duplicate decoded keys that JSON.parse would discard. */
|
|
31
|
+
export function parseRequest(bytes, maximum = 65_536) {
|
|
32
|
+
if (bytes.byteLength > maximum)
|
|
33
|
+
fail('MAP_REQUEST_TOO_LARGE');
|
|
34
|
+
try {
|
|
35
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
36
|
+
let offset = 0;
|
|
37
|
+
let nodes = 0;
|
|
38
|
+
const space = () => { while (/[\t\n\r ]/.test(text[offset] ?? '\0'))
|
|
39
|
+
offset++; };
|
|
40
|
+
const string = () => {
|
|
41
|
+
const start = offset++;
|
|
42
|
+
while (offset < text.length) {
|
|
43
|
+
const char = text[offset++];
|
|
44
|
+
if (char === '\\')
|
|
45
|
+
offset++;
|
|
46
|
+
else if (char === '"')
|
|
47
|
+
return JSON.parse(text.slice(start, offset));
|
|
48
|
+
}
|
|
49
|
+
throw new Error();
|
|
50
|
+
};
|
|
51
|
+
const value = (depth) => {
|
|
52
|
+
if (++nodes > 2048 || depth > 12)
|
|
53
|
+
throw new Error();
|
|
54
|
+
space();
|
|
55
|
+
const char = text[offset];
|
|
56
|
+
if (char === '"')
|
|
57
|
+
return string();
|
|
58
|
+
if (char === '{') {
|
|
59
|
+
offset++;
|
|
60
|
+
space();
|
|
61
|
+
const object = Object.create(null);
|
|
62
|
+
if (text[offset] === '}') {
|
|
63
|
+
offset++;
|
|
64
|
+
return object;
|
|
65
|
+
}
|
|
66
|
+
while (true) {
|
|
67
|
+
space();
|
|
68
|
+
if (text[offset] !== '"')
|
|
69
|
+
throw new Error();
|
|
70
|
+
const key = string();
|
|
71
|
+
if (Object.hasOwn(object, key))
|
|
72
|
+
throw new Error();
|
|
73
|
+
space();
|
|
74
|
+
if (text[offset++] !== ':')
|
|
75
|
+
throw new Error();
|
|
76
|
+
object[key] = value(depth + 1);
|
|
77
|
+
space();
|
|
78
|
+
if (text[offset] === '}') {
|
|
79
|
+
offset++;
|
|
80
|
+
return object;
|
|
81
|
+
}
|
|
82
|
+
if (text[offset++] !== ',')
|
|
83
|
+
throw new Error();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (char === '[') {
|
|
87
|
+
offset++;
|
|
88
|
+
space();
|
|
89
|
+
const array = [];
|
|
90
|
+
if (text[offset] === ']') {
|
|
91
|
+
offset++;
|
|
92
|
+
return array;
|
|
93
|
+
}
|
|
94
|
+
while (true) {
|
|
95
|
+
array.push(value(depth + 1));
|
|
96
|
+
space();
|
|
97
|
+
if (text[offset] === ']') {
|
|
98
|
+
offset++;
|
|
99
|
+
return array;
|
|
100
|
+
}
|
|
101
|
+
if (text[offset++] !== ',')
|
|
102
|
+
throw new Error();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const token = /^(?:null|true|false|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(text.slice(offset))?.[0];
|
|
106
|
+
if (token === undefined)
|
|
107
|
+
throw new Error();
|
|
108
|
+
offset += token.length;
|
|
109
|
+
const parsed = JSON.parse(token);
|
|
110
|
+
if (typeof parsed === 'number' && !Number.isFinite(parsed))
|
|
111
|
+
throw new Error();
|
|
112
|
+
return parsed;
|
|
113
|
+
};
|
|
114
|
+
const parsed = value(0);
|
|
115
|
+
space();
|
|
116
|
+
if (offset !== text.length)
|
|
117
|
+
throw new Error();
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return fail('MAP_REQUEST_INVALID');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
/** npm's POSIX executable is a symlink. Node resolves it when loading the module. */
|
|
4
|
+
export function isCliEntry(meta) {
|
|
5
|
+
if (typeof meta.main === 'boolean')
|
|
6
|
+
return meta.main;
|
|
7
|
+
const entry = process.argv[1];
|
|
8
|
+
if (!entry)
|
|
9
|
+
return false;
|
|
10
|
+
try {
|
|
11
|
+
// Older supported Node versions do not expose import.meta.main.
|
|
12
|
+
return pathToFileURL(realpathSync(entry)).href === meta.url;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Imports from eval/stdin can supply an argv value that is not a file.
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { isCliEntry } from './cli-entry.js';
|
|
7
|
+
import { createNeo4jMapStore } from './store.js';
|
|
8
|
+
import { MapService } from './service.js';
|
|
9
|
+
import { startMapServer } from './http.js';
|
|
10
|
+
import { validateReadScope } from './types.js';
|
|
11
|
+
import { fields, fail, integer, parseRequest, trustedHostFile } from './boundary.js';
|
|
12
|
+
function environmentName(value) {
|
|
13
|
+
if (typeof value !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(value))
|
|
14
|
+
fail('MAP_CONFIG_INVALID');
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
/** Keep the type check and bounded read on one nonblocking descriptor. */
|
|
18
|
+
async function configBytes(path) {
|
|
19
|
+
if (typeof constants.O_NONBLOCK !== 'number')
|
|
20
|
+
fail('MAP_CONFIG_INVALID');
|
|
21
|
+
const maximum = 128 * 1024;
|
|
22
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
23
|
+
try {
|
|
24
|
+
const info = await file.stat();
|
|
25
|
+
if (!trustedHostFile(info) || info.size > maximum)
|
|
26
|
+
fail('MAP_CONFIG_INVALID');
|
|
27
|
+
// The file may grow after stat; never use an unbounded readFile here.
|
|
28
|
+
const data = Buffer.alloc(maximum + 1);
|
|
29
|
+
let bytes = 0;
|
|
30
|
+
while (bytes < data.length) {
|
|
31
|
+
const read = await file.read(data, bytes, data.length - bytes, null);
|
|
32
|
+
if (!read.bytesRead)
|
|
33
|
+
break;
|
|
34
|
+
bytes += read.bytesRead;
|
|
35
|
+
}
|
|
36
|
+
if (bytes > maximum)
|
|
37
|
+
fail('MAP_CONFIG_INVALID');
|
|
38
|
+
return data.subarray(0, bytes);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await file.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Credential values stay in the environment and are never printed by this launcher. */
|
|
45
|
+
export async function runMapCli(argv = process.argv.slice(2)) {
|
|
46
|
+
const { values } = parseArgs({ args: argv, options: { config: { type: 'string' }, help: { type: 'boolean' } }, strict: true });
|
|
47
|
+
if (values.help) {
|
|
48
|
+
process.stdout.write('agent-guard-map --config HOST_CONFIG.json\n');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!values.config)
|
|
52
|
+
fail('MAP_CONFIG_INVALID');
|
|
53
|
+
const path = resolve(values.config);
|
|
54
|
+
const config = fields(parseRequest(await configBytes(path), 128 * 1024), ['neo4j', 'credentials', 'port', 'viewerDirectory'], ['neo4j', 'credentials']);
|
|
55
|
+
const database = fields(config.neo4j, ['uri', 'username', 'passwordEnv', 'database'], ['uri', 'username', 'passwordEnv']);
|
|
56
|
+
if (typeof database.uri !== 'string' || typeof database.username !== 'string' ||
|
|
57
|
+
(database.database !== undefined && typeof database.database !== 'string'))
|
|
58
|
+
fail('MAP_CONFIG_INVALID');
|
|
59
|
+
const password = process.env[environmentName(database.passwordEnv)];
|
|
60
|
+
if (!password)
|
|
61
|
+
fail('MAP_CREDENTIAL_REQUIRED');
|
|
62
|
+
if (!Array.isArray(config.credentials) || config.credentials.length < 1 || config.credentials.length > 128)
|
|
63
|
+
fail('MAP_CONFIG_INVALID');
|
|
64
|
+
const credentials = config.credentials.map(value => {
|
|
65
|
+
const entry = fields(value, ['tokenEnv', 'scope'], ['tokenEnv', 'scope']);
|
|
66
|
+
const token = process.env[environmentName(entry.tokenEnv)];
|
|
67
|
+
if (!token)
|
|
68
|
+
fail('MAP_CREDENTIAL_REQUIRED');
|
|
69
|
+
return { token, scope: validateReadScope(entry.scope) };
|
|
70
|
+
});
|
|
71
|
+
if (config.viewerDirectory !== undefined && typeof config.viewerDirectory !== 'string')
|
|
72
|
+
fail('MAP_CONFIG_INVALID');
|
|
73
|
+
const store = createNeo4jMapStore({ uri: database.uri, username: database.username, password,
|
|
74
|
+
...(database.database === undefined ? {} : { database: database.database }) });
|
|
75
|
+
try {
|
|
76
|
+
const server = await startMapServer({ service: new MapService(store), credentials,
|
|
77
|
+
port: integer(config.port, 8789, 0, 65535),
|
|
78
|
+
...(config.viewerDirectory === undefined ? {} : { viewerDirectory: resolve(config.viewerDirectory) }) });
|
|
79
|
+
process.stdout.write(`${JSON.stringify({ url: server.url, mode: 'read-only' })}\n`);
|
|
80
|
+
await new Promise(done => {
|
|
81
|
+
const stop = () => { process.off('SIGINT', stop); process.off('SIGTERM', stop); done(); };
|
|
82
|
+
process.once('SIGINT', stop);
|
|
83
|
+
process.once('SIGTERM', stop);
|
|
84
|
+
});
|
|
85
|
+
await server.close();
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await store.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (isCliEntry(import.meta)) {
|
|
92
|
+
void runMapCli().catch(() => { process.stderr.write('MAP_START_FAILED\n'); process.exitCode = 1; });
|
|
93
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
+
import { MapService } from './service.js';
|
|
3
|
+
import { type ReadScope } from './types.js';
|
|
4
|
+
export interface MapCredential {
|
|
5
|
+
readonly token: string;
|
|
6
|
+
readonly scope: ReadScope;
|
|
7
|
+
}
|
|
8
|
+
export interface MapServerOptions {
|
|
9
|
+
service: MapService;
|
|
10
|
+
credentials: readonly MapCredential[];
|
|
11
|
+
viewerDirectory?: string;
|
|
12
|
+
port?: number;
|
|
13
|
+
maxRequestBytes?: number;
|
|
14
|
+
maxResponseBytes?: number;
|
|
15
|
+
requestTimeoutMs?: number;
|
|
16
|
+
maxConcurrentRequests?: number;
|
|
17
|
+
rateLimit?: {
|
|
18
|
+
maxRequests: number;
|
|
19
|
+
windowMs: number;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export declare function createMapToken(): string;
|
|
23
|
+
/** Loopback pilot server. Remote serving requires a trusted, verified TLS reverse proxy. */
|
|
24
|
+
export declare function startMapServer(options: MapServerOptions): Promise<{
|
|
25
|
+
server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
26
|
+
url: string;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { immutableJson } from '@shieldaai/agent-guard';
|
|
4
|
+
import { MapService } from './service.js';
|
|
5
|
+
import { validateReadScope } from './types.js';
|
|
6
|
+
import { createMapMcpHandler } from './mcp.js';
|
|
7
|
+
import { fail, fields, integer, parseRequest } from './boundary.js';
|
|
8
|
+
import { headers, errorResponse, safeError, count, consume, readBody } from './transport.js';
|
|
9
|
+
import { loadMapAssets } from './assets.js';
|
|
10
|
+
const TOKEN = /^agm_v1\.[A-Za-z0-9_-]{43}$/;
|
|
11
|
+
const hash = (text) => createHash('sha256').update(text).digest();
|
|
12
|
+
export function createMapToken() { return `agm_v1.${randomBytes(32).toString('base64url')}`; }
|
|
13
|
+
/** Loopback pilot server. Remote serving requires a trusted, verified TLS reverse proxy. */
|
|
14
|
+
export async function startMapServer(options) {
|
|
15
|
+
const config = fields(options, ['service', 'credentials', 'viewerDirectory', 'port', 'maxRequestBytes',
|
|
16
|
+
'maxResponseBytes', 'requestTimeoutMs', 'maxConcurrentRequests', 'rateLimit'], ['service', 'credentials']);
|
|
17
|
+
if (!(config.service instanceof MapService))
|
|
18
|
+
fail('MAP_CONFIG_INVALID');
|
|
19
|
+
const service = config.service;
|
|
20
|
+
const port = integer(config.port, 0, 0, 65535);
|
|
21
|
+
const requestLimit = integer(config.maxRequestBytes, 32_768, 128, 65_536);
|
|
22
|
+
const responseLimit = integer(config.maxResponseBytes, 2 * 1024 * 1024, 128, 2 * 1024 * 1024);
|
|
23
|
+
const timeout = integer(config.requestTimeoutMs, 10_000, 10, 30_000);
|
|
24
|
+
const concurrency = integer(config.maxConcurrentRequests, 4, 1, 32);
|
|
25
|
+
const rate = config.rateLimit === undefined ? {} : fields(config.rateLimit, ['maxRequests', 'windowMs'], ['maxRequests', 'windowMs']);
|
|
26
|
+
const maxRequests = integer(rate.maxRequests, 120, 1, 10_000), windowMs = integer(rate.windowMs, 60_000, 10, 60_000);
|
|
27
|
+
let copied;
|
|
28
|
+
try {
|
|
29
|
+
copied = immutableJson(config.credentials, 8, 100_000, 1024 * 1024);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
fail('MAP_CONFIG_INVALID');
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(copied) || copied.length === 0 || copied.length > 128)
|
|
35
|
+
fail('MAP_CONFIG_INVALID');
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const credentials = copied.map(value => {
|
|
38
|
+
const entry = fields(value, ['token', 'scope'], ['token', 'scope']);
|
|
39
|
+
if (typeof entry.token !== 'string' || !TOKEN.test(entry.token) || seen.has(entry.token))
|
|
40
|
+
fail('MAP_CONFIG_INVALID');
|
|
41
|
+
seen.add(entry.token);
|
|
42
|
+
const scope = validateReadScope(entry.scope);
|
|
43
|
+
return { digest: hash(entry.token), scope, bucket: { since: performance.now(), count: 0 } };
|
|
44
|
+
});
|
|
45
|
+
seen.clear();
|
|
46
|
+
const assets = await loadMapAssets(config.viewerDirectory);
|
|
47
|
+
const sockets = new Set();
|
|
48
|
+
const aborts = new Set();
|
|
49
|
+
const socketTimers = new WeakMap();
|
|
50
|
+
let active = 0, closing = false, host = '';
|
|
51
|
+
const globalBucket = { since: performance.now(), count: 0 };
|
|
52
|
+
const server = createServer({ maxHeaderSize: 8192, headersTimeout: timeout, requestTimeout: timeout, keepAliveTimeout: 1 }, (request, response) => { void handle(request, response); });
|
|
53
|
+
server.maxConnections = 64;
|
|
54
|
+
server.maxHeadersCount = 0;
|
|
55
|
+
async function handle(request, response) {
|
|
56
|
+
request.on('error', () => { });
|
|
57
|
+
response.on('error', () => { });
|
|
58
|
+
// Once headers arrive, leave a short bounded flush window after the request
|
|
59
|
+
// deadline. A connection timer at the same instant would erase the 408.
|
|
60
|
+
clearTimeout(socketTimers.get(request.socket));
|
|
61
|
+
const socketTimer = setTimeout(() => request.socket.destroy(), timeout + 250);
|
|
62
|
+
socketTimer.unref();
|
|
63
|
+
socketTimers.set(request.socket, socketTimer);
|
|
64
|
+
const started = performance.now();
|
|
65
|
+
const abort = new AbortController();
|
|
66
|
+
let admitted = false;
|
|
67
|
+
let timer;
|
|
68
|
+
const check = () => { if (abort.signal.aborted || performance.now() - started > timeout)
|
|
69
|
+
fail('MAP_TIMEOUT'); };
|
|
70
|
+
try {
|
|
71
|
+
consume(globalBucket, Math.min(maxRequests * credentials.length, 10_000), windowMs);
|
|
72
|
+
if (closing || active >= concurrency)
|
|
73
|
+
fail('MAP_BUSY');
|
|
74
|
+
active++;
|
|
75
|
+
admitted = true;
|
|
76
|
+
aborts.add(abort);
|
|
77
|
+
timer = setTimeout(() => { abort.abort(); errorResponse(response, 'MAP_TIMEOUT'); }, timeout);
|
|
78
|
+
timer.unref();
|
|
79
|
+
if (count(request, 'host') !== 1 || request.headers.host !== host || request.headers['proxy-authorization'] !== undefined)
|
|
80
|
+
fail('MAP_REQUEST_DENIED');
|
|
81
|
+
const origin = request.headers.origin;
|
|
82
|
+
if (count(request, 'origin') > 1 || (origin !== undefined && origin !== `http://${host}`) ||
|
|
83
|
+
(request.headers['sec-fetch-site'] !== undefined && !['same-origin', 'none'].includes(request.headers['sec-fetch-site'])))
|
|
84
|
+
fail('MAP_REQUEST_DENIED');
|
|
85
|
+
const path = request.url === '/' ? '/index.html' : request.url ?? '';
|
|
86
|
+
if (request.method === 'GET' && path === '/auth/session') {
|
|
87
|
+
const body = Buffer.from(JSON.stringify({ mode: 'bearer' }));
|
|
88
|
+
headers(response, body.length);
|
|
89
|
+
response.writeHead(200);
|
|
90
|
+
response.end(body);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const asset = assets.get(path);
|
|
94
|
+
if (request.method === 'GET' && asset) {
|
|
95
|
+
headers(response, asset.body.length, asset.mime);
|
|
96
|
+
response.writeHead(200);
|
|
97
|
+
response.end(asset.body);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (request.method !== 'POST' || !['/v1/query', '/mcp'].includes(path))
|
|
101
|
+
fail('MAP_REQUEST_DENIED');
|
|
102
|
+
if (count(request, 'authorization') !== 1)
|
|
103
|
+
fail('MAP_UNAUTHENTICATED');
|
|
104
|
+
const authorization = request.headers.authorization;
|
|
105
|
+
if (!authorization?.startsWith('Bearer ') || !TOKEN.test(authorization.slice(7)))
|
|
106
|
+
fail('MAP_UNAUTHENTICATED');
|
|
107
|
+
const digest = hash(authorization.slice(7));
|
|
108
|
+
let credential;
|
|
109
|
+
for (const item of credentials)
|
|
110
|
+
if (timingSafeEqual(item.digest, digest))
|
|
111
|
+
credential = item;
|
|
112
|
+
digest.fill(0);
|
|
113
|
+
if (!credential)
|
|
114
|
+
fail('MAP_UNAUTHENTICATED');
|
|
115
|
+
consume(credential.bucket, maxRequests, windowMs);
|
|
116
|
+
if (count(request, 'content-type') !== 1 || !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(request.headers['content-type'] ?? '') ||
|
|
117
|
+
count(request, 'content-length') > 1 || count(request, 'transfer-encoding') > 1 || request.headers['content-encoding'] !== undefined)
|
|
118
|
+
fail('MAP_REQUEST_INVALID');
|
|
119
|
+
const length = request.headers['content-length'];
|
|
120
|
+
if (length !== undefined && (!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length))))
|
|
121
|
+
fail('MAP_REQUEST_INVALID');
|
|
122
|
+
if (length !== undefined && Number(length) > requestLimit)
|
|
123
|
+
fail('MAP_REQUEST_TOO_LARGE');
|
|
124
|
+
const bytes = await readBody(request, requestLimit, abort.signal);
|
|
125
|
+
check();
|
|
126
|
+
const parsed = parseRequest(bytes, requestLimit);
|
|
127
|
+
let output;
|
|
128
|
+
let status = 200;
|
|
129
|
+
let mime = 'application/json';
|
|
130
|
+
if (path === '/v1/query') {
|
|
131
|
+
let envelope;
|
|
132
|
+
try {
|
|
133
|
+
envelope = fields(parsed, ['operation', 'args'], ['operation', 'args']);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
fail('MAP_REQUEST_INVALID');
|
|
137
|
+
}
|
|
138
|
+
const result = await service.query(credential.scope, envelope.operation, envelope.args);
|
|
139
|
+
check();
|
|
140
|
+
output = Buffer.from(JSON.stringify(result));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
144
|
+
fail('MAP_REQUEST_INVALID');
|
|
145
|
+
const method = parsed.method;
|
|
146
|
+
if (typeof method !== 'string' || !['initialize', 'notifications/initialized', 'server/discover', 'tools/list', 'tools/call', 'ping'].includes(method))
|
|
147
|
+
fail('MAP_REQUEST_DENIED');
|
|
148
|
+
const handler = createMapMcpHandler(service, credential.scope);
|
|
149
|
+
try {
|
|
150
|
+
const forwarded = new Headers({ 'content-type': 'application/json', accept: 'application/json, text/event-stream' });
|
|
151
|
+
for (const name of ['mcp-protocol-version', 'mcp-session-id']) {
|
|
152
|
+
if (count(request, name) > 1)
|
|
153
|
+
fail('MAP_REQUEST_INVALID');
|
|
154
|
+
const value = request.headers[name];
|
|
155
|
+
if (typeof value === 'string')
|
|
156
|
+
forwarded.set(name, value);
|
|
157
|
+
}
|
|
158
|
+
const result = await handler.fetch(new Request(`http://${host}/mcp`, { method: 'POST', headers: forwarded,
|
|
159
|
+
body: JSON.stringify(parsed), signal: abort.signal }), { parsedBody: parsed });
|
|
160
|
+
const reader = result.body?.getReader();
|
|
161
|
+
const chunks = [];
|
|
162
|
+
let size = 0;
|
|
163
|
+
if (reader) {
|
|
164
|
+
try {
|
|
165
|
+
while (true) {
|
|
166
|
+
const { value, done } = await reader.read();
|
|
167
|
+
check();
|
|
168
|
+
if (done)
|
|
169
|
+
break;
|
|
170
|
+
size += value.byteLength;
|
|
171
|
+
if (size > responseLimit)
|
|
172
|
+
fail('MAP_RESPONSE_TOO_LARGE');
|
|
173
|
+
chunks.push(value);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
await reader.cancel().catch(() => { });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
output = Buffer.concat(chunks);
|
|
181
|
+
status = result.status;
|
|
182
|
+
mime = result.headers.get('content-type') ?? 'application/json';
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await handler.close();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
check();
|
|
189
|
+
if (output.length > responseLimit)
|
|
190
|
+
fail('MAP_RESPONSE_TOO_LARGE');
|
|
191
|
+
headers(response, output.length, mime);
|
|
192
|
+
response.writeHead(status);
|
|
193
|
+
response.end(output);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
errorResponse(response, safeError(error));
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
if (timer)
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
aborts.delete(abort);
|
|
202
|
+
if (admitted)
|
|
203
|
+
active--;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
server.on('connection', socket => {
|
|
207
|
+
sockets.add(socket);
|
|
208
|
+
const timer = setTimeout(() => socket.destroy(), timeout);
|
|
209
|
+
timer.unref();
|
|
210
|
+
socketTimers.set(socket, timer);
|
|
211
|
+
socket.once('close', () => { sockets.delete(socket); clearTimeout(socketTimers.get(socket)); });
|
|
212
|
+
});
|
|
213
|
+
server.on('checkContinue', (request, response) => { void handle(request, response); });
|
|
214
|
+
server.on('checkExpectation', (_request, response) => errorResponse(response, 'MAP_REQUEST_DENIED'));
|
|
215
|
+
server.on('connect', (_request, socket) => socket.destroy());
|
|
216
|
+
server.on('upgrade', (_request, socket) => socket.destroy());
|
|
217
|
+
server.on('clientError', (_error, socket) => socket.destroy());
|
|
218
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', () => { server.off('error', reject); resolve(); }); });
|
|
219
|
+
const address = server.address();
|
|
220
|
+
if (!address || typeof address === 'string')
|
|
221
|
+
fail('MAP_UNAVAILABLE');
|
|
222
|
+
host = `127.0.0.1:${address.port}`;
|
|
223
|
+
return { server, url: `http://${host}`, async close() {
|
|
224
|
+
if (closing)
|
|
225
|
+
return;
|
|
226
|
+
closing = true;
|
|
227
|
+
for (const abort of aborts)
|
|
228
|
+
abort.abort();
|
|
229
|
+
for (const socket of sockets)
|
|
230
|
+
socket.destroy();
|
|
231
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
232
|
+
} };
|
|
233
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createNeo4jMapStore } from './store.js';
|
|
3
|
+
import { type MapIdentityServerOptions } from './identity-http.js';
|
|
4
|
+
export declare function loadMapIdentityConfig(filename: string): Promise<{
|
|
5
|
+
server: Omit<MapIdentityServerOptions, 'service'>;
|
|
6
|
+
database: Parameters<typeof createNeo4jMapStore>[0];
|
|
7
|
+
}>;
|
|
8
|
+
/** Restart reloads policy/secrets and invalidates sessions; credentials are never printed. */
|
|
9
|
+
export declare function runMapIdentityCli(argv?: string[]): Promise<void>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { isCliEntry } from './cli-entry.js';
|
|
7
|
+
import { createNeo4jMapStore } from './store.js';
|
|
8
|
+
import { MapService } from './service.js';
|
|
9
|
+
import { startMapIdentityServer } from './identity-http.js';
|
|
10
|
+
import { fields, fail, parseRequest } from './boundary.js';
|
|
11
|
+
function environment(value) {
|
|
12
|
+
if (typeof value !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(value))
|
|
13
|
+
fail('MAP_CONFIG_INVALID');
|
|
14
|
+
const secret = process.env[value];
|
|
15
|
+
if (!secret || Buffer.byteLength(secret) > 65_536)
|
|
16
|
+
fail('MAP_CREDENTIAL_REQUIRED');
|
|
17
|
+
return secret;
|
|
18
|
+
}
|
|
19
|
+
/** POSIX launcher: owned regular files only; private inputs exclude group/other access. */
|
|
20
|
+
async function boundedFile(path, maximum, privateFile) {
|
|
21
|
+
if (typeof process.getuid !== 'function' || !constants.O_NOFOLLOW)
|
|
22
|
+
fail('MAP_CONFIG_INVALID');
|
|
23
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
24
|
+
try {
|
|
25
|
+
const info = await file.stat();
|
|
26
|
+
if (!info.isFile() || info.nlink !== 1 || info.uid !== process.getuid() || info.size > maximum ||
|
|
27
|
+
(privateFile && (info.mode & 0o077) !== 0) || (info.mode & 0o022) !== 0)
|
|
28
|
+
fail('MAP_CONFIG_INVALID');
|
|
29
|
+
const data = Buffer.alloc(maximum + 1);
|
|
30
|
+
let bytes = 0;
|
|
31
|
+
while (bytes < data.length) {
|
|
32
|
+
const read = await file.read(data, bytes, data.length - bytes, null);
|
|
33
|
+
if (!read.bytesRead)
|
|
34
|
+
break;
|
|
35
|
+
bytes += read.bytesRead;
|
|
36
|
+
}
|
|
37
|
+
if (bytes > maximum)
|
|
38
|
+
fail('MAP_CONFIG_INVALID');
|
|
39
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(data.subarray(0, bytes));
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
await file.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function loadMapIdentityConfig(filename) {
|
|
46
|
+
const path = resolve(filename), directory = dirname(path);
|
|
47
|
+
const config = fields(parseRequest(Buffer.from(await boundedFile(path, 128 * 1024, true)), 128 * 1024), ['neo4j', 'publicOrigin', 'tls', 'oidc', 'companyId', 'bindings', 'listenHost', 'port', 'viewerDirectory',
|
|
48
|
+
'sessionTtlMs', 'loginTtlMs', 'maxSessions', 'maxLoginTransactions', 'requestTimeoutMs', 'maxRequestBytes',
|
|
49
|
+
'maxResponseBytes', 'maxConcurrentRequests', 'rateLimit'], ['neo4j', 'publicOrigin', 'tls', 'oidc', 'companyId', 'bindings']);
|
|
50
|
+
const db = fields(config.neo4j, ['uri', 'username', 'passwordEnv', 'database'], ['uri', 'username', 'passwordEnv']);
|
|
51
|
+
if (typeof db.uri !== 'string' || typeof db.username !== 'string' || (db.database !== undefined && typeof db.database !== 'string'))
|
|
52
|
+
fail('MAP_CONFIG_INVALID');
|
|
53
|
+
const database = { uri: db.uri, username: db.username, password: environment(db.passwordEnv), ...(db.database === undefined ? {} : { database: db.database }) };
|
|
54
|
+
const tls = fields(config.tls, ['keyFile', 'certFile'], ['keyFile', 'certFile']);
|
|
55
|
+
const oidc = fields(config.oidc, ['issuer', 'clientId', 'clientSecretEnv', 'clientAuthMethod', 'endpointOrigins', 'caFile', 'requestTimeoutMs', 'maxResponseBytes'], ['issuer', 'clientId', 'clientSecretEnv']);
|
|
56
|
+
for (const value of [tls.keyFile, tls.certFile])
|
|
57
|
+
if (typeof value !== 'string' || !value)
|
|
58
|
+
fail('MAP_CONFIG_INVALID');
|
|
59
|
+
if (oidc.caFile !== undefined && (typeof oidc.caFile !== 'string' || !oidc.caFile))
|
|
60
|
+
fail('MAP_CONFIG_INVALID');
|
|
61
|
+
if (config.viewerDirectory !== undefined && typeof config.viewerDirectory !== 'string')
|
|
62
|
+
fail('MAP_CONFIG_INVALID');
|
|
63
|
+
const { neo4j: _database, tls: _tls, oidc: _oidc, ...server } = config;
|
|
64
|
+
const { clientSecretEnv: _secret, caFile: _ca, ...provider } = oidc;
|
|
65
|
+
return { database, server: { ...server,
|
|
66
|
+
tls: { key: await boundedFile(resolve(directory, tls.keyFile), 256 * 1024, true),
|
|
67
|
+
cert: await boundedFile(resolve(directory, tls.certFile), 256 * 1024, false) },
|
|
68
|
+
oidc: { ...provider, clientSecret: environment(oidc.clientSecretEnv),
|
|
69
|
+
...(oidc.caFile === undefined ? {} : { ca: await boundedFile(resolve(directory, oidc.caFile), 256 * 1024, false) }) },
|
|
70
|
+
...(config.viewerDirectory === undefined ? {} : { viewerDirectory: resolve(directory, config.viewerDirectory) }), } };
|
|
71
|
+
}
|
|
72
|
+
/** Restart reloads policy/secrets and invalidates sessions; credentials are never printed. */
|
|
73
|
+
export async function runMapIdentityCli(argv = process.argv.slice(2)) {
|
|
74
|
+
const { values } = parseArgs({ args: argv, options: { config: { type: 'string' }, help: { type: 'boolean' } }, strict: true });
|
|
75
|
+
if (values.help) {
|
|
76
|
+
process.stdout.write('agent-guard-map-identity --config PRIVATE_HOST_CONFIG.json\n');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!values.config)
|
|
80
|
+
fail('MAP_CONFIG_INVALID');
|
|
81
|
+
const config = await loadMapIdentityConfig(values.config);
|
|
82
|
+
const store = createNeo4jMapStore(config.database);
|
|
83
|
+
try {
|
|
84
|
+
const server = await startMapIdentityServer({ ...config.server, service: new MapService(store) });
|
|
85
|
+
try {
|
|
86
|
+
process.stdout.write(`${JSON.stringify({ url: server.url, mode: 'oidc-read-only' })}\n`);
|
|
87
|
+
await new Promise(done => {
|
|
88
|
+
const stop = () => { process.off('SIGINT', stop); process.off('SIGTERM', stop); done(); };
|
|
89
|
+
process.once('SIGINT', stop);
|
|
90
|
+
process.once('SIGTERM', stop);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await server.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await store.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (isCliEntry(import.meta)) {
|
|
102
|
+
void runMapIdentityCli().catch(() => { process.stderr.write('MAP_START_FAILED\n'); process.exitCode = 1; });
|
|
103
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type Server } from 'node:https';
|
|
2
|
+
import { MapService } from './service.js';
|
|
3
|
+
import { type ReadScope } from './types.js';
|
|
4
|
+
import { type MapOidcOptions } from './oidc.js';
|
|
5
|
+
export interface MapIdentityBinding {
|
|
6
|
+
readonly subject: string;
|
|
7
|
+
readonly scope: ReadScope;
|
|
8
|
+
}
|
|
9
|
+
export interface MapIdentityServerOptions {
|
|
10
|
+
service: MapService;
|
|
11
|
+
publicOrigin: string;
|
|
12
|
+
tls: {
|
|
13
|
+
readonly key: string;
|
|
14
|
+
readonly cert: string;
|
|
15
|
+
};
|
|
16
|
+
oidc: Omit<MapOidcOptions, 'redirectUri'>;
|
|
17
|
+
companyId: string;
|
|
18
|
+
bindings: readonly MapIdentityBinding[];
|
|
19
|
+
listenHost?: string;
|
|
20
|
+
port?: number;
|
|
21
|
+
viewerDirectory?: string;
|
|
22
|
+
sessionTtlMs?: number;
|
|
23
|
+
loginTtlMs?: number;
|
|
24
|
+
maxSessions?: number;
|
|
25
|
+
maxLoginTransactions?: number;
|
|
26
|
+
requestTimeoutMs?: number;
|
|
27
|
+
maxRequestBytes?: number;
|
|
28
|
+
maxResponseBytes?: number;
|
|
29
|
+
maxConcurrentRequests?: number;
|
|
30
|
+
rateLimit?: {
|
|
31
|
+
maxRequests: number;
|
|
32
|
+
windowMs: number;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export interface MapIdentityServer {
|
|
36
|
+
readonly server: Server;
|
|
37
|
+
readonly url: string;
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
replaceBindings(bindings: readonly MapIdentityBinding[]): void;
|
|
40
|
+
revokeSubject(subject: string): void;
|
|
41
|
+
invalidateSessions(): void;
|
|
42
|
+
}
|
|
43
|
+
/** Dedicated HTTPS browser boundary. It calls the scoped service directly and trusts no proxy identity headers. */
|
|
44
|
+
export declare function startMapIdentityServer(options: MapIdentityServerOptions): Promise<MapIdentityServer>;
|