@dieulc/workflow 0.5.2 → 0.6.1

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/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",
@@ -4056,21 +4187,30 @@ export default function workflowExtension(pi: ExtensionAPI) {
4056
4187
  const lbl = ` ${box} ${roleIcon(roleName)} ${roleName} `;
4057
4188
  chips.push(
4058
4189
  i === currentTab
4059
- ? theme.bg("selectedBg", theme.fg("text", lbl))
4060
- : theme.fg(dirty ? "success" : "muted", lbl),
4190
+ ? theme.bg(
4191
+ "selectedBg",
4192
+ `${theme.fg("accent", "→")} ${theme.fg("text", lbl)}`,
4193
+ )
4194
+ : theme.fg(dirty ? "success" : "muted", ` ${lbl}`),
4061
4195
  );
4062
4196
  }
4063
4197
  const saveActivateLbl = " ✓ Save & activate ";
4064
4198
  const saveOnlyLbl = " 💾 Save only ";
4065
4199
  chips.push(
4066
4200
  isSaveActivate()
4067
- ? theme.bg("selectedBg", theme.fg("text", saveActivateLbl))
4068
- : theme.fg("success", saveActivateLbl),
4201
+ ? theme.bg(
4202
+ "selectedBg",
4203
+ `${theme.fg("accent", "→")} ${theme.fg("text", saveActivateLbl)}`,
4204
+ )
4205
+ : theme.fg("success", ` ${saveActivateLbl}`),
4069
4206
  );
4070
4207
  chips.push(
4071
4208
  currentTab === tabIds.length + 1
4072
- ? theme.bg("selectedBg", theme.fg("text", saveOnlyLbl))
4073
- : theme.fg("success", saveOnlyLbl),
4209
+ ? theme.bg(
4210
+ "selectedBg",
4211
+ `${theme.fg("accent", "→")} ${theme.fg("text", saveOnlyLbl)}`,
4212
+ )
4213
+ : theme.fg("success", ` ${saveOnlyLbl}`),
4074
4214
  );
4075
4215
  lines.push(` ${chips.join("")}`);
4076
4216
  lines.push("");
@@ -4860,15 +5000,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
4860
5000
  const lbl = ` ${box} ${roleIcon(r.name)} ${r.name} `;
4861
5001
  chips.push(
4862
5002
  i === currentTab
4863
- ? theme.bg("selectedBg", theme.fg("text", lbl))
4864
- : theme.fg(dirty ? "success" : "muted", lbl),
5003
+ ? theme.bg(
5004
+ "selectedBg",
5005
+ `${theme.fg("accent", "→")} ${theme.fg("text", lbl)}`,
5006
+ )
5007
+ : theme.fg(dirty ? "success" : "muted", ` ${lbl}`),
4865
5008
  );
4866
5009
  }
4867
5010
  const submitLbl = " ✓ Submit ";
4868
5011
  chips.push(
4869
5012
  isSubmitTab()
4870
- ? theme.bg("selectedBg", theme.fg("text", submitLbl))
4871
- : theme.fg("success", submitLbl),
5013
+ ? theme.bg(
5014
+ "selectedBg",
5015
+ `${theme.fg("accent", "→")} ${theme.fg("text", submitLbl)}`,
5016
+ )
5017
+ : theme.fg("success", ` ${submitLbl}`),
4872
5018
  );
4873
5019
  lines.push(` ${chips.join("")}`);
4874
5020
  lines.push("");
@@ -5599,17 +5745,28 @@ export default function workflowExtension(pi: ExtensionAPI) {
5599
5745
  const box = answered ? "■" : "□";
5600
5746
  const color = answered ? "success" : "muted";
5601
5747
  const text = ` ${box} ${lbl} `;
5748
+ // The active tab carries an explicit marker, not only the
5749
+ // selection fill. A fill alone is easy to miss in a host that
5750
+ // paints it in its own palette (the desktop app); the marker is
5751
+ // what says "this one" in every surface. The marker replaces
5752
+ // the two-space indent, so the labels stay aligned.
5602
5753
  const styled = active
5603
- ? theme.bg("selectedBg", theme.fg("text", text))
5604
- : theme.fg(color as any, text);
5754
+ ? theme.bg(
5755
+ "selectedBg",
5756
+ `${theme.fg("accent", "→")} ${theme.fg("text", text)}`,
5757
+ )
5758
+ : theme.fg(color as any, ` ${text}`);
5605
5759
  tabs.push(`${styled} `);
5606
5760
  }
5607
5761
  const canSubmit = allAnswered();
5608
5762
  const isSubmit = currentTab === questions.length;
5609
5763
  const submitText = " ✓ Submit ";
5610
5764
  const sStyled = isSubmit
5611
- ? theme.bg("selectedBg", theme.fg("text", submitText))
5612
- : theme.fg(canSubmit ? "success" : "dim", submitText);
5765
+ ? theme.bg(
5766
+ "selectedBg",
5767
+ `${theme.fg("accent", "→")} ${theme.fg("text", submitText)}`,
5768
+ )
5769
+ : theme.fg(canSubmit ? "success" : "dim", ` ${submitText}`);
5613
5770
  tabs.push(`${sStyled}`);
5614
5771
  // naive join wrapped
5615
5772
  lines.push(` ${tabs.join("")}`);
@@ -6588,6 +6745,249 @@ export default function workflowExtension(pi: ExtensionAPI) {
6588
6745
  }
6589
6746
  }
6590
6747
 
6748
+ /**
6749
+ * Write permission gate (workflow.writePolicy) — Default mode only.
6750
+ *
6751
+ * The Workflow extension's three modes are Plan → Build → Default. Plan
6752
+ * mode is read-only and Build mode is unrestricted by design, but DEFAULT
6753
+ * mode was ALSO unrestricted: `resolveSessionRole()` returns undefined
6754
+ * there, so the tool_call gate has no branch to take and the agent edits
6755
+ * files without asking. This gate is the missing permission knob.
6756
+ *
6757
+ * It is deliberately INDEPENDENT of the role gate and of the commit
6758
+ * policy: those govern *what* a role may do, this governs *whether the
6759
+ * user permits a write at all*.
6760
+ *
6761
+ * Returns a block object when the call must not proceed, undefined when it
6762
+ * may. Never throws — a throwing tool_call handler blocks the tool as a
6763
+ * fail-safe, so every failure path here fails CLOSED with an actionable
6764
+ * reason (the reason is what the model reads).
6765
+ */
6766
+ async function writePolicyGate(
6767
+ event: any,
6768
+ ctx: any,
6769
+ cwd: string,
6770
+ ): Promise<{ block: true; reason: string } | undefined> {
6771
+ try {
6772
+ // Scope: the top-level Default-mode session only.
6773
+ // - Plan mode has its own stricter rules (writes only to .pi/plans/).
6774
+ // - Build mode is the deliberate "go ahead" execution escape hatch.
6775
+ // - A spawned child carries PI_WORKFLOW_ROLE and is already read-only
6776
+ // through the role gate (and has no UI to answer a prompt).
6777
+ if (workflowMode !== null) return undefined;
6778
+ if (
6779
+ resolveSessionRole({
6780
+ envRole: process.env[WORKFLOW_ROLE_ENV],
6781
+ workflowMode,
6782
+ }) !== undefined
6783
+ ) {
6784
+ return undefined;
6785
+ }
6786
+
6787
+ const resolved = readWritePolicyConfig(cwd, getAgentDir());
6788
+ const config = resolved.config;
6789
+ if (config.mode === "allow") return undefined;
6790
+
6791
+ const classification = classifyWriteCall(
6792
+ event.toolName,
6793
+ event.input,
6794
+ config,
6795
+ );
6796
+ if (!classification.mutating) return undefined;
6797
+
6798
+ // The workflow's own plan directory stays writable in every mode — a
6799
+ // session that leaves Plan mode mid-flow must not be stranded.
6800
+ if (
6801
+ classification.paths.length > 0 &&
6802
+ classification.paths.every((p) => isPlanWritePath(p, cwd))
6803
+ ) {
6804
+ return undefined;
6805
+ }
6806
+
6807
+ const allowed = isWriteCallAllowed(classification, config, cwd);
6808
+ const decision = decideWriteGate({
6809
+ mode: config.mode,
6810
+ mutating: true,
6811
+ allowed,
6812
+ });
6813
+ if (decision === "allow") return undefined;
6814
+
6815
+ const target =
6816
+ classification.paths.length > 0
6817
+ ? classification.paths.join(", ")
6818
+ : (classification.key ?? String(event.toolName));
6819
+ const why = classification.reason
6820
+ ? ` (${classification.reason})`
6821
+ : "";
6822
+
6823
+ if (decision === "block") {
6824
+ return {
6825
+ block: true,
6826
+ reason:
6827
+ `Write policy is 'never' (workflow.writePolicy): the agent may not modify files in Default mode${why}. ` +
6828
+ "Do not retry. Ask the user how to proceed, or tell them to switch to Build mode (/build) — that is the approved-execution path. " +
6829
+ `The user can also change the policy with /write-policy (ask to be prompted, allow to write freely). Target: ${target}`,
6830
+ };
6831
+ }
6832
+
6833
+ // decision === "ask" — prompt the user.
6834
+ const ui = ctx?.ui;
6835
+ if (!ui || !ctx.hasUI) {
6836
+ return {
6837
+ block: true,
6838
+ reason:
6839
+ `Write policy is 'ask' (workflow.writePolicy): writing needs user approval${why}, but no interactive UI is available. ` +
6840
+ "Do not retry. Report what you would change and let the user apply it, or tell them to switch to Build mode (/build). " +
6841
+ `Target: ${target}`,
6842
+ };
6843
+ }
6844
+
6845
+ const isPathGrant = classification.paths.length > 0;
6846
+ const options = writeGateOptions(isPathGrant ? "path" : "command");
6847
+ let sel: unknown;
6848
+ try {
6849
+ sel = await ui.select(
6850
+ `Write policy 'ask': allow this write${why}?`,
6851
+ options,
6852
+ );
6853
+ } catch (_e) {
6854
+ void _e;
6855
+ sel = undefined;
6856
+ }
6857
+ const choice = parseWriteGateChoice(sel);
6858
+ if (choice === "allow-once") return undefined;
6859
+
6860
+ if (choice === "always") {
6861
+ // Persist the grant so the same target stops asking. A path grant
6862
+ // stores the absolute path; a command grant stores the command key.
6863
+ const patch = isPathGrant
6864
+ ? {
6865
+ allowedPaths: classification.paths.map((p) =>
6866
+ normalizeWritePath(p, cwd),
6867
+ ),
6868
+ }
6869
+ : { allowedCommands: classification.key ? [classification.key] : [] };
6870
+ const written = writeWritePolicyConfig("global", cwd, getAgentDir(), patch);
6871
+ if (written.ok) {
6872
+ try {
6873
+ (ctx as any).ui?.notify?.(
6874
+ `Write policy: always allowed ${isPathGrant ? classification.paths.join(", ") : classification.key}`,
6875
+ "info",
6876
+ );
6877
+ } catch (_e) {
6878
+ void _e;
6879
+ }
6880
+ return undefined;
6881
+ }
6882
+ // The grant could not be persisted — do not silently allow a write
6883
+ // the user asked to remember. Fall through to the denial below.
6884
+ return {
6885
+ block: true,
6886
+ reason:
6887
+ `Write policy 'ask': could not save the permission grant (${written.error ?? "unknown error"}), so the write was not allowed. ` +
6888
+ `Target: ${target}`,
6889
+ };
6890
+ }
6891
+
6892
+ // ── Denial ──
6893
+ // "Deny with instructions…" lets the user say WHY in one sentence
6894
+ // ("only explore and propose a plan, do not edit files yet"), so the
6895
+ // model can change course instead of stopping cold. The sentence
6896
+ // travels through TWO channels:
6897
+ // 1. the block reason (the tool result the model reads right now), and
6898
+ // 2. a USER message (a steer turn the model must obey).
6899
+ // Anthropic shipped the comment-only version and then shipped the bug
6900
+ // where the model ignored it (anthropics/claude-code#89231, #52824):
6901
+ // a tool result is a fact the model may reinterpret, a user turn is an
6902
+ // instruction it must follow.
6903
+ let instruction = "";
6904
+ if (choice === "deny-with-instructions") {
6905
+ const inputFn = (ui as any).input;
6906
+ if (typeof inputFn === "function") {
6907
+ let typed: unknown;
6908
+ try {
6909
+ typed = await inputFn.call(
6910
+ ui,
6911
+ WRITE_INSTRUCTION_PROMPT_TITLE,
6912
+ WRITE_INSTRUCTION_PROMPT_PLACEHOLDER,
6913
+ );
6914
+ } catch (_e) {
6915
+ void _e;
6916
+ typed = undefined;
6917
+ }
6918
+ instruction = sanitizeWriteInstruction(typed);
6919
+ }
6920
+ // No input dialog, a cancelled dialog, or empty text → plain denial
6921
+ // below. A cancel is never an implicit allow.
6922
+ }
6923
+
6924
+ const reason = buildWriteDenyReason({
6925
+ target,
6926
+ why: classification.reason,
6927
+ instruction,
6928
+ });
6929
+
6930
+ if (instruction) {
6931
+ // Channel 2: a user-role message. `steer` queues it while the agent
6932
+ // is running; it lands after the current tool batch, before the next
6933
+ // model call, as a genuine user turn.
6934
+ //
6935
+ // Guard: while a review round is active the extension's `input`
6936
+ // handler routes incoming user text to the REVIEWER, so steering
6937
+ // here would hand the instruction to the wrong agent. The tool result
6938
+ // still carries it, so skip the steer rather than misroute.
6939
+ const rt = reviewRuntime;
6940
+ const wouldRouteToReviewer = shouldRouteInputToReviewer({
6941
+ reviewEnabled: reviewEnabled(),
6942
+ reviewActive: rt?.isRoundActive() === true,
6943
+ hasReviewerSession: rt?.isAlive() === true,
6944
+ });
6945
+ if (!wouldRouteToReviewer) {
6946
+ try {
6947
+ (pi as any).sendUserMessage?.(
6948
+ buildWriteGuidanceMessage({ target, instruction }),
6949
+ { deliverAs: "steer" },
6950
+ );
6951
+ } catch (_e) {
6952
+ void _e;
6953
+ }
6954
+ }
6955
+ // Audit trail — transcript only (appendEntry is never sent to the LLM).
6956
+ try {
6957
+ pi.appendEntry("workflow-write-denial", {
6958
+ tool: String(event.toolName ?? ""),
6959
+ target,
6960
+ instruction,
6961
+ at: Date.now(),
6962
+ });
6963
+ } catch (_e) {
6964
+ void _e;
6965
+ }
6966
+ }
6967
+
6968
+ return {
6969
+ block: true,
6970
+ reason,
6971
+ // NOTE: no `terminate` here. pi consults `terminate` only AFTER the
6972
+ // whole tool batch has run (`executeToolCallsParallel` prepares and
6973
+ // runs every call before `shouldTerminateToolBatch()`), so it cannot
6974
+ // suppress a sibling write in the same batch — it only decides
6975
+ // whether the agent gets ANOTHER LLM turn. With it set, a plain Deny
6976
+ // would end the run with no model acknowledgement at all: a
6977
+ // behaviour change nobody asked for. The guided path gets its next
6978
+ // turn from the queued steer message instead.
6979
+ };
6980
+ } catch (e: unknown) {
6981
+ // Fail closed: a broken policy read must not become an open door.
6982
+ return {
6983
+ block: true,
6984
+ reason:
6985
+ `Write policy (workflow.writePolicy) could not be evaluated (${e instanceof Error ? e.message : String(e)}), so the write was blocked. ` +
6986
+ "Do not retry. Ask the user to switch to Build mode (/build) or to fix the policy with /write-policy.",
6987
+ };
6988
+ }
6989
+ }
6990
+
6591
6991
  // Block edits/writes + bash gating
6592
6992
  pi.on("tool_call", async (event, ctx) => {
6593
6993
  const cwd = (ctx as ExtensionContext).cwd;
@@ -6721,6 +7121,15 @@ export default function workflowExtension(pi: ExtensionAPI) {
6721
7121
  reason: `${gateRole} role: ${event.toolName} blocked — this role is read-only.`,
6722
7122
  } as any;
6723
7123
  }
7124
+ // ── Write permission gate (workflow.writePolicy) ── Independent of the
7125
+ // role gate and of the commit policy: it governs whether the USER
7126
+ // permits a write at all. It only applies in Default mode — Plan has its
7127
+ // own stricter rules and Build is the approved-execution escape hatch —
7128
+ // and it runs last so it only sees calls nothing else claimed.
7129
+ if (workflowMode === null) {
7130
+ const writeBlock = await writePolicyGate(event, ctx, cwd);
7131
+ if (writeBlock) return writeBlock as any;
7132
+ }
6724
7133
  // In build mode, ensure todos exist before first real edit
6725
7134
  if (
6726
7135
  (event.toolName === "edit" || event.toolName === "write") &&
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@dieulc/workflow",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
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
- /** Writing/formatting flags that must never reach a verify-class command. */
428
- const DENIED_FLAG_RE =
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 `$(...)`";