a
This commit is contained in:
@@ -26,6 +26,7 @@ import type { JsonObject } from "./types.js";
|
||||
import { SessionWorktreeManager, type SessionMetadata } from "./session-lifecycle.js";
|
||||
import {
|
||||
SecureCommandExecutor,
|
||||
type SecurityViolationHandling,
|
||||
type SecurityAuditEvent,
|
||||
type SecurityAuditSink,
|
||||
SecurityRulesEngine,
|
||||
@@ -46,7 +47,7 @@ export type OrchestrationSettings = {
|
||||
maxRetries: number;
|
||||
maxChildren: number;
|
||||
mergeConflictMaxAttempts: number;
|
||||
securityViolationHandling: "hard_abort" | "validation_fail";
|
||||
securityViolationHandling: SecurityViolationHandling;
|
||||
runtimeContext: Record<string, string | number | boolean>;
|
||||
};
|
||||
|
||||
@@ -211,6 +212,9 @@ function createActorSecurityContext(input: {
|
||||
blockedEnvAssignments: ["AGENT_STATE_ROOT", "AGENT_PROJECT_CONTEXT_PATH"],
|
||||
},
|
||||
auditSink,
|
||||
{
|
||||
violationHandling: input.settings.securityViolationHandling,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -342,6 +346,7 @@ export class SchemaDrivenExecutionEngine {
|
||||
this.sessionWorktreeManager = new SessionWorktreeManager({
|
||||
worktreeRoot: resolve(this.settings.workspaceRoot, this.config.provisioning.gitWorktree.rootDirectory),
|
||||
baseRef: this.config.provisioning.gitWorktree.baseRef,
|
||||
targetPath: this.config.provisioning.gitWorktree.targetPath,
|
||||
});
|
||||
|
||||
this.actorExecutors = toExecutorMap(input.actorExecutors);
|
||||
@@ -426,7 +431,11 @@ export class SchemaDrivenExecutionEngine {
|
||||
}): Promise<PipelineRunSummary> {
|
||||
const managerSessionId = `${input.sessionId}__pipeline`;
|
||||
const managerSession = this.manager.createSession(managerSessionId);
|
||||
const workspaceRoot = input.sessionMetadata?.baseWorkspacePath ?? this.settings.workspaceRoot;
|
||||
const workspaceRoot = input.sessionMetadata
|
||||
? this.sessionWorktreeManager.resolveWorkingDirectoryForWorktree(
|
||||
input.sessionMetadata.baseWorkspacePath,
|
||||
)
|
||||
: this.settings.workspaceRoot;
|
||||
const projectContextStore = input.sessionMetadata
|
||||
? new FileSystemProjectContextStore({
|
||||
filePath: resolveSessionProjectContextPath(this.settings.stateRoot, input.sessionId),
|
||||
@@ -531,6 +540,7 @@ export class SchemaDrivenExecutionEngine {
|
||||
|
||||
return {
|
||||
taskId,
|
||||
workingDirectory: ensured.taskWorkingDirectory,
|
||||
worktreePath: ensured.taskWorktreePath,
|
||||
statusAtStart,
|
||||
...(existing?.metadata ? { metadata: existing.metadata } : {}),
|
||||
|
||||
@@ -63,6 +63,7 @@ export type ActorExecutionResult = {
|
||||
export type ActorToolPermissionResult =
|
||||
| {
|
||||
behavior: "allow";
|
||||
updatedInput?: Record<string, unknown>;
|
||||
toolUseID?: string;
|
||||
}
|
||||
| {
|
||||
@@ -171,6 +172,7 @@ export type ActorExecutionSecurityContext = {
|
||||
|
||||
export type TaskExecutionResolution = {
|
||||
taskId: string;
|
||||
workingDirectory: string;
|
||||
worktreePath: string;
|
||||
statusAtStart: string;
|
||||
metadata?: JsonObject;
|
||||
@@ -941,7 +943,7 @@ export class PipelineExecutor {
|
||||
node,
|
||||
toolClearance,
|
||||
prompt,
|
||||
worktreePathOverride: taskResolution?.worktreePath,
|
||||
worktreePathOverride: taskResolution?.workingDirectory,
|
||||
});
|
||||
|
||||
const result = await this.invokeActorExecutor({
|
||||
@@ -970,6 +972,7 @@ export class PipelineExecutor {
|
||||
...(taskResolution
|
||||
? {
|
||||
taskId: taskResolution.taskId,
|
||||
workingDirectory: taskResolution.workingDirectory,
|
||||
worktreePath: taskResolution.worktreePath,
|
||||
}
|
||||
: {}),
|
||||
@@ -1309,6 +1312,7 @@ export class PipelineExecutor {
|
||||
const createToolPermissionHandler = (): ActorToolPermissionHandler =>
|
||||
this.createToolPermissionHandler({
|
||||
allowedTools: executionContext.allowedTools,
|
||||
violationMode: executionContext.security.violationMode,
|
||||
sessionId: input.sessionId,
|
||||
nodeId: input.nodeId,
|
||||
attempt: input.attempt,
|
||||
@@ -1326,6 +1330,7 @@ export class PipelineExecutor {
|
||||
|
||||
private createToolPermissionHandler(input: {
|
||||
allowedTools: readonly string[];
|
||||
violationMode: SecurityViolationHandling;
|
||||
sessionId: string;
|
||||
nodeId: string;
|
||||
attempt: number;
|
||||
@@ -1340,7 +1345,7 @@ export class PipelineExecutor {
|
||||
attempt: input.attempt,
|
||||
};
|
||||
|
||||
return async (toolName, _input, options) => {
|
||||
return async (toolName, toolInput, options) => {
|
||||
const toolUseID = options.toolUseID;
|
||||
if (options.signal.aborted) {
|
||||
return {
|
||||
@@ -1358,11 +1363,28 @@ export class PipelineExecutor {
|
||||
caseInsensitiveLookup: caseInsensitiveAllowLookup,
|
||||
});
|
||||
if (!allowMatch) {
|
||||
rulesEngine?.assertToolInvocationAllowed({
|
||||
tool: candidates[0] ?? toolName,
|
||||
toolClearance: toolPolicy,
|
||||
context: toolAuditContext,
|
||||
});
|
||||
if (rulesEngine) {
|
||||
try {
|
||||
rulesEngine.assertToolInvocationAllowed({
|
||||
tool: candidates[0] ?? toolName,
|
||||
toolClearance: toolPolicy,
|
||||
context: toolAuditContext,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!(input.violationMode === "dangerous_warn_only" && error instanceof SecurityViolationError)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.violationMode === "dangerous_warn_only") {
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
...(toolUseID ? { toolUseID } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: `Tool "${toolName}" is not in the resolved execution allowlist.`,
|
||||
@@ -1379,6 +1401,7 @@ export class PipelineExecutor {
|
||||
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
...(toolUseID ? { toolUseID } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -358,13 +358,16 @@ export class FileSystemSessionMetadataStore {
|
||||
export class SessionWorktreeManager {
|
||||
private readonly worktreeRoot: string;
|
||||
private readonly baseRef: string;
|
||||
private readonly targetPath?: string;
|
||||
|
||||
constructor(input: {
|
||||
worktreeRoot: string;
|
||||
baseRef: string;
|
||||
targetPath?: string;
|
||||
}) {
|
||||
this.worktreeRoot = assertAbsolutePath(input.worktreeRoot, "worktreeRoot");
|
||||
this.baseRef = assertNonEmptyString(input.baseRef, "baseRef");
|
||||
this.targetPath = normalizeWorktreeTargetPath(input.targetPath, "targetPath");
|
||||
}
|
||||
|
||||
resolveBaseWorkspacePath(sessionId: string): string {
|
||||
@@ -378,6 +381,11 @@ export class SessionWorktreeManager {
|
||||
return resolve(this.worktreeRoot, scopedSession, "tasks", scopedTask);
|
||||
}
|
||||
|
||||
resolveWorkingDirectoryForWorktree(worktreePath: string): string {
|
||||
const normalizedWorktreePath = assertAbsolutePath(worktreePath, "worktreePath");
|
||||
return this.targetPath ? resolve(normalizedWorktreePath, this.targetPath) : normalizedWorktreePath;
|
||||
}
|
||||
|
||||
private resolveBaseBranchName(sessionId: string): string {
|
||||
const scoped = sanitizeSegment(sessionId, "session");
|
||||
return `ai-ops/${scoped}/base`;
|
||||
@@ -399,14 +407,13 @@ export class SessionWorktreeManager {
|
||||
|
||||
await mkdir(dirname(baseWorkspacePath), { recursive: true });
|
||||
|
||||
const alreadyExists = await pathExists(baseWorkspacePath);
|
||||
if (alreadyExists) {
|
||||
return;
|
||||
if (!(await pathExists(baseWorkspacePath))) {
|
||||
const repoRoot = await runGit(["-C", projectPath, "rev-parse", "--show-toplevel"]);
|
||||
const branchName = this.resolveBaseBranchName(input.sessionId);
|
||||
await runGit(["-C", repoRoot, "worktree", "add", "-B", branchName, baseWorkspacePath, this.baseRef]);
|
||||
}
|
||||
|
||||
const repoRoot = await runGit(["-C", projectPath, "rev-parse", "--show-toplevel"]);
|
||||
const branchName = this.resolveBaseBranchName(input.sessionId);
|
||||
await runGit(["-C", repoRoot, "worktree", "add", "-B", branchName, baseWorkspacePath, this.baseRef]);
|
||||
await this.ensureWorktreeTargetPath(baseWorkspacePath);
|
||||
}
|
||||
|
||||
async ensureTaskWorktree(input: {
|
||||
@@ -416,6 +423,7 @@ export class SessionWorktreeManager {
|
||||
existingWorktreePath?: string;
|
||||
}): Promise<{
|
||||
taskWorktreePath: string;
|
||||
taskWorkingDirectory: string;
|
||||
}> {
|
||||
const baseWorkspacePath = assertAbsolutePath(input.baseWorkspacePath, "baseWorkspacePath");
|
||||
const maybeExisting = input.existingWorktreePath?.trim();
|
||||
@@ -451,8 +459,10 @@ export class SessionWorktreeManager {
|
||||
if (addResult.exitCode !== 0) {
|
||||
const attachedAfterFailure = await this.findWorktreePathForBranch(baseWorkspacePath, branchName);
|
||||
if (attachedAfterFailure === worktreePath && (await pathExists(worktreePath))) {
|
||||
const taskWorkingDirectory = await this.ensureWorktreeTargetPath(worktreePath);
|
||||
return {
|
||||
taskWorktreePath: worktreePath,
|
||||
taskWorkingDirectory,
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
@@ -462,8 +472,10 @@ export class SessionWorktreeManager {
|
||||
}
|
||||
}
|
||||
|
||||
const taskWorkingDirectory = await this.ensureWorktreeTargetPath(worktreePath);
|
||||
return {
|
||||
taskWorktreePath: worktreePath,
|
||||
taskWorkingDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -780,4 +792,69 @@ export class SessionWorktreeManager {
|
||||
}
|
||||
return parseGitWorktreeRecords(result.stdout);
|
||||
}
|
||||
|
||||
private async ensureWorktreeTargetPath(worktreePath: string): Promise<string> {
|
||||
if (this.targetPath) {
|
||||
await runGit(["-C", worktreePath, "sparse-checkout", "init", "--cone"]);
|
||||
await runGit(["-C", worktreePath, "sparse-checkout", "set", this.targetPath]);
|
||||
}
|
||||
|
||||
const workingDirectory = this.resolveWorkingDirectoryForWorktree(worktreePath);
|
||||
let workingDirectoryStats;
|
||||
try {
|
||||
workingDirectoryStats = await stat(workingDirectory);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
if (this.targetPath) {
|
||||
throw new Error(
|
||||
`Configured worktree target path "${this.targetPath}" is not a directory in ref "${this.baseRef}".`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Worktree path "${workingDirectory}" does not exist.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!workingDirectoryStats.isDirectory()) {
|
||||
if (this.targetPath) {
|
||||
throw new Error(
|
||||
`Configured worktree target path "${this.targetPath}" is not a directory in ref "${this.baseRef}".`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Worktree path "${workingDirectory}" is not a directory.`);
|
||||
}
|
||||
|
||||
return workingDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWorktreeTargetPath(value: string | undefined, key: string): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const slashNormalized = trimmed.replace(/\\/g, "/");
|
||||
if (isAbsolute(slashNormalized) || /^[a-zA-Z]:\//.test(slashNormalized)) {
|
||||
throw new Error(`${key} must be a relative path within the repository worktree.`);
|
||||
}
|
||||
|
||||
const normalizedSegments = slashNormalized
|
||||
.split("/")
|
||||
.map((segment) => segment.trim())
|
||||
.filter((segment) => segment.length > 0 && segment !== ".");
|
||||
|
||||
if (normalizedSegments.some((segment) => segment === "..")) {
|
||||
throw new Error(`${key} must not contain ".." path segments.`);
|
||||
}
|
||||
|
||||
if (normalizedSegments.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalizedSegments.join("/");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user