rulesync 31.0.0 → 32.0.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.
@@ -453,36 +453,36 @@ const isFeatureValueEnabled = (value) => {
453
453
  const parseCommaSeparatedList = (value) => value.split(",").map((s) => s.trim()).filter(Boolean);
454
454
  //#endregion
455
455
  //#region src/constants/rulesync-paths.ts
456
- const { join: join$416 } = node_path.posix;
456
+ const { join: join$417 } = node_path.posix;
457
457
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
458
458
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
459
459
  const RULESYNC_USER_CONFIG_DIR_NAME = "rulesync";
460
460
  const RULESYNC_XDG_CONFIG_HOME_DEFAULT_DIR_NAME = ".config";
461
461
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
462
462
  const RULES_FEATURE_SUBDIR = "rules";
463
- const CURATED_RULES_FEATURE_SUBDIR = join$416(RULES_FEATURE_SUBDIR, ".curated");
463
+ const CURATED_RULES_FEATURE_SUBDIR = join$417(RULES_FEATURE_SUBDIR, ".curated");
464
464
  const COMMANDS_FEATURE_SUBDIR = "commands";
465
465
  const SUBAGENTS_FEATURE_SUBDIR = "subagents";
466
466
  const CHECKS_FEATURE_SUBDIR = "checks";
467
467
  const SKILLS_FEATURE_SUBDIR = "skills";
468
- const CURATED_SKILLS_FEATURE_SUBDIR = join$416(SKILLS_FEATURE_SUBDIR, ".curated");
469
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
470
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
471
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
472
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
473
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
474
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
475
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
476
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
477
- join$416(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
478
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
479
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
468
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$417(SKILLS_FEATURE_SUBDIR, ".curated");
469
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
470
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
471
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
472
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
473
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
474
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
475
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
476
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
477
+ join$417(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
478
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
479
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
480
480
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
481
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
481
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
482
482
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
483
483
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
484
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
485
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
484
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
485
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$417(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
486
486
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
487
487
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
488
488
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -4845,13 +4845,11 @@ const PI_HOOK_EVENTS = [
4845
4845
  /**
4846
4846
  * Hook events supported by oh-my-pi (`omp`), Pi's fork, through the same
4847
4847
  * generated TypeScript extension. oh-my-pi keeps Pi's names for every event
4848
- * listed here, but it has neither `agent_before_settle` nor `ui_prompt_start`,
4849
- * so `stop` and `notification` are not mapped yet. oh-my-pi's stop surface is
4850
- * `session_stop`, which takes a Claude-compatible `{ decision: "block", reason }`
4851
- * result; it needs its own handler in the generator and is left for a
4852
- * follow-up. See CANONICAL_TO_OMP_EVENT_NAMES.
4848
+ * listed here except `stop`: it has no `agent_before_settle`, so `stop` maps to
4849
+ * its own `session_stop` event instead. It has no `ui_prompt_start` either, so
4850
+ * `notification` is not mapped. See CANONICAL_TO_OMP_EVENT_NAMES.
4853
4851
  *
4854
- * @see https://github.com/can1357/oh-my-pi/blob/40e9368ef0458fd9073329cdff4174895f91bc6b/docs/extensions.md
4852
+ * @see https://github.com/can1357/oh-my-pi/blob/fa5ff4a3a977a28b93d025f4cf50f046e019f18c/docs/extensions.md
4855
4853
  */
4856
4854
  const OMP_HOOK_EVENTS = [
4857
4855
  "sessionStart",
@@ -4862,6 +4860,7 @@ const OMP_HOOK_EVENTS = [
4862
4860
  "preModelInvocation",
4863
4861
  "postModelInvocation",
4864
4862
  "beforeSubmitPrompt",
4863
+ "stop",
4865
4864
  "preCompact",
4866
4865
  "postCompact"
4867
4866
  ];
@@ -6283,9 +6282,10 @@ const CANONICAL_TO_PI_EVENT_NAMES = {
6283
6282
  /**
6284
6283
  * Map canonical camelCase event names to oh-my-pi extension event names. They
6285
6284
  * are Pi's names (see CANONICAL_TO_PI_EVENT_NAMES) for the events oh-my-pi
6286
- * kept. Note that oh-my-pi runs `session_shutdown` handlers with a 2-second
6287
- * budget, and fails a `tool_call` closed when its handler exceeds
6288
- * `extensionHandlers.toolCallTimeoutMs` (30 seconds by default).
6285
+ * kept, plus `session_stop` for `stop`. Note that oh-my-pi runs
6286
+ * `session_shutdown` handlers with a 2-second budget, and fails a `tool_call`
6287
+ * closed when its handler exceeds `extensionHandlers.toolCallTimeoutMs`
6288
+ * (30 seconds by default).
6289
6289
  *
6290
6290
  * @see https://github.com/can1357/oh-my-pi/blob/40e9368ef0458fd9073329cdff4174895f91bc6b/docs/extensions.md
6291
6291
  */
@@ -6298,6 +6298,7 @@ const CANONICAL_TO_OMP_EVENT_NAMES = {
6298
6298
  preModelInvocation: "context",
6299
6299
  postModelInvocation: "message_end",
6300
6300
  beforeSubmitPrompt: "input",
6301
+ stop: "session_stop",
6301
6302
  preCompact: "session_before_compact",
6302
6303
  postCompact: "session_compact"
6303
6304
  };
@@ -12955,10 +12956,16 @@ const SHARED_CONFIG_OWNERSHIP = {
12955
12956
  [CODEWHALE_CONFIG_SHARED_FILE_KEY]: {
12956
12957
  format: "toml",
12957
12958
  invalidRootPolicy: "error",
12958
- features: { hooks: {
12959
- kind: "replace-owned-keys",
12960
- ownedKeys: ["hooks"]
12961
- } }
12959
+ features: {
12960
+ hooks: {
12961
+ kind: "replace-owned-keys",
12962
+ ownedKeys: ["hooks"]
12963
+ },
12964
+ permissions: {
12965
+ kind: "replace-owned-keys",
12966
+ ownedKeys: ["allow_shell"]
12967
+ }
12968
+ }
12962
12969
  },
12963
12970
  ".letta/settings.json": {
12964
12971
  format: "json",
@@ -32857,6 +32864,31 @@ const PI_PROMPT_BLOCKING_EVENT = "input";
32857
32864
  * @see https://github.com/earendil-works/pi/blob/v0.87.1/packages/coding-agent/src/core/extensions/types.ts
32858
32865
  */
32859
32866
  const PI_SETTLE_BLOCKING_EVENT = "agent_before_settle";
32867
+ /**
32868
+ * `session_stop` is oh-my-pi's stop surface (it has no `agent_before_settle`).
32869
+ * It fires when a main-session run is about to settle (never for subagents,
32870
+ * nor for a run the user aborted), and a handler returning
32871
+ * `{ continue: true, additionalContext }` queues one more turn with that text
32872
+ * as hidden model-visible context. As on Pi, only a stop command exiting with
32873
+ * code 2 asks for that, the feedback of every such command is combined, and
32874
+ * any other failure is rethrown for oh-my-pi to report. The generated handler
32875
+ * continues only when `event.stop_hook_active` is false, which oh-my-pi sets
32876
+ * after a continuation and clears once a stop pass lets the run settle, so the
32877
+ * agent is continued at most once per stop (the flag is shared by every
32878
+ * extension, so another extension's continuation also suppresses ours);
32879
+ * oh-my-pi additionally caps these
32880
+ * advisory continuations at 8 in a row. A run that ended on a provider error
32881
+ * (`stopReason: "error"`) is not continued, matching Pi's `completed`-only
32882
+ * gate. The advisory form is used rather than `{ decision: "block", reason }`,
32883
+ * which oh-my-pi keeps enforcing without that cap. oh-my-pi gives
32884
+ * `session_stop` handlers the 30-second extension handler budget: stop
32885
+ * commands running longer time the handler out, the run settles without a
32886
+ * continuation, and the command is not killed.
32887
+ *
32888
+ * @see https://github.com/can1357/oh-my-pi/blob/fa5ff4a3a977a28b93d025f4cf50f046e019f18c/docs/extensions.md
32889
+ * @see https://github.com/can1357/oh-my-pi/blob/fa5ff4a3a977a28b93d025f4cf50f046e019f18c/packages/coding-agent/src/session/agent-session.ts
32890
+ */
32891
+ const OMP_SESSION_STOP_BLOCKING_EVENT = "session_stop";
32860
32892
  const PI_EXTENSION_DIALECT = {
32861
32893
  overrideKey: "pi",
32862
32894
  typesPackage: "@earendil-works/pi-coding-agent",
@@ -32878,7 +32910,8 @@ const OMP_EXTENSION_DIALECT = {
32878
32910
  const PI_BLOCKING_MODE_BY_EVENT = {
32879
32911
  [PI_BLOCKING_EVENT]: "tool",
32880
32912
  [PI_PROMPT_BLOCKING_EVENT]: "prompt",
32881
- [PI_SETTLE_BLOCKING_EVENT]: "settle"
32913
+ [PI_SETTLE_BLOCKING_EVENT]: "settle",
32914
+ [OMP_SESSION_STOP_BLOCKING_EVENT]: "sessionStop"
32882
32915
  };
32883
32916
  /**
32884
32917
  * The body emitted inside a blocking handler's `catch`, per gate. `tool_call`
@@ -32891,7 +32924,8 @@ function failureLinesByMode(dialect) {
32891
32924
  none: [],
32892
32925
  tool: ["return { block: true, reason: toBlockReason(error) };"],
32893
32926
  prompt: ["reportPromptGateFailure(ctx, toBlockReason(error));", dialect.promptCancelLine],
32894
- settle: ["stopHookFailures.push(error);"]
32927
+ settle: ["stopHookFailures.push(error);"],
32928
+ sessionStop: ["stopHookFailures.push(error);"]
32895
32929
  };
32896
32930
  }
32897
32931
  /**
@@ -32983,6 +33017,12 @@ const PROMPT_GATE_HELPER_LINES = [
32983
33017
  " console.error(reason);",
32984
33018
  "}"
32985
33019
  ];
33020
+ /** Helper emitted alongside every stop gate (Pi's settle gate and oh-my-pi's `session_stop`). */
33021
+ const STOP_HOOK_BLOCK_HELPER_LINES = [
33022
+ "function isStopHookBlock(error: unknown): boolean {",
33023
+ " return (error as { code?: unknown } | null)?.code === 2;",
33024
+ "}"
33025
+ ];
32986
33026
  /**
32987
33027
  * Helper emitted alongside the stop hook's settle gate. `event.entries` holds
32988
33028
  * the drafts earlier extensions proposed at this boundary, so they are kept and
@@ -32993,10 +33033,6 @@ const PROMPT_GATE_HELPER_LINES = [
32993
33033
  * appended message is committed, and Pi re-validates the final context itself.
32994
33034
  */
32995
33035
  const STOP_HOOK_HELPER_LINES = [
32996
- "function isStopHookBlock(error: unknown): boolean {",
32997
- " return (error as { code?: unknown } | null)?.code === 2;",
32998
- "}",
32999
- "",
33000
33036
  "function continueAfterStopHook(",
33001
33037
  " event: AgentBeforeSettleEvent,",
33002
33038
  " reason: string,",
@@ -33079,14 +33115,16 @@ function buildSubscriptionLines(handlerGroups, dialect) {
33079
33115
  const blocking = PI_BLOCKING_MODE_BY_EVENT[piEvent] ?? "none";
33080
33116
  const isPromptGate = blocking === "prompt";
33081
33117
  const isSettleGate = blocking === "settle";
33118
+ const isSessionStopGate = blocking === "sessionStop";
33119
+ const collectsStopFailures = isSettleGate || isSessionStopGate;
33082
33120
  const usesToolName = PI_TOOL_EVENTS.has(piEvent) && handlers.some((h) => h.matcher);
33083
33121
  const usesErrorFlag = handlers.some((h) => h.onlyOnError);
33084
33122
  const gatesOnAssistant = PI_ASSISTANT_MESSAGE_EVENTS.has(piEvent);
33085
- const params = isPromptGate ? "event, ctx" : usesToolName || usesErrorFlag || gatesOnAssistant || isPromptGate || isSettleGate ? "event" : "";
33123
+ const params = isPromptGate ? "event, ctx" : usesToolName || usesErrorFlag || gatesOnAssistant || isPromptGate || collectsStopFailures ? "event" : "";
33086
33124
  lines.push(` pi.on(${JSON.stringify(piEvent)}, async (${params}) => {`);
33087
33125
  if (gatesOnAssistant) lines.push(` if (event.message.role !== "assistant") return;`);
33088
33126
  if (isPromptGate) lines.push(` if (event.source === "extension") ${dialect.promptPassLine}`);
33089
- if (isSettleGate) lines.push(" const stopHookFailures: unknown[] = [];");
33127
+ if (collectsStopFailures) lines.push(" const stopHookFailures: unknown[] = [];");
33090
33128
  for (const handler of handlers) lines.push(...buildCommandLines({
33091
33129
  handler,
33092
33130
  usesToolName,
@@ -33102,6 +33140,17 @@ function buildSubscriptionLines(handlerGroups, dialect) {
33102
33140
  lines.push(" }");
33103
33141
  lines.push(" if (stopHookFailures.length > 0) throw stopHookFailures[0];");
33104
33142
  }
33143
+ if (isSessionStopGate) {
33144
+ lines.push(" const blocks = stopHookFailures.filter(isStopHookBlock);");
33145
+ lines.push(" const endedOnError = (event.last_assistant_message as { stopReason?: unknown } | undefined)?.stopReason === \"error\";");
33146
+ lines.push(" if (blocks.length > 0 && !event.stop_hook_active && !endedOnError) {");
33147
+ lines.push(" return {");
33148
+ lines.push(" continue: true,");
33149
+ lines.push(" additionalContext: `Stop hook feedback:\\n${blocks.map(toBlockReason).join(\"\\n\\n\")}`,");
33150
+ lines.push(" };");
33151
+ lines.push(" }");
33152
+ lines.push(" if (stopHookFailures.length > 0) throw stopHookFailures[0];");
33153
+ }
33105
33154
  lines.push(" });");
33106
33155
  if (isSettleGate) {
33107
33156
  lines.push(` pi.on("agent_settled", async () => {`);
@@ -33120,8 +33169,9 @@ function buildSubscriptionLines(handlerGroups, dialect) {
33120
33169
  * `{ block: true, reason }`, and on `input` — Pi's prompt-submission gate —
33121
33170
  * where a non-zero exit cancels the prompt with the dialect's cancel result
33122
33171
  * (`{ action: "handled" }` for Pi, `{ handled: true }` for oh-my-pi), and
33123
- * on `agent_before_settle`, where a stop command exiting with code 2 asks the
33124
- * agent to continue once with the command's output as feedback.
33172
+ * on `agent_before_settle` (Pi) / `session_stop` (oh-my-pi), where a stop
33173
+ * command exiting with code 2 asks the agent to continue once with the
33174
+ * command's output as feedback.
33125
33175
  * `postToolUse` and `postToolUseFailure` share Pi's `tool_result` event; the
33126
33176
  * latter's commands run only when `event.isError` is set.
33127
33177
  *
@@ -33142,7 +33192,8 @@ function generatePiExtensionCode({ config, supportedEvents, eventMap, dialect =
33142
33192
  const subscriptionLines = buildSubscriptionLines(handlerGroups, dialect);
33143
33193
  const hasPromptGate = Boolean(handlerGroups[PI_PROMPT_BLOCKING_EVENT]);
33144
33194
  const hasSettleGate = Boolean(handlerGroups[PI_SETTLE_BLOCKING_EVENT]);
33145
- const needsBlockReasonHelper = Boolean(handlerGroups[PI_BLOCKING_EVENT]) || hasPromptGate || hasSettleGate;
33195
+ const hasSessionStopGate = Boolean(handlerGroups[OMP_SESSION_STOP_BLOCKING_EVENT]);
33196
+ const needsBlockReasonHelper = Boolean(handlerGroups[PI_BLOCKING_EVENT]) || hasPromptGate || hasSettleGate || hasSessionStopGate;
33146
33197
  const lines = ["// Generated by rulesync. Do not edit manually."];
33147
33198
  if (subscriptionLines.length === 0) {
33148
33199
  lines.push("export default function () {}");
@@ -33169,6 +33220,10 @@ function generatePiExtensionCode({ config, supportedEvents, eventMap, dialect =
33169
33220
  lines.push(...PROMPT_GATE_HELPER_LINES);
33170
33221
  lines.push("");
33171
33222
  }
33223
+ if (hasSettleGate || hasSessionStopGate) {
33224
+ lines.push(...STOP_HOOK_BLOCK_HELPER_LINES);
33225
+ lines.push("");
33226
+ }
33172
33227
  if (hasSettleGate) {
33173
33228
  lines.push(...STOP_HOOK_HELPER_LINES);
33174
33229
  lines.push("");
@@ -55965,6 +56020,124 @@ function parseCodebuddySettings({ fileContent, relativePath }) {
55965
56020
  }
55966
56021
  }
55967
56022
  //#endregion
56023
+ //#region src/features/permissions/codewhale-project-config.ts
56024
+ /**
56025
+ * The top-level key of the per-project `.codewhale/config.toml` overlay that
56026
+ * hides Codewhale's shell tools. The overlay only tightens: `false` disables
56027
+ * shell for the session, while `true` is ignored with a warning.
56028
+ * @see https://github.com/Hmbown/Codewhale/blob/fead51eeea0fb6038ea3e30923751b1a1c765b25/docs/CONFIGURATION.md#per-project-overlay-485
56029
+ * @see https://github.com/Hmbown/Codewhale/blob/fead51eeea0fb6038ea3e30923751b1a1c765b25/crates/tui/src/lib.rs
56030
+ */
56031
+ const CODEWHALE_ALLOW_SHELL_KEY = "allow_shell";
56032
+ const CODEWHALE_PROJECT_CONFIG_PATHS = {
56033
+ relativeDirPath: CODEWHALE_DIR,
56034
+ relativeFilePath: CODEWHALE_CONFIG_FILE_NAME
56035
+ };
56036
+ /** The project `.codewhale/config.toml` overlay, as written by the permissions feature. */
56037
+ var CodewhaleProjectConfigFile = class extends ToolFile {
56038
+ validate() {
56039
+ return {
56040
+ success: true,
56041
+ error: null
56042
+ };
56043
+ }
56044
+ };
56045
+ /**
56046
+ * Whether the canonical config denies every shell command: a `deny` on the
56047
+ * catch-all `*` (or `**`) pattern of `bash` or of the all-tools `*` category.
56048
+ * Codewhale decides by action before specificity, so such a deny already
56049
+ * blocks every command in global scope too, whatever `allow` rules sit beside
56050
+ * it.
56051
+ */
56052
+ function deniesEveryShellCommand(permission) {
56053
+ return collectShellCommandRules(permission).rules.some(({ pattern, action }) => action === "deny" && isShellCatchAll(pattern));
56054
+ }
56055
+ /** Whether a canonical shell pattern names every command. */
56056
+ function isShellCatchAll(pattern) {
56057
+ const trimmed = pattern.trim();
56058
+ return trimmed === "*" || trimmed === "**";
56059
+ }
56060
+ function projectConfigFilePath(outputRoot) {
56061
+ return (0, node_path.join)(outputRoot, CODEWHALE_PROJECT_CONFIG_PATHS.relativeDirPath, CODEWHALE_PROJECT_CONFIG_PATHS.relativeFilePath);
56062
+ }
56063
+ function parseProjectConfig({ fileContent, filePath }) {
56064
+ return parseSharedConfig({
56065
+ format: "toml",
56066
+ fileContent,
56067
+ filePath,
56068
+ invalidRootPolicy: "error"
56069
+ });
56070
+ }
56071
+ /**
56072
+ * Whether an overlay disables shell (`allow_shell = false`). A file that
56073
+ * cannot be read as TOML is reported and treated as not disabling shell.
56074
+ */
56075
+ function disablesShell({ fileContent, filePath, logger }) {
56076
+ try {
56077
+ return parseProjectConfig({
56078
+ fileContent,
56079
+ filePath
56080
+ })[CODEWHALE_ALLOW_SHELL_KEY] === false;
56081
+ } catch (error) {
56082
+ logger?.warn(`Codewhale permissions: not reading "allow_shell" from ${filePath}, so a shell deny set there is not taken into account: ${formatError(error)}`);
56083
+ return false;
56084
+ }
56085
+ }
56086
+ /**
56087
+ * Whether the project overlay at `outputRoot` disables shell
56088
+ * (`allow_shell = false`), for import.
56089
+ */
56090
+ async function readCodewhaleProjectShellDisabled({ outputRoot, logger }) {
56091
+ const filePath = projectConfigFilePath(outputRoot);
56092
+ const fileContent = await readFileContentOrNull(filePath);
56093
+ if (fileContent === null) return false;
56094
+ return disablesShell({
56095
+ fileContent,
56096
+ filePath,
56097
+ logger
56098
+ });
56099
+ }
56100
+ /**
56101
+ * The project `.codewhale/config.toml` overlay carrying `allow_shell = false`
56102
+ * when the canonical config denies every shell command, or `null` when the
56103
+ * file needs no change.
56104
+ *
56105
+ * Only `allow_shell` is written; every other overlay key (`model`,
56106
+ * `approval_policy`, ...) is kept, and an existing file that is not valid TOML
56107
+ * is an error only when it has to be written. An existing
56108
+ * `allow_shell = false` is never removed: the overlay cannot widen access, and
56109
+ * a hand-written value cannot be told apart from one rulesync wrote, so it is
56110
+ * reported instead.
56111
+ */
56112
+ async function createCodewhaleProjectConfigFile({ outputRoot = process.cwd(), config, logger }) {
56113
+ const filePath = projectConfigFilePath(outputRoot);
56114
+ const existingContent = await readFileContentOrNull(filePath);
56115
+ if (!deniesEveryShellCommand(config.permission)) {
56116
+ if (existingContent !== null && disablesShell({
56117
+ fileContent: existingContent,
56118
+ filePath,
56119
+ logger
56120
+ })) logger?.warn(`Codewhale permissions: keeping "allow_shell = false" in ${filePath}; rulesync does not remove it. Delete it by hand to re-enable shell tools in this project, or add bash: { "*": "deny" } to the rulesync permissions to match it.`);
56121
+ return null;
56122
+ }
56123
+ if (parseProjectConfig({
56124
+ fileContent: existingContent ?? "",
56125
+ filePath
56126
+ })[CODEWHALE_ALLOW_SHELL_KEY] === false) return null;
56127
+ return new CodewhaleProjectConfigFile({
56128
+ outputRoot,
56129
+ ...CODEWHALE_PROJECT_CONFIG_PATHS,
56130
+ fileContent: applySharedConfigPatch({
56131
+ fileKey: sharedConfigFileKey(CODEWHALE_PROJECT_CONFIG_PATHS),
56132
+ feature: "permissions",
56133
+ existingContent: existingContent ?? "",
56134
+ patch: { [CODEWHALE_ALLOW_SHELL_KEY]: false },
56135
+ filePath,
56136
+ logger
56137
+ })
56138
+ });
56139
+ }
56140
+ //#endregion
55968
56141
  //#region src/features/permissions/codewhale-repo-law.ts
55969
56142
  /**
55970
56143
  * Project-scope Codewhale permissions: the enforced `protected_invariants` of
@@ -56115,7 +56288,8 @@ function collectRestrictions({ config, logger }) {
56115
56288
  let skippedAllowCount = 0;
56116
56289
  for (const [category, rules] of Object.entries(config.permission)) {
56117
56290
  if (!REPO_LAW_CATEGORIES.includes(category)) {
56118
- if (Object.keys(rules).length > 0) skippedCategories.push(category);
56291
+ const entries = Object.entries(rules);
56292
+ if ((category === "bash" && entries.some(([pattern, action]) => action === "deny" && isShellCatchAll(pattern)) ? entries.filter(([, action]) => action === "allow") : entries).length > 0) skippedCategories.push(category);
56119
56293
  continue;
56120
56294
  }
56121
56295
  for (const [pattern, action] of Object.entries(rules)) {
@@ -56841,17 +57015,32 @@ async function warnOnShadowedConstitution({ outputRoot, logger }) {
56841
57015
  * Project scope manages the enforced `protected_invariants` of the repo
56842
57016
  * constitution `.codewhale/constitution.json` (see `codewhale-repo-law.ts`):
56843
57017
  * the project `.codewhale/config.toml` overlay cannot carry rules, but repo
56844
- * law can hold writes to protected paths.
57018
+ * law can hold writes to protected paths. A deny on every shell command also
57019
+ * sets `allow_shell = false` in that overlay (see
57020
+ * `codewhale-project-config.ts`), which import reads back here.
56845
57021
  *
56846
57022
  * @see https://github.com/Hmbown/Codewhale/blob/main/docs/CONFIGURATION.md
56847
57023
  * @see https://github.com/Hmbown/Codewhale/blob/main/docs/AUTHORIZATION_ORDER.md
56848
57024
  */
56849
57025
  var CodewhalePermissions = class CodewhalePermissions extends ToolPermissions {
56850
- constructor(params) {
57026
+ /**
57027
+ * Whether the project `.codewhale/config.toml` overlay disables shell
57028
+ * (`allow_shell = false`); read on import in project scope only.
57029
+ */
57030
+ projectShellDisabled;
57031
+ constructor({ projectShellDisabled = false, ...params }) {
56851
57032
  super({
56852
57033
  ...params,
56853
57034
  fileContent: params.fileContent ?? ""
56854
57035
  });
57036
+ this.projectShellDisabled = projectShellDisabled;
57037
+ }
57038
+ /**
57039
+ * The project `.codewhale/config.toml` overlay, where a deny on every shell
57040
+ * command becomes `allow_shell = false` beside the user's other overlay keys.
57041
+ */
57042
+ static getExtraSharedWritePaths(options) {
57043
+ return options?.global ? [] : [{ ...CODEWHALE_PROJECT_CONFIG_PATHS }];
56855
57044
  }
56856
57045
  /**
56857
57046
  * Codewhale's approval card writes to the global file, and the constitution
@@ -56888,7 +57077,11 @@ var CodewhalePermissions = class CodewhalePermissions extends ToolPermissions {
56888
57077
  relativeFilePath: paths.relativeFilePath,
56889
57078
  fileContent,
56890
57079
  validate,
56891
- global
57080
+ global,
57081
+ projectShellDisabled: global ? false : await readCodewhaleProjectShellDisabled({
57082
+ outputRoot,
57083
+ logger
57084
+ })
56892
57085
  });
56893
57086
  }
56894
57087
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, logger, global = false }) {
@@ -56950,11 +57143,15 @@ var CodewhalePermissions = class CodewhalePermissions extends ToolPermissions {
56950
57143
  toRulesyncPermissions() {
56951
57144
  const filePath = (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath());
56952
57145
  if (!this.global) {
56953
- const document = parseCodewhaleConstitution({
57146
+ const permission = codewhaleConstitutionToCanonical(parseCodewhaleConstitution({
56954
57147
  fileContent: this.getFileContent(),
56955
57148
  filePath
56956
- });
56957
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission: codewhaleConstitutionToCanonical(document) }, null, 2) });
57149
+ }));
57150
+ if (this.projectShellDisabled) permission.bash = {
57151
+ ...permission.bash,
57152
+ "*": "deny"
57153
+ };
57154
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
56958
57155
  }
56959
57156
  const { permission, nativeRules } = codewhaleRulesToCanonical(partitionRuleRecords(parsePermissionsDocument({
56960
57157
  fileContent: this.getFileContent(),
@@ -71883,6 +72080,14 @@ var PermissionsProcessor = class extends FeatureProcessor {
71883
72080
  logger: this.logger,
71884
72081
  global: this.global
71885
72082
  });
72083
+ if (this.toolTarget === "codewhale" && !this.global) {
72084
+ const projectConfigFile = await createCodewhaleProjectConfigFile({
72085
+ outputRoot: this.outputRoot,
72086
+ config: effectivePermissions.getJson(),
72087
+ logger: this.logger
72088
+ });
72089
+ return projectConfigFile ? [toolPermissions, projectConfigFile] : [toolPermissions];
72090
+ }
71886
72091
  if (this.toolTarget !== "codexcli") return [toolPermissions];
71887
72092
  return [toolPermissions, createCodexcliBashRulesFile({
71888
72093
  outputRoot: this.outputRoot,