feat(git): classify managed-file changes before apply (#1832)

* feat(git): classify managed-file changes before apply

Pull now builds a fingerprint-bound plan of adds, modifies, deletes, and local conflicts. Apply refuses stale or blocked plans instead of overwriting live files, and promotion stays the only filesystem mutator.

* fix(git): contain stack-dir probes before filesystem access

The missing-stack and root-.env existence checks now resolve against the compose base and refuse paths that escape it before lstat or existsSync.

* fix(git): address managed-file change plan audit blockers

Wire build-context live inventory into the planner, reject special file nodes without readFile, fingerprint configured project env files, enrich plan metadata, and compute the create plan before promotion. Redact drift ledger service keys for managed-path conflicts and clear pending plan columns on revision reset.

* fix(git): unblock change-plan CI sinks and fifo test

Hash stack files through a contained open plus fstat on the same handle so CodeQL no longer flags the lstat/read race, and create fifo fixtures with mkfifo instead of mkfifoSync.

* fix(git): preserve unowned context files and align candidate validation

Inspect prior and candidate build contexts together, delete only owned paths, reject context-root symlinks before walking, and validate with the env-file model deploy will use after promotion.

* fix(git): contain live context and candidate env path sinks

Inline resolve and startsWith at the lstat and access calls so containment is checked at the filesystem sink.

* fix(git): resolve live context walks from the compose root

Rebuild readdir, lstat, and access paths from the compose directory at each sink so containment is checked against a known-safe base.

* fix(git): validate synced env removal against post-promotion files

A managed .env that the next revision omits must not be used for candidate validation or invocation, because promotion deletes it. Context walks now bound directory entries and skip descendants under nested symlinks. Plan fingerprints bind review metadata and secret-path matching covers .env.* names.

* docs(git): capture classified change-plan review screenshots

Replace the old Monaco pull-preview images with the classified operation list used by Apply.

* fix(git): treat invocation drift as reviewable, not a file conflict

A live Compose command-line change is not a managed-file conflict. Reviewed apply records the incoming invocation; webhook auto-apply still refuses.
This commit is contained in:
Anso
2026-08-14 09:53:31 -04:00
committed by GitHub
parent 4c93947004
commit 3c4c057467
38 changed files with 4877 additions and 673 deletions
+36
View File
@@ -150,3 +150,39 @@ export async function authoredComposeEnvFileArgs(stackName: string, nodeId?: num
}
return ['--env-file', envPath];
}
/**
* `--env-file` arguments for candidate `docker compose config` validation.
* Configured project env files stay live-stack paths (same as deploy).
* Otherwise a context-dir stack uses the candidate `.env` when that file
* exists on the candidate. If it does not, fall back to the live legacy `.env`
* only when that file will survive promotion (`syncEnv` is false). A managed
* synced `.env` that this generation omits must not be used for validation.
*/
export async function candidateValidationEnvFileArgs(opts: {
stackName: string;
nodeId: number;
candidateAbs: string;
contextDir: string | null;
syncEnv: boolean;
}): Promise<string[]> {
const configured = DatabaseService.getInstance().getStackProjectEnvFiles(opts.nodeId, opts.stackName);
if (configured.length > 0) {
return authoredComposeEnvFileArgs(opts.stackName, opts.nodeId);
}
if (!opts.contextDir) return [];
// Canonical js/path-injection barrier inline with the access sink. CodeQL
// does not credit a wrapped helper or a check separated from the sink.
const baseResolved = path.resolve(opts.candidateAbs);
const candidateEnv = path.resolve(baseResolved, '.env');
try {
if (candidateEnv.startsWith(baseResolved + path.sep)) {
await fsPromises.access(candidateEnv);
return ['--env-file', candidateEnv];
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
if (opts.syncEnv) return [];
return authoredComposeEnvFileArgs(opts.stackName, opts.nodeId);
}
@@ -0,0 +1,98 @@
/**
* Pure candidate compose invocation builder.
*
* Derives the ordered docker-compose argv (`-f`, `-p`, optional
* `--project-directory`, `--env-file`) from the *candidate* Git selection,
* never from the currently applied deploy spec. Using the live spec here would
* stamp the previous generation's file list onto a new one.
*
* Project-env-file flags are current stack configuration (not prior spec), so
* the caller may pass them to keep deploy-time env files on the new generation.
*/
import path from 'path';
import { gitSourceLocalComposeFiles } from './gitComposeFiles';
import { isPathWithinBase, isValidRelativeStackPath } from './validation';
export interface CandidateComposeInvocationInput {
stackName: string;
composePaths: string[];
contextDir: string | null;
/** Stack directory (absolute). Used only to resolve `--project-directory` and `--env-file`. */
stackDir: string;
syncEnv: boolean;
envContentPresent: boolean;
/** Stack-root project env files currently configured for this stack. */
projectEnvFiles?: string[];
/**
* True when an unmanaged stack-root `.env` will survive promotion.
* Ignored when `syncEnv` is true; that path uses `envContentPresent` only.
*/
rootEnvFilePresent?: boolean;
}
export function buildCandidateComposeInvocation(input: CandidateComposeInvocationInput): string[] {
const { stackName, composePaths, contextDir, stackDir, syncEnv, envContentPresent } = input;
const stackRoot = path.resolve(stackDir);
const args: string[] = [];
const rootEnvFilePresent = input.rootEnvFilePresent === true;
const emitFileArgs = composePaths.length > 1 || !!contextDir;
if (emitFileArgs) {
const localFiles = gitSourceLocalComposeFiles(composePaths);
for (const file of localFiles) {
if (!file || !isValidRelativeStackPath(file)) {
throw new Error(`Invalid compose file path in candidate selection for stack "${stackName}"`);
}
if (!isPathWithinBase(path.resolve(stackRoot, file), stackRoot)) {
throw new Error(`Compose file path escapes the stack directory for stack "${stackName}"`);
}
args.push('-f', file);
}
args.push('-p', stackName);
if (contextDir) {
if (!isValidRelativeStackPath(contextDir)) {
throw new Error(`Invalid context directory in candidate selection for stack "${stackName}"`);
}
const ctxAbs = path.resolve(stackRoot, contextDir);
if (!isPathWithinBase(ctxAbs, stackRoot)) {
throw new Error(`Context directory escapes the stack directory for stack "${stackName}"`);
}
args.push('--project-directory', ctxAbs);
}
}
const projectEnvFiles = input.projectEnvFiles ?? [];
if (projectEnvFiles.length > 0) {
for (const file of projectEnvFiles) {
if (!file || !isValidRelativeStackPath(file)) {
throw new Error(`Invalid project env file path for stack "${stackName}": "${file}"`);
}
if (file.includes('/') || file.includes('\\')) {
throw new Error(
`Project env file "${file}" for stack "${stackName}" must be at the stack root.`,
);
}
const envPath = path.resolve(stackRoot, file);
if (!isPathWithinBase(envPath, stackRoot)) {
throw new Error(`Project env file path escapes stack directory for stack "${stackName}": "${file}"`);
}
args.push('--env-file', envPath);
}
return args;
}
// Compose auto-loads stack-root .env for single-file selections. For a
// context dir, emit --env-file only when this generation will own `.env`
// (sync-env content) or an unmanaged live file will survive promotion.
// A managed `.env` scheduled for deletion must not appear here.
const includeRootEnvFile = syncEnv ? envContentPresent : rootEnvFilePresent;
if (contextDir && includeRootEnvFile) {
const envPath = path.resolve(stackRoot, '.env');
if (!isPathWithinBase(envPath, stackRoot)) {
throw new Error(`Env file path escapes the stack directory for stack "${stackName}"`);
}
args.push('--env-file', envPath);
}
return args;
}
+16 -4
View File
@@ -17,13 +17,22 @@ import { GitSourceError } from '../services/GitSourceService';
export function gitSourceStatus(code: GitSourceErrorCode): number {
switch (code) {
case 'AUTH_FAILED': return 400;
case 'AUTH_FAILED':
case 'PLAN_FINGERPRINT_REQUIRED':
return 400;
case 'REPO_NOT_FOUND':
case 'BRANCH_NOT_FOUND':
case 'FILE_NOT_FOUND':
return 404;
case 'NETWORK_TIMEOUT': return 504;
default: return 400;
case 'STALE_PLAN':
case 'PLAN_BLOCKED':
case 'LEGACY_PENDING':
case 'PLAN_UNAVAILABLE':
return 409;
case 'NETWORK_TIMEOUT':
return 504;
default:
return 400;
}
}
@@ -54,7 +63,10 @@ export function webhookPullStatus(status: 'success' | 'skipped' | 'error'): numb
export function sendGitSourceError(res: Response, err: unknown): void {
if (err instanceof GitSourceError) {
res.status(gitSourceStatus(err.code)).json({ error: err.message, code: err.code });
const body: Record<string, unknown> = { error: err.message, code: err.code };
if (err.extras?.plan) body.plan = err.extras.plan;
if (err.extras?.planFingerprint) body.planFingerprint = err.extras.planFingerprint;
res.status(gitSourceStatus(err.code)).json(body);
return;
}
console.error('[GitSource] Unexpected error:', err);