vouchington-tooling 0.41.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/policy-runner.mjs +26 -0
- package/dist/agent-blackboard/feedback-codec.d.mts +1 -0
- package/dist/agent-blackboard/feedback-codec.mjs +1 -1
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +3 -1
- package/dist/gh-cli/pr-create.d.mts +2 -0
- package/dist/gh-cli/pr-create.mjs +5 -1
- package/dist/mcp-server/context.d.mts +22 -2
- package/dist/mcp-server/context.mjs +14 -1
- package/dist/mcp-server/dispatch.d.mts +11 -0
- package/dist/mcp-server/dispatch.mjs +12 -0
- package/dist/mcp-server/handlers-journal.d.mts +14 -1
- package/dist/mcp-server/handlers-journal.mjs +14 -9
- package/dist/mcp-server/handlers-pr.d.mts +4 -0
- package/dist/mcp-server/handlers-pr.mjs +143 -0
- package/dist/mcp-server/handlers-retrospective.d.mts +12 -0
- package/dist/mcp-server/handlers-retrospective.mjs +154 -0
- package/dist/mcp-server/harness.test-helpers.d.mts +10 -1
- package/dist/mcp-server/harness.test-helpers.mjs +4 -1
- package/dist/mcp-server/journal-read.d.mts +9 -0
- package/dist/mcp-server/journal-read.mjs +33 -0
- package/dist/mcp-server/policy-config.d.mts +6 -0
- package/dist/mcp-server/policy-config.mjs +60 -0
- package/dist/mcp-server/policy-run.d.mts +40 -0
- package/dist/mcp-server/policy-run.mjs +116 -0
- package/dist/mcp-server/policy-spawn.d.mts +24 -0
- package/dist/mcp-server/policy-spawn.mjs +97 -0
- package/dist/mcp-server/pr-body-file.d.mts +4 -0
- package/dist/mcp-server/pr-body-file.mjs +22 -0
- package/dist/mcp-server/pr-body.d.mts +20 -0
- package/dist/mcp-server/pr-body.mjs +47 -0
- package/dist/mcp-server/pr-defaults.d.mts +16 -0
- package/dist/mcp-server/pr-defaults.mjs +35 -0
- package/dist/mcp-server/pr-gh.d.mts +16 -0
- package/dist/mcp-server/pr-gh.mjs +56 -0
- package/dist/mcp-server/pr-input.d.mts +26 -0
- package/dist/mcp-server/pr-input.mjs +62 -0
- package/dist/mcp-server/pr-journal.d.mts +12 -0
- package/dist/mcp-server/pr-journal.mjs +86 -0
- package/dist/mcp-server/pr-live.d.mts +24 -0
- package/dist/mcp-server/pr-live.mjs +49 -0
- package/dist/mcp-server/pr-tools.test-helpers.d.mts +54 -0
- package/dist/mcp-server/pr-tools.test-helpers.mjs +67 -0
- package/dist/mcp-server/retrospective-facts-input.d.mts +6 -0
- package/dist/mcp-server/retrospective-facts-input.mjs +25 -0
- package/dist/mcp-server/retrospective-front-matter.d.mts +22 -0
- package/dist/mcp-server/retrospective-front-matter.mjs +88 -0
- package/dist/mcp-server/retrospective-input.d.mts +29 -0
- package/dist/mcp-server/retrospective-input.mjs +145 -0
- package/dist/mcp-server/retrospective-resolve.d.mts +15 -0
- package/dist/mcp-server/retrospective-resolve.mjs +41 -0
- package/dist/mcp-server/tools-pr.d.mts +2 -0
- package/dist/mcp-server/tools-pr.mjs +69 -0
- package/dist/mcp-server/tools-retrospective.d.mts +2 -0
- package/dist/mcp-server/tools-retrospective.mjs +48 -0
- package/dist/mcp-server/tools-schema.d.mts +54 -0
- package/dist/mcp-server/tools-schema.mjs +70 -0
- package/dist/mcp-server/tools.d.mts +1 -18
- package/dist/mcp-server/tools.mjs +6 -70
- package/dist/mcp-server/worktree.d.mts +11 -4
- package/dist/mcp-server/worktree.mjs +31 -6
- package/docs/mcp-server.md +164 -16
- package/package.json +1 -1
- package/skills/pr-description/SKILL.md +8 -0
- package/skills/retrospective/SKILL.md +5 -2
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createFeedbackEnvelope, validateFeedbackEnvelope, writeFeedback, } from "../agent-blackboard/index.mjs";
|
|
3
|
+
import { optionalRecord, optionalString, requiredChoice, requiredString, requiredStringArray, } from "./args.mjs";
|
|
4
|
+
import { assertPolicyAllowed, outboxDirectory, readIdentity } from "./context.mjs";
|
|
5
|
+
import { byCreatedAt, deliveryResponse, MODES, OUTCOMES, readCoverage, } from "./handlers-journal.mjs";
|
|
6
|
+
import { readEntriesIfPresent } from "./journal-read.mjs";
|
|
7
|
+
import { runValidator } from "./policy-run.mjs";
|
|
8
|
+
import { parseRetrospectiveFrontMatter } from "./retrospective-front-matter.mjs";
|
|
9
|
+
import { FEEDBACK_MARKDOWN_MAX_BYTES, redactFeedbackText, } from "../agent-blackboard/feedback-codec.mjs";
|
|
10
|
+
import { composeFromArgs, sensitiveValues } from "./retrospective-input.mjs";
|
|
11
|
+
import { checkFrontMatter, resolveRepositories } from "./retrospective-resolve.mjs";
|
|
12
|
+
function retrospectiveData(entry) {
|
|
13
|
+
const data = entry?.data;
|
|
14
|
+
return data?.type === 'retrospective' ? data : undefined;
|
|
15
|
+
}
|
|
16
|
+
/** Whether the session already holds a retrospective, and which (sourceEventId, issues, prs). */
|
|
17
|
+
export const retrospectiveCheck = async (args, context) => {
|
|
18
|
+
const sourceEventId = optionalString(args, 'sourceEventId');
|
|
19
|
+
const entries = await readEntriesIfPresent(context.sessionId, context.env, context.dependencies);
|
|
20
|
+
const found = (entries ?? [])
|
|
21
|
+
.toSorted(byCreatedAt)
|
|
22
|
+
.flatMap((entry) => {
|
|
23
|
+
const data = retrospectiveData(entry);
|
|
24
|
+
return data === undefined ? [] : [data];
|
|
25
|
+
})
|
|
26
|
+
.filter((data) => sourceEventId === undefined || data.sourceEventId === sourceEventId)
|
|
27
|
+
.map((data) => ({
|
|
28
|
+
sourceEventId: data.sourceEventId,
|
|
29
|
+
timestamp: data.timestamp,
|
|
30
|
+
issues: data.issues ?? [],
|
|
31
|
+
prs: data.prs ?? [],
|
|
32
|
+
}));
|
|
33
|
+
return { sessionId: context.sessionId, saved: found.length > 0, entries: found };
|
|
34
|
+
};
|
|
35
|
+
function readMarkdownSource(args) {
|
|
36
|
+
const compose = optionalRecord(args, 'compose');
|
|
37
|
+
if ((args.markdown === undefined) === (compose === undefined))
|
|
38
|
+
throw new Error('pass exactly one of markdown or compose');
|
|
39
|
+
return compose === undefined ? { markdown: requiredString(args, 'markdown') } : { compose };
|
|
40
|
+
}
|
|
41
|
+
/** The default event id, hashed when the session id would push it past the 256-byte limit. */
|
|
42
|
+
export function defaultSourceEventId(sessionId) {
|
|
43
|
+
const id = `retrospective-${sessionId}`;
|
|
44
|
+
return Buffer.byteLength(id) <= 256
|
|
45
|
+
? id
|
|
46
|
+
: `retrospective-${createHash('sha256').update(sessionId).digest('hex')}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Saves the session's retrospective: composes it when `compose` is given, checks the arguments
|
|
50
|
+
* against the document's front matter, runs the checkout's `validateRetrospective` policy (in a
|
|
51
|
+
* child process) and writes nothing if it reports errors, then writes through the same verified
|
|
52
|
+
* path as `journal_append`.
|
|
53
|
+
*/
|
|
54
|
+
export const retrospectiveSave = async (args, context) => {
|
|
55
|
+
assertPolicyAllowed(context);
|
|
56
|
+
const identity = readIdentity(args, context.sessionId);
|
|
57
|
+
const mode = requiredChoice(args, 'mode', MODES);
|
|
58
|
+
const workOutcome = requiredChoice(args, 'workOutcome', OUTCOMES);
|
|
59
|
+
const feedbackCoverage = readCoverage(args);
|
|
60
|
+
const argRepositories = args.repositories === undefined ? undefined : requiredStringArray(args, 'repositories');
|
|
61
|
+
const sourceEventId = optionalString(args, 'sourceEventId') ?? defaultSourceEventId(context.sessionId);
|
|
62
|
+
const category = optionalString(args, 'category');
|
|
63
|
+
if (args.dryRun !== undefined && typeof args.dryRun !== 'boolean')
|
|
64
|
+
throw new Error('dryRun must be a boolean');
|
|
65
|
+
const policy = {
|
|
66
|
+
worktree: context.worktree,
|
|
67
|
+
env: context.env,
|
|
68
|
+
...(context.spawnPolicy === undefined ? {} : { spawn: context.spawnPolicy }),
|
|
69
|
+
};
|
|
70
|
+
const { markdown: given, compose } = readMarkdownSource(args);
|
|
71
|
+
const secrets = sensitiveValues(compose, context.env);
|
|
72
|
+
// Everything the policy sees is derived from the redacted document, never the raw input.
|
|
73
|
+
const redactedGiven = given === undefined ? undefined : redactFeedbackText(given, secrets);
|
|
74
|
+
// Reject a document that can never be stored before any policy export runs for it.
|
|
75
|
+
if (redactedGiven !== undefined && Buffer.byteLength(redactedGiven) > FEEDBACK_MARKDOWN_MAX_BYTES)
|
|
76
|
+
throw new Error(`markdown must be at most ${FEEDBACK_MARKDOWN_MAX_BYTES} bytes`);
|
|
77
|
+
const early = redactedGiven === undefined ? undefined : parseRetrospectiveFrontMatter(redactedGiven);
|
|
78
|
+
const repositories = await resolveRepositories(argRepositories, early?.repositories, policy);
|
|
79
|
+
const markdown = redactedGiven ??
|
|
80
|
+
redactFeedbackText(await composeFromArgs(compose, {
|
|
81
|
+
sessionId: context.sessionId,
|
|
82
|
+
repositories,
|
|
83
|
+
workOutcome,
|
|
84
|
+
feedbackCoverage,
|
|
85
|
+
worktree: context.worktree,
|
|
86
|
+
env: context.env,
|
|
87
|
+
dependencies: context.dependencies,
|
|
88
|
+
}), secrets);
|
|
89
|
+
const front = early ?? parseRetrospectiveFrontMatter(markdown);
|
|
90
|
+
checkFrontMatter(front, {
|
|
91
|
+
sessionId: context.sessionId,
|
|
92
|
+
workOutcome,
|
|
93
|
+
// The document was redacted before parsing, so compare it with equally redacted sources.
|
|
94
|
+
feedbackCoverage: {
|
|
95
|
+
...feedbackCoverage,
|
|
96
|
+
sources: feedbackCoverage.sources.map((source) => redactFeedbackText(source, secrets)),
|
|
97
|
+
},
|
|
98
|
+
repositories: argRepositories,
|
|
99
|
+
});
|
|
100
|
+
// Building the envelope first rejects empty or oversized markdown before any policy runs.
|
|
101
|
+
const envelope = createFeedbackEnvelope({
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
type: 'retrospective',
|
|
104
|
+
sourceEventId,
|
|
105
|
+
// The server owns the timestamp, as in journal_append.
|
|
106
|
+
timestamp: new Date().toISOString(),
|
|
107
|
+
date: front.date,
|
|
108
|
+
issues: front.issues,
|
|
109
|
+
prs: front.prs,
|
|
110
|
+
repositories,
|
|
111
|
+
markdown,
|
|
112
|
+
workOutcome,
|
|
113
|
+
feedbackCoverage,
|
|
114
|
+
...(category === undefined ? {} : { category }),
|
|
115
|
+
}, { knownSensitiveValues: secrets });
|
|
116
|
+
const redactedFront = parseRetrospectiveFrontMatter(envelope.markdown);
|
|
117
|
+
validateFeedbackEnvelope(envelope);
|
|
118
|
+
const report = await runValidator({
|
|
119
|
+
...policy,
|
|
120
|
+
export: 'validateRetrospective',
|
|
121
|
+
input: {
|
|
122
|
+
sessionId: context.sessionId,
|
|
123
|
+
parentSessionId: identity.parentSessionId,
|
|
124
|
+
mode,
|
|
125
|
+
workOutcome,
|
|
126
|
+
// The envelope's copy, which is redacted like the markdown.
|
|
127
|
+
feedbackCoverage: envelope.feedbackCoverage,
|
|
128
|
+
repositories,
|
|
129
|
+
markdown: envelope.markdown,
|
|
130
|
+
sourceEventId,
|
|
131
|
+
metadata: {
|
|
132
|
+
date: redactedFront.date,
|
|
133
|
+
issues: redactedFront.issues,
|
|
134
|
+
prs: redactedFront.prs,
|
|
135
|
+
description: redactedFront.description,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
if ('errors' in report && report.errors.length)
|
|
140
|
+
throw new Error(`policy rejected the retrospective:\n- ${report.errors.join('\n- ')}`);
|
|
141
|
+
const warnings = 'warnings' in report ? report.warnings : [];
|
|
142
|
+
if (args.dryRun === true)
|
|
143
|
+
return { markdown: envelope.markdown, policy: { warnings } };
|
|
144
|
+
const directory = mode === 'interactive' ? outboxDirectory(context.worktree) : undefined;
|
|
145
|
+
const result = await writeFeedback({
|
|
146
|
+
identity,
|
|
147
|
+
envelope,
|
|
148
|
+
mode,
|
|
149
|
+
...(directory === undefined ? {} : { outboxDirectory: directory }),
|
|
150
|
+
env: context.env,
|
|
151
|
+
dependencies: context.dependencies,
|
|
152
|
+
});
|
|
153
|
+
return { ...deliveryResponse(result, context, mode), policy: { warnings } };
|
|
154
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { type ToolCallResult } from './dispatch.mts';
|
|
1
|
+
import { type ServerEnvironment, type ToolCallResult } from './dispatch.mts';
|
|
2
2
|
import { type FakeBlackboard, type FakeOptions } from './fake-blackboard.test-helpers.mts';
|
|
3
3
|
import { type RepoFixture } from './git-fixture.test-helpers.mts';
|
|
4
|
+
import type { GhRunner } from './pr-gh.mts';
|
|
5
|
+
import type { PolicySpawner } from './policy-spawn.mts';
|
|
4
6
|
/** A fresh pair of real repositories for every test. */
|
|
5
7
|
export declare function useRepoFixture(): () => RepoFixture;
|
|
6
8
|
export declare function outboxPath(worktree: string): string;
|
|
@@ -12,6 +14,13 @@ export type Harness = {
|
|
|
12
14
|
export declare function harness(fixture: RepoFixture, options?: FakeOptions & {
|
|
13
15
|
env?: NodeJS.ProcessEnv;
|
|
14
16
|
launchRoot?: string | undefined;
|
|
17
|
+
spawnPolicy?: PolicySpawner;
|
|
18
|
+
runGit?: ServerEnvironment['runGit'];
|
|
19
|
+
runGh?: GhRunner;
|
|
20
|
+
device?: () => {
|
|
21
|
+
user: string;
|
|
22
|
+
host: string;
|
|
23
|
+
};
|
|
15
24
|
}): Harness;
|
|
16
25
|
export declare function textOf(result: ToolCallResult): string;
|
|
17
26
|
export declare function jsonOf(result: ToolCallResult): Record<string, unknown>;
|
|
@@ -22,8 +22,11 @@ export function harness(fixture, options = {}) {
|
|
|
22
22
|
const environment = {
|
|
23
23
|
launchRoot: 'launchRoot' in options ? options.launchRoot : fixture.main,
|
|
24
24
|
env: options.env ?? BLACKBOARD_ENV,
|
|
25
|
-
runGit: runIsolatedGit,
|
|
25
|
+
runGit: options.runGit ?? runIsolatedGit,
|
|
26
26
|
blackboard: fake.dependencies,
|
|
27
|
+
...(options.spawnPolicy === undefined ? {} : { spawnPolicy: options.spawnPolicy }),
|
|
28
|
+
...(options.runGh === undefined ? {} : { runGh: options.runGh }),
|
|
29
|
+
...(options.device === undefined ? {} : { device: options.device }),
|
|
27
30
|
};
|
|
28
31
|
return { fake, call: (name, args) => callTool(name, args, environment) };
|
|
29
32
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type JournalLoader } from '../agent-blackboard/index.mts';
|
|
2
|
+
import type { BlackboardClientDependencies } from '../agent-blackboard/client.mts';
|
|
3
|
+
/**
|
|
4
|
+
* Every entry of the session through the server's own blackboard client, or undefined when the
|
|
5
|
+
* session was never created (the blackboard answers 404). Any other failure is thrown.
|
|
6
|
+
*/
|
|
7
|
+
export declare function readEntriesIfPresent(sessionId: string, env: NodeJS.ProcessEnv, dependencies: BlackboardClientDependencies): Promise<unknown[] | undefined>;
|
|
8
|
+
/** The journal loader `composeRetrospective` takes, reading through the server's own client. */
|
|
9
|
+
export declare function serverJournalLoader(env: NodeJS.ProcessEnv, dependencies: BlackboardClientDependencies): JournalLoader;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readJournal } from "../agent-blackboard/index.mjs";
|
|
2
|
+
function isNotFound(error) {
|
|
3
|
+
return (typeof error === 'object' &&
|
|
4
|
+
error !== null &&
|
|
5
|
+
(('status' in error && error.status === 404) ||
|
|
6
|
+
('statusCode' in error && error.statusCode === 404)));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Every entry of the session through the server's own blackboard client, or undefined when the
|
|
10
|
+
* session was never created (the blackboard answers 404). Any other failure is thrown.
|
|
11
|
+
*/
|
|
12
|
+
export async function readEntriesIfPresent(sessionId, env, dependencies) {
|
|
13
|
+
try {
|
|
14
|
+
return await readJournal(sessionId, env, dependencies);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (isNotFound(error))
|
|
18
|
+
return undefined;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** The journal loader `composeRetrospective` takes, reading through the server's own client. */
|
|
23
|
+
export function serverJournalLoader(env, dependencies) {
|
|
24
|
+
return async (sessionId) => {
|
|
25
|
+
const entries = await readEntriesIfPresent(sessionId, env, dependencies);
|
|
26
|
+
return entries === undefined
|
|
27
|
+
? { status: 'not-found' }
|
|
28
|
+
: {
|
|
29
|
+
status: 'ok',
|
|
30
|
+
entries: entries,
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real path of the policy module the worktree's package.json declares as
|
|
3
|
+
* `"vouchington-tooling": { "policy": "<relative path>" }`, or undefined when it declares none.
|
|
4
|
+
* The path must be relative and, after resolving symlinks, stay inside the worktree.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolvePolicyModule(worktree: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
3
|
+
function packagePolicy(worktree, text) {
|
|
4
|
+
let manifest;
|
|
5
|
+
try {
|
|
6
|
+
manifest = JSON.parse(text);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
throw new Error(`${join(worktree, 'package.json')} is not valid JSON`);
|
|
10
|
+
}
|
|
11
|
+
const section = manifest?.['vouchington-tooling'];
|
|
12
|
+
if (section === undefined)
|
|
13
|
+
return undefined;
|
|
14
|
+
// A malformed section must not read as "no policy declared", which would skip validation.
|
|
15
|
+
if (section === null || typeof section !== 'object' || Array.isArray(section))
|
|
16
|
+
throw new Error('package.json "vouchington-tooling" must be an object');
|
|
17
|
+
const policy = section.policy;
|
|
18
|
+
if (policy === undefined)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (typeof policy !== 'string' || !policy.trim())
|
|
21
|
+
throw new Error('package.json "vouchington-tooling".policy must be a non-empty string path');
|
|
22
|
+
return policy;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The real path of the policy module the worktree's package.json declares as
|
|
26
|
+
* `"vouchington-tooling": { "policy": "<relative path>" }`, or undefined when it declares none.
|
|
27
|
+
* The path must be relative and, after resolving symlinks, stay inside the worktree.
|
|
28
|
+
*/
|
|
29
|
+
export async function resolvePolicyModule(worktree) {
|
|
30
|
+
let text;
|
|
31
|
+
try {
|
|
32
|
+
text = await readFile(join(worktree, 'package.json'), 'utf8');
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
// Only a missing manifest means "no policy"; an unreadable one must not silently disable it.
|
|
36
|
+
if (error.code === 'ENOENT')
|
|
37
|
+
return undefined;
|
|
38
|
+
throw new Error(`${join(worktree, 'package.json')} could not be read: ${error.message}`, {
|
|
39
|
+
cause: error,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const declared = packagePolicy(worktree, text);
|
|
43
|
+
if (declared === undefined)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (isAbsolute(declared))
|
|
46
|
+
throw new Error(`policy path ${declared} must be relative`);
|
|
47
|
+
const root = await realpath(worktree);
|
|
48
|
+
let resolved;
|
|
49
|
+
try {
|
|
50
|
+
resolved = await realpath(join(root, declared));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error(`policy module ${declared} does not exist in the worktree`);
|
|
54
|
+
}
|
|
55
|
+
const inside = relative(root, resolved);
|
|
56
|
+
// relative(), not a prefix test, so a worktree at the filesystem root still contains its files.
|
|
57
|
+
if (!inside || inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside))
|
|
58
|
+
throw new Error(`policy path ${declared} must stay inside the worktree`);
|
|
59
|
+
return resolved;
|
|
60
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type PolicySpawner } from './policy-spawn.mts';
|
|
2
|
+
/** How long each policy export may run before its process is killed, in milliseconds. */
|
|
3
|
+
export declare const POLICY_TIMEOUTS_MS: {
|
|
4
|
+
readonly validateRetrospective: 30000;
|
|
5
|
+
readonly validatePullRequest: 120000;
|
|
6
|
+
readonly pullRequestDefaults: 120000;
|
|
7
|
+
};
|
|
8
|
+
type PolicyExport = keyof typeof POLICY_TIMEOUTS_MS;
|
|
9
|
+
export type PolicyResult = {
|
|
10
|
+
status: 'no-policy';
|
|
11
|
+
} | {
|
|
12
|
+
status: 'skipped';
|
|
13
|
+
} | {
|
|
14
|
+
status: 'ok';
|
|
15
|
+
result: unknown;
|
|
16
|
+
};
|
|
17
|
+
export type PolicyRun = {
|
|
18
|
+
worktree: string;
|
|
19
|
+
export: PolicyExport;
|
|
20
|
+
input: unknown;
|
|
21
|
+
env: NodeJS.ProcessEnv;
|
|
22
|
+
/** Test seam: replaces the real child process. */
|
|
23
|
+
spawn?: PolicySpawner;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Runs one export of the worktree's declared policy module in a fresh child process (never in
|
|
27
|
+
* this one). Resolves `no-policy` when the worktree declares none and `skipped` when the module
|
|
28
|
+
* lacks the export. A timeout, crash, non-zero exit, oversized output, or unusable reply throws:
|
|
29
|
+
* a failed policy is never a pass.
|
|
30
|
+
*/
|
|
31
|
+
export declare function runPolicyExport(run: PolicyRun): Promise<PolicyResult>;
|
|
32
|
+
export type ValidatorReport = {
|
|
33
|
+
errors: string[];
|
|
34
|
+
warnings: string[];
|
|
35
|
+
};
|
|
36
|
+
/** Runs a validator export and checks its result is `{ errors: string[], warnings?: string[] }`. */
|
|
37
|
+
export declare function runValidator(run: PolicyRun): Promise<ValidatorReport | {
|
|
38
|
+
status: 'no-policy' | 'skipped';
|
|
39
|
+
}>;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { resolvePolicyModule } from "./policy-config.mjs";
|
|
3
|
+
import { policyEnvironment, spawnPolicy, } from "./policy-spawn.mjs";
|
|
4
|
+
/** How long each policy export may run before its process is killed, in milliseconds. */
|
|
5
|
+
export const POLICY_TIMEOUTS_MS = {
|
|
6
|
+
validateRetrospective: 30_000,
|
|
7
|
+
validatePullRequest: 120_000,
|
|
8
|
+
pullRequestDefaults: 120_000,
|
|
9
|
+
};
|
|
10
|
+
const POLICY_MAX_OUTPUT_BYTES = 1_000_000;
|
|
11
|
+
const STDERR_EXCERPT_BYTES = 2_000;
|
|
12
|
+
const RUNNER = fileURLToPath(new URL('../../bin/policy-runner.mjs', import.meta.url));
|
|
13
|
+
function failure(name, reason, outcome) {
|
|
14
|
+
const stderr = outcome?.stderr.trim().slice(-STDERR_EXCERPT_BYTES);
|
|
15
|
+
return new Error(`policy ${name} ${reason}${stderr ? `: ${stderr}` : ''}`);
|
|
16
|
+
}
|
|
17
|
+
function parseReply(name, outcome) {
|
|
18
|
+
let reply;
|
|
19
|
+
try {
|
|
20
|
+
reply = JSON.parse(outcome.stdout);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw failure(name, 'produced output that is not valid JSON', outcome);
|
|
24
|
+
}
|
|
25
|
+
if (reply?.ok === false)
|
|
26
|
+
throw failure(name, `failed: ${String(reply.error)}`);
|
|
27
|
+
if (reply?.ok !== true)
|
|
28
|
+
throw failure(name, 'produced an unrecognized reply');
|
|
29
|
+
return reply.skipped === true ? { status: 'skipped' } : { status: 'ok', result: reply.result };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Runs one export of the worktree's declared policy module in a fresh child process (never in
|
|
33
|
+
* this one). Resolves `no-policy` when the worktree declares none and `skipped` when the module
|
|
34
|
+
* lacks the export. A timeout, crash, non-zero exit, oversized output, or unusable reply throws:
|
|
35
|
+
* a failed policy is never a pass.
|
|
36
|
+
*/
|
|
37
|
+
export async function runPolicyExport(run) {
|
|
38
|
+
const secrets = credentialValues(run.env);
|
|
39
|
+
// A policy can echo a credential it was given (GH_TOKEN) in a stack, stderr, or result; the
|
|
40
|
+
// caller of an MCP tool must never receive one. Diagnostics may carry it JSON-escaped too.
|
|
41
|
+
const escaped = secrets.map((secret) => JSON.stringify(secret).slice(1, -1));
|
|
42
|
+
const forms = [...new Set([...escaped, ...secrets])].sort((a, b) => b.length - a.length);
|
|
43
|
+
const spawn = run.spawn ?? spawnPolicy;
|
|
44
|
+
// stderr is scrubbed whole, before its excerpt could cut a credential into an unmatched suffix.
|
|
45
|
+
const scrubbing = async (request) => {
|
|
46
|
+
const outcome = await spawn(request);
|
|
47
|
+
return { ...outcome, stderr: scrubText(outcome.stderr, forms) };
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
const result = await runUnscrubbed(run, scrubbing);
|
|
51
|
+
return result.status === 'ok'
|
|
52
|
+
? { status: 'ok', result: scrubValue(result.result, secrets) }
|
|
53
|
+
: result;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw new Error(scrubText(error.message, forms), { cause: error });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const CREDENTIAL_NAMES = ['AGENT_BLACKBOARD_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'];
|
|
60
|
+
/**
|
|
61
|
+
* Credential values held by the server, longest first. A value too short to redact without
|
|
62
|
+
* mangling every output fails closed: the policy never runs rather than risk echoing it.
|
|
63
|
+
*/
|
|
64
|
+
function credentialValues(env) {
|
|
65
|
+
const values = CREDENTIAL_NAMES.map((name) => env[name]).filter((value) => Boolean(value));
|
|
66
|
+
if (values.some((value) => value.length < 4))
|
|
67
|
+
throw new Error(`a credential (${CREDENTIAL_NAMES.join(', ')}) is too short to redact: policy tools refuse to run`);
|
|
68
|
+
return [...new Set(values)].sort((a, b) => b.length - a.length);
|
|
69
|
+
}
|
|
70
|
+
function scrubText(text, secrets) {
|
|
71
|
+
return secrets.reduce((scrubbed, secret) => scrubbed.replaceAll(secret, '[REDACTED]'), text);
|
|
72
|
+
}
|
|
73
|
+
function scrubValue(value, secrets) {
|
|
74
|
+
if (secrets.length === 0 || value === undefined)
|
|
75
|
+
return value;
|
|
76
|
+
// Each secret is matched in its JSON-escaped form so the round trip stays valid JSON.
|
|
77
|
+
const escaped = secrets.map((secret) => JSON.stringify(secret).slice(1, -1));
|
|
78
|
+
return JSON.parse(scrubText(JSON.stringify(value), escaped));
|
|
79
|
+
}
|
|
80
|
+
async function runUnscrubbed(run, spawn) {
|
|
81
|
+
const { export: name } = run;
|
|
82
|
+
const module = await resolvePolicyModule(run.worktree);
|
|
83
|
+
if (module === undefined)
|
|
84
|
+
return { status: 'no-policy' };
|
|
85
|
+
const timeoutMs = POLICY_TIMEOUTS_MS[name];
|
|
86
|
+
const outcome = await spawn({
|
|
87
|
+
command: process.execPath,
|
|
88
|
+
args: [RUNNER, module],
|
|
89
|
+
cwd: run.worktree,
|
|
90
|
+
env: policyEnvironment(run.env),
|
|
91
|
+
input: JSON.stringify({ policyVersion: 1, export: name, input: run.input }),
|
|
92
|
+
timeoutMs,
|
|
93
|
+
maxOutputBytes: POLICY_MAX_OUTPUT_BYTES,
|
|
94
|
+
});
|
|
95
|
+
if (outcome.timedOut)
|
|
96
|
+
throw failure(name, `timed out after ${timeoutMs / 1000}s`);
|
|
97
|
+
if (outcome.overflowed)
|
|
98
|
+
throw failure(name, `wrote more than ${POLICY_MAX_OUTPUT_BYTES} bytes of output`);
|
|
99
|
+
if (outcome.exitCode !== 0)
|
|
100
|
+
throw failure(name, `exited with code ${outcome.exitCode}`, outcome);
|
|
101
|
+
return parseReply(name, outcome);
|
|
102
|
+
}
|
|
103
|
+
function isStringArray(value) {
|
|
104
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
105
|
+
}
|
|
106
|
+
/** Runs a validator export and checks its result is `{ errors: string[], warnings?: string[] }`. */
|
|
107
|
+
export async function runValidator(run) {
|
|
108
|
+
const outcome = await runPolicyExport(run);
|
|
109
|
+
if (outcome.status !== 'ok')
|
|
110
|
+
return outcome;
|
|
111
|
+
const result = outcome.result;
|
|
112
|
+
if (!isStringArray(result?.errors) ||
|
|
113
|
+
(result.warnings !== undefined && !isStringArray(result.warnings)))
|
|
114
|
+
throw new Error(`policy ${run.export} must return { errors: string[], warnings?: string[] }`);
|
|
115
|
+
return { errors: result.errors, warnings: result.warnings ?? [] };
|
|
116
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type PolicyRequest = {
|
|
2
|
+
command: string;
|
|
3
|
+
args: string[];
|
|
4
|
+
cwd: string;
|
|
5
|
+
env: NodeJS.ProcessEnv;
|
|
6
|
+
input: string;
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
maxOutputBytes: number;
|
|
9
|
+
};
|
|
10
|
+
export type PolicyOutcome = {
|
|
11
|
+
exitCode: number | null;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
timedOut: boolean;
|
|
15
|
+
/** True when stdout or stderr exceeded `maxOutputBytes` and the child was killed. */
|
|
16
|
+
overflowed: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type PolicySpawner = (request: PolicyRequest) => Promise<PolicyOutcome>;
|
|
19
|
+
/**
|
|
20
|
+
* The child's whole environment. Blackboard credentials and NODE_OPTIONS are never passed; GitHub
|
|
21
|
+
* tokens are passed only when the server has them, for the checks that call `gh`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function policyEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
24
|
+
export declare const spawnPolicy: PolicySpawner;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { accessSync, constants } from 'node:fs';
|
|
3
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
4
|
+
function isExecutable(path) {
|
|
5
|
+
try {
|
|
6
|
+
accessSync(path, constants.X_OK);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** The directory of the first `gh` on the server's PATH, so policy checks can call it. */
|
|
14
|
+
function ghDirectory(env) {
|
|
15
|
+
const found = (env.PATH ?? '')
|
|
16
|
+
.split(delimiter)
|
|
17
|
+
.filter((directory) => directory !== '')
|
|
18
|
+
.find((directory) => isExecutable(join(directory, 'gh')));
|
|
19
|
+
return found === undefined ? [] : [found];
|
|
20
|
+
}
|
|
21
|
+
/** The fixed system directories, node's own, and the directory holding `gh`, deduplicated. */
|
|
22
|
+
function policyPath(env) {
|
|
23
|
+
const directories = [
|
|
24
|
+
'/usr/bin',
|
|
25
|
+
'/bin',
|
|
26
|
+
'/usr/local/bin',
|
|
27
|
+
'/opt/homebrew/bin',
|
|
28
|
+
dirname(process.execPath),
|
|
29
|
+
...ghDirectory(env),
|
|
30
|
+
];
|
|
31
|
+
return [...new Set(directories)].join(delimiter);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The child's whole environment. Blackboard credentials and NODE_OPTIONS are never passed; GitHub
|
|
35
|
+
* tokens are passed only when the server has them, for the checks that call `gh`.
|
|
36
|
+
*/
|
|
37
|
+
export function policyEnvironment(env) {
|
|
38
|
+
const child = { PATH: policyPath(env) };
|
|
39
|
+
for (const name of ['HOME', 'GH_TOKEN', 'GITHUB_TOKEN']) {
|
|
40
|
+
const value = env[name];
|
|
41
|
+
if (value !== undefined)
|
|
42
|
+
child[name] = value;
|
|
43
|
+
}
|
|
44
|
+
return child;
|
|
45
|
+
}
|
|
46
|
+
export const spawnPolicy = (request) => new Promise((resolve, reject) => {
|
|
47
|
+
const child = spawn(request.command, request.args, {
|
|
48
|
+
cwd: request.cwd,
|
|
49
|
+
env: request.env,
|
|
50
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
51
|
+
// Its own process group, so every grandchild the policy started can be killed with it.
|
|
52
|
+
detached: process.platform !== 'win32',
|
|
53
|
+
});
|
|
54
|
+
const killGroup = () => {
|
|
55
|
+
try {
|
|
56
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
child.kill('SIGKILL');
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const chunks = { stdout: [], stderr: [] };
|
|
63
|
+
const sizes = { stdout: 0, stderr: 0 };
|
|
64
|
+
let timedOut = false;
|
|
65
|
+
let overflowed = false;
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
timedOut = true;
|
|
68
|
+
killGroup();
|
|
69
|
+
}, request.timeoutMs);
|
|
70
|
+
for (const name of ['stdout', 'stderr'])
|
|
71
|
+
child[name].on('data', (chunk) => {
|
|
72
|
+
sizes[name] += chunk.length;
|
|
73
|
+
if (sizes[name] > request.maxOutputBytes) {
|
|
74
|
+
overflowed = true;
|
|
75
|
+
killGroup();
|
|
76
|
+
}
|
|
77
|
+
else
|
|
78
|
+
chunks[name].push(chunk);
|
|
79
|
+
});
|
|
80
|
+
child.on('error', (error) => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
reject(error);
|
|
83
|
+
});
|
|
84
|
+
// Reap anything the policy left running in its group as soon as the runner exits: a descendant
|
|
85
|
+
// would otherwise keep the GitHub token it inherited, and one holding an inherited pipe would
|
|
86
|
+
// delay `close` until the timeout.
|
|
87
|
+
child.on('exit', () => {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
killGroup();
|
|
90
|
+
});
|
|
91
|
+
child.on('close', (exitCode) => {
|
|
92
|
+
const text = (name) => Buffer.concat(chunks[name]).toString('utf8');
|
|
93
|
+
resolve({ exitCode, stdout: text('stdout'), stderr: text('stderr'), timedOut, overflowed });
|
|
94
|
+
});
|
|
95
|
+
child.stdin.on('error', () => { });
|
|
96
|
+
child.stdin.end(request.input);
|
|
97
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Writes the body to a private temp file for the duration of `use`, then removes it. */
|
|
2
|
+
export declare function withBodyFile<T>(body: string, use: (file: string) => Promise<T>): Promise<T>;
|
|
3
|
+
/** The pull request number at the end of the URL `gh pr create` prints. */
|
|
4
|
+
export declare function pullRequestNumber(url: string): number;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** Writes the body to a private temp file for the duration of `use`, then removes it. */
|
|
5
|
+
export async function withBodyFile(body, use) {
|
|
6
|
+
const directory = await mkdtemp(join(tmpdir(), 'vouchington-pr-'));
|
|
7
|
+
try {
|
|
8
|
+
const file = join(directory, 'body.md');
|
|
9
|
+
await writeFile(file, body, { mode: 0o600 });
|
|
10
|
+
return await use(file);
|
|
11
|
+
}
|
|
12
|
+
finally {
|
|
13
|
+
await rm(directory, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The pull request number at the end of the URL `gh pr create` prints. */
|
|
17
|
+
export function pullRequestNumber(url) {
|
|
18
|
+
const match = /\/pull\/(\d+)\s*$/.exec(url);
|
|
19
|
+
if (!match)
|
|
20
|
+
throw new Error(`could not read the pull request number from: ${url}`);
|
|
21
|
+
return Number(match[1]);
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PullRequestInput } from './pr-input.mts';
|
|
2
|
+
export type PullRequestProvenance = {
|
|
3
|
+
sessionId: string;
|
|
4
|
+
/** The operating system user name. */
|
|
5
|
+
user: string;
|
|
6
|
+
/** The host name; lowercased when rendered. */
|
|
7
|
+
host: string;
|
|
8
|
+
/** The checkout label, see {@link worktreeLabel}. */
|
|
9
|
+
worktree: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The pull request body: the Summary, Impact, Related issues and Details sections followed by the
|
|
13
|
+
* provenance lines. Pure; the caller supplies the device and worktree facts.
|
|
14
|
+
*/
|
|
15
|
+
export declare function renderPullRequestBody(input: PullRequestInput, provenance: PullRequestProvenance): string;
|
|
16
|
+
/**
|
|
17
|
+
* The worktree label: the path after `/worktrees/` when present, `main` for the repository's main
|
|
18
|
+
* worktree, else the directory name.
|
|
19
|
+
*/
|
|
20
|
+
export declare function worktreeLabel(worktree: string, mainWorktree: string | undefined): string;
|