@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/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.5.2",
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
- /** 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 `$(...)`";