vouchington-tooling 0.1.5 → 0.1.7
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 +32 -1
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +1 -0
- package/dist/session-friction/ci-failures.d.mts +11 -0
- package/dist/session-friction/ci-failures.mjs +78 -0
- package/dist/session-friction/classify.d.mts +2 -0
- package/dist/session-friction/classify.mjs +87 -0
- package/dist/session-friction/directory.d.mts +1 -0
- package/dist/session-friction/directory.mjs +71 -0
- package/dist/session-friction/index.d.mts +6 -0
- package/dist/session-friction/index.mjs +5 -0
- package/dist/session-friction/lock.d.mts +1 -0
- package/dist/session-friction/lock.mjs +193 -0
- package/dist/session-friction/log.d.mts +8 -0
- package/dist/session-friction/log.mjs +195 -0
- package/dist/session-friction/normalize.d.mts +1 -0
- package/dist/session-friction/normalize.mjs +174 -0
- package/dist/session-friction/report.d.mts +2 -0
- package/dist/session-friction/report.mjs +113 -0
- package/dist/session-friction/sandbox.d.mts +2 -0
- package/dist/session-friction/sandbox.mjs +15 -0
- package/dist/session-friction/session-id.d.mts +2 -0
- package/dist/session-friction/session-id.mjs +17 -0
- package/dist/session-friction/text.d.mts +5 -0
- package/dist/session-friction/text.mjs +45 -0
- package/dist/session-friction/types.d.mts +52 -0
- package/dist/session-friction/types.mjs +1 -0
- package/dist/session-friction/utf8.d.mts +1 -0
- package/dist/session-friction/utf8.mjs +8 -0
- package/package.json +6 -1
- package/scripts/gha/download-optional-run-artifacts.sh +26 -2
package/README.md
CHANGED
|
@@ -50,7 +50,8 @@ vouchington stage-review-payload optional|required <source> <destination>
|
|
|
50
50
|
`download-optional-run-artifacts` uses the current Actions run and host. Pattern mode discovers
|
|
51
51
|
non-expired artifacts across the run, keeps the first result for each name (matching `gh run
|
|
52
52
|
download`), and extracts each selected name into its own directory. Ordinary absence is reported as
|
|
53
|
-
`availability=unavailable
|
|
53
|
+
`availability=unavailable`. Artifact listing retries up to three times with bounded backoff;
|
|
54
|
+
exhausted transport errors, invalid names, and cancellation remain hard failures.
|
|
54
55
|
|
|
55
56
|
`retrospective-transcript` discovers Codex and Claude transcripts by default. It also reads a
|
|
56
57
|
Claude-compatible transcript when `CURSOR_SESSION_ID` is set, and Grok's `updates.jsonl` session
|
|
@@ -134,8 +135,38 @@ import {
|
|
|
134
135
|
readDiagnosticReportSummaries,
|
|
135
136
|
} from 'vouchington-tooling/vitest-diagnostics'
|
|
136
137
|
import { runRetrospectiveTranscript } from 'vouchington-tooling/retrospective-transcript'
|
|
138
|
+
import { buildSessionFrictionReport, recordFriction } from 'vouchington-tooling/session-friction'
|
|
137
139
|
```
|
|
138
140
|
|
|
141
|
+
`session-friction` is an opt-in capture and reporting library. Callers supply the session id,
|
|
142
|
+
absolute log directory, host-independent observation, and journal loader; it does not inspect host
|
|
143
|
+
environment variables, install hooks, or connect to a journal service by itself. Invoking
|
|
144
|
+
`recordFriction` touches the session log even when no event is classified, preserving the
|
|
145
|
+
difference between an observed clean session and missing evidence. Report markdown keeps backend
|
|
146
|
+
diagnostics separate from its paste-safe output. Capture stores at most 500 events per session,
|
|
147
|
+
truncates event detail to 1,000 characters, and consumes up to 500 entries from the journal loader
|
|
148
|
+
when building a report, stopping earlier when its aggregate 1 MB inspected-byte budget is reached.
|
|
149
|
+
Bounded journal scans that stop before exhaustion are reported as incomplete rather than clean.
|
|
150
|
+
Report liveness inherits the caller-supplied journal loader, which must bound its own I/O and yields.
|
|
151
|
+
Log reads are capped at 2 MB, journal Markdown at 10,000 bytes per entry,
|
|
152
|
+
and rendered audit fields at 120 escaped characters. The supplied log directory must be dedicated
|
|
153
|
+
to session-friction; existing directories must already be owner-only, while newly created
|
|
154
|
+
directories and log files are enforced as owner-only when recording. Reads use a fixed bounded
|
|
155
|
+
buffer that can detect growth one byte beyond the documented 2 MB cap.
|
|
156
|
+
Ownership checks require POSIX effective-user IDs (Linux and macOS); session-friction throws on
|
|
157
|
+
Windows and other platforms where those IDs are unavailable.
|
|
158
|
+
Root-owned system symlink ancestors are supported for paths such as macOS `/var`; callers must not
|
|
159
|
+
allow the directory chain to be mutated while it is being validated.
|
|
160
|
+
Command-prefix normalization recognizes simple shell
|
|
161
|
+
segments with single or double quotes; it does not evaluate substitutions or implement a full shell
|
|
162
|
+
grammar. Normalization attempts limited redaction of obvious credential patterns but is not a secret
|
|
163
|
+
scrubber; callers must ensure credentials are never included in captured commands.
|
|
164
|
+
Failure classification inspects at most 100,000 structured-stderr characters, split evenly between
|
|
165
|
+
the beginning and end when input exceeds that bound.
|
|
166
|
+
Cooperating log readers and writers are serialized, including the initial clean-session
|
|
167
|
+
touch. Recording and report log reads are synchronous: on contention they block the caller's
|
|
168
|
+
event loop for up to one second before failing explicitly. Avoid these APIs on hot request paths.
|
|
169
|
+
|
|
139
170
|
The artifact, review-payload, HTTP body, and pagination APIs validate untrusted inputs at their
|
|
140
171
|
boundaries. Review posting lives in `gha-post-review` and talks to GitHub only through caller-supplied
|
|
141
172
|
credentials (job token or a minted Claude GitHub App token).
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { codexChildren, codexIdentity, computeTranscriptFacts, formatTranscriptFacts, formatUnavailable, resolveTranscriptFile, runRetrospectiveTranscript, } from './retrospective-transcript/index.mts';
|
|
2
2
|
export type { ResolveOptions, TokenTotals, TranscriptFacts, } from './retrospective-transcript/index.mts';
|
|
3
|
+
export { buildSessionFrictionReport, classifyFrictionObservation, FRICTION_LOG_MAX_EVENTS, isConformingCiFailureBlock, normalizeCommandPrefix, readFrictionLog, recordFriction, } from './session-friction/index.mts';
|
|
4
|
+
export type { FrictionEvent, FrictionEventKind, FrictionLogOptions, FrictionLogReadResult, FrictionObservation, JournalEntry, JournalLoader, JournalLoadResult, PermissionRequestObservation, SessionFrictionReport, SessionFrictionReportOptions, ToolResultObservation, } from './session-friction/index.mts';
|
|
3
5
|
export { EphemeralListenerAttemptsExhaustedError, isRunnerReservedPort, listenOnRunnerUnreservedEphemeralPort, loadRunnerPortPolicy, runnerPortPolicy, validateRunnerPortPolicy, } from './runner-port-policy/index.mts';
|
|
4
6
|
export type { EphemeralListenerOptions, RunnerPortPolicy } from './runner-port-policy/index.mts';
|
|
5
7
|
export { extractAlterTableAddColumnLocations, extractCreateIndexMetadata, extractCreateTableMetadata, extractDefaultFunction, extractDropIndexMetadata, extractFuncCallArgColumnNames, extractMigrationConstraintMetadata, initSqlAst, lineOfUtf8ByteOffset, MissingSqlAstParserError, parseSql, } from './sql-ast/index.mts';
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* eslint-disable max-lines -- package entry point enumerates the supported public API. */
|
|
2
2
|
export { codexChildren, codexIdentity, computeTranscriptFacts, formatTranscriptFacts, formatUnavailable, resolveTranscriptFile, runRetrospectiveTranscript, } from './retrospective-transcript/index.mjs';
|
|
3
|
+
export { buildSessionFrictionReport, classifyFrictionObservation, FRICTION_LOG_MAX_EVENTS, isConformingCiFailureBlock, normalizeCommandPrefix, readFrictionLog, recordFriction, } from './session-friction/index.mjs';
|
|
3
4
|
export { EphemeralListenerAttemptsExhaustedError, isRunnerReservedPort, listenOnRunnerUnreservedEphemeralPort, loadRunnerPortPolicy, runnerPortPolicy, validateRunnerPortPolicy, } from './runner-port-policy/index.mjs';
|
|
4
5
|
export { extractAlterTableAddColumnLocations, extractCreateIndexMetadata, extractCreateTableMetadata, extractDefaultFunction, extractDropIndexMetadata, extractFuncCallArgColumnNames, extractMigrationConstraintMetadata, initSqlAst, lineOfUtf8ByteOffset, MissingSqlAstParserError, parseSql, } from './sql-ast/index.mjs';
|
|
5
6
|
export { dollarQuoteEnd, lineOf, maskSqlQuotedText, readDollarQuoteDelimiter, readStringLiteral, splitSqlStatements, sqlFragments, stripSqlComments, } from './sql-scanner/index.mjs';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FrictionLogReadResult, JournalEntry } from './types.mts';
|
|
2
|
+
export declare function isConformingCiFailureBlock(markdown: string): boolean;
|
|
3
|
+
export declare function getConformingGroups(entries: Iterable<JournalEntry>): string[];
|
|
4
|
+
export declare function buildCiFailuresSection(sessionId: string, journal: {
|
|
5
|
+
status: 'ok';
|
|
6
|
+
markdownBlocks: string[];
|
|
7
|
+
truncated: boolean;
|
|
8
|
+
} | {
|
|
9
|
+
status: 'unreachable';
|
|
10
|
+
diagnostic: string;
|
|
11
|
+
}, frictionStatus: FrictionLogReadResult['status']): string;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { isWellFormedUnicode, markdownAuditText } from './text.mjs';
|
|
2
|
+
const GROUP_HEADER = /^- `(recurring|one-off)` — `GitHub Actions` — .*[^\s]$/;
|
|
3
|
+
const EVIDENCE = /^ {2}- Evidence: .*[^\s]$/;
|
|
4
|
+
const ROOT_DIAGNOSTIC = /^ {2}- Root diagnostic: .*[^\s]$/;
|
|
5
|
+
const DISPOSITION = /^ {2}- Disposition: .*[^\s]$/;
|
|
6
|
+
const BLANK = /^\s*$/;
|
|
7
|
+
const CI_FAILURES_HEADER = '## CI Failures';
|
|
8
|
+
const CI_FAILURE_BLOCK_MAX_BYTES = 10_000;
|
|
9
|
+
const FIELD_PREFIXES = [
|
|
10
|
+
'- `recurring` — `GitHub Actions` — ',
|
|
11
|
+
'- `one-off` — `GitHub Actions` — ',
|
|
12
|
+
' - Evidence: ',
|
|
13
|
+
' - Root diagnostic: ',
|
|
14
|
+
' - Disposition: ',
|
|
15
|
+
];
|
|
16
|
+
function safeField(line) {
|
|
17
|
+
const prefix = FIELD_PREFIXES.find((value) => line.startsWith(value));
|
|
18
|
+
/* v8 ignore next -- matchBlock currently passes only fields with a known prefix. */
|
|
19
|
+
if (!prefix)
|
|
20
|
+
return markdownAuditText(line) || null;
|
|
21
|
+
const content = markdownAuditText(line.slice(prefix.length));
|
|
22
|
+
return content ? `${prefix}${content}` : null;
|
|
23
|
+
}
|
|
24
|
+
function matchBlock(markdown) {
|
|
25
|
+
if (!isWellFormedUnicode(markdown))
|
|
26
|
+
return null;
|
|
27
|
+
const lines = markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
|
28
|
+
let index = 0;
|
|
29
|
+
const consume = (pattern) => {
|
|
30
|
+
while (index < lines.length && BLANK.test(lines[index]))
|
|
31
|
+
index++;
|
|
32
|
+
const line = lines[index];
|
|
33
|
+
if (line === undefined || !pattern.test(line))
|
|
34
|
+
return null;
|
|
35
|
+
index++;
|
|
36
|
+
return line;
|
|
37
|
+
};
|
|
38
|
+
const fields = [
|
|
39
|
+
consume(GROUP_HEADER),
|
|
40
|
+
consume(EVIDENCE),
|
|
41
|
+
consume(ROOT_DIAGNOSTIC),
|
|
42
|
+
consume(DISPOSITION),
|
|
43
|
+
];
|
|
44
|
+
if (fields.some((field) => field === null))
|
|
45
|
+
return null;
|
|
46
|
+
if (!lines.slice(index).every((line) => BLANK.test(line)))
|
|
47
|
+
return null;
|
|
48
|
+
const safeFields = fields.map((field) => safeField(field));
|
|
49
|
+
if (safeFields.some((field) => field === null))
|
|
50
|
+
return null;
|
|
51
|
+
return safeFields.join('\n');
|
|
52
|
+
}
|
|
53
|
+
export function isConformingCiFailureBlock(markdown) {
|
|
54
|
+
if (markdown.length > CI_FAILURE_BLOCK_MAX_BYTES ||
|
|
55
|
+
Buffer.byteLength(markdown) > CI_FAILURE_BLOCK_MAX_BYTES)
|
|
56
|
+
return false;
|
|
57
|
+
return matchBlock(markdown) !== null;
|
|
58
|
+
}
|
|
59
|
+
function journalMarkdown(entries) {
|
|
60
|
+
return [...entries].flatMap((entry) => {
|
|
61
|
+
const data = entry?.data;
|
|
62
|
+
return data?.type === 'journal' && typeof data.markdown === 'string' ? [data.markdown] : [];
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export function getConformingGroups(entries) {
|
|
66
|
+
return journalMarkdown(entries)
|
|
67
|
+
.map(matchBlock)
|
|
68
|
+
.filter((block) => block !== null);
|
|
69
|
+
}
|
|
70
|
+
export function buildCiFailuresSection(sessionId, journal, frictionStatus) {
|
|
71
|
+
if (journal.status === 'unreachable')
|
|
72
|
+
return `${CI_FAILURES_HEADER}\nStatus: unavailable (blackboard unreachable)`;
|
|
73
|
+
if (journal.markdownBlocks.length === 0 && frictionStatus === 'absent')
|
|
74
|
+
return `${CI_FAILURES_HEADER}\nStatus: unavailable (no friction log for session ${markdownAuditText(sessionId)})`;
|
|
75
|
+
if (journal.markdownBlocks.length === 0)
|
|
76
|
+
return `${CI_FAILURES_HEADER}\nStatus: none observed`;
|
|
77
|
+
return `${CI_FAILURES_HEADER}\nStatus: failures observed\n\n${journal.markdownBlocks.join('\n\n')}`;
|
|
78
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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 { kind: 'sandbox-escalation', commandPrefix, detail: 'permission-request' };
|
|
51
|
+
}
|
|
52
|
+
if (observation.type !== 'tool-result')
|
|
53
|
+
return null;
|
|
54
|
+
if (observation.escalationDetail !== undefined &&
|
|
55
|
+
typeof observation.escalationDetail !== 'string')
|
|
56
|
+
return null;
|
|
57
|
+
const escalationDetail = normalizeAuditText(boundedText(observation.escalationDetail ?? '', DETAIL_MAX_LENGTH));
|
|
58
|
+
if (!isWellFormedUnicode(escalationDetail))
|
|
59
|
+
return null;
|
|
60
|
+
if (escalationDetail) {
|
|
61
|
+
return {
|
|
62
|
+
kind: 'sandbox-escalation',
|
|
63
|
+
commandPrefix,
|
|
64
|
+
detail: escalationDetail,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const rawStderr = observation.structuredStderr;
|
|
68
|
+
if (rawStderr !== undefined && (typeof rawStderr !== 'string' || !isWellFormedUnicode(rawStderr)))
|
|
69
|
+
return null;
|
|
70
|
+
const stderr = rawStderr ? boundedStderr(rawStderr) : rawStderr;
|
|
71
|
+
if (stderr === undefined)
|
|
72
|
+
return null;
|
|
73
|
+
const token = FAILURE_TOKENS.find(([, pattern]) => pattern.test(stderr))?.[0];
|
|
74
|
+
if (token)
|
|
75
|
+
return { kind: 'sandbox-failure', commandPrefix, detail: `stderr matched "${token}"` };
|
|
76
|
+
if (stderr.split(/\r\n|[\r\n]/).some((rawLine) => {
|
|
77
|
+
const line = withoutUrlUserinfo(rawLine);
|
|
78
|
+
return (CONNECTION_REFUSED.test(line) && LOOPBACK_ADDRESSES.some((pattern) => pattern.test(line)));
|
|
79
|
+
})) {
|
|
80
|
+
return {
|
|
81
|
+
kind: 'sandbox-failure',
|
|
82
|
+
commandPrefix,
|
|
83
|
+
detail: 'stderr matched localhost connection failure',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function ensurePrivateDirectory(directory: string, create: boolean): boolean;
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
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 } from './report.mts';
|
|
5
|
+
export { isConformingCiFailureBlock } from './ci-failures.mts';
|
|
6
|
+
export type { FrictionEvent, FrictionEventKind, FrictionLogOptions, FrictionLogReadResult, FrictionObservation, JournalEntry, JournalLoadResult, JournalLoader, PermissionRequestObservation, SessionFrictionReport, SessionFrictionReportOptions, ToolResultObservation, } from './types.mts';
|
|
@@ -0,0 +1,5 @@
|
|
|
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 } from './report.mjs';
|
|
5
|
+
export { isConformingCiFailureBlock } from './ci-failures.mjs';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function withFileLock<Result>(path: string, action: () => Result): Result;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
const LOCK_ATTEMPTS = 200;
|
|
4
|
+
const STALE_LOCK_AGE_MS = 30_000;
|
|
5
|
+
const LOCK_OWNER_MAX_BYTES = 32;
|
|
6
|
+
const MAX_PID = 2_147_483_647;
|
|
7
|
+
const LOCK_CREATE_FLAGS = constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW;
|
|
8
|
+
const lockWait = new Int32Array(new SharedArrayBuffer(4));
|
|
9
|
+
function waitForLock() {
|
|
10
|
+
// Intentional synchronous backoff for CLI and hook callers.
|
|
11
|
+
Atomics.wait(lockWait, 0, 0, 5);
|
|
12
|
+
}
|
|
13
|
+
function hasCode(error, code) {
|
|
14
|
+
return Boolean(error && typeof error === 'object' && 'code' in error && error.code === code);
|
|
15
|
+
}
|
|
16
|
+
function isFresh(mtimeMs) {
|
|
17
|
+
const age = Date.now() - Math.floor(mtimeMs);
|
|
18
|
+
return age > -STALE_LOCK_AGE_MS && age < STALE_LOCK_AGE_MS;
|
|
19
|
+
}
|
|
20
|
+
function inspectLock(lockPath) {
|
|
21
|
+
let descriptor;
|
|
22
|
+
try {
|
|
23
|
+
descriptor = openSync(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* v8 ignore next -- detects a lock that disappears or is replaced while being inspected. */
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const status = fstatSync(descriptor);
|
|
31
|
+
/* v8 ignore next -- opening a non-regular lock requires a platform-specific FIFO/device. */
|
|
32
|
+
if (!status.isFile())
|
|
33
|
+
return null;
|
|
34
|
+
if (status.size > LOCK_OWNER_MAX_BYTES)
|
|
35
|
+
return isFresh(status.mtimeMs)
|
|
36
|
+
? null
|
|
37
|
+
: { dev: status.dev, ino: status.ino, mtimeMs: status.mtimeMs, owner: null };
|
|
38
|
+
const buffer = Buffer.alloc(LOCK_OWNER_MAX_BYTES + 1);
|
|
39
|
+
let length = 0;
|
|
40
|
+
while (length < buffer.length) {
|
|
41
|
+
const bytes = readSync(descriptor, buffer, length, buffer.length - length, length);
|
|
42
|
+
if (bytes === 0)
|
|
43
|
+
break;
|
|
44
|
+
length += bytes;
|
|
45
|
+
}
|
|
46
|
+
/* v8 ignore next -- detects external growth after the descriptor metadata check. */
|
|
47
|
+
if (length > LOCK_OWNER_MAX_BYTES)
|
|
48
|
+
return null;
|
|
49
|
+
const rawOwner = buffer.subarray(0, length).toString();
|
|
50
|
+
return {
|
|
51
|
+
dev: status.dev,
|
|
52
|
+
ino: status.ino,
|
|
53
|
+
mtimeMs: status.mtimeMs,
|
|
54
|
+
owner: /^[1-9][0-9]*$/.test(rawOwner) ? Number(rawOwner) : null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
closeSync(descriptor);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function withReaperLock(lockPath, action) {
|
|
62
|
+
const reaperPath = `${lockPath}.reap`;
|
|
63
|
+
let descriptor;
|
|
64
|
+
try {
|
|
65
|
+
descriptor = openSync(reaperPath, LOCK_CREATE_FLAGS, 0o600);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
/* v8 ignore next 2 -- requires a narrow filesystem race or platform open failure. */
|
|
69
|
+
if (hasCode(error, 'EEXIST') || hasCode(error, 'ENOENT'))
|
|
70
|
+
return false;
|
|
71
|
+
/* v8 ignore next -- unexpected platform errors must propagate. */
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return action();
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
closeSync(descriptor);
|
|
79
|
+
try {
|
|
80
|
+
unlinkSync(reaperPath);
|
|
81
|
+
}
|
|
82
|
+
catch { }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function removeOrphanedReaper(reaperPath) {
|
|
86
|
+
try {
|
|
87
|
+
const inspected = lstatSync(reaperPath);
|
|
88
|
+
if (isFresh(inspected.mtimeMs))
|
|
89
|
+
return false;
|
|
90
|
+
const current = lstatSync(reaperPath);
|
|
91
|
+
/* v8 ignore next 5 -- requires external replacement during stale-reaper recovery. */
|
|
92
|
+
if (current.dev !== inspected.dev ||
|
|
93
|
+
current.ino !== inspected.ino ||
|
|
94
|
+
current.mtimeMs !== inspected.mtimeMs)
|
|
95
|
+
return false;
|
|
96
|
+
unlinkSync(reaperPath);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* v8 ignore next -- requires external removal during stale-reaper recovery. */
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function removeStaleLock(lockPath) {
|
|
105
|
+
return withReaperLock(lockPath, () => {
|
|
106
|
+
try {
|
|
107
|
+
const inspected = inspectLock(lockPath);
|
|
108
|
+
if (!inspected)
|
|
109
|
+
return false;
|
|
110
|
+
const fresh = isFresh(inspected.mtimeMs);
|
|
111
|
+
const { owner } = inspected;
|
|
112
|
+
if (typeof owner === 'number' && Number.isInteger(owner) && owner > 0 && owner <= MAX_PID) {
|
|
113
|
+
try {
|
|
114
|
+
process.kill(owner, 0);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (!hasCode(error, 'ESRCH'))
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else if (fresh)
|
|
123
|
+
return false;
|
|
124
|
+
const current = lstatSync(lockPath);
|
|
125
|
+
/* v8 ignore next 5 -- requires external replacement despite the reaper protocol. */
|
|
126
|
+
if (current.dev !== inspected.dev ||
|
|
127
|
+
current.ino !== inspected.ino ||
|
|
128
|
+
current.mtimeMs !== inspected.mtimeMs)
|
|
129
|
+
return false;
|
|
130
|
+
unlinkSync(lockPath);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
export function withFileLock(path, action) {
|
|
139
|
+
const lockPath = `${path}.lock`;
|
|
140
|
+
const reaperPath = `${lockPath}.reap`;
|
|
141
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
|
|
142
|
+
let reaperExists = false;
|
|
143
|
+
try {
|
|
144
|
+
lstatSync(reaperPath);
|
|
145
|
+
reaperExists = true;
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
/* v8 ignore next -- unexpected reaper lstat failures must propagate. */
|
|
149
|
+
if (!hasCode(error, 'ENOENT'))
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
if (reaperExists) {
|
|
153
|
+
if (removeOrphanedReaper(reaperPath))
|
|
154
|
+
continue;
|
|
155
|
+
waitForLock();
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
let descriptor;
|
|
159
|
+
try {
|
|
160
|
+
descriptor = openSync(lockPath, LOCK_CREATE_FLAGS, 0o600);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (!hasCode(error, 'EEXIST'))
|
|
164
|
+
throw error;
|
|
165
|
+
let lockStatus;
|
|
166
|
+
try {
|
|
167
|
+
lockStatus = lstatSync(lockPath);
|
|
168
|
+
}
|
|
169
|
+
catch (statusError) {
|
|
170
|
+
/* v8 ignore next -- requires the owner to release between EEXIST and lstat. */
|
|
171
|
+
if (hasCode(statusError, 'ENOENT'))
|
|
172
|
+
continue;
|
|
173
|
+
/* v8 ignore next -- requires an unexpected lstat failure during the same race. */
|
|
174
|
+
throw statusError;
|
|
175
|
+
}
|
|
176
|
+
if (!lockStatus.isFile())
|
|
177
|
+
throw new Error('session-friction log lock must be a regular file');
|
|
178
|
+
if (removeStaleLock(lockPath))
|
|
179
|
+
continue;
|
|
180
|
+
waitForLock();
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
writeFileSync(descriptor, String(process.pid));
|
|
185
|
+
return action();
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
closeSync(descriptor);
|
|
189
|
+
unlinkSync(lockPath);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
throw new Error(`could not acquire session-friction log lock for ${basename(path)}`);
|
|
193
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { closeSync, constants, fchmodSync, fstatSync, openSync, readSync, writeSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { classifyFrictionObservation } from './classify.mjs';
|
|
4
|
+
import { ensurePrivateDirectory } from './directory.mjs';
|
|
5
|
+
import { withFileLock } from './lock.mjs';
|
|
6
|
+
import { boundedText, isSafeAuditText, isWellFormedUnicode, normalizeAuditText } from './text.mjs';
|
|
7
|
+
import { decodeUtf8 } from './utf8.mjs';
|
|
8
|
+
import { sanitizeSessionId } from './session-id.mjs';
|
|
9
|
+
export const FRICTION_LOG_MAX_EVENTS = 500;
|
|
10
|
+
const LOG_MAX_BYTES = 2_000_000;
|
|
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
|
+
function validEvent(value) {
|
|
31
|
+
if (!value || typeof value !== 'object')
|
|
32
|
+
return false;
|
|
33
|
+
const record = value;
|
|
34
|
+
return ((record.kind === 'sandbox-escalation' || record.kind === 'sandbox-failure') &&
|
|
35
|
+
isSafeAuditText(record.timestamp) &&
|
|
36
|
+
isSafeAuditText(record.commandPrefix) &&
|
|
37
|
+
isSafeAuditText(record.detail));
|
|
38
|
+
}
|
|
39
|
+
function openLogFile(path, flags) {
|
|
40
|
+
let descriptor;
|
|
41
|
+
try {
|
|
42
|
+
descriptor = openSync(path, flags | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error &&
|
|
46
|
+
typeof error === 'object' &&
|
|
47
|
+
'code' in error &&
|
|
48
|
+
(error.code === 'ELOOP' || error.code === 'EISDIR' || error.code === 'ENOTDIR'))
|
|
49
|
+
throw new Error('session-friction log must be a regular file');
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const status = fstatSync(descriptor);
|
|
54
|
+
if (!status.isFile() || status.nlink !== 1)
|
|
55
|
+
throw new Error('session-friction log must be a regular file');
|
|
56
|
+
return descriptor;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
closeSync(descriptor);
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function readLogContent(descriptor) {
|
|
64
|
+
if (fstatSync(descriptor).size > LOG_MAX_BYTES)
|
|
65
|
+
throw new Error('session-friction log is too large');
|
|
66
|
+
const buffer = Buffer.alloc(LOG_MAX_BYTES + 1);
|
|
67
|
+
let length = 0;
|
|
68
|
+
while (length < buffer.length) {
|
|
69
|
+
const bytes = readSync(descriptor, buffer, length, buffer.length - length, length);
|
|
70
|
+
if (bytes === 0)
|
|
71
|
+
break;
|
|
72
|
+
length += bytes;
|
|
73
|
+
}
|
|
74
|
+
/* v8 ignore next -- detects external growth after the descriptor size check. */
|
|
75
|
+
if (length > LOG_MAX_BYTES)
|
|
76
|
+
throw new Error('session-friction log is too large');
|
|
77
|
+
return decodeUtf8(buffer.subarray(0, length));
|
|
78
|
+
}
|
|
79
|
+
function validEventCount(content, limit) {
|
|
80
|
+
let count = 0;
|
|
81
|
+
for (const line of content.split('\n')) {
|
|
82
|
+
if (!line.trim())
|
|
83
|
+
continue;
|
|
84
|
+
try {
|
|
85
|
+
if (validEvent(JSON.parse(line)))
|
|
86
|
+
count++;
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
if (count >= limit)
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
return count;
|
|
93
|
+
}
|
|
94
|
+
function atEventLimit(content, limit) {
|
|
95
|
+
return validEventCount(content, limit) >= limit;
|
|
96
|
+
}
|
|
97
|
+
function writeAll(descriptor, value) {
|
|
98
|
+
const buffer = Buffer.from(value);
|
|
99
|
+
let offset = 0;
|
|
100
|
+
while (offset < buffer.length) {
|
|
101
|
+
const written = writeSync(descriptor, buffer, offset, buffer.length - offset);
|
|
102
|
+
/* v8 ignore next -- a zero-byte synchronous file write is an external I/O failure. */
|
|
103
|
+
if (written === 0)
|
|
104
|
+
throw new Error('session-friction log write made no progress');
|
|
105
|
+
offset += written;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function recordFriction(sessionId, observation, options) {
|
|
109
|
+
const maxEvents = eventLimit(options.maxEvents);
|
|
110
|
+
const timestamp = typeof options.timestamp === 'function' ? options.timestamp() : options.timestamp;
|
|
111
|
+
if (timestamp !== undefined && typeof timestamp !== 'string')
|
|
112
|
+
throw new Error('timestamp must be a string');
|
|
113
|
+
const rawTimestamp = timestamp ?? new Date().toISOString();
|
|
114
|
+
const normalizedTimestamp = normalizeAuditText(boundedText(rawTimestamp, EVENT_FIELD_MAX_LENGTH));
|
|
115
|
+
if (!normalizedTimestamp)
|
|
116
|
+
throw new Error('timestamp must be non-empty');
|
|
117
|
+
if (!isWellFormedUnicode(normalizedTimestamp))
|
|
118
|
+
throw new Error('timestamp must be well-formed Unicode');
|
|
119
|
+
const classified = classifyFrictionObservation(observation);
|
|
120
|
+
const path = logPath(sessionId, options.directory);
|
|
121
|
+
const directory = requireDirectory(options.directory);
|
|
122
|
+
ensurePrivateDirectory(directory, true);
|
|
123
|
+
withFileLock(path, () => {
|
|
124
|
+
/* v8 ignore next 2 -- requires the directory path to be replaced after its lock is acquired. */
|
|
125
|
+
if (!ensurePrivateDirectory(directory, false))
|
|
126
|
+
throw new Error('session-friction log directory disappeared while acquiring the lock');
|
|
127
|
+
const descriptor = openLogFile(path, constants.O_CREAT | constants.O_RDWR | constants.O_APPEND);
|
|
128
|
+
try {
|
|
129
|
+
fchmodSync(descriptor, 0o600);
|
|
130
|
+
if (fstatSync(descriptor).size > LOG_MAX_BYTES)
|
|
131
|
+
throw new Error('session-friction log is too large');
|
|
132
|
+
if (!classified)
|
|
133
|
+
return;
|
|
134
|
+
const content = readLogContent(descriptor);
|
|
135
|
+
if (atEventLimit(content, maxEvents))
|
|
136
|
+
return;
|
|
137
|
+
const event = {
|
|
138
|
+
...classified,
|
|
139
|
+
commandPrefix: normalizeAuditText(classified.commandPrefix),
|
|
140
|
+
detail: normalizeAuditText(classified.detail),
|
|
141
|
+
timestamp: normalizedTimestamp,
|
|
142
|
+
};
|
|
143
|
+
/* v8 ignore next -- normalized classified fields satisfy validEvent defensively. */
|
|
144
|
+
if (!validEvent(event))
|
|
145
|
+
return;
|
|
146
|
+
const prefix = content && !content.endsWith('\n') ? '\n' : '';
|
|
147
|
+
const addition = `${prefix}${JSON.stringify(event)}\n`;
|
|
148
|
+
if (fstatSync(descriptor).size + Buffer.byteLength(addition) > LOG_MAX_BYTES)
|
|
149
|
+
throw new Error('session-friction log is too large');
|
|
150
|
+
writeAll(descriptor, addition);
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
closeSync(descriptor);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
export function readFrictionLog(sessionId, options) {
|
|
158
|
+
const maxEvents = eventLimit(options.maxEvents);
|
|
159
|
+
const path = logPath(sessionId, options.directory);
|
|
160
|
+
const directory = requireDirectory(options.directory);
|
|
161
|
+
if (!ensurePrivateDirectory(directory, false))
|
|
162
|
+
return { status: 'absent' };
|
|
163
|
+
try {
|
|
164
|
+
return withFileLock(path, () => {
|
|
165
|
+
/* v8 ignore next -- detects directory removal after acquiring the file lock. */
|
|
166
|
+
if (!ensurePrivateDirectory(directory, false))
|
|
167
|
+
return { status: 'absent' };
|
|
168
|
+
const descriptor = openLogFile(path, constants.O_RDONLY);
|
|
169
|
+
try {
|
|
170
|
+
const events = [];
|
|
171
|
+
for (const line of readLogContent(descriptor).split('\n')) {
|
|
172
|
+
if (!line.trim())
|
|
173
|
+
continue;
|
|
174
|
+
try {
|
|
175
|
+
const value = JSON.parse(line);
|
|
176
|
+
if (validEvent(value))
|
|
177
|
+
events.push(value);
|
|
178
|
+
}
|
|
179
|
+
catch { }
|
|
180
|
+
if (events.length >= maxEvents)
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
return events.length ? { status: 'events', events } : { status: 'empty' };
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
closeSync(descriptor);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
|
192
|
+
return { status: 'absent' };
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function normalizeCommandPrefix(command: string, wrappersToStrip?: string[]): string;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
const MAX_COMMAND_TOKEN_LENGTH = 40;
|
|
2
|
+
const MAX_COMMAND_LENGTH = 100_000;
|
|
3
|
+
const MAX_COMMAND_WRAPPERS = 16;
|
|
4
|
+
const REDACTED_TOKEN = '[REDACTED]';
|
|
5
|
+
const CREDENTIAL_OPTION = /^--?(?:api[-_]?key|auth|credential|password|secret|token)=/i;
|
|
6
|
+
const URL_USERINFO = /^[a-z][a-z0-9+.-]*:\/\/[^/@\s]+@/i;
|
|
7
|
+
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
8
|
+
const PACKAGE_RUNNERS = new Set(['npm', 'npx', 'pnpm', 'pnpx', 'yarn']);
|
|
9
|
+
const GIT_OPTIONS_WITH_ARGS = new Set(['-C', '-c', '--git-dir', '--work-tree']);
|
|
10
|
+
const ENV_OPTIONS_WITH_ARGS = new Set(['-C', '--chdir', '-S', '--split-string', '-u', '--unset']);
|
|
11
|
+
const ENV_OPTIONS_WITHOUT_ARGS = new Set([
|
|
12
|
+
'-i',
|
|
13
|
+
'--ignore-environment',
|
|
14
|
+
'-0',
|
|
15
|
+
'--null',
|
|
16
|
+
'-v',
|
|
17
|
+
'--debug',
|
|
18
|
+
]);
|
|
19
|
+
const GIT_OPTIONS_WITHOUT_ARGS = new Set([
|
|
20
|
+
'-v',
|
|
21
|
+
'--version',
|
|
22
|
+
'-h',
|
|
23
|
+
'--help',
|
|
24
|
+
'--exec-path',
|
|
25
|
+
'--html-path',
|
|
26
|
+
'--man-path',
|
|
27
|
+
'--info-path',
|
|
28
|
+
'-p',
|
|
29
|
+
'--paginate',
|
|
30
|
+
'-P',
|
|
31
|
+
'--no-pager',
|
|
32
|
+
'--no-replace-objects',
|
|
33
|
+
'--bare',
|
|
34
|
+
]);
|
|
35
|
+
function splitCommand(command) {
|
|
36
|
+
const result = [];
|
|
37
|
+
let segment = [];
|
|
38
|
+
let word = '';
|
|
39
|
+
let quote;
|
|
40
|
+
let escaped = false;
|
|
41
|
+
const flushWord = () => {
|
|
42
|
+
if (word)
|
|
43
|
+
segment.push(word);
|
|
44
|
+
word = '';
|
|
45
|
+
};
|
|
46
|
+
const flushSegment = () => {
|
|
47
|
+
flushWord();
|
|
48
|
+
if (segment.length)
|
|
49
|
+
result.push(segment);
|
|
50
|
+
segment = [];
|
|
51
|
+
};
|
|
52
|
+
for (const char of command) {
|
|
53
|
+
if (escaped) {
|
|
54
|
+
word += char;
|
|
55
|
+
escaped = false;
|
|
56
|
+
}
|
|
57
|
+
else if (char === '\\' && quote !== "'") {
|
|
58
|
+
escaped = true;
|
|
59
|
+
}
|
|
60
|
+
else if (quote) {
|
|
61
|
+
if (char === quote)
|
|
62
|
+
quote = undefined;
|
|
63
|
+
else
|
|
64
|
+
word += char;
|
|
65
|
+
}
|
|
66
|
+
else if (char === '"' || char === "'")
|
|
67
|
+
quote = char;
|
|
68
|
+
else if (';&|<>\n\r'.includes(char))
|
|
69
|
+
flushSegment();
|
|
70
|
+
else if (/\s/.test(char))
|
|
71
|
+
flushWord();
|
|
72
|
+
else
|
|
73
|
+
word += char;
|
|
74
|
+
}
|
|
75
|
+
if (escaped)
|
|
76
|
+
word += '\\';
|
|
77
|
+
flushSegment();
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
function redact(token) {
|
|
81
|
+
return token.length > MAX_COMMAND_TOKEN_LENGTH ||
|
|
82
|
+
CREDENTIAL_OPTION.test(token) ||
|
|
83
|
+
URL_USERINFO.test(token)
|
|
84
|
+
? REDACTED_TOKEN
|
|
85
|
+
: token;
|
|
86
|
+
}
|
|
87
|
+
function stripAssignments(tokens) {
|
|
88
|
+
let index = 0;
|
|
89
|
+
while (index < tokens.length && ENV_ASSIGNMENT.test(tokens[index]))
|
|
90
|
+
index++;
|
|
91
|
+
const wrapper = tokens[index];
|
|
92
|
+
// Assignments without an executable are represented by a safe sentinel.
|
|
93
|
+
if (wrapper !== 'env' && !wrapper?.endsWith('/env'))
|
|
94
|
+
return index < tokens.length ? tokens.slice(index) : index ? [REDACTED_TOKEN] : tokens;
|
|
95
|
+
index++;
|
|
96
|
+
while (index < tokens.length) {
|
|
97
|
+
const option = tokens[index];
|
|
98
|
+
if (option === '--') {
|
|
99
|
+
index++;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
const optionName = option.split('=', 1)[0];
|
|
103
|
+
const splitStringOption = optionName === '-S' || (optionName.length >= 5 && '--split-string'.startsWith(optionName));
|
|
104
|
+
if (splitStringOption)
|
|
105
|
+
return [REDACTED_TOKEN];
|
|
106
|
+
const abbreviatedArgument = optionName.length >= 5 && '--chdir'.startsWith(optionName);
|
|
107
|
+
if (ENV_OPTIONS_WITH_ARGS.has(optionName) || abbreviatedArgument) {
|
|
108
|
+
index += option.includes('=') ? 1 : 2;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ENV_OPTIONS_WITHOUT_ARGS.has(option)) {
|
|
112
|
+
index++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (option.startsWith('-'))
|
|
116
|
+
return [REDACTED_TOKEN];
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
while (index < tokens.length && ENV_ASSIGNMENT.test(tokens[index]))
|
|
120
|
+
index++;
|
|
121
|
+
if (index < tokens.length)
|
|
122
|
+
return tokens.slice(index);
|
|
123
|
+
return tokens.some((token) => ENV_ASSIGNMENT.test(token)) ? [REDACTED_TOKEN] : [];
|
|
124
|
+
}
|
|
125
|
+
function stripGitOptions(tokens) {
|
|
126
|
+
let index = 0;
|
|
127
|
+
while (index < tokens.length) {
|
|
128
|
+
const token = tokens[index];
|
|
129
|
+
if (token === '--')
|
|
130
|
+
return tokens.slice(index + 1);
|
|
131
|
+
if (GIT_OPTIONS_WITH_ARGS.has(token)) {
|
|
132
|
+
if (index + 1 >= tokens.length)
|
|
133
|
+
return [REDACTED_TOKEN];
|
|
134
|
+
index += 2;
|
|
135
|
+
}
|
|
136
|
+
else if (GIT_OPTIONS_WITHOUT_ARGS.has(token) || /^--[a-z0-9-]+=/i.test(token))
|
|
137
|
+
index++;
|
|
138
|
+
else
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
return tokens.slice(index);
|
|
142
|
+
}
|
|
143
|
+
function packageRunner(token) {
|
|
144
|
+
return [...PACKAGE_RUNNERS].some((runner) => token === runner || token.endsWith(`/${runner}`));
|
|
145
|
+
}
|
|
146
|
+
function normalizeSegment(tokens) {
|
|
147
|
+
const [first, ...rest] = tokens;
|
|
148
|
+
if (!first)
|
|
149
|
+
return '';
|
|
150
|
+
if (packageRunner(first) && (rest[0] === 'run' || rest[0] === 'exec') && rest[1])
|
|
151
|
+
return `${redact(first)} ${rest[0]} ${redact(rest[1])}`;
|
|
152
|
+
const effective = first === 'git' || first.endsWith('/git') ? stripGitOptions(rest) : rest;
|
|
153
|
+
return effective[0] ? `${redact(first)} ${redact(effective[0])}` : redact(first);
|
|
154
|
+
}
|
|
155
|
+
export function normalizeCommandPrefix(command, wrappersToStrip = []) {
|
|
156
|
+
if (command.length > MAX_COMMAND_LENGTH)
|
|
157
|
+
return '[REDACTED: command too long]';
|
|
158
|
+
const segments = splitCommand(command);
|
|
159
|
+
let index = 0;
|
|
160
|
+
while (index < segments.length - 1 && stripAssignments(segments[index])[0] === 'cd')
|
|
161
|
+
index++;
|
|
162
|
+
const tokens = stripAssignments(segments[index] ?? []);
|
|
163
|
+
const wrapperValues = Array.isArray(wrappersToStrip) ? wrappersToStrip : [];
|
|
164
|
+
const wrapperNames = new Set(wrapperValues
|
|
165
|
+
.slice(0, MAX_COMMAND_WRAPPERS)
|
|
166
|
+
.filter((value) => typeof value === 'string' && value.length > 0 && value.length <= MAX_COMMAND_TOKEN_LENGTH));
|
|
167
|
+
let wrappers = 0;
|
|
168
|
+
while (tokens[wrappers] && wrapperNames.has(tokens[wrappers]))
|
|
169
|
+
wrappers++;
|
|
170
|
+
if (!wrappers)
|
|
171
|
+
return normalizeSegment(tokens);
|
|
172
|
+
const nested = normalizeSegment(stripAssignments(tokens.slice(wrappers)));
|
|
173
|
+
return nested ? `${redact(tokens[0])} ${nested}` : redact(tokens[0]);
|
|
174
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { eventLimit, readFrictionLog, requireDirectory } from './log.mjs';
|
|
2
|
+
import { buildCiFailuresSection, getConformingGroups } from './ci-failures.mjs';
|
|
3
|
+
import { buildSandboxSection } from './sandbox.mjs';
|
|
4
|
+
import { validateSessionId } from './session-id.mjs';
|
|
5
|
+
const JOURNAL_ENTRY_MAX_COUNT = 500;
|
|
6
|
+
const JOURNAL_MARKDOWN_MAX_BYTES = 10_000;
|
|
7
|
+
const JOURNAL_TOTAL_MAX_BYTES = 1_000_000;
|
|
8
|
+
function journalData(entry) {
|
|
9
|
+
try {
|
|
10
|
+
const data = entry?.data;
|
|
11
|
+
return data ? { type: data.type, markdown: data.markdown } : undefined;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function collectEntries(entries) {
|
|
18
|
+
const result = [];
|
|
19
|
+
let consumed = 0;
|
|
20
|
+
let inspectedBytes = 0;
|
|
21
|
+
let retainedBytes = 0;
|
|
22
|
+
let truncated = false;
|
|
23
|
+
for await (const entry of entries) {
|
|
24
|
+
consumed++;
|
|
25
|
+
const data = journalData(entry);
|
|
26
|
+
const markdown = data?.markdown;
|
|
27
|
+
if (data?.type === 'journal' && typeof markdown === 'string') {
|
|
28
|
+
const bytes = Buffer.byteLength(markdown);
|
|
29
|
+
inspectedBytes += bytes;
|
|
30
|
+
if (bytes > JOURNAL_MARKDOWN_MAX_BYTES)
|
|
31
|
+
truncated = true;
|
|
32
|
+
if (bytes <= JOURNAL_MARKDOWN_MAX_BYTES && retainedBytes + bytes <= JOURNAL_TOTAL_MAX_BYTES) {
|
|
33
|
+
result.push({ data: { type: 'journal', markdown } });
|
|
34
|
+
retainedBytes += bytes;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (consumed >= JOURNAL_ENTRY_MAX_COUNT ||
|
|
38
|
+
inspectedBytes >= JOURNAL_TOTAL_MAX_BYTES ||
|
|
39
|
+
retainedBytes >= JOURNAL_TOTAL_MAX_BYTES) {
|
|
40
|
+
truncated = true;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { entries: result, truncated };
|
|
45
|
+
}
|
|
46
|
+
function errorMessage(error) {
|
|
47
|
+
try {
|
|
48
|
+
return error instanceof Error ? error.message : String(error);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return '[unprintable error]';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function sandboxMarkdown(friction) {
|
|
55
|
+
if (friction.status === 'events')
|
|
56
|
+
return buildSandboxSection(friction.events);
|
|
57
|
+
const status = friction.status === 'empty' ? 'none observed' : 'unavailable (no friction log)';
|
|
58
|
+
return `## Sandbox & Permission Audit\nStatus: ${status}`;
|
|
59
|
+
}
|
|
60
|
+
function reportFromLog(sessionId, logOptions, journal) {
|
|
61
|
+
try {
|
|
62
|
+
const friction = readFrictionLog(sessionId, logOptions);
|
|
63
|
+
if (journal.status === 'ok' && journal.truncated)
|
|
64
|
+
return {
|
|
65
|
+
markdown: '## CI Failures\nStatus: unavailable (journal scan incomplete)\n\n' +
|
|
66
|
+
sandboxMarkdown(friction),
|
|
67
|
+
};
|
|
68
|
+
const markdown = buildCiFailuresSection(sessionId, journal, friction.status);
|
|
69
|
+
return { markdown: `${markdown}\n\n${sandboxMarkdown(friction)}` };
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
const ciMarkdown = journal.status === 'ok' && journal.truncated
|
|
73
|
+
? '## CI Failures\nStatus: unavailable (journal scan incomplete)'
|
|
74
|
+
: journal.status === 'ok' && journal.markdownBlocks.length === 0
|
|
75
|
+
? '## CI Failures\nStatus: unavailable (friction log unreadable)'
|
|
76
|
+
: buildCiFailuresSection(sessionId, journal, 'empty');
|
|
77
|
+
return {
|
|
78
|
+
markdown: `${ciMarkdown}\n\n## Sandbox & Permission Audit\nStatus: unavailable (friction log unreadable)`,
|
|
79
|
+
diagnostic: errorMessage(error),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function buildSessionFrictionReport(sessionId, options) {
|
|
84
|
+
validateSessionId(sessionId);
|
|
85
|
+
requireDirectory(options.directory);
|
|
86
|
+
eventLimit(options.maxEvents);
|
|
87
|
+
let journal;
|
|
88
|
+
try {
|
|
89
|
+
const loaded = await options.journalLoader(sessionId);
|
|
90
|
+
if (loaded.status === 'not-found')
|
|
91
|
+
journal = { status: 'ok', markdownBlocks: [], truncated: false };
|
|
92
|
+
else {
|
|
93
|
+
const collected = await collectEntries(loaded.entries);
|
|
94
|
+
journal = {
|
|
95
|
+
status: 'ok',
|
|
96
|
+
markdownBlocks: getConformingGroups(collected.entries),
|
|
97
|
+
truncated: collected.truncated,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
const diagnostic = errorMessage(error);
|
|
103
|
+
journal = { status: 'unreachable', diagnostic };
|
|
104
|
+
}
|
|
105
|
+
const report = reportFromLog(sessionId, options, journal);
|
|
106
|
+
if (journal.status === 'unreachable') {
|
|
107
|
+
const diagnostic = report.diagnostic
|
|
108
|
+
? `${journal.diagnostic}; ${report.diagnostic}`
|
|
109
|
+
: journal.diagnostic;
|
|
110
|
+
return { ...report, diagnostic };
|
|
111
|
+
}
|
|
112
|
+
return report;
|
|
113
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { markdownAuditText } from './text.mjs';
|
|
2
|
+
const SANDBOX_SECTION_HEADER = '## Sandbox & Permission Audit';
|
|
3
|
+
const KIND_ORDER = ['sandbox-escalation', 'sandbox-failure'];
|
|
4
|
+
export function buildSandboxSection(events) {
|
|
5
|
+
const groups = KIND_ORDER.map((kind) => {
|
|
6
|
+
const selected = events.filter((event) => event.kind === kind);
|
|
7
|
+
return selected.length
|
|
8
|
+
? [
|
|
9
|
+
`- ${kind} (${selected.length})`,
|
|
10
|
+
...selected.map((event) => ` - ${markdownAuditText(event.commandPrefix)} — ${markdownAuditText(event.detail)} — ${markdownAuditText(event.timestamp)}`),
|
|
11
|
+
].join('\n')
|
|
12
|
+
: undefined;
|
|
13
|
+
}).filter((group) => group !== undefined);
|
|
14
|
+
return `${SANDBOX_SECTION_HEADER}\nEvents observed: ${events.length}\n\n${groups.join('\n')}`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isWellFormedUnicode, normalizeAuditText } from './text.mjs';
|
|
3
|
+
const SESSION_ID_MAX_LENGTH = 4096;
|
|
4
|
+
export function validateSessionId(sessionId) {
|
|
5
|
+
if (typeof sessionId !== 'string')
|
|
6
|
+
throw new Error('sessionId must be a string');
|
|
7
|
+
if (sessionId.length > SESSION_ID_MAX_LENGTH)
|
|
8
|
+
throw new Error('sessionId is too long');
|
|
9
|
+
if (!isWellFormedUnicode(sessionId))
|
|
10
|
+
throw new Error('sessionId must be well-formed Unicode');
|
|
11
|
+
if (normalizeAuditText(sessionId) === '')
|
|
12
|
+
throw new Error('sessionId must be non-empty');
|
|
13
|
+
}
|
|
14
|
+
export function sanitizeSessionId(sessionId) {
|
|
15
|
+
validateSessionId(sessionId);
|
|
16
|
+
return createHash('sha256').update(sessionId).digest('hex');
|
|
17
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function normalizeAuditText(value: string): string;
|
|
2
|
+
export declare function boundedText(value: string, maximum: number): string;
|
|
3
|
+
export declare function isWellFormedUnicode(value: string): boolean;
|
|
4
|
+
export declare function isSafeAuditText(value: unknown): value is string;
|
|
5
|
+
export declare function markdownAuditText(value: string): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const CONTROL_CHARACTERS = /[\p{Cc}\p{Cf}]+/gu;
|
|
2
|
+
const MARKDOWN_CHARACTER = /\\|`|\*|_|~|\[|\]|<|>|#|-|\+|!|\||&/;
|
|
3
|
+
const MARKDOWN_AUDIT_MAX_LENGTH = 120;
|
|
4
|
+
export function normalizeAuditText(value) {
|
|
5
|
+
return value.replace(CONTROL_CHARACTERS, ' ').trim();
|
|
6
|
+
}
|
|
7
|
+
export function boundedText(value, maximum) {
|
|
8
|
+
let end = Math.min(value.length, maximum);
|
|
9
|
+
if (end < value.length &&
|
|
10
|
+
/[\uD800-\uDBFF]/.test(value[end - 1]) &&
|
|
11
|
+
/[\uDC00-\uDFFF]/.test(value[end]))
|
|
12
|
+
end--;
|
|
13
|
+
return value.slice(0, end);
|
|
14
|
+
}
|
|
15
|
+
export function isWellFormedUnicode(value) {
|
|
16
|
+
for (let index = 0; index < value.length; index++) {
|
|
17
|
+
const code = value.charCodeAt(index);
|
|
18
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
19
|
+
const next = value.charCodeAt(index + 1);
|
|
20
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
21
|
+
return false;
|
|
22
|
+
index++;
|
|
23
|
+
}
|
|
24
|
+
else if (code >= 0xdc00 && code <= 0xdfff)
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
export function isSafeAuditText(value) {
|
|
30
|
+
return (typeof value === 'string' &&
|
|
31
|
+
value !== '' &&
|
|
32
|
+
isWellFormedUnicode(value) &&
|
|
33
|
+
value === normalizeAuditText(value));
|
|
34
|
+
}
|
|
35
|
+
export function markdownAuditText(value) {
|
|
36
|
+
// Escaping is atomic, so the result may be shorter than the maximum.
|
|
37
|
+
let result = '';
|
|
38
|
+
for (const character of normalizeAuditText(value)) {
|
|
39
|
+
const escaped = MARKDOWN_CHARACTER.test(character) ? `\\${character}` : character;
|
|
40
|
+
if (result.length + escaped.length > MARKDOWN_AUDIT_MAX_LENGTH)
|
|
41
|
+
break;
|
|
42
|
+
result += escaped;
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type FrictionEventKind = 'sandbox-escalation' | 'sandbox-failure';
|
|
2
|
+
export type FrictionEvent = {
|
|
3
|
+
kind: FrictionEventKind;
|
|
4
|
+
timestamp: string;
|
|
5
|
+
commandPrefix: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
};
|
|
8
|
+
export type ToolResultObservation = {
|
|
9
|
+
type: 'tool-result';
|
|
10
|
+
command: string;
|
|
11
|
+
commandWrappers?: string[];
|
|
12
|
+
escalationDetail?: string;
|
|
13
|
+
structuredStderr?: string;
|
|
14
|
+
};
|
|
15
|
+
export type PermissionRequestObservation = {
|
|
16
|
+
type: 'permission-request';
|
|
17
|
+
command: string;
|
|
18
|
+
commandWrappers?: string[];
|
|
19
|
+
};
|
|
20
|
+
export type FrictionObservation = ToolResultObservation | PermissionRequestObservation;
|
|
21
|
+
export type FrictionLogReadResult = {
|
|
22
|
+
status: 'absent';
|
|
23
|
+
} | {
|
|
24
|
+
status: 'empty';
|
|
25
|
+
} | {
|
|
26
|
+
status: 'events';
|
|
27
|
+
events: FrictionEvent[];
|
|
28
|
+
};
|
|
29
|
+
export type FrictionLogOptions = {
|
|
30
|
+
directory: string;
|
|
31
|
+
maxEvents?: number;
|
|
32
|
+
};
|
|
33
|
+
export type JournalEntry = {
|
|
34
|
+
data?: {
|
|
35
|
+
type?: unknown;
|
|
36
|
+
markdown?: unknown;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
export type JournalLoadResult = {
|
|
40
|
+
status: 'ok';
|
|
41
|
+
entries: Iterable<JournalEntry> | AsyncIterable<JournalEntry>;
|
|
42
|
+
} | {
|
|
43
|
+
status: 'not-found';
|
|
44
|
+
};
|
|
45
|
+
export type JournalLoader = (sessionId: string) => JournalLoadResult | Promise<JournalLoadResult>;
|
|
46
|
+
export type SessionFrictionReport = {
|
|
47
|
+
markdown: string;
|
|
48
|
+
diagnostic?: string;
|
|
49
|
+
};
|
|
50
|
+
export type SessionFrictionReportOptions = FrictionLogOptions & {
|
|
51
|
+
journalLoader: JournalLoader;
|
|
52
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function decodeUtf8(value: Uint8Array): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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": {
|
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
"import": "./dist/retrospective-transcript/index.mjs",
|
|
42
42
|
"default": "./dist/retrospective-transcript/index.mjs"
|
|
43
43
|
},
|
|
44
|
+
"./session-friction": {
|
|
45
|
+
"types": "./dist/session-friction/index.d.mts",
|
|
46
|
+
"import": "./dist/session-friction/index.mjs",
|
|
47
|
+
"default": "./dist/session-friction/index.mjs"
|
|
48
|
+
},
|
|
44
49
|
"./sql-ast": {
|
|
45
50
|
"types": "./dist/sql-ast/index.d.mts",
|
|
46
51
|
"import": "./dist/sql-ast/index.mjs",
|
|
@@ -71,15 +71,39 @@ download_exact() {
|
|
|
71
71
|
GH_HOST="$github_host" gh run download "$GITHUB_RUN_ID" --repo "$repository" --name "$artifact" --dir "$directory"
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
list_artifact_names_once() {
|
|
75
75
|
GH_HOST="$github_host" gh api \
|
|
76
76
|
--paginate \
|
|
77
77
|
"repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts?per_page=100" \
|
|
78
78
|
--jq '.artifacts[] | select(.expired | not) | .name'
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
list_artifact_names() {
|
|
82
|
+
local attempt status=0 delay artifact_names
|
|
83
|
+
for attempt in 1 2 3; do
|
|
84
|
+
if artifact_names=$(list_artifact_names_once); then
|
|
85
|
+
[ -z "$artifact_names" ] || printf '%s\n' "$artifact_names"
|
|
86
|
+
return 0
|
|
87
|
+
else
|
|
88
|
+
status=$?
|
|
89
|
+
fi
|
|
90
|
+
case "$status" in 130|143) return "$status" ;; esac
|
|
91
|
+
if [ "$attempt" -lt 3 ]; then
|
|
92
|
+
delay=2
|
|
93
|
+
[ "$attempt" -eq 2 ] && delay=5
|
|
94
|
+
echo "::warning::Optional same-run artifact listing failed (attempt $attempt/3 exit=$status); retrying in ${delay}s" >&2
|
|
95
|
+
if sleep "$delay"; then :; else return $?; fi
|
|
96
|
+
fi
|
|
97
|
+
done
|
|
98
|
+
echo "[optional-run-artifacts] listing exhausted attempts=3 exit=$status" >&2
|
|
99
|
+
return "$status"
|
|
100
|
+
}
|
|
101
|
+
|
|
81
102
|
download_pattern() {
|
|
82
|
-
artifacts=$(list_artifact_names
|
|
103
|
+
artifacts=$(list_artifact_names) || return $?
|
|
104
|
+
if [ -n "$artifacts" ]; then
|
|
105
|
+
artifacts=$(printf '%s\n' "$artifacts" | awk '!seen[$0]++')
|
|
106
|
+
fi
|
|
83
107
|
selected_artifacts=()
|
|
84
108
|
while IFS= read -r artifact; do
|
|
85
109
|
[ -n "$artifact" ] || continue
|