@gregiteen/ssss-cli 0.13.1 → 0.14.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/CHANGELOG.md +38 -0
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +1 -1
- package/docs/ssss-spec.md +121 -16
- package/package.json +1 -1
- package/registry/core.json +14 -0
- package/scripts/audit-registry-field-usage.mjs +3 -0
- package/scripts/cmd-adapter.mjs +2 -0
- package/scripts/cmd-bridge.mjs +113 -0
- package/scripts/cmd-import.mjs +21 -1
- package/scripts/cmd-provision.mjs +33 -2
- package/scripts/cmd-serve.mjs +155 -0
- package/scripts/cmd-upgrade.mjs +110 -0
- package/scripts/conformance-09.mjs +139 -0
- package/scripts/conformance.mjs +473 -1
- package/scripts/ssss.mjs +7 -1
- package/skills/ssss/SKILL.md +2 -2
- package/skills/ssss/core/SKILL.md +3 -3
- package/skills/ssss/core/references/core-registry.json +14 -0
- package/skills/ssss/core/references/ssss-spec.md +121 -16
- package/src/authorization.mjs +58 -7
- package/src/bundle.mjs +469 -27
- package/src/engine.mjs +11 -1
- package/src/kernel.mjs +30 -5
- package/src/projections.mjs +93 -0
- package/src/registry.mjs +82 -3
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssss serve — reference HTTP server for the SSSS Operation Contract (spec §6.8).
|
|
3
|
+
* Dependency-free implementation using Node's built-in http module.
|
|
4
|
+
*/
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { createEngine } from '../src/engine.mjs';
|
|
10
|
+
import { statusForResponse } from '../src/http.mjs';
|
|
11
|
+
import { parseArgs, wantsHelp } from './lib/cli.mjs';
|
|
12
|
+
|
|
13
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const VERSION = (() => {
|
|
15
|
+
try { return fs.readFileSync(path.resolve(HERE, '..', 'VERSION'), 'utf8').trim(); }
|
|
16
|
+
catch { return '0.0.0'; }
|
|
17
|
+
})();
|
|
18
|
+
|
|
19
|
+
export function createServer(options = {}) {
|
|
20
|
+
const vaultRoot = path.resolve(options.vault || '.');
|
|
21
|
+
const engine = options.engine || createEngine({
|
|
22
|
+
registryDir: options.registryDir,
|
|
23
|
+
extensionFile: options.extensionFile,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
let expectedToken = null;
|
|
27
|
+
if (options.token) {
|
|
28
|
+
expectedToken = options.token.trim();
|
|
29
|
+
} else if (options.tokenFile) {
|
|
30
|
+
expectedToken = fs.readFileSync(path.resolve(options.tokenFile), 'utf8').trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const server = http.createServer(async (req, res) => {
|
|
34
|
+
res.setHeader('Content-Type', 'application/json');
|
|
35
|
+
|
|
36
|
+
if (req.method === 'GET' && (req.url === '/health' || req.url === '/healthz')) {
|
|
37
|
+
res.writeHead(200);
|
|
38
|
+
res.end(JSON.stringify({ ok: true, version: VERSION }));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hostHeader = req.headers.host || 'localhost';
|
|
43
|
+
const parsedUrl = new URL(req.url, `http://${hostHeader}`);
|
|
44
|
+
|
|
45
|
+
if (req.method !== 'POST' || (parsedUrl.pathname !== '/v1/operations' && parsedUrl.pathname !== '/')) {
|
|
46
|
+
res.writeHead(404);
|
|
47
|
+
res.end(JSON.stringify({
|
|
48
|
+
success: false,
|
|
49
|
+
error: { code: 'not_found', message: `Route '${req.method} ${parsedUrl.pathname}' not found.` },
|
|
50
|
+
}));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (expectedToken) {
|
|
55
|
+
const authHeader = req.headers.authorization || '';
|
|
56
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
57
|
+
const token = match ? match[1].trim() : null;
|
|
58
|
+
if (!token || token !== expectedToken) {
|
|
59
|
+
res.writeHead(401);
|
|
60
|
+
res.end(JSON.stringify({
|
|
61
|
+
success: false,
|
|
62
|
+
error: { code: 'unauthorized', message: 'Missing or invalid bearer token.' },
|
|
63
|
+
}));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const chunks = [];
|
|
69
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
70
|
+
req.on('end', async () => {
|
|
71
|
+
let payload;
|
|
72
|
+
try {
|
|
73
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
74
|
+
payload = JSON.parse(text);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
res.writeHead(400);
|
|
77
|
+
res.end(JSON.stringify({
|
|
78
|
+
success: false,
|
|
79
|
+
error: { code: 'invalid_request', message: `Malformed JSON: ${err.message}` },
|
|
80
|
+
}));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const envelope = payload?.envelope || payload;
|
|
85
|
+
const callOptions = {};
|
|
86
|
+
if (payload?.principal) callOptions.principal = payload.principal;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const response = await engine.processOperation(envelope, vaultRoot, callOptions);
|
|
90
|
+
const status = statusForResponse(response);
|
|
91
|
+
res.writeHead(status);
|
|
92
|
+
res.end(JSON.stringify(response));
|
|
93
|
+
} catch (err) {
|
|
94
|
+
res.writeHead(500);
|
|
95
|
+
res.end(JSON.stringify({
|
|
96
|
+
success: false,
|
|
97
|
+
error: { code: 'internal_error', message: err.message },
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
req.on('error', (err) => {
|
|
103
|
+
if (!res.headersSent) {
|
|
104
|
+
res.writeHead(500);
|
|
105
|
+
res.end(JSON.stringify({ success: false, error: { code: 'internal_error', message: err.message } }));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return server;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function run(argv) {
|
|
114
|
+
if (wantsHelp(argv)) {
|
|
115
|
+
console.log(`Usage: ssss serve [options]
|
|
116
|
+
|
|
117
|
+
Start a reference HTTP server exposing the SSSS Operation Contract (POST /v1/operations).
|
|
118
|
+
|
|
119
|
+
Options:
|
|
120
|
+
--vault <dir> Path to vault directory (default: '.')
|
|
121
|
+
--port <num> Port to listen on (default: 8443, 0 for dynamic)
|
|
122
|
+
--host <ip> Host address to bind to (default: 127.0.0.1)
|
|
123
|
+
--token <pat> Optional bearer token required for requests
|
|
124
|
+
--token-file <file> Optional path to bearer token file
|
|
125
|
+
--registry <dir> Path to registry directory
|
|
126
|
+
--extension <file> Path to extension registry file
|
|
127
|
+
--quiet Suppress listening message
|
|
128
|
+
--help, -h Show help`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { flags } = parseArgs(argv, { booleans: ['quiet'] });
|
|
133
|
+
const vault = flags.vault || '.';
|
|
134
|
+
const port = flags.port != null ? parseInt(flags.port, 10) : 8443;
|
|
135
|
+
const host = flags.host || '127.0.0.1';
|
|
136
|
+
const token = flags.token;
|
|
137
|
+
const tokenFile = flags['token-file'];
|
|
138
|
+
const registryDir = flags.registry;
|
|
139
|
+
const extensionFile = flags.extension;
|
|
140
|
+
|
|
141
|
+
const server = createServer({ vault, token, tokenFile, registryDir, extensionFile });
|
|
142
|
+
server.listen(port, host, () => {
|
|
143
|
+
const addr = server.address();
|
|
144
|
+
const actualPort = typeof addr === 'object' ? addr.port : port;
|
|
145
|
+
if (!flags.quiet) {
|
|
146
|
+
console.log(`SSSS reference server listening on http://${host}:${actualPort} (vault: ${path.resolve(vault)})`);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const close = () => {
|
|
151
|
+
server.close(() => process.exit(0));
|
|
152
|
+
};
|
|
153
|
+
process.on('SIGINT', close);
|
|
154
|
+
process.on('SIGTERM', close);
|
|
155
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ssss upgrade` — move an installed capability bundle to a new version via
|
|
3
|
+
* structural-only patch envelopes (spec §17.4). Refuses any tenant_private
|
|
4
|
+
* modification.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { exportBundle, upgradeBundle, importBundle } from '../src/bundle.mjs';
|
|
9
|
+
import { createEngine } from '../src/engine.mjs';
|
|
10
|
+
import { parseArgs, usage, die, wantsHelp } from './lib/cli.mjs';
|
|
11
|
+
|
|
12
|
+
const HELP = usage({
|
|
13
|
+
summary: 'Upgrade an installed bundle to a new version via structural-only patches (§17.4).',
|
|
14
|
+
usage: 'ssss upgrade <bundle.ucw.json> --vault <dir> [options]',
|
|
15
|
+
options: [
|
|
16
|
+
['--vault <dir>', 'Target vault directory. Required.'],
|
|
17
|
+
['--from <file>', 'Baseline bundle or plan (if omitted, exported from vault).'],
|
|
18
|
+
['--workspace <id>', 'Target workspace id (default: ws-upgrade).'],
|
|
19
|
+
['--prefix <path>', 'Path prefix for the bundle within the vault.'],
|
|
20
|
+
['--dry-run', 'Validate every patch envelope without committing.'],
|
|
21
|
+
['--json', 'Emit machine-readable JSON upgrade summary.'],
|
|
22
|
+
['--registry <dir>', 'Registry directory (default: core registry).'],
|
|
23
|
+
],
|
|
24
|
+
examples: [
|
|
25
|
+
'ssss upgrade festival-v2.ucw.json --vault ./tenant',
|
|
26
|
+
'ssss upgrade festival-v2.ucw.json --vault ./tenant --dry-run',
|
|
27
|
+
'ssss upgrade festival-v2.ucw.json --vault ./tenant --from festival-v1.ucw.json',
|
|
28
|
+
],
|
|
29
|
+
seeAlso: ['ssss import', 'ssss provision', 'ssss export'],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export async function run(argv) {
|
|
33
|
+
if (wantsHelp(argv)) { console.log(HELP); return; }
|
|
34
|
+
const { positionals, flags } = parseArgs(argv, { booleans: ['dry-run', 'json'] });
|
|
35
|
+
const file = positionals[0];
|
|
36
|
+
if (!file) die('upgrade needs a new bundle file.\n\n' + HELP);
|
|
37
|
+
if (!fs.existsSync(file)) die(`no such file: ${file}`);
|
|
38
|
+
if (!flags.vault) die('upgrade needs --vault <dir>.\n\n' + HELP);
|
|
39
|
+
|
|
40
|
+
const vault = path.resolve(flags.vault);
|
|
41
|
+
if (!fs.existsSync(vault)) die(`no such vault directory: ${vault}`);
|
|
42
|
+
|
|
43
|
+
let nextBundle;
|
|
44
|
+
try { nextBundle = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
45
|
+
catch (err) { die(`bundle is not valid JSON: ${err.message}`); }
|
|
46
|
+
|
|
47
|
+
let installedBundle;
|
|
48
|
+
if (flags.from) {
|
|
49
|
+
if (!fs.existsSync(flags.from)) die(`no such baseline file: ${flags.from}`);
|
|
50
|
+
try { installedBundle = JSON.parse(fs.readFileSync(flags.from, 'utf8')); }
|
|
51
|
+
catch (err) { die(`baseline file is not valid JSON: ${err.message}`); }
|
|
52
|
+
} else {
|
|
53
|
+
try {
|
|
54
|
+
installedBundle = exportBundle(vault, {
|
|
55
|
+
profile: 'backup',
|
|
56
|
+
registryDir: flags.registry,
|
|
57
|
+
});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
die(`failed to inspect current vault state: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const upgrade = upgradeBundle(installedBundle, nextBundle, {
|
|
64
|
+
workspaceId: flags.workspace || 'ws-upgrade',
|
|
65
|
+
pathPrefix: flags.prefix || '',
|
|
66
|
+
dryRun: !!flags['dry-run'],
|
|
67
|
+
registryDir: flags.registry,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!upgrade.ok) {
|
|
71
|
+
for (const e of upgrade.errors) console.error(`✗ upgrade error: ${e}`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const declared = nextBundle?.manifest?.required_extensions;
|
|
76
|
+
const registryNames = Array.isArray(declared) && declared.length ? declared : null;
|
|
77
|
+
const engine = createEngine({ registryDir: flags.registry, ...(registryNames ? { registryNames } : {}) });
|
|
78
|
+
|
|
79
|
+
const result = await importBundle(upgrade.plan, vault, engine);
|
|
80
|
+
const failed = result.results.filter((r) => !r.success);
|
|
81
|
+
|
|
82
|
+
if (flags.json) {
|
|
83
|
+
const summary = {
|
|
84
|
+
ok: result.ok,
|
|
85
|
+
from_version: upgrade.fromVersion,
|
|
86
|
+
to_version: upgrade.toVersion,
|
|
87
|
+
migration: upgrade.migration,
|
|
88
|
+
patches_count: upgrade.patchesCount,
|
|
89
|
+
operations_count: upgrade.operationsCount,
|
|
90
|
+
committed: result.committed,
|
|
91
|
+
would_commit: result.wouldCommit,
|
|
92
|
+
failed_count: failed.length,
|
|
93
|
+
dry_run: !!flags['dry-run'],
|
|
94
|
+
};
|
|
95
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
96
|
+
if (!result.ok) process.exit(1);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (flags['dry-run']) {
|
|
101
|
+
console.log(`${result.ok ? '✓' : '✗'} upgrade dry-run ${upgrade.fromVersion} → ${upgrade.toVersion}: ${result.wouldCommit || 0} would apply, ${upgrade.plan.length - (result.wouldCommit || 0)} unchanged, ${failed.length} failed → ${vault}`);
|
|
102
|
+
} else {
|
|
103
|
+
console.log(`${result.ok ? '✓' : '✗'} upgrade ${upgrade.fromVersion} → ${upgrade.toVersion}: ${result.committed} committed, ${upgrade.plan.length - result.committed} unchanged, ${failed.length} failed → ${vault}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (failed.length) {
|
|
107
|
+
for (const r of failed) console.error(` ✗ ${r.path}: ${(r.validation?.errors || []).join('; ')}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -303,6 +303,12 @@ async function checkCapabilities(root, check) {
|
|
|
303
303
|
capabilityCovers('ssss:assistant:*', 'ssss:assistant:create') && !capabilityCovers('ssss:assistant:*', 'ssss:rule:create'));
|
|
304
304
|
check('capabilities: a namespace wildcard covers its namespace only',
|
|
305
305
|
capabilityCovers('ssss:*', 'ssss:rule:patch') && !capabilityCovers('acme:*', 'ssss:rule:patch') && !capabilityCovers('ss:*', 'ssss:rule:patch'));
|
|
306
|
+
check('capabilities: path selector @<glob> grants matching path',
|
|
307
|
+
capabilityCovers('ssss:rule:*@rules/marketing/**', 'ssss:rule:create', 'rules/marketing/promo.md')
|
|
308
|
+
&& !capabilityCovers('ssss:rule:*@rules/marketing/**', 'ssss:rule:create', 'rules/finance/audit.md'));
|
|
309
|
+
check('capabilities: path selector @<glob> with *:* grants only matching paths',
|
|
310
|
+
capabilityCovers('*:*@capabilities/messaging/**', 'ssss:rule:create', 'capabilities/messaging/rules.md')
|
|
311
|
+
&& !capabilityCovers('*:*@capabilities/messaging/**', 'ssss:rule:create', 'capabilities/other/rules.md'));
|
|
306
312
|
|
|
307
313
|
const engine = createEngine();
|
|
308
314
|
const vault = path.join(root, 'legacy-roles');
|
|
@@ -313,11 +319,123 @@ async function checkCapabilities(root, check) {
|
|
|
313
319
|
await write('lr-1', 'roles/editor/ROLE.md', role('editor', ['"write:rule"']), 'system');
|
|
314
320
|
await write('lr-2', 'roles/owner/ROLE.md', role('owner', ['"write:*"']), 'system');
|
|
315
321
|
await write('lr-3', 'roles/typed/ROLE.md', role('typed', ['"*:assistant"']), 'system');
|
|
322
|
+
await write('lr-rw', 'roles/rolewriter/ROLE.md', role('rolewriter', ['"write:role"']), 'system');
|
|
316
323
|
check('legacy roles: write:<type> grants writes to that type', (await write('lr-4', 'rules/a.md', doc('rule', 'A'), 'editor')).success);
|
|
317
324
|
check('legacy roles: write:<type> denies other types', (await write('lr-5', 'assistants/b/ASSISTANT.md', doc('assistant', 'B'), 'editor')).error?.code === 'forbidden');
|
|
318
325
|
check('legacy roles: *:<type> grants writes to that type', (await write('lr-6', 'assistants/c/ASSISTANT.md', doc('assistant', 'C'), 'typed')).success);
|
|
319
326
|
check('legacy roles: write:* grants writes to every type', (await write('lr-7', 'workflows/d/WORKFLOW.md', doc('workflow', 'D'), 'owner')).success);
|
|
320
327
|
check('legacy roles: an unknown role is forbidden, not promoted', (await write('lr-8', 'rules/e.md', doc('rule', 'E'), 'ghost')).error?.code === 'forbidden');
|
|
328
|
+
check('legacy roles: write:role grants writes to security_role (alias unification)',
|
|
329
|
+
(await write('lr-aliased-1', 'roles/sec/ROLE.md', role('sec', ['"write:rule"']), 'rolewriter')).success);
|
|
330
|
+
|
|
331
|
+
const roleDoc = (name, permissions) => `---\ntype: role\ntitle: "${name}"\ndescription: "Canonical role."\ntimestamp: "2026-09-24T00:00:00Z"\nname: "${name}"\npermissions: [${permissions.join(', ')}]\n---\n`;
|
|
332
|
+
const replaceRes = await write('lr-aliased-replace', 'roles/sec/ROLE.md', roleDoc('sec', ['"write:rule"', '"write:task"']), 'rolewriter');
|
|
333
|
+
check('kernel mutation: replace security_role with role succeeds (alias unification)', replaceRes.success);
|
|
334
|
+
|
|
335
|
+
const illegalTypeChange = await write('lr-illegal-type', 'roles/sec/ROLE.md', doc('assistant', 'SecAsst'), 'owner');
|
|
336
|
+
check('kernel mutation: replace role with assistant fails closed',
|
|
337
|
+
illegalTypeChange.error?.code === 'validation_failed' &&
|
|
338
|
+
illegalTypeChange.error?.message?.includes('Operation may not change an existing document type.'));
|
|
339
|
+
|
|
340
|
+
const convDoc = `---\ntype: conversation\ntitle: "Thread"\ndescription: "Chat transcript."\ntimestamp: "2026-09-24T00:00:00Z"\nthread_id: "t-1"\n---\n\nInitial message.\n`;
|
|
341
|
+
await engine.processOperation({ type: 'operation', workspace_id: 'w', idempotency_key: 'conv-init', path: 'threads/t1/CONVERSATION.md', content: convDoc, actor: { role: 'system' } }, vault);
|
|
342
|
+
const patchRes = await engine.processOperation({
|
|
343
|
+
type: 'patch', workspace_id: 'w', idempotency_key: 'conv-patch-alias', path: 'threads/t1/CONVERSATION.md',
|
|
344
|
+
patches: { type: 'thread', __body__: 'New reply.' },
|
|
345
|
+
actor: { role: 'system' },
|
|
346
|
+
}, vault);
|
|
347
|
+
check('kernel mutation: patch conversation with type thread succeeds (alias unification)', patchRes.success);
|
|
348
|
+
|
|
349
|
+
const regTypes = engine._registry.types;
|
|
350
|
+
check('core registry: release, model, page, conflict declare deprecated: true',
|
|
351
|
+
regTypes.get('release')?.deprecated === true &&
|
|
352
|
+
regTypes.get('model')?.deprecated === true &&
|
|
353
|
+
regTypes.get('page')?.deprecated === true &&
|
|
354
|
+
regTypes.get('conflict')?.deprecated === true);
|
|
355
|
+
|
|
356
|
+
let selfAliasRejected = false;
|
|
357
|
+
try {
|
|
358
|
+
composeRegistries({
|
|
359
|
+
core: {
|
|
360
|
+
registry: 'ssss',
|
|
361
|
+
version: '0.14.0',
|
|
362
|
+
universal_fields: ['type'],
|
|
363
|
+
portability: { classes: { structural: {} } },
|
|
364
|
+
document_primitives: {
|
|
365
|
+
rule: { family: 'core', append_only: false, portability: 'structural', required_fields: ['type'], aliases: ['rule'] },
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
extensions: [],
|
|
369
|
+
});
|
|
370
|
+
} catch (err) {
|
|
371
|
+
selfAliasRejected = err.message.includes('cannot alias itself');
|
|
372
|
+
}
|
|
373
|
+
check('core registry: self-alias on a primitive is rejected', selfAliasRejected);
|
|
374
|
+
|
|
375
|
+
let asymmetricAliasRejected = false;
|
|
376
|
+
try {
|
|
377
|
+
composeRegistries({
|
|
378
|
+
core: {
|
|
379
|
+
registry: 'ssss',
|
|
380
|
+
version: '0.14.0',
|
|
381
|
+
universal_fields: ['type'],
|
|
382
|
+
portability: { classes: { structural: {} } },
|
|
383
|
+
document_primitives: {
|
|
384
|
+
rule: { family: 'core', append_only: false, portability: 'structural', required_fields: ['type'], aliases: ['assistant'] },
|
|
385
|
+
assistant: { family: 'core', append_only: false, portability: 'structural', required_fields: ['type'] },
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
extensions: [],
|
|
389
|
+
});
|
|
390
|
+
} catch (err) {
|
|
391
|
+
asymmetricAliasRejected = err.message.includes('collides with existing primitive');
|
|
392
|
+
}
|
|
393
|
+
check('core registry: non-mutual core alias collision is rejected', asymmetricAliasRejected);
|
|
394
|
+
|
|
395
|
+
const floorAuthorizer = createCapabilityAuthorizer({
|
|
396
|
+
policyFloors: { 'ssss:role': { create: ['governance:elevated'] } },
|
|
397
|
+
});
|
|
398
|
+
const secRoleDef = regTypes.get('security_role');
|
|
399
|
+
const floorBlocked = await floorAuthorizer({
|
|
400
|
+
principal: {
|
|
401
|
+
id: 'u1',
|
|
402
|
+
kind: 'agent',
|
|
403
|
+
workspaceIds: ['w'],
|
|
404
|
+
capabilities: ['ssss:security_role:create'],
|
|
405
|
+
authentication: { provider: 'token', assurance: 'verified' },
|
|
406
|
+
},
|
|
407
|
+
workspaceId: 'w',
|
|
408
|
+
definition: secRoleDef,
|
|
409
|
+
action: 'create',
|
|
410
|
+
});
|
|
411
|
+
const floorAllowed = await floorAuthorizer({
|
|
412
|
+
principal: {
|
|
413
|
+
id: 'u1',
|
|
414
|
+
kind: 'agent',
|
|
415
|
+
workspaceIds: ['w'],
|
|
416
|
+
capabilities: ['ssss:security_role:create', 'governance:elevated'],
|
|
417
|
+
authentication: { provider: 'token', assurance: 'verified' },
|
|
418
|
+
},
|
|
419
|
+
workspaceId: 'w',
|
|
420
|
+
definition: secRoleDef,
|
|
421
|
+
action: 'create',
|
|
422
|
+
});
|
|
423
|
+
check('authorization: policyFloors on ssss:role apply when mutating security_role alias',
|
|
424
|
+
floorBlocked.allowed === false && floorBlocked.missing?.includes('governance:elevated') && floorAllowed.allowed === true);
|
|
425
|
+
|
|
426
|
+
const pathPrincipal = {
|
|
427
|
+
id: 'scoped-user',
|
|
428
|
+
kind: 'agent',
|
|
429
|
+
workspaceIds: ['w'],
|
|
430
|
+
capabilities: ['ssss:rule:*@rules/marketing/**'],
|
|
431
|
+
authentication: { provider: 'token', assurance: 'verified' },
|
|
432
|
+
};
|
|
433
|
+
const writeScoped = (key, pathName, content) => engine.processOperation(
|
|
434
|
+
{ type: 'operation', workspace_id: 'w', idempotency_key: key, path: pathName, content }, vault, { principal: pathPrincipal });
|
|
435
|
+
check('kernel authorization: path-scoped grant allows matching path (G5)',
|
|
436
|
+
(await writeScoped('ps-1', 'rules/marketing/banner.md', doc('rule', 'Banner'))).success);
|
|
437
|
+
check('kernel authorization: path-scoped grant forbids non-matching path (G5)',
|
|
438
|
+
(await writeScoped('ps-2', 'rules/engineering/lint.md', doc('rule', 'Lint'))).error?.code === 'forbidden');
|
|
321
439
|
}
|
|
322
440
|
|
|
323
441
|
// Resource hooks (spec §6.3): every prepare ends in exactly one finalize or
|
|
@@ -412,6 +530,27 @@ async function checkResourceLifecycle(check) {
|
|
|
412
530
|
check('references: a reference to a disallowed type is rejected', wrongType.error?.code === 'validation_failed');
|
|
413
531
|
check('references: a patch cannot repoint a reference at a missing document', repointed.error?.code === 'validation_failed');
|
|
414
532
|
|
|
533
|
+
const withRoleRefs = createValidator({ extensions: [{
|
|
534
|
+
registry: 'acme', extends: 'core',
|
|
535
|
+
document_primitives: {
|
|
536
|
+
grant_doc: {
|
|
537
|
+
family: 'extension', append_only: false, portability: 'structural', required_fields: ['type', 'name', 'role_ref'],
|
|
538
|
+
references: { role_ref: { allowed_types: ['role'] } },
|
|
539
|
+
},
|
|
540
|
+
deny_role_doc: {
|
|
541
|
+
family: 'extension', append_only: false, portability: 'structural', required_fields: ['type', 'name', 'target_ref'],
|
|
542
|
+
references: { target_ref: { disallowed_types: ['role'] } },
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
}] });
|
|
546
|
+
const roleRefKernel = createKernel({ vfs: new MemoryVfs(), eventStore: new MemoryEventStore(), validator: withRoleRefs });
|
|
547
|
+
const writeRef = (key, pathName, content) => roleRefKernel.execute({ type: 'operation', workspace_id: 'w', idempotency_key: key, path: pathName, content }, { principal });
|
|
548
|
+
await writeRef('rr-1', 'roles/admin.md', `---\ntype: security_role\ntitle: "Admin"\ndescription: "D"\ntimestamp: "2026-09-24T00:00:00Z"\nrole_id: "admin"\nname: "Admin"\npermissions: ["*:*"]\n---\n`);
|
|
549
|
+
const allowedViaAlias = await writeRef('rr-2', 'grants/g1.md', `---\ntype: grant_doc\ntitle: "G"\ndescription: "D"\ntimestamp: "2026-09-24T00:00:00Z"\nname: "G"\nrole_ref: "roles/admin.md"\n---\n`);
|
|
550
|
+
const disallowedViaAlias = await writeRef('rr-3', 'denies/d1.md', `---\ntype: deny_role_doc\ntitle: "D"\ndescription: "D"\ntimestamp: "2026-09-24T00:00:00Z"\nname: "D"\ntarget_ref: "roles/admin.md"\n---\n`);
|
|
551
|
+
check('references: allowed_types role accepts aliased security_role target', allowedViaAlias.success);
|
|
552
|
+
check('references: disallowed_types role rejects aliased security_role target', disallowedViaAlias.error?.code === 'validation_failed');
|
|
553
|
+
|
|
415
554
|
// Every failure carries a symbolic code that maps to the §6.5 status table.
|
|
416
555
|
const plain = createKernel({ vfs: new MemoryVfs(), eventStore: new MemoryEventStore(), leaseStore: new MemoryLeaseStore() });
|
|
417
556
|
const missing = await plain.execute({ type: 'patch', workspace_id: 'w', idempotency_key: 'ec-404', path: 'rules/none.md', patches: { name: 'x' } }, { principal });
|