fix(gsd): enforce workflow write gates over MCP
This commit is contained in:
parent
c297559211
commit
20cbc1ed37
7 changed files with 323 additions and 8 deletions
|
|
@ -27,6 +27,26 @@ function cleanup(base: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
function writeWriteGateSnapshot(
|
||||
base: string,
|
||||
snapshot: { verifiedDepthMilestones?: string[]; activeQueuePhase?: boolean; pendingGateId?: string | null },
|
||||
): void {
|
||||
mkdirSync(join(base, ".gsd", "runtime"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(base, ".gsd", "runtime", "write-gate-state.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
verifiedDepthMilestones: snapshot.verifiedDepthMilestones ?? [],
|
||||
activeQueuePhase: snapshot.activeQueuePhase ?? false,
|
||||
pendingGateId: snapshot.pendingGateId ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
function makeMockServer() {
|
||||
const tools: Array<{
|
||||
name: string;
|
||||
|
|
@ -176,6 +196,72 @@ describe("workflow MCP tools", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("blocks workflow mutation tools while a discussion gate is pending", async () => {
|
||||
const base = makeTmpBase();
|
||||
try {
|
||||
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
|
||||
"# S01\n\n- [ ] **T01: Demo** `est:5m`\n",
|
||||
);
|
||||
writeWriteGateSnapshot(base, { pendingGateId: "depth_verification_M001_confirm" });
|
||||
|
||||
const server = makeMockServer();
|
||||
registerWorkflowTools(server as any);
|
||||
const taskTool = server.tools.find((t) => t.name === "gsd_task_complete");
|
||||
assert.ok(taskTool, "task tool should be registered");
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
taskTool!.handler({
|
||||
projectDir: base,
|
||||
taskId: "T01",
|
||||
sliceId: "S01",
|
||||
milestoneId: "M001",
|
||||
oneLiner: "Completed task",
|
||||
narrative: "Did the work",
|
||||
verification: "npm test",
|
||||
}),
|
||||
/Discussion gate .* has not been confirmed/,
|
||||
);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks workflow mutation tools during queue mode", async () => {
|
||||
const base = makeTmpBase();
|
||||
try {
|
||||
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
|
||||
"# S01\n\n- [ ] **T01: Demo** `est:5m`\n",
|
||||
);
|
||||
writeWriteGateSnapshot(base, { activeQueuePhase: true });
|
||||
|
||||
const server = makeMockServer();
|
||||
registerWorkflowTools(server as any);
|
||||
const taskTool = server.tools.find((t) => t.name === "gsd_task_complete");
|
||||
assert.ok(taskTool, "task tool should be registered");
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
taskTool!.handler({
|
||||
projectDir: base,
|
||||
taskId: "T01",
|
||||
sliceId: "S01",
|
||||
milestoneId: "M001",
|
||||
oneLiner: "Completed task",
|
||||
narrative: "Did the work",
|
||||
verification: "npm test",
|
||||
}),
|
||||
/planning tool .* not executes work|Cannot gsd_task_complete|Unknown tools are not permitted during queue mode/,
|
||||
);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
it("gsd_task_complete and gsd_milestone_status work end-to-end", async () => {
|
||||
const base = makeTmpBase();
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -216,8 +216,37 @@ type WorkflowToolExecutors = {
|
|||
) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type WorkflowWriteGateModule = {
|
||||
loadWriteGateSnapshot: (basePath?: string) => {
|
||||
verifiedDepthMilestones: string[];
|
||||
activeQueuePhase: boolean;
|
||||
pendingGateId: string | null;
|
||||
};
|
||||
shouldBlockPendingGateInSnapshot: (
|
||||
snapshot: {
|
||||
verifiedDepthMilestones: string[];
|
||||
activeQueuePhase: boolean;
|
||||
pendingGateId: string | null;
|
||||
},
|
||||
toolName: string,
|
||||
milestoneId: string | null,
|
||||
queuePhaseActive?: boolean,
|
||||
) => { block: boolean; reason?: string };
|
||||
shouldBlockQueueExecutionInSnapshot: (
|
||||
snapshot: {
|
||||
verifiedDepthMilestones: string[];
|
||||
activeQueuePhase: boolean;
|
||||
pendingGateId: string | null;
|
||||
},
|
||||
toolName: string,
|
||||
input: string,
|
||||
queuePhaseActive?: boolean,
|
||||
) => { block: boolean; reason?: string };
|
||||
};
|
||||
|
||||
let workflowToolExecutorsPromise: Promise<WorkflowToolExecutors> | null = null;
|
||||
let workflowExecutionQueue: Promise<void> = Promise.resolve();
|
||||
let workflowWriteGatePromise: Promise<WorkflowWriteGateModule> | null = null;
|
||||
|
||||
function getAllowedProjectRoot(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
const configuredRoot = env.GSD_WORKFLOW_PROJECT_ROOT?.trim();
|
||||
|
|
@ -285,6 +314,13 @@ function getSupportedSummaryArtifactTypes(executors: WorkflowToolExecutors): rea
|
|||
return executors.SUPPORTED_SUMMARY_ARTIFACT_TYPES;
|
||||
}
|
||||
|
||||
function getWriteGateModuleCandidates(): string[] {
|
||||
return [
|
||||
new URL("../../../src/resources/extensions/gsd/bootstrap/write-gate.js", import.meta.url).href,
|
||||
new URL("../../../src/resources/extensions/gsd/bootstrap/write-gate.ts", import.meta.url).href,
|
||||
];
|
||||
}
|
||||
|
||||
function toFileUrl(modulePath: string): string {
|
||||
return pathToFileURL(resolve(modulePath)).href;
|
||||
}
|
||||
|
|
@ -334,6 +370,36 @@ async function getWorkflowToolExecutors(): Promise<WorkflowToolExecutors> {
|
|||
return workflowToolExecutorsPromise;
|
||||
}
|
||||
|
||||
async function getWorkflowWriteGateModule(): Promise<WorkflowWriteGateModule> {
|
||||
if (!workflowWriteGatePromise) {
|
||||
workflowWriteGatePromise = (async () => {
|
||||
const attempts: string[] = [];
|
||||
for (const candidate of getWriteGateModuleCandidates()) {
|
||||
try {
|
||||
const loaded = await import(candidate);
|
||||
if (
|
||||
loaded &&
|
||||
typeof loaded.loadWriteGateSnapshot === "function" &&
|
||||
typeof loaded.shouldBlockPendingGateInSnapshot === "function" &&
|
||||
typeof loaded.shouldBlockQueueExecutionInSnapshot === "function"
|
||||
) {
|
||||
return loaded as WorkflowWriteGateModule;
|
||||
}
|
||||
attempts.push(`${candidate} (module shape mismatch)`);
|
||||
} catch (err) {
|
||||
attempts.push(`${candidate} (${err instanceof Error ? err.message : String(err)})`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unable to load GSD write-gate bridge for workflow MCP tools. " +
|
||||
`Attempts: ${attempts.join("; ")}`,
|
||||
);
|
||||
})();
|
||||
}
|
||||
return workflowWriteGatePromise;
|
||||
}
|
||||
|
||||
interface McpToolServer {
|
||||
tool(
|
||||
name: string,
|
||||
|
|
@ -360,10 +426,39 @@ async function runSerializedWorkflowOperation<T>(fn: () => Promise<T>): Promise<
|
|||
}
|
||||
}
|
||||
|
||||
async function enforceWorkflowWriteGate(
|
||||
toolName: string,
|
||||
projectDir: string,
|
||||
milestoneId: string | null = null,
|
||||
): Promise<void> {
|
||||
const writeGate = await getWorkflowWriteGateModule();
|
||||
const snapshot = writeGate.loadWriteGateSnapshot(projectDir);
|
||||
const pendingGate = writeGate.shouldBlockPendingGateInSnapshot(
|
||||
snapshot,
|
||||
toolName,
|
||||
milestoneId,
|
||||
snapshot.activeQueuePhase,
|
||||
);
|
||||
if (pendingGate.block) {
|
||||
throw new Error(pendingGate.reason ?? "workflow tool blocked by pending discussion gate");
|
||||
}
|
||||
|
||||
const queueGuard = writeGate.shouldBlockQueueExecutionInSnapshot(
|
||||
snapshot,
|
||||
toolName,
|
||||
"",
|
||||
snapshot.activeQueuePhase,
|
||||
);
|
||||
if (queueGuard.block) {
|
||||
throw new Error(queueGuard.reason ?? "workflow tool blocked during queue mode");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTaskComplete(
|
||||
projectDir: string,
|
||||
args: Omit<z.infer<typeof taskCompleteSchema>, "projectDir">,
|
||||
): Promise<unknown> {
|
||||
await enforceWorkflowWriteGate("gsd_task_complete", projectDir, args.milestoneId);
|
||||
const {
|
||||
taskId,
|
||||
sliceId,
|
||||
|
|
@ -404,6 +499,7 @@ async function handleSliceComplete(
|
|||
projectDir: string,
|
||||
args: z.infer<typeof sliceCompleteSchema>,
|
||||
): Promise<unknown> {
|
||||
await enforceWorkflowWriteGate("gsd_slice_complete", projectDir, args.milestoneId);
|
||||
const { executeSliceComplete } = await getWorkflowToolExecutors();
|
||||
const { projectDir: _projectDir, ...params } = args;
|
||||
return runSerializedWorkflowOperation(() => executeSliceComplete(params, projectDir));
|
||||
|
|
@ -413,6 +509,7 @@ async function handleReplanSlice(
|
|||
projectDir: string,
|
||||
args: z.infer<typeof replanSliceSchema>,
|
||||
): Promise<unknown> {
|
||||
await enforceWorkflowWriteGate("gsd_replan_slice", projectDir, args.milestoneId);
|
||||
const { executeReplanSlice } = await getWorkflowToolExecutors();
|
||||
const { projectDir: _projectDir, ...params } = args;
|
||||
return runSerializedWorkflowOperation(() => executeReplanSlice(params, projectDir));
|
||||
|
|
@ -422,6 +519,7 @@ async function handleCompleteMilestone(
|
|||
projectDir: string,
|
||||
args: z.infer<typeof completeMilestoneSchema>,
|
||||
): Promise<unknown> {
|
||||
await enforceWorkflowWriteGate("gsd_complete_milestone", projectDir, args.milestoneId);
|
||||
const { executeCompleteMilestone } = await getWorkflowToolExecutors();
|
||||
const { projectDir: _projectDir, ...params } = args;
|
||||
return runSerializedWorkflowOperation(() => executeCompleteMilestone(params, projectDir));
|
||||
|
|
@ -431,6 +529,7 @@ async function handleValidateMilestone(
|
|||
projectDir: string,
|
||||
args: z.infer<typeof validateMilestoneSchema>,
|
||||
): Promise<unknown> {
|
||||
await enforceWorkflowWriteGate("gsd_validate_milestone", projectDir, args.milestoneId);
|
||||
const { executeValidateMilestone } = await getWorkflowToolExecutors();
|
||||
const { projectDir: _projectDir, ...params } = args;
|
||||
return runSerializedWorkflowOperation(() => executeValidateMilestone(params, projectDir));
|
||||
|
|
@ -440,6 +539,7 @@ async function handleReassessRoadmap(
|
|||
projectDir: string,
|
||||
args: z.infer<typeof reassessRoadmapSchema>,
|
||||
): Promise<unknown> {
|
||||
await enforceWorkflowWriteGate("gsd_reassess_roadmap", projectDir, args.milestoneId);
|
||||
const { executeReassessRoadmap } = await getWorkflowToolExecutors();
|
||||
const { projectDir: _projectDir, ...params } = args;
|
||||
return runSerializedWorkflowOperation(() => executeReassessRoadmap(params, projectDir));
|
||||
|
|
@ -449,6 +549,7 @@ async function handleSaveGateResult(
|
|||
projectDir: string,
|
||||
args: z.infer<typeof saveGateResultSchema>,
|
||||
): Promise<unknown> {
|
||||
await enforceWorkflowWriteGate("gsd_save_gate_result", projectDir, args.milestoneId);
|
||||
const { executeSaveGateResult } = await getWorkflowToolExecutors();
|
||||
const { projectDir: _projectDir, ...params } = args;
|
||||
return runSerializedWorkflowOperation(() => executeSaveGateResult(params, projectDir));
|
||||
|
|
@ -699,6 +800,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
async (args: Record<string, unknown>) => {
|
||||
const parsed = parseWorkflowArgs(planMilestoneSchema, args);
|
||||
const { projectDir, ...params } = parsed;
|
||||
await enforceWorkflowWriteGate("gsd_plan_milestone", projectDir, params.milestoneId);
|
||||
const { executePlanMilestone } = await getWorkflowToolExecutors();
|
||||
return runSerializedWorkflowOperation(() => executePlanMilestone(params, projectDir));
|
||||
},
|
||||
|
|
@ -711,6 +813,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
async (args: Record<string, unknown>) => {
|
||||
const parsed = parseWorkflowArgs(planSliceSchema, args);
|
||||
const { projectDir, ...params } = parsed;
|
||||
await enforceWorkflowWriteGate("gsd_plan_slice", projectDir, params.milestoneId);
|
||||
const { executePlanSlice } = await getWorkflowToolExecutors();
|
||||
return runSerializedWorkflowOperation(() => executePlanSlice(params, projectDir));
|
||||
},
|
||||
|
|
@ -833,6 +936,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
async (args: Record<string, unknown>) => {
|
||||
const parsed = parseWorkflowArgs(summarySaveSchema, args);
|
||||
const { projectDir, milestone_id, slice_id, task_id, artifact_type, content } = parsed;
|
||||
await enforceWorkflowWriteGate("gsd_summary_save", projectDir, milestone_id);
|
||||
const executors = await getWorkflowToolExecutors();
|
||||
const supportedArtifactTypes = getSupportedSummaryArtifactTypes(executors);
|
||||
if (!supportedArtifactTypes.includes(artifact_type)) {
|
||||
|
|
@ -874,6 +978,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
|
|||
milestoneStatusParams,
|
||||
async (args: Record<string, unknown>) => {
|
||||
const { projectDir, milestoneId } = parseWorkflowArgs(milestoneStatusSchema, args);
|
||||
await enforceWorkflowWriteGate("gsd_milestone_status", projectDir, milestoneId);
|
||||
const { executeMilestoneStatus } = await getWorkflowToolExecutors();
|
||||
return runSerializedWorkflowOperation(() => executeMilestoneStatus({ milestoneId }, projectDir));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ describe("stream-adapter — session persistence (#2859)", () => {
|
|||
args: ["packages/mcp-server/dist/cli.js"],
|
||||
env: {
|
||||
GSD_CLI_PATH: "/tmp/gsd",
|
||||
GSD_PERSIST_WRITE_GATE_STATE: "1",
|
||||
GSD_WORKFLOW_PROJECT_ROOT: "/tmp/project",
|
||||
},
|
||||
cwd: "/tmp/project",
|
||||
|
|
@ -230,6 +231,7 @@ describe("stream-adapter — session persistence (#2859)", () => {
|
|||
args: [realpathSync(resolve(repoDir, "packages", "mcp-server", "dist", "cli.js"))],
|
||||
env: {
|
||||
GSD_CLI_PATH: "/tmp/gsd",
|
||||
GSD_PERSIST_WRITE_GATE_STATE: "1",
|
||||
GSD_WORKFLOW_PROJECT_ROOT: resolvedRepoDir,
|
||||
},
|
||||
cwd: resolvedRepoDir,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const MILESTONE_CONTEXT_RE = /M\d+(?:-[a-z0-9]{6})?-CONTEXT\.md$/;
|
||||
const CONTEXT_MILESTONE_RE = /(?:^|[/\\])(M\d+(?:-[a-z0-9]{6})?)-CONTEXT\.md$/i;
|
||||
const DEPTH_VERIFICATION_MILESTONE_RE = /depth_verification[_-](M\d+(?:-[a-z0-9]{6})?)/i;
|
||||
|
|
@ -65,6 +68,69 @@ const GATE_SAFE_TOOLS = new Set([
|
|||
"search_and_read",
|
||||
]);
|
||||
|
||||
export interface WriteGateSnapshot {
|
||||
verifiedDepthMilestones: string[];
|
||||
activeQueuePhase: boolean;
|
||||
pendingGateId: string | null;
|
||||
}
|
||||
|
||||
function shouldPersistWriteGateSnapshot(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return env.GSD_PERSIST_WRITE_GATE_STATE === "1";
|
||||
}
|
||||
|
||||
function writeGateSnapshotPath(basePath: string = process.cwd()): string {
|
||||
return join(basePath, ".gsd", "runtime", "write-gate-state.json");
|
||||
}
|
||||
|
||||
function currentWriteGateSnapshot(): WriteGateSnapshot {
|
||||
return {
|
||||
verifiedDepthMilestones: [...verifiedDepthMilestones].sort(),
|
||||
activeQueuePhase,
|
||||
pendingGateId,
|
||||
};
|
||||
}
|
||||
|
||||
function persistWriteGateSnapshot(basePath: string = process.cwd()): void {
|
||||
if (!shouldPersistWriteGateSnapshot()) return;
|
||||
const path = writeGateSnapshotPath(basePath);
|
||||
mkdirSync(join(basePath, ".gsd", "runtime"), { recursive: true });
|
||||
const tempPath = `${path}.tmp`;
|
||||
writeFileSync(tempPath, JSON.stringify(currentWriteGateSnapshot(), null, 2), "utf-8");
|
||||
renameSync(tempPath, path);
|
||||
}
|
||||
|
||||
function clearPersistedWriteGateSnapshot(basePath: string = process.cwd()): void {
|
||||
if (!shouldPersistWriteGateSnapshot()) return;
|
||||
const path = writeGateSnapshotPath(basePath);
|
||||
try {
|
||||
unlinkSync(path);
|
||||
} catch {
|
||||
// swallow
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWriteGateSnapshot(value: unknown): WriteGateSnapshot {
|
||||
const record = value && typeof value === "object" ? value as Record<string, unknown> : {};
|
||||
const verified = Array.isArray(record.verifiedDepthMilestones)
|
||||
? record.verifiedDepthMilestones.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
return {
|
||||
verifiedDepthMilestones: [...new Set(verified)].sort(),
|
||||
activeQueuePhase: record.activeQueuePhase === true,
|
||||
pendingGateId: typeof record.pendingGateId === "string" ? record.pendingGateId : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadWriteGateSnapshot(basePath: string = process.cwd()): WriteGateSnapshot {
|
||||
const path = writeGateSnapshotPath(basePath);
|
||||
if (!existsSync(path)) return currentWriteGateSnapshot();
|
||||
try {
|
||||
return normalizeWriteGateSnapshot(JSON.parse(readFileSync(path, "utf-8")));
|
||||
} catch {
|
||||
return currentWriteGateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
export function isDepthVerified(): boolean {
|
||||
return verifiedDepthMilestones.size > 0;
|
||||
}
|
||||
|
|
@ -77,28 +143,40 @@ export function isMilestoneDepthVerified(milestoneId: string | null | undefined)
|
|||
return verifiedDepthMilestones.has(milestoneId);
|
||||
}
|
||||
|
||||
export function isMilestoneDepthVerifiedInSnapshot(
|
||||
snapshot: WriteGateSnapshot,
|
||||
milestoneId: string | null | undefined,
|
||||
): boolean {
|
||||
if (!milestoneId) return false;
|
||||
return snapshot.verifiedDepthMilestones.includes(milestoneId);
|
||||
}
|
||||
|
||||
export function isQueuePhaseActive(): boolean {
|
||||
return activeQueuePhase;
|
||||
}
|
||||
|
||||
export function setQueuePhaseActive(active: boolean): void {
|
||||
activeQueuePhase = active;
|
||||
persistWriteGateSnapshot();
|
||||
}
|
||||
|
||||
export function resetWriteGateState(): void {
|
||||
verifiedDepthMilestones.clear();
|
||||
pendingGateId = null;
|
||||
persistWriteGateSnapshot();
|
||||
}
|
||||
|
||||
export function clearDiscussionFlowState(): void {
|
||||
verifiedDepthMilestones.clear();
|
||||
activeQueuePhase = false;
|
||||
pendingGateId = null;
|
||||
clearPersistedWriteGateSnapshot();
|
||||
}
|
||||
|
||||
export function markDepthVerified(milestoneId?: string | null): void {
|
||||
export function markDepthVerified(milestoneId?: string | null, basePath: string = process.cwd()): void {
|
||||
if (!milestoneId) return;
|
||||
verifiedDepthMilestones.add(milestoneId);
|
||||
persistWriteGateSnapshot(basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -130,6 +208,7 @@ function extractContextMilestoneId(inputPath: string): string | null {
|
|||
*/
|
||||
export function setPendingGate(gateId: string): void {
|
||||
pendingGateId = gateId;
|
||||
persistWriteGateSnapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -137,6 +216,7 @@ export function setPendingGate(gateId: string): void {
|
|||
*/
|
||||
export function clearPendingGate(): void {
|
||||
pendingGateId = null;
|
||||
persistWriteGateSnapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -154,11 +234,20 @@ export function getPendingGate(): string | null {
|
|||
* Read-only tools and ask_user_questions itself are always allowed.
|
||||
*/
|
||||
export function shouldBlockPendingGate(
|
||||
toolName: string,
|
||||
milestoneId: string | null,
|
||||
queuePhaseActive?: boolean,
|
||||
): { block: boolean; reason?: string } {
|
||||
return shouldBlockPendingGateInSnapshot(currentWriteGateSnapshot(), toolName, milestoneId, queuePhaseActive);
|
||||
}
|
||||
|
||||
export function shouldBlockPendingGateInSnapshot(
|
||||
snapshot: WriteGateSnapshot,
|
||||
toolName: string,
|
||||
_milestoneId: string | null,
|
||||
_queuePhaseActive?: boolean,
|
||||
): { block: boolean; reason?: string } {
|
||||
if (!pendingGateId) return { block: false };
|
||||
if (!snapshot.pendingGateId) return { block: false };
|
||||
|
||||
if (GATE_SAFE_TOOLS.has(toolName)) return { block: false };
|
||||
|
||||
|
|
@ -168,7 +257,7 @@ export function shouldBlockPendingGate(
|
|||
return {
|
||||
block: true,
|
||||
reason: [
|
||||
`HARD BLOCK: Discussion gate "${pendingGateId}" has not been confirmed by the user.`,
|
||||
`HARD BLOCK: Discussion gate "${snapshot.pendingGateId}" has not been confirmed by the user.`,
|
||||
`You MUST re-call ask_user_questions with the gate question before making any other tool calls.`,
|
||||
`If the previous ask_user_questions call failed, errored, was cancelled, or the user's response`,
|
||||
`did not match a provided option, you MUST re-ask — never rationalize past the block.`,
|
||||
|
|
@ -182,11 +271,20 @@ export function shouldBlockPendingGate(
|
|||
* Read-only bash commands are allowed; mutating commands are blocked.
|
||||
*/
|
||||
export function shouldBlockPendingGateBash(
|
||||
command: string,
|
||||
milestoneId: string | null,
|
||||
queuePhaseActive?: boolean,
|
||||
): { block: boolean; reason?: string } {
|
||||
return shouldBlockPendingGateBashInSnapshot(currentWriteGateSnapshot(), command, milestoneId, queuePhaseActive);
|
||||
}
|
||||
|
||||
export function shouldBlockPendingGateBashInSnapshot(
|
||||
snapshot: WriteGateSnapshot,
|
||||
command: string,
|
||||
_milestoneId: string | null,
|
||||
_queuePhaseActive?: boolean,
|
||||
): { block: boolean; reason?: string } {
|
||||
if (!pendingGateId) return { block: false };
|
||||
if (!snapshot.pendingGateId) return { block: false };
|
||||
|
||||
// Allow read-only bash commands
|
||||
if (BASH_READ_ONLY_RE.test(command)) return { block: false };
|
||||
|
|
@ -194,7 +292,7 @@ export function shouldBlockPendingGateBash(
|
|||
return {
|
||||
block: true,
|
||||
reason: [
|
||||
`HARD BLOCK: Discussion gate "${pendingGateId}" has not been confirmed by the user.`,
|
||||
`HARD BLOCK: Discussion gate "${snapshot.pendingGateId}" has not been confirmed by the user.`,
|
||||
`You MUST re-call ask_user_questions with the gate question before running mutating commands.`,
|
||||
`If the previous ask_user_questions call failed, errored, was cancelled, or the user's response`,
|
||||
`did not match a provided option, you MUST re-ask — never rationalize past the block.`,
|
||||
|
|
@ -275,6 +373,15 @@ export function shouldBlockContextArtifactSave(
|
|||
artifactType: string,
|
||||
milestoneId: string | null,
|
||||
sliceId?: string | null,
|
||||
): { block: boolean; reason?: string } {
|
||||
return shouldBlockContextArtifactSaveInSnapshot(currentWriteGateSnapshot(), artifactType, milestoneId, sliceId);
|
||||
}
|
||||
|
||||
export function shouldBlockContextArtifactSaveInSnapshot(
|
||||
snapshot: WriteGateSnapshot,
|
||||
artifactType: string,
|
||||
milestoneId: string | null,
|
||||
sliceId?: string | null,
|
||||
): { block: boolean; reason?: string } {
|
||||
if (artifactType !== "CONTEXT") return { block: false };
|
||||
if (sliceId) return { block: false };
|
||||
|
|
@ -287,7 +394,7 @@ export function shouldBlockContextArtifactSave(
|
|||
].join(" "),
|
||||
};
|
||||
}
|
||||
if (isMilestoneDepthVerified(milestoneId)) return { block: false };
|
||||
if (isMilestoneDepthVerifiedInSnapshot(snapshot, milestoneId)) return { block: false };
|
||||
|
||||
return {
|
||||
block: true,
|
||||
|
|
@ -317,6 +424,15 @@ export function shouldBlockQueueExecution(
|
|||
toolName: string,
|
||||
input: string,
|
||||
queuePhaseActive: boolean,
|
||||
): { block: boolean; reason?: string } {
|
||||
return shouldBlockQueueExecutionInSnapshot(currentWriteGateSnapshot(), toolName, input, queuePhaseActive);
|
||||
}
|
||||
|
||||
export function shouldBlockQueueExecutionInSnapshot(
|
||||
snapshot: WriteGateSnapshot,
|
||||
toolName: string,
|
||||
input: string,
|
||||
queuePhaseActive: boolean = snapshot.activeQueuePhase,
|
||||
): { block: boolean; reason?: string } {
|
||||
if (!queuePhaseActive) return { block: false };
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ test("detectWorkflowMcpLaunchConfig prefers explicit env override", () => {
|
|||
env: {
|
||||
FOO: "bar",
|
||||
GSD_CLI_PATH: "/tmp/gsd",
|
||||
GSD_PERSIST_WRITE_GATE_STATE: "1",
|
||||
GSD_WORKFLOW_PROJECT_ROOT: "/tmp/project",
|
||||
},
|
||||
});
|
||||
|
|
@ -62,6 +63,7 @@ test("buildWorkflowMcpServers mirrors explicit launch config", () => {
|
|||
command: "node",
|
||||
args: ["dist/cli.js"],
|
||||
env: {
|
||||
GSD_PERSIST_WRITE_GATE_STATE: "1",
|
||||
GSD_WORKFLOW_PROJECT_ROOT: "/tmp/project",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ensureDbOpen } from "../bootstrap/dynamic-tools.js";
|
||||
import { sanitizeCompleteMilestoneParams } from "../bootstrap/sanitize-complete-milestone.js";
|
||||
import { shouldBlockContextArtifactSave } from "../bootstrap/write-gate.js";
|
||||
import { loadWriteGateSnapshot, shouldBlockContextArtifactSaveInSnapshot } from "../bootstrap/write-gate.js";
|
||||
import {
|
||||
getMilestone,
|
||||
getSliceStatusSummary,
|
||||
|
|
@ -65,7 +65,8 @@ export async function executeSummarySave(
|
|||
details: { operation: "save_summary", error: "invalid_artifact_type" },
|
||||
};
|
||||
}
|
||||
const contextGuard = shouldBlockContextArtifactSave(
|
||||
const contextGuard = shouldBlockContextArtifactSaveInSnapshot(
|
||||
loadWriteGateSnapshot(basePath),
|
||||
params.artifact_type,
|
||||
params.milestone_id ?? null,
|
||||
params.slice_id ?? null,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export function detectWorkflowMcpLaunchConfig(
|
|||
const launchEnv = {
|
||||
...(explicitEnv ?? {}),
|
||||
...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}),
|
||||
GSD_PERSIST_WRITE_GATE_STATE: "1",
|
||||
GSD_WORKFLOW_PROJECT_ROOT: resolve(workflowProjectRoot),
|
||||
};
|
||||
return {
|
||||
|
|
@ -105,6 +106,7 @@ export function detectWorkflowMcpLaunchConfig(
|
|||
cwd: projectRoot,
|
||||
env: {
|
||||
...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}),
|
||||
GSD_PERSIST_WRITE_GATE_STATE: "1",
|
||||
GSD_WORKFLOW_PROJECT_ROOT: resolve(projectRoot),
|
||||
},
|
||||
};
|
||||
|
|
@ -117,6 +119,7 @@ export function detectWorkflowMcpLaunchConfig(
|
|||
command: binPath,
|
||||
env: {
|
||||
...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}),
|
||||
GSD_PERSIST_WRITE_GATE_STATE: "1",
|
||||
GSD_WORKFLOW_PROJECT_ROOT: resolve(projectRoot),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue