diff --git a/dashboard/copilot-session.js b/dashboard/copilot-session.js
index 06a93bd..e3bc71f 100644
--- a/dashboard/copilot-session.js
+++ b/dashboard/copilot-session.js
@@ -65,7 +65,8 @@ export async function runCopilotSession(config, { client, ask, output, onUsage =
config.dashboardPort < 1 || config.dashboardPort > 65535)) {
throw new Error("Reviewed validation-gate trust requires the owning dashboard's current port.");
}
- const permissions = createRuntimePermissionPolicy(config.repository, config.protectedPaths, fileAccess, config.dashboardPort);
+ const permissions = createRuntimePermissionPolicy(config.repository, config.protectedPaths, fileAccess,
+ config.dashboardPort, config.sessionId);
const publication = createPublicationPermissions(config.repository, config.protectedPaths);
let publicationGranted = false;
let policyReady = false;
@@ -162,8 +163,11 @@ export async function runCopilotSession(config, { client, ask, output, onUsage =
}
}, null, 2);
}
+ const sourceEditReview = policyReady && config.mode === "fix-and-validate"
+ ? permissions.reviewSourceEdit(request, prompt) : null;
const response = await ask({
- ...prompt, ...(grantScope ? { grantScope } : {}), ...(canAllowAll ? { canAllowAll: true } : {})
+ ...prompt, ...(grantScope ? { grantScope } : {}), ...(canAllowAll ? { canAllowAll: true } : {}),
+ ...(sourceEditReview ? { sourceEditReview } : {})
});
if (budgetReached) {
return { kind: "reject" };
diff --git a/dashboard/dev-post.js b/dashboard/dev-post.js
index f75de0a..b4787cb 100644
--- a/dashboard/dev-post.js
+++ b/dashboard/dev-post.js
@@ -109,6 +109,15 @@ export function createPpeWriteApproval(repository, targetEnvironment, jobId) {
return createOriginWriteApproval(repository, targetEnvironment, jobId, "ppe");
}
+export function inspectPpeWriteConfiguration(repository) {
+ try {
+ return { configured: Boolean(configurationAt(repository, "ppe", true, true)), error: null };
+ } catch (error) {
+ return { configured: false, error: safeRuntimeText(error.message, 1000)
+ ? error.message : "PPE write setup validation failed; review the configured origins and authentication helper." };
+ }
+}
+
export function isOriginWriteApproval(approval) {
return approval?.mode === "dev-origin" || approval?.mode === "ppe-origin";
}
diff --git a/dashboard/public/app.js b/dashboard/public/app.js
index cfe1807..85770cc 100644
--- a/dashboard/public/app.js
+++ b/dashboard/public/app.js
@@ -187,7 +187,7 @@ function updatePostRequestsOption() {
const key = available ? environment : "";
if (postCheckboxEnvironment !== key) {
allowPostRequestsInput.checked = false;
- allowDevWritesInput.checked = false;
+ allowDevWritesInput.checked = key === "ppe" && repositoryReadiness?.ppeWriteConfigured === true;
postCheckboxEnvironment = key;
}
allowDevWritesInput.disabled = !available || !["dev", "ppe"].includes(environment);
@@ -405,11 +405,72 @@ const fileAccessMessage = document.querySelector("#job-file-access-message");
const fileAccessSave = document.querySelector("#job-file-access-save");
let fileAccessSelection = null;
let fileAccessBusy = false;
+const sourceEditReviewPanel = document.querySelector("#source-edit-review");
+const sourceEditReviewSummary = document.querySelector("#source-edit-review-summary");
+const sourceEditReviewOpen = document.querySelector("#source-edit-review-open");
+let sourceEditReviewSelection = null;
+let sourceEditAuthorizationSelection = null;
+
+function prepareSourceEditAuthorization(job) {
+ const review = job.sourceEditReview;
+ jobInputDetails.value = review.authorization;
+ jobInputAction.value = job.status === "running" ? "comment" : "continue";
+ sourceEditAuthorizationSelection = {
+ instanceId: dashboardInstanceId, jobId: job.id, requestId: review.requestId,
+ authorization: review.authorization
+ };
+ sourceEditReviewSelection = null;
+ fileAccessMessage.textContent = "File trust is reviewed. Read the fresh authorization below and send it to continue. No edit has been replayed.";
+ openWorkspaceSection("job-input-panel");
+ jobInputDetails.focus();
+}
+
+async function reviewSourceEditAccess() {
+ const job = activeJob;
+ const review = job?.sourceEditReview;
+ if (!review || job.mode !== "fix-and-validate" || runtimeBusy || fileAccessBusy) { return; }
+ const selection = {
+ instanceId: dashboardInstanceId, jobId: job.id, requestId: review.requestId
+ };
+ if (job.status === "running" &&
+ !window.confirm("Stop only this job's executor to review source-edit access? Pending operations are not approved or replayed. File trust and a fresh attempt require separate explicit confirmation.")) {
+ return;
+ }
+ sourceEditReviewSelection = selection;
+ setRuntimeBusy(true);
+ try {
+ if (job.status === "running") {
+ const body = await fetchJson("/api/job/source-edit-review", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...selection, confirmed: true })
+ });
+ if (dashboardInstanceId !== selection.instanceId || activeJob?.id !== selection.jobId) {
+ throw new Error("The job changed while stopping. Refresh before reviewing access.");
+ }
+ activeJob = body.job;
+ rememberJob(body.job);
+ renderJob(body.job, body.job);
+ } else {
+ renderFileAccess(job, true);
+ }
+ if (sourceEditReviewSelection) { openWorkspaceSection("job-file-access-panel"); }
+ } catch (error) {
+ sourceEditReviewSelection = null;
+ runtimeMessage.textContent = error.message;
+ fileAccessMessage.textContent = error.message;
+ } finally {
+ setRuntimeBusy(false);
+ }
+}
+
+sourceEditReviewOpen.addEventListener("click", () => void reviewSourceEditAccess());
function renderFileAccess(job, isActive) {
fileAccessPanel.hidden = !job || job.runtime !== "copilot";
if (fileAccessPanel.hidden) {
fileAccessSelection = null;
+ sourceEditReviewSelection = null;
+ sourceEditAuthorizationSelection = null;
return;
}
const mode = job.fileAccess?.mode ?? "manual";
@@ -426,6 +487,40 @@ function renderFileAccess(job, isActive) {
fileAccessSummary.textContent = mode === "manual" ? "Individual file approvals are active."
: `${mode === "source-read-write" ? "Read and edit" : "Read"} non-sensitive source/test files for this job and checkout, including resumes. ${job.fileAccess?.allowValidationGates ? "Dashboard-owned validation gates are also trusted; other commands remain manual." : "Commands remain separately reviewed."}`;
const editable = isActive && ["blocked", "failed", "passed"].includes(job.status);
+ const review = isActive && job.mode === "fix-and-validate" && job.status !== "passed"
+ ? job.sourceEditReview : null;
+ sourceEditReviewPanel.hidden = !review;
+ if (sourceEditReviewSelection &&
+ (sourceEditReviewSelection.instanceId !== dashboardInstanceId ||
+ sourceEditReviewSelection.jobId !== job.id ||
+ sourceEditReviewSelection.requestId !== review?.requestId)) {
+ sourceEditReviewSelection = null;
+ }
+ if (sourceEditAuthorizationSelection &&
+ (sourceEditAuthorizationSelection.instanceId !== dashboardInstanceId ||
+ sourceEditAuthorizationSelection.jobId !== job.id ||
+ sourceEditAuthorizationSelection.requestId !== review?.requestId)) {
+ sourceEditAuthorizationSelection = null;
+ }
+ if (review) {
+ sourceEditReviewSummary.textContent =
+ `${review.fileName}: ${review.reviewCharacters.toLocaleString()} review characters exceed inline approval's 8,192-character limit. ` +
+ "Nothing was approved. Review job-wide source/test access, then explicitly authorize a fresh attempt; creating another job or reinstalling does not remove this limit.";
+ sourceEditReviewOpen.hidden = job.runtimeRequests?.some((item) =>
+ item.id === review.requestId && item.sourceEditReview) ?? false;
+ sourceEditReviewOpen.disabled = runtimeBusy || fileAccessBusy || job.executorStopping;
+ if (sourceEditReviewSelection && sourceEditReviewSelection.instanceId === dashboardInstanceId &&
+ sourceEditReviewSelection.jobId === job.id &&
+ sourceEditReviewSelection.requestId === review.requestId && editable && !fileAccessBusy) {
+ if (mode === "source-read-write") {
+ prepareSourceEditAuthorization(job);
+ } else if (!sourceEditReviewSelection.trustPrepared) {
+ sourceEditReviewSelection = { ...sourceEditReviewSelection, trustPrepared: true };
+ fileAccessMode.value = "source-read-write";
+ fileAccessMessage.textContent = "Review source/test edit access for this job and checkout, not just this file. Update stopped job file trust to confirm; this does not approve or replay the hidden operation.";
+ }
+ }
+ }
fileAccessMode.disabled = fileAccessBusy || !editable;
fileAccessSave.disabled = fileAccessBusy || !editable;
fileAccessGates.disabled = fileAccessBusy || !editable || fileAccessMode.value === "manual";
@@ -769,6 +864,14 @@ function renderRuntimeRequest(job, isActive) {
if (pending.canRecover === true) {
button(runtimeActions, "Deny and request safe recovery", "deny", undefined, undefined, true);
}
+ if (pending.sourceEditReview && job.mode === "fix-and-validate") {
+ const review = document.createElement("button");
+ review.type = "button";
+ review.textContent = "Review source-edit access";
+ review.disabled = runtimeBusy;
+ review.addEventListener("click", () => void reviewSourceEditAccess());
+ runtimeActions.append(review);
+ }
button(runtimeActions, "Deny", "deny");
} else {
pending.choices.forEach((choice, index) => {
@@ -2650,6 +2753,7 @@ form.addEventListener("submit", async (event) => {
}
allowPostRequestsInput.checked = false;
allowDevWritesInput.checked = false;
+ postCheckboxEnvironment = null;
updatePostRequestsOption();
requestInput.focus();
selectedScreenshots = [];
@@ -2863,9 +2967,19 @@ jobInputSubmit.addEventListener("click", async () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action,
- details
+ details,
+ ...(sourceEditAuthorizationSelection && sourceEditAuthorizationSelection.instanceId === dashboardInstanceId &&
+ sourceEditAuthorizationSelection.jobId === activeJob?.id &&
+ details === sourceEditAuthorizationSelection.authorization
+ ? {
+ confirmed: true,
+ instanceId: sourceEditAuthorizationSelection.instanceId,
+ jobId: sourceEditAuthorizationSelection.jobId,
+ sourceEditReviewId: sourceEditAuthorizationSelection.requestId
+ } : {})
})
});
+ sourceEditAuthorizationSelection = null;
jobInputDetails.value = "";
jobInputMessage.textContent =
body.queued
diff --git a/dashboard/public/index.html b/dashboard/public/index.html
index d54ee3e..8115fcb 100644
--- a/dashboard/public/index.html
+++ b/dashboard/public/index.html
@@ -303,7 +303,7 @@
Azure DevOps intake (optional)
Allow POST writes for this job in the selected environment
Native Copilot, DEV/SIT/UAT/PPE only; off by default. Checking this authorizes configured endpoint/test-record writes for this job without repeat confirmation. If unchecked, each write needs confirmation. Saved endpoint/test-record/authentication configuration is still required. Approval expires on stop, hold, completion or restart; configuration changes require a newly reviewed job. Explicit read-only/intercepted modes and production remain blocked. Browser guards are not disabled.
Allow all DEV API writes for this job
- Native Copilot and DEV/PPE only; off by default. Authorizes POST, PUT, PATCH and DELETE to any path on the selected environment's reviewed dataSafety.devWrite or dataSafety.ppeWrite HTTPS origins, affecting any record your account can access. DEV and PPE grants are separate. No endpoint/test-record list or repeat confirmation. Existing SSO helper required. Expires on stop, hold, completion or restart. Other environments, explicit read-only/intercepted modes and browser-guard bypass remain blocked. Separate from Allow all tools.
+ Native Copilot and DEV/PPE only. PPE defaults on only when PPE origins are configured; uncheck to opt out. DEV remains off by default. Authorizes POST, PUT, PATCH and DELETE to any path on the selected environment's reviewed dataSafety.devWrite or dataSafety.ppeWrite HTTPS origins, affecting any record your account can access. DEV and PPE grants are separate. No endpoint/test-record list or repeat confirmation. Existing SSO helper required. Expires on stop, hold, completion or restart. Other environments, explicit read-only/intercepted modes and browser-guard bypass remain blocked. Separate from Allow all tools.
Batch completion
Create a separate PR per bug
@@ -347,6 +347,10 @@ Job
This job's repository file trust
+
+
+
Review source-edit access
+
Reviewed access for this job, including resumes
Individual approvals
diff --git a/dashboard/runtime-permission-policy.js b/dashboard/runtime-permission-policy.js
index 7df74aa..70c2d68 100644
--- a/dashboard/runtime-permission-policy.js
+++ b/dashboard/runtime-permission-policy.js
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { lstatSync, readFileSync, realpathSync } from "node:fs";
+import { homedir } from "node:os";
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { safeRuntimeText } from "./runtime-requests.js";
@@ -55,7 +56,8 @@ function safeProfile(path) {
!sensitive(profile);
}
-export function createRuntimePermissionPolicy(repository, protectedPaths = [], fileAccess = null, dashboardPort = null) {
+export function createRuntimePermissionPolicy(repository, protectedPaths = [], fileAccess = null, dashboardPort = null,
+ sessionId = null, copilotHome = join(homedir(), ".copilot")) {
if (!Array.isArray(protectedPaths) || protectedPaths.some((path) => typeof path !== "string" || !isAbsolute(path))) {
throw new Error("Protected runtime paths must be an array of absolute paths.");
}
@@ -108,6 +110,26 @@ export function createRuntimePermissionPolicy(repository, protectedPaths = [], f
contained(path, realpathSync.native(fileURLToPath(new URL("../", import.meta.url)))) === helperPath &&
lstatSync(path).isFile();
+ function checkpointRead(request, prompt) {
+ if (request.kind !== "read" || !prompt.canApprove || typeof request.path !== "string" ||
+ !isAbsolute(request.path) || !/^[a-f0-9-]{36}$/.test(sessionId ?? "") ||
+ request.managedApprovalRequired || request.requestSandboxBypass || request.requestSandboxPermissive ||
+ request.sandboxPathGrant || request.warning || request.hasWriteFileRedirection) { return null; }
+ try {
+ if (lstatSync(copilotHome).isSymbolicLink() || realpathSync.native(copilotHome) !== copilotHome) { return null; }
+ const directory = join(copilotHome, "session-state", sessionId, "checkpoints");
+ if (dirname(request.path) !== directory || !/^[^\\/]+\.md$/i.test(basename(request.path))) { return null; }
+ const file = contained(request.path, copilotHome);
+ if (!file || protectedPath(file) || (request.resolvedPath && request.resolvedPath !== file)) { return null; }
+ const stat = lstatSync(file);
+ return stat.isFile() && stat.nlink === 1 && stat.size <= 1024 * 1024
+ ? "current session checkpoint Markdown (read only)" : null;
+ } catch (error) {
+ if (["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(error.code)) { return null; }
+ throw error;
+ }
+ }
+
function candidate(request, prompt, automatic = false) {
if (!eligible(request, prompt)) {
return null;
@@ -343,7 +365,7 @@ export function createRuntimePermissionPolicy(repository, protectedPaths = [], f
commands.clear();
},
automaticApproval(request, prompt) {
- return candidate(request, prompt, true)?.key ?? null;
+ return checkpointRead(request, prompt) ?? candidate(request, prompt, true)?.key ?? null;
},
review(request, prompt) {
const match = candidate(request, prompt);
@@ -356,6 +378,20 @@ export function createRuntimePermissionPolicy(repository, protectedPaths = [], f
isSourceFile(path) {
return candidate({ kind: "write", fileName: path }, { canApprove: true })?.scope === "repository-source-write";
},
+ reviewSourceEdit(request, prompt) {
+ if (request.kind !== "write" || prompt.canApprove || prompt.canUseFileTrust !== true ||
+ prompt.canRecover !== true || prompt.blockedReasons?.length !== 1 ||
+ prompt.blockedReasons[0] !== "oversized" ||
+ candidate(request, { ...prompt, canApprove: true })?.scope !== "repository-source-write") {
+ return null;
+ }
+ const fileName = relative(root(), resolve(root(), request.fileName));
+ if (!safeRuntimeText(fileName, 400)) { return null; }
+ return {
+ fileName,
+ reviewCharacters: prompt.reviewCharacters
+ };
+ },
allows(request, prompt) {
const match = candidate(request, prompt);
return Boolean(match && (match.scope === "repository-reads"
diff --git a/dashboard/runtime-requests.js b/dashboard/runtime-requests.js
index fc8c663..b5a8b86 100644
--- a/dashboard/runtime-requests.js
+++ b/dashboard/runtime-requests.js
@@ -32,6 +32,32 @@ export function safeRuntimeText(value, limit = 8192) {
!secret.test(value) && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(value);
}
+export function validateSourceEditReview(value) {
+ if (!value || typeof value.fileName !== "string" || !safeRuntimeText(value.fileName, 400) ||
+ /^[\\/]|[*?:]/.test(value.fileName) ||
+ value.fileName.split(/[\\/]/).some((part) => !part || part === "." || part === "..") ||
+ !Number.isSafeInteger(value.reviewCharacters) || value.reviewCharacters <= 8192 ||
+ value.reviewCharacters > MAX_RUNTIME_FRAME_BYTES) {
+ throw new Error("Invalid oversized source-edit review metadata.");
+ }
+ return { fileName: value.fileName, reviewCharacters: value.reviewCharacters };
+}
+
+export function recordedSourceEditReview(value) {
+ if (value === undefined || value === null) { return null; }
+ if (typeof value.requestId !== "string" || !/^[a-f0-9-]{36}$/.test(value.requestId)) {
+ throw new Error("Invalid recorded source-edit request identity.");
+ }
+ return { requestId: value.requestId, ...validateSourceEditReview(value) };
+}
+
+export function sourceEditAuthorization(value) {
+ const { fileName } = validateSourceEditReview(value);
+ return `I explicitly authorize a fresh attempt at the previously reviewed source edit in ${fileName}. ` +
+ "This supersedes the earlier denial only for that edit under the reviewed source/test file trust for this job and checkout. " +
+ "Do not reopen the old approval, split or conceal the edit, retry other denied operations, or bypass full content, path, managed-policy or sandbox checks.";
+}
+
export function validateRuntimeUsage(value, previous = {}) {
const usage = {};
for (const name of ["inputTokens", "cachedInputTokens", "cacheWriteTokens", "outputTokens"]) {
@@ -110,6 +136,7 @@ export function runtimePermissionPrompt(request) {
: "Deny this operation. Complete human sign-in or resolve the underlying policy/prerequisite before retrying; no restriction can be overridden here."),
canApprove: reviewable,
canUseFileTrust,
+ reviewCharacters: details.length,
...(blockedReasons.length ? { blockedReasons } : {}),
...(canRecover ? { canRecover: true } : {}),
choices: [],
@@ -164,6 +191,11 @@ export function createRuntimeRequestBridge({ send, onChange }) {
(value.canRecover !== undefined &&
(value.canRecover !== true || value.kind !== "permission" ||
value.canApprove || !value.blockedReasons?.length)) ||
+ (value.sourceEditReview !== undefined &&
+ (!value.sourceEditReview || value.kind !== "permission" || value.canApprove || value.canRecover !== true ||
+ value.blockedReasons?.length !== 1 || value.blockedReasons[0] !== "oversized" ||
+ Object.keys(value.sourceEditReview ?? {}).some((key) =>
+ !["fileName", "reviewCharacters"].includes(key)))) ||
(value.canAllowAll !== undefined &&
(typeof value.canAllowAll !== "boolean" ||
(value.canAllowAll && (value.kind !== "permission" || !value.canApprove)))) ||
@@ -183,6 +215,7 @@ export function createRuntimeRequestBridge({ send, onChange }) {
...(value.perAction === true ? { perAction: true } : {}),
...(value.blockedReasons ? { blockedReasons: [...value.blockedReasons] } : {}),
...(value.canRecover === true ? { canRecover: true } : {}),
+ ...(value.sourceEditReview ? { sourceEditReview: validateSourceEditReview(value.sourceEditReview) } : {}),
...(value.canAllowAll === true ? { canAllowAll: true } : {}),
...(value.grantScope ? { grantScope: value.grantScope } : {})
});
diff --git a/dashboard/server.js b/dashboard/server.js
index e9030d4..65d17c1 100644
--- a/dashboard/server.js
+++ b/dashboard/server.js
@@ -39,12 +39,12 @@ import { createSessionRegistry } from "./sessions.js";
import { inspectBrowserProfileOwnership } from "./browser-ownership.js";
import { canonicalExecutionRepository, createExecutorLease } from "./executor-lease.js";
import { spawnWindowsExecutor } from "./windows-executor.js";
-import { createRuntimeRequestBridge, MAX_RUNTIME_FRAME_BYTES, safeRuntimeText, validateRuntimeUsage, validateToolApprovalMode } from "./runtime-requests.js";
+import { createRuntimeRequestBridge, MAX_RUNTIME_FRAME_BYTES, recordedSourceEditReview, safeRuntimeText, sourceEditAuthorization, validateRuntimeUsage, validateToolApprovalMode } from "./runtime-requests.js";
import { validateJobFileAccess } from "./runtime-permission-policy.js";
import { configuredWorkflow, efficientWorkflowGuidance, allowsDraftIntegrationDeferral, allowsUnverifiedDraft, UNVERIFIED_DRAFT_GUIDANCE, OPTIONAL_INTEGRATION_GUIDANCE, AUTONOMOUS_RECOVERY_GUIDANCE, DRAFT_INTEGRATION_GUIDANCE, COPILOT_EXECUTION_GUIDANCE, RUNTIME_RECOVERY_GUIDANCE, READ_ONLY_POST_GUIDANCE, EXISTING_APPLICATION_GUIDANCE, API_ONLY_PLAYWRIGHT_GUIDANCE, UNIT_TEST_ONLY_GUIDANCE, TRANSIENT_PLAYWRIGHT_GUIDANCE, CONCISE_PR_GUIDANCE, PR_LEARNING_GUIDANCE } from "./workflow-policy.js";
import { componentRetrievalGuidance } from "./affected-surface.js";
import { apiAuthenticationConfiguration } from "./api-authentication.js";
-import { createDevWriteApproval, createPpeWriteApproval, createPostWriteApproval, isOriginWriteApproval } from "./dev-post.js";
+import { createDevWriteApproval, createPpeWriteApproval, createPostWriteApproval, inspectPpeWriteConfiguration, isOriginWriteApproval } from "./dev-post.js";
import { browserConfigurationErrors } from "./repository-setup.js";
import { applyRepositorySettings, inspectRepositorySettings, localRepository, planRepositorySettings } from "./repository-settings.js";
import { configuredLinkedApplications, resolveLinkedApplications } from "./linked-applications.js";
@@ -608,6 +608,7 @@ function publicQueue(jobs) {
devWriteApproved: Boolean(job.postWriteApproval?.revision) && job.postWriteApproval.mode === "dev-origin",
ppeWriteApproved: Boolean(job.postWriteApproval?.revision) && job.postWriteApproval.mode === "ppe-origin",
fileAccess: job.fileAccess ?? null,
+ sourceEditReview: recordedSourceEditReview(job.sourceEditReview),
recordPlaywrightVideo: job.recordPlaywrightVideo,
manualLocalhostTest: job.manualLocalhostTest,
manualLocalhostUrl: job.manualLocalhostUrl,
@@ -759,6 +760,7 @@ function dashboardSnapshot(job) {
budgetExceeded: job.budgetExceeded === true,
toolApprovalMode: "ask",
fileAccess: job.fileAccess ?? null,
+ sourceEditReview: recordedSourceEditReview(job.sourceEditReview),
browserPreflightBlocked: job.browserPreflightBlocked === true,
browserSessionNotStarted: job.browserSessionNotStarted === true,
browserBlocker: job.browserBlocker ?? null,
@@ -823,6 +825,7 @@ function restoreDashboardJob(snapshot, repository) {
}
const restored = {
...snapshot,
+ sourceEditReview: recordedSourceEditReview(snapshot.sourceEditReview),
...(profile.browserAutomation?.mode === "api"
? { testEvidence: { ...snapshot.testEvidence, apiOnly: true } }
: {}),
@@ -1134,11 +1137,14 @@ export function inspectRepository(repository) {
error: `live-test profile is incomplete: ${liveTestProfile.detail}`
};
}
+ const ppeWrites = inspectPpeWriteConfiguration(resolvedRepository);
return {
repository: resolvedRepository,
repositoryReady: true,
profileReady: true,
unitTestsOnly: profile.validation?.liveTestsRequired === false,
+ ppeWriteConfigured: ppeWrites.configured,
+ ppeWriteSetupError: ppeWrites.error,
playwrightMode: profile.browserAutomation?.mode ?? "browser",
profilePath,
environments: configuredValidationEnvironments(profile),
@@ -2422,6 +2428,10 @@ function publicJob(job) {
browserBlocker: job.browserBlocker ?? null,
runtimeRequests: job.runtimeRequests ?? [],
fileAccess: job.fileAccess ?? null,
+ sourceEditReview: job.sourceEditReview ? {
+ ...recordedSourceEditReview(job.sourceEditReview),
+ authorization: sourceEditAuthorization(job.sourceEditReview)
+ } : null,
inputTokenLimit: job.inputTokenLimit ?? 0,
toolApprovalMode: job.toolApprovalMode ?? "ask",
postWriteApproved: Boolean(job.postWriteApproval?.revision) && !isOriginWriteApproval(job.postWriteApproval),
@@ -3939,6 +3949,7 @@ export function createDashboardServer({
...dashboardSnapshot(sourceJob),
id: randomUUID(),
fileAccess: null,
+ sourceEditReview: null,
runtimeDirectoryGrants: [],
workflow: configuredWorkflow(loadRepositoryProfile(resolvedRepository)),
status,
@@ -4382,6 +4393,12 @@ export function createDashboardServer({
},
onRuntimeRequests(requests) {
job.runtimeRequests = requests;
+ const review = job.mode === "fix-and-validate"
+ ? requests.find((item) => item.sourceEditReview) : null;
+ if (review && job.sourceEditReview?.requestId !== review.id) {
+ job.sourceEditReview = recordedSourceEditReview({ requestId: review.id, ...review.sourceEditReview });
+ recordJobMetric(job);
+ }
job.lastActivityAt = new Date().toISOString();
armIdleWatchdog();
},
@@ -4680,6 +4697,7 @@ export function createDashboardServer({
instanceId: sessions.id,
repository: resolvedRepository,
controlProtocol: 1,
+ ppeWriteConfigured: runtime === "copilot" && inspectPpeWriteConfiguration(resolvedRepository).configured,
job: job ? {
id: job.id, status: job.status, canResume: job.canResume, canComment: job.canComment,
canReviewPermissions: job.canReviewPermissions,
@@ -5937,6 +5955,19 @@ export function createDashboardServer({
sendJson(response, 400, { error: "validationIntent must be scoped, full, or affected." });
return;
}
+ if (body.sourceEditReviewId !== undefined) {
+ const review = currentJob.sourceEditReview;
+ const expectedOrigin = `http://${DASHBOARD_HOST}:${server.address().port}`;
+ if (request.headers.host !== new URL(expectedOrigin).host || request.headers.origin !== expectedOrigin ||
+ body.confirmed !== true || body.instanceId !== sessions.id || body.jobId !== currentJob.id ||
+ !review || body.sourceEditReviewId !== review.requestId || currentJob.mode !== "fix-and-validate" ||
+ currentJob.fileAccess?.mode !== "source-read-write" ||
+ !["continue", "retry", "comment"].includes(action) ||
+ details !== sourceEditAuthorization(review)) {
+ sendJson(response, 409, { error: "The reviewed source edit or its authorization changed. Refresh and review it again." });
+ return;
+ }
+ }
if (body.reviewedWorkspace && (running || !currentJob.workspaceReview ||
!["continue", "retry"].includes(action))) {
sendJson(response, 409, { error: "Reviewed workspace reuse is available only for a stopped workspace recovery question." });
@@ -5966,6 +5997,9 @@ export function createDashboardServer({
details,
createdAt: new Date().toISOString()
};
+ if (body.sourceEditReviewId !== undefined) {
+ currentJob.sourceEditReview = null;
+ }
currentJob.inputs.push(input);
if (manualResult) {
currentJob.manualLocalhostPending = false;
@@ -6017,7 +6051,7 @@ export function createDashboardServer({
if (
request.method === "POST" &&
- requestUrl.pathname === "/api/job/stop"
+ ["/api/job/stop", "/api/job/source-edit-review"].includes(requestUrl.pathname)
) {
if (
!currentJob ||
@@ -6038,6 +6072,24 @@ export function createDashboardServer({
});
return;
}
+ if (requestUrl.pathname === "/api/job/source-edit-review") {
+ let stopBody;
+ try {
+ stopBody = await readJsonBody(request);
+ } catch (error) {
+ sendJson(response, 400, { error: error.message });
+ return;
+ }
+ const expectedOrigin = `http://${DASHBOARD_HOST}:${server.address().port}`;
+ if (request.headers.host !== new URL(expectedOrigin).host || request.headers.origin !== expectedOrigin ||
+ stopBody.confirmed !== true || stopBody.instanceId !== sessions.id ||
+ stopBody.jobId !== currentJob?.id || currentJob?.mode !== "fix-and-validate" ||
+ !currentJob.sourceEditReview || stopBody.requestId !== currentJob.sourceEditReview.requestId ||
+ !activeHandle?.terminate || currentJob.executorStopping) {
+ sendJson(response, 409, { error: "The oversized source-edit review changed. Refresh before stopping its executor." });
+ return;
+ }
+ }
pushLog(currentJob, {
index: currentJob.nextLogIndex,
timestamp: new Date().toISOString(),
@@ -6603,7 +6655,8 @@ export function createDashboardServer({
}
const expectedOrigin = `http://${DASHBOARD_HOST}:${server.address().port}`;
if (request.headers.host !== new URL(expectedOrigin).host ||
- request.headers.origin !== expectedOrigin) {
+ (request.headers.origin !== expectedOrigin &&
+ !(field === "allowPpeWrites" && request.verifyConversation))) {
sendJson(response, 403, { error: `${label} write approval requires the dashboard's exact loopback host and origin.` });
return;
}
diff --git a/docs/architecture.md b/docs/architecture.md
index 4a271bd..46518c9 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -33,6 +33,22 @@ read-only contract is expanded; browser guards remain intact. See
PPE has an independent `dataSafety.ppeWrite` configuration, per-job confirmation
and `fixlab_ppe_write` tool with the same CRUD methods and grant lifecycle.
DEV grants never authorize PPE or other environments.
+For future native PPE jobs, dashboard intake and identity-bound MCP `job-start`
+default this choice on only after the reviewed PPE origins/helper pass the
+existing configuration checks. Explicit opt-out, read-only/intercepted selections
+and scoped POST choices take precedence. Invalid optional setup is reported in
+readiness without blocking ordinary read-only work. Direct HTTP starts remain
+explicit; existing jobs and grants are unchanged.
+
+Dashboard and MCP starts default routine source/test access and dashboard-owned
+validation gates on, with explicit opt-out; validation-only jobs remain read-only.
+The active SDK session's bounded regular checkpoint Markdown has a read-only
+exception, not general access to Copilot state. Managed and sandbox checks remain.
+Eligible source edits exceeding inline review's 8,192 characters retain only
+safe filename/size/request metadata. Guided review stops the owned executor,
+reviews stopped-job source trust and prepares explicit fresh authorization.
+Old denials stay denied; nothing replays automatically. The complete request
+still undergoes the existing 1 MiB scan and path/content/policy checks.
Job authors may explicitly select **Create an unverified draft PR without
running checks** (`deferValidation`). This changes draft creation readiness only:
diff --git a/docs/commands.md b/docs/commands.md
index 0df2f4c..4de3820 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -503,9 +503,10 @@ Dashboard intake enables **Allow routine source access and checks for this job**
by default. Submitting a fix-and-validate job grants non-sensitive source/test
editing and dashboard-owned validation gates for that job and checkout.
Validation-only modes receive read access, never source/test editing.
-Uncheck the option before submission to retain individual approvals. API callers
-must still explicitly send `routineFileAccess: true`; omitted or false values
-grant no routine access. This default does not enable Allow all tool commands,
+Uncheck the option before submission to retain individual approvals. Connected
+MCP `job-start` uses the same default; `routineFileAccess: false` opts out.
+Direct HTTP callers must still explicitly send `routineFileAccess: true`;
+omitted or false values grant no routine access. This default does not enable Allow all tool commands,
override earlier denials, or change existing jobs.
Separately, a stopped native job's **Repository file trust** editor can review
source/test reads or source/test reads and edits for that exact job and canonical
@@ -529,6 +530,31 @@ serialized review payload is at most 1 MiB and passes the full sensitive-content
scan. Display size never replaces the job/checkout, protected-path, source-file,
managed-approval or sandbox checks. Without reviewed edit trust, oversized writes
still cannot be approved through the dashboard.
+Eligible oversized source edits offer **Review source-edit access**, including
+after safe recovery denies the old request and no live approval remains.
+The guided flow stops only the owned executor with confirmation, opens the
+stopped job's file-trust review, then prepares a separately submitted fresh
+authorization. Reviewing trust alone never resumes or replays an edit.
+Only the relative filename, size and request identity are retained, not the
+hidden diff. Fresh authorization remains instance/job/review-bound; full
+scanning and all existing restrictions still apply. Do not truncate, split or
+repackage the edit to evade the inline limit.
+
+The active Copilot session's direct `checkpoints/*.md` regular files are
+automatically readable without a repeated permission prompt. This bounded,
+read-only exception excludes other sessions, links, directories, configuration,
+writes, protected paths and managed/sandbox exceptions.
+
+For a new native PPE job, **Allow all PPE API writes** defaults on only when
+the repository's reviewed `dataSafety.ppeWrite` origins and authentication helper
+pass configuration checks. Uncheck it to opt out. Connected MCP starts explicitly
+targeting PPE use the same default; `allowPpeWrites: false`, explicit read-only or
+intercepted modes, or a separately selected scoped POST option suppress it.
+Invalid optional write setup is reported in readiness and does not default on.
+Direct HTTP starts still require explicit write fields and confirmation.
+DEV/SIT/UAT, production and existing jobs are unchanged. Each new job receives
+its own revision-bound grant, which is not recovered after stop or restart.
+
Read trust also covers bounded, non-sensitive `stdout.log`/`stderr.log` under
this checkout's `.git/fixlab/command-evidence`, only with a canonical, regular
`job.json` receipt binding the gate to this exact job and repository. New
diff --git a/docs/security.md b/docs/security.md
index 70aad78..be6bfb3 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -165,6 +165,16 @@ PPE uses its own **Allow all PPE API writes** choice, `fixlab_ppe_write` tool an
`environments` and configure reviewed origins such as
`https://api.ppe.example.test`. PPE grants never inherit DEV configuration or
consent. Neither choice enables broad SIT/UAT or production writes.
+For future native PPE jobs only, the dashboard preselects this choice after
+the existing origin/helper configuration checks pass. Users can uncheck it;
+explicit read-only/intercepted selections suppress it. Identity-bound,
+user-authorized MCP `job-start` targeting PPE applies the same default and
+accepts `allowPpeWrites: false` to opt out. A scoped POST selection also takes
+precedence. The server revalidates configuration at grant creation and use;
+readiness is not a grant. Invalid optional PPE setup is reported, not enabled.
+Direct HTTP callers still need the exact browser origin and explicit write
+confirmation fields. These defaults never modify existing jobs or recover
+expired grants. DEV remains off by default.
In `.github/fixlab/repository-profile.json`, declare DEV in `environments`, keep
`productionAllowed: false`, and add this inside `browserAutomation.dataSafety`:
diff --git a/e2e/dashboard-readiness.spec.js b/e2e/dashboard-readiness.spec.js
index 924c479..a5b9bdc 100644
--- a/e2e/dashboard-readiness.spec.js
+++ b/e2e/dashboard-readiness.spec.js
@@ -351,6 +351,44 @@ test(`${environment.toUpperCase()} write checkbox is environment-specific, exclu
});
}
+test("configured PPE writes default on for future jobs, preserve opt-out and never select other environments", async ({ page }) => {
+ const posts = [];
+ await mockChatWorkspace(page, { environments: ["dev", "sit", "uat", "ppe"], ppeWriteConfigured: true });
+ await page.route("**/api/jobs", async (route) => {
+ posts.push(route.request().postDataJSON());
+ await route.fulfill({ contentType: "application/json", status: 202,
+ body: JSON.stringify({ job: { id: "ppe-default", request: "Synthetic PPE validation.",
+ mode: "validate-only", status: "running", stages: {}, bugs: [], logs: [], activity: [] },
+ queue: [], history: [] }) });
+ });
+ await openWorkflowControls(page, baseUrl);
+ const checkbox = page.locator("#allow-dev-writes");
+ await page.locator("#target-environment").selectOption("dev");
+ await expect(checkbox).not.toBeChecked();
+ await page.locator("#target-environment").selectOption("ppe");
+ await expect(checkbox).toBeChecked();
+ await expect(page.locator("#allow-post-requests")).toBeDisabled();
+ await checkbox.uncheck();
+ await page.waitForResponse("**/api/status");
+ await expect(checkbox).not.toBeChecked();
+ await page.locator("#request").fill("Validate PPE without write access.");
+ await page.locator("#start-button").click();
+ await expect.poll(() => posts.length).toBe(1);
+ expect(posts[0].allowPpeWrites).toBe(false);
+ await expect(checkbox).toBeChecked();
+ await page.locator("#request").fill("Validate PPE with reviewed default access.");
+ await page.locator("#start-button").click();
+ await expect.poll(() => posts.length).toBe(2);
+ expect(posts[1].allowPpeWrites).toBe(true);
+ expect(posts[1].ppeWriteApprovalConfirmed).toBe(true);
+ expect(posts[1].allowDevWrites).toBe(false);
+ await page.locator("#job-test-mutation-mode").selectOption("none");
+ await expect(checkbox).toBeDisabled();
+ await expect(checkbox).not.toBeChecked();
+ await page.locator("#target-environment").selectOption("sit");
+ await expect(checkbox).not.toBeChecked();
+});
+
for (const profileReady of [true, false]) {
test(`unverified drafts with live readiness ${profileReady} submit deployed-backend choices and reset without fabricating checks`, async ({ page }) => {
const posts = [];
@@ -840,6 +878,75 @@ test("Copilot file trust requires a stopped exact job and a separate confirmatio
}
});
+test("large source-edit recovery reviews trust and prepares but never sends fresh authorization automatically", async ({ page }) => {
+ const directory = mkdtempSync(join(tmpdir(), "fixlab-large-edit-ui-"));
+ mkdirSync(join(directory, ".github", "fixlab"), { recursive: true });
+ writeFileSync(join(directory, ".github", "fixlab", "repository-profile.json"),
+ readFileSync(join(repository, ".github", "fixlab", "repository-profile.json")));
+ const sent = [];
+ const launches = [];
+ let bridge;
+ let finish;
+ const instance = createDashboardServer({
+ repository: directory, packageRoot, runtime: "copilot", browserOpener() {},
+ executor(input) {
+ launches.push(input);
+ bridge = createRuntimeRequestBridge({ send: (message) => sent.push(message),
+ onChange: input.onRuntimeRequests });
+ return { completion: new Promise((resolve) => { finish = resolve; }),
+ respondToRuntimeRequest: (id, decision) => bridge.respond(id, decision),
+ terminate() { bridge.cancel(); finish({ code: 130 }); }, hasStopped: () => true };
+ }
+ });
+ try {
+ const address = await instance.listen({ port: await availablePort() });
+ await openWorkflowControls(page, address.url);
+ await page.locator('[name="routineFileAccess"]').uncheck();
+ await page.locator("#request").fill("Synthetic React header edit recovery.");
+ await page.locator("#start-button").click();
+ const prompt = runtimePermissionPrompt({ kind: "write", fileName: "src/header.tsx", diff: "+".repeat(9000) });
+ const id = randomUUID();
+ bridge.receive({ ...prompt, id,
+ sourceEditReview: { fileName: "src/header.tsx", reviewCharacters: prompt.reviewCharacters } });
+ const review = page.getByRole("button", { name: "Review source-edit access", exact: true });
+ await expect(review).toBeVisible();
+ page.once("dialog", (dialog) => dialog.dismiss());
+ await review.click();
+ expect(launches).toHaveLength(1);
+ expect(sent).toHaveLength(0);
+ page.once("dialog", (dialog) => dialog.accept());
+ await page.getByRole("button", { name: "Deny and request safe recovery", exact: true }).click();
+ await expect.poll(() => sent.at(-1)?.recover).toBe(true);
+ finish({ code: 1 });
+ await expect(page.locator("#source-edit-review-open")).toBeVisible();
+ await expect(page.locator("#job-file-access-save")).toBeEnabled();
+ await page.locator("#source-edit-review-open").click();
+ await expect(page.locator("#job-file-access-mode")).toHaveValue("source-read-write");
+ await page.locator("#job-file-access-mode").selectOption("manual");
+ await page.waitForResponse("**/api/status");
+ await expect(page.locator("#job-file-access-mode")).toHaveValue("manual");
+ await page.locator("#job-file-access-mode").selectOption("source-read-write");
+ const currentJob = async () => (await (await page.request.get(`${address.url}/api/job`)).json()).job;
+ expect((await currentJob()).fileAccess).toBeNull();
+ page.once("dialog", (dialog) => dialog.dismiss());
+ await page.locator("#job-file-access-save").click();
+ expect((await currentJob()).fileAccess).toBeNull();
+ page.once("dialog", (dialog) => dialog.accept());
+ await page.locator("#job-file-access-save").click();
+ await expect(page.locator("#job-file-access-message")).toContainText("fresh authorization");
+ await expect(page.locator("#job-input-details")).toHaveValue(/explicitly authorize a fresh attempt/);
+ expect(launches).toHaveLength(1);
+ expect((await currentJob()).sourceEditReview.requestId).toBe(id);
+ await page.locator("#job-input-submit").click();
+ await expect.poll(() => launches.length).toBe(2);
+ expect((await currentJob()).sourceEditReview).toBeNull();
+ expect(sent).toEqual([{ type: "response", id, action: "deny", recover: true }]);
+ } finally {
+ await instance.close();
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
+
test("Show budget is unchecked by default and reveals usage and stopped-job controls without changing limits", async ({ page }) => {
const directory = mkdtempSync(join(tmpdir(), "fixlab-budget-ui-"));
mkdirSync(join(directory, ".github", "fixlab"), { recursive: true });
@@ -2200,12 +2307,12 @@ test("dashboard exposes explicit pull-request approval when validation is comple
});
async function mockChatWorkspace(page, { job = null, queue = [], history = [], environments,
- profileReady = true, draftOnlyReady = false } = {}) {
+ profileReady = true, draftOnlyReady = false, ppeWriteConfigured = false } = {}) {
await page.route("**/api/status", (route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify({
instanceId: "workspace-owner",
- readiness: { repositoryReady: true, profileReady, draftOnlyReady,
+ readiness: { repositoryReady: true, profileReady, draftOnlyReady, ppeWriteConfigured,
repository: "C:\\code\\alpha", profileName: "Alpha workspace", environments },
runtime: { name: "copilot", supportsModelSelection: true, defaultModel: "" },
job, queue, held: [], history
diff --git a/mcp/dashboard-client.js b/mcp/dashboard-client.js
index b99b732..cc37ffc 100644
--- a/mcp/dashboard-client.js
+++ b/mcp/dashboard-client.js
@@ -74,8 +74,17 @@ export async function dashboardControl(args) {
throw new Error("Use current instance/job IDs and explicit user authorization for dashboard changes.");
}
const body = operation.method === "POST"
- ? { ...args.parameters, ...(operation.action ? { action: operation.action } : {}) }
+ ? { ...(args.operation === "job-start" ? { routineFileAccess: true } : {}),
+ ...args.parameters, ...(operation.action ? { action: operation.action } : {}) }
: undefined;
+ if (args.operation === "job-start" && typeof args.parameters.targetEnvironment === "string" &&
+ args.parameters.targetEnvironment.toLowerCase() === "ppe" &&
+ args.parameters.allowPpeWrites === undefined && args.parameters.ppeWriteApprovalConfirmed !== false &&
+ args.parameters.allowPostRequests !== true && !args.parameters.testMutationMode &&
+ current.ppeWriteConfigured === true) {
+ body.allowPpeWrites = true;
+ body.ppeWriteApprovalConfirmed = true;
+ }
const path = args.operation === "metrics" && args.parameters.period
? `${operation.path}?period=${encodeURIComponent(args.parameters.period)}`
: operation.path;
diff --git a/test/dashboard-runtime-policy.test.js b/test/dashboard-runtime-policy.test.js
index cfd9122..3e308b7 100644
--- a/test/dashboard-runtime-policy.test.js
+++ b/test/dashboard-runtime-policy.test.js
@@ -557,6 +557,66 @@ test("reviewed file trust covers source reads and edits across hosts, never othe
}
});
+test("oversized source edits offer a fresh review only after complete eligibility checks, never a grant", (t) => {
+ const state = fixture(t);
+ const request = { kind: "write", fileName: join(state.repository, "src", "index.ts"), diff: "+".repeat(9000) };
+ const prompt = runtimePermissionPrompt(request);
+ const review = state.policy.reviewSourceEdit(request, prompt);
+ assert.equal(review.fileName, join("src", "index.ts"));
+ assert.ok(review.reviewCharacters > 8192);
+ assert.equal(state.policy.allows(request, prompt), false);
+ for (const change of [
+ { diff: "Small edit" }, { diff: `${request.diff}\nconst token = "synthetic-value";` },
+ { diff: "+".repeat(MAX_RUNTIME_FRAME_BYTES) }, { managedApprovalRequired: true },
+ { requestSandboxBypass: true }, { warning: "Manual review required" },
+ { fileName: join(state.directory, "outside.ts") },
+ { fileName: join(state.repository, "src", "settings.ts") }
+ ]) {
+ const changed = { ...request, ...change };
+ assert.equal(state.policy.reviewSourceEdit(changed, runtimePermissionPrompt(changed)), null);
+ }
+ const protectedPolicy = createRuntimePermissionPolicy(state.repository, [join(state.repository, "src")]);
+ assert.equal(protectedPolicy.reviewSourceEdit(request, prompt), null);
+ const longDirectory = join(state.repository, "src", "a".repeat(200), "b".repeat(200));
+ mkdirSync(longDirectory, { recursive: true });
+ const longRequest = { ...request, fileName: join(longDirectory, "index.ts") };
+ assert.equal(state.policy.isSourceFile(longRequest.fileName), true);
+ assert.equal(state.policy.reviewSourceEdit(longRequest, runtimePermissionPrompt(longRequest)), null,
+ "unrepresentable hint metadata keeps the normal denial rather than breaking the permission bridge");
+});
+
+test("only the current session's regular checkpoint Markdown is automatically readable", (t) => {
+ const state = fixture(t);
+ const id = randomUUID();
+ const home = join(state.directory, "copilot-home");
+ const checkpoints = join(home, "session-state", id, "checkpoints");
+ mkdirSync(checkpoints, { recursive: true });
+ const file = join(checkpoints, "002-proposed-regression.md");
+ writeFileSync(file, "Synthetic current-job checkpoint.");
+ const policy = createRuntimePermissionPolicy(state.repository, [], null, null, id, home);
+ const read = { kind: "read", path: file };
+ const automatic = (request) => policy.automaticApproval(request, runtimePermissionPrompt(request));
+ assert.ok(automatic(read));
+ assert.equal(state.policy.automaticApproval(read, runtimePermissionPrompt(read)), null);
+ const otherSession = createRuntimePermissionPolicy(state.repository, [], null, null, randomUUID(), home);
+ assert.equal(otherSession.automaticApproval(read, runtimePermissionPrompt(read)), null);
+ for (const request of [
+ { kind: "write", fileName: file, diff: "Changed checkpoint" },
+ { ...read, path: checkpoints }, { ...read, path: join(home, "config.json") },
+ { ...read, managedApprovalRequired: true }, { ...read, requestSandboxBypass: true },
+ { ...read, resolvedPath: join(state.repository, "src", "index.ts") }
+ ]) { assert.equal(automatic(request), null); }
+ const link = join(checkpoints, "linked.md");
+ linkSync(file, link);
+ assert.equal(automatic(read), null);
+ rmSync(link);
+ symlinkSync(join(state.repository, "src"), link, process.platform === "win32" ? "junction" : "dir");
+ assert.equal(automatic({ ...read, path: link }), null);
+ rmSync(link);
+ writeFileSync(file, "x".repeat(1024 * 1024 + 1));
+ assert.equal(automatic(read), null);
+});
+
test("the exact safe repository profile is readable under reviewed trust but never writable", (t) => {
const state = fixture(t);
const path = join(state.repository, ".github", "fixlab", "repository-profile.json");
diff --git a/test/dashboard-runtime-requests.test.js b/test/dashboard-runtime-requests.test.js
index d4f4eb2..cf9f8c9 100644
--- a/test/dashboard-runtime-requests.test.js
+++ b/test/dashboard-runtime-requests.test.js
@@ -74,6 +74,28 @@ test("guided recovery denies only the exact request, never creates a grant and c
}
});
+test("large-edit review metadata survives transport but cannot approve or replay the hidden operation", () => {
+ const state = fixture();
+ const view = runtimePermissionPrompt({ kind: "write", fileName: "Header.tsx", diff: "+".repeat(9000) });
+ const request = { ...view, id: randomUUID(),
+ sourceEditReview: { fileName: "Header.tsx", reviewCharacters: view.reviewCharacters } };
+ state.bridge.receive(request);
+ assert.deepEqual(state.requests[0].sourceEditReview, request.sourceEditReview);
+ assert.doesNotMatch(state.requests[0].details, /\+{100}/u);
+ assert.throws(() => state.bridge.respond(request.id, { action: "allow" }), /not available/u);
+ state.bridge.respond(request.id, { action: "deny", recover: true });
+ assert.equal(state.sent[0].action, "deny");
+ assert.throws(() => state.bridge.respond(request.id, { action: "allow" }), /stale/u);
+ for (const metadata of [
+ null, { fileName: "..\\Header.tsx", reviewCharacters: 9000 },
+ { fileName: "Header.tsx", reviewCharacters: 8192 },
+ { fileName: "Header.tsx", reviewCharacters: 9000, diff: "Hidden source" }
+ ]) {
+ assert.throws(() => state.bridge.receive({ ...request, id: randomUUID(), sourceEditReview: metadata }),
+ /Invalid|invalid/u);
+ }
+});
+
test("SDK safe recovery returns a denial with bounded feedback rather than approving the hidden operation", async () => {
let decisions = 0;
await runCopilotSession({ repository: "synthetic-repository", prompt: "Recover safely.", toolApprovalMode: "allow-all" }, {
diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js
index 471706b..cd26f95 100644
--- a/test/dashboard-server.test.js
+++ b/test/dashboard-server.test.js
@@ -1405,6 +1405,74 @@ test("connected control starts scoped browser jobs, manages the queue and stops
}
});
+test("connected PPE jobs default reviewed writes and routine test access, preserving explicit opt-outs", async () => {
+ const repository = createGitRepository();
+ const profile = validProfile();
+ profile.browserAutomation.testSynthesis.defaultDataSource = "non-production-approved";
+ profile.browserAutomation.testSynthesis.mutationMode = "approved-write";
+ profile.environments = ["dev", "sit", "uat", "ppe"];
+ profile.browserAutomation.dataSafety.ppeWrite = {
+ scope: "api://example-service/access", sessionHelper: "ppe-session.mjs",
+ origins: ["https://api.ppe.example.test"]
+ };
+ writeFileSync(join(repository, "ppe-session.mjs"),
+ 'export async function withApiToken(options, run) { await run("synthetic-value"); }\n');
+ const profilePath = join(repository, ".github", "fixlab", "repository-profile.json");
+ writeFileSync(profilePath, JSON.stringify(profile));
+ const launches = [];
+ const { dashboard, url } = await startDashboard(repository, (input) => {
+ launches.push(input);
+ input.onOutput("stdout", `${"Synthetic retained output. ".repeat(160)}\n`.repeat(40));
+ return { completion: new Promise(() => {}) };
+ }, undefined, { runtime: "copilot" });
+ const base = { repository, port: Number(new URL(url).port) };
+ const start = async (parameters) => {
+ const state = await dashboardConversation(base);
+ return dashboardControl({ ...base, instanceId: state.instanceId, jobId: state.job?.id ?? null,
+ operation: "job-start", confirmed: true,
+ parameters: { request: "Synthetic future-job defaults.", mode: "fix-and-validate",
+ targetEnvironment: "ppe", ...parameters } });
+ };
+ try {
+ assert.equal(inspectRepository(repository).ppeWriteConfigured, true);
+ await start({});
+ assert.equal(launches[0].postWriteApproval.mode, "ppe-origin");
+ assert.equal(launches[0].fileAccess.mode, "source-read-write");
+ assert.equal(launches[0].fileAccess.allowValidationGates, true);
+ assert.equal(launches[0].toolApprovalMode, "ask");
+ assert.ok(Buffer.byteLength(JSON.stringify((await jsonRequest(url, "/api/status")).body)) > 128 * 1024,
+ "long-job logs must not enter the bounded MCP default lookup");
+ for (const parameters of [
+ { targetEnvironment: "dev" }, { targetEnvironment: "sit" }, { targetEnvironment: "uat" },
+ { allowPpeWrites: false, routineFileAccess: false }, { ppeWriteApprovalConfirmed: false },
+ { testMutationMode: "none" }, { testMutationMode: "intercepted" },
+ { allowPostRequests: true }
+ ]) {
+ await start({ mode: "validate-only", ...parameters });
+ const queued = (await jsonRequest(url, "/api/job")).body.queue.at(-1);
+ assert.equal(queued.ppeWriteApproved, false, JSON.stringify(parameters));
+ assert.equal(queued.fileAccess?.mode ?? null, parameters.routineFileAccess === false ? null : "source-read");
+ }
+ profile.browserAutomation.dataSafety.ppeWrite.origins = ["https://api.prod.example.test"];
+ writeFileSync(profilePath, JSON.stringify(profile));
+ const invalid = inspectRepository(repository);
+ assert.equal(invalid.profileReady, true, "optional write setup must not block read-only validation");
+ assert.equal(invalid.ppeWriteConfigured, false);
+ assert.match(invalid.ppeWriteSetupError, /PPE write origins/);
+ await start({});
+ assert.equal((await jsonRequest(url, "/api/job")).body.queue.at(-1).ppeWriteApproved, false);
+ await assert.rejects(start({ allowPpeWrites: true, ppeWriteApprovalConfirmed: true }), /PPE write origins/);
+ delete profile.browserAutomation.dataSafety.ppeWrite;
+ writeFileSync(profilePath, JSON.stringify(profile));
+ await start({});
+ assert.equal((await jsonRequest(url, "/api/job")).body.queue.at(-1).ppeWriteApproved, false);
+ assert.equal(launches.length, 1, "future-job defaults do not resume or alter the active executor");
+ } finally {
+ await dashboard.close();
+ rmSync(repository, { recursive: true, force: true });
+ }
+});
+
test("connected control rechecks job identity after an asynchronous request body", async () => {
const repository = createGitRepository();
const { dashboard, url } = await startDashboard(repository, () => ({
@@ -2352,6 +2420,87 @@ test("reviewed file trust is exact-job bound, persists on resume/restart and is
}
});
+test("oversized edit recovery retains denial and requires exact fresh authorization after stopped-job trust", async () => {
+ const repository = createGitRepository();
+ const sent = [];
+ const launches = [];
+ let bridge;
+ let stopped = 0;
+ const executor = (input) => {
+ launches.push(input);
+ bridge = createRuntimeRequestBridge({ send: (message) => sent.push(message),
+ onChange: input.onRuntimeRequests });
+ return { completion: new Promise(() => {}),
+ respondToRuntimeRequest: (id, decision) => bridge.respond(id, decision),
+ terminate() { stopped++; bridge.cancel(); } };
+ };
+ let current = await startDashboard(repository, executor, undefined, { runtime: "copilot" });
+ const post = (path, body, origin = current.url) => jsonRequest(current.url, path, {
+ method: "POST", headers: { "Content-Type": "application/json", ...(origin ? { Origin: origin } : {}) },
+ body: JSON.stringify(body)
+ });
+ try {
+ const created = await post("/api/jobs", { request: "Review a large source edit.", mode: "fix-and-validate" });
+ const identity = (await jsonRequest(current.url, "/api/status")).body.instanceId;
+ const id = randomUUID();
+ bridge.receive({ ...runtimePermissionPrompt({ kind: "write", fileName: "src/header.tsx",
+ diff: "synthetic-hidden-diff\n".repeat(500) }), id,
+ sourceEditReview: { fileName: "src/header.tsx", reviewCharacters: 11000 } });
+ const state = (await jsonRequest(current.url, "/api/job")).body.job;
+ assert.equal(state.sourceEditReview.requestId, id);
+ assert.doesNotMatch(JSON.stringify(state.sourceEditReview), /synthetic-hidden-diff/);
+ const binding = { instanceId: identity, jobId: created.body.job.id, requestId: id, confirmed: true };
+ for (const changes of [{ instanceId: randomUUID() }, { jobId: randomUUID() },
+ { requestId: randomUUID() }, { confirmed: false }]) {
+ assert.equal((await post("/api/job/source-edit-review", { ...binding, ...changes })).response.status, 409);
+ }
+ assert.equal((await post("/api/job/source-edit-review", binding, "https://example.invalid")).response.status, 409);
+ assert.equal(stopped, 0);
+ const denied = await post("/api/job/runtime-response", { ...binding, action: "deny", recover: true });
+ assert.equal(denied.response.status, 200, denied.body.error);
+ assert.deepEqual(sent, [{ type: "response", id, action: "deny", recover: true }]);
+ assert.equal((await jsonRequest(current.url, "/api/job")).body.job.sourceEditReview.requestId, id);
+ assert.equal((await post("/api/job/source-edit-review", binding)).response.status, 202);
+ for (let attempt = 0; attempt < 100; attempt++) {
+ if ((await jsonRequest(current.url, "/api/job")).body.job.canResume) { break; }
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ assert.equal(stopped, 1);
+ assert.throws(() => bridge.respond(id, { action: "allow" }), /stale/);
+ const authorization = { action: "continue", details: state.sourceEditReview.authorization,
+ instanceId: identity, jobId: created.body.job.id, sourceEditReviewId: id, confirmed: true };
+ assert.equal((await post("/api/job/input", authorization)).response.status, 409);
+ await current.dashboard.close();
+ current = await startDashboard(repository, executor, undefined, { runtime: "copilot" });
+ const restored = (await jsonRequest(current.url, "/api/status")).body;
+ assert.equal(restored.job.sourceEditReview.requestId, id);
+ assert.equal(restored.job.runtimeRequests.length, 0);
+ assert.doesNotMatch(readFileSync(join(dirname(createCacheContext(repository).cachePath), "dashboard-jobs.json"), "utf8"),
+ /synthetic-hidden-diff/);
+ authorization.instanceId = restored.instanceId;
+ const trust = await post("/api/job/file-access", { instanceId: restored.instanceId,
+ jobId: created.body.job.id, repository, mode: "source-read-write", confirmed: true });
+ assert.equal(trust.response.status, 200, trust.body.error);
+ assert.equal(launches.length, 1, "reviewing trust never resumes or approves an operation");
+ for (const changes of [{ instanceId: identity }, { jobId: randomUUID() },
+ { sourceEditReviewId: randomUUID(), validationIntent: "full" },
+ { confirmed: false }, { details: "Different authorization" }]) {
+ assert.equal((await post("/api/job/input", { ...authorization, ...changes })).response.status, 409);
+ }
+ assert.equal((await post("/api/job/input", authorization, "")).response.status, 409);
+ assert.equal((await jsonRequest(current.url, "/api/job")).body.job.validationIntent, state.validationIntent);
+ const resumed = await post("/api/job/input", authorization);
+ assert.equal(resumed.response.status, 202, resumed.body.error);
+ assert.equal(resumed.body.job.sourceEditReview, null);
+ assert.equal(launches.length, 2);
+ assert.match(launches[1].prompt, /explicitly authorize a fresh attempt/);
+ assert.equal(sent.length, 1, "the old callback remains denied, never replayed");
+ } finally {
+ await current.dashboard.close();
+ rmSync(repository, { recursive: true, force: true });
+ }
+});
+
test("Allow all job consent is origin-bound, reaches the executor and is not recovered or inherited", async () => {
const repository = createGitRepository();
const launches = [];