graphlin 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +12 -3
- package/docs/decision-service.md +393 -0
- package/docs/extension-authoring.md +553 -0
- package/docs/model-api.md +293 -0
- package/docs/usage.md +472 -0
- package/docs/visualizer-views.md +240 -0
- package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
- package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
- package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
- package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
- package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
- package/package.json +78 -9
- package/plugin.json +4 -2
- package/runtime/architecture/analysis.mjs +344 -0
- package/runtime/architecture/controller.mjs +209 -0
- package/runtime/architecture/evidence.mjs +108 -0
- package/runtime/architecture/profile.mjs +56 -0
- package/runtime/core/evidence.mjs +43 -9
- package/runtime/core/graph.mjs +11 -6
- package/runtime/core/privacy.mjs +1 -0
- package/runtime/daemon/auth.mjs +7 -3
- package/runtime/daemon/diagnostics.mjs +1 -1
- package/runtime/daemon/extension-api.mjs +203 -0
- package/runtime/daemon/lineage.mjs +70 -0
- package/runtime/daemon/manager.mjs +9 -6
- package/runtime/daemon/model-api.mjs +728 -0
- package/runtime/daemon/model-persistence.mjs +220 -0
- package/runtime/daemon/server.mjs +81 -14
- package/runtime/daemon/settings.mjs +11 -3
- package/runtime/decisions/broker.mjs +349 -0
- package/runtime/decisions/contracts.mjs +179 -0
- package/runtime/decisions/evaluation.mjs +305 -0
- package/runtime/decisions/faults.mjs +32 -0
- package/runtime/decisions/index.mjs +818 -0
- package/runtime/decisions/profiles.mjs +93 -0
- package/runtime/decisions/questions.mjs +268 -0
- package/runtime/discovery/index.mjs +2 -0
- package/runtime/discovery/inventory.mjs +160 -0
- package/runtime/discovery/parser.mjs +40 -0
- package/runtime/discovery/structure.mjs +232 -0
- package/runtime/extensions/contracts.mjs +59 -0
- package/runtime/extensions/frame.mjs +64 -0
- package/runtime/extensions/index.mjs +9 -0
- package/runtime/extensions/manifest.mjs +95 -0
- package/runtime/extensions/packages.mjs +222 -0
- package/runtime/extensions/profiles.mjs +36 -0
- package/runtime/extensions/projection.mjs +130 -0
- package/runtime/extensions/registry.mjs +285 -0
- package/runtime/extensions/scene.mjs +105 -0
- package/runtime/extensions/sdk.d.ts +205 -0
- package/runtime/extensions/sdk.mjs +88 -0
- package/runtime/jev/index.mjs +13 -777
- package/runtime/jev/provider.mjs +101 -0
- package/runtime/jev/questions.mjs +16 -258
- package/runtime/jev/wire.mjs +17 -25
- package/runtime/model/changes.mjs +42 -0
- package/runtime/model/history.mjs +124 -0
- package/runtime/model/index.mjs +2 -0
- package/runtime/model/project-model.mjs +1020 -0
- package/runtime/model/records.mjs +240 -0
- package/runtime/pipeline.mjs +267 -55
- package/runtime/platform.mjs +254 -0
- package/runtime/visualizers/blocks.mjs +5 -0
- package/runtime/visualizers/c4.mjs +154 -0
- package/runtime/visualizers/changes.mjs +24 -0
- package/runtime/visualizers/code.mjs +5 -0
- package/runtime/visualizers/index.mjs +23 -0
- package/runtime/visualizers/structure.mjs +120 -0
- package/runtime/visualizers/timeline.mjs +66 -0
- package/runtime/web/app.js +225 -63
- package/runtime/web/extension-frame.js +128 -0
- package/runtime/web/index.html +38 -1
- package/runtime/web/model-client.js +162 -0
- package/runtime/web/platform.js +445 -0
- package/runtime/web/scene.js +111 -0
- package/runtime/web/style.css +51 -0
- package/schemas/graph.schema.json +4 -1
- package/scripts/arguments.mjs +5 -1
- package/scripts/build-packages.mjs +6 -2
- package/scripts/control.mjs +1 -1
- package/scripts/daemon.mjs +2 -1
- package/scripts/extensions.mjs +44 -0
- package/scripts/graphlin.mjs +23 -3
- package/scripts/onboarding.mjs +10 -3
- package/scripts/validate-packages.mjs +54 -8
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, readdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { gunzipSync } from 'node:zlib';
|
|
6
|
+
import {
|
|
7
|
+
MANIFEST_FILE, EXTENSION_LIMITS as L, check, extensionError, assetPath, version,
|
|
8
|
+
} from './contracts.mjs';
|
|
9
|
+
import { validateManifest, validateAssets, bundleDigest, decisionProfiles } from './manifest.mjs';
|
|
10
|
+
|
|
11
|
+
export function checkCancelled(signal) {
|
|
12
|
+
if (signal?.aborted) throw extensionError('extension_cancelled');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Walk every component, including ancestors: O_NOFOLLOW only protects a leaf.
|
|
16
|
+
export async function safeDirectory(directory, { owned = false } = {}) {
|
|
17
|
+
const resolved = path.resolve(directory);
|
|
18
|
+
let current = path.parse(resolved).root;
|
|
19
|
+
for (const part of resolved.slice(current.length).split(path.sep).filter(Boolean)) {
|
|
20
|
+
current = path.join(current, part);
|
|
21
|
+
const stat = await lstat(current);
|
|
22
|
+
check(stat.isDirectory() && !stat.isSymbolicLink(), 'unsafe_extension_directory');
|
|
23
|
+
}
|
|
24
|
+
const stat = await lstat(resolved);
|
|
25
|
+
if (owned) check((stat.mode & 0o077) === 0 &&
|
|
26
|
+
(process.getuid?.() === undefined || stat.uid === process.getuid()), 'unsafe_extension_directory');
|
|
27
|
+
return resolved;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function readRegular(filename, maxBytes = L.assetBytes, { privateFile = false } = {}) {
|
|
31
|
+
await safeDirectory(path.dirname(filename));
|
|
32
|
+
let handle;
|
|
33
|
+
try {
|
|
34
|
+
handle = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
35
|
+
const stat = await handle.stat();
|
|
36
|
+
check(stat.isFile() && stat.nlink === 1 && stat.size <= maxBytes, 'unsafe_extension_file');
|
|
37
|
+
if (privateFile) check((stat.mode & 0o077) === 0 &&
|
|
38
|
+
(process.getuid?.() === undefined || stat.uid === process.getuid()), 'unsafe_extension_file');
|
|
39
|
+
// A bounded read also covers a file growing after stat().
|
|
40
|
+
const bytes = Buffer.alloc(Math.min(stat.size + 1, maxBytes + 1));
|
|
41
|
+
let offset = 0;
|
|
42
|
+
while (offset < bytes.length) {
|
|
43
|
+
const result = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
44
|
+
if (!result.bytesRead) break;
|
|
45
|
+
offset += result.bytesRead;
|
|
46
|
+
}
|
|
47
|
+
check(offset === stat.size, 'extension_file_changed');
|
|
48
|
+
return bytes.subarray(0, offset);
|
|
49
|
+
} finally { await handle?.close(); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parsePackageSpec(spec) {
|
|
53
|
+
check(typeof spec === 'string' && spec.length <= 240, 'invalid_package_spec');
|
|
54
|
+
const match = /^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)@(.+)$/.exec(spec);
|
|
55
|
+
check(match && version(match[2]), 'exact_package_version_required');
|
|
56
|
+
return { name: match[1], version: match[2] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function json(bytes, code) {
|
|
60
|
+
try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
|
|
61
|
+
catch { throw extensionError(code); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateFiles(files, packageSpec) {
|
|
65
|
+
check(files.has(MANIFEST_FILE), 'manifest_missing');
|
|
66
|
+
const manifestBytes = files.get(MANIFEST_FILE);
|
|
67
|
+
check(manifestBytes.length <= L.manifestBytes, 'manifest_too_large');
|
|
68
|
+
const manifest = validateManifest(json(manifestBytes, 'invalid_manifest'));
|
|
69
|
+
const allowed = new Set([MANIFEST_FILE, 'package.json', 'README.md', 'LICENSE', ...Object.keys(manifest.assets)]);
|
|
70
|
+
check([...files.keys()].every(name => allowed.has(name)), 'undeclared_package_file');
|
|
71
|
+
if (files.has('package.json')) {
|
|
72
|
+
const pkg = json(files.get('package.json'), 'invalid_package_metadata');
|
|
73
|
+
check(pkg && typeof pkg === 'object' && !Array.isArray(pkg) && pkg.version === manifest.version,
|
|
74
|
+
'package_version_mismatch');
|
|
75
|
+
for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies', 'bundledDependencies', 'bundleDependencies']) {
|
|
76
|
+
check(pkg[field] === undefined || (pkg[field] && typeof pkg[field] === 'object' &&
|
|
77
|
+
Object.keys(pkg[field]).length === 0), 'runtime_dependencies_not_supported');
|
|
78
|
+
}
|
|
79
|
+
if (packageSpec) check(pkg.name === packageSpec.name && pkg.version === packageSpec.version, 'package_identity_mismatch');
|
|
80
|
+
} else check(!packageSpec, 'package_metadata_missing');
|
|
81
|
+
const assetInputs = Object.fromEntries(Object.keys(manifest.assets).filter(name => files.has(name))
|
|
82
|
+
.map(name => [name, files.get(name)]));
|
|
83
|
+
const assets = validateAssets(manifest, assetInputs);
|
|
84
|
+
const profiles = decisionProfiles(manifest, assets);
|
|
85
|
+
return { manifest, assets, profiles, digest: bundleDigest(manifest) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function readLocalPackage(directory, { signal } = {}) {
|
|
89
|
+
const root = await safeDirectory(directory);
|
|
90
|
+
const files = new Map();
|
|
91
|
+
let count = 0, total = 0;
|
|
92
|
+
// Parse the manifest first; only the explicit package allowlist may be read.
|
|
93
|
+
files.set(MANIFEST_FILE, await readRegular(path.join(root, MANIFEST_FILE), L.manifestBytes));
|
|
94
|
+
const manifest = validateManifest(json(files.get(MANIFEST_FILE), 'invalid_manifest'));
|
|
95
|
+
const allowed = new Set([MANIFEST_FILE, 'package.json', 'README.md', 'LICENSE', ...Object.keys(manifest.assets)]);
|
|
96
|
+
async function inspect(relative = '') {
|
|
97
|
+
checkCancelled(signal);
|
|
98
|
+
const entries = await readdir(path.join(root, relative), { withFileTypes: true });
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
check(++count <= L.files, 'package_file_limit');
|
|
101
|
+
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
|
102
|
+
check(assetPath(name), 'unsafe_package_path');
|
|
103
|
+
const stat = await lstat(path.join(root, name));
|
|
104
|
+
check(!stat.isSymbolicLink(), 'unsafe_package_link');
|
|
105
|
+
if (stat.isDirectory()) {
|
|
106
|
+
check([...allowed].some(value => value.startsWith(`${name}/`)), 'undeclared_package_file');
|
|
107
|
+
await inspect(name);
|
|
108
|
+
} else {
|
|
109
|
+
check(stat.isFile() && allowed.has(name), 'undeclared_package_file');
|
|
110
|
+
if (name !== MANIFEST_FILE) files.set(name, await readRegular(path.join(root, name)));
|
|
111
|
+
total += files.get(name).length;
|
|
112
|
+
check(total <= L.packageBytes, 'package_too_large');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
await inspect();
|
|
117
|
+
return validateFiles(files);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function tarString(block, start, size) {
|
|
121
|
+
const field = block.subarray(start, start + size);
|
|
122
|
+
const end = field.indexOf(0);
|
|
123
|
+
if (end >= 0) check(field.subarray(end).every(byte => byte === 0), 'invalid_archive_header');
|
|
124
|
+
return field.subarray(0, end < 0 ? field.length : end).toString('utf8');
|
|
125
|
+
}
|
|
126
|
+
function octal(block, start, size) {
|
|
127
|
+
const value = block.subarray(start, start + size).toString('ascii').replace(/\0.*$/, '').trim();
|
|
128
|
+
check(/^[0-7]+$/.test(value), 'invalid_archive_number');
|
|
129
|
+
const result = Number.parseInt(value, 8);
|
|
130
|
+
check(Number.isSafeInteger(result), 'invalid_archive_number');
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Validate before extraction. No system tar, filesystem writes, links, PAX,
|
|
135
|
+
* sparse files, device nodes, or archive-controlled permissions are used. */
|
|
136
|
+
export function readPackageArchive(input, spec) {
|
|
137
|
+
check(input instanceof Uint8Array && input.length <= L.archiveBytes, 'archive_too_large');
|
|
138
|
+
let bytes = Buffer.from(input);
|
|
139
|
+
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
|
140
|
+
try { bytes = gunzipSync(bytes, { maxOutputLength: L.archiveBytes }); }
|
|
141
|
+
catch { throw extensionError('invalid_or_oversized_archive'); }
|
|
142
|
+
}
|
|
143
|
+
check(bytes.length <= L.archiveBytes && bytes.length % 512 === 0, 'invalid_archive');
|
|
144
|
+
const files = new Map(), names = new Set();
|
|
145
|
+
let offset = 0, total = 0, count = 0, ended = false;
|
|
146
|
+
while (offset + 512 <= bytes.length) {
|
|
147
|
+
const header = bytes.subarray(offset, offset + 512);
|
|
148
|
+
if (header.every(byte => byte === 0)) {
|
|
149
|
+
check(bytes.length - offset >= 1024 && bytes.subarray(offset).every(byte => byte === 0), 'invalid_archive_end');
|
|
150
|
+
ended = true; break;
|
|
151
|
+
}
|
|
152
|
+
check(++count <= L.files, 'package_file_limit');
|
|
153
|
+
const sum = header.reduce((value, byte, index) => value + (index >= 148 && index < 156 ? 32 : byte), 0);
|
|
154
|
+
check(sum === octal(header, 148, 8), 'archive_checksum_mismatch');
|
|
155
|
+
const type = header[156];
|
|
156
|
+
check([0, 48, 53].includes(type), 'unsafe_archive_entry');
|
|
157
|
+
check(!tarString(header, 157, 100), 'unsafe_archive_link');
|
|
158
|
+
const prefix = tarString(header, 345, 155);
|
|
159
|
+
const raw = `${prefix ? `${prefix}/` : ''}${tarString(header, 0, 100)}`;
|
|
160
|
+
const name = type === 53 && raw.endsWith('/') ? raw.slice(0, -1) : raw;
|
|
161
|
+
check(name === 'package' || (name.startsWith('package/') && assetPath(name.slice(8))), 'unsafe_archive_path');
|
|
162
|
+
check(!names.has(name.toLowerCase()), 'duplicate_archive_path');
|
|
163
|
+
names.add(name.toLowerCase());
|
|
164
|
+
const size = octal(header, 124, 12);
|
|
165
|
+
check(size <= L.assetBytes && (type !== 53 || size === 0), 'archive_file_limit');
|
|
166
|
+
offset += 512;
|
|
167
|
+
check(offset + Math.ceil(size / 512) * 512 <= bytes.length, 'truncated_archive');
|
|
168
|
+
if (type !== 53) {
|
|
169
|
+
check(name !== 'package', 'unsafe_archive_path');
|
|
170
|
+
total += size;
|
|
171
|
+
check(total <= L.packageBytes, 'package_too_large');
|
|
172
|
+
files.set(name.slice(8), bytes.subarray(offset, offset + size));
|
|
173
|
+
}
|
|
174
|
+
offset += Math.ceil(size / 512) * 512;
|
|
175
|
+
}
|
|
176
|
+
check(ended, 'invalid_archive_end');
|
|
177
|
+
for (const name of files.keys()) {
|
|
178
|
+
check(![...files.keys()].some(other => other.startsWith(`${name}/`)), 'archive_path_conflict');
|
|
179
|
+
}
|
|
180
|
+
return validateFiles(files, parsePackageSpec(spec));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The only network transport. Tests inject a transport and never call npm. */
|
|
184
|
+
export async function npmPackTransport(spec, { directory, signal } = {}) {
|
|
185
|
+
parsePackageSpec(spec);
|
|
186
|
+
checkCancelled(signal);
|
|
187
|
+
const args = ['pack', '--ignore-scripts', '--json', '--registry=https://registry.npmjs.org/',
|
|
188
|
+
'--pack-destination', directory, '--', spec];
|
|
189
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
190
|
+
const child = spawn('npm', args, { cwd: directory, shell: false, stdio: ['ignore', 'pipe', 'ignore'],
|
|
191
|
+
env: { PATH: process.env.PATH, npm_config_ignore_scripts: 'true', npm_config_userconfig: path.join(directory, 'user.npmrc'),
|
|
192
|
+
npm_config_globalconfig: path.join(directory, 'global.npmrc'), npm_config_cache: path.join(directory, 'npm-cache'),
|
|
193
|
+
npm_config_audit: 'false', npm_config_fund: 'false' } });
|
|
194
|
+
let output = '', reason, escalation;
|
|
195
|
+
const stop = code => {
|
|
196
|
+
if (reason) return;
|
|
197
|
+
reason = extensionError(code);
|
|
198
|
+
child.kill('SIGTERM');
|
|
199
|
+
escalation = setTimeout(() => child.kill('SIGKILL'), 300);
|
|
200
|
+
};
|
|
201
|
+
const abort = () => stop('extension_cancelled');
|
|
202
|
+
const timer = setTimeout(() => stop('npm_pack_timeout'), 30_000);
|
|
203
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
204
|
+
if (signal?.aborted) abort();
|
|
205
|
+
child.stdout.on('data', bytes => {
|
|
206
|
+
if (output.length + bytes.length > 64 * 1024) stop('npm_output_limit');
|
|
207
|
+
else output += bytes.toString('utf8');
|
|
208
|
+
});
|
|
209
|
+
const finish = error => {
|
|
210
|
+
clearTimeout(timer); clearTimeout(escalation);
|
|
211
|
+
signal?.removeEventListener('abort', abort);
|
|
212
|
+
if (error) reject(error); else resolve(output);
|
|
213
|
+
};
|
|
214
|
+
child.once('error', () => finish(extensionError('npm_unavailable')));
|
|
215
|
+
child.once('close', code => finish(reason || (code ? extensionError('npm_pack_failed') : undefined)));
|
|
216
|
+
});
|
|
217
|
+
let result;
|
|
218
|
+
try { result = JSON.parse(stdout); } catch { throw extensionError('invalid_npm_response'); }
|
|
219
|
+
check(Array.isArray(result) && result.length === 1 && typeof result[0].filename === 'string' &&
|
|
220
|
+
/^[A-Za-z0-9][A-Za-z0-9_.-]*\.tgz$/.test(result[0].filename), 'invalid_npm_response');
|
|
221
|
+
return readRegular(path.join(directory, result[0].filename), L.archiveBytes);
|
|
222
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { check, exact, id, text, uniqueStrings, jsonBytes } from './contracts.mjs';
|
|
2
|
+
|
|
3
|
+
const INTERPRETATION_KINDS = new Set([
|
|
4
|
+
'application', 'container', 'component', 'system', 'external_system',
|
|
5
|
+
'actor', 'person', 'context', 'datastore',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
/** Declarative decisions only; the service broker owns evidence and execution. */
|
|
9
|
+
export function validateDecisionProfile(profile) {
|
|
10
|
+
jsonBytes(profile, 32 * 1024, 'decision_profile_limit');
|
|
11
|
+
check(exact(profile, ['id', 'questions', 'selectors']) &&
|
|
12
|
+
typeof profile.id === 'string' && /^[a-z][a-z0-9-]{0,39}$/.test(profile.id) &&
|
|
13
|
+
Array.isArray(profile.questions) && profile.questions.length > 0 && profile.questions.length <= 16,
|
|
14
|
+
'invalid_decision_profile');
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
for (const question of profile.questions) {
|
|
17
|
+
check(exact(question, ['id', 'kind', 'question'], ['options', 'interpretationKind', 'interpretationLabel']) && id(question.id) &&
|
|
18
|
+
!seen.has(question.id) && ['boolean', 'choice', 'score'].includes(question.kind) &&
|
|
19
|
+
text(question.question, 400), 'invalid_decision_question');
|
|
20
|
+
check(question.kind === 'choice' ? uniqueStrings(question.options, value => text(value, 80), 16) &&
|
|
21
|
+
question.options.length >= 2 : question.options === undefined, 'invalid_decision_options');
|
|
22
|
+
check(question.interpretationKind === undefined || INTERPRETATION_KINDS.has(question.interpretationKind) ||
|
|
23
|
+
(question.interpretationKind === 'selected-choice' && question.kind === 'choice' &&
|
|
24
|
+
question.options.every(value => value === 'unknown' || INTERPRETATION_KINDS.has(value))),
|
|
25
|
+
'invalid_interpretation_kind');
|
|
26
|
+
check(question.interpretationLabel === undefined ||
|
|
27
|
+
(question.interpretationKind !== undefined && text(question.interpretationLabel, 80)),
|
|
28
|
+
'invalid_interpretation_label');
|
|
29
|
+
seen.add(question.id);
|
|
30
|
+
}
|
|
31
|
+
check(exact(profile.selectors, ['fields', 'candidateIds']) &&
|
|
32
|
+
uniqueStrings(profile.selectors.fields, value => ['entities', 'relations', 'interpretations'].includes(value), 3) &&
|
|
33
|
+
profile.selectors.fields.length > 0 &&
|
|
34
|
+
uniqueStrings(profile.selectors.candidateIds, id, 256), 'invalid_decision_selectors');
|
|
35
|
+
return JSON.parse(JSON.stringify(profile));
|
|
36
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { safeText } from '../core/privacy.mjs';
|
|
2
|
+
import {
|
|
3
|
+
DATA_FIELDS, EXTENSION_LIMITS as L, check, plain, exact, id, extensionId,
|
|
4
|
+
digest, integer, text, uniqueStrings, jsonBytes,
|
|
5
|
+
} from './contracts.mjs';
|
|
6
|
+
|
|
7
|
+
export function validateGrant(grant) {
|
|
8
|
+
check(exact(grant, ['projectId', 'extensionId', 'digest', 'fields', 'history', 'approved'], ['grantedAt', 'profiles']) &&
|
|
9
|
+
id(grant.projectId) && extensionId(grant.extensionId) && digest(grant.digest) &&
|
|
10
|
+
uniqueStrings(grant.fields, value => DATA_FIELDS.includes(value)) &&
|
|
11
|
+
typeof grant.history === 'boolean' && typeof grant.approved === 'boolean' &&
|
|
12
|
+
(!grant.fields.includes('checkpoints') || grant.history), 'invalid_extension_grant');
|
|
13
|
+
check(grant.grantedAt === undefined || timestamp(grant.grantedAt), 'invalid_extension_grant');
|
|
14
|
+
check(grant.profiles === undefined || uniqueStrings(grant.profiles,
|
|
15
|
+
value => typeof value === 'string' && /^[a-z][a-z0-9-]{0,39}$/.test(value), 8), 'invalid_extension_grant');
|
|
16
|
+
return JSON.parse(JSON.stringify(grant));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const timestamp = value => typeof value === 'string' &&
|
|
20
|
+
/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value) && Number.isFinite(Date.parse(value));
|
|
21
|
+
const safeId = value => id(value) && safeText(value, 160);
|
|
22
|
+
// Display metadata is still constrained. All locator fields are omitted, and
|
|
23
|
+
// even strings smuggled into a label cannot contain absolute paths or secrets.
|
|
24
|
+
const safeLabel = value => text(value) && safeText(value, L.text) &&
|
|
25
|
+
!/(?:^|[\s"'`(=])(?:\/|[A-Za-z]:[\\/]|\\\\)|[\\]/.test(value);
|
|
26
|
+
const token = value => typeof value === 'string' && /^[a-z][a-z0-9_.-]{0,63}$/.test(value);
|
|
27
|
+
const pick = (input, fields) => {
|
|
28
|
+
if (!plain(input)) return {};
|
|
29
|
+
return Object.fromEntries(Object.entries(fields).filter(([key, valid]) => valid(input[key]))
|
|
30
|
+
.map(([key]) => [key, input[key]]));
|
|
31
|
+
};
|
|
32
|
+
const refs = values => Array.isArray(values) ? values.slice(0, 16).filter(plain).map(value => pick(value, {
|
|
33
|
+
artifactId: safeId, eventId: safeId, hash: digest, generation: integer,
|
|
34
|
+
startLine: value => integer(value, 10_000_000) && value > 0,
|
|
35
|
+
endLine: value => integer(value, 10_000_000) && value > 0,
|
|
36
|
+
sourceClass: value => ['source', 'public_intent', 'runtime'].includes(value),
|
|
37
|
+
})) : [];
|
|
38
|
+
const ids = values => Array.isArray(values) ? [...new Set(values.slice(0, 256).filter(safeId))] : [];
|
|
39
|
+
const basis = value => ['metadata', 'parsed', 'lexical', 'decision', 'legacy',
|
|
40
|
+
'documentation', 'annotation', 'runtime'].includes(value);
|
|
41
|
+
const validity = value => ['current', 'stale', 'retracted'].includes(value);
|
|
42
|
+
const classification = value => ['accepted', 'tentative', 'unknown', 'stale', 'pending', 'abstained'].includes(value);
|
|
43
|
+
const base = { id: safeId, kind: token, basis, validity };
|
|
44
|
+
const map = (values, limit, project) => Array.isArray(values)
|
|
45
|
+
? values.slice(0, limit).filter(value => plain(value) && safeId(value.id)).map(project) : [];
|
|
46
|
+
|
|
47
|
+
function entity(value) {
|
|
48
|
+
const result = {
|
|
49
|
+
...pick(value, { ...base, artifactId: safeId, qualifiedName: safeLabel, classification }),
|
|
50
|
+
label: safeLabel(value.label) ? value.label : 'Entity',
|
|
51
|
+
parentId: safeId(value.parentId) ? value.parentId : null,
|
|
52
|
+
sourceRefs: refs(value.sourceRefs),
|
|
53
|
+
};
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
function relation(value) {
|
|
57
|
+
return { ...pick(value, { ...base, source: safeId, target: safeId }), sourceRefs: refs(value.sourceRefs) };
|
|
58
|
+
}
|
|
59
|
+
function interpretation(value) {
|
|
60
|
+
return {
|
|
61
|
+
...pick(value, { ...base, namespace: safeId, version: safeId, classification,
|
|
62
|
+
support: value => ['supported', 'tentative', 'unknown', 'contradicted'].includes(value) }),
|
|
63
|
+
label: safeLabel(value.label) ? value.label : 'Interpretation',
|
|
64
|
+
entityIds: ids(value.entityIds), sourceRefs: refs(value.sourceRefs),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function activity(value) {
|
|
68
|
+
return {
|
|
69
|
+
...pick(value, { id: safeId, kind: token, sessionId: safeId, agentId: safeId, toolCallId: safeId,
|
|
70
|
+
sequence: integer, knownAtSequence: integer, at: timestamp, recordedAt: timestamp,
|
|
71
|
+
toolCategory: value => ['read', 'write', 'edit', 'shell', 'search', 'test', 'other'].includes(value),
|
|
72
|
+
attribution: value => ['observed', 'correlated', 'unknown'].includes(value),
|
|
73
|
+
outcome: value => ['pending', 'succeeded', 'failed', 'interrupted', 'denied', 'unresolved', 'observed'].includes(value),
|
|
74
|
+
creation: value => typeof value === 'boolean' }),
|
|
75
|
+
entityIds: ids(value.entityIds), artifactIds: ids(value.artifactIds), sourceRefs: refs(value.sourceRefs),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const counts = Object.fromEntries(['total', 'inventoried', 'inspected', 'parsed', 'deferred', 'excluded',
|
|
79
|
+
'unsupported', 'unavailable', 'truncated', 'retained', 'files', 'entities', 'relations']
|
|
80
|
+
.map(key => [key, integer]));
|
|
81
|
+
function coverage(value) {
|
|
82
|
+
if (!plain(value)) return {};
|
|
83
|
+
return {
|
|
84
|
+
...pick(value, { ...counts, complete: value => typeof value === 'boolean', status: token }),
|
|
85
|
+
...(plain(value.counts) ? { counts: pick(value.counts, counts) } : {}),
|
|
86
|
+
scopes: map(value.scopes, 1024, item => ({
|
|
87
|
+
...pick(item, { id: safeId, entityId: safeId, parentId: safeId, status: token, ...counts }),
|
|
88
|
+
label: safeLabel(item.label) ? item.label : 'Scope',
|
|
89
|
+
})),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Pure API-v1 disclosure ceiling. `snapshot` MUST already reflect the current
|
|
95
|
+
* core policy, including when replaying. Obtain `grant` from getGrant for every
|
|
96
|
+
* delivery; a copied grant is not a bearer credential or a live authorization.
|
|
97
|
+
* Denied/unapproved/cross-project grants return null, never an empty snapshot.
|
|
98
|
+
*/
|
|
99
|
+
export function getExtensionDataProjection(snapshot, grant) {
|
|
100
|
+
try { validateGrant(grant); } catch { return null; }
|
|
101
|
+
if (!grant.approved || !plain(snapshot) || snapshot.projectId !== grant.projectId) return null;
|
|
102
|
+
check(snapshot.schemaVersion === 2 && safeId(snapshot.projectId) &&
|
|
103
|
+
integer(snapshot.revision) && integer(snapshot.sequence), 'invalid_model_snapshot');
|
|
104
|
+
if ((snapshot.replay === true || snapshot.checkpointId !== undefined) && !grant.history) return null;
|
|
105
|
+
const fields = new Set(grant.fields);
|
|
106
|
+
const result = {
|
|
107
|
+
schemaVersion: 2, projectId: snapshot.projectId, revision: snapshot.revision, sequence: snapshot.sequence,
|
|
108
|
+
entities: fields.has('entities') ? map(snapshot.entities, L.entities, entity) : [],
|
|
109
|
+
relations: fields.has('relations') && fields.has('entities') ? map(snapshot.relations, L.relations, relation) : [],
|
|
110
|
+
interpretations: fields.has('interpretations') && fields.has('entities')
|
|
111
|
+
? map(snapshot.interpretations, 512, interpretation) : [],
|
|
112
|
+
activity: fields.has('activity') ? map(snapshot.activity, 2048, activity) : [],
|
|
113
|
+
coverage: fields.has('coverage') ? coverage(snapshot.coverage) : {},
|
|
114
|
+
sessions: fields.has('sessions') ? map(snapshot.sessions, 64, value => pick(value, {
|
|
115
|
+
id: safeId, host: value => ['claude', 'codex', 'kiro', 'demo', 'unknown'].includes(value),
|
|
116
|
+
startedAt: timestamp, endedAt: timestamp, status: token,
|
|
117
|
+
})) : [],
|
|
118
|
+
checkpoints: fields.has('checkpoints') && grant.history ? map(snapshot.checkpoints, 64, value => ({
|
|
119
|
+
...pick(value, { id: safeId, revision: integer, sequence: integer, at: timestamp, createdAt: timestamp }),
|
|
120
|
+
label: safeLabel(value.label) ? value.label : 'Checkpoint',
|
|
121
|
+
})) : [],
|
|
122
|
+
};
|
|
123
|
+
const entityIds = new Set(result.entities.map(value => value.id));
|
|
124
|
+
for (const value of result.entities) if (!entityIds.has(value.parentId)) value.parentId = null;
|
|
125
|
+
result.relations = result.relations.filter(value => entityIds.has(value.source) && entityIds.has(value.target));
|
|
126
|
+
result.interpretations = result.interpretations.filter(value => value.entityIds.every(entityId => entityIds.has(entityId)));
|
|
127
|
+
for (const value of result.activity) value.entityIds = value.entityIds.filter(entityId => entityIds.has(entityId));
|
|
128
|
+
jsonBytes(result, L.projectionBytes, 'extension_projection_limit');
|
|
129
|
+
return result;
|
|
130
|
+
}
|