@dieulc/workflow 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -2
- package/edit-policy.ts +955 -0
- package/index.ts +389 -6
- package/package.json +3 -2
- package/permissions.ts +25 -5
package/index.ts
CHANGED
|
@@ -110,6 +110,25 @@ import {
|
|
|
110
110
|
type PendingCommitFlow,
|
|
111
111
|
type StepCommitCandidate,
|
|
112
112
|
} from "./commit-policy.ts";
|
|
113
|
+
import {
|
|
114
|
+
buildWriteDenyReason,
|
|
115
|
+
buildWriteGuidanceMessage,
|
|
116
|
+
classifyWriteCall,
|
|
117
|
+
decideWriteGate,
|
|
118
|
+
describeWritePolicyMode,
|
|
119
|
+
isWriteCallAllowed,
|
|
120
|
+
normalizeWritePath,
|
|
121
|
+
parseWriteGateChoice,
|
|
122
|
+
readWritePolicyConfig,
|
|
123
|
+
sanitizeWriteInstruction,
|
|
124
|
+
writePolicyArgumentCompletions,
|
|
125
|
+
writePolicyConfigPaths,
|
|
126
|
+
writeWritePolicyConfig,
|
|
127
|
+
writeGateOptions,
|
|
128
|
+
WRITE_INSTRUCTION_PROMPT_PLACEHOLDER,
|
|
129
|
+
WRITE_INSTRUCTION_PROMPT_TITLE,
|
|
130
|
+
type WritePolicyMode,
|
|
131
|
+
} from "./edit-policy.ts";
|
|
113
132
|
import {
|
|
114
133
|
isGitRepo,
|
|
115
134
|
isGitRepoSync,
|
|
@@ -908,14 +927,29 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
908
927
|
commitMode = "never";
|
|
909
928
|
}
|
|
910
929
|
const policy = ` · commits:${commitMode}`;
|
|
930
|
+
// Write-policy surfacing (workflow.writePolicy): the active file-write
|
|
931
|
+
// permission, visible at a glance like the commit policy. Read live so a
|
|
932
|
+
// /write-policy change shows up without a reload. It only applies in
|
|
933
|
+
// Default mode, so it is omitted in the other modes.
|
|
934
|
+
let writeMode: WritePolicyMode = "allow";
|
|
935
|
+
try {
|
|
936
|
+
writeMode = readWritePolicyConfig(
|
|
937
|
+
(ctx as any).cwd ?? process.cwd(),
|
|
938
|
+
getAgentDir(),
|
|
939
|
+
).config.mode;
|
|
940
|
+
} catch {
|
|
941
|
+
writeMode = "allow";
|
|
942
|
+
}
|
|
943
|
+
const writePolicy =
|
|
944
|
+
workflowMode === null ? ` · writes:${writeMode}` : "";
|
|
911
945
|
const staleMarker = todoStale ? " ⚠" : "";
|
|
912
946
|
const done = todoItems.filter((t) => isDone(t)).length;
|
|
913
947
|
const total = todoItems.length;
|
|
914
948
|
if (workflowMode === "build") {
|
|
915
949
|
const label =
|
|
916
950
|
total > 0
|
|
917
|
-
? `▶ build ${done}/${total}${staleMarker}${suffix}${policy}`
|
|
918
|
-
: `▶ build${suffix}${policy}`;
|
|
951
|
+
? `▶ build ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}`
|
|
952
|
+
: `▶ build${suffix}${policy}${writePolicy}`;
|
|
919
953
|
ctx.ui.setStatus("workflow", ctx.ui.theme.fg("accent", label));
|
|
920
954
|
} else if (workflowMode === "plan") {
|
|
921
955
|
const base = plannotatorActive
|
|
@@ -923,21 +957,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
923
957
|
: "⏸ plan";
|
|
924
958
|
const label =
|
|
925
959
|
total > 0
|
|
926
|
-
? `${base} ${done}/${total}${staleMarker}${suffix}${policy}`
|
|
927
|
-
: `${base}${suffix}${policy}`;
|
|
960
|
+
? `${base} ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}`
|
|
961
|
+
: `${base}${suffix}${policy}${writePolicy}`;
|
|
928
962
|
ctx.ui.setStatus("workflow", ctx.ui.theme.fg("warning", label));
|
|
929
963
|
} else if (total > 0) {
|
|
930
964
|
ctx.ui.setStatus(
|
|
931
965
|
"workflow",
|
|
932
966
|
ctx.ui.theme.fg(
|
|
933
967
|
"muted",
|
|
934
|
-
`◦ ${done}/${total}${staleMarker}${suffix}${policy}`,
|
|
968
|
+
`◦ ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}`,
|
|
935
969
|
),
|
|
936
970
|
);
|
|
937
971
|
} else {
|
|
938
972
|
ctx.ui.setStatus(
|
|
939
973
|
"workflow",
|
|
940
|
-
ctx.ui.theme.fg("muted", `◦ commits:${commitMode}`),
|
|
974
|
+
ctx.ui.theme.fg("muted", `◦ commits:${commitMode}${writePolicy}`),
|
|
941
975
|
);
|
|
942
976
|
}
|
|
943
977
|
if (todoItems.length > 0) {
|
|
@@ -1597,6 +1631,103 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1597
1631
|
commitPolicyArgumentCompletions(prefix),
|
|
1598
1632
|
});
|
|
1599
1633
|
|
|
1634
|
+
// ── /write-policy: view/set the write permission (workflow.writePolicy) ──
|
|
1635
|
+
// Available in every mode: it only writes settings JSON; the gate itself
|
|
1636
|
+
// lives in tool_call and applies in Default mode only.
|
|
1637
|
+
pi.registerCommand("write-policy", {
|
|
1638
|
+
description:
|
|
1639
|
+
"View or set the write permission (what the agent may modify in Default mode): /write-policy allow|ask|never [global|project]",
|
|
1640
|
+
handler: async (args, ctx) => {
|
|
1641
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
1642
|
+
const agentDir = getAgentDir();
|
|
1643
|
+
const tokens = String(args || "")
|
|
1644
|
+
.trim()
|
|
1645
|
+
.split(/\s+/)
|
|
1646
|
+
.filter(Boolean);
|
|
1647
|
+
const sub = (tokens[0] || "").toLowerCase();
|
|
1648
|
+
const scope: "global" | "project" =
|
|
1649
|
+
tokens.includes("project") && !tokens.includes("global")
|
|
1650
|
+
? "project"
|
|
1651
|
+
: "global";
|
|
1652
|
+
|
|
1653
|
+
if (!sub || sub === "status") {
|
|
1654
|
+
const resolved = readWritePolicyConfig(cwd, agentDir);
|
|
1655
|
+
const paths = writePolicyConfigPaths(cwd, agentDir);
|
|
1656
|
+
const where =
|
|
1657
|
+
resolved.sources.mode === "default"
|
|
1658
|
+
? "default — no settings file supplies it"
|
|
1659
|
+
: `${resolved.sources.mode} settings`;
|
|
1660
|
+
const lines = [
|
|
1661
|
+
`Write policy: ${resolved.config.mode} (${where})`,
|
|
1662
|
+
` ${describeWritePolicyMode(resolved.config.mode)}`,
|
|
1663
|
+
" Applies to Default mode only — Plan mode is read-only already, /build is the execution escape hatch.",
|
|
1664
|
+
];
|
|
1665
|
+
if (resolved.config.allowedPaths.length > 0) {
|
|
1666
|
+
lines.push(
|
|
1667
|
+
`Always-allowed paths (${resolved.config.allowedPaths.length}):`,
|
|
1668
|
+
...resolved.config.allowedPaths.map((p) => ` ${p}`),
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
if (resolved.config.allowedCommands.length > 0) {
|
|
1672
|
+
lines.push(
|
|
1673
|
+
`Always-allowed commands (${resolved.config.allowedCommands.length}):`,
|
|
1674
|
+
...resolved.config.allowedCommands.map((c) => ` ${c}`),
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
if (resolved.config.mutatingTools.length > 0) {
|
|
1678
|
+
lines.push(
|
|
1679
|
+
`Extra write tools (${resolved.config.mutatingTools.length}):`,
|
|
1680
|
+
...resolved.config.mutatingTools.map((t) => ` ${t}`),
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1683
|
+
lines.push(
|
|
1684
|
+
`Global settings: ${paths.global}`,
|
|
1685
|
+
`Project settings: ${paths.project}`,
|
|
1686
|
+
"Usage: /write-policy allow|ask|never [global|project]",
|
|
1687
|
+
);
|
|
1688
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
1689
|
+
updateStatus(ctx as any);
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
if (sub !== "allow" && sub !== "ask" && sub !== "never") {
|
|
1694
|
+
ctx.ui.notify(
|
|
1695
|
+
[
|
|
1696
|
+
"Usage: /write-policy allow|ask|never [global|project]",
|
|
1697
|
+
" allow the agent may write files freely in Default mode",
|
|
1698
|
+
" ask every file write asks first; granted paths/commands are remembered",
|
|
1699
|
+
" never the agent may not write files — you write, or switch to /build",
|
|
1700
|
+
].join("\n"),
|
|
1701
|
+
"info",
|
|
1702
|
+
);
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const res = writeWritePolicyConfig(scope, cwd, agentDir, {
|
|
1707
|
+
mode: sub as WritePolicyMode,
|
|
1708
|
+
});
|
|
1709
|
+
if (!res.ok) {
|
|
1710
|
+
ctx.ui.notify(
|
|
1711
|
+
`Could not write write-policy settings: ${res.error ?? "unknown error"}`,
|
|
1712
|
+
"error",
|
|
1713
|
+
);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
ctx.ui.notify(
|
|
1717
|
+
`Write policy → ${sub} (${scope})\n${describeWritePolicyMode(
|
|
1718
|
+
sub as WritePolicyMode,
|
|
1719
|
+
)}\nSettings: ${res.path}`,
|
|
1720
|
+
"info",
|
|
1721
|
+
);
|
|
1722
|
+
updateStatus(ctx as any);
|
|
1723
|
+
},
|
|
1724
|
+
// pi-tui full-argument contract (see writePolicyArgumentCompletions):
|
|
1725
|
+
// modes first, then the write scope, with values that replace the whole
|
|
1726
|
+
// typed argument text ("ask project", never bare "project").
|
|
1727
|
+
getArgumentCompletions: (prefix: string) =>
|
|
1728
|
+
writePolicyArgumentCompletions(prefix),
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1600
1731
|
pi.registerCommand("review-prompt", {
|
|
1601
1732
|
description:
|
|
1602
1733
|
"Edit the reviewer's guidance (framework alignment / best practice) used by Review Mode",
|
|
@@ -6588,6 +6719,249 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6588
6719
|
}
|
|
6589
6720
|
}
|
|
6590
6721
|
|
|
6722
|
+
/**
|
|
6723
|
+
* Write permission gate (workflow.writePolicy) — Default mode only.
|
|
6724
|
+
*
|
|
6725
|
+
* The Workflow extension's three modes are Plan → Build → Default. Plan
|
|
6726
|
+
* mode is read-only and Build mode is unrestricted by design, but DEFAULT
|
|
6727
|
+
* mode was ALSO unrestricted: `resolveSessionRole()` returns undefined
|
|
6728
|
+
* there, so the tool_call gate has no branch to take and the agent edits
|
|
6729
|
+
* files without asking. This gate is the missing permission knob.
|
|
6730
|
+
*
|
|
6731
|
+
* It is deliberately INDEPENDENT of the role gate and of the commit
|
|
6732
|
+
* policy: those govern *what* a role may do, this governs *whether the
|
|
6733
|
+
* user permits a write at all*.
|
|
6734
|
+
*
|
|
6735
|
+
* Returns a block object when the call must not proceed, undefined when it
|
|
6736
|
+
* may. Never throws — a throwing tool_call handler blocks the tool as a
|
|
6737
|
+
* fail-safe, so every failure path here fails CLOSED with an actionable
|
|
6738
|
+
* reason (the reason is what the model reads).
|
|
6739
|
+
*/
|
|
6740
|
+
async function writePolicyGate(
|
|
6741
|
+
event: any,
|
|
6742
|
+
ctx: any,
|
|
6743
|
+
cwd: string,
|
|
6744
|
+
): Promise<{ block: true; reason: string } | undefined> {
|
|
6745
|
+
try {
|
|
6746
|
+
// Scope: the top-level Default-mode session only.
|
|
6747
|
+
// - Plan mode has its own stricter rules (writes only to .pi/plans/).
|
|
6748
|
+
// - Build mode is the deliberate "go ahead" execution escape hatch.
|
|
6749
|
+
// - A spawned child carries PI_WORKFLOW_ROLE and is already read-only
|
|
6750
|
+
// through the role gate (and has no UI to answer a prompt).
|
|
6751
|
+
if (workflowMode !== null) return undefined;
|
|
6752
|
+
if (
|
|
6753
|
+
resolveSessionRole({
|
|
6754
|
+
envRole: process.env[WORKFLOW_ROLE_ENV],
|
|
6755
|
+
workflowMode,
|
|
6756
|
+
}) !== undefined
|
|
6757
|
+
) {
|
|
6758
|
+
return undefined;
|
|
6759
|
+
}
|
|
6760
|
+
|
|
6761
|
+
const resolved = readWritePolicyConfig(cwd, getAgentDir());
|
|
6762
|
+
const config = resolved.config;
|
|
6763
|
+
if (config.mode === "allow") return undefined;
|
|
6764
|
+
|
|
6765
|
+
const classification = classifyWriteCall(
|
|
6766
|
+
event.toolName,
|
|
6767
|
+
event.input,
|
|
6768
|
+
config,
|
|
6769
|
+
);
|
|
6770
|
+
if (!classification.mutating) return undefined;
|
|
6771
|
+
|
|
6772
|
+
// The workflow's own plan directory stays writable in every mode — a
|
|
6773
|
+
// session that leaves Plan mode mid-flow must not be stranded.
|
|
6774
|
+
if (
|
|
6775
|
+
classification.paths.length > 0 &&
|
|
6776
|
+
classification.paths.every((p) => isPlanWritePath(p, cwd))
|
|
6777
|
+
) {
|
|
6778
|
+
return undefined;
|
|
6779
|
+
}
|
|
6780
|
+
|
|
6781
|
+
const allowed = isWriteCallAllowed(classification, config, cwd);
|
|
6782
|
+
const decision = decideWriteGate({
|
|
6783
|
+
mode: config.mode,
|
|
6784
|
+
mutating: true,
|
|
6785
|
+
allowed,
|
|
6786
|
+
});
|
|
6787
|
+
if (decision === "allow") return undefined;
|
|
6788
|
+
|
|
6789
|
+
const target =
|
|
6790
|
+
classification.paths.length > 0
|
|
6791
|
+
? classification.paths.join(", ")
|
|
6792
|
+
: (classification.key ?? String(event.toolName));
|
|
6793
|
+
const why = classification.reason
|
|
6794
|
+
? ` (${classification.reason})`
|
|
6795
|
+
: "";
|
|
6796
|
+
|
|
6797
|
+
if (decision === "block") {
|
|
6798
|
+
return {
|
|
6799
|
+
block: true,
|
|
6800
|
+
reason:
|
|
6801
|
+
`Write policy is 'never' (workflow.writePolicy): the agent may not modify files in Default mode${why}. ` +
|
|
6802
|
+
"Do not retry. Ask the user how to proceed, or tell them to switch to Build mode (/build) — that is the approved-execution path. " +
|
|
6803
|
+
`The user can also change the policy with /write-policy (ask to be prompted, allow to write freely). Target: ${target}`,
|
|
6804
|
+
};
|
|
6805
|
+
}
|
|
6806
|
+
|
|
6807
|
+
// decision === "ask" — prompt the user.
|
|
6808
|
+
const ui = ctx?.ui;
|
|
6809
|
+
if (!ui || !ctx.hasUI) {
|
|
6810
|
+
return {
|
|
6811
|
+
block: true,
|
|
6812
|
+
reason:
|
|
6813
|
+
`Write policy is 'ask' (workflow.writePolicy): writing needs user approval${why}, but no interactive UI is available. ` +
|
|
6814
|
+
"Do not retry. Report what you would change and let the user apply it, or tell them to switch to Build mode (/build). " +
|
|
6815
|
+
`Target: ${target}`,
|
|
6816
|
+
};
|
|
6817
|
+
}
|
|
6818
|
+
|
|
6819
|
+
const isPathGrant = classification.paths.length > 0;
|
|
6820
|
+
const options = writeGateOptions(isPathGrant ? "path" : "command");
|
|
6821
|
+
let sel: unknown;
|
|
6822
|
+
try {
|
|
6823
|
+
sel = await ui.select(
|
|
6824
|
+
`Write policy 'ask': allow this write${why}?`,
|
|
6825
|
+
options,
|
|
6826
|
+
);
|
|
6827
|
+
} catch (_e) {
|
|
6828
|
+
void _e;
|
|
6829
|
+
sel = undefined;
|
|
6830
|
+
}
|
|
6831
|
+
const choice = parseWriteGateChoice(sel);
|
|
6832
|
+
if (choice === "allow-once") return undefined;
|
|
6833
|
+
|
|
6834
|
+
if (choice === "always") {
|
|
6835
|
+
// Persist the grant so the same target stops asking. A path grant
|
|
6836
|
+
// stores the absolute path; a command grant stores the command key.
|
|
6837
|
+
const patch = isPathGrant
|
|
6838
|
+
? {
|
|
6839
|
+
allowedPaths: classification.paths.map((p) =>
|
|
6840
|
+
normalizeWritePath(p, cwd),
|
|
6841
|
+
),
|
|
6842
|
+
}
|
|
6843
|
+
: { allowedCommands: classification.key ? [classification.key] : [] };
|
|
6844
|
+
const written = writeWritePolicyConfig("global", cwd, getAgentDir(), patch);
|
|
6845
|
+
if (written.ok) {
|
|
6846
|
+
try {
|
|
6847
|
+
(ctx as any).ui?.notify?.(
|
|
6848
|
+
`Write policy: always allowed ${isPathGrant ? classification.paths.join(", ") : classification.key}`,
|
|
6849
|
+
"info",
|
|
6850
|
+
);
|
|
6851
|
+
} catch (_e) {
|
|
6852
|
+
void _e;
|
|
6853
|
+
}
|
|
6854
|
+
return undefined;
|
|
6855
|
+
}
|
|
6856
|
+
// The grant could not be persisted — do not silently allow a write
|
|
6857
|
+
// the user asked to remember. Fall through to the denial below.
|
|
6858
|
+
return {
|
|
6859
|
+
block: true,
|
|
6860
|
+
reason:
|
|
6861
|
+
`Write policy 'ask': could not save the permission grant (${written.error ?? "unknown error"}), so the write was not allowed. ` +
|
|
6862
|
+
`Target: ${target}`,
|
|
6863
|
+
};
|
|
6864
|
+
}
|
|
6865
|
+
|
|
6866
|
+
// ── Denial ──
|
|
6867
|
+
// "Deny with instructions…" lets the user say WHY in one sentence
|
|
6868
|
+
// ("only explore and propose a plan, do not edit files yet"), so the
|
|
6869
|
+
// model can change course instead of stopping cold. The sentence
|
|
6870
|
+
// travels through TWO channels:
|
|
6871
|
+
// 1. the block reason (the tool result the model reads right now), and
|
|
6872
|
+
// 2. a USER message (a steer turn the model must obey).
|
|
6873
|
+
// Anthropic shipped the comment-only version and then shipped the bug
|
|
6874
|
+
// where the model ignored it (anthropics/claude-code#89231, #52824):
|
|
6875
|
+
// a tool result is a fact the model may reinterpret, a user turn is an
|
|
6876
|
+
// instruction it must follow.
|
|
6877
|
+
let instruction = "";
|
|
6878
|
+
if (choice === "deny-with-instructions") {
|
|
6879
|
+
const inputFn = (ui as any).input;
|
|
6880
|
+
if (typeof inputFn === "function") {
|
|
6881
|
+
let typed: unknown;
|
|
6882
|
+
try {
|
|
6883
|
+
typed = await inputFn.call(
|
|
6884
|
+
ui,
|
|
6885
|
+
WRITE_INSTRUCTION_PROMPT_TITLE,
|
|
6886
|
+
WRITE_INSTRUCTION_PROMPT_PLACEHOLDER,
|
|
6887
|
+
);
|
|
6888
|
+
} catch (_e) {
|
|
6889
|
+
void _e;
|
|
6890
|
+
typed = undefined;
|
|
6891
|
+
}
|
|
6892
|
+
instruction = sanitizeWriteInstruction(typed);
|
|
6893
|
+
}
|
|
6894
|
+
// No input dialog, a cancelled dialog, or empty text → plain denial
|
|
6895
|
+
// below. A cancel is never an implicit allow.
|
|
6896
|
+
}
|
|
6897
|
+
|
|
6898
|
+
const reason = buildWriteDenyReason({
|
|
6899
|
+
target,
|
|
6900
|
+
why: classification.reason,
|
|
6901
|
+
instruction,
|
|
6902
|
+
});
|
|
6903
|
+
|
|
6904
|
+
if (instruction) {
|
|
6905
|
+
// Channel 2: a user-role message. `steer` queues it while the agent
|
|
6906
|
+
// is running; it lands after the current tool batch, before the next
|
|
6907
|
+
// model call, as a genuine user turn.
|
|
6908
|
+
//
|
|
6909
|
+
// Guard: while a review round is active the extension's `input`
|
|
6910
|
+
// handler routes incoming user text to the REVIEWER, so steering
|
|
6911
|
+
// here would hand the instruction to the wrong agent. The tool result
|
|
6912
|
+
// still carries it, so skip the steer rather than misroute.
|
|
6913
|
+
const rt = reviewRuntime;
|
|
6914
|
+
const wouldRouteToReviewer = shouldRouteInputToReviewer({
|
|
6915
|
+
reviewEnabled: reviewEnabled(),
|
|
6916
|
+
reviewActive: rt?.isRoundActive() === true,
|
|
6917
|
+
hasReviewerSession: rt?.isAlive() === true,
|
|
6918
|
+
});
|
|
6919
|
+
if (!wouldRouteToReviewer) {
|
|
6920
|
+
try {
|
|
6921
|
+
(pi as any).sendUserMessage?.(
|
|
6922
|
+
buildWriteGuidanceMessage({ target, instruction }),
|
|
6923
|
+
{ deliverAs: "steer" },
|
|
6924
|
+
);
|
|
6925
|
+
} catch (_e) {
|
|
6926
|
+
void _e;
|
|
6927
|
+
}
|
|
6928
|
+
}
|
|
6929
|
+
// Audit trail — transcript only (appendEntry is never sent to the LLM).
|
|
6930
|
+
try {
|
|
6931
|
+
pi.appendEntry("workflow-write-denial", {
|
|
6932
|
+
tool: String(event.toolName ?? ""),
|
|
6933
|
+
target,
|
|
6934
|
+
instruction,
|
|
6935
|
+
at: Date.now(),
|
|
6936
|
+
});
|
|
6937
|
+
} catch (_e) {
|
|
6938
|
+
void _e;
|
|
6939
|
+
}
|
|
6940
|
+
}
|
|
6941
|
+
|
|
6942
|
+
return {
|
|
6943
|
+
block: true,
|
|
6944
|
+
reason,
|
|
6945
|
+
// NOTE: no `terminate` here. pi consults `terminate` only AFTER the
|
|
6946
|
+
// whole tool batch has run (`executeToolCallsParallel` prepares and
|
|
6947
|
+
// runs every call before `shouldTerminateToolBatch()`), so it cannot
|
|
6948
|
+
// suppress a sibling write in the same batch — it only decides
|
|
6949
|
+
// whether the agent gets ANOTHER LLM turn. With it set, a plain Deny
|
|
6950
|
+
// would end the run with no model acknowledgement at all: a
|
|
6951
|
+
// behaviour change nobody asked for. The guided path gets its next
|
|
6952
|
+
// turn from the queued steer message instead.
|
|
6953
|
+
};
|
|
6954
|
+
} catch (e: unknown) {
|
|
6955
|
+
// Fail closed: a broken policy read must not become an open door.
|
|
6956
|
+
return {
|
|
6957
|
+
block: true,
|
|
6958
|
+
reason:
|
|
6959
|
+
`Write policy (workflow.writePolicy) could not be evaluated (${e instanceof Error ? e.message : String(e)}), so the write was blocked. ` +
|
|
6960
|
+
"Do not retry. Ask the user to switch to Build mode (/build) or to fix the policy with /write-policy.",
|
|
6961
|
+
};
|
|
6962
|
+
}
|
|
6963
|
+
}
|
|
6964
|
+
|
|
6591
6965
|
// Block edits/writes + bash gating
|
|
6592
6966
|
pi.on("tool_call", async (event, ctx) => {
|
|
6593
6967
|
const cwd = (ctx as ExtensionContext).cwd;
|
|
@@ -6721,6 +7095,15 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6721
7095
|
reason: `${gateRole} role: ${event.toolName} blocked — this role is read-only.`,
|
|
6722
7096
|
} as any;
|
|
6723
7097
|
}
|
|
7098
|
+
// ── Write permission gate (workflow.writePolicy) ── Independent of the
|
|
7099
|
+
// role gate and of the commit policy: it governs whether the USER
|
|
7100
|
+
// permits a write at all. It only applies in Default mode — Plan has its
|
|
7101
|
+
// own stricter rules and Build is the approved-execution escape hatch —
|
|
7102
|
+
// and it runs last so it only sees calls nothing else claimed.
|
|
7103
|
+
if (workflowMode === null) {
|
|
7104
|
+
const writeBlock = await writePolicyGate(event, ctx, cwd);
|
|
7105
|
+
if (writeBlock) return writeBlock as any;
|
|
7106
|
+
}
|
|
6724
7107
|
// In build mode, ensure todos exist before first real edit
|
|
6725
7108
|
if (
|
|
6726
7109
|
(event.toolName === "edit" || event.toolName === "write") &&
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dieulc/workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
|
|
7
7
|
"description": "Plan↔Build mode with an independent Review Mode reviewer, questionnaire, subagent explore, todos and shadow-checkpoint rewind for Pi.",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts review.test.ts review-ui.test.ts commit-policy.test.ts",
|
|
10
|
+
"test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts review.test.ts review-ui.test.ts commit-policy.test.ts edit-policy.test.ts",
|
|
11
11
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
12
12
|
},
|
|
13
13
|
"repository": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"utils.ts",
|
|
28
28
|
"checkpoint.ts",
|
|
29
29
|
"commit-policy.ts",
|
|
30
|
+
"edit-policy.ts",
|
|
30
31
|
"roles.ts",
|
|
31
32
|
"profiles.ts",
|
|
32
33
|
"permissions.ts",
|
package/permissions.ts
CHANGED
|
@@ -424,8 +424,14 @@ const DESTRUCTIVE_PATTERNS: RegExp[] = [
|
|
|
424
424
|
/(^|\s)(sh|bash|dash|zsh|fish|ksh|csh|tcsh|pwsh|powershell|cmd|cscript|wscript|perl|ruby)(\.exe)?(\s|$)/i,
|
|
425
425
|
];
|
|
426
426
|
|
|
427
|
-
/**
|
|
428
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Writing/formatting flags that must never reach a verify-class command.
|
|
429
|
+
*
|
|
430
|
+
* Exported for the write policy's command classification (edit-policy.ts):
|
|
431
|
+
* a verify-class command that carries one of these still rewrites files
|
|
432
|
+
* (`eslint --fix`), so the write gate must treat it as a write.
|
|
433
|
+
*/
|
|
434
|
+
export const DENIED_FLAG_RE =
|
|
429
435
|
/(^|\s)(--fix|--fix-dry-run|--write|--update|--update-snapshots?|--updateSnapshot|--test-update-snapshots|--watch|--watchAll|--inspect|--inspect-brk|--in-place|--output)(\s|$|=)/i;
|
|
430
436
|
|
|
431
437
|
/**
|
|
@@ -547,6 +553,22 @@ function packageScriptArg(segment: string): string | undefined {
|
|
|
547
553
|
const NULL_DEVICE_REDIRECT_RE =
|
|
548
554
|
/(^|[\s;&|()])(?:[12]|&)?>>?\s*\/dev\/null(?=$|[\s;&|)])/g;
|
|
549
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Strip harmless redirection tokens from a command: fd duplications
|
|
558
|
+
* (`2>&1` / `1>&2`) and null-device targets (`> /dev/null`). Neither writes
|
|
559
|
+
* a file the user can see, so both are removed before the command is
|
|
560
|
+
* classified by its first word — otherwise `2>/dev/null ls` would look like
|
|
561
|
+
* an unknown binary and be treated as a write.
|
|
562
|
+
*
|
|
563
|
+
* Exported for the write policy's command classification (edit-policy.ts).
|
|
564
|
+
*/
|
|
565
|
+
export function stripHarmlessRedirects(command: unknown): string {
|
|
566
|
+
const raw = typeof command === "string" ? command : "";
|
|
567
|
+
return raw
|
|
568
|
+
.replace(/\d*>&\d+/g, " ")
|
|
569
|
+
.replace(NULL_DEVICE_REDIRECT_RE, "$1");
|
|
570
|
+
}
|
|
571
|
+
|
|
550
572
|
/**
|
|
551
573
|
* Why a command's syntax cannot be classified (`null` = the syntax is fine).
|
|
552
574
|
* Mirrors the order the gate has always used, so a refusal can name the first
|
|
@@ -556,9 +578,7 @@ export function forbiddenSyntaxReason(command: unknown): string | null {
|
|
|
556
578
|
const raw = typeof command === "string" ? command : "";
|
|
557
579
|
// `2>&1` / `1>&2` are fd duplications, not file writes; `/dev/null` targets
|
|
558
580
|
// are a bit bucket, not a file.
|
|
559
|
-
const scrubbed = raw
|
|
560
|
-
.replace(/\d*>&\d+/g, " ")
|
|
561
|
-
.replace(NULL_DEVICE_REDIRECT_RE, "$1");
|
|
581
|
+
const scrubbed = stripHarmlessRedirects(raw);
|
|
562
582
|
if (scrubbed.replace(/&&/g, " ").includes("&"))
|
|
563
583
|
return "a background `&` (only `&&` chains are allowed)";
|
|
564
584
|
if (raw.includes("$(")) return "command substitution `$(...)`";
|