vouchington-tooling 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -4
- package/dist/agent-blackboard/client.d.mts +34 -0
- package/dist/agent-blackboard/client.mjs +41 -0
- package/dist/agent-blackboard/feedback-canonical.d.mts +1 -0
- package/dist/agent-blackboard/feedback-canonical.mjs +13 -0
- package/dist/agent-blackboard/feedback-codec.d.mts +7 -0
- package/dist/agent-blackboard/feedback-codec.mjs +121 -0
- package/dist/agent-blackboard/feedback-compose.d.mts +37 -0
- package/dist/agent-blackboard/feedback-compose.mjs +109 -0
- package/dist/agent-blackboard/feedback-deadline.d.mts +1 -0
- package/dist/agent-blackboard/feedback-deadline.mjs +18 -0
- package/dist/agent-blackboard/feedback-delivery.d.mts +18 -0
- package/dist/agent-blackboard/feedback-delivery.mjs +88 -0
- package/dist/agent-blackboard/feedback-fields.d.mts +4 -0
- package/dist/agent-blackboard/feedback-fields.mjs +38 -0
- package/dist/agent-blackboard/feedback-identity.d.mts +2 -0
- package/dist/agent-blackboard/feedback-identity.mjs +29 -0
- package/dist/agent-blackboard/feedback-online-error.d.mts +7 -0
- package/dist/agent-blackboard/feedback-online-error.mjs +23 -0
- package/dist/agent-blackboard/feedback-online.d.mts +2 -0
- package/dist/agent-blackboard/feedback-online.mjs +137 -0
- package/dist/agent-blackboard/feedback-outbox.d.mts +8 -0
- package/dist/agent-blackboard/feedback-outbox.mjs +157 -0
- package/dist/agent-blackboard/feedback-types.d.mts +66 -0
- package/dist/agent-blackboard/feedback-types.mjs +1 -0
- package/dist/agent-blackboard/index.d.mts +16 -39
- package/dist/agent-blackboard/index.mjs +35 -81
- package/dist/cli/commands/agent-blackboard.mjs +35 -4
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +3 -1
- package/dist/retrospective-facts/coverage.d.mts +6 -0
- package/dist/retrospective-facts/coverage.mjs +42 -0
- package/dist/retrospective-facts/index.d.mts +3 -0
- package/dist/retrospective-facts/index.mjs +18 -1
- package/dist/retrospective-transcript/codex.mjs +8 -3
- package/dist/retrospective-transcript/index.d.mts +5 -0
- package/dist/retrospective-transcript/index.mjs +29 -9
- package/dist/session-friction/classify.mjs +25 -14
- package/dist/session-friction/coverage-ledger.d.mts +10 -0
- package/dist/session-friction/coverage-ledger.mjs +75 -0
- package/dist/session-friction/index.d.mts +1 -1
- package/dist/session-friction/log-file.d.mts +2 -2
- package/dist/session-friction/log-file.mjs +22 -10
- package/dist/session-friction/log.mjs +57 -10
- package/dist/session-friction/report.mjs +36 -7
- package/dist/session-friction/sandbox.mjs +11 -4
- package/dist/session-friction/types.d.mts +25 -4
- package/dist/workflow-policy/index.d.mts +1 -1
- package/dist/workflow-policy/index.mjs +1 -1
- package/dist/workflow-policy/permission-declarations.d.mts +14 -0
- package/dist/workflow-policy/permission-declarations.mjs +55 -0
- package/dist/workflow-policy/permission-inheritance.d.mts +8 -0
- package/dist/workflow-policy/permission-inheritance.mjs +88 -0
- package/dist/workflow-policy/permission-mismatches.d.mts +4 -1
- package/dist/workflow-policy/permission-mismatches.mjs +14 -3
- package/docs/workflow-and-compiler.md +18 -1
- package/package.json +1 -1
- package/skills/blackboard/SKILL.md +34 -14
- package/skills/retrospective/SKILL.md +27 -16
- package/skills/retrospective-distill/SKILL.md +15 -6
|
@@ -7,10 +7,25 @@ export function validEvent(value) {
|
|
|
7
7
|
return false;
|
|
8
8
|
// oxlint-disable-next-line no-mistakes/ts-no-const-aliases -- establish a record view after the runtime object check
|
|
9
9
|
const record = value;
|
|
10
|
-
return ((record.kind === 'sandbox-escalation' ||
|
|
10
|
+
return ((record.kind === 'sandbox-escalation' ||
|
|
11
|
+
record.kind === 'sandbox-failure' ||
|
|
12
|
+
record.kind === 'ambiguous-failure') &&
|
|
11
13
|
isSafeAuditText(record.timestamp) &&
|
|
12
14
|
isSafeAuditText(record.commandPrefix) &&
|
|
13
|
-
isSafeAuditText(record.detail)
|
|
15
|
+
isSafeAuditText(record.detail) &&
|
|
16
|
+
(record.failure === undefined || validFailure(record.failure)) &&
|
|
17
|
+
(record.outcome === undefined ||
|
|
18
|
+
(typeof record.outcome === 'string' &&
|
|
19
|
+
['requested', 'approved', 'denied', 'unknown'].includes(record.outcome))));
|
|
20
|
+
}
|
|
21
|
+
function validFailure(value) {
|
|
22
|
+
if (!value || typeof value !== 'object')
|
|
23
|
+
return false;
|
|
24
|
+
// oxlint-disable-next-line no-mistakes/ts-no-const-aliases -- establish a validated object view
|
|
25
|
+
const failure = value;
|
|
26
|
+
return (Object.keys(failure).length === 2 &&
|
|
27
|
+
(failure.kind === 'sandbox-failure' || failure.kind === 'ambiguous-failure') &&
|
|
28
|
+
isSafeAuditText(failure.detail));
|
|
14
29
|
}
|
|
15
30
|
export function openLogFile(path, flags) {
|
|
16
31
|
let descriptor;
|
|
@@ -36,10 +51,10 @@ export function openLogFile(path, flags) {
|
|
|
36
51
|
throw error;
|
|
37
52
|
}
|
|
38
53
|
}
|
|
39
|
-
export function readLogContent(descriptor) {
|
|
40
|
-
if (fstatSync(descriptor).size >
|
|
54
|
+
export function readLogContent(descriptor, maximumBytes = LOG_MAX_BYTES) {
|
|
55
|
+
if (fstatSync(descriptor).size > maximumBytes)
|
|
41
56
|
throw new Error('session-friction log is too large');
|
|
42
|
-
const buffer = Buffer.alloc(
|
|
57
|
+
const buffer = Buffer.alloc(maximumBytes + 1);
|
|
43
58
|
let length = 0;
|
|
44
59
|
while (length < buffer.length) {
|
|
45
60
|
const bytes = readSync(descriptor, buffer, length, buffer.length - length, length);
|
|
@@ -48,11 +63,11 @@ export function readLogContent(descriptor) {
|
|
|
48
63
|
length += bytes;
|
|
49
64
|
}
|
|
50
65
|
/* v8 ignore next -- detects external growth after the descriptor size check. */
|
|
51
|
-
if (length >
|
|
66
|
+
if (length > maximumBytes)
|
|
52
67
|
throw new Error('session-friction log is too large');
|
|
53
68
|
return decodeUtf8(buffer.subarray(0, length));
|
|
54
69
|
}
|
|
55
|
-
function validEventCount(content, limit) {
|
|
70
|
+
export function validEventCount(content, limit) {
|
|
56
71
|
let count = 0;
|
|
57
72
|
for (const line of content.split('\n')) {
|
|
58
73
|
if (!line.trim())
|
|
@@ -67,9 +82,6 @@ function validEventCount(content, limit) {
|
|
|
67
82
|
}
|
|
68
83
|
return count;
|
|
69
84
|
}
|
|
70
|
-
export function atEventLimit(content, limit) {
|
|
71
|
-
return validEventCount(content, limit) >= limit;
|
|
72
|
-
}
|
|
73
85
|
export function writeAll(descriptor, value) {
|
|
74
86
|
const buffer = Buffer.from(value);
|
|
75
87
|
let offset = 0;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { readCoverageLedger, writeCoverageLedger } from './coverage-ledger.mjs';
|
|
1
2
|
import { closeSync, constants, fchmodSync, fstatSync } from 'node:fs';
|
|
2
3
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
4
|
import { classifyFrictionObservation } from './classify.mjs';
|
|
4
5
|
import { ensurePrivateDirectory } from './directory.mjs';
|
|
5
6
|
import { withFileLock } from './lock.mjs';
|
|
6
|
-
import {
|
|
7
|
+
import { validEventCount, LOG_MAX_BYTES, openLogFile, readLogContent, validEvent, writeAll, } from './log-file.mjs';
|
|
7
8
|
import { sanitizeSessionId } from './session-id.mjs';
|
|
8
9
|
import { boundedText, isWellFormedUnicode, normalizeAuditText } from './text.mjs';
|
|
9
10
|
export const FRICTION_LOG_MAX_EVENTS = 500;
|
|
@@ -52,9 +53,23 @@ export function recordFriction(sessionId, observation, options) {
|
|
|
52
53
|
throw new Error('session-friction log is too large');
|
|
53
54
|
if (!classified)
|
|
54
55
|
return;
|
|
56
|
+
const coverage = readCoverageLedger(path);
|
|
57
|
+
const logBytes = fstatSync(descriptor).size;
|
|
58
|
+
if (coverage && coverage.logBytes === logBytes && coverage.retainedEvents >= maxEvents) {
|
|
59
|
+
writeCoverageLedger(path, { ...coverage, droppedCount: coverage.droppedCount + 1 });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
55
62
|
const content = readLogContent(descriptor);
|
|
56
|
-
|
|
63
|
+
const retainedEvents = validEventCount(content, FRICTION_LOG_MAX_EVENTS);
|
|
64
|
+
if (retainedEvents >= maxEvents) {
|
|
65
|
+
writeCoverageLedger(path, {
|
|
66
|
+
schemaVersion: 1,
|
|
67
|
+
logBytes,
|
|
68
|
+
retainedEvents,
|
|
69
|
+
droppedCount: (coverage?.droppedCount ?? 0) + 1,
|
|
70
|
+
});
|
|
57
71
|
return;
|
|
72
|
+
}
|
|
58
73
|
const event = {
|
|
59
74
|
...classified,
|
|
60
75
|
commandPrefix: normalizeAuditText(classified.commandPrefix),
|
|
@@ -66,9 +81,22 @@ export function recordFriction(sessionId, observation, options) {
|
|
|
66
81
|
return;
|
|
67
82
|
const prefix = content && !content.endsWith('\n') ? '\n' : '';
|
|
68
83
|
const addition = `${prefix}${JSON.stringify(event)}\n`;
|
|
69
|
-
if (
|
|
70
|
-
|
|
84
|
+
if (logBytes + Buffer.byteLength(addition) > LOG_MAX_BYTES) {
|
|
85
|
+
writeCoverageLedger(path, {
|
|
86
|
+
schemaVersion: 1,
|
|
87
|
+
logBytes,
|
|
88
|
+
retainedEvents,
|
|
89
|
+
droppedCount: (coverage?.droppedCount ?? 0) + 1,
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
71
93
|
writeAll(descriptor, addition);
|
|
94
|
+
if (coverage)
|
|
95
|
+
writeCoverageLedger(path, {
|
|
96
|
+
...coverage,
|
|
97
|
+
logBytes: fstatSync(descriptor).size,
|
|
98
|
+
retainedEvents: retainedEvents + 1,
|
|
99
|
+
});
|
|
72
100
|
}
|
|
73
101
|
finally {
|
|
74
102
|
closeSync(descriptor);
|
|
@@ -89,19 +117,38 @@ export function readFrictionLog(sessionId, options) {
|
|
|
89
117
|
const descriptor = openLogFile(path, constants.O_RDONLY);
|
|
90
118
|
try {
|
|
91
119
|
const events = [];
|
|
120
|
+
const ledger = readCoverageLedger(path);
|
|
121
|
+
let droppedCount = ledger?.droppedCount ?? 0;
|
|
122
|
+
let retainedEvents = 0;
|
|
92
123
|
for (const line of readLogContent(descriptor).split('\n')) {
|
|
93
124
|
if (!line.trim())
|
|
94
125
|
continue;
|
|
95
126
|
try {
|
|
96
127
|
const value = JSON.parse(line);
|
|
97
|
-
if (validEvent(value))
|
|
98
|
-
|
|
128
|
+
if (validEvent(value)) {
|
|
129
|
+
retainedEvents++;
|
|
130
|
+
if (events.length < maxEvents)
|
|
131
|
+
events.push(value);
|
|
132
|
+
else
|
|
133
|
+
droppedCount++;
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
droppedCount++;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
droppedCount++;
|
|
99
140
|
}
|
|
100
|
-
catch { }
|
|
101
|
-
if (events.length >= maxEvents)
|
|
102
|
-
break;
|
|
103
141
|
}
|
|
104
|
-
|
|
142
|
+
if (ledger &&
|
|
143
|
+
(ledger.logBytes !== fstatSync(descriptor).size ||
|
|
144
|
+
ledger.retainedEvents !== Math.min(retainedEvents, FRICTION_LOG_MAX_EVENTS)))
|
|
145
|
+
throw new Error('session-friction coverage ledger does not match retained log');
|
|
146
|
+
if (!Number.isSafeInteger(droppedCount))
|
|
147
|
+
throw new Error('session-friction dropped count overflow; capture incomplete');
|
|
148
|
+
const coverage = droppedCount ? { truncated: true, droppedCount } : {};
|
|
149
|
+
return events.length
|
|
150
|
+
? { status: 'events', events, ...coverage }
|
|
151
|
+
: { status: 'empty', ...coverage };
|
|
105
152
|
}
|
|
106
153
|
finally {
|
|
107
154
|
closeSync(descriptor);
|
|
@@ -11,7 +11,7 @@ function journalData(entry) {
|
|
|
11
11
|
return data ? { type: data.type, markdown: data.markdown } : undefined;
|
|
12
12
|
}
|
|
13
13
|
catch {
|
|
14
|
-
return
|
|
14
|
+
return 'unreadable';
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
async function collectEntries(entries) {
|
|
@@ -22,8 +22,13 @@ async function collectEntries(entries) {
|
|
|
22
22
|
let truncated = false;
|
|
23
23
|
for await (const entry of entries) {
|
|
24
24
|
consumed++;
|
|
25
|
-
const
|
|
25
|
+
const scanned = journalData(entry);
|
|
26
|
+
if (scanned === 'unreadable')
|
|
27
|
+
truncated = true;
|
|
28
|
+
const data = scanned === 'unreadable' ? undefined : scanned;
|
|
26
29
|
const markdown = data?.markdown;
|
|
30
|
+
if (data?.type === 'journal' && typeof markdown !== 'string')
|
|
31
|
+
truncated = true;
|
|
27
32
|
if (data?.type === 'journal' && typeof markdown === 'string') {
|
|
28
33
|
const bytes = Buffer.byteLength(markdown);
|
|
29
34
|
inspectedBytes += bytes;
|
|
@@ -53,28 +58,52 @@ function errorMessage(error) {
|
|
|
53
58
|
}
|
|
54
59
|
function sandboxMarkdown(friction) {
|
|
55
60
|
if (friction.status === 'events')
|
|
56
|
-
return buildSandboxSection(friction.events)
|
|
61
|
+
return `${friction.truncated ? `## Capture Coverage\nStatus: partial\nDropped records: ${friction.droppedCount}\n\n` : ''}${buildSandboxSection(friction.events)}`;
|
|
62
|
+
if (friction.status === 'empty' && friction.truncated)
|
|
63
|
+
return `## Sandbox & Permission Audit\nStatus: unavailable (partial capture; dropped ${friction.droppedCount} records)`;
|
|
57
64
|
const status = friction.status === 'empty' ? 'none observed' : 'unavailable (no friction log)';
|
|
58
65
|
return `## Sandbox & Permission Audit\nStatus: ${status}`;
|
|
59
66
|
}
|
|
67
|
+
function incompleteCiSection(markdownBlocks) {
|
|
68
|
+
return ['## CI Failures\nStatus: unavailable (journal scan incomplete)', ...markdownBlocks].join('\n\n');
|
|
69
|
+
}
|
|
60
70
|
function reportFromLog(sessionId, logOptions, journal) {
|
|
61
71
|
try {
|
|
62
72
|
const friction = readFrictionLog(sessionId, logOptions);
|
|
73
|
+
const coverage = {
|
|
74
|
+
journalStatus: journal.status === 'unreachable'
|
|
75
|
+
? 'unavailable'
|
|
76
|
+
: journal.truncated
|
|
77
|
+
? 'partial'
|
|
78
|
+
: 'complete',
|
|
79
|
+
frictionStatus: friction.status,
|
|
80
|
+
truncated: friction.status !== 'absent' && friction.truncated === true,
|
|
81
|
+
...(friction.status === 'absent' ? {} : { droppedCount: friction.droppedCount ?? 0 }),
|
|
82
|
+
};
|
|
63
83
|
if (journal.status === 'ok' && journal.truncated)
|
|
64
84
|
return {
|
|
65
|
-
|
|
66
|
-
|
|
85
|
+
coverage,
|
|
86
|
+
markdown: incompleteCiSection(journal.markdownBlocks) + '\n\n' + sandboxMarkdown(friction),
|
|
67
87
|
};
|
|
68
88
|
const markdown = buildCiFailuresSection(sessionId, journal, friction.status);
|
|
69
|
-
return { markdown: `${markdown}\n\n${sandboxMarkdown(friction)}` };
|
|
89
|
+
return { coverage, markdown: `${markdown}\n\n${sandboxMarkdown(friction)}` };
|
|
70
90
|
}
|
|
71
91
|
catch (error) {
|
|
72
92
|
const ciMarkdown = journal.status === 'ok' && journal.truncated
|
|
73
|
-
?
|
|
93
|
+
? incompleteCiSection(journal.markdownBlocks)
|
|
74
94
|
: journal.status === 'ok' && journal.markdownBlocks.length === 0
|
|
75
95
|
? '## CI Failures\nStatus: unavailable (friction log unreadable)'
|
|
76
96
|
: buildCiFailuresSection(sessionId, journal, 'empty');
|
|
77
97
|
return {
|
|
98
|
+
coverage: {
|
|
99
|
+
journalStatus: journal.status === 'unreachable'
|
|
100
|
+
? 'unavailable'
|
|
101
|
+
: journal.truncated
|
|
102
|
+
? 'partial'
|
|
103
|
+
: 'complete',
|
|
104
|
+
frictionStatus: 'unreadable',
|
|
105
|
+
truncated: true,
|
|
106
|
+
},
|
|
78
107
|
markdown: `${ciMarkdown}\n\n## Sandbox & Permission Audit\nStatus: unavailable (friction log unreadable)`,
|
|
79
108
|
diagnostic: errorMessage(error),
|
|
80
109
|
};
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { markdownAuditText } from './text.mjs';
|
|
2
2
|
const SANDBOX_SECTION_HEADER = '## Sandbox & Permission Audit';
|
|
3
|
-
const KIND_ORDER = [
|
|
3
|
+
const KIND_ORDER = [
|
|
4
|
+
'sandbox-escalation',
|
|
5
|
+
'sandbox-failure',
|
|
6
|
+
'ambiguous-failure',
|
|
7
|
+
];
|
|
4
8
|
export function buildSandboxSection(events) {
|
|
9
|
+
const observations = events.flatMap((event) => event.failure
|
|
10
|
+
? [event, { ...event, kind: event.failure.kind, detail: event.failure.detail }]
|
|
11
|
+
: [event]);
|
|
5
12
|
const groups = KIND_ORDER.map((kind) => {
|
|
6
|
-
const selected =
|
|
13
|
+
const selected = observations.filter((event) => event.kind === kind);
|
|
7
14
|
return selected.length
|
|
8
15
|
? [
|
|
9
16
|
`- ${kind} (${selected.length})`,
|
|
10
|
-
...selected.map((event) => ` - ${markdownAuditText(event.commandPrefix)} — ${markdownAuditText(event.detail)} — ${markdownAuditText(event.timestamp)}`),
|
|
17
|
+
...selected.map((event) => ` - ${markdownAuditText(event.commandPrefix)} — ${markdownAuditText(event.detail)} — ${markdownAuditText(event.timestamp)}${event.kind === 'sandbox-escalation' ? ` — outcome: ${event.outcome ?? 'unknown'}` : ''}`),
|
|
11
18
|
].join('\n')
|
|
12
19
|
: undefined;
|
|
13
20
|
}).filter((group) => group !== undefined);
|
|
14
|
-
return `${SANDBOX_SECTION_HEADER}\nEvents observed: ${
|
|
21
|
+
return `${SANDBOX_SECTION_HEADER}\nEvents observed: ${observations.length}\n\n${groups.join('\n')}`;
|
|
15
22
|
}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
export type FrictionEventKind = 'sandbox-escalation' | 'sandbox-failure';
|
|
1
|
+
export type FrictionEventKind = 'sandbox-escalation' | 'sandbox-failure' | 'ambiguous-failure';
|
|
2
2
|
export type FrictionEvent = {
|
|
3
3
|
kind: FrictionEventKind;
|
|
4
4
|
timestamp: string;
|
|
5
5
|
commandPrefix: string;
|
|
6
6
|
detail: string;
|
|
7
|
+
outcome?: 'requested' | 'approved' | 'denied' | 'unknown';
|
|
8
|
+
failure?: {
|
|
9
|
+
kind: 'sandbox-failure' | 'ambiguous-failure';
|
|
10
|
+
detail: string;
|
|
11
|
+
};
|
|
7
12
|
};
|
|
8
13
|
export type ToolResultObservation = {
|
|
9
14
|
type: 'tool-result';
|
|
10
15
|
command: string;
|
|
11
16
|
commandWrappers?: string[];
|
|
17
|
+
permissionOutcome?: 'approved' | 'denied';
|
|
12
18
|
escalationDetail?: string;
|
|
13
19
|
structuredStderr?: string;
|
|
14
20
|
};
|
|
@@ -18,14 +24,21 @@ export type PermissionRequestObservation = {
|
|
|
18
24
|
commandWrappers?: string[];
|
|
19
25
|
};
|
|
20
26
|
export type FrictionObservation = ToolResultObservation | PermissionRequestObservation;
|
|
27
|
+
type FrictionLogCoverage = {
|
|
28
|
+
truncated: true;
|
|
29
|
+
droppedCount: number;
|
|
30
|
+
} | {
|
|
31
|
+
truncated?: false;
|
|
32
|
+
droppedCount?: never;
|
|
33
|
+
};
|
|
21
34
|
export type FrictionLogReadResult = {
|
|
22
35
|
status: 'absent';
|
|
23
|
-
} | {
|
|
36
|
+
} | ({
|
|
24
37
|
status: 'empty';
|
|
25
|
-
} | {
|
|
38
|
+
} & FrictionLogCoverage) | ({
|
|
26
39
|
status: 'events';
|
|
27
40
|
events: FrictionEvent[];
|
|
28
|
-
};
|
|
41
|
+
} & FrictionLogCoverage);
|
|
29
42
|
export type FrictionLogOptions = {
|
|
30
43
|
directory: string;
|
|
31
44
|
maxEvents?: number;
|
|
@@ -43,10 +56,18 @@ export type JournalLoadResult = {
|
|
|
43
56
|
status: 'not-found';
|
|
44
57
|
};
|
|
45
58
|
export type JournalLoader = (sessionId: string) => JournalLoadResult | Promise<JournalLoadResult>;
|
|
59
|
+
export type SessionFrictionCoverage = {
|
|
60
|
+
journalStatus: 'complete' | 'partial' | 'unavailable';
|
|
61
|
+
frictionStatus: 'absent' | 'empty' | 'events' | 'unreadable';
|
|
62
|
+
truncated: boolean;
|
|
63
|
+
droppedCount?: number;
|
|
64
|
+
};
|
|
46
65
|
export type SessionFrictionReport = {
|
|
66
|
+
coverage: SessionFrictionCoverage;
|
|
47
67
|
markdown: string;
|
|
48
68
|
diagnostic?: string;
|
|
49
69
|
};
|
|
50
70
|
export type SessionFrictionReportOptions = FrictionLogOptions & {
|
|
51
71
|
journalLoader: JournalLoader;
|
|
52
72
|
};
|
|
73
|
+
export {};
|
|
@@ -2,6 +2,6 @@ export { evaluateGraphPolicy } from './graph.mts';
|
|
|
2
2
|
export { evaluateLockPolicy, type ConcurrencyPolicy } from './concurrency.mts';
|
|
3
3
|
export { conditionEntails } from './condition-entailment.mts';
|
|
4
4
|
export { unprovisionedSecretsWithoutReadinessStep } from './secret-readiness.mts';
|
|
5
|
-
export { callerCalleePermissionMismatches } from './permission-mismatches.mts';
|
|
5
|
+
export { callerCalleePermissionMismatches, type CallerCalleePermissionPolicy, } from './permission-mismatches.mts';
|
|
6
6
|
export { missingTopLevelPermissionPaths, parsePermissions, parseWorkflow, requiredWorkflowPermissions, type Workflow, } from './permissions.mts';
|
|
7
7
|
export type { WorkflowTopology, WorkflowTopologyIndex, WorkflowTopologyPolicy, WorkflowNode, WorkflowJobNode, WorkflowStep, } from './types.mts';
|
|
@@ -2,5 +2,5 @@ export { evaluateGraphPolicy } from './graph.mjs';
|
|
|
2
2
|
export { evaluateLockPolicy } from './concurrency.mjs';
|
|
3
3
|
export { conditionEntails } from './condition-entailment.mjs';
|
|
4
4
|
export { unprovisionedSecretsWithoutReadinessStep } from './secret-readiness.mjs';
|
|
5
|
-
export { callerCalleePermissionMismatches } from './permission-mismatches.mjs';
|
|
5
|
+
export { callerCalleePermissionMismatches, } from './permission-mismatches.mjs';
|
|
6
6
|
export { missingTopLevelPermissionPaths, parsePermissions, parseWorkflow, requiredWorkflowPermissions, } from './permissions.mjs';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const PERMISSION_LEVEL_RANK: {
|
|
2
|
+
readonly none: 0;
|
|
3
|
+
readonly read: 1;
|
|
4
|
+
readonly write: 2;
|
|
5
|
+
};
|
|
6
|
+
export type PermissionLevel = keyof typeof PERMISSION_LEVEL_RANK;
|
|
7
|
+
type PermissionMap = Record<string, PermissionLevel>;
|
|
8
|
+
export type BoundedPermissionDeclaration = PermissionMap | 'read-all';
|
|
9
|
+
export declare function isPlainRecord(value: unknown): value is Record<string, unknown>;
|
|
10
|
+
export declare function isValidPermissionMap(value: unknown): value is PermissionMap;
|
|
11
|
+
export declare function isValidDeclaration(value: unknown): value is BoundedPermissionDeclaration;
|
|
12
|
+
export declare function permissionMap(value: BoundedPermissionDeclaration): Map<string, PermissionLevel>;
|
|
13
|
+
export declare function mergePermissionMaps(target: Map<string, PermissionLevel>, source: Map<string, PermissionLevel>): void;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const PERMISSION_LEVEL_RANK = { none: 0, read: 1, write: 2 };
|
|
2
|
+
const LEVELS_BY_SCOPE = {
|
|
3
|
+
actions: ['none', 'read', 'write'],
|
|
4
|
+
'artifact-metadata': ['none', 'read', 'write'],
|
|
5
|
+
attestations: ['none', 'read', 'write'],
|
|
6
|
+
checks: ['none', 'read', 'write'],
|
|
7
|
+
'code-quality': ['none', 'read', 'write'],
|
|
8
|
+
contents: ['none', 'read', 'write'],
|
|
9
|
+
deployments: ['none', 'read', 'write'],
|
|
10
|
+
discussions: ['none', 'read', 'write'],
|
|
11
|
+
'id-token': ['none', 'write'],
|
|
12
|
+
issues: ['none', 'read', 'write'],
|
|
13
|
+
models: ['none', 'read'],
|
|
14
|
+
packages: ['none', 'read', 'write'],
|
|
15
|
+
pages: ['none', 'read', 'write'],
|
|
16
|
+
'pull-requests': ['none', 'read', 'write'],
|
|
17
|
+
'repository-projects': ['none', 'read', 'write'],
|
|
18
|
+
'security-events': ['none', 'read', 'write'],
|
|
19
|
+
statuses: ['none', 'read', 'write'],
|
|
20
|
+
'vulnerability-alerts': ['none', 'read'],
|
|
21
|
+
};
|
|
22
|
+
export function isPlainRecord(value) {
|
|
23
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value))
|
|
24
|
+
return false;
|
|
25
|
+
const prototype = Object.getPrototypeOf(value);
|
|
26
|
+
return prototype === Object.prototype || prototype === null;
|
|
27
|
+
}
|
|
28
|
+
export function isValidPermissionMap(value) {
|
|
29
|
+
if (!isPlainRecord(value))
|
|
30
|
+
return false;
|
|
31
|
+
return Object.entries(value).every(([scope, level]) => {
|
|
32
|
+
const allowed = Object.hasOwn(LEVELS_BY_SCOPE, scope)
|
|
33
|
+
? LEVELS_BY_SCOPE[scope]
|
|
34
|
+
: undefined;
|
|
35
|
+
return typeof level === 'string' && allowed?.includes(level) === true;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function isValidDeclaration(value) {
|
|
39
|
+
return value === 'read-all' || isValidPermissionMap(value);
|
|
40
|
+
}
|
|
41
|
+
export function permissionMap(value) {
|
|
42
|
+
if (value === 'read-all') {
|
|
43
|
+
return new Map(Object.entries(LEVELS_BY_SCOPE)
|
|
44
|
+
.filter(([, levels]) => levels.includes('read'))
|
|
45
|
+
.map(([scope]) => [scope, 'read']));
|
|
46
|
+
}
|
|
47
|
+
return new Map(Object.entries(value).filter(([, level]) => level !== 'none'));
|
|
48
|
+
}
|
|
49
|
+
export function mergePermissionMaps(target, source) {
|
|
50
|
+
for (const [scope, level] of source) {
|
|
51
|
+
const current = target.get(scope) ?? 'none';
|
|
52
|
+
if (PERMISSION_LEVEL_RANK[level] > PERMISSION_LEVEL_RANK[current])
|
|
53
|
+
target.set(scope, level);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { isPlainRecord, isValidDeclaration, isValidPermissionMap, mergePermissionMaps, permissionMap, PERMISSION_LEVEL_RANK, } from './permission-declarations.mjs';
|
|
2
|
+
function sortedEntries(value) {
|
|
3
|
+
return [...value].toSorted(([left], [right]) => left.localeCompare(right));
|
|
4
|
+
}
|
|
5
|
+
function formatMap(value) {
|
|
6
|
+
return JSON.stringify(Object.fromEntries(sortedEntries(value)));
|
|
7
|
+
}
|
|
8
|
+
function diagnostic(callerPath, callerJobKey, calleePath, detail) {
|
|
9
|
+
return ` ${callerPath} job "${callerJobKey}" → ${calleePath}: ${detail}`;
|
|
10
|
+
}
|
|
11
|
+
export function inheritanceAwarePermissionMismatches(args) {
|
|
12
|
+
const prefix = (detail) => diagnostic(args.callerPath, args.callerJobKey, args.calleePath, detail);
|
|
13
|
+
const callerJob = args.callerWorkflow.jobs?.[args.callerJobKey];
|
|
14
|
+
const callerPermissions = callerJob?.permissions;
|
|
15
|
+
if (callerPermissions === 'write-all')
|
|
16
|
+
return [prefix('caller grants write-all')];
|
|
17
|
+
if (callerJob == null ||
|
|
18
|
+
!Object.hasOwn(callerJob, 'permissions') ||
|
|
19
|
+
!isValidPermissionMap(callerPermissions)) {
|
|
20
|
+
return [prefix('caller permissions must be an explicit map')];
|
|
21
|
+
}
|
|
22
|
+
const jobsPresent = Object.hasOwn(args.calleeWorkflow, 'jobs');
|
|
23
|
+
const jobsValue = args.calleeWorkflow.jobs;
|
|
24
|
+
if (jobsPresent && !isPlainRecord(jobsValue))
|
|
25
|
+
return [prefix('callee jobs are invalid')];
|
|
26
|
+
const calleeJobEntries = Object.entries(isPlainRecord(jobsValue) ? jobsValue : {});
|
|
27
|
+
const invalidJobKeys = [];
|
|
28
|
+
const calleeJobs = [];
|
|
29
|
+
for (const [key, job] of calleeJobEntries) {
|
|
30
|
+
if (isPlainRecord(job))
|
|
31
|
+
calleeJobs.push([key, job]);
|
|
32
|
+
else
|
|
33
|
+
invalidJobKeys.push(key);
|
|
34
|
+
}
|
|
35
|
+
if (invalidJobKeys.length > 0) {
|
|
36
|
+
return invalidJobKeys.map((key) => prefix(`callee job "${key}" is invalid`));
|
|
37
|
+
}
|
|
38
|
+
const topLevelPresent = Object.hasOwn(args.calleeWorkflow, 'permissions');
|
|
39
|
+
const topLevelPermissions = args.calleeWorkflow.permissions;
|
|
40
|
+
let validatedTopLevelPermissions;
|
|
41
|
+
if (topLevelPresent) {
|
|
42
|
+
if (topLevelPermissions === 'write-all')
|
|
43
|
+
return [prefix('callee requires write-all')];
|
|
44
|
+
if (!isValidDeclaration(topLevelPermissions)) {
|
|
45
|
+
return [prefix('callee top-level permissions are invalid')];
|
|
46
|
+
}
|
|
47
|
+
validatedTopLevelPermissions = topLevelPermissions;
|
|
48
|
+
}
|
|
49
|
+
if (calleeJobs.length === 0)
|
|
50
|
+
return [prefix('callee must declare at least one job')];
|
|
51
|
+
const invalidJobs = [];
|
|
52
|
+
const validatedJobPermissions = [];
|
|
53
|
+
let requiresWriteAll = false;
|
|
54
|
+
for (const [key, job] of calleeJobs) {
|
|
55
|
+
if (!Object.hasOwn(job, 'permissions'))
|
|
56
|
+
continue;
|
|
57
|
+
if (job.permissions === 'write-all') {
|
|
58
|
+
requiresWriteAll = true;
|
|
59
|
+
}
|
|
60
|
+
else if (isValidDeclaration(job.permissions)) {
|
|
61
|
+
validatedJobPermissions.push(job.permissions);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
invalidJobs.push(prefix(`callee job "${key}" permissions are invalid`));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (invalidJobs.length > 0)
|
|
68
|
+
return invalidJobs;
|
|
69
|
+
if (requiresWriteAll)
|
|
70
|
+
return [prefix('callee requires write-all')];
|
|
71
|
+
const callerMap = permissionMap(callerPermissions);
|
|
72
|
+
const calleeMap = new Map();
|
|
73
|
+
if (validatedTopLevelPermissions !== undefined) {
|
|
74
|
+
mergePermissionMaps(calleeMap, permissionMap(validatedTopLevelPermissions));
|
|
75
|
+
}
|
|
76
|
+
for (const permissions of validatedJobPermissions) {
|
|
77
|
+
mergePermissionMaps(calleeMap, permissionMap(permissions));
|
|
78
|
+
}
|
|
79
|
+
const calleeFitsCaller = [...calleeMap].every(([scope, level]) => {
|
|
80
|
+
const callerLevel = callerMap.get(scope) ?? 'none';
|
|
81
|
+
return PERMISSION_LEVEL_RANK[callerLevel] >= PERMISSION_LEVEL_RANK[level];
|
|
82
|
+
});
|
|
83
|
+
const hasCallerInheritance = !topLevelPresent && calleeJobs.some(([, job]) => !Object.hasOwn(job, 'permissions'));
|
|
84
|
+
const permissionsMatch = JSON.stringify(sortedEntries(callerMap)) === JSON.stringify(sortedEntries(calleeMap));
|
|
85
|
+
if (calleeFitsCaller && (hasCallerInheritance || permissionsMatch))
|
|
86
|
+
return [];
|
|
87
|
+
return [prefix(`caller grants ${formatMap(callerMap)}, callee requires ${formatMap(calleeMap)}`)];
|
|
88
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
import type { WorkflowTopology } from './types.mts';
|
|
2
2
|
import { type Workflow } from './permissions.mts';
|
|
3
|
-
export
|
|
3
|
+
export type CallerCalleePermissionPolicy = {
|
|
4
|
+
comparison: 'exact' | 'inheritance-aware';
|
|
5
|
+
};
|
|
6
|
+
export declare function callerCalleePermissionMismatches(topology: WorkflowTopology, documents: Readonly<Record<string, Workflow>>, policy?: CallerCalleePermissionPolicy): string[];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parsePermissions, permissionsToMap, requiredWorkflowPermissions, } from './permissions.mjs';
|
|
2
|
+
import { inheritanceAwarePermissionMismatches } from './permission-inheritance.mjs';
|
|
2
3
|
function formatPermissions(value) {
|
|
3
4
|
if (value === null)
|
|
4
5
|
return 'none';
|
|
@@ -6,7 +7,7 @@ function formatPermissions(value) {
|
|
|
6
7
|
return value;
|
|
7
8
|
return JSON.stringify(Object.fromEntries([...value].toSorted(([a], [b]) => a.localeCompare(b))));
|
|
8
9
|
}
|
|
9
|
-
export function callerCalleePermissionMismatches(topology, documents) {
|
|
10
|
+
export function callerCalleePermissionMismatches(topology, documents, policy = { comparison: 'exact' }) {
|
|
10
11
|
const mismatches = [];
|
|
11
12
|
const jobsById = new Map(topology.jobs.map((job) => [job.id, job]));
|
|
12
13
|
const workflowsById = new Map(topology.workflows.map((workflow) => [workflow.id, workflow]));
|
|
@@ -22,14 +23,24 @@ export function callerCalleePermissionMismatches(topology, documents) {
|
|
|
22
23
|
if (!caller)
|
|
23
24
|
throw new Error(`missing workflow document: ${callerPath}`);
|
|
24
25
|
const job = caller.jobs?.[callerJob.key];
|
|
25
|
-
if (!job)
|
|
26
|
+
if (policy.comparison === 'exact' && !job)
|
|
26
27
|
continue;
|
|
27
28
|
const calleePath = callee.path;
|
|
28
29
|
const calleeWorkflow = documents[calleePath];
|
|
29
30
|
if (!calleeWorkflow)
|
|
30
31
|
throw new Error(`missing workflow document: ${calleePath}`);
|
|
32
|
+
if (policy.comparison === 'inheritance-aware') {
|
|
33
|
+
mismatches.push(...inheritanceAwarePermissionMismatches({
|
|
34
|
+
callerPath,
|
|
35
|
+
callerJobKey: callerJob.key,
|
|
36
|
+
callerWorkflow: caller,
|
|
37
|
+
calleePath,
|
|
38
|
+
calleeWorkflow,
|
|
39
|
+
}));
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
31
42
|
const calleePerms = requiredWorkflowPermissions(calleeWorkflow);
|
|
32
|
-
const jobPerms = parsePermissions(job
|
|
43
|
+
const jobPerms = parsePermissions(job?.permissions);
|
|
33
44
|
if (jobPerms == null) {
|
|
34
45
|
mismatches.push(` ${callerPath} job "${callerJob.key}" → ${calleePath}: missing explicit job-level permissions`);
|
|
35
46
|
continue;
|
|
@@ -37,7 +37,24 @@ const guardCoversConsumer = conditionEntails('always()', 'failure()')
|
|
|
37
37
|
|
|
38
38
|
The permission comparison expects documents for every local caller and callable callee in the
|
|
39
39
|
topology. The consumer reads workflow files and decides which inventory entries are provisioned;
|
|
40
|
-
these functions never fetch secret values.
|
|
40
|
+
these functions never fetch secret values. The two-argument permission comparison requires exact
|
|
41
|
+
caller/callee grants for compatibility. Consumers that intentionally let a reusable workflow job
|
|
42
|
+
inherit the caller's token can opt into the fail-closed inheritance policy:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
callerCalleePermissionMismatches(topology, documents, {
|
|
46
|
+
comparison: 'inheritance-aware',
|
|
47
|
+
})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This mode requires an explicit caller permission map, rejects malformed declarations and
|
|
51
|
+
`write-all`, and permits extra caller grants only when the callee omits top-level permissions and
|
|
52
|
+
at least one callee job omits its own permissions. Explicit grants from every callee job still form
|
|
53
|
+
required minima that must fit within the caller grants, including jobs guarded by `if`. The strict
|
|
54
|
+
scope model follows GitHub's
|
|
55
|
+
[workflow permission syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions).
|
|
56
|
+
|
|
57
|
+
Readiness checks rely on the parser's
|
|
41
58
|
`secretReferences` at workflow, job, and step scope. A missing required secret is considered ready
|
|
42
59
|
only when a preceding guarded `run` step binds it to an env var, checks that var for emptiness,
|
|
43
60
|
and exits with status 1. Optional `workflow_call` secrets are exempt.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Vouchington CLI and extractable tooling libraries.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
|
|
6
6
|
"bugs": {
|