feat: add execution policy profiles
This commit is contained in:
parent
b0fce94f9e
commit
e9df932234
3 changed files with 174 additions and 5 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
48
src/resources/extensions/sf/tests/execution-policy.test.mjs
Normal file
48
src/resources/extensions/sf/tests/execution-policy.test.mjs
Normal 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"]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue