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.
Files changed (60) hide show
  1. package/README.md +76 -4
  2. package/dist/agent-blackboard/client.d.mts +34 -0
  3. package/dist/agent-blackboard/client.mjs +41 -0
  4. package/dist/agent-blackboard/feedback-canonical.d.mts +1 -0
  5. package/dist/agent-blackboard/feedback-canonical.mjs +13 -0
  6. package/dist/agent-blackboard/feedback-codec.d.mts +7 -0
  7. package/dist/agent-blackboard/feedback-codec.mjs +121 -0
  8. package/dist/agent-blackboard/feedback-compose.d.mts +37 -0
  9. package/dist/agent-blackboard/feedback-compose.mjs +109 -0
  10. package/dist/agent-blackboard/feedback-deadline.d.mts +1 -0
  11. package/dist/agent-blackboard/feedback-deadline.mjs +18 -0
  12. package/dist/agent-blackboard/feedback-delivery.d.mts +18 -0
  13. package/dist/agent-blackboard/feedback-delivery.mjs +88 -0
  14. package/dist/agent-blackboard/feedback-fields.d.mts +4 -0
  15. package/dist/agent-blackboard/feedback-fields.mjs +38 -0
  16. package/dist/agent-blackboard/feedback-identity.d.mts +2 -0
  17. package/dist/agent-blackboard/feedback-identity.mjs +29 -0
  18. package/dist/agent-blackboard/feedback-online-error.d.mts +7 -0
  19. package/dist/agent-blackboard/feedback-online-error.mjs +23 -0
  20. package/dist/agent-blackboard/feedback-online.d.mts +2 -0
  21. package/dist/agent-blackboard/feedback-online.mjs +137 -0
  22. package/dist/agent-blackboard/feedback-outbox.d.mts +8 -0
  23. package/dist/agent-blackboard/feedback-outbox.mjs +157 -0
  24. package/dist/agent-blackboard/feedback-types.d.mts +66 -0
  25. package/dist/agent-blackboard/feedback-types.mjs +1 -0
  26. package/dist/agent-blackboard/index.d.mts +16 -39
  27. package/dist/agent-blackboard/index.mjs +35 -81
  28. package/dist/cli/commands/agent-blackboard.mjs +35 -4
  29. package/dist/cli/usage.d.mts +1 -1
  30. package/dist/cli/usage.mjs +3 -1
  31. package/dist/retrospective-facts/coverage.d.mts +6 -0
  32. package/dist/retrospective-facts/coverage.mjs +42 -0
  33. package/dist/retrospective-facts/index.d.mts +3 -0
  34. package/dist/retrospective-facts/index.mjs +18 -1
  35. package/dist/retrospective-transcript/codex.mjs +8 -3
  36. package/dist/retrospective-transcript/index.d.mts +5 -0
  37. package/dist/retrospective-transcript/index.mjs +29 -9
  38. package/dist/session-friction/classify.mjs +25 -14
  39. package/dist/session-friction/coverage-ledger.d.mts +10 -0
  40. package/dist/session-friction/coverage-ledger.mjs +75 -0
  41. package/dist/session-friction/index.d.mts +1 -1
  42. package/dist/session-friction/log-file.d.mts +2 -2
  43. package/dist/session-friction/log-file.mjs +22 -10
  44. package/dist/session-friction/log.mjs +57 -10
  45. package/dist/session-friction/report.mjs +36 -7
  46. package/dist/session-friction/sandbox.mjs +11 -4
  47. package/dist/session-friction/types.d.mts +25 -4
  48. package/dist/workflow-policy/index.d.mts +1 -1
  49. package/dist/workflow-policy/index.mjs +1 -1
  50. package/dist/workflow-policy/permission-declarations.d.mts +14 -0
  51. package/dist/workflow-policy/permission-declarations.mjs +55 -0
  52. package/dist/workflow-policy/permission-inheritance.d.mts +8 -0
  53. package/dist/workflow-policy/permission-inheritance.mjs +88 -0
  54. package/dist/workflow-policy/permission-mismatches.d.mts +4 -1
  55. package/dist/workflow-policy/permission-mismatches.mjs +14 -3
  56. package/docs/workflow-and-compiler.md +18 -1
  57. package/package.json +1 -1
  58. package/skills/blackboard/SKILL.md +34 -14
  59. package/skills/retrospective/SKILL.md +27 -16
  60. 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' || record.kind === 'sandbox-failure') &&
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 > LOG_MAX_BYTES)
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(LOG_MAX_BYTES + 1);
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 > LOG_MAX_BYTES)
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 { atEventLimit, LOG_MAX_BYTES, openLogFile, readLogContent, validEvent, writeAll, } from './log-file.mjs';
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
- if (atEventLimit(content, maxEvents))
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 (fstatSync(descriptor).size + Buffer.byteLength(addition) > LOG_MAX_BYTES)
70
- throw new Error('session-friction log is too large');
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
- events.push(value);
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
- return events.length ? { status: 'events', events } : { status: 'empty' };
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 undefined;
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 data = journalData(entry);
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
- markdown: '## CI Failures\nStatus: unavailable (journal scan incomplete)\n\n' +
66
- sandboxMarkdown(friction),
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
- ? '## CI Failures\nStatus: unavailable (journal scan incomplete)'
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 = ['sandbox-escalation', 'sandbox-failure'];
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 = events.filter((event) => event.kind === kind);
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: ${events.length}\n\n${groups.join('\n')}`;
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,8 @@
1
+ import type { Workflow } from './permissions.mts';
2
+ export declare function inheritanceAwarePermissionMismatches(args: {
3
+ callerPath: string;
4
+ callerJobKey: string;
5
+ callerWorkflow: Workflow;
6
+ calleePath: string;
7
+ calleeWorkflow: Workflow;
8
+ }): string[];
@@ -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 declare function callerCalleePermissionMismatches(topology: WorkflowTopology, documents: Readonly<Record<string, Workflow>>): string[];
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.permissions);
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. Readiness checks rely on the parser's
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.23.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": {