@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,324 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { Transform } from 'node:stream';
|
|
3
|
+
import { TextDecoder } from 'node:util';
|
|
4
|
+
export class RedactionError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = 'RedactionError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const DEFAULT_LIMITS = {
|
|
13
|
+
maxInputBytes: 8 * 1024 * 1024,
|
|
14
|
+
maxDepth: 32,
|
|
15
|
+
maxNodes: 100_000,
|
|
16
|
+
maxObjectKeys: 10_000,
|
|
17
|
+
maxArrayItems: 100_000,
|
|
18
|
+
};
|
|
19
|
+
const BUILTIN_SENSITIVE_KEY = /(?:^|[_-])(?:authorization|auth[_-]?header|cookie|set[_-]?cookie|credential|password|passwd|secret|token|api[_-]?key|private[_-]?key|connection[_-]?string|client[_-]?secret)(?:$|[_-])/i;
|
|
20
|
+
const BUILTIN_PATTERNS = [
|
|
21
|
+
{
|
|
22
|
+
id: 'private-key',
|
|
23
|
+
pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'authorization-header',
|
|
27
|
+
pattern: /(\bAuthorization\s*:\s*)([^\r\n]+)/gi,
|
|
28
|
+
preservePrefix: true,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'cookie-header',
|
|
32
|
+
pattern: /(\b(?:Cookie|Set-Cookie)\s*:\s*)([^\r\n]+)/gi,
|
|
33
|
+
preservePrefix: true,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'url-query-secret',
|
|
37
|
+
pattern: /([?&](?:access[_-]?token|refresh[_-]?token|api[_-]?key|token|secret|password|passwd|signature|sig)=)([^&#\s]+)/gi,
|
|
38
|
+
preservePrefix: true,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'sensitive-assignment',
|
|
42
|
+
pattern: /((?:^|[\s,{])["']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|passwd|secret|client[_-]?secret|credential)["']?\s*(?:[:=]|\s+)\s*")([^"\r\n]*)(")/gim,
|
|
43
|
+
preservePrefix: true,
|
|
44
|
+
preserveSuffix: true,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'sensitive-assignment',
|
|
48
|
+
pattern: /((?:^|[\s,{])["']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|passwd|secret|client[_-]?secret|credential)["']?\s*(?:[:=]|\s+)\s*')([^'\r\n]*)(')/gim,
|
|
49
|
+
preservePrefix: true,
|
|
50
|
+
preserveSuffix: true,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'sensitive-assignment',
|
|
54
|
+
pattern: /((?:^|[\s,{])["']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|passwd|secret|client[_-]?secret|credential)["']?\s*(?:[:=]|\s+)\s*)([^\s"',;}\]]{4,})/gim,
|
|
55
|
+
preservePrefix: true,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'environment-secret',
|
|
59
|
+
pattern: /(\b[A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|PRIVATE_KEY)\s*=\s*)(?:"[^"]+"|'[^']+'|[^\s]+)/g,
|
|
60
|
+
preservePrefix: true,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'provider-token',
|
|
64
|
+
pattern: /\b(?:sk-(?:proj-)?|gh[pousr]_|github_pat_|xox[baprs]-|AKIA)[A-Za-z0-9._~+/=-]{6,}\b/g,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'bearer-token',
|
|
68
|
+
pattern: /(\bBearer\s+)([A-Za-z0-9._~+/=-]{6,})\b/gi,
|
|
69
|
+
preservePrefix: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'connection-string',
|
|
73
|
+
pattern: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqps?):\/\/[^\s"'<>]+/gi,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'url-userinfo',
|
|
77
|
+
pattern: /(https?:\/\/[^/\s:@]+:)([^/\s@]+)(@[^\s"'<>]+)/gi,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
function resolvedLimits(options) {
|
|
81
|
+
const limits = { ...DEFAULT_LIMITS, ...(options.limits ?? {}) };
|
|
82
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
83
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
84
|
+
throw new RedactionError('LIMIT_EXCEEDED', `${name} must be a positive safe integer`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return limits;
|
|
88
|
+
}
|
|
89
|
+
function validatePatternSource(pattern) {
|
|
90
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(pattern.id)) {
|
|
91
|
+
throw new RedactionError('INVALID_PATTERN', 'organization redaction pattern IDs must be lowercase stable identifiers');
|
|
92
|
+
}
|
|
93
|
+
if (pattern.pattern.length === 0 || pattern.pattern.length > 512) {
|
|
94
|
+
throw new RedactionError('INVALID_PATTERN', `organization pattern '${pattern.id}' must contain 1-512 characters`);
|
|
95
|
+
}
|
|
96
|
+
if (/\\[1-9]|\(\?<[=!]|\([^)]*[+*][^)]*\)[+*{]/.test(pattern.pattern)) {
|
|
97
|
+
throw new RedactionError('INVALID_PATTERN', `organization pattern '${pattern.id}' uses a disallowed high-risk construct`);
|
|
98
|
+
}
|
|
99
|
+
const requestedFlags = pattern.flags ?? 'g';
|
|
100
|
+
if (/[^gimsuy]/.test(requestedFlags)) {
|
|
101
|
+
throw new RedactionError('INVALID_PATTERN', `organization pattern '${pattern.id}' has unsupported flags`);
|
|
102
|
+
}
|
|
103
|
+
const flags = requestedFlags.includes('g') ? requestedFlags : `${requestedFlags}g`;
|
|
104
|
+
try {
|
|
105
|
+
return new RegExp(pattern.pattern, flags);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw new RedactionError('INVALID_PATTERN', `organization pattern '${pattern.id}' is not a valid regular expression`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function fingerprint(value, key) {
|
|
112
|
+
if (key === undefined)
|
|
113
|
+
return undefined;
|
|
114
|
+
return `hmac-sha256:${createHmac('sha256', key).update(value).digest('hex').slice(0, 16)}`;
|
|
115
|
+
}
|
|
116
|
+
function marker(classification, value, options) {
|
|
117
|
+
const length = Buffer.byteLength(value);
|
|
118
|
+
const valueFingerprint = fingerprint(value, options.fingerprintKey);
|
|
119
|
+
const attributes = [
|
|
120
|
+
options.includeLength === false ? null : `len=${length}`,
|
|
121
|
+
valueFingerprint ? `fp=${valueFingerprint}` : null,
|
|
122
|
+
].filter((part) => part !== null);
|
|
123
|
+
return {
|
|
124
|
+
marker: `[REDACTED:${classification}${attributes.length ? `;${attributes.join(';')}` : ''}]`,
|
|
125
|
+
finding: {
|
|
126
|
+
class: classification,
|
|
127
|
+
...(options.includeLength === false ? {} : { length }),
|
|
128
|
+
...(valueFingerprint ? { fingerprint: valueFingerprint } : {}),
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function applyPattern(input, definition, options, findings) {
|
|
133
|
+
definition.pattern.lastIndex = 0;
|
|
134
|
+
return input.replace(definition.pattern, (...args) => {
|
|
135
|
+
const full = String(args[0]);
|
|
136
|
+
if (definition.id === 'url-userinfo') {
|
|
137
|
+
const prefix = String(args[1]);
|
|
138
|
+
const secret = String(args[2]);
|
|
139
|
+
const suffix = String(args[3]);
|
|
140
|
+
const redacted = marker(definition.id, secret, options);
|
|
141
|
+
findings.push(redacted.finding);
|
|
142
|
+
return `${prefix}${redacted.marker}${suffix}`;
|
|
143
|
+
}
|
|
144
|
+
if (definition.preservePrefix) {
|
|
145
|
+
const prefix = String(args[1]);
|
|
146
|
+
const secret = String(args[2] ?? full.slice(prefix.length));
|
|
147
|
+
if (secret.startsWith('[REDACTED:'))
|
|
148
|
+
return full;
|
|
149
|
+
const redacted = marker(definition.id, secret, options);
|
|
150
|
+
findings.push(redacted.finding);
|
|
151
|
+
const suffix = definition.preserveSuffix ? String(args[3] ?? '') : '';
|
|
152
|
+
return `${prefix}${redacted.marker}${suffix}`;
|
|
153
|
+
}
|
|
154
|
+
const redacted = marker(definition.id, full, options);
|
|
155
|
+
findings.push(redacted.finding);
|
|
156
|
+
return redacted.marker;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function decodedSecretClass(value) {
|
|
160
|
+
if (value.length < 24 || value.length > 16 * 1024 || value.length % 4 === 1)
|
|
161
|
+
return null;
|
|
162
|
+
try {
|
|
163
|
+
const decoded = Buffer.from(value, 'base64').toString('utf8');
|
|
164
|
+
if (!decoded || decoded.includes('\uFFFD'))
|
|
165
|
+
return null;
|
|
166
|
+
if (/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/.test(decoded))
|
|
167
|
+
return 'encoded-private-key';
|
|
168
|
+
if (/(?:api[_-]?key|token|password|passwd|secret|authorization)\s*[:=]/i.test(decoded))
|
|
169
|
+
return 'encoded-secret';
|
|
170
|
+
if (/(?:sk-(?:proj-)?|gh[pousr]_|github_pat_|xox[baprs]-|AKIA)[A-Za-z0-9._~+/=-]{6,}/.test(decoded))
|
|
171
|
+
return 'encoded-secret';
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Redact secret-bearing values from a complete text value. */
|
|
179
|
+
export function redactText(input, options = {}) {
|
|
180
|
+
const limits = resolvedLimits(options);
|
|
181
|
+
if (Buffer.byteLength(input) > limits.maxInputBytes) {
|
|
182
|
+
throw new RedactionError('LIMIT_EXCEEDED', 'text exceeds the configured redaction byte limit');
|
|
183
|
+
}
|
|
184
|
+
const findings = [];
|
|
185
|
+
let text = input;
|
|
186
|
+
for (const definition of BUILTIN_PATTERNS) {
|
|
187
|
+
text = applyPattern(text, definition, options, findings);
|
|
188
|
+
}
|
|
189
|
+
for (const configured of options.organizationPatterns ?? []) {
|
|
190
|
+
const definition = {
|
|
191
|
+
id: `organization-${configured.id}`,
|
|
192
|
+
pattern: validatePatternSource(configured),
|
|
193
|
+
};
|
|
194
|
+
text = applyPattern(text, definition, options, findings);
|
|
195
|
+
}
|
|
196
|
+
text = text.replace(/\b[A-Za-z0-9+/]{24,}={0,2}\b/g, (candidate) => {
|
|
197
|
+
const classification = decodedSecretClass(candidate);
|
|
198
|
+
if (!classification)
|
|
199
|
+
return candidate;
|
|
200
|
+
const redacted = marker(classification, candidate, options);
|
|
201
|
+
findings.push(redacted.finding);
|
|
202
|
+
return redacted.marker;
|
|
203
|
+
});
|
|
204
|
+
return { text, sensitivity: findings.length ? 'sensitive' : 'none', findings };
|
|
205
|
+
}
|
|
206
|
+
function sensitiveKey(key, options) {
|
|
207
|
+
if (BUILTIN_SENSITIVE_KEY.test(key))
|
|
208
|
+
return true;
|
|
209
|
+
let matched = false;
|
|
210
|
+
for (const source of options.sensitiveKeyPatterns ?? []) {
|
|
211
|
+
if (source.length === 0 || source.length > 256) {
|
|
212
|
+
throw new RedactionError('INVALID_PATTERN', 'sensitive key patterns must contain 1-256 characters');
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
if (new RegExp(source, 'i').test(key))
|
|
216
|
+
matched = true;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
throw new RedactionError('INVALID_PATTERN', 'a sensitive key pattern is not a valid regular expression');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return matched;
|
|
223
|
+
}
|
|
224
|
+
/** Recursively redact nested JSON/YAML-compatible values while preserving shape and field names. */
|
|
225
|
+
export function redactStructured(input, options = {}) {
|
|
226
|
+
const limits = resolvedLimits(options);
|
|
227
|
+
const findings = [];
|
|
228
|
+
const active = new WeakSet();
|
|
229
|
+
let nodes = 0;
|
|
230
|
+
const visit = (value, path, depth, key) => {
|
|
231
|
+
nodes += 1;
|
|
232
|
+
if (nodes > limits.maxNodes || depth > limits.maxDepth) {
|
|
233
|
+
throw new RedactionError('LIMIT_EXCEEDED', 'structured value exceeds configured redaction traversal limits');
|
|
234
|
+
}
|
|
235
|
+
if (key !== undefined && sensitiveKey(key, options)) {
|
|
236
|
+
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
|
|
237
|
+
const redacted = marker('sensitive-field', serialized ?? String(value), options);
|
|
238
|
+
findings.push({ ...redacted.finding, path });
|
|
239
|
+
return redacted.marker;
|
|
240
|
+
}
|
|
241
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number')
|
|
242
|
+
return value;
|
|
243
|
+
if (typeof value === 'bigint')
|
|
244
|
+
return String(value);
|
|
245
|
+
if (typeof value === 'string') {
|
|
246
|
+
const redacted = redactText(value, options);
|
|
247
|
+
findings.push(...redacted.findings.map((finding) => ({ ...finding, path })));
|
|
248
|
+
return redacted.text;
|
|
249
|
+
}
|
|
250
|
+
if (typeof value !== 'object') {
|
|
251
|
+
throw new RedactionError('UNSUPPORTED_VALUE', `unsupported structured value at ${path}`);
|
|
252
|
+
}
|
|
253
|
+
if (active.has(value))
|
|
254
|
+
throw new RedactionError('UNSUPPORTED_VALUE', `circular structured value at ${path}`);
|
|
255
|
+
active.add(value);
|
|
256
|
+
try {
|
|
257
|
+
if (Array.isArray(value)) {
|
|
258
|
+
if (value.length > limits.maxArrayItems) {
|
|
259
|
+
throw new RedactionError('LIMIT_EXCEEDED', `array exceeds configured item limit at ${path}`);
|
|
260
|
+
}
|
|
261
|
+
return value.map((item, index) => visit(item, `${path}/${index}`, depth + 1));
|
|
262
|
+
}
|
|
263
|
+
const entries = Object.entries(value);
|
|
264
|
+
if (entries.length > limits.maxObjectKeys) {
|
|
265
|
+
throw new RedactionError('LIMIT_EXCEEDED', `object exceeds configured key limit at ${path}`);
|
|
266
|
+
}
|
|
267
|
+
return Object.fromEntries(entries.map(([childKey, child]) => [
|
|
268
|
+
childKey,
|
|
269
|
+
visit(child, `${path}/${childKey.replaceAll('~', '~0').replaceAll('/', '~1')}`, depth + 1, childKey),
|
|
270
|
+
]));
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
active.delete(value);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
const value = visit(input, '', 0);
|
|
277
|
+
return { value, sensitivity: findings.length ? 'sensitive' : 'none', findings };
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* A bounded streaming interface. It buffers one logical output value and emits
|
|
281
|
+
* only after full-value sanitization, so secrets split across chunks cannot
|
|
282
|
+
* escape. Exceeding the configured limit fails without emitting partial data.
|
|
283
|
+
*/
|
|
284
|
+
export class RedactionTransform extends Transform {
|
|
285
|
+
options;
|
|
286
|
+
chunks = [];
|
|
287
|
+
bytes = 0;
|
|
288
|
+
limits;
|
|
289
|
+
constructor(options = {}) {
|
|
290
|
+
super();
|
|
291
|
+
this.options = options;
|
|
292
|
+
this.limits = resolvedLimits(options);
|
|
293
|
+
}
|
|
294
|
+
_transform(chunk, encoding, callback) {
|
|
295
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
|
|
296
|
+
this.bytes += value.length;
|
|
297
|
+
if (this.bytes > this.limits.maxInputBytes) {
|
|
298
|
+
callback(new RedactionError('LIMIT_EXCEEDED', 'stream exceeds the configured redaction byte limit'));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
this.chunks.push(value);
|
|
302
|
+
callback();
|
|
303
|
+
}
|
|
304
|
+
_flush(callback) {
|
|
305
|
+
try {
|
|
306
|
+
let source;
|
|
307
|
+
try {
|
|
308
|
+
source = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(this.chunks));
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
throw new RedactionError('INVALID_UTF8', 'stream is not valid UTF-8');
|
|
312
|
+
}
|
|
313
|
+
this.push(redactText(source, this.options).text);
|
|
314
|
+
callback();
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
callback(error);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
export function createRedactionTransform(options = {}) {
|
|
322
|
+
return new RedactionTransform(options);
|
|
323
|
+
}
|
|
324
|
+
//# sourceMappingURL=redaction.js.map
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
export const DEFAULT_RETENTION_RULES = [
|
|
3
|
+
{ id: 'raw-audit-short-lived', version: '1', category: 'raw-audit', tier: 'raw', duration: 'P7D', action: 'delete' },
|
|
4
|
+
{ id: 'identity-audit-durable', version: '1', category: 'identity-audit', tier: 'durable', duration: 'P30D', action: 'summarize' },
|
|
5
|
+
{ id: 'network-inventory-durable', version: '1', category: 'network-inventory', tier: 'durable', duration: 'P30D', action: 'archive', archiveSink: 'encrypted-artifact-store' },
|
|
6
|
+
{ id: 'dr-evidence-durable', version: '1', category: 'dr-evidence', tier: 'durable', duration: 'P90D', action: 'archive', archiveSink: 'encrypted-artifact-store' },
|
|
7
|
+
{ id: 'sanitized-summary-durable', version: '1', category: 'sanitized-summary', tier: 'durable', duration: 'P365D', action: 'summarize' },
|
|
8
|
+
{ id: 'generic-durable', version: '1', category: 'generic', tier: 'durable', duration: 'P90D', action: 'summarize' },
|
|
9
|
+
];
|
|
10
|
+
function digest(value) {
|
|
11
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
12
|
+
}
|
|
13
|
+
function parseDuration(value) {
|
|
14
|
+
if (value === null || value === undefined)
|
|
15
|
+
return null;
|
|
16
|
+
const match = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(value);
|
|
17
|
+
if (!match || !match.slice(1).some(Boolean))
|
|
18
|
+
throw new Error(`unsupported retention duration '${value}'`);
|
|
19
|
+
const days = Number(match[1] ?? 0);
|
|
20
|
+
const hours = Number(match[2] ?? 0);
|
|
21
|
+
const minutes = Number(match[3] ?? 0);
|
|
22
|
+
const seconds = Number(match[4] ?? 0);
|
|
23
|
+
const milliseconds = (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1_000;
|
|
24
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds < 1)
|
|
25
|
+
throw new Error(`invalid retention duration '${value}'`);
|
|
26
|
+
return milliseconds;
|
|
27
|
+
}
|
|
28
|
+
export function validateRetentionRules(rules) {
|
|
29
|
+
const ids = new Set();
|
|
30
|
+
const actions = new Set(['retain', 'summarize', 'redact-fields', 'archive', 'delete']);
|
|
31
|
+
for (const rule of rules) {
|
|
32
|
+
if (!/^[a-z0-9][a-z0-9.-]{0,127}$/.test(rule.id))
|
|
33
|
+
throw new Error('retention rule IDs must be lowercase stable identifiers');
|
|
34
|
+
if (ids.has(rule.id))
|
|
35
|
+
throw new Error(`duplicate retention rule '${rule.id}'`);
|
|
36
|
+
ids.add(rule.id);
|
|
37
|
+
if (!rule.version.trim())
|
|
38
|
+
throw new Error(`retention rule '${rule.id}' requires a version`);
|
|
39
|
+
if (!actions.has(rule.action))
|
|
40
|
+
throw new Error(`retention rule '${rule.id}' has an unsupported action`);
|
|
41
|
+
if (rule.tier !== undefined && rule.tier !== 'raw' && rule.tier !== 'durable') {
|
|
42
|
+
throw new Error(`retention rule '${rule.id}' has an unsupported tier`);
|
|
43
|
+
}
|
|
44
|
+
if (rule.priority !== undefined && !Number.isSafeInteger(rule.priority)) {
|
|
45
|
+
throw new Error(`retention rule '${rule.id}' priority must be a safe integer`);
|
|
46
|
+
}
|
|
47
|
+
for (const [name, value] of Object.entries({ category: rule.category, classification: rule.classification, sink: rule.sink })) {
|
|
48
|
+
if (value !== undefined && (typeof value !== 'string' || value.length === 0)) {
|
|
49
|
+
throw new Error(`retention rule '${rule.id}' ${name} must be a non-empty string`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
parseDuration(rule.duration);
|
|
53
|
+
if (rule.action === 'redact-fields' && (!rule.redactFields || rule.redactFields.length === 0)) {
|
|
54
|
+
throw new Error(`retention rule '${rule.id}' requires redactFields`);
|
|
55
|
+
}
|
|
56
|
+
if (rule.action === 'archive' && !rule.archiveSink) {
|
|
57
|
+
throw new Error(`retention rule '${rule.id}' requires archiveSink`);
|
|
58
|
+
}
|
|
59
|
+
if (rule.redactFields !== undefined && (!Array.isArray(rule.redactFields) || rule.redactFields.some((field) => typeof field !== 'string' || !field))) {
|
|
60
|
+
throw new Error(`retention rule '${rule.id}' redactFields must contain non-empty strings`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function ruleMatches(rule, input) {
|
|
65
|
+
return (rule.category === undefined || rule.category === input.category)
|
|
66
|
+
&& (rule.classification === undefined || rule.classification === input.classification)
|
|
67
|
+
&& (rule.sink === undefined || rule.sink === input.sinkId)
|
|
68
|
+
&& (rule.tier === undefined || rule.tier === input.tier);
|
|
69
|
+
}
|
|
70
|
+
function specificity(rule) {
|
|
71
|
+
return [rule.category, rule.classification, rule.sink, rule.tier].filter((value) => value !== undefined).length;
|
|
72
|
+
}
|
|
73
|
+
export function resolveRetentionRule(input, rules = [], requestedPolicyId) {
|
|
74
|
+
const candidates = [
|
|
75
|
+
...rules.map((rule) => ({ rule, configured: true })),
|
|
76
|
+
...DEFAULT_RETENTION_RULES.map((rule) => ({ rule, configured: false })),
|
|
77
|
+
]
|
|
78
|
+
.filter(({ rule }) => (requestedPolicyId === undefined || rule.id === requestedPolicyId) && ruleMatches(rule, input))
|
|
79
|
+
.sort((left, right) => (right.rule.priority ?? 0) - (left.rule.priority ?? 0)
|
|
80
|
+
|| specificity(right.rule) - specificity(left.rule)
|
|
81
|
+
|| Number(right.configured) - Number(left.configured)
|
|
82
|
+
|| left.rule.id.localeCompare(right.rule.id));
|
|
83
|
+
const selected = candidates[0]?.rule;
|
|
84
|
+
if (!selected)
|
|
85
|
+
throw new Error(`no retention rule matches ${input.category}/${input.classification}/${input.sinkId}/${input.tier}`);
|
|
86
|
+
parseDuration(selected.duration);
|
|
87
|
+
if (selected.action === 'redact-fields' && (!selected.redactFields || selected.redactFields.length === 0)) {
|
|
88
|
+
throw new Error(`retention rule '${selected.id}' requires redactFields`);
|
|
89
|
+
}
|
|
90
|
+
if (selected.action === 'archive' && !selected.archiveSink) {
|
|
91
|
+
// Built-in archive policies deliberately require the project to select a destination.
|
|
92
|
+
if (!DEFAULT_RETENTION_RULES.some((rule) => rule.id === selected.id)) {
|
|
93
|
+
throw new Error(`retention rule '${selected.id}' requires archiveSink`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { ...selected };
|
|
97
|
+
}
|
|
98
|
+
export function createEvidenceLifecycle(input) {
|
|
99
|
+
const createdAt = input.createdAt ?? new Date().toISOString();
|
|
100
|
+
const created = Date.parse(createdAt);
|
|
101
|
+
if (!Number.isFinite(created))
|
|
102
|
+
throw new Error('evidence creation time must be valid ISO-8601');
|
|
103
|
+
if (input.tier === 'raw' && !input.rawCaptureReason?.trim()) {
|
|
104
|
+
throw new Error('full raw evidence capture requires an explicit reason');
|
|
105
|
+
}
|
|
106
|
+
if (!input.sink.mutable && input.tier === 'raw') {
|
|
107
|
+
throw new Error(`raw evidence cannot be published to immutable sink '${input.sink.id}'; publish a sanitized summary instead`);
|
|
108
|
+
}
|
|
109
|
+
const immutableSummaryRule = !input.sink.mutable && input.category === 'sanitized-summary'
|
|
110
|
+
? {
|
|
111
|
+
id: 'immutable-sanitized-summary', version: '1', category: 'sanitized-summary',
|
|
112
|
+
sink: input.sink.id, tier: 'durable', duration: null, action: 'retain', priority: 10_000,
|
|
113
|
+
}
|
|
114
|
+
: undefined;
|
|
115
|
+
const rule = resolveRetentionRule({
|
|
116
|
+
category: input.category,
|
|
117
|
+
classification: input.classification,
|
|
118
|
+
sinkId: input.sink.id,
|
|
119
|
+
tier: input.tier,
|
|
120
|
+
}, [...(immutableSummaryRule ? [immutableSummaryRule] : []), ...(input.rules ?? [])], input.requestedPolicyId);
|
|
121
|
+
const duration = parseDuration(rule.duration);
|
|
122
|
+
if (!input.sink.mutable && duration !== null && rule.action !== 'retain') {
|
|
123
|
+
throw new Error(`sink '${input.sink.id}' cannot satisfy finite lifecycle action '${rule.action}'`);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
schemaVersion: 'ops-evidence-lifecycle.aiwg.io/v1',
|
|
127
|
+
artifactId: input.artifactId.startsWith('sha256:') && /^sha256:[a-f0-9]{64}$/.test(input.artifactId)
|
|
128
|
+
? input.artifactId
|
|
129
|
+
: digest(input.artifactId),
|
|
130
|
+
category: input.category,
|
|
131
|
+
classification: input.classification,
|
|
132
|
+
sinkId: input.sink.id,
|
|
133
|
+
tier: input.tier,
|
|
134
|
+
createdAt,
|
|
135
|
+
policyId: rule.id,
|
|
136
|
+
policyVersion: rule.version,
|
|
137
|
+
dispositionDeadline: duration === null ? null : new Date(created + duration).toISOString(),
|
|
138
|
+
action: rule.action,
|
|
139
|
+
...(rule.redactFields ? { dispositionFields: [...rule.redactFields] } : {}),
|
|
140
|
+
...(rule.archiveSink ? { archiveSink: rule.archiveSink } : {}),
|
|
141
|
+
...(input.rawCaptureReason ? { rawCaptureReasonDigest: digest(input.rawCaptureReason) } : {}),
|
|
142
|
+
holds: [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export function reapplyRetentionPolicy(input) {
|
|
146
|
+
const metadata = createEvidenceLifecycle({
|
|
147
|
+
artifactId: input.record.metadata.artifactId,
|
|
148
|
+
category: input.record.metadata.category,
|
|
149
|
+
classification: input.record.metadata.classification,
|
|
150
|
+
sink: input.sink,
|
|
151
|
+
tier: input.record.metadata.tier,
|
|
152
|
+
rules: input.rules,
|
|
153
|
+
requestedPolicyId: input.requestedPolicyId,
|
|
154
|
+
rawCaptureReason: input.record.metadata.rawCaptureReasonDigest ? 'previously-approved-raw-capture' : undefined,
|
|
155
|
+
createdAt: input.record.metadata.createdAt,
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
payload: input.record.payload,
|
|
159
|
+
metadata: {
|
|
160
|
+
...metadata,
|
|
161
|
+
...(input.record.metadata.rawCaptureReasonDigest
|
|
162
|
+
? { rawCaptureReasonDigest: input.record.metadata.rawCaptureReasonDigest }
|
|
163
|
+
: {}),
|
|
164
|
+
holds: [...input.record.metadata.holds],
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export function placeEvidenceHold(input) {
|
|
169
|
+
if (!input.holdId || !input.actor || !input.reason.trim())
|
|
170
|
+
throw new Error('hold ID, actor, and reason are required');
|
|
171
|
+
if (input.record.metadata.holds.some((hold) => hold.id === input.holdId && !hold.releasedAt)) {
|
|
172
|
+
throw new Error(`hold '${input.holdId}' is already active`);
|
|
173
|
+
}
|
|
174
|
+
const occurredAt = (input.now ?? new Date()).toISOString();
|
|
175
|
+
const reasonDigest = digest(input.reason);
|
|
176
|
+
const hold = { id: input.holdId, actor: input.actor, reasonDigest, placedAt: occurredAt };
|
|
177
|
+
return {
|
|
178
|
+
record: {
|
|
179
|
+
payload: input.record.payload,
|
|
180
|
+
metadata: { ...input.record.metadata, holds: [...input.record.metadata.holds, hold] },
|
|
181
|
+
},
|
|
182
|
+
audit: {
|
|
183
|
+
schemaVersion: 'ops-evidence-hold.aiwg.io/v1', eventId: randomUUID(),
|
|
184
|
+
artifactId: input.record.metadata.artifactId, holdId: input.holdId,
|
|
185
|
+
action: 'placed', actor: input.actor, reasonDigest, occurredAt,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export function releaseEvidenceHold(input) {
|
|
190
|
+
if (!input.actor || !input.reason.trim())
|
|
191
|
+
throw new Error('release actor and reason are required');
|
|
192
|
+
const occurredAt = (input.now ?? new Date()).toISOString();
|
|
193
|
+
const reasonDigest = digest(input.reason);
|
|
194
|
+
let released = false;
|
|
195
|
+
const holds = input.record.metadata.holds.map((hold) => {
|
|
196
|
+
if (hold.id !== input.holdId || hold.releasedAt)
|
|
197
|
+
return hold;
|
|
198
|
+
released = true;
|
|
199
|
+
return { ...hold, releasedAt: occurredAt, releasedBy: input.actor, releaseReasonDigest: reasonDigest };
|
|
200
|
+
});
|
|
201
|
+
if (!released)
|
|
202
|
+
throw new Error(`active hold '${input.holdId}' was not found`);
|
|
203
|
+
return {
|
|
204
|
+
record: { payload: input.record.payload, metadata: { ...input.record.metadata, holds } },
|
|
205
|
+
audit: {
|
|
206
|
+
schemaVersion: 'ops-evidence-hold.aiwg.io/v1', eventId: randomUUID(),
|
|
207
|
+
artifactId: input.record.metadata.artifactId, holdId: input.holdId,
|
|
208
|
+
action: 'released', actor: input.actor, reasonDigest, occurredAt,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function receipt(record, action, outcome, occurredAt, extras = {}) {
|
|
213
|
+
return {
|
|
214
|
+
schemaVersion: 'ops-disposition-receipt.aiwg.io/v1',
|
|
215
|
+
receiptId: randomUUID(),
|
|
216
|
+
artifactId: record.metadata.artifactId,
|
|
217
|
+
policyId: record.metadata.policyId,
|
|
218
|
+
policyVersion: record.metadata.policyVersion,
|
|
219
|
+
action,
|
|
220
|
+
outcome,
|
|
221
|
+
occurredAt,
|
|
222
|
+
...(extras.destinationId ? { destinationId: extras.destinationId } : {}),
|
|
223
|
+
...(extras.errorCode ? { errorCode: extras.errorCode } : {}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function errorCode(error) {
|
|
227
|
+
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' && /^[A-Z0-9_-]{1,64}$/.test(error.code)) {
|
|
228
|
+
return error.code;
|
|
229
|
+
}
|
|
230
|
+
return 'LIFECYCLE_ACTION_FAILED';
|
|
231
|
+
}
|
|
232
|
+
/** Execute a due lifecycle action. Receipts contain identifiers/outcomes only, never removed payloads or error messages. */
|
|
233
|
+
export async function executeLifecycle(record, adapter, now = new Date()) {
|
|
234
|
+
const occurredAt = now.toISOString();
|
|
235
|
+
if (record.metadata.holds.some((hold) => !hold.releasedAt)) {
|
|
236
|
+
return receipt(record, record.metadata.action, 'held', occurredAt);
|
|
237
|
+
}
|
|
238
|
+
if (record.metadata.dispositionDeadline === null || Date.parse(record.metadata.dispositionDeadline) > now.getTime()) {
|
|
239
|
+
return receipt(record, record.metadata.action, 'not-due', occurredAt);
|
|
240
|
+
}
|
|
241
|
+
const action = record.metadata.action;
|
|
242
|
+
try {
|
|
243
|
+
switch (action) {
|
|
244
|
+
case 'retain': break;
|
|
245
|
+
case 'summarize':
|
|
246
|
+
if (!adapter.summarize)
|
|
247
|
+
throw Object.assign(new Error('summarize adapter is unavailable'), { code: 'SUMMARIZE_UNAVAILABLE' });
|
|
248
|
+
await adapter.summarize(record);
|
|
249
|
+
break;
|
|
250
|
+
case 'redact-fields':
|
|
251
|
+
if (!adapter.redactFields)
|
|
252
|
+
throw Object.assign(new Error('redact-fields adapter is unavailable'), { code: 'REDACT_FIELDS_UNAVAILABLE' });
|
|
253
|
+
await adapter.redactFields(record, record.metadata.dispositionFields ?? []);
|
|
254
|
+
break;
|
|
255
|
+
case 'archive': {
|
|
256
|
+
if (!adapter.archive)
|
|
257
|
+
throw Object.assign(new Error('archive adapter is unavailable'), { code: 'ARCHIVE_UNAVAILABLE' });
|
|
258
|
+
const destination = record.metadata.archiveSink ?? 'project-configured-archive';
|
|
259
|
+
await adapter.archive(record, destination);
|
|
260
|
+
return receipt(record, action, 'completed', occurredAt, { destinationId: destination });
|
|
261
|
+
}
|
|
262
|
+
case 'delete':
|
|
263
|
+
if (!adapter.delete)
|
|
264
|
+
throw Object.assign(new Error('delete adapter is unavailable'), { code: 'DELETE_UNAVAILABLE' });
|
|
265
|
+
await adapter.delete(record);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
return receipt(record, action, 'completed', occurredAt);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
return receipt(record, action, 'failed', occurredAt, { errorCode: errorCode(error) });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
//# sourceMappingURL=retention.js.map
|
|
@@ -2,13 +2,12 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { resolveWorkspaceFile } from './flow.js';
|
|
5
|
+
import { redactText } from '../governance/redaction.js';
|
|
5
6
|
function redact(text, sensitiveValues) {
|
|
6
7
|
let output = text;
|
|
7
8
|
for (const value of sensitiveValues.filter(value => value.length >= 4))
|
|
8
9
|
output = output.split(value).join('[REDACTED]');
|
|
9
|
-
return output
|
|
10
|
-
.replace(/\b(authorization|cookie|set-cookie)\s*[:=]\s*[^\s,;]+/giu, '$1=[REDACTED]')
|
|
11
|
-
.replace(/\b(bearer|token)\s+[A-Za-z0-9._~+\/-]{8,}/giu, '$1 [REDACTED]');
|
|
10
|
+
return redactText(output).text;
|
|
12
11
|
}
|
|
13
12
|
async function sensitiveValues(files) {
|
|
14
13
|
const values = [];
|