feat: add execution policy profiles

This commit is contained in:
Mikael Hugo 2026-05-07 18:21:47 +02:00
parent b0fce94f9e
commit e9df932234
3 changed files with 174 additions and 5 deletions

View file

@ -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:

View file

@ -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,
};
}

View file

@ -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"]);
});
});