@shieldaai/agent-map 0.1.0-alpha.2 → 1.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/README.md +653 -268
- package/dist/assets.js +16 -26
- package/dist/boundary-flags.d.ts +73 -0
- package/dist/boundary-flags.js +352 -0
- package/dist/boundary.d.ts +19 -2
- package/dist/boundary.js +75 -19
- package/dist/cli-entry.d.ts +13 -0
- package/dist/cli-entry.js +66 -0
- package/dist/cli.js +41 -44
- package/dist/common.d.ts +34 -0
- package/dist/common.js +94 -0
- package/dist/http.js +78 -51
- package/dist/identity-cli.js +94 -47
- package/dist/identity-http.js +226 -94
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/mcp.js +40 -5
- package/dist/oidc.d.ts +5 -0
- package/dist/oidc.js +121 -38
- package/dist/policy-reader.d.ts +56 -3
- package/dist/policy-reader.js +823 -110
- package/dist/projection-fields.d.ts +4 -0
- package/dist/projection-fields.js +144 -0
- package/dist/service/changes.d.ts +5 -0
- package/dist/service/changes.js +255 -0
- package/dist/service/context.d.ts +33 -0
- package/dist/service/context.js +2 -0
- package/dist/service/discovery.d.ts +6 -0
- package/dist/service/discovery.js +31 -0
- package/dist/service/explain.d.ts +12 -0
- package/dist/service/explain.js +126 -0
- package/dist/service/flags.d.ts +20 -0
- package/dist/service/flags.js +70 -0
- package/dist/service/inventory.d.ts +4 -0
- package/dist/service/inventory.js +62 -0
- package/dist/service/json.d.ts +8 -0
- package/dist/service/json.js +7 -0
- package/dist/service/operations/discovery.d.ts +5 -0
- package/dist/service/operations/discovery.js +90 -0
- package/dist/service/operations/explain-access.d.ts +3 -0
- package/dist/service/operations/explain-access.js +25 -0
- package/dist/service/operations/find-agents-with-access.d.ts +3 -0
- package/dist/service/operations/find-agents-with-access.js +120 -0
- package/dist/service/operations/get-agent-access.d.ts +3 -0
- package/dist/service/operations/get-agent-access.js +75 -0
- package/dist/service/operations/get-agent-inventory.d.ts +3 -0
- package/dist/service/operations/get-agent-inventory.js +42 -0
- package/dist/service/operations/get-changes.d.ts +3 -0
- package/dist/service/operations/get-changes.js +11 -0
- package/dist/service/operations/get-inventory-health.d.ts +3 -0
- package/dist/service/operations/get-inventory-health.js +81 -0
- package/dist/service/operations/get-resource-access.d.ts +3 -0
- package/dist/service/operations/get-resource-access.js +48 -0
- package/dist/service/operations/list-agents.d.ts +3 -0
- package/dist/service/operations/list-agents.js +92 -0
- package/dist/service/operations/list-owners-resources.d.ts +5 -0
- package/dist/service/operations/list-owners-resources.js +69 -0
- package/dist/service/ordering.d.ts +9 -0
- package/dist/service/ordering.js +47 -0
- package/dist/service/outside-guard.d.ts +68 -0
- package/dist/service/outside-guard.js +313 -0
- package/dist/service/policy.d.ts +26 -0
- package/dist/service/policy.js +299 -0
- package/dist/service/schemas.d.ts +16 -0
- package/dist/service/schemas.js +192 -0
- package/dist/service/summaries.d.ts +32 -0
- package/dist/service/summaries.js +247 -0
- package/dist/service/work.d.ts +33 -0
- package/dist/service/work.js +116 -0
- package/dist/service.d.ts +5 -5
- package/dist/service.js +110 -645
- package/dist/store/ancestors.d.ts +9 -0
- package/dist/store/ancestors.js +86 -0
- package/dist/store/discovery.d.ts +8 -0
- package/dist/store/discovery.js +288 -0
- package/dist/store/evidence.d.ts +12 -0
- package/dist/store/evidence.js +90 -0
- package/dist/store/identity-scope.d.ts +9 -0
- package/dist/store/identity-scope.js +71 -0
- package/dist/store/nodes.d.ts +24 -0
- package/dist/store/nodes.js +237 -0
- package/dist/store/projection.d.ts +19 -0
- package/dist/store/projection.js +242 -0
- package/dist/store/queries.d.ts +48 -0
- package/dist/store/queries.js +302 -0
- package/dist/store/read.d.ts +81 -0
- package/dist/store/read.js +135 -0
- package/dist/store/selection.d.ts +3 -0
- package/dist/store/selection.js +102 -0
- package/dist/store.d.ts +15 -0
- package/dist/store.js +102 -395
- package/dist/transport.d.ts +42 -2
- package/dist/transport.js +144 -16
- package/dist/types.d.ts +184 -4
- package/dist/types.js +52 -11
- package/package.json +32 -5
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { MapQueryError } from './types.js';
|
|
5
|
+
/** Optional `neo4j` configuration keys passed to the store as its per-read limits. */
|
|
6
|
+
export const STORE_LIMITS = ['maxNodes', 'maxEdges', 'maxBytes'];
|
|
7
|
+
export const PACKAGE_VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
8
|
+
/** npm's POSIX executable is a symlink. Node resolves it when loading the module. */
|
|
9
|
+
export function isCliEntry(meta) {
|
|
10
|
+
if (typeof meta.main === 'boolean')
|
|
11
|
+
return meta.main;
|
|
12
|
+
const entry = process.argv[1];
|
|
13
|
+
if (!entry)
|
|
14
|
+
return false;
|
|
15
|
+
try {
|
|
16
|
+
// Older supported Node versions do not expose import.meta.main.
|
|
17
|
+
return pathToFileURL(realpathSync(entry)).href === meta.url;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Imports from eval/stdin can supply an argv value that is not a file.
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// One fixed line per cause. Nothing from the error itself is printed: its message can hold a
|
|
25
|
+
// path, an argument or a configuration value.
|
|
26
|
+
const STARTUP_HINTS = Object.freeze({
|
|
27
|
+
MAP_ARGUMENT_INVALID: 'an argument is unknown or incomplete, or --config is missing.',
|
|
28
|
+
MAP_CONFIG_NOT_FOUND: 'the configuration file, or a file it names, does not exist.',
|
|
29
|
+
MAP_CONFIG_UNREADABLE: 'the configuration file, or a file it names, cannot be opened; check its type, owner and permissions.',
|
|
30
|
+
MAP_CONFIG_INVALID: 'the configuration is not accepted; check that it is strict JSON with known field names and valid values, and that each file is a regular file owned by the service user and not writable by others.',
|
|
31
|
+
MAP_CREDENTIAL_REQUIRED: 'an environment variable the configuration names is unset or empty.',
|
|
32
|
+
MAP_SCOPE_INVALID: 'a credential or binding scope is not valid.',
|
|
33
|
+
MAP_LISTEN_FAILED: 'the configured address and port could not be opened; another process may be using the port.',
|
|
34
|
+
MAP_IDENTITY_INVALID: 'the identity provider metadata does not match the configuration (issuer, RS256, S256 PKCE, client authentication or endpoint origins).',
|
|
35
|
+
MAP_IDENTITY_UNAVAILABLE: 'the identity provider did not answer within the deadline.',
|
|
36
|
+
MAP_UNAVAILABLE: 'a required service or capability is unavailable.',
|
|
37
|
+
MAP_START_FAILED: 'unexpected failure during startup.',
|
|
38
|
+
});
|
|
39
|
+
/** The specific safe code for a startup failure, and whether the usage line helps. */
|
|
40
|
+
export function startupFailure(error) {
|
|
41
|
+
const raw = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
|
42
|
+
const syscall = error && typeof error === 'object' && 'syscall' in error ? String(error.syscall) : '';
|
|
43
|
+
let code = 'MAP_START_FAILED';
|
|
44
|
+
if (error instanceof MapQueryError) {
|
|
45
|
+
// A configuration file that is not strict JSON, or too large, is a configuration fault.
|
|
46
|
+
code = ['MAP_REQUEST_INVALID', 'MAP_REQUEST_TOO_LARGE'].includes(raw) ? 'MAP_CONFIG_INVALID' : raw;
|
|
47
|
+
}
|
|
48
|
+
else if (raw.startsWith('ERR_PARSE_ARGS'))
|
|
49
|
+
code = 'MAP_ARGUMENT_INVALID';
|
|
50
|
+
else if (syscall === 'listen' || raw === 'EADDRINUSE' || raw === 'EADDRNOTAVAIL')
|
|
51
|
+
code = 'MAP_LISTEN_FAILED';
|
|
52
|
+
else if (raw === 'ENOENT' || raw === 'ENOTDIR')
|
|
53
|
+
code = 'MAP_CONFIG_NOT_FOUND';
|
|
54
|
+
else if (['EACCES', 'EPERM', 'ELOOP', 'EISDIR', 'ENXIO', 'EMFILE', 'ENFILE'].includes(raw))
|
|
55
|
+
code = 'MAP_CONFIG_UNREADABLE';
|
|
56
|
+
if (!Object.hasOwn(STARTUP_HINTS, code))
|
|
57
|
+
code = 'MAP_START_FAILED';
|
|
58
|
+
return { code, hint: STARTUP_HINTS[code], usage: code === 'MAP_ARGUMENT_INVALID' };
|
|
59
|
+
}
|
|
60
|
+
/** Prints `MAP_START_FAILED <code>: <hint>` (and the usage for an argument error) and sets the exit code. */
|
|
61
|
+
export function reportStartupFailure(error, usage) {
|
|
62
|
+
const failure = startupFailure(error);
|
|
63
|
+
const cause = failure.code === 'MAP_START_FAILED' ? '' : ` ${failure.code}`;
|
|
64
|
+
process.stderr.write(`MAP_START_FAILED${cause}: ${failure.hint}\n${failure.usage ? `Usage: ${usage}\n` : ''}`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,59 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { constants } from 'node:fs';
|
|
3
|
-
import { open } from 'node:fs/promises';
|
|
4
2
|
import { resolve } from 'node:path';
|
|
5
3
|
import { parseArgs } from 'node:util';
|
|
6
|
-
import {
|
|
4
|
+
import { PACKAGE_VERSION, STORE_LIMITS, isCliEntry, reportStartupFailure } from './cli-entry.js';
|
|
7
5
|
import { createNeo4jMapStore } from './store.js';
|
|
8
6
|
import { MapService } from './service.js';
|
|
9
7
|
import { startMapServer } from './http.js';
|
|
10
8
|
import { validateReadScope } from './types.js';
|
|
11
|
-
import {
|
|
9
|
+
import { fail, integer } from './common.js';
|
|
10
|
+
import { fields, parseRequest, readHostFile } from './boundary.js';
|
|
12
11
|
function environmentName(value) {
|
|
13
12
|
if (typeof value !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(value))
|
|
14
13
|
fail('MAP_CONFIG_INVALID');
|
|
15
14
|
return value;
|
|
16
15
|
}
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
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 (!info.isFile() || 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
|
-
}
|
|
16
|
+
/** The reference launcher's configuration: a regular file of this user or root, not writable by others. */
|
|
17
|
+
const CONFIG_FILE = { noFollow: false, owner: 'service-or-root', singleLink: false, private: false };
|
|
18
|
+
const USAGE = 'agent-guard-map --config HOST_CONFIG.json | --help | --version';
|
|
44
19
|
/** Credential values stay in the environment and are never printed by this launcher. */
|
|
45
20
|
export async function runMapCli(argv = process.argv.slice(2)) {
|
|
46
|
-
const { values } = parseArgs({
|
|
21
|
+
const { values } = parseArgs({
|
|
22
|
+
args: argv,
|
|
23
|
+
options: { config: { type: 'string' }, help: { type: 'boolean' }, version: { type: 'boolean' } },
|
|
24
|
+
strict: true,
|
|
25
|
+
});
|
|
47
26
|
if (values.help) {
|
|
48
|
-
process.stdout.write(
|
|
27
|
+
process.stdout.write(`${USAGE}\n`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (values.version) {
|
|
31
|
+
process.stdout.write(`${PACKAGE_VERSION}\n`);
|
|
49
32
|
return;
|
|
50
33
|
}
|
|
51
34
|
if (!values.config)
|
|
52
|
-
fail('
|
|
35
|
+
fail('MAP_ARGUMENT_INVALID');
|
|
53
36
|
const path = resolve(values.config);
|
|
54
|
-
const config = fields(parseRequest(await
|
|
55
|
-
const database = fields(config.neo4j, ['uri', 'username', 'passwordEnv', 'database'], ['uri', 'username', 'passwordEnv']);
|
|
56
|
-
if (typeof database.uri !== 'string' ||
|
|
37
|
+
const config = fields(parseRequest(await readHostFile(path, 128 * 1024, CONFIG_FILE), 128 * 1024), ['neo4j', 'credentials', 'port', 'viewerDirectory'], ['neo4j', 'credentials']);
|
|
38
|
+
const database = fields(config.neo4j, ['uri', 'username', 'passwordEnv', 'database', ...STORE_LIMITS], ['uri', 'username', 'passwordEnv']);
|
|
39
|
+
if (typeof database.uri !== 'string' ||
|
|
40
|
+
typeof database.username !== 'string' ||
|
|
57
41
|
(database.database !== undefined && typeof database.database !== 'string'))
|
|
58
42
|
fail('MAP_CONFIG_INVALID');
|
|
59
43
|
const password = process.env[environmentName(database.passwordEnv)];
|
|
@@ -70,15 +54,28 @@ export async function runMapCli(argv = process.argv.slice(2)) {
|
|
|
70
54
|
});
|
|
71
55
|
if (config.viewerDirectory !== undefined && typeof config.viewerDirectory !== 'string')
|
|
72
56
|
fail('MAP_CONFIG_INVALID');
|
|
73
|
-
const store = createNeo4jMapStore({
|
|
74
|
-
|
|
57
|
+
const store = createNeo4jMapStore({
|
|
58
|
+
uri: database.uri,
|
|
59
|
+
username: database.username,
|
|
60
|
+
password,
|
|
61
|
+
...(database.database === undefined ? {} : { database: database.database }),
|
|
62
|
+
// Optional read limits; the store checks their type and hard maximum.
|
|
63
|
+
...Object.fromEntries(STORE_LIMITS.filter(key => database[key] !== undefined).map(key => [key, database[key]])),
|
|
64
|
+
});
|
|
75
65
|
try {
|
|
76
|
-
const server = await startMapServer({
|
|
66
|
+
const server = await startMapServer({
|
|
67
|
+
service: new MapService(store),
|
|
68
|
+
credentials,
|
|
77
69
|
port: integer(config.port, 8789, 0, 65535),
|
|
78
|
-
...(config.viewerDirectory === undefined ? {} : { viewerDirectory: resolve(config.viewerDirectory) })
|
|
70
|
+
...(config.viewerDirectory === undefined ? {} : { viewerDirectory: resolve(config.viewerDirectory) }),
|
|
71
|
+
});
|
|
79
72
|
process.stdout.write(`${JSON.stringify({ url: server.url, mode: 'read-only' })}\n`);
|
|
80
73
|
await new Promise(done => {
|
|
81
|
-
const stop = () => {
|
|
74
|
+
const stop = () => {
|
|
75
|
+
process.off('SIGINT', stop);
|
|
76
|
+
process.off('SIGTERM', stop);
|
|
77
|
+
done();
|
|
78
|
+
};
|
|
82
79
|
process.once('SIGINT', stop);
|
|
83
80
|
process.once('SIGTERM', stop);
|
|
84
81
|
});
|
|
@@ -88,6 +85,6 @@ export async function runMapCli(argv = process.argv.slice(2)) {
|
|
|
88
85
|
await store.close();
|
|
89
86
|
}
|
|
90
87
|
}
|
|
91
|
-
if (
|
|
92
|
-
void runMapCli().catch(
|
|
88
|
+
if (isCliEntry(import.meta)) {
|
|
89
|
+
void runMapCli().catch(error => reportStartupFailure(error, USAGE));
|
|
93
90
|
}
|
package/dist/common.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type MapEdge, type MapIdentityScope, type MapNode } from './types.js';
|
|
2
|
+
export declare function fail(code: string): never;
|
|
3
|
+
/** A safe integer option within `[min, max]`, or `fallback` when it is not set. */
|
|
4
|
+
export declare function integer(value: unknown, fallback: number, min: number, max: number): number;
|
|
5
|
+
/** Unicode code-point order (the order Python sorts strings in), independent of host locale. */
|
|
6
|
+
export declare function compareCodePoints(a: string, b: string): number;
|
|
7
|
+
/**
|
|
8
|
+
* The bucket and object key of an S3 ARN, or null when it is not one. `key` is undefined for
|
|
9
|
+
* a bare bucket ARN and may be empty for one that ends in a slash.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseS3Arn(arn: unknown): {
|
|
12
|
+
readonly bucket: string;
|
|
13
|
+
readonly key: string | undefined;
|
|
14
|
+
} | null;
|
|
15
|
+
/** The bucket name of an S3 bucket or object ARN, or null. */
|
|
16
|
+
export declare const s3BucketName: (arn: unknown) => string | null;
|
|
17
|
+
export declare const LIST_SHORTENED = "bucket_list_shortened";
|
|
18
|
+
/**
|
|
19
|
+
* An identity-scope record with its bucket list cut to `keep` entries. Only the list changes:
|
|
20
|
+
* the status came from the full comparison and stands.
|
|
21
|
+
*/
|
|
22
|
+
export declare function shortenScopeList(record: MapIdentityScope, keep: number): MapIdentityScope;
|
|
23
|
+
/** Adjacency over one projection's nodes and edges, in projection order. */
|
|
24
|
+
export interface GraphIndex {
|
|
25
|
+
readonly nodes: Map<string, MapNode>;
|
|
26
|
+
/** Positions of each node ID in the node list, so subgraphs keep projection order. */
|
|
27
|
+
readonly nodePositions: Map<string, number[]>;
|
|
28
|
+
/** Positions of each source's edges in the edge list. */
|
|
29
|
+
readonly edgesFrom: Map<string, number[]>;
|
|
30
|
+
readonly outgoing: Map<string, Map<MapEdge['kind'], string[]>>;
|
|
31
|
+
readonly incoming: Map<string, Map<MapEdge['kind'], string[]>>;
|
|
32
|
+
}
|
|
33
|
+
/** Builds the index in one pass; callers account for the work themselves. */
|
|
34
|
+
export declare function buildGraphIndex(nodes: readonly MapNode[], edges: readonly MapEdge[]): GraphIndex;
|
package/dist/common.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { MapQueryError } from './types.js';
|
|
2
|
+
// Helpers shared by the service, the store and the HTTP boundaries. Nothing in this file touches
|
|
3
|
+
// the network, the file system or a Node-only module. The service as a whole is not free of
|
|
4
|
+
// them: `service/work.ts` and `service/policy.ts` import `node:buffer`, and the JSON helpers of
|
|
5
|
+
// `@shieldaai/agent-guard/internal` import `node:buffer`, `node:crypto` and `node:util/types`.
|
|
6
|
+
// A browser bundle of `@shieldaai/agent-map/service` must supply stand-ins for those three
|
|
7
|
+
// (the viewer build does).
|
|
8
|
+
export function fail(code) {
|
|
9
|
+
throw new MapQueryError(code);
|
|
10
|
+
}
|
|
11
|
+
/** A safe integer option within `[min, max]`, or `fallback` when it is not set. */
|
|
12
|
+
export function integer(value, fallback, min, max) {
|
|
13
|
+
const result = value === undefined ? fallback : value;
|
|
14
|
+
if (typeof result !== 'number' || !Number.isSafeInteger(result) || result < min || result > max)
|
|
15
|
+
fail('MAP_CONFIG_INVALID');
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
/** Unicode code-point order (the order Python sorts strings in), independent of host locale. */
|
|
19
|
+
export function compareCodePoints(a, b) {
|
|
20
|
+
let left = 0, right = 0;
|
|
21
|
+
while (left < a.length && right < b.length) {
|
|
22
|
+
const ac = a.codePointAt(left), bc = b.codePointAt(right);
|
|
23
|
+
if (ac !== bc)
|
|
24
|
+
return ac - bc;
|
|
25
|
+
left += ac > 0xffff ? 2 : 1;
|
|
26
|
+
right += bc > 0xffff ? 2 : 1;
|
|
27
|
+
}
|
|
28
|
+
return a.length - left - (b.length - right);
|
|
29
|
+
}
|
|
30
|
+
const S3_ARN = /^arn:aws:s3:::([a-z0-9][a-z0-9.-]{1,61}[a-z0-9])(?:\/(.*))?$/;
|
|
31
|
+
/**
|
|
32
|
+
* The bucket and object key of an S3 ARN, or null when it is not one. `key` is undefined for
|
|
33
|
+
* a bare bucket ARN and may be empty for one that ends in a slash.
|
|
34
|
+
*/
|
|
35
|
+
export function parseS3Arn(arn) {
|
|
36
|
+
const match = S3_ARN.exec(typeof arn === 'string' ? arn : '');
|
|
37
|
+
return match ? { bucket: match[1], key: match[2] } : null;
|
|
38
|
+
}
|
|
39
|
+
/** The bucket name of an S3 bucket or object ARN, or null. */
|
|
40
|
+
export const s3BucketName = (arn) => parseS3Arn(arn)?.bucket ?? null;
|
|
41
|
+
export const LIST_SHORTENED = 'bucket_list_shortened';
|
|
42
|
+
/**
|
|
43
|
+
* An identity-scope record with its bucket list cut to `keep` entries. Only the list changes:
|
|
44
|
+
* the status came from the full comparison and stands.
|
|
45
|
+
*/
|
|
46
|
+
export function shortenScopeList(record, keep) {
|
|
47
|
+
if (record.extraResources.length <= keep)
|
|
48
|
+
return record;
|
|
49
|
+
return {
|
|
50
|
+
...record,
|
|
51
|
+
extraResources: record.extraResources.slice(0, keep),
|
|
52
|
+
truncated: true,
|
|
53
|
+
notes: record.notes?.includes(LIST_SHORTENED) ? record.notes : [...(record.notes ?? []), LIST_SHORTENED],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Builds the index in one pass; callers account for the work themselves. */
|
|
57
|
+
export function buildGraphIndex(nodes, edges) {
|
|
58
|
+
const result = {
|
|
59
|
+
nodes: new Map(),
|
|
60
|
+
nodePositions: new Map(),
|
|
61
|
+
edgesFrom: new Map(),
|
|
62
|
+
outgoing: new Map(),
|
|
63
|
+
incoming: new Map(),
|
|
64
|
+
};
|
|
65
|
+
const push = (map, key, position) => {
|
|
66
|
+
const list = map.get(key);
|
|
67
|
+
if (list)
|
|
68
|
+
list.push(position);
|
|
69
|
+
else
|
|
70
|
+
map.set(key, [position]);
|
|
71
|
+
};
|
|
72
|
+
nodes.forEach((node, position) => {
|
|
73
|
+
result.nodes.set(node.id, node);
|
|
74
|
+
push(result.nodePositions, node.id, position);
|
|
75
|
+
});
|
|
76
|
+
const add = (map, from, kind, to) => {
|
|
77
|
+
let kinds = map.get(from);
|
|
78
|
+
if (!kinds) {
|
|
79
|
+
kinds = new Map();
|
|
80
|
+
map.set(from, kinds);
|
|
81
|
+
}
|
|
82
|
+
const items = kinds.get(kind);
|
|
83
|
+
if (items)
|
|
84
|
+
items.push(to);
|
|
85
|
+
else
|
|
86
|
+
kinds.set(kind, [to]);
|
|
87
|
+
};
|
|
88
|
+
edges.forEach((edge, position) => {
|
|
89
|
+
push(result.edgesFrom, edge.source, position);
|
|
90
|
+
add(result.outgoing, edge.source, edge.kind, edge.target);
|
|
91
|
+
add(result.incoming, edge.target, edge.kind, edge.source);
|
|
92
|
+
});
|
|
93
|
+
return result;
|
|
94
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
-
import { immutableJson } from '@shieldaai/agent-guard';
|
|
3
|
+
import { immutableJson } from '@shieldaai/agent-guard/internal';
|
|
4
4
|
import { MapService } from './service.js';
|
|
5
5
|
import { validateReadScope } from './types.js';
|
|
6
6
|
import { createMapMcpHandler } from './mcp.js';
|
|
7
|
-
import { fail,
|
|
8
|
-
import {
|
|
7
|
+
import { fail, integer } from './common.js';
|
|
8
|
+
import { fields } from './boundary.js';
|
|
9
|
+
import { headers, errorResponse, respondError, count, consume, queryEnvelope, rateLimit, readJsonBody, timeoutStatus, refuseOtherTraffic, sendAsset, } from './transport.js';
|
|
9
10
|
import { loadMapAssets } from './assets.js';
|
|
10
11
|
const TOKEN = /^agm_v1\.[A-Za-z0-9_-]{43}$/;
|
|
11
12
|
const hash = (text) => createHash('sha256').update(text).digest();
|
|
12
|
-
export function createMapToken() {
|
|
13
|
+
export function createMapToken() {
|
|
14
|
+
return `agm_v1.${randomBytes(32).toString('base64url')}`;
|
|
15
|
+
}
|
|
13
16
|
/** Loopback pilot server. Remote serving requires a trusted, verified TLS reverse proxy. */
|
|
14
17
|
export async function startMapServer(options) {
|
|
15
|
-
const config = fields(options, [
|
|
16
|
-
'
|
|
18
|
+
const config = fields(options, [
|
|
19
|
+
'service',
|
|
20
|
+
'credentials',
|
|
21
|
+
'viewerDirectory',
|
|
22
|
+
'port',
|
|
23
|
+
'maxRequestBytes',
|
|
24
|
+
'maxResponseBytes',
|
|
25
|
+
'requestTimeoutMs',
|
|
26
|
+
'maxConcurrentRequests',
|
|
27
|
+
'rateLimit',
|
|
28
|
+
], ['service', 'credentials']);
|
|
17
29
|
if (!(config.service instanceof MapService))
|
|
18
30
|
fail('MAP_CONFIG_INVALID');
|
|
19
31
|
const service = config.service;
|
|
@@ -22,8 +34,7 @@ export async function startMapServer(options) {
|
|
|
22
34
|
const responseLimit = integer(config.maxResponseBytes, 2 * 1024 * 1024, 128, 2 * 1024 * 1024);
|
|
23
35
|
const timeout = integer(config.requestTimeoutMs, 10_000, 10, 30_000);
|
|
24
36
|
const concurrency = integer(config.maxConcurrentRequests, 4, 1, 32);
|
|
25
|
-
const
|
|
26
|
-
const maxRequests = integer(rate.maxRequests, 120, 1, 10_000), windowMs = integer(rate.windowMs, 60_000, 10, 60_000);
|
|
37
|
+
const { maxRequests, windowMs } = rateLimit(config.rateLimit, 120);
|
|
27
38
|
let copied;
|
|
28
39
|
try {
|
|
29
40
|
copied = immutableJson(config.credentials, 8, 100_000, 1024 * 1024);
|
|
@@ -49,14 +60,16 @@ export async function startMapServer(options) {
|
|
|
49
60
|
const socketTimers = new WeakMap();
|
|
50
61
|
let active = 0, closing = false, host = '';
|
|
51
62
|
const globalBucket = { since: performance.now(), count: 0 };
|
|
52
|
-
const server = createServer({ maxHeaderSize: 8192, headersTimeout: timeout, requestTimeout: timeout, keepAliveTimeout: 1 }, (request, response) => {
|
|
63
|
+
const server = createServer({ maxHeaderSize: 8192, headersTimeout: timeout, requestTimeout: timeout, keepAliveTimeout: 1 }, (request, response) => {
|
|
64
|
+
void handle(request, response);
|
|
65
|
+
});
|
|
53
66
|
server.maxConnections = 64;
|
|
54
67
|
server.maxHeadersCount = 0;
|
|
55
68
|
async function handle(request, response) {
|
|
56
69
|
request.on('error', () => { });
|
|
57
70
|
response.on('error', () => { });
|
|
58
71
|
// 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.
|
|
72
|
+
// deadline. A connection timer at the same instant would erase the 408 or 504.
|
|
60
73
|
clearTimeout(socketTimers.get(request.socket));
|
|
61
74
|
const socketTimer = setTimeout(() => request.socket.destroy(), timeout + 250);
|
|
62
75
|
socketTimer.unref();
|
|
@@ -64,9 +77,14 @@ export async function startMapServer(options) {
|
|
|
64
77
|
const started = performance.now();
|
|
65
78
|
const abort = new AbortController();
|
|
66
79
|
let admitted = false;
|
|
80
|
+
// Whether the whole request has arrived (a GET has no body to wait for): a timeout after
|
|
81
|
+
// that is the service's (504), not the client's (408).
|
|
82
|
+
let received = request.method === 'GET';
|
|
67
83
|
let timer;
|
|
68
|
-
const check = () => {
|
|
69
|
-
|
|
84
|
+
const check = () => {
|
|
85
|
+
if (abort.signal.aborted || performance.now() - started > timeout)
|
|
86
|
+
fail('MAP_TIMEOUT');
|
|
87
|
+
};
|
|
70
88
|
try {
|
|
71
89
|
consume(globalBucket, Math.min(maxRequests * credentials.length, 10_000), windowMs);
|
|
72
90
|
if (closing || active >= concurrency)
|
|
@@ -74,15 +92,22 @@ export async function startMapServer(options) {
|
|
|
74
92
|
active++;
|
|
75
93
|
admitted = true;
|
|
76
94
|
aborts.add(abort);
|
|
77
|
-
timer = setTimeout(() => {
|
|
95
|
+
timer = setTimeout(() => {
|
|
96
|
+
abort.abort();
|
|
97
|
+
errorResponse(response, 'MAP_TIMEOUT', timeoutStatus(received));
|
|
98
|
+
}, timeout);
|
|
78
99
|
timer.unref();
|
|
79
|
-
if (count(request, 'host') !== 1 ||
|
|
100
|
+
if (count(request, 'host') !== 1 ||
|
|
101
|
+
request.headers.host !== host ||
|
|
102
|
+
request.headers['proxy-authorization'] !== undefined)
|
|
80
103
|
fail('MAP_REQUEST_DENIED');
|
|
81
104
|
const origin = request.headers.origin;
|
|
82
|
-
if (count(request, 'origin') > 1 ||
|
|
83
|
-
(
|
|
105
|
+
if (count(request, 'origin') > 1 ||
|
|
106
|
+
(origin !== undefined && origin !== `http://${host}`) ||
|
|
107
|
+
(request.headers['sec-fetch-site'] !== undefined &&
|
|
108
|
+
!['same-origin', 'none'].includes(request.headers['sec-fetch-site'])))
|
|
84
109
|
fail('MAP_REQUEST_DENIED');
|
|
85
|
-
const path = request.url === '/' ? '/index.html' : request.url ?? '';
|
|
110
|
+
const path = request.url === '/' ? '/index.html' : (request.url ?? '');
|
|
86
111
|
if (request.method === 'GET' && path === '/auth/session') {
|
|
87
112
|
const body = Buffer.from(JSON.stringify({ mode: 'bearer' }));
|
|
88
113
|
headers(response, body.length);
|
|
@@ -92,9 +117,7 @@ export async function startMapServer(options) {
|
|
|
92
117
|
}
|
|
93
118
|
const asset = assets.get(path);
|
|
94
119
|
if (request.method === 'GET' && asset) {
|
|
95
|
-
|
|
96
|
-
response.writeHead(200);
|
|
97
|
-
response.end(asset.body);
|
|
120
|
+
sendAsset(response, asset);
|
|
98
121
|
return;
|
|
99
122
|
}
|
|
100
123
|
if (request.method !== 'POST' || !['/v1/query', '/mcp'].includes(path))
|
|
@@ -113,28 +136,15 @@ export async function startMapServer(options) {
|
|
|
113
136
|
if (!credential)
|
|
114
137
|
fail('MAP_UNAUTHENTICATED');
|
|
115
138
|
consume(credential.bucket, maxRequests, windowMs);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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);
|
|
139
|
+
// The credential and rate checks have passed; a refusal above already answered with its status.
|
|
140
|
+
const parsed = await readJsonBody(request, response, requestLimit, abort.signal);
|
|
141
|
+
received = true;
|
|
125
142
|
check();
|
|
126
|
-
const parsed = parseRequest(bytes, requestLimit);
|
|
127
143
|
let output;
|
|
128
144
|
let status = 200;
|
|
129
145
|
let mime = 'application/json';
|
|
130
146
|
if (path === '/v1/query') {
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
envelope = fields(parsed, ['operation', 'args'], ['operation', 'args']);
|
|
134
|
-
}
|
|
135
|
-
catch {
|
|
136
|
-
fail('MAP_REQUEST_INVALID');
|
|
137
|
-
}
|
|
147
|
+
const envelope = queryEnvelope(parsed);
|
|
138
148
|
const result = await service.query(credential.scope, envelope.operation, envelope.args);
|
|
139
149
|
check();
|
|
140
150
|
output = Buffer.from(JSON.stringify(result));
|
|
@@ -143,11 +153,15 @@ export async function startMapServer(options) {
|
|
|
143
153
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
144
154
|
fail('MAP_REQUEST_INVALID');
|
|
145
155
|
const method = parsed.method;
|
|
146
|
-
if (typeof method !== 'string' ||
|
|
156
|
+
if (typeof method !== 'string' ||
|
|
157
|
+
!['initialize', 'notifications/initialized', 'server/discover', 'tools/list', 'tools/call', 'ping'].includes(method))
|
|
147
158
|
fail('MAP_REQUEST_DENIED');
|
|
148
159
|
const handler = createMapMcpHandler(service, credential.scope);
|
|
149
160
|
try {
|
|
150
|
-
const forwarded = new Headers({
|
|
161
|
+
const forwarded = new Headers({
|
|
162
|
+
'content-type': 'application/json',
|
|
163
|
+
accept: 'application/json, text/event-stream',
|
|
164
|
+
});
|
|
151
165
|
for (const name of ['mcp-protocol-version', 'mcp-session-id']) {
|
|
152
166
|
if (count(request, name) > 1)
|
|
153
167
|
fail('MAP_REQUEST_INVALID');
|
|
@@ -155,8 +169,12 @@ export async function startMapServer(options) {
|
|
|
155
169
|
if (typeof value === 'string')
|
|
156
170
|
forwarded.set(name, value);
|
|
157
171
|
}
|
|
158
|
-
const result = await handler.fetch(new Request(`http://${host}/mcp`, {
|
|
159
|
-
|
|
172
|
+
const result = await handler.fetch(new Request(`http://${host}/mcp`, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: forwarded,
|
|
175
|
+
body: JSON.stringify(parsed),
|
|
176
|
+
signal: abort.signal,
|
|
177
|
+
}), { parsedBody: parsed });
|
|
160
178
|
const reader = result.body?.getReader();
|
|
161
179
|
const chunks = [];
|
|
162
180
|
let size = 0;
|
|
@@ -193,7 +211,7 @@ export async function startMapServer(options) {
|
|
|
193
211
|
response.end(output);
|
|
194
212
|
}
|
|
195
213
|
catch (error) {
|
|
196
|
-
|
|
214
|
+
respondError(response, error, received);
|
|
197
215
|
}
|
|
198
216
|
finally {
|
|
199
217
|
if (timer)
|
|
@@ -208,19 +226,27 @@ export async function startMapServer(options) {
|
|
|
208
226
|
const timer = setTimeout(() => socket.destroy(), timeout);
|
|
209
227
|
timer.unref();
|
|
210
228
|
socketTimers.set(socket, timer);
|
|
211
|
-
socket.once('close', () => {
|
|
229
|
+
socket.once('close', () => {
|
|
230
|
+
sockets.delete(socket);
|
|
231
|
+
clearTimeout(socketTimers.get(socket));
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
refuseOtherTraffic(server, handle);
|
|
235
|
+
await new Promise((resolve, reject) => {
|
|
236
|
+
server.once('error', reject);
|
|
237
|
+
server.listen(port, '127.0.0.1', () => {
|
|
238
|
+
server.off('error', reject);
|
|
239
|
+
resolve();
|
|
240
|
+
});
|
|
212
241
|
});
|
|
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
242
|
const address = server.address();
|
|
220
243
|
if (!address || typeof address === 'string')
|
|
221
244
|
fail('MAP_UNAVAILABLE');
|
|
222
245
|
host = `127.0.0.1:${address.port}`;
|
|
223
|
-
return {
|
|
246
|
+
return {
|
|
247
|
+
server,
|
|
248
|
+
url: `http://${host}`,
|
|
249
|
+
async close() {
|
|
224
250
|
if (closing)
|
|
225
251
|
return;
|
|
226
252
|
closing = true;
|
|
@@ -229,5 +255,6 @@ export async function startMapServer(options) {
|
|
|
229
255
|
for (const socket of sockets)
|
|
230
256
|
socket.destroy();
|
|
231
257
|
await new Promise(resolve => server.close(() => resolve()));
|
|
232
|
-
}
|
|
258
|
+
},
|
|
259
|
+
};
|
|
233
260
|
}
|