vouchington-tooling 0.45.0 → 0.46.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 (88) hide show
  1. package/README.md +10 -52
  2. package/dist/agent-blackboard/feedback-audit-source.d.mts +4 -10
  3. package/dist/agent-blackboard/feedback-audit-source.mjs +7 -18
  4. package/dist/agent-blackboard/feedback-compose-audit.mjs +6 -14
  5. package/dist/agent-blackboard/feedback-compose.d.mts +1 -4
  6. package/dist/agent-blackboard/feedback-compose.mjs +2 -5
  7. package/dist/agent-blackboard/feedback-fields.mjs +1 -1
  8. package/dist/agent-blackboard/feedback-identity.mjs +1 -1
  9. package/dist/agent-blackboard/feedback-journal-audit.d.mts +6 -8
  10. package/dist/agent-blackboard/feedback-journal-audit.mjs +9 -10
  11. package/dist/agent-blackboard/feedback-journal-sandbox.d.mts +2 -2
  12. package/dist/agent-blackboard/feedback-journal-sandbox.mjs +2 -2
  13. package/dist/agent-blackboard/feedback-outbox-rejected.mjs +1 -1
  14. package/dist/agent-blackboard/feedback-outbox.mjs +2 -2
  15. package/dist/agent-blackboard/index.d.mts +1 -1
  16. package/dist/audit-journal/ci-failures.d.mts +6 -0
  17. package/dist/{session-friction → audit-journal}/ci-failures.mjs +3 -9
  18. package/dist/{session-friction → audit-journal}/journal.d.mts +0 -1
  19. package/dist/{session-friction → audit-journal}/journal.mjs +1 -1
  20. package/dist/audit-journal/session-id.d.mts +1 -0
  21. package/dist/{session-friction → audit-journal}/session-id.mjs +0 -5
  22. package/dist/{session-friction → audit-journal}/text.d.mts +1 -4
  23. package/dist/{session-friction → audit-journal}/text.mjs +4 -22
  24. package/dist/audit-journal/types.d.mts +17 -0
  25. package/dist/cli/commands/gha-runtime-audit.mjs +2 -0
  26. package/dist/gh-cli/env.d.mts +9 -0
  27. package/dist/gh-cli/env.mjs +12 -0
  28. package/dist/gh-cli/exec.d.mts +7 -4
  29. package/dist/gh-cli/exec.mjs +7 -5
  30. package/dist/gh-cli/index.d.mts +1 -0
  31. package/dist/gh-cli/index.mjs +1 -0
  32. package/dist/gha-post-review/github.mjs +2 -1
  33. package/dist/index.d.mts +3 -3
  34. package/dist/index.mjs +2 -2
  35. package/dist/mcp-server/context.d.mts +2 -0
  36. package/dist/mcp-server/dispatch.d.mts +2 -0
  37. package/dist/mcp-server/dispatch.mjs +1 -0
  38. package/dist/mcp-server/harness.test-helpers.d.mts +1 -0
  39. package/dist/mcp-server/harness.test-helpers.mjs +1 -0
  40. package/dist/mcp-server/pr-agent.d.mts +12 -0
  41. package/dist/mcp-server/pr-agent.mjs +34 -0
  42. package/dist/mcp-server/pr-body.d.mts +2 -0
  43. package/dist/mcp-server/pr-body.mjs +1 -2
  44. package/dist/mcp-server/pr-gh.mjs +2 -1
  45. package/dist/mcp-server/pr-input.d.mts +2 -1
  46. package/dist/mcp-server/pr-input.mjs +1 -1
  47. package/dist/mcp-server/pr-live.d.mts +1 -0
  48. package/dist/mcp-server/pr-live.mjs +1 -1
  49. package/dist/mcp-server/pr-prepare.mjs +25 -4
  50. package/dist/mcp-server/server.mjs +1 -0
  51. package/dist/mcp-server/tools-pr.mjs +8 -12
  52. package/dist/{session-friction → private-files}/lock.mjs +3 -2
  53. package/dist/private-files/log-file.d.mts +3 -0
  54. package/dist/private-files/log-file.mjs +53 -0
  55. package/dist/{session-friction → private-files}/utf8.mjs +1 -1
  56. package/dist/retrospective-facts/exec.mjs +2 -1
  57. package/docs/mcp-server.md +18 -4
  58. package/package.json +1 -6
  59. package/dist/session-friction/ci-failures.d.mts +0 -13
  60. package/dist/session-friction/classify.d.mts +0 -2
  61. package/dist/session-friction/classify.mjs +0 -98
  62. package/dist/session-friction/command-segments.d.mts +0 -3
  63. package/dist/session-friction/command-segments.mjs +0 -94
  64. package/dist/session-friction/coverage-ledger.d.mts +0 -10
  65. package/dist/session-friction/coverage-ledger.mjs +0 -75
  66. package/dist/session-friction/directory.d.mts +0 -1
  67. package/dist/session-friction/directory.mjs +0 -71
  68. package/dist/session-friction/index.d.mts +0 -6
  69. package/dist/session-friction/index.mjs +0 -5
  70. package/dist/session-friction/log-file.d.mts +0 -7
  71. package/dist/session-friction/log-file.mjs +0 -95
  72. package/dist/session-friction/log.d.mts +0 -8
  73. package/dist/session-friction/log.mjs +0 -163
  74. package/dist/session-friction/normalize.d.mts +0 -1
  75. package/dist/session-friction/normalize.mjs +0 -81
  76. package/dist/session-friction/report.d.mts +0 -16
  77. package/dist/session-friction/report.mjs +0 -91
  78. package/dist/session-friction/sandbox.d.mts +0 -3
  79. package/dist/session-friction/sandbox.mjs +0 -43
  80. package/dist/session-friction/session-id.d.mts +0 -2
  81. package/dist/session-friction/types.d.mts +0 -80
  82. /package/dist/{session-friction → audit-journal}/audit-block.d.mts +0 -0
  83. /package/dist/{session-friction → audit-journal}/audit-block.mjs +0 -0
  84. /package/dist/{session-friction → audit-journal}/audit-fit.d.mts +0 -0
  85. /package/dist/{session-friction → audit-journal}/audit-fit.mjs +0 -0
  86. /package/dist/{session-friction → audit-journal}/types.mjs +0 -0
  87. /package/dist/{session-friction → private-files}/lock.d.mts +0 -0
  88. /package/dist/{session-friction → private-files}/utf8.d.mts +0 -0
@@ -1,98 +0,0 @@
1
- import { normalizeCommandPrefix } from "./normalize.mjs";
2
- import { boundedText, isWellFormedUnicode, normalizeAuditText } from "./text.mjs";
3
- const FAILURE_TOKENS = [
4
- ['Operation not permitted', /Operation not permitted/i],
5
- ['E2BIG', /\bE2BIG\b/i],
6
- ['EPERM', /\bEPERM\b/i],
7
- ];
8
- const CONNECTION_REFUSED = /\bECONNREFUSED\b/i;
9
- const URL_USERINFO = /([a-z][a-z0-9+.-]{0,31}:\/\/)[^\s/?#@]*@/gi;
10
- const STDERR_INSPECTION_MAX_LENGTH = 100_000;
11
- const COMMAND_INSPECTION_MAX_LENGTH = 10_000;
12
- const IPV4_OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)';
13
- const LOOPBACK_ADDRESSES = [
14
- new RegExp(String.raw `(?<![0-9a-z_.:-])(?:127\.${IPV4_OCTET}(?:\.${IPV4_OCTET}){2}|localhost)(?![0-9a-z_.-])`, 'i'),
15
- /(?<![0-9a-z_.-])\[(?:::1|0:0:0:0:0:0:0:1)(?:%[a-z0-9_.-]+)?\](?![0-9a-z_.-])/i,
16
- /(?<![0-9a-z_.:\x5b\x5d-])(?:::1|0:0:0:0:0:0:0:1)(?:%[a-z0-9_.-]+)?(?::\d{1,5})?(?![0-9a-z_.:\x5b\x5d-])/i,
17
- ];
18
- const DETAIL_MAX_LENGTH = 1_000;
19
- function boundedTail(value, maximum) {
20
- let start = Math.max(0, value.length - maximum);
21
- const first = value.charCodeAt(start);
22
- const previous = value.charCodeAt(start - 1);
23
- if (first >= 0xdc00 && first <= 0xdfff && previous >= 0xd800 && previous <= 0xdbff)
24
- start++;
25
- return value.slice(start);
26
- }
27
- function boundedStderr(value) {
28
- if (value.length <= STDERR_INSPECTION_MAX_LENGTH)
29
- return value;
30
- const headLength = Math.floor((STDERR_INSPECTION_MAX_LENGTH - 1) / 2);
31
- const head = boundedText(value, headLength);
32
- const tail = boundedTail(value, STDERR_INSPECTION_MAX_LENGTH - 1 - head.length);
33
- return `${head}\n${tail}`;
34
- }
35
- function withoutUrlUserinfo(line) {
36
- return line.includes('://') && line.includes('@') ? line.replace(URL_USERINFO, '$1') : line;
37
- }
38
- export function classifyFrictionObservation(observation) {
39
- if (!observation || typeof observation !== 'object')
40
- return null;
41
- if (typeof observation.command !== 'string')
42
- return null;
43
- const command = boundedText(observation.command, COMMAND_INSPECTION_MAX_LENGTH);
44
- if (!isWellFormedUnicode(command) || normalizeAuditText(command) === '')
45
- return null;
46
- const commandPrefix = normalizeCommandPrefix(command, observation.commandWrappers);
47
- if (commandPrefix === '' || !isWellFormedUnicode(commandPrefix))
48
- return null;
49
- if (observation.type === 'permission-request') {
50
- return {
51
- kind: 'sandbox-escalation',
52
- commandPrefix,
53
- detail: 'permission-request',
54
- outcome: 'requested',
55
- };
56
- }
57
- if (observation.type !== 'tool-result')
58
- return null;
59
- if (observation.escalationDetail !== undefined &&
60
- typeof observation.escalationDetail !== 'string')
61
- return null;
62
- const escalationDetail = normalizeAuditText(boundedText(observation.escalationDetail ?? '', DETAIL_MAX_LENGTH));
63
- if (!isWellFormedUnicode(escalationDetail))
64
- return null;
65
- if (observation.permissionOutcome !== undefined &&
66
- !['approved', 'denied'].includes(observation.permissionOutcome))
67
- return null;
68
- const permission = escalationDetail || observation.permissionOutcome
69
- ? {
70
- kind: 'sandbox-escalation',
71
- commandPrefix,
72
- detail: escalationDetail || `permission-${observation.permissionOutcome}`,
73
- outcome: observation.permissionOutcome ?? 'unknown',
74
- }
75
- : null;
76
- const rawStderr = observation.structuredStderr;
77
- if (rawStderr !== undefined && (typeof rawStderr !== 'string' || !isWellFormedUnicode(rawStderr)))
78
- return null;
79
- const stderr = rawStderr ? boundedStderr(rawStderr) : rawStderr;
80
- if (stderr === undefined)
81
- return permission;
82
- const token = FAILURE_TOKENS.find(([, pattern]) => pattern.test(stderr))?.[0];
83
- if (token) {
84
- const failure = { kind: 'sandbox-failure', detail: `stderr matched "${token}"` };
85
- return permission ? { ...permission, failure } : { ...failure, commandPrefix };
86
- }
87
- if (stderr.split(/\r\n|[\r\n]/).some((rawLine) => {
88
- const line = withoutUrlUserinfo(rawLine);
89
- return (CONNECTION_REFUSED.test(line) && LOOPBACK_ADDRESSES.some((pattern) => pattern.test(line)));
90
- })) {
91
- const failure = {
92
- kind: 'ambiguous-failure',
93
- detail: 'localhost connection refused; sandbox cause unverified',
94
- };
95
- return permission ? { ...permission, failure } : { ...failure, commandPrefix };
96
- }
97
- return permission;
98
- }
@@ -1,3 +0,0 @@
1
- export declare const REDACTED_TOKEN = "[REDACTED]";
2
- export declare function splitCommand(command: string): string[][];
3
- export declare function stripAssignments(tokens: string[]): string[];
@@ -1,94 +0,0 @@
1
- export const REDACTED_TOKEN = '[REDACTED]';
2
- const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
3
- const ENV_OPTIONS_WITH_ARGS = new Set(['-C', '--chdir', '-S', '--split-string', '-u', '--unset']);
4
- const ENV_OPTIONS_WITHOUT_ARGS = new Set([
5
- '-i',
6
- '--ignore-environment',
7
- '-0',
8
- '--null',
9
- '-v',
10
- '--debug',
11
- ]);
12
- export function splitCommand(command) {
13
- const result = [];
14
- let segment = [];
15
- let word = '';
16
- let quote;
17
- let escaped = false;
18
- const flushWord = () => {
19
- if (word)
20
- segment.push(word);
21
- word = '';
22
- };
23
- const flushSegment = () => {
24
- flushWord();
25
- if (segment.length)
26
- result.push(segment);
27
- segment = [];
28
- };
29
- for (const char of command) {
30
- if (escaped) {
31
- word += char;
32
- escaped = false;
33
- }
34
- else if (char === '\\' && quote !== "'") {
35
- escaped = true;
36
- }
37
- else if (quote) {
38
- if (char === quote)
39
- quote = undefined;
40
- else
41
- word += char;
42
- }
43
- else if (char === '"' || char === "'")
44
- quote = char;
45
- else if (';&|<>\n\r'.includes(char))
46
- flushSegment();
47
- else if (/\s/.test(char))
48
- flushWord();
49
- else
50
- word += char;
51
- }
52
- if (escaped)
53
- word += '\\';
54
- flushSegment();
55
- return result;
56
- }
57
- export function stripAssignments(tokens) {
58
- let index = 0;
59
- while (index < tokens.length && ENV_ASSIGNMENT.test(tokens[index]))
60
- index++;
61
- const wrapper = tokens[index];
62
- // Assignments without an executable are represented by a safe sentinel.
63
- if (wrapper !== 'env' && !wrapper?.endsWith('/env'))
64
- return index < tokens.length ? tokens.slice(index) : index ? [REDACTED_TOKEN] : tokens;
65
- index++;
66
- while (index < tokens.length) {
67
- const option = tokens[index];
68
- if (option === '--') {
69
- index++;
70
- break;
71
- }
72
- const optionName = option.split('=', 1)[0];
73
- const splitStringOption = optionName === '-S' || (optionName.length >= 5 && '--split-string'.startsWith(optionName));
74
- if (splitStringOption)
75
- return [REDACTED_TOKEN];
76
- const abbreviatedArgument = optionName.length >= 5 && '--chdir'.startsWith(optionName);
77
- if (ENV_OPTIONS_WITH_ARGS.has(optionName) || abbreviatedArgument) {
78
- index += option.includes('=') ? 1 : 2;
79
- continue;
80
- }
81
- if (ENV_OPTIONS_WITHOUT_ARGS.has(option)) {
82
- index++;
83
- continue;
84
- }
85
- if (option.startsWith('-'))
86
- return [REDACTED_TOKEN];
87
- break;
88
- }
89
- while (index < tokens.length && ENV_ASSIGNMENT.test(tokens[index]))
90
- index++;
91
- if (index < tokens.length)
92
- return tokens.slice(index);
93
- return tokens.some((token) => ENV_ASSIGNMENT.test(token)) ? [REDACTED_TOKEN] : [];
94
- }
@@ -1,10 +0,0 @@
1
- export type CoverageLedger = {
2
- schemaVersion: 1;
3
- retainedEvents: number;
4
- logBytes: number;
5
- droppedCount: number;
6
- };
7
- /** Caller holds the event log lock; incomplete atomic updates must never imply complete capture. */
8
- export declare function readCoverageLedger(path: string): CoverageLedger | undefined;
9
- /** The fixed-size atomic sidecar keeps saturation work independent of the accumulated drop count. */
10
- export declare function writeCoverageLedger(path: string, ledger: CoverageLedger): void;
@@ -1,75 +0,0 @@
1
- import { closeSync, constants, fsyncSync, lstatSync, openSync, renameSync } from 'node:fs';
2
- import { dirname } from 'node:path';
3
- import { openLogFile, readLogContent, writeAll, LOG_MAX_BYTES } from "./log-file.mjs";
4
- const LEDGER_MAX_BYTES = 192;
5
- function missing(error) {
6
- return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
7
- }
8
- /** Caller holds the event log lock; incomplete atomic updates must never imply complete capture. */
9
- export function readCoverageLedger(path) {
10
- try {
11
- lstatSync(`${path}.coverage.pending`);
12
- throw new Error('session-friction coverage update is incomplete');
13
- }
14
- catch (error) {
15
- if (!missing(error))
16
- throw error;
17
- }
18
- let descriptor;
19
- try {
20
- descriptor = openLogFile(`${path}.coverage`, constants.O_RDONLY);
21
- }
22
- catch (error) {
23
- if (missing(error))
24
- return undefined;
25
- throw error;
26
- }
27
- try {
28
- const value = JSON.parse(readLogContent(descriptor, LEDGER_MAX_BYTES));
29
- if (!value || typeof value !== 'object')
30
- throw new Error('invalid session-friction coverage ledger');
31
- // oxlint-disable-next-line no-mistakes/ts-no-const-aliases -- establish a validated object view
32
- const item = value;
33
- if (Object.keys(item).length !== 4 ||
34
- item.schemaVersion !== 1 ||
35
- !Number.isSafeInteger(item.retainedEvents) ||
36
- item.retainedEvents < 0 ||
37
- item.retainedEvents > 500 ||
38
- !Number.isSafeInteger(item.logBytes) ||
39
- item.logBytes < 0 ||
40
- item.logBytes > LOG_MAX_BYTES ||
41
- !Number.isSafeInteger(item.droppedCount) ||
42
- item.droppedCount < 1)
43
- throw new Error('invalid session-friction coverage ledger');
44
- return item;
45
- }
46
- finally {
47
- closeSync(descriptor);
48
- }
49
- }
50
- /** The fixed-size atomic sidecar keeps saturation work independent of the accumulated drop count. */
51
- export function writeCoverageLedger(path, ledger) {
52
- if (!Number.isSafeInteger(ledger.droppedCount))
53
- throw new Error('session-friction dropped count overflow; capture incomplete');
54
- const temporary = `${path}.coverage.pending`;
55
- const descriptor = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
56
- try {
57
- syncDirectory(path);
58
- writeAll(descriptor, JSON.stringify(ledger));
59
- fsyncSync(descriptor);
60
- }
61
- finally {
62
- closeSync(descriptor);
63
- }
64
- renameSync(temporary, `${path}.coverage`);
65
- syncDirectory(path);
66
- }
67
- function syncDirectory(path) {
68
- const descriptor = openSync(dirname(path), constants.O_RDONLY | constants.O_NOFOLLOW);
69
- try {
70
- fsyncSync(descriptor);
71
- }
72
- finally {
73
- closeSync(descriptor);
74
- }
75
- }
@@ -1 +0,0 @@
1
- export declare function ensurePrivateDirectory(directory: string, create: boolean): boolean;
@@ -1,71 +0,0 @@
1
- import { chmodSync, lstatSync, mkdirSync, statSync } from 'node:fs';
2
- import { dirname } from 'node:path';
3
- const PRIVATE_DIRECTORY_MODE = 0o700;
4
- function isCode(error, code) {
5
- return Boolean(error && typeof error === 'object' && 'code' in error && error.code === code);
6
- }
7
- function directoryChain(directory) {
8
- const chain = [directory];
9
- let current = directory;
10
- while (dirname(current) !== current) {
11
- current = dirname(current);
12
- chain.push(current);
13
- }
14
- return chain.reverse();
15
- }
16
- export function ensurePrivateDirectory(directory, create) {
17
- const chain = directoryChain(directory);
18
- const effectiveUserId = process.geteuid?.();
19
- if (effectiveUserId === undefined)
20
- throw new Error('session-friction requires POSIX directory permissions');
21
- for (const [index, path] of chain.entries()) {
22
- let created = false;
23
- let status;
24
- try {
25
- status = lstatSync(path);
26
- }
27
- catch (error) {
28
- /* v8 ignore next -- unexpected filesystem errors are propagated unchanged. */
29
- if (!isCode(error, 'ENOENT'))
30
- throw error;
31
- if (!create)
32
- return false;
33
- try {
34
- mkdirSync(path, { mode: PRIVATE_DIRECTORY_MODE });
35
- created = true;
36
- }
37
- catch (mkdirError) {
38
- /* v8 ignore next -- another process can create the directory after lstat. */
39
- if (!isCode(mkdirError, 'EEXIST'))
40
- throw mkdirError;
41
- }
42
- status = lstatSync(path);
43
- }
44
- const leaf = index === chain.length - 1;
45
- if (status.isSymbolicLink()) {
46
- if (leaf || status.uid !== 0)
47
- throw new Error('session-friction log directory must be a private directory');
48
- try {
49
- status = statSync(path);
50
- }
51
- catch {
52
- throw new Error('session-friction log directory must be a private directory');
53
- }
54
- }
55
- if (!status.isDirectory())
56
- throw new Error('session-friction log directory must be a private directory');
57
- if (created)
58
- chmodSync(path, PRIVATE_DIRECTORY_MODE);
59
- if (!created) {
60
- const ownedByCaller = status.uid === effectiveUserId;
61
- const ownedByRoot = status.uid === 0;
62
- const writableByOthers = (status.mode & 0o022) !== 0;
63
- const sticky = (status.mode & 0o1000) !== 0;
64
- if ((leaf && ((status.mode & 0o777) !== PRIVATE_DIRECTORY_MODE || !ownedByCaller)) ||
65
- (!leaf && !ownedByCaller && !ownedByRoot) ||
66
- (!leaf && writableByOthers && !sticky))
67
- throw new Error('session-friction log directory must be a private directory');
68
- }
69
- }
70
- return true;
71
- }
@@ -1,6 +0,0 @@
1
- export { classifyFrictionObservation } from './classify.mts';
2
- export { normalizeCommandPrefix } from './normalize.mts';
3
- export { FRICTION_LOG_MAX_EVENTS, readFrictionLog, recordFriction } from './log.mts';
4
- export { buildSessionFrictionReport, loadFrictionReportInputs, renderFrictionReport, type FrictionReportInputs, } from './report.mts';
5
- export { isConformingCiFailureBlock } from './ci-failures.mts';
6
- export type { FrictionEvent, FrictionEventKind, FrictionLogOptions, FrictionLogReadResult, FrictionObservation, JournalEntry, JournalLoadResult, JournalLoader, PermissionRequestObservation, SessionFrictionReport, SessionFrictionCoverage, SessionFrictionReportOptions, ToolResultObservation, } from './types.mts';
@@ -1,5 +0,0 @@
1
- export { classifyFrictionObservation } from "./classify.mjs";
2
- export { normalizeCommandPrefix } from "./normalize.mjs";
3
- export { FRICTION_LOG_MAX_EVENTS, readFrictionLog, recordFriction } from "./log.mjs";
4
- export { buildSessionFrictionReport, loadFrictionReportInputs, renderFrictionReport, } from "./report.mjs";
5
- export { isConformingCiFailureBlock } from "./ci-failures.mjs";
@@ -1,7 +0,0 @@
1
- import type { FrictionEvent } from './types.mts';
2
- export declare const LOG_MAX_BYTES = 2000000;
3
- export declare function validEvent(value: unknown): value is FrictionEvent;
4
- export declare function openLogFile(path: string, flags: number): number;
5
- export declare function readLogContent(descriptor: number, maximumBytes?: number): string;
6
- export declare function validEventCount(content: string, limit: number): number;
7
- export declare function writeAll(descriptor: number, value: string): void;
@@ -1,95 +0,0 @@
1
- import { closeSync, constants, fstatSync, openSync, readSync, writeSync } from 'node:fs';
2
- import { isSafeAuditText } from "./text.mjs";
3
- import { decodeUtf8 } from "./utf8.mjs";
4
- export const LOG_MAX_BYTES = 2_000_000;
5
- export function validEvent(value) {
6
- if (!value || typeof value !== 'object')
7
- return false;
8
- // oxlint-disable-next-line no-mistakes/ts-no-const-aliases -- establish a record view after the runtime object check
9
- const record = value;
10
- return ((record.kind === 'sandbox-escalation' ||
11
- record.kind === 'sandbox-failure' ||
12
- record.kind === 'ambiguous-failure') &&
13
- isSafeAuditText(record.timestamp) &&
14
- isSafeAuditText(record.commandPrefix) &&
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));
29
- }
30
- export function openLogFile(path, flags) {
31
- let descriptor;
32
- try {
33
- descriptor = openSync(path, flags | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
34
- }
35
- catch (error) {
36
- if (error &&
37
- typeof error === 'object' &&
38
- 'code' in error &&
39
- (error.code === 'ELOOP' || error.code === 'EISDIR' || error.code === 'ENOTDIR'))
40
- throw new Error('session-friction log must be a regular file');
41
- throw error;
42
- }
43
- try {
44
- const status = fstatSync(descriptor);
45
- if (!status.isFile() || status.nlink !== 1)
46
- throw new Error('session-friction log must be a regular file');
47
- return descriptor;
48
- }
49
- catch (error) {
50
- closeSync(descriptor);
51
- throw error;
52
- }
53
- }
54
- export function readLogContent(descriptor, maximumBytes = LOG_MAX_BYTES) {
55
- if (fstatSync(descriptor).size > maximumBytes)
56
- throw new Error('session-friction log is too large');
57
- const buffer = Buffer.alloc(maximumBytes + 1);
58
- let length = 0;
59
- while (length < buffer.length) {
60
- const bytes = readSync(descriptor, buffer, length, buffer.length - length, length);
61
- if (bytes === 0)
62
- break;
63
- length += bytes;
64
- }
65
- /* v8 ignore next -- detects external growth after the descriptor size check. */
66
- if (length > maximumBytes)
67
- throw new Error('session-friction log is too large');
68
- return decodeUtf8(buffer.subarray(0, length));
69
- }
70
- export function validEventCount(content, limit) {
71
- let count = 0;
72
- for (const line of content.split('\n')) {
73
- if (!line.trim())
74
- continue;
75
- try {
76
- if (validEvent(JSON.parse(line)))
77
- count++;
78
- }
79
- catch { }
80
- if (count >= limit)
81
- break;
82
- }
83
- return count;
84
- }
85
- export function writeAll(descriptor, value) {
86
- const buffer = Buffer.from(value);
87
- let offset = 0;
88
- while (offset < buffer.length) {
89
- const written = writeSync(descriptor, buffer, offset, buffer.length - offset);
90
- /* v8 ignore next -- a zero-byte synchronous file write is an external I/O failure. */
91
- if (written === 0)
92
- throw new Error('session-friction log write made no progress');
93
- offset += written;
94
- }
95
- }
@@ -1,8 +0,0 @@
1
- import type { FrictionLogOptions, FrictionLogReadResult, FrictionObservation } from './types.mts';
2
- export declare const FRICTION_LOG_MAX_EVENTS = 500;
3
- export declare function requireDirectory(directory: string): string;
4
- export declare function eventLimit(maxEvents: number | undefined): number;
5
- export declare function recordFriction(sessionId: string, observation: FrictionObservation, options: FrictionLogOptions & {
6
- timestamp?: string | (() => string);
7
- }): void;
8
- export declare function readFrictionLog(sessionId: string, options: FrictionLogOptions): FrictionLogReadResult;
@@ -1,163 +0,0 @@
1
- import { readCoverageLedger, writeCoverageLedger } from "./coverage-ledger.mjs";
2
- import { closeSync, constants, fchmodSync, fstatSync } from 'node:fs';
3
- import { isAbsolute, join, resolve } from 'node:path';
4
- import { classifyFrictionObservation } from "./classify.mjs";
5
- import { ensurePrivateDirectory } from "./directory.mjs";
6
- import { withFileLock } from "./lock.mjs";
7
- import { validEventCount, LOG_MAX_BYTES, openLogFile, readLogContent, validEvent, writeAll, } from "./log-file.mjs";
8
- import { sanitizeSessionId } from "./session-id.mjs";
9
- import { boundedText, isWellFormedUnicode, normalizeAuditText } from "./text.mjs";
10
- export const FRICTION_LOG_MAX_EVENTS = 500;
11
- const EVENT_FIELD_MAX_LENGTH = 1_000;
12
- export function requireDirectory(directory) {
13
- if (typeof directory !== 'string')
14
- throw new Error('log directory must be a string');
15
- if (Buffer.byteLength(directory) > 4096)
16
- throw new Error('session-friction log directory path is too long');
17
- if (!isAbsolute(directory))
18
- throw new Error('session-friction log directory must be absolute');
19
- return resolve(directory);
20
- }
21
- export function eventLimit(maxEvents) {
22
- const limit = maxEvents ?? FRICTION_LOG_MAX_EVENTS;
23
- if (!Number.isInteger(limit) || limit < 1)
24
- throw new Error('maxEvents must be a positive integer');
25
- return Math.min(limit, FRICTION_LOG_MAX_EVENTS);
26
- }
27
- function logPath(sessionId, directory) {
28
- return join(requireDirectory(directory), `${sanitizeSessionId(sessionId)}.jsonl`);
29
- }
30
- export function recordFriction(sessionId, observation, options) {
31
- const maxEvents = eventLimit(options.maxEvents);
32
- const timestamp = typeof options.timestamp === 'function' ? options.timestamp() : options.timestamp;
33
- if (timestamp !== undefined && typeof timestamp !== 'string')
34
- throw new Error('timestamp must be a string');
35
- const rawTimestamp = timestamp ?? new Date().toISOString();
36
- const normalizedTimestamp = normalizeAuditText(boundedText(rawTimestamp, EVENT_FIELD_MAX_LENGTH));
37
- if (!normalizedTimestamp)
38
- throw new Error('timestamp must be non-empty');
39
- if (!isWellFormedUnicode(normalizedTimestamp))
40
- throw new Error('timestamp must be well-formed Unicode');
41
- const classified = classifyFrictionObservation(observation);
42
- const path = logPath(sessionId, options.directory);
43
- const directory = requireDirectory(options.directory);
44
- ensurePrivateDirectory(directory, true);
45
- withFileLock(path, () => {
46
- /* v8 ignore next 2 -- requires the directory path to be replaced after its lock is acquired. */
47
- if (!ensurePrivateDirectory(directory, false))
48
- throw new Error('session-friction log directory disappeared while acquiring the lock');
49
- const descriptor = openLogFile(path, constants.O_CREAT | constants.O_RDWR | constants.O_APPEND);
50
- try {
51
- fchmodSync(descriptor, 0o600);
52
- if (fstatSync(descriptor).size > LOG_MAX_BYTES)
53
- throw new Error('session-friction log is too large');
54
- if (!classified)
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
- }
62
- const content = readLogContent(descriptor);
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
- });
71
- return;
72
- }
73
- const event = {
74
- ...classified,
75
- commandPrefix: normalizeAuditText(classified.commandPrefix),
76
- detail: normalizeAuditText(classified.detail),
77
- timestamp: normalizedTimestamp,
78
- };
79
- /* v8 ignore next -- normalized classified fields satisfy validEvent defensively. */
80
- if (!validEvent(event))
81
- return;
82
- const prefix = content && !content.endsWith('\n') ? '\n' : '';
83
- const addition = `${prefix}${JSON.stringify(event)}\n`;
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
- }
93
- writeAll(descriptor, addition);
94
- if (coverage)
95
- writeCoverageLedger(path, {
96
- ...coverage,
97
- logBytes: fstatSync(descriptor).size,
98
- retainedEvents: retainedEvents + 1,
99
- });
100
- }
101
- finally {
102
- closeSync(descriptor);
103
- }
104
- });
105
- }
106
- export function readFrictionLog(sessionId, options) {
107
- const maxEvents = eventLimit(options.maxEvents);
108
- const path = logPath(sessionId, options.directory);
109
- const directory = requireDirectory(options.directory);
110
- if (!ensurePrivateDirectory(directory, false))
111
- return { status: 'absent' };
112
- try {
113
- return withFileLock(path, () => {
114
- /* v8 ignore next -- detects directory removal after acquiring the file lock. */
115
- if (!ensurePrivateDirectory(directory, false))
116
- return { status: 'absent' };
117
- const descriptor = openLogFile(path, constants.O_RDONLY);
118
- try {
119
- const events = [];
120
- const ledger = readCoverageLedger(path);
121
- let droppedCount = ledger?.droppedCount ?? 0;
122
- let retainedEvents = 0;
123
- for (const line of readLogContent(descriptor).split('\n')) {
124
- if (!line.trim())
125
- continue;
126
- try {
127
- const value = JSON.parse(line);
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++;
140
- }
141
- }
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 };
152
- }
153
- finally {
154
- closeSync(descriptor);
155
- }
156
- });
157
- }
158
- catch (error) {
159
- if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
160
- return { status: 'absent' };
161
- throw error;
162
- }
163
- }
@@ -1 +0,0 @@
1
- export declare function normalizeCommandPrefix(command: string, wrappersToStrip?: string[]): string;