@webpresso/agent-kit 3.3.1 → 3.3.3
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/catalog/AGENTS.md.tpl +3 -5
- package/catalog/agent/rules/pre-implementation.md +15 -8
- package/catalog/agent/skills/ultragoal/SKILL.md +5 -18
- package/catalog/agent/skills/verify/SKILL.md +12 -12
- package/catalog/compose/admin-blocks-registry.json +659 -0
- package/dist/esm/blueprint/core/parser.js +2 -4
- package/dist/esm/blueprint/core/validation/criteria.js +2 -1
- package/dist/esm/blueprint/core/validation/task-blocks.js +7 -1
- package/dist/esm/blueprint/lifecycle/review-provenance.js +85 -30
- package/dist/esm/blueprint/markdown/task-heading.d.ts +21 -0
- package/dist/esm/blueprint/markdown/task-heading.js +25 -0
- package/dist/esm/blueprint/trust/command-runner.d.ts +40 -0
- package/dist/esm/blueprint/trust/command-runner.js +265 -41
- package/dist/esm/build/cli-mcp-parity.js +6 -0
- package/dist/esm/build/package-manifest.js +7 -2
- package/dist/esm/ci/native-session-memory-cache.d.ts +2 -1
- package/dist/esm/ci/native-session-memory-cache.js +2 -1
- package/dist/esm/ci/release-progress.d.ts +37 -0
- package/dist/esm/ci/release-progress.js +139 -0
- package/dist/esm/cli/cli.d.ts +1 -1
- package/dist/esm/cli/cli.js +9 -0
- package/dist/esm/cli/commands/audit-core.js +9 -1
- package/dist/esm/cli/commands/audit.js +16 -5
- package/dist/esm/cli/commands/blueprint/mutations.js +2 -3
- package/dist/esm/cli/commands/init/mcp-spec.d.ts +18 -3
- package/dist/esm/cli/commands/init/mcp-spec.js +54 -6
- package/dist/esm/cli/commands/init/scaffold-agents-md.js +5 -7
- package/dist/esm/cli/commands/package-manager.js +8 -2
- package/dist/esm/cli/commands/release-progress.d.ts +53 -0
- package/dist/esm/cli/commands/release-progress.js +155 -0
- package/dist/esm/cli/commands/review.js +74 -11
- package/dist/esm/cli/utils.d.ts +8 -1
- package/dist/esm/cli/utils.js +12 -2
- package/dist/esm/compose/compose.d.ts +38 -0
- package/dist/esm/compose/compose.js +143 -0
- package/dist/esm/compose/escape-html.d.ts +5 -0
- package/dist/esm/compose/escape-html.js +12 -0
- package/dist/esm/compose/ops-report-render.d.ts +5 -0
- package/dist/esm/compose/ops-report-render.js +71 -0
- package/dist/esm/compose/ops-report-schema.d.ts +42 -0
- package/dist/esm/compose/ops-report-schema.js +46 -0
- package/dist/esm/compose/preview.d.ts +14 -0
- package/dist/esm/compose/preview.js +32 -0
- package/dist/esm/compose/registry-resolve.d.ts +47 -0
- package/dist/esm/compose/registry-resolve.js +133 -0
- package/dist/esm/git/changed-files.d.ts +20 -0
- package/dist/esm/git/changed-files.js +63 -0
- package/dist/esm/hooks/pretool-guard/validators/plan-frontmatter.js +3 -2
- package/dist/esm/hooks/stop/qa-changed-files.js +6 -1
- package/dist/esm/mcp/blueprint/handlers/document-mutations.js +2 -1
- package/dist/esm/mcp/blueprint/handlers/task-advance.js +2 -12
- package/dist/esm/mcp/blueprint/handlers/validate.js +6 -0
- package/dist/esm/mcp/tools/_names.d.ts +1 -1
- package/dist/esm/mcp/tools/_names.js +4 -0
- package/dist/esm/mcp/tools/_registry.js +8 -0
- package/dist/esm/mcp/tools/release-progress.d.ts +9 -0
- package/dist/esm/mcp/tools/release-progress.js +110 -0
- package/dist/esm/mcp/tools/wp-ui-blocks-list.d.ts +3 -0
- package/dist/esm/mcp/tools/wp-ui-blocks-list.js +43 -0
- package/dist/esm/mcp/tools/wp-ui-compose.d.ts +3 -0
- package/dist/esm/mcp/tools/wp-ui-compose.js +62 -0
- package/dist/esm/mcp/tools/wp-ui-preview.d.ts +3 -0
- package/dist/esm/mcp/tools/wp-ui-preview.js +52 -0
- package/dist/esm/package.json +2 -0
- package/dist/esm/review/delivery-verifier.d.ts +33 -2
- package/dist/esm/review/delivery-verifier.js +130 -24
- package/dist/esm/review/execution/review-checkout.d.ts +1 -0
- package/dist/esm/review/execution/review-checkout.js +386 -23
- package/dist/esm/review/execution/sandbox/adapter.d.ts +37 -0
- package/dist/esm/review/execution/sandbox/adapter.js +39 -0
- package/dist/esm/review/execution/sandbox/env.d.ts +12 -0
- package/dist/esm/review/execution/sandbox/env.js +37 -0
- package/dist/esm/review/execution/sandbox/index.d.ts +5 -0
- package/dist/esm/review/execution/sandbox/index.js +4 -0
- package/dist/esm/review/execution/sandbox/policy.d.ts +19 -0
- package/dist/esm/review/execution/sandbox/policy.js +95 -0
- package/dist/esm/review/execution/sandbox/probe.d.ts +20 -0
- package/dist/esm/review/execution/sandbox/probe.js +97 -0
- package/dist/esm/review/execution/sandbox/shell-quote.d.ts +14 -0
- package/dist/esm/review/execution/sandbox/shell-quote.js +18 -0
- package/dist/esm/review/execution/sandbox/tmpdir.d.ts +9 -0
- package/dist/esm/review/execution/sandbox/tmpdir.js +36 -0
- package/dist/esm/review/execution/sandbox/types.d.ts +31 -0
- package/dist/esm/review/execution/sandbox/types.js +6 -0
- package/dist/esm/review/execution/supervisor.js +44 -5
- package/dist/esm/review/execution/types.d.ts +2 -0
- package/package.json +24 -12
|
@@ -139,6 +139,83 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
|
|
|
139
139
|
return false;
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Candidate delivery subjects for the working path.
|
|
144
|
+
*
|
|
145
|
+
* Two sources, in this order:
|
|
146
|
+
*
|
|
147
|
+
* 1. **The live tree at HEAD.** This mirrors what {@link readReviewAuthorityAtRef}
|
|
148
|
+
* already does for delivery on the ref path: it builds the subject from the
|
|
149
|
+
* ref under audit and matches events by digest, never resolving the commit an
|
|
150
|
+
* event recorded. Doing the same here removes the working path's outlier
|
|
151
|
+
* behaviour.
|
|
152
|
+
* 2. **Reconstruction at each event's `reviewedCommit`**, for events the live
|
|
153
|
+
* subject does not already cover.
|
|
154
|
+
*
|
|
155
|
+
* Why the live tree has to come first: `reviewedCommit` is a *locator*, not the
|
|
156
|
+
* binding. The binding is `subjectDigest`, which content-addresses the reviewed
|
|
157
|
+
* tree. A squash merge destroys the branch commit while preserving its tree, so
|
|
158
|
+
* an approval recorded at the branch tip kept a digest that still describes the
|
|
159
|
+
* post-merge tree exactly — yet resolving its now-orphaned SHA threw, the event
|
|
160
|
+
* was marked compromised, and a genuinely valid approval was discarded. The
|
|
161
|
+
* standing workaround was to hand-append a rebind event after every squash.
|
|
162
|
+
*
|
|
163
|
+
* Matching on the digest first is not a loosening: an event is accepted only
|
|
164
|
+
* when the tree it recorded is byte-for-byte the tree we have. Content that
|
|
165
|
+
* differs still fails, and an event whose commit is gone *and* whose digest
|
|
166
|
+
* matches nothing reachable is still marked unresolvable — fail-closed.
|
|
167
|
+
*/
|
|
168
|
+
function collectWorkingDeliverySubjects(input) {
|
|
169
|
+
const { repoRoot, slug, blueprintPath, events, unresolvableEventIds, issues } = input;
|
|
170
|
+
const candidates = new Map();
|
|
171
|
+
// Nothing to resolve against: skip before paying for a whole-tree read. This
|
|
172
|
+
// path also runs for plan-only ledgers, where the old code cost nothing.
|
|
173
|
+
if (!events.some((event) => event.purpose === "delivery"))
|
|
174
|
+
return [];
|
|
175
|
+
let liveDigest = null;
|
|
176
|
+
try {
|
|
177
|
+
const live = createDeliverySubjectAtRef(repoRoot, "HEAD", slug, blueprintPath);
|
|
178
|
+
liveDigest = live.digest;
|
|
179
|
+
candidates.set(live.digest, { scheme: live.scheme, digest: live.digest });
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
// No HEAD (fresh repo) or no blueprint document at HEAD (first commit of a
|
|
183
|
+
// new blueprint). Not fatal: fall through to per-event reconstruction.
|
|
184
|
+
issues?.push(`Working delivery subject unavailable at HEAD for ${slug}: ${error instanceof Error ? error.message : String(error)}`);
|
|
185
|
+
}
|
|
186
|
+
for (const event of events) {
|
|
187
|
+
if (event.purpose !== "delivery")
|
|
188
|
+
continue;
|
|
189
|
+
// Already covered by the live tree — the reviewed content is exactly what
|
|
190
|
+
// we have, so the recorded commit does not need to exist any more.
|
|
191
|
+
if (liveDigest !== null && event.subjectDigest === liveDigest)
|
|
192
|
+
continue;
|
|
193
|
+
// Scope subject-construction failure to THIS event. A rebase rewrites
|
|
194
|
+
// commit SHAs, so a previously-recorded `reviewedCommit` can become
|
|
195
|
+
// unresolvable; letting that throw would escape to the outer catch and
|
|
196
|
+
// discard every approval, including valid ones for commits that still
|
|
197
|
+
// resolve. Mirror the ref-path's markCompromisedArtifactEvents: record the
|
|
198
|
+
// issue, mark the event compromised, and drop only it.
|
|
199
|
+
let subject;
|
|
200
|
+
try {
|
|
201
|
+
subject = createDeliverySubjectAtRef(repoRoot, event.reviewedCommit, slug, blueprintPath);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
issues?.push(`Working delivery review subject unavailable for ${event.id} at ${event.reviewedCommit}: ${error instanceof Error ? error.message : String(error)}`);
|
|
205
|
+
// Compromised (not merely skipped) so the event can never grant approval
|
|
206
|
+
// while still participating in reviewer supersession — see
|
|
207
|
+
// evaluateReviewEvents.
|
|
208
|
+
unresolvableEventIds.add(event.id);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (subject.digest !== event.subjectDigest) {
|
|
212
|
+
issues?.push(`Working delivery review subject mismatch for ${event.id}: expected ${subject.digest}, found ${event.subjectDigest}.`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
candidates.set(subject.digest, { scheme: subject.scheme, digest: subject.digest });
|
|
216
|
+
}
|
|
217
|
+
return [...candidates.values()];
|
|
218
|
+
}
|
|
142
219
|
// Working-ledger acceptance: pre-commit validates a blueprint whose new review
|
|
143
220
|
// events are staged alongside it and therefore not yet at any git ref. An
|
|
144
221
|
// event still counts only when its subjectDigest matches the digest of the
|
|
@@ -167,36 +244,14 @@ function collectWorkingV2ReviewProvenance(file, purpose, issues) {
|
|
|
167
244
|
createLegacyPlanSubjectDigestForContent(slug, readFileSync(file)),
|
|
168
245
|
].map((digest) => [digest, { scheme: "wp-blueprint-v1", digest }])).values(),
|
|
169
246
|
]
|
|
170
|
-
:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// including valid ones for commits that still resolve. Mirror
|
|
179
|
-
// the ref-path's markCompromisedArtifactEvents: record the
|
|
180
|
-
// issue, mark the event compromised, and drop only it.
|
|
181
|
-
let subject;
|
|
182
|
-
try {
|
|
183
|
-
subject = createDeliverySubjectAtRef(repoRoot, event.reviewedCommit, slug, normalizePath(path.relative(repoRoot, file)));
|
|
184
|
-
}
|
|
185
|
-
catch (error) {
|
|
186
|
-
issues?.push(`Working delivery review subject unavailable for ${event.id} at ${event.reviewedCommit}: ${error instanceof Error ? error.message : String(error)}`);
|
|
187
|
-
// Compromised (not merely skipped) so the event can never
|
|
188
|
-
// grant approval while still participating in reviewer
|
|
189
|
-
// supersession — see evaluateReviewEvents.
|
|
190
|
-
unresolvableEventIds.add(event.id);
|
|
191
|
-
return [];
|
|
192
|
-
}
|
|
193
|
-
if (subject.digest !== event.subjectDigest) {
|
|
194
|
-
issues?.push(`Working delivery review subject mismatch for ${event.id}: expected ${subject.digest}, found ${event.subjectDigest}.`);
|
|
195
|
-
return [];
|
|
196
|
-
}
|
|
197
|
-
return [[subject.digest, subject]];
|
|
198
|
-
})).values(),
|
|
199
|
-
];
|
|
247
|
+
: collectWorkingDeliverySubjects({
|
|
248
|
+
repoRoot,
|
|
249
|
+
slug,
|
|
250
|
+
blueprintPath: normalizePath(path.relative(repoRoot, file)),
|
|
251
|
+
events: working.events,
|
|
252
|
+
unresolvableEventIds,
|
|
253
|
+
issues,
|
|
254
|
+
});
|
|
200
255
|
return subjects
|
|
201
256
|
.flatMap((subject) => evaluateReviewEvents(working.events, {
|
|
202
257
|
purpose,
|
|
@@ -5,3 +5,24 @@ export declare function buildTaskHeadingRegex(flags?: string): RegExp;
|
|
|
5
5
|
export declare function buildTaskHeaderRegexForId(taskId: string, flags?: string): RegExp;
|
|
6
6
|
export declare function buildTaskSectionBoundaryRegex(flags?: string): RegExp;
|
|
7
7
|
export declare function isTaskHeaderLine(line: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Detect a task heading written at the wrong depth (`###` instead of `####`).
|
|
10
|
+
*
|
|
11
|
+
* Shares {@link TASK_HEADING_PREFIX_PATTERN} and {@link TASK_ID_PATTERN} with
|
|
12
|
+
* the canonical matcher so the "you used three hashes" diagnostic can never
|
|
13
|
+
* drift from the grammar it is diagnosing against — a detector built from a
|
|
14
|
+
* private copy of the pattern silently stops recognizing headings the moment
|
|
15
|
+
* the real grammar gains a prefix or id shape.
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildWrongDepthTaskHeadingRegex(flags?: string): RegExp;
|
|
18
|
+
/**
|
|
19
|
+
* Serialize a task heading. The single writer-side counterpart to
|
|
20
|
+
* {@link buildTaskHeadingRegex}: every emitter goes through here so the
|
|
21
|
+
* written form and the parsed form cannot diverge.
|
|
22
|
+
*
|
|
23
|
+
* Lane is deliberately not a parameter. The reader accepts an optional
|
|
24
|
+
* `[lane]` heading prefix for hand-authored blueprints, but the writer emits
|
|
25
|
+
* lane as a `**Lane:**` body line instead, so round-tripping never has to
|
|
26
|
+
* reconstruct a prefix the writer does not produce.
|
|
27
|
+
*/
|
|
28
|
+
export declare function formatTaskHeading(taskId: string, title: string): string;
|
|
@@ -16,3 +16,28 @@ export function buildTaskSectionBoundaryRegex(flags = "m") {
|
|
|
16
16
|
export function isTaskHeaderLine(line) {
|
|
17
17
|
return buildTaskHeadingRegex("m").test(line);
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Detect a task heading written at the wrong depth (`###` instead of `####`).
|
|
21
|
+
*
|
|
22
|
+
* Shares {@link TASK_HEADING_PREFIX_PATTERN} and {@link TASK_ID_PATTERN} with
|
|
23
|
+
* the canonical matcher so the "you used three hashes" diagnostic can never
|
|
24
|
+
* drift from the grammar it is diagnosing against — a detector built from a
|
|
25
|
+
* private copy of the pattern silently stops recognizing headings the moment
|
|
26
|
+
* the real grammar gains a prefix or id shape.
|
|
27
|
+
*/
|
|
28
|
+
export function buildWrongDepthTaskHeadingRegex(flags = "gm") {
|
|
29
|
+
return new RegExp(`^###\\s+${TASK_HEADING_PREFIX_PATTERN}Task\\s+(${TASK_ID_PATTERN}):`, flags);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Serialize a task heading. The single writer-side counterpart to
|
|
33
|
+
* {@link buildTaskHeadingRegex}: every emitter goes through here so the
|
|
34
|
+
* written form and the parsed form cannot diverge.
|
|
35
|
+
*
|
|
36
|
+
* Lane is deliberately not a parameter. The reader accepts an optional
|
|
37
|
+
* `[lane]` heading prefix for hand-authored blueprints, but the writer emits
|
|
38
|
+
* lane as a `**Lane:**` body line instead, so round-tripping never has to
|
|
39
|
+
* reconstruct a prefix the writer does not produce.
|
|
40
|
+
*/
|
|
41
|
+
export function formatTaskHeading(taskId, title) {
|
|
42
|
+
return `#### Task ${taskId}: ${title}`;
|
|
43
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
export declare const ALLOWED_WP_COMMAND_TIMEOUT_MS = 30000;
|
|
3
3
|
export declare const ALLOWED_WP_COMMAND_STDIO_TAIL_LIMIT = 500;
|
|
4
|
+
export declare const ALLOWED_WP_COMMAND_CAPTURE_LIMIT = 1000000;
|
|
4
5
|
export type AllowedWpCommandOutcome = "passed" | "failed" | "timed-out";
|
|
5
6
|
export interface AllowedWpCommandResult {
|
|
6
7
|
readonly command: string;
|
|
@@ -21,6 +22,45 @@ export interface ExecuteAllowedWpCommandInput {
|
|
|
21
22
|
readonly env?: NodeJS.ProcessEnv;
|
|
22
23
|
readonly spawn?: typeof spawnSync;
|
|
23
24
|
}
|
|
25
|
+
export interface ResolvedWpInvocation {
|
|
26
|
+
readonly command: string;
|
|
27
|
+
readonly args: string[];
|
|
28
|
+
readonly env: NodeJS.ProcessEnv;
|
|
29
|
+
}
|
|
30
|
+
export interface ResolveInvocationOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Replace `process.env` with `extraEnv` as the base environment instead of
|
|
33
|
+
* spreading over it. Used by the sandboxed delivery path so caller secrets
|
|
34
|
+
* never reach the child; the promotion path keeps the default spread.
|
|
35
|
+
*/
|
|
36
|
+
readonly replaceProcessEnv?: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface RunResolvedInvocationContext {
|
|
39
|
+
/** The original human-readable command string, for logging/reporting. */
|
|
40
|
+
readonly command: string;
|
|
41
|
+
readonly executionRoot: string;
|
|
42
|
+
readonly logRoot: string;
|
|
43
|
+
readonly startedAt: number;
|
|
44
|
+
readonly spawn?: typeof spawnSync;
|
|
45
|
+
readonly signal?: AbortSignal;
|
|
46
|
+
}
|
|
24
47
|
export declare function executeAllowedWpCommand(input: ExecuteAllowedWpCommandInput): AllowedWpCommandResult;
|
|
48
|
+
/** Parses an allowed `wp` command string and resolves it to a spawnable invocation. */
|
|
49
|
+
export declare function resolveInvocationFromCommand(executionRoot: string, command: string, env: NodeJS.ProcessEnv | undefined, options?: ResolveInvocationOptions): ResolvedWpInvocation;
|
|
50
|
+
/** Builds the `command-policy-rejected` result (invocation could not be resolved). */
|
|
51
|
+
export declare function rejectedInvocationResult(command: string, logRoot: string, startedAt: number, error: unknown): AllowedWpCommandResult;
|
|
52
|
+
/** Runs a resolved invocation synchronously (promotion trust path). */
|
|
53
|
+
export declare function runResolvedInvocationSync(invocation: ResolvedWpInvocation, context: RunResolvedInvocationContext): AllowedWpCommandResult;
|
|
54
|
+
/**
|
|
55
|
+
* Runs a resolved invocation asynchronously with bounded process-tree
|
|
56
|
+
* termination. The child leads its own process group (`detached`), so a manual
|
|
57
|
+
* timeout, an abort signal, or normal completion all `SIGKILL` the whole group —
|
|
58
|
+
* reaping descendants a hostile gate may have forked. On Linux srt's bwrap
|
|
59
|
+
* `--unshare-pid` already reaps the sandbox PID namespace; this is the macOS
|
|
60
|
+
* backstop (a descendant that calls `setsid()` can still escape the group — a
|
|
61
|
+
* documented residual, contained by the disposable checkout and network deny).
|
|
62
|
+
*/
|
|
63
|
+
export declare function runResolvedInvocationAsync(invocation: ResolvedWpInvocation, context: RunResolvedInvocationContext): Promise<AllowedWpCommandResult>;
|
|
25
64
|
export declare function tailBounded(text: string): string;
|
|
26
65
|
export declare function relativeCommandLogPath(root: string, logPath: string | undefined): string | undefined;
|
|
66
|
+
export declare function resolveAllowedWpInvocation(executionRoot: string, binary: string, args: readonly string[], extraEnv: NodeJS.ProcessEnv | undefined, options?: ResolveInvocationOptions): ResolvedWpInvocation;
|
|
@@ -1,70 +1,294 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
1
|
+
import { spawn as spawnChild, spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, relative } from "node:path";
|
|
4
4
|
import { parseAllowedWpCommand } from "./gates.js";
|
|
5
5
|
import { resolvePackageAssetPreferred } from "#utils/package-assets.js";
|
|
6
6
|
export const ALLOWED_WP_COMMAND_TIMEOUT_MS = 30_000;
|
|
7
7
|
export const ALLOWED_WP_COMMAND_STDIO_TAIL_LIMIT = 500;
|
|
8
|
+
// Per-stream capture ceiling. `spawnSync` enforced this implicitly via
|
|
9
|
+
// `maxBuffer` (default 1 MB); the async runner must replace that bound or a
|
|
10
|
+
// target-controlled gate can stream output fast enough to exhaust reviewer
|
|
11
|
+
// memory before the timeout fires. On breach we stop accumulating and kill the
|
|
12
|
+
// process group, mirroring spawnSync's ENOBUFS termination.
|
|
13
|
+
export const ALLOWED_WP_COMMAND_CAPTURE_LIMIT = 1_000_000;
|
|
8
14
|
export function executeAllowedWpCommand(input) {
|
|
9
|
-
const
|
|
15
|
+
const startedAt = Date.now();
|
|
16
|
+
const context = {
|
|
17
|
+
command: input.command,
|
|
18
|
+
executionRoot: input.executionRoot,
|
|
19
|
+
logRoot: input.logRoot,
|
|
20
|
+
startedAt,
|
|
21
|
+
...(input.spawn ? { spawn: input.spawn } : {}),
|
|
22
|
+
};
|
|
10
23
|
let invocation;
|
|
11
24
|
try {
|
|
12
|
-
|
|
13
|
-
const [binary, ...args] = argv;
|
|
14
|
-
if (binary === undefined)
|
|
15
|
-
throw new Error(`Promotion gate command is empty: ${input.command}`);
|
|
16
|
-
invocation = resolveAllowedWpInvocation(input.executionRoot, binary, args, input.env);
|
|
25
|
+
invocation = resolveInvocationFromCommand(input.executionRoot, input.command, input.env);
|
|
17
26
|
}
|
|
18
27
|
catch (error) {
|
|
19
|
-
|
|
20
|
-
const result = {
|
|
21
|
-
command: input.command,
|
|
22
|
-
outcome: "failed",
|
|
23
|
-
durationMs: Date.now() - started,
|
|
24
|
-
exitCode: null,
|
|
25
|
-
stdout: "",
|
|
26
|
-
stderr: message,
|
|
27
|
-
failureCode: "command-policy-rejected",
|
|
28
|
-
errorMessage: message,
|
|
29
|
-
};
|
|
30
|
-
return {
|
|
31
|
-
...result,
|
|
32
|
-
logPath: writeAllowedWpCommandLog(input.logRoot, input.command, null, result),
|
|
33
|
-
};
|
|
28
|
+
return rejectedInvocationResult(input.command, input.logRoot, startedAt, error);
|
|
34
29
|
}
|
|
35
|
-
|
|
30
|
+
return runResolvedInvocationSync(invocation, context);
|
|
31
|
+
}
|
|
32
|
+
/** Parses an allowed `wp` command string and resolves it to a spawnable invocation. */
|
|
33
|
+
export function resolveInvocationFromCommand(executionRoot, command, env, options) {
|
|
34
|
+
const argv = parseAllowedWpCommand(command);
|
|
35
|
+
const [binary, ...args] = argv;
|
|
36
|
+
if (binary === undefined)
|
|
37
|
+
throw new Error(`Promotion gate command is empty: ${command}`);
|
|
38
|
+
return resolveAllowedWpInvocation(executionRoot, binary, args, env, options);
|
|
39
|
+
}
|
|
40
|
+
/** Builds the `command-policy-rejected` result (invocation could not be resolved). */
|
|
41
|
+
export function rejectedInvocationResult(command, logRoot, startedAt, error) {
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
const result = {
|
|
44
|
+
command,
|
|
45
|
+
outcome: "failed",
|
|
46
|
+
durationMs: Date.now() - startedAt,
|
|
47
|
+
exitCode: null,
|
|
48
|
+
stdout: "",
|
|
49
|
+
stderr: message,
|
|
50
|
+
failureCode: "command-policy-rejected",
|
|
51
|
+
errorMessage: message,
|
|
52
|
+
};
|
|
53
|
+
return { ...result, logPath: writeAllowedWpCommandLog(logRoot, command, null, result) };
|
|
54
|
+
}
|
|
55
|
+
/** Runs a resolved invocation synchronously (promotion trust path). */
|
|
56
|
+
export function runResolvedInvocationSync(invocation, context) {
|
|
57
|
+
const spawn = context.spawn ?? spawnSync;
|
|
36
58
|
const result = spawn(invocation.command, invocation.args, {
|
|
37
|
-
cwd:
|
|
59
|
+
cwd: context.executionRoot,
|
|
38
60
|
encoding: "utf8",
|
|
39
61
|
stdio: ["ignore", "pipe", "pipe"],
|
|
40
62
|
timeout: ALLOWED_WP_COMMAND_TIMEOUT_MS,
|
|
41
63
|
env: invocation.env,
|
|
42
64
|
});
|
|
43
|
-
const durationMs = Date.now() - started;
|
|
44
65
|
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
45
66
|
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
46
67
|
const timedOut = result.error ? errorCode(result.error) === "ETIMEDOUT" : false;
|
|
68
|
+
return shapeInvocationResult(context.command, invocation, {
|
|
69
|
+
status: typeof result.status === "number" ? result.status : null,
|
|
70
|
+
signal: result.signal ?? null,
|
|
71
|
+
error: result.error,
|
|
72
|
+
stdout,
|
|
73
|
+
stderr,
|
|
74
|
+
timedOut,
|
|
75
|
+
durationMs: Date.now() - context.startedAt,
|
|
76
|
+
}, context.logRoot);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Runs a resolved invocation asynchronously with bounded process-tree
|
|
80
|
+
* termination. The child leads its own process group (`detached`), so a manual
|
|
81
|
+
* timeout, an abort signal, or normal completion all `SIGKILL` the whole group —
|
|
82
|
+
* reaping descendants a hostile gate may have forked. On Linux srt's bwrap
|
|
83
|
+
* `--unshare-pid` already reaps the sandbox PID namespace; this is the macOS
|
|
84
|
+
* backstop (a descendant that calls `setsid()` can still escape the group — a
|
|
85
|
+
* documented residual, contained by the disposable checkout and network deny).
|
|
86
|
+
*/
|
|
87
|
+
export function runResolvedInvocationAsync(invocation, context) {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
const child = spawnChild(invocation.command, invocation.args, {
|
|
90
|
+
cwd: context.executionRoot,
|
|
91
|
+
env: invocation.env,
|
|
92
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
93
|
+
detached: true,
|
|
94
|
+
});
|
|
95
|
+
let stdout = "";
|
|
96
|
+
let stderr = "";
|
|
97
|
+
let timedOut = false;
|
|
98
|
+
let captureExceeded = false;
|
|
99
|
+
let settled = false;
|
|
100
|
+
const appendBounded = (current, chunk) => {
|
|
101
|
+
if (current.length >= ALLOWED_WP_COMMAND_CAPTURE_LIMIT)
|
|
102
|
+
return current;
|
|
103
|
+
return (current + chunk.toString("utf8")).slice(0, ALLOWED_WP_COMMAND_CAPTURE_LIMIT);
|
|
104
|
+
};
|
|
105
|
+
const enforceCapture = () => {
|
|
106
|
+
if (!captureExceeded &&
|
|
107
|
+
(stdout.length >= ALLOWED_WP_COMMAND_CAPTURE_LIMIT ||
|
|
108
|
+
stderr.length >= ALLOWED_WP_COMMAND_CAPTURE_LIMIT)) {
|
|
109
|
+
captureExceeded = true;
|
|
110
|
+
killProcessGroup(child.pid);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
child.stdout?.on("data", (chunk) => {
|
|
114
|
+
stdout = appendBounded(stdout, chunk);
|
|
115
|
+
enforceCapture();
|
|
116
|
+
});
|
|
117
|
+
child.stderr?.on("data", (chunk) => {
|
|
118
|
+
stderr = appendBounded(stderr, chunk);
|
|
119
|
+
enforceCapture();
|
|
120
|
+
});
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
timedOut = true;
|
|
123
|
+
killProcessGroup(child.pid);
|
|
124
|
+
}, ALLOWED_WP_COMMAND_TIMEOUT_MS);
|
|
125
|
+
let aborted = false;
|
|
126
|
+
const onAbort = () => {
|
|
127
|
+
aborted = true;
|
|
128
|
+
killProcessGroup(child.pid);
|
|
129
|
+
};
|
|
130
|
+
context.signal?.addEventListener("abort", onAbort, { once: true });
|
|
131
|
+
// Close the abort race: a signal that fired between the caller's pre-check
|
|
132
|
+
// and this listener registration does not replay, so recheck now and kill
|
|
133
|
+
// immediately if it is already aborted.
|
|
134
|
+
if (context.signal?.aborted)
|
|
135
|
+
onAbort();
|
|
136
|
+
const finish = (status, signal, error) => {
|
|
137
|
+
if (settled)
|
|
138
|
+
return;
|
|
139
|
+
settled = true;
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
context.signal?.removeEventListener("abort", onAbort);
|
|
142
|
+
killProcessGroup(child.pid);
|
|
143
|
+
// Confirm the process GROUP is actually dead before resolving, so the
|
|
144
|
+
// caller's sandbox/filesystem cleanup cannot race a descendant a hostile
|
|
145
|
+
// gate forked: SIGKILL is delivered above but reaping is asynchronous. A
|
|
146
|
+
// DETECTABLE survivor still in the group fails the gate (reapUnconfirmed,
|
|
147
|
+
// never silent). A descendant that ESCAPED the group (macOS `setsid()`) is
|
|
148
|
+
// invisible here — the documented, contained residual (see
|
|
149
|
+
// awaitProcessGroupExit).
|
|
150
|
+
void awaitProcessGroupExit(child.pid).then((reaped) => {
|
|
151
|
+
resolve(shapeInvocationResult(context.command, invocation, {
|
|
152
|
+
status,
|
|
153
|
+
signal,
|
|
154
|
+
error: error ? { message: error.message, code: errorCode(error) } : undefined,
|
|
155
|
+
stdout,
|
|
156
|
+
stderr,
|
|
157
|
+
timedOut,
|
|
158
|
+
captureExceeded,
|
|
159
|
+
aborted,
|
|
160
|
+
reapUnconfirmed: !reaped,
|
|
161
|
+
durationMs: Date.now() - context.startedAt,
|
|
162
|
+
}, context.logRoot));
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
let exitCode = null;
|
|
166
|
+
let exitSignal = null;
|
|
167
|
+
// B1: on macOS a setsid() descendant can leave the process group while still
|
|
168
|
+
// holding inherited stdout/stderr write ends. Node fires `exit` when the
|
|
169
|
+
// direct child ends, but `close` only after all stdio ends — so a survivor
|
|
170
|
+
// wedging the pipes would hang forever. On `exit`, start a stdio grace equal
|
|
171
|
+
// to PROCESS_GROUP_REAP_TIMEOUT_MS; if `close` has not settled by then,
|
|
172
|
+
// destroy the streams and finish anyway (reapUnconfirmed already reports
|
|
173
|
+
// detectable survivors).
|
|
174
|
+
let stdioGrace;
|
|
175
|
+
child.on("error", (error) => {
|
|
176
|
+
if (stdioGrace !== undefined)
|
|
177
|
+
clearTimeout(stdioGrace);
|
|
178
|
+
finish(null, null, error);
|
|
179
|
+
});
|
|
180
|
+
child.on("exit", (code, signal) => {
|
|
181
|
+
exitCode = code;
|
|
182
|
+
exitSignal = signal;
|
|
183
|
+
// Reap backgrounded descendants still holding the stdio pipes so `close`
|
|
184
|
+
// can fire promptly (a hostile gate may fork a child that outlives it).
|
|
185
|
+
killProcessGroup(child.pid);
|
|
186
|
+
if (settled)
|
|
187
|
+
return;
|
|
188
|
+
stdioGrace = setTimeout(() => {
|
|
189
|
+
if (settled)
|
|
190
|
+
return;
|
|
191
|
+
child.stdout?.destroy();
|
|
192
|
+
child.stderr?.destroy();
|
|
193
|
+
finish(exitCode, exitSignal);
|
|
194
|
+
}, PROCESS_GROUP_REAP_TIMEOUT_MS);
|
|
195
|
+
});
|
|
196
|
+
child.on("close", () => {
|
|
197
|
+
if (stdioGrace !== undefined)
|
|
198
|
+
clearTimeout(stdioGrace);
|
|
199
|
+
finish(exitCode, exitSignal);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// Poll interval and ceiling for confirming a killed process group has actually
|
|
204
|
+
// exited. Short interval; the normal case (no surviving descendant) resolves on
|
|
205
|
+
// the first probe. The ceiling bounds a wedged/escaped descendant.
|
|
206
|
+
const PROCESS_GROUP_REAP_POLL_MS = 20;
|
|
207
|
+
const PROCESS_GROUP_REAP_TIMEOUT_MS = 2_000;
|
|
208
|
+
function processGroupAlive(pid) {
|
|
209
|
+
try {
|
|
210
|
+
// Signal 0 to the NEGATIVE pid probes the whole group without delivering a
|
|
211
|
+
// signal: no error => a member is alive; ESRCH => the group is empty.
|
|
212
|
+
process.kill(-pid, 0);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
// EPERM means a member exists but is not signalable by us — treat as alive.
|
|
217
|
+
return error instanceof Error && errorCode(error) === "EPERM";
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Confirm a killed process group has actually exited. Returns true when the
|
|
221
|
+
// group is empty; false when a member is still alive after the ceiling — a
|
|
222
|
+
// DETECTABLE survivor that must surface as a failed verification, not be
|
|
223
|
+
// swallowed silently.
|
|
224
|
+
//
|
|
225
|
+
// macOS RESIDUAL: a descendant that calls `setsid()` LEAVES this group and
|
|
226
|
+
// reparents to launchd, so it is invisible here. macOS has no PID
|
|
227
|
+
// namespace/cgroup to track it, and `ps` cannot enumerate a descendant's env
|
|
228
|
+
// either (verified), so such a process cannot be guaranteed-reaped in
|
|
229
|
+
// unprivileged userspace. It is contained by the network deny (no exfiltration)
|
|
230
|
+
// and the disposable checkout (no persistence) and can only spin CPU until it
|
|
231
|
+
// exits. On Linux, srt's bwrap `--unshare-pid` reaps the whole tree, so this
|
|
232
|
+
// ceiling is never reached there.
|
|
233
|
+
async function awaitProcessGroupExit(pid) {
|
|
234
|
+
if (pid === undefined)
|
|
235
|
+
return true;
|
|
236
|
+
const deadline = Date.now() + PROCESS_GROUP_REAP_TIMEOUT_MS;
|
|
237
|
+
while (processGroupAlive(pid)) {
|
|
238
|
+
if (Date.now() >= deadline)
|
|
239
|
+
return false;
|
|
240
|
+
await new Promise((resolve) => {
|
|
241
|
+
setTimeout(resolve, PROCESS_GROUP_REAP_POLL_MS);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
function shapeInvocationResult(command, invocation, parts, logRoot) {
|
|
47
247
|
const output = {
|
|
48
|
-
command
|
|
49
|
-
outcome:
|
|
248
|
+
command,
|
|
249
|
+
outcome: parts.status === 0 &&
|
|
250
|
+
!parts.error &&
|
|
251
|
+
parts.signal === null &&
|
|
252
|
+
!parts.captureExceeded &&
|
|
253
|
+
!parts.aborted &&
|
|
254
|
+
!parts.reapUnconfirmed
|
|
50
255
|
? "passed"
|
|
51
|
-
: timedOut
|
|
256
|
+
: parts.timedOut
|
|
52
257
|
? "timed-out"
|
|
53
258
|
: "failed",
|
|
54
|
-
durationMs,
|
|
55
|
-
exitCode:
|
|
56
|
-
...(
|
|
57
|
-
stdout,
|
|
58
|
-
stderr,
|
|
59
|
-
...(
|
|
60
|
-
|
|
259
|
+
durationMs: parts.durationMs,
|
|
260
|
+
exitCode: parts.status,
|
|
261
|
+
...(parts.signal ? { signal: parts.signal } : {}),
|
|
262
|
+
stdout: parts.stdout,
|
|
263
|
+
stderr: parts.stderr,
|
|
264
|
+
...(parts.aborted
|
|
265
|
+
? { failureCode: "command-cancelled" }
|
|
266
|
+
: parts.timedOut
|
|
267
|
+
? { failureCode: "command-timed-out" }
|
|
268
|
+
: parts.captureExceeded
|
|
269
|
+
? { failureCode: "command-output-exceeded" }
|
|
270
|
+
: parts.reapUnconfirmed
|
|
271
|
+
? { failureCode: "process-reap-unconfirmed" }
|
|
272
|
+
: {}),
|
|
273
|
+
...(parts.error
|
|
274
|
+
? { errorMessage: parts.error.message }
|
|
275
|
+
: parts.captureExceeded
|
|
276
|
+
? { errorMessage: "gate output exceeded the capture limit (possible resource exhaustion)" }
|
|
277
|
+
: {}),
|
|
61
278
|
};
|
|
62
279
|
if (output.outcome === "passed")
|
|
63
280
|
return output;
|
|
64
|
-
return {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
281
|
+
return { ...output, logPath: writeAllowedWpCommandLog(logRoot, command, invocation, output) };
|
|
282
|
+
}
|
|
283
|
+
function killProcessGroup(pid) {
|
|
284
|
+
if (pid === undefined)
|
|
285
|
+
return;
|
|
286
|
+
try {
|
|
287
|
+
process.kill(-pid, "SIGKILL");
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// The group is already gone (normal completion) — nothing to reap.
|
|
291
|
+
}
|
|
68
292
|
}
|
|
69
293
|
export function tailBounded(text) {
|
|
70
294
|
if (text.length <= ALLOWED_WP_COMMAND_STDIO_TAIL_LIMIT)
|
|
@@ -77,8 +301,8 @@ export function relativeCommandLogPath(root, logPath) {
|
|
|
77
301
|
const rel = relative(root, logPath).replace(/\\/gu, "/");
|
|
78
302
|
return rel === "" || rel.startsWith("../") ? logPath : rel;
|
|
79
303
|
}
|
|
80
|
-
function resolveAllowedWpInvocation(executionRoot, binary, args, extraEnv) {
|
|
81
|
-
const baseEnv = { ...process.env, ...extraEnv };
|
|
304
|
+
export function resolveAllowedWpInvocation(executionRoot, binary, args, extraEnv, options) {
|
|
305
|
+
const baseEnv = options?.replaceProcessEnv ? { ...extraEnv } : { ...process.env, ...extraEnv };
|
|
82
306
|
if (binary === "./bin/wp" || binary === "wp") {
|
|
83
307
|
const localLauncher = resolveRepoWpLauncher(executionRoot);
|
|
84
308
|
if (localLauncher !== null) {
|
|
@@ -50,6 +50,12 @@ export const CLI_MCP_PARITY_CLASSIFICATION = {
|
|
|
50
50
|
classification: "follow-up-candidate",
|
|
51
51
|
reason: "local pre-push aggregator of the required-check verbs; CLI-only dev gate with no dedicated MCP tool",
|
|
52
52
|
},
|
|
53
|
+
"release-progress": {
|
|
54
|
+
command: "release-progress",
|
|
55
|
+
classification: "existing-mcp",
|
|
56
|
+
tools: ["wp_release_progress"],
|
|
57
|
+
reason: "Release workflow phase reporter (detect/native-matrix/publish) is available as wp_release_progress MCP and CLI",
|
|
58
|
+
},
|
|
53
59
|
cleanup: {
|
|
54
60
|
command: "cleanup",
|
|
55
61
|
classification: "unsafe-credentialed-destructive",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { parse as parseYaml } from "yaml";
|
|
4
|
-
import { assertBuiltBlueprintMigrationSqlAssets } from "./blueprint-migration-assets.js";
|
|
5
|
-
import { assertBuiltSastRuleAssets } from "./sast-rule-assets.js";
|
|
4
|
+
import { assertBuiltBlueprintMigrationSqlAssets, syncBlueprintMigrationSqlAssets, } from "./blueprint-migration-assets.js";
|
|
5
|
+
import { assertBuiltSastRuleAssets, syncSastRuleAssets } from "./sast-rule-assets.js";
|
|
6
6
|
import { SESSION_MEMORY_NATIVE_TARGETS } from "#session-memory/native-targets.js";
|
|
7
7
|
import { RUNTIME_TARGETS } from "./runtime-targets.js";
|
|
8
8
|
const DEPENDENCY_SECTIONS = [
|
|
@@ -304,6 +304,11 @@ export function preparePackedManifest(rootDir, options = {}) {
|
|
|
304
304
|
// out-of-org consumers cannot resolve.
|
|
305
305
|
const packedManifest = createPackedManifest(manifest, readWorkspaceCatalogs(workspacePath), options);
|
|
306
306
|
if (options.assertBlueprintMigrationAssets ?? true) {
|
|
307
|
+
// Sync authoring sources into dist before assert so pack/prepack paths
|
|
308
|
+
// (version dry-run, npm pack) do not require a full `pnpm run build` first.
|
|
309
|
+
// Assert still fails closed on missing source or content drift.
|
|
310
|
+
syncBlueprintMigrationSqlAssets(rootDir);
|
|
311
|
+
syncSastRuleAssets(rootDir);
|
|
307
312
|
assertBuiltBlueprintMigrationSqlAssets(rootDir);
|
|
308
313
|
assertBuiltSastRuleAssets(rootDir);
|
|
309
314
|
}
|
|
@@ -22,7 +22,8 @@ export declare const NATIVE_SESSION_MEMORY_CACHE_SUBDIR = "session-memory-engine
|
|
|
22
22
|
export declare const NATIVE_SESSION_MEMORY_TRUST_CACHED_NODE_ENV = "WP_NATIVE_SESSION_MEMORY_TRUST_CACHED_NODE";
|
|
23
23
|
/**
|
|
24
24
|
* Relative path fragments used in GHA `hashFiles` for the warm-cache key.
|
|
25
|
-
* Must match the Test job warm step in ci.agent-kit.yml
|
|
25
|
+
* Must match the Test job warm step in ci.agent-kit.yml and the release native
|
|
26
|
+
* matrix `sm-release-node-*` key in release.yml exactly (contract tests).
|
|
26
27
|
* Absolute cache dir still comes from env-paths / CACHE_ROOT override at runtime.
|
|
27
28
|
*/
|
|
28
29
|
export declare const NATIVE_SESSION_MEMORY_CACHE_HASH_GLOBS: readonly ["native/session-memory-engine/Cargo.lock", "native/session-memory-engine/Cargo.toml", "native/session-memory-engine/crates/**/*.rs", "native/session-memory-engine/crates/**/Cargo.toml"];
|
|
@@ -22,7 +22,8 @@ export const NATIVE_SESSION_MEMORY_CACHE_SUBDIR = "session-memory-engine";
|
|
|
22
22
|
export const NATIVE_SESSION_MEMORY_TRUST_CACHED_NODE_ENV = "WP_NATIVE_SESSION_MEMORY_TRUST_CACHED_NODE";
|
|
23
23
|
/**
|
|
24
24
|
* Relative path fragments used in GHA `hashFiles` for the warm-cache key.
|
|
25
|
-
* Must match the Test job warm step in ci.agent-kit.yml
|
|
25
|
+
* Must match the Test job warm step in ci.agent-kit.yml and the release native
|
|
26
|
+
* matrix `sm-release-node-*` key in release.yml exactly (contract tests).
|
|
26
27
|
* Absolute cache dir still comes from env-paths / CACHE_ROOT override at runtime.
|
|
27
28
|
*/
|
|
28
29
|
export const NATIVE_SESSION_MEMORY_CACHE_HASH_GLOBS = [
|