187 lines
5.2 KiB
JavaScript
187 lines
5.2 KiB
JavaScript
/**
|
|
* execution-policy.js — internal execution permission profiles for SF tool calls.
|
|
*
|
|
* Purpose: give autonomous run control and the machine surface a Codex/Crush-style
|
|
* vocabulary for filesystem, network, git, and mutation posture before those
|
|
* policies are enforced at every tool boundary.
|
|
*
|
|
* Consumer: write-gate, autonomous run-control hooks, machine-surface event DTOs,
|
|
* and future DB evidence rows that need stable permission-profile names instead
|
|
* of ad hoc booleans.
|
|
*/
|
|
|
|
import { shouldBlockQueueExecutionInSnapshot } from "./bootstrap/write-gate.js";
|
|
import { resolvePermissionProfile } from "./operating-model.js";
|
|
import { classifyCommand } from "./safety/destructive-guard.js";
|
|
|
|
export const EXECUTION_POLICY_PROFILES = {
|
|
restricted: {
|
|
id: "restricted",
|
|
permissionProfile: "restricted",
|
|
filesystem: "read-mostly",
|
|
network: "read-only",
|
|
git: "read-only",
|
|
mutation: "planning-artifacts-only",
|
|
},
|
|
normal: {
|
|
id: "normal",
|
|
permissionProfile: "normal",
|
|
filesystem: "workspace-write",
|
|
network: "allowed",
|
|
git: "normal",
|
|
mutation: "workspace",
|
|
},
|
|
trusted: {
|
|
id: "trusted",
|
|
permissionProfile: "trusted",
|
|
filesystem: "workspace-write",
|
|
network: "allowed",
|
|
git: "normal",
|
|
mutation: "workspace",
|
|
},
|
|
unrestricted: {
|
|
id: "unrestricted",
|
|
permissionProfile: "unrestricted",
|
|
filesystem: "danger-full-access",
|
|
network: "allowed",
|
|
git: "dangerous",
|
|
mutation: "host",
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Resolve a named execution policy profile.
|
|
*
|
|
* Purpose: normalize unknown or missing policy names to the safest default
|
|
* profile so callers can fail closed.
|
|
*
|
|
* Consumer: tool-call classifiers and future typed headless events.
|
|
*/
|
|
export function resolveExecutionPolicyProfile(profileId) {
|
|
const id = resolvePermissionProfile(profileId);
|
|
return EXECUTION_POLICY_PROFILES[id] ?? EXECUTION_POLICY_PROFILES.restricted;
|
|
}
|
|
|
|
function classifyBashRisk(command) {
|
|
const destructive = classifyCommand(command);
|
|
if (destructive.destructive) {
|
|
return { risk: "destructive", labels: destructive.labels };
|
|
}
|
|
if (
|
|
/\b(git\s+push|npm\s+publish|docker\s+push|kubectl\s+apply|terraform\s+apply)\b/.test(
|
|
command,
|
|
)
|
|
) {
|
|
return { risk: "external-mutation", labels: [] };
|
|
}
|
|
if (/\b(curl|wget|fetch|gh\s+api)\b/.test(command)) {
|
|
return { risk: "network", labels: [] };
|
|
}
|
|
return { risk: "normal", labels: [] };
|
|
}
|
|
|
|
/**
|
|
* Classify a tool call under a named execution profile.
|
|
*
|
|
* Purpose: produce deterministic allow/deny/risk metadata before tool calls are
|
|
* executed, without coupling policy consumers to queue-mode implementation
|
|
* details.
|
|
*
|
|
* Consumer: auto/headless safety gates and execution evidence writers.
|
|
*/
|
|
export function classifyExecutionPolicyCall(profileId, toolName, input = "") {
|
|
const profile = resolveExecutionPolicyProfile(profileId);
|
|
if (profile.id === "restricted") {
|
|
const queueDecision = shouldBlockQueueExecutionInSnapshot(
|
|
{
|
|
verifiedDepthMilestones: [],
|
|
activeQueuePhase: true,
|
|
pendingGateId: null,
|
|
},
|
|
toolName,
|
|
input,
|
|
true,
|
|
);
|
|
if (queueDecision.block) {
|
|
return {
|
|
profile: profile.id,
|
|
permissionProfile: profile.permissionProfile,
|
|
allowed: false,
|
|
reason: queueDecision.reason,
|
|
risk: "mutation",
|
|
destructiveLabels: [],
|
|
};
|
|
}
|
|
}
|
|
const bashRisk =
|
|
toolName === "bash"
|
|
? classifyBashRisk(input)
|
|
: { risk: "normal", labels: [] };
|
|
const allowed =
|
|
profile.id === "unrestricted" ||
|
|
bashRisk.risk !== "destructive" ||
|
|
profile.mutation !== "planning-artifacts-only";
|
|
return {
|
|
profile: profile.id,
|
|
permissionProfile: profile.permissionProfile,
|
|
allowed,
|
|
reason: allowed ? "allowed" : "destructive command requires approval",
|
|
risk: bashRisk.risk,
|
|
destructiveLabels: bashRisk.labels,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract the policy-relevant input string from a tool-call event.
|
|
*
|
|
* Purpose: avoid leaking full structured tool payloads into policy logs while
|
|
* preserving the command or path needed for deterministic classification.
|
|
*
|
|
* Consumer: execution-policy journal events.
|
|
*/
|
|
export function extractExecutionPolicyInput(toolName, input) {
|
|
if (!input || typeof input !== "object") return "";
|
|
if (toolName === "bash") return String(input.command ?? "");
|
|
if (toolName === "write" || toolName === "edit") {
|
|
return String(input.path ?? "");
|
|
}
|
|
if (toolName === "sf_exec") return String(input.script ?? "");
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Build a journal entry for a tool-call execution-policy decision.
|
|
*
|
|
* Purpose: make policy decisions inspectable before broad enforcement, matching
|
|
* the comparison-survey direction from Codex/Crush without changing runtime
|
|
* permissions in this slice.
|
|
*
|
|
* Consumer: SF autonomous mode tool_call hook.
|
|
*/
|
|
export function buildExecutionPolicyJournalEntry(args) {
|
|
const input = extractExecutionPolicyInput(
|
|
args.event.toolName,
|
|
args.event.input,
|
|
);
|
|
const decision = classifyExecutionPolicyCall(
|
|
args.profileId,
|
|
args.event.toolName,
|
|
input,
|
|
);
|
|
return {
|
|
ts: args.now?.() ?? new Date().toISOString(),
|
|
flowId:
|
|
args.flowId ??
|
|
`execution-policy:${args.event.toolCallId ?? args.event.toolName}`,
|
|
seq: 0,
|
|
eventType: "execution-policy-decision",
|
|
unitType: args.unit?.type,
|
|
unitId: args.unit?.id,
|
|
data: {
|
|
toolCallId: args.event.toolCallId,
|
|
toolName: args.event.toolName,
|
|
input,
|
|
decision,
|
|
},
|
|
};
|
|
}
|