@aiwg/cli 2026.8.26 → 2026.8.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/aiwg.mjs +6 -0
- package/dist/src/a2a/client.js +4 -1
- package/dist/src/a2a/codecs.js +5 -2
- package/dist/src/a2a/protocol.js +12 -1
- package/dist/src/activity-log/cli.js +4 -1
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +1 -0
- package/dist/src/artifacts/backends/graphology-backend.js +11 -2
- package/dist/src/artifacts/backends/json-backend.js +12 -2
- package/dist/src/artifacts/backends/sqlite-backend.js +20 -7
- package/dist/src/artifacts/fortemi-core-sync.js +37 -0
- package/dist/src/artifacts/graph-backend.js +16 -0
- package/dist/src/audit/operator-decision.js +9 -25
- package/dist/src/features/catalog.js +3 -2
- package/dist/src/governance/boundary.js +354 -0
- package/dist/src/governance/classification.js +191 -0
- package/dist/src/governance/index.js +5 -0
- package/dist/src/governance/redaction.js +324 -0
- package/dist/src/governance/retention.js +274 -0
- package/dist/src/jobs/executor.js +2 -3
- package/dist/src/ops/cli.js +95 -0
- package/dist/src/serve/dispatch-router.js +1 -1
- package/dist/src/sessions/repository.js +8 -6
- package/dist/src/storage/backends/postgres.js +6 -1
- package/dist/src/storage/index.js +1 -1
- package/dist/src/storage/migration-protocol.js +228 -50
- package/dist/src/storage/qualification.js +39 -5
- package/package.json +1 -1
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createSanitizedSummary, evaluatePublicationGate, resolveArtifactGovernance, resolveClassificationDefinitions, } from './classification.js';
|
|
3
|
+
import { createEvidenceLifecycle, validateRetentionRules, } from './retention.js';
|
|
4
|
+
import { redactStructured, redactText, } from './redaction.js';
|
|
5
|
+
export const DEFAULT_PUBLICATION_SINKS = {
|
|
6
|
+
'local-ephemeral': {
|
|
7
|
+
id: 'local-ephemeral', visibility: 'restricted', external: false,
|
|
8
|
+
persistent: false, mutable: true, maxClassification: 'restricted-identity',
|
|
9
|
+
},
|
|
10
|
+
'private-repository': {
|
|
11
|
+
id: 'private-repository', visibility: 'private', external: false,
|
|
12
|
+
persistent: true, mutable: true, maxClassification: 'restricted-infrastructure',
|
|
13
|
+
},
|
|
14
|
+
'public-repository': {
|
|
15
|
+
id: 'public-repository', visibility: 'public', external: true,
|
|
16
|
+
persistent: true, mutable: true, maxClassification: 'public', acceptsSanitizedSummary: true,
|
|
17
|
+
},
|
|
18
|
+
'private-issue': {
|
|
19
|
+
id: 'private-issue', visibility: 'private', external: true,
|
|
20
|
+
persistent: true, mutable: false, maxClassification: 'confidential', acceptsSanitizedSummary: true,
|
|
21
|
+
},
|
|
22
|
+
'public-issue': {
|
|
23
|
+
id: 'public-issue', visibility: 'public', external: true,
|
|
24
|
+
persistent: true, mutable: false, maxClassification: 'public', acceptsSanitizedSummary: true,
|
|
25
|
+
},
|
|
26
|
+
'encrypted-artifact-store': {
|
|
27
|
+
id: 'encrypted-artifact-store', visibility: 'restricted', external: false,
|
|
28
|
+
persistent: true, mutable: true, maxClassification: 'restricted-identity',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
export function resolveGovernancePolicy(policy = {}) {
|
|
32
|
+
const resolved = {
|
|
33
|
+
...policy,
|
|
34
|
+
sinks: { ...DEFAULT_PUBLICATION_SINKS, ...(policy.sinks ?? {}) },
|
|
35
|
+
};
|
|
36
|
+
const classes = resolveClassificationDefinitions(resolved.classification);
|
|
37
|
+
const classificationReferences = [
|
|
38
|
+
resolved.classification?.defaultClassification,
|
|
39
|
+
...Object.values(resolved.classification?.defaultsByKind ?? {}),
|
|
40
|
+
...Object.values(resolved.classification?.defaultsByCategory ?? {}),
|
|
41
|
+
].filter((value) => value !== undefined);
|
|
42
|
+
for (const value of classificationReferences) {
|
|
43
|
+
if (!classes[value])
|
|
44
|
+
throw new Error(`governance policy references unknown classification '${value}'`);
|
|
45
|
+
}
|
|
46
|
+
for (const [id, sink] of Object.entries(resolved.sinks ?? {})) {
|
|
47
|
+
if (!sink || typeof sink !== 'object')
|
|
48
|
+
throw new Error(`sink '${id}' must be an object`);
|
|
49
|
+
if (sink.id !== id)
|
|
50
|
+
throw new Error(`sink map key '${id}' does not match sink ID '${sink.id}'`);
|
|
51
|
+
if (!new Set(['public', 'private', 'restricted', 'unknown']).has(sink.visibility)) {
|
|
52
|
+
throw new Error(`sink '${id}' has invalid visibility`);
|
|
53
|
+
}
|
|
54
|
+
for (const property of ['external', 'persistent', 'mutable']) {
|
|
55
|
+
if (typeof sink[property] !== 'boolean')
|
|
56
|
+
throw new Error(`sink '${id}' requires boolean ${property}`);
|
|
57
|
+
}
|
|
58
|
+
for (const property of ['acceptsSanitizedSummary', 'allowRedactionOverride']) {
|
|
59
|
+
if (sink[property] !== undefined && typeof sink[property] !== 'boolean') {
|
|
60
|
+
throw new Error(`sink '${id}' requires boolean ${property}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (sink.maxClassification && !classes[sink.maxClassification]) {
|
|
64
|
+
throw new Error(`sink '${id}' references unknown classification '${sink.maxClassification}'`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
validateRetentionRules(resolved.retention ?? []);
|
|
68
|
+
// Compile configured patterns up front. Empty input cannot create findings,
|
|
69
|
+
// but invalid or high-risk organization patterns still fail validation.
|
|
70
|
+
redactText('', resolved.redaction ?? {});
|
|
71
|
+
redactStructured({ validation: 'ok' }, resolved.redaction ?? {});
|
|
72
|
+
return resolved;
|
|
73
|
+
}
|
|
74
|
+
function sha256(value) {
|
|
75
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
76
|
+
}
|
|
77
|
+
function safeAuditLabel(value) {
|
|
78
|
+
try {
|
|
79
|
+
if (/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(value) && redactText(value).sensitivity === 'none')
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Hashing is the fail-closed representation for malformed labels.
|
|
84
|
+
}
|
|
85
|
+
return sha256(value);
|
|
86
|
+
}
|
|
87
|
+
function stableStatus(value) {
|
|
88
|
+
if (!value)
|
|
89
|
+
return undefined;
|
|
90
|
+
const normalized = value.toLowerCase().replaceAll('_', '-').replaceAll(' ', '-');
|
|
91
|
+
return new Set([
|
|
92
|
+
'ok', 'pass', 'passed', 'fail', 'failed', 'complete', 'completed', 'blocked',
|
|
93
|
+
'partial', 'unknown', 'in-progress', 'review-needed', 'success', 'error',
|
|
94
|
+
]).has(normalized) ? normalized : undefined;
|
|
95
|
+
}
|
|
96
|
+
function contentDigest(value) {
|
|
97
|
+
if (typeof value === 'string')
|
|
98
|
+
return sha256(value);
|
|
99
|
+
try {
|
|
100
|
+
return sha256(JSON.stringify(value) ?? '[undefined]');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return sha256('[unserializable]');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function boundedExcerpt(value, maxBytes) {
|
|
107
|
+
if (typeof value !== 'string')
|
|
108
|
+
return undefined;
|
|
109
|
+
const source = Buffer.from(value);
|
|
110
|
+
return {
|
|
111
|
+
excerpt: source.subarray(0, maxBytes).toString('utf8'),
|
|
112
|
+
bytes: source.length,
|
|
113
|
+
digest: sha256(value),
|
|
114
|
+
truncated: source.length > maxBytes,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Reduce command evidence to outcomes, bounded excerpts, counts, and correlation digests by default. */
|
|
118
|
+
export function minimizeEvidence(payload, maxExcerptBytes = 512) {
|
|
119
|
+
if (!Number.isSafeInteger(maxExcerptBytes) || maxExcerptBytes < 0 || maxExcerptBytes > 64 * 1024) {
|
|
120
|
+
throw new Error('maxExcerptBytes must be an integer from 0 through 65536');
|
|
121
|
+
}
|
|
122
|
+
if (typeof payload === 'string') {
|
|
123
|
+
return boundedExcerpt(payload, maxExcerptBytes);
|
|
124
|
+
}
|
|
125
|
+
if (Array.isArray(payload)) {
|
|
126
|
+
return { itemCount: payload.length, digest: contentDigest(payload) };
|
|
127
|
+
}
|
|
128
|
+
if (!payload || typeof payload !== 'object')
|
|
129
|
+
return payload;
|
|
130
|
+
const source = payload;
|
|
131
|
+
const result = {
|
|
132
|
+
schemaVersion: 'ops-minimum-evidence.aiwg.io/v1',
|
|
133
|
+
sourceFieldCount: Object.keys(source).length,
|
|
134
|
+
sourceDigest: contentDigest(source),
|
|
135
|
+
};
|
|
136
|
+
for (const key of ['status', 'outcome', 'success', 'exitCode', 'durationMs', 'startedAt', 'completedAt']) {
|
|
137
|
+
const value = source[key];
|
|
138
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
|
|
139
|
+
result[key] = value;
|
|
140
|
+
}
|
|
141
|
+
const stdout = boundedExcerpt(source.stdout ?? source.output, maxExcerptBytes);
|
|
142
|
+
const stderr = boundedExcerpt(source.stderr, maxExcerptBytes);
|
|
143
|
+
if (stdout)
|
|
144
|
+
result.stdout = stdout;
|
|
145
|
+
if (stderr)
|
|
146
|
+
result.stderr = stderr;
|
|
147
|
+
if (source.command !== undefined)
|
|
148
|
+
result.commandDigest = contentDigest(source.command);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
function validOverride(override, artifactId, sinkId, now) {
|
|
152
|
+
if (!override?.id || !override.actor || !override.reason.trim())
|
|
153
|
+
return false;
|
|
154
|
+
if (override.artifactId !== artifactId || override.sinkId !== sinkId)
|
|
155
|
+
return false;
|
|
156
|
+
const approvedAt = Date.parse(override.approvedAt);
|
|
157
|
+
if (!Number.isFinite(approvedAt) || approvedAt > now)
|
|
158
|
+
return false;
|
|
159
|
+
if (override.expiresAt !== undefined) {
|
|
160
|
+
const expiresAt = Date.parse(override.expiresAt);
|
|
161
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now || expiresAt <= approvedAt)
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
function emptyAudit(input, sinkId, now) {
|
|
167
|
+
return {
|
|
168
|
+
schemaVersion: 'ops-evidence-boundary.aiwg.io/v1',
|
|
169
|
+
eventId: randomUUID(),
|
|
170
|
+
occurredAt: now.toISOString(),
|
|
171
|
+
artifactId: sha256(input.id),
|
|
172
|
+
artifactKind: safeAuditLabel(input.kind),
|
|
173
|
+
sinkId: safeAuditLabel(sinkId),
|
|
174
|
+
decision: 'deny',
|
|
175
|
+
reasonCodes: [],
|
|
176
|
+
redaction: 'not-needed',
|
|
177
|
+
redactionCount: 0,
|
|
178
|
+
redactionClasses: [],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function sanitizePayload(payload, options) {
|
|
182
|
+
if (typeof payload === 'string') {
|
|
183
|
+
const result = redactText(payload, options);
|
|
184
|
+
return { value: result.text, findings: result.findings };
|
|
185
|
+
}
|
|
186
|
+
const result = redactStructured(payload, options);
|
|
187
|
+
return { value: result.value, findings: result.findings };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Mandatory sink boundary: minimize, redact, classify/gate, and attach lifecycle
|
|
191
|
+
* metadata before returning any publishable value. Failure returns no payload.
|
|
192
|
+
*/
|
|
193
|
+
export function prepareEvidenceForSink(input) {
|
|
194
|
+
const now = input.now ?? new Date();
|
|
195
|
+
const audit = emptyAudit(input.artifact, input.sinkId, now);
|
|
196
|
+
let policy;
|
|
197
|
+
try {
|
|
198
|
+
policy = resolveGovernancePolicy(input.policy);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
audit.reasonCodes = ['invalid-governance-policy'];
|
|
202
|
+
return { allowed: false, audit };
|
|
203
|
+
}
|
|
204
|
+
const sink = policy.sinks?.[input.sinkId];
|
|
205
|
+
if (!sink) {
|
|
206
|
+
audit.reasonCodes = ['unknown-sink'];
|
|
207
|
+
audit.redaction = 'failed';
|
|
208
|
+
return { allowed: false, audit };
|
|
209
|
+
}
|
|
210
|
+
let governance;
|
|
211
|
+
try {
|
|
212
|
+
governance = resolveArtifactGovernance({
|
|
213
|
+
kind: input.artifact.kind,
|
|
214
|
+
category: input.artifact.category,
|
|
215
|
+
metadata: input.artifact.governance,
|
|
216
|
+
parent: input.artifact.parentGovernance,
|
|
217
|
+
policy: policy.classification,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
audit.reasonCodes = ['invalid-classification-metadata'];
|
|
222
|
+
return { allowed: false, audit };
|
|
223
|
+
}
|
|
224
|
+
const tier = input.artifact.tier ?? 'durable';
|
|
225
|
+
let candidate;
|
|
226
|
+
try {
|
|
227
|
+
candidate = tier === 'raw' || input.artifact.category === 'sanitized-summary'
|
|
228
|
+
? input.artifact.payload
|
|
229
|
+
: minimizeEvidence(input.artifact.payload, input.maxExcerptBytes);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
audit.reasonCodes = ['minimization-failed'];
|
|
233
|
+
return { allowed: false, audit };
|
|
234
|
+
}
|
|
235
|
+
let findings = [];
|
|
236
|
+
try {
|
|
237
|
+
const sanitized = sanitizePayload(candidate, policy.redaction ?? {});
|
|
238
|
+
candidate = sanitized.value;
|
|
239
|
+
findings = sanitized.findings;
|
|
240
|
+
audit.redaction = findings.length ? 'completed' : 'not-needed';
|
|
241
|
+
audit.redactionCount = findings.length;
|
|
242
|
+
audit.redactionClasses = [...new Set(findings.map((finding) => finding.class))].sort();
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
const override = input.redactionOverride;
|
|
246
|
+
if (!sink.allowRedactionOverride || !validOverride(override, input.artifact.id, sink.id, now.getTime())) {
|
|
247
|
+
audit.redaction = 'failed';
|
|
248
|
+
audit.reasonCodes = ['sanitization-failed'];
|
|
249
|
+
return { allowed: false, audit };
|
|
250
|
+
}
|
|
251
|
+
audit.redaction = 'override';
|
|
252
|
+
audit.decision = 'override';
|
|
253
|
+
audit.redactionOverrideId = override.id;
|
|
254
|
+
audit.redactionOverrideActor = override.actor;
|
|
255
|
+
audit.redactionOverrideReasonDigest = sha256(override.reason);
|
|
256
|
+
}
|
|
257
|
+
const gate = evaluatePublicationGate({
|
|
258
|
+
artifactId: input.artifact.id,
|
|
259
|
+
artifactKind: input.artifact.kind,
|
|
260
|
+
governance,
|
|
261
|
+
sink,
|
|
262
|
+
sourceRepository: input.sourceRepository,
|
|
263
|
+
approval: input.publicationApproval,
|
|
264
|
+
classes: resolveClassificationDefinitions(policy.classification),
|
|
265
|
+
now,
|
|
266
|
+
});
|
|
267
|
+
audit.publication = gate.audit;
|
|
268
|
+
audit.reasonCodes = gate.reasonCodes;
|
|
269
|
+
let summary = false;
|
|
270
|
+
const immutableSinkRequiresSummary = !sink.mutable && input.artifact.category !== 'sanitized-summary';
|
|
271
|
+
if (!gate.allowed || immutableSinkRequiresSummary) {
|
|
272
|
+
if (!gate.allowed && gate.decision !== 'summarize') {
|
|
273
|
+
audit.decision = 'deny';
|
|
274
|
+
return { allowed: false, audit };
|
|
275
|
+
}
|
|
276
|
+
candidate = createSanitizedSummary({
|
|
277
|
+
artifactId: input.artifact.id,
|
|
278
|
+
artifactKind: input.artifact.kind,
|
|
279
|
+
status: stableStatus(input.artifact.status),
|
|
280
|
+
omittedFields: candidate && typeof candidate === 'object' ? Object.keys(candidate).length : 1,
|
|
281
|
+
redactionClasses: audit.redactionClasses,
|
|
282
|
+
});
|
|
283
|
+
governance = resolveArtifactGovernance({
|
|
284
|
+
kind: 'SanitizedSummary',
|
|
285
|
+
category: 'sanitized-summary',
|
|
286
|
+
metadata: { classification: 'public', owner: governance.owner, handling: { allowedSinks: [sink.id], crossRepo: 'allow' } },
|
|
287
|
+
policy: policy.classification,
|
|
288
|
+
});
|
|
289
|
+
const summaryGate = evaluatePublicationGate({
|
|
290
|
+
artifactId: input.artifact.id,
|
|
291
|
+
artifactKind: 'SanitizedSummary',
|
|
292
|
+
governance,
|
|
293
|
+
sink,
|
|
294
|
+
sourceRepository: input.sourceRepository,
|
|
295
|
+
classes: resolveClassificationDefinitions(policy.classification),
|
|
296
|
+
now,
|
|
297
|
+
});
|
|
298
|
+
audit.publication = summaryGate.audit;
|
|
299
|
+
if (!summaryGate.allowed) {
|
|
300
|
+
audit.decision = 'deny';
|
|
301
|
+
audit.reasonCodes = ['sanitized-summary-denied', ...summaryGate.reasonCodes];
|
|
302
|
+
return { allowed: false, audit };
|
|
303
|
+
}
|
|
304
|
+
summary = true;
|
|
305
|
+
}
|
|
306
|
+
let lifecycle;
|
|
307
|
+
try {
|
|
308
|
+
lifecycle = createEvidenceLifecycle({
|
|
309
|
+
artifactId: input.artifact.id,
|
|
310
|
+
category: summary ? 'sanitized-summary' : input.artifact.category,
|
|
311
|
+
classification: governance.classification,
|
|
312
|
+
sink,
|
|
313
|
+
tier: summary ? 'durable' : tier,
|
|
314
|
+
rules: policy.retention,
|
|
315
|
+
requestedPolicyId: governance.handling.retentionPolicy,
|
|
316
|
+
rawCaptureReason: input.artifact.rawCaptureReason,
|
|
317
|
+
createdAt: now.toISOString(),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
audit.decision = 'deny';
|
|
322
|
+
audit.reasonCodes = ['retention-policy-unsatisfied'];
|
|
323
|
+
return { allowed: false, audit };
|
|
324
|
+
}
|
|
325
|
+
audit.retentionPolicyId = lifecycle.policyId;
|
|
326
|
+
audit.dispositionDeadline = lifecycle.dispositionDeadline;
|
|
327
|
+
audit.decision = summary ? 'summary' : gate.decision === 'override' || audit.redaction === 'override' ? 'override' : 'allow';
|
|
328
|
+
return {
|
|
329
|
+
allowed: true,
|
|
330
|
+
prepared: {
|
|
331
|
+
payload: candidate,
|
|
332
|
+
governance: {
|
|
333
|
+
classification: governance.classification,
|
|
334
|
+
...(governance.owner ? { owner: governance.owner } : {}),
|
|
335
|
+
handling: {
|
|
336
|
+
allowedSinks: governance.handling.allowedSinks ?? [sink.id],
|
|
337
|
+
crossRepo: governance.handling.crossRepo ?? 'approval-required',
|
|
338
|
+
retentionPolicy: lifecycle.policyId,
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
lifecycle,
|
|
342
|
+
summary,
|
|
343
|
+
},
|
|
344
|
+
audit,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/** Call a sink writer only after the boundary returns publishable evidence. */
|
|
348
|
+
export async function publishEvidence(input) {
|
|
349
|
+
const result = prepareEvidenceForSink(input);
|
|
350
|
+
if (result.allowed && result.prepared)
|
|
351
|
+
await input.writer(result.prepared);
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
//# sourceMappingURL=boundary.js.map
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
export const BUILTIN_CLASSIFICATIONS = {
|
|
3
|
+
public: { rank: 0, description: 'Approved for unrestricted disclosure.' },
|
|
4
|
+
internal: { rank: 10, description: 'Routine non-public operational information.' },
|
|
5
|
+
confidential: { rank: 20, description: 'Sensitive business or operational information.' },
|
|
6
|
+
'restricted-infrastructure': { rank: 30, description: 'Topology, access path, recovery, or detailed infrastructure information.' },
|
|
7
|
+
'restricted-identity': { rank: 40, description: 'Named-user, identity-provider, authentication, or entitlement information.' },
|
|
8
|
+
};
|
|
9
|
+
export const SECURE_KIND_DEFAULTS = {
|
|
10
|
+
ITAsset: 'restricted-infrastructure',
|
|
11
|
+
ITService: 'restricted-infrastructure',
|
|
12
|
+
ITNetworkState: 'restricted-infrastructure',
|
|
13
|
+
OpsInventory: 'restricted-infrastructure',
|
|
14
|
+
OpsPlaybook: 'internal',
|
|
15
|
+
IncidentReport: 'confidential',
|
|
16
|
+
IdentityAudit: 'restricted-identity',
|
|
17
|
+
DREvidence: 'restricted-infrastructure',
|
|
18
|
+
RawAuditEvidence: 'restricted-infrastructure',
|
|
19
|
+
};
|
|
20
|
+
export const SECURE_CATEGORY_DEFAULTS = {
|
|
21
|
+
'raw-audit': 'restricted-infrastructure',
|
|
22
|
+
'identity-audit': 'restricted-identity',
|
|
23
|
+
'network-inventory': 'restricted-infrastructure',
|
|
24
|
+
'dr-evidence': 'restricted-infrastructure',
|
|
25
|
+
'sanitized-summary': 'internal',
|
|
26
|
+
generic: 'internal',
|
|
27
|
+
};
|
|
28
|
+
function stableId(value, label) {
|
|
29
|
+
if (!/^[a-z0-9][a-z0-9.-]{0,127}$/.test(value)) {
|
|
30
|
+
throw new Error(`${label} must be a lowercase stable identifier`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function resolveClassificationDefinitions(policy = {}) {
|
|
34
|
+
const classes = { ...BUILTIN_CLASSIFICATIONS };
|
|
35
|
+
for (const [id, definition] of Object.entries(policy.classes ?? {})) {
|
|
36
|
+
stableId(id, 'classification');
|
|
37
|
+
if (!Number.isSafeInteger(definition.rank) || definition.rank < 0 || definition.rank > 10_000) {
|
|
38
|
+
throw new Error(`classification '${id}' rank must be an integer from 0 through 10000`);
|
|
39
|
+
}
|
|
40
|
+
const builtin = BUILTIN_CLASSIFICATIONS[id];
|
|
41
|
+
if (builtin && builtin.rank !== definition.rank) {
|
|
42
|
+
throw new Error(`built-in classification '${id}' rank cannot be changed`);
|
|
43
|
+
}
|
|
44
|
+
classes[id] = { ...definition, ...(builtin ? { rank: builtin.rank } : {}) };
|
|
45
|
+
}
|
|
46
|
+
return classes;
|
|
47
|
+
}
|
|
48
|
+
function mergeHandling(parent, child) {
|
|
49
|
+
return {
|
|
50
|
+
...(parent ?? {}),
|
|
51
|
+
...(child ?? {}),
|
|
52
|
+
...(child?.allowedSinks ? { allowedSinks: [...child.allowedSinks] } : parent?.allowedSinks ? { allowedSinks: [...parent.allowedSinks] } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Resolve explicit metadata, parent inheritance, secure kind/category defaults, then the policy default. */
|
|
56
|
+
export function resolveArtifactGovernance(input) {
|
|
57
|
+
const policy = input.policy ?? {};
|
|
58
|
+
const definitions = resolveClassificationDefinitions(policy);
|
|
59
|
+
const explicitRequired = new Set(policy.requireExplicitForKinds ?? []);
|
|
60
|
+
if (explicitRequired.has(input.kind) && input.metadata?.classification === undefined) {
|
|
61
|
+
throw new Error(`artifact kind '${input.kind}' requires an explicit classification`);
|
|
62
|
+
}
|
|
63
|
+
const candidates = [
|
|
64
|
+
[input.metadata?.classification, 'artifact'],
|
|
65
|
+
[input.parent?.classification, 'parent'],
|
|
66
|
+
[policy.defaultsByKind?.[input.kind] ?? SECURE_KIND_DEFAULTS[input.kind], 'kind-default'],
|
|
67
|
+
[policy.defaultsByCategory?.[input.category] ?? SECURE_CATEGORY_DEFAULTS[input.category], 'category-default'],
|
|
68
|
+
[policy.defaultClassification ?? 'internal', 'policy-default'],
|
|
69
|
+
];
|
|
70
|
+
const selected = candidates.find(([value]) => value !== undefined);
|
|
71
|
+
const classification = selected?.[0];
|
|
72
|
+
if (!classification || !definitions[classification]) {
|
|
73
|
+
throw new Error(`unknown or missing classification '${classification ?? '<missing>'}'`);
|
|
74
|
+
}
|
|
75
|
+
const handling = mergeHandling(input.parent?.handling, input.metadata?.handling);
|
|
76
|
+
for (const sink of handling.allowedSinks ?? [])
|
|
77
|
+
stableId(sink, 'allowed sink');
|
|
78
|
+
return {
|
|
79
|
+
classification,
|
|
80
|
+
classificationRank: definitions[classification].rank,
|
|
81
|
+
classificationSource: selected[1],
|
|
82
|
+
...(input.metadata?.owner ?? input.parent?.owner ? { owner: input.metadata?.owner ?? input.parent?.owner } : {}),
|
|
83
|
+
handling,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function defaultSinkMaxRank(sink) {
|
|
87
|
+
switch (sink.visibility) {
|
|
88
|
+
case 'public': return BUILTIN_CLASSIFICATIONS.public.rank;
|
|
89
|
+
case 'private': return BUILTIN_CLASSIFICATIONS['restricted-infrastructure'].rank;
|
|
90
|
+
case 'restricted': return BUILTIN_CLASSIFICATIONS['restricted-identity'].rank;
|
|
91
|
+
case 'unknown': return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function approvalIsValid(approval, artifactId, sinkId, now) {
|
|
95
|
+
if (!approval)
|
|
96
|
+
return false;
|
|
97
|
+
if (!approval.id || !approval.actor || !approval.reason.trim())
|
|
98
|
+
return false;
|
|
99
|
+
if (approval.artifactId !== artifactId || approval.sinkId !== sinkId)
|
|
100
|
+
return false;
|
|
101
|
+
const approvedAt = Date.parse(approval.approvedAt);
|
|
102
|
+
if (!Number.isFinite(approvedAt) || approvedAt > now)
|
|
103
|
+
return false;
|
|
104
|
+
if (approval.expiresAt !== undefined) {
|
|
105
|
+
const expiresAt = Date.parse(approval.expiresAt);
|
|
106
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now || expiresAt <= approvedAt)
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
function reasonDigest(reason) {
|
|
112
|
+
return `sha256:${createHash('sha256').update(reason).digest('hex')}`;
|
|
113
|
+
}
|
|
114
|
+
function correlationDigest(value) {
|
|
115
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
116
|
+
}
|
|
117
|
+
function safeArtifactKind(value) {
|
|
118
|
+
return /^[A-Za-z][A-Za-z0-9.-]{0,127}$/.test(value) ? value : correlationDigest(value);
|
|
119
|
+
}
|
|
120
|
+
/** Evaluate an artifact against a known sink before disclosure. Audit data never contains payload content. */
|
|
121
|
+
export function evaluatePublicationGate(input) {
|
|
122
|
+
const occurredAt = (input.now ?? new Date()).toISOString();
|
|
123
|
+
const now = Date.parse(occurredAt);
|
|
124
|
+
const sinkId = input.sink?.id ?? 'unknown';
|
|
125
|
+
const reasons = [];
|
|
126
|
+
const sink = input.sink;
|
|
127
|
+
if (!sink || sink.visibility === 'unknown') {
|
|
128
|
+
reasons.push('unknown-sink-visibility');
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
stableId(sink.id, 'sink');
|
|
132
|
+
if (input.governance.handling.allowedSinks && !input.governance.handling.allowedSinks.includes(sink.id)) {
|
|
133
|
+
reasons.push('sink-not-allowed');
|
|
134
|
+
}
|
|
135
|
+
const definitions = input.classes ?? resolveClassificationDefinitions();
|
|
136
|
+
const configuredMax = sink.maxClassification;
|
|
137
|
+
const maxRank = configuredMax === undefined
|
|
138
|
+
? defaultSinkMaxRank(sink)
|
|
139
|
+
: definitions[configuredMax]?.rank;
|
|
140
|
+
if (maxRank === undefined || maxRank === null)
|
|
141
|
+
reasons.push('unknown-sink-classification-limit');
|
|
142
|
+
else if (input.governance.classificationRank > maxRank)
|
|
143
|
+
reasons.push('classification-exceeds-sink');
|
|
144
|
+
const crossRepo = Boolean(input.sourceRepository && sink.repository && input.sourceRepository !== sink.repository);
|
|
145
|
+
if (crossRepo && input.governance.handling.crossRepo === 'deny')
|
|
146
|
+
reasons.push('cross-repo-denied');
|
|
147
|
+
if (crossRepo && input.governance.handling.crossRepo === 'approval-required')
|
|
148
|
+
reasons.push('cross-repo-approval-required');
|
|
149
|
+
}
|
|
150
|
+
const validApproval = approvalIsValid(input.approval, input.artifactId, sinkId, now);
|
|
151
|
+
const overridableReasons = new Set(['classification-exceeds-sink', 'cross-repo-approval-required']);
|
|
152
|
+
const approvalCoversReasons = reasons.length > 0 && reasons.every((reason) => overridableReasons.has(reason));
|
|
153
|
+
let decision;
|
|
154
|
+
if (reasons.length === 0)
|
|
155
|
+
decision = 'allow';
|
|
156
|
+
else if (validApproval && approvalCoversReasons && sink && sink.visibility !== 'unknown')
|
|
157
|
+
decision = 'override';
|
|
158
|
+
else if (sink?.acceptsSanitizedSummary)
|
|
159
|
+
decision = 'summarize';
|
|
160
|
+
else
|
|
161
|
+
decision = 'deny';
|
|
162
|
+
const audit = {
|
|
163
|
+
schemaVersion: 'ops-publication-decision.aiwg.io/v1',
|
|
164
|
+
eventId: randomUUID(),
|
|
165
|
+
occurredAt,
|
|
166
|
+
artifactId: correlationDigest(input.artifactId),
|
|
167
|
+
artifactKind: safeArtifactKind(input.artifactKind),
|
|
168
|
+
classification: input.governance.classification,
|
|
169
|
+
sinkId,
|
|
170
|
+
decision,
|
|
171
|
+
reasonCodes: [...reasons].sort(),
|
|
172
|
+
...(decision === 'override' && input.approval ? {
|
|
173
|
+
approvalId: input.approval.id,
|
|
174
|
+
approvalActor: input.approval.actor,
|
|
175
|
+
approvalReasonDigest: reasonDigest(input.approval.reason),
|
|
176
|
+
} : {}),
|
|
177
|
+
};
|
|
178
|
+
return { decision, allowed: decision === 'allow' || decision === 'override', reasonCodes: audit.reasonCodes, audit };
|
|
179
|
+
}
|
|
180
|
+
/** Produce a payload-free summary suitable for a second, separately gated publication attempt. */
|
|
181
|
+
export function createSanitizedSummary(input) {
|
|
182
|
+
return {
|
|
183
|
+
schemaVersion: 'ops-sanitized-summary.aiwg.io/v1',
|
|
184
|
+
artifactFingerprint: `sha256:${createHash('sha256').update(input.artifactId).digest('hex')}`,
|
|
185
|
+
artifactKind: input.artifactKind,
|
|
186
|
+
...(input.status ? { status: input.status } : {}),
|
|
187
|
+
omittedFields: input.omittedFields,
|
|
188
|
+
redactionClasses: [...new Set(input.redactionClasses)].sort(),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=classification.js.map
|