diff --git a/docs/records/2026-05-07-cli-agent-code-survey.md b/docs/records/2026-05-07-cli-agent-code-survey.md index 6d2162044..1bb87e78e 100644 --- a/docs/records/2026-05-07-cli-agent-code-survey.md +++ b/docs/records/2026-05-07-cli-agent-code-survey.md @@ -383,6 +383,19 @@ The first DB-backed retrieval slice landed with schema v41: Next slices should wrap `search_and_read` and `fetch_page` results in the same evidence contract before using them for planning. +The first execution-policy vocabulary slice also landed: + +- `execution-policy.js` defines named `plan`, `build`, and `unrestricted` + profiles with filesystem, network, git, and mutation posture. +- The `plan` profile reuses the existing queue-mode write gate, so read-only + commands and `.sf/` planning artifacts are allowed while source mutations are + denied. +- The `build` profile records destructive bash risk labels from the existing + destructive-command classifier without changing runtime enforcement yet. + +Next slices should attach these profile decisions to tool-call events, UOK +evidence, and headless JSON output before broad enforcement. + ## Resulting Direction Forge should absorb proven patterns into UOK and the existing DB-first runtime: diff --git a/src/resources/extensions/sf/execution-policy.js b/src/resources/extensions/sf/execution-policy.js index 65397f694..039461260 100644 --- a/src/resources/extensions/sf/execution-policy.js +++ b/src/resources/extensions/sf/execution-policy.js @@ -1,8 +1,116 @@ /** - * execution-policy.ts — ExecutionPolicy interface. + * execution-policy.js — named execution policy profiles for SF tool calls. * - * Defines the policy layer that governs model selection, verification, - * recovery, and closeout for each execution step. Imports only from - * the leaf-node engine-types. + * Purpose: give auto/headless a Codex/Crush-style vocabulary for filesystem, + * network, git, and mutation posture before those policies are enforced at + * every tool boundary. + * + * Consumer: write-gate, auto-mode, headless event DTOs, and future DB evidence + * rows that need stable policy names instead of ad hoc booleans. */ -export {}; + +import { shouldBlockQueueExecutionInSnapshot } from "./bootstrap/write-gate.js"; +import { classifyCommand } from "./safety/destructive-guard.js"; + +export const EXECUTION_POLICY_PROFILES = { + plan: { + id: "plan", + filesystem: "read-mostly", + network: "read-only", + git: "read-only", + mutation: "planning-artifacts-only", + }, + build: { + id: "build", + filesystem: "workspace-write", + network: "allowed", + git: "normal", + mutation: "workspace", + }, + unrestricted: { + id: "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) { + return EXECUTION_POLICY_PROFILES[profileId] ?? EXECUTION_POLICY_PROFILES.plan; +} + +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 === "plan") { + const queueDecision = shouldBlockQueueExecutionInSnapshot( + { + verifiedDepthMilestones: [], + activeQueuePhase: true, + pendingGateId: null, + }, + toolName, + input, + true, + ); + if (queueDecision.block) { + return { + profile: profile.id, + 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, + allowed, + reason: allowed ? "allowed" : "destructive command requires approval", + risk: bashRisk.risk, + destructiveLabels: bashRisk.labels, + }; +} diff --git a/src/resources/extensions/sf/tests/execution-policy.test.mjs b/src/resources/extensions/sf/tests/execution-policy.test.mjs new file mode 100644 index 000000000..7470af41c --- /dev/null +++ b/src/resources/extensions/sf/tests/execution-policy.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { describe, test } from "vitest"; +import { + classifyExecutionPolicyCall, + resolveExecutionPolicyProfile, +} from "../execution-policy.js"; + +describe("execution policy profiles", () => { + test("resolveExecutionPolicyProfile_when_unknown_returns_plan_profile", () => { + assert.equal(resolveExecutionPolicyProfile("missing").id, "plan"); + }); + + test("classifyExecutionPolicyCall_plan_allows_read_only_bash", () => { + const decision = classifyExecutionPolicyCall("plan", "bash", "git status"); + + assert.equal(decision.allowed, true); + assert.equal(decision.profile, "plan"); + }); + + test("classifyExecutionPolicyCall_plan_blocks_source_write", () => { + const decision = classifyExecutionPolicyCall("plan", "write", "src/app.ts"); + + assert.equal(decision.allowed, false); + assert.match(decision.reason, /planning tool/i); + }); + + test("classifyExecutionPolicyCall_plan_allows_sf_artifact_write", () => { + const decision = classifyExecutionPolicyCall( + "plan", + "write", + ".sf/milestones/M001/CONTEXT.md", + ); + + assert.equal(decision.allowed, true); + }); + + test("classifyExecutionPolicyCall_build_marks_destructive_bash_but_allows_for_later_gate", () => { + const decision = classifyExecutionPolicyCall( + "build", + "bash", + "git reset --hard", + ); + + assert.equal(decision.allowed, true); + assert.equal(decision.risk, "destructive"); + assert.deepEqual(decision.destructiveLabels, ["hard reset"]); + }); +});