rulesync 31.0.0 → 33.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",
@@ -21759,22 +21766,29 @@ function mergeOpencodeV2Entries({ config, legacyKey, pluralKey, lower }) {
21759
21766
  const entry = asOpencodeEntries(value);
21760
21767
  const lowered = entry === null ? null : lower(entry);
21761
21768
  if (Object.hasOwn(merged, name) || lowered === null) continue;
21762
- Object.defineProperty(merged, name, {
21763
- value: lowered,
21764
- enumerable: true,
21765
- configurable: true,
21766
- writable: true
21767
- });
21769
+ setOwnEntry(merged, name, lowered);
21768
21770
  }
21769
21771
  return Object.keys(merged).length > 0 ? merged : legacy;
21770
21772
  }
21771
21773
  /**
21774
+ * Sets `target[key]` as an own, enumerable property. A plain assignment to a
21775
+ * key named `__proto__` would set the prototype instead.
21776
+ */
21777
+ function setOwnEntry(target, key, value) {
21778
+ Object.defineProperty(target, key, {
21779
+ value,
21780
+ enumerable: true,
21781
+ configurable: true,
21782
+ writable: true
21783
+ });
21784
+ }
21785
+ /**
21772
21786
  * Returns the inline agent entries of an OpenCode config in the V1 `agent`
21773
21787
  * shape. Since v1.18.24 OpenCode V1 also reads the V2 `agents` spelling and
21774
21788
  * lowers it, so a layout that does so (`readsV2ConfigSpellings`) gets those
21775
21789
  * entries too.
21776
21790
  *
21777
- * @see https://github.com/anomalyco/opencode/blob/v1.18.34/packages/opencode/src/config/v2-compat.ts
21791
+ * @see https://github.com/anomalyco/opencode/blob/v1.18.35/packages/opencode/src/config/v2-compat.ts
21778
21792
  */
21779
21793
  function getOpencodeAgentEntries({ config, layout = OPENCODE_LAYOUT }) {
21780
21794
  if (!layout.readsV2ConfigSpellings) return asOpencodeEntries(config.agent);
@@ -21793,7 +21807,7 @@ function getOpencodeAgentEntries({ config, layout = OPENCODE_LAYOUT }) {
21793
21807
  * Other fields are kept: V1 drops a V2 `subagent`, but rulesync reads it as
21794
21808
  * `subtask` everywhere (as V2 does), so it is left for the command importer.
21795
21809
  *
21796
- * @see https://github.com/anomalyco/opencode/blob/v1.18.34/packages/opencode/src/config/v2-compat.ts
21810
+ * @see https://github.com/anomalyco/opencode/blob/v1.18.35/packages/opencode/src/config/v2-compat.ts
21797
21811
  */
21798
21812
  function getOpencodeCommandEntries({ config, layout = OPENCODE_LAYOUT }) {
21799
21813
  if (!layout.readsV2ConfigSpellings) return asOpencodeEntries(config.command);
@@ -21816,7 +21830,7 @@ function getOpencodeCommandEntries({ config, layout = OPENCODE_LAYOUT }) {
21816
21830
  * reads V2 spellings, a flat V2 `skills` array is accepted too: its entries
21817
21831
  * that are not `http(s)://` URLs are the paths, as OpenCode V1 lowers them.
21818
21832
  *
21819
- * @see https://github.com/anomalyco/opencode/blob/v1.18.34/packages/opencode/src/config/v2-compat.ts
21833
+ * @see https://github.com/anomalyco/opencode/blob/v1.18.35/packages/opencode/src/config/v2-compat.ts
21820
21834
  */
21821
21835
  function getOpencodeSkillPaths({ config, layout = OPENCODE_LAYOUT }) {
21822
21836
  if (Array.isArray(config.skills)) {
@@ -21826,6 +21840,160 @@ function getOpencodeSkillPaths({ config, layout = OPENCODE_LAYOUT }) {
21826
21840
  const skills = asOpencodeEntries(config.skills);
21827
21841
  return skills !== null && Array.isArray(skills.paths) ? skills.paths : [];
21828
21842
  }
21843
+ /**
21844
+ * Whether a record is itself an MCP server entry rather than a V2 container
21845
+ * (`mcp.servers`, `mcp.timeout`): a server — or a server literally named
21846
+ * `type` / `enabled` — carries a scalar `type` or `enabled`. Mirrors OpenCode
21847
+ * V1's `isDirectServer`.
21848
+ */
21849
+ function isDirectOpencodeMcpServer(value) {
21850
+ return ["type", "enabled"].some((key) => Object.hasOwn(value, key) && (value[key] === null || typeof value[key] !== "object" || Array.isArray(value[key])));
21851
+ }
21852
+ const OPENCODE_V2_TIMEOUT_KEYS = [
21853
+ "startup",
21854
+ "catalog",
21855
+ "execution"
21856
+ ];
21857
+ /**
21858
+ * Whether an MCP server entry uses a V2-only per-server spelling, so that
21859
+ * OpenCode V1 lowers it: `disabled`, `codemode`, an object `timeout`, or a
21860
+ * snake_case `oauth` key.
21861
+ */
21862
+ function isOpencodeV2NativeServer(value) {
21863
+ const oauth = value.oauth;
21864
+ return Object.hasOwn(value, "disabled") || Object.hasOwn(value, "codemode") || isRecord$1(value.timeout) || isRecord$1(oauth) && [
21865
+ "client_id",
21866
+ "client_secret",
21867
+ "callback_port",
21868
+ "redirect_uri"
21869
+ ].some((key) => Object.hasOwn(oauth, key));
21870
+ }
21871
+ /**
21872
+ * Lowers a V2 timeout object (`{ startup, catalog, execution }`) to V1's single
21873
+ * number, the way OpenCode V1 does: only a `catalog` equal to `execution`, with
21874
+ * no `startup`, has a V1 equivalent. Anything else — including a value that is
21875
+ * not a positive integer — is `undefined` (dropped).
21876
+ */
21877
+ function lowerOpencodeV2Timeout(value) {
21878
+ if (!hasPositiveIntegerTimeouts(value) || value.startup !== void 0 || typeof value.catalog !== "number") return;
21879
+ return value.catalog === value.execution ? value.catalog : void 0;
21880
+ }
21881
+ const OPENCODE_V2_OAUTH_KEYS = /* @__PURE__ */ new Map([
21882
+ ["client_id", "clientId"],
21883
+ ["client_secret", "clientSecret"],
21884
+ ["callback_port", "callbackPort"],
21885
+ ["redirect_uri", "redirectUri"]
21886
+ ]);
21887
+ /**
21888
+ * V2 `oauth` keys that have no V1 spelling. OpenCode V1 drops them when it
21889
+ * lowers a server, so they are dropped (and reported) here too rather than
21890
+ * being written back as a snake_case key among camelCase ones.
21891
+ */
21892
+ const OPENCODE_V2_UNSUPPORTED_OAUTH_KEYS = /* @__PURE__ */ new Set(["auth_server_metadata_url"]);
21893
+ function lowerOpencodeV2OAuth(oauth, path, onDropped) {
21894
+ const lowered = {};
21895
+ for (const [key, value] of Object.entries(oauth)) {
21896
+ if (OPENCODE_V2_UNSUPPORTED_OAUTH_KEYS.has(key)) {
21897
+ onDropped?.(`${path}.${key}`, "unsupported");
21898
+ continue;
21899
+ }
21900
+ setOwnEntry(lowered, OPENCODE_V2_OAUTH_KEYS.get(key) ?? key, value);
21901
+ }
21902
+ return lowered;
21903
+ }
21904
+ /**
21905
+ * Lowers one V2-spelled MCP server entry to V1's shape, as OpenCode V1 does:
21906
+ * `disabled` becomes `enabled` (an explicit `enabled` wins), an object
21907
+ * `timeout` becomes a number or is dropped, and snake_case `oauth` keys become
21908
+ * camelCase. `codemode` is kept: Rulesync passes it through where V1 drops it.
21909
+ *
21910
+ * Like V1, only an entry that is a local or remote server is lowered, and one
21911
+ * whose `enabled` or `disabled` is not a boolean is left as it is. Unlike V1,
21912
+ * such an entry is not dropped but passed on, so the schema rejects it loudly.
21913
+ */
21914
+ function lowerOpencodeV2Server(value, path, onDropped) {
21915
+ if (!isRecord$1(value) || value.type !== "local" && value.type !== "remote" || Object.hasOwn(value, "enabled") && typeof value.enabled !== "boolean" || Object.hasOwn(value, "disabled") && typeof value.disabled !== "boolean") return value;
21916
+ const { disabled, timeout, ...rest } = value;
21917
+ const lowered = { ...rest };
21918
+ if (!Object.hasOwn(value, "enabled")) lowered.enabled = disabled !== true;
21919
+ else if (value.enabled === disabled) onDropped?.(`${path}.disabled`, "conflict");
21920
+ const lowTimeout = isRecord$1(timeout) ? lowerOpencodeV2Timeout(timeout) : timeout;
21921
+ if (lowTimeout !== void 0) lowered.timeout = lowTimeout;
21922
+ else if (isRecord$1(timeout) && Object.keys(timeout).length > 0) onDropped?.(`${path}.timeout`, "unsupported");
21923
+ if (isRecord$1(value.oauth)) lowered.oauth = lowerOpencodeV2OAuth(value.oauth, `${path}.oauth`, onDropped);
21924
+ return lowered;
21925
+ }
21926
+ function isPositiveInteger(value) {
21927
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
21928
+ }
21929
+ /** Whether each V2 timeout value a record carries is a positive integer. */
21930
+ function hasPositiveIntegerTimeouts(value) {
21931
+ return OPENCODE_V2_TIMEOUT_KEYS.every((key) => value[key] === void 0 || isPositiveInteger(value[key]));
21932
+ }
21933
+ /**
21934
+ * Whether `mcp.timeout` is V2's global timeout object rather than a server
21935
+ * named `timeout`. As in OpenCode V1, each of its values must be a positive
21936
+ * integer; anything else is read as a server, which the schema then rejects.
21937
+ */
21938
+ function isOpencodeV2GlobalTimeout(value) {
21939
+ return isRecord$1(value) && !isDirectOpencodeMcpServer(value) && (Object.keys(value).length === 0 || OPENCODE_V2_TIMEOUT_KEYS.some((key) => Object.hasOwn(value, key))) && hasPositiveIntegerTimeouts(value);
21940
+ }
21941
+ function getOpencodeV2McpEnvelope(mcp) {
21942
+ const nested = mcp.servers;
21943
+ return isRecord$1(nested) && !isDirectOpencodeMcpServer(nested) ? nested : null;
21944
+ }
21945
+ /**
21946
+ * Returns the paths of the V2-only containers in an OpenCode `mcp` value that
21947
+ * a write in V1's flat shape does not keep: a non-empty `mcp.servers`
21948
+ * envelope, and a non-empty global `mcp.timeout`.
21949
+ */
21950
+ function findOpencodeV2McpContainers(mcp) {
21951
+ if (!isRecord$1(mcp)) return [];
21952
+ const envelope = getOpencodeV2McpEnvelope(mcp);
21953
+ return [...envelope !== null && Object.keys(envelope).length > 0 ? ["mcp.servers"] : [], ...isOpencodeV2GlobalTimeout(mcp.timeout) && Object.keys(mcp.timeout).length > 0 ? ["mcp.timeout"] : []];
21954
+ }
21955
+ /**
21956
+ * Lowers OpenCode V2 `mcp` spellings found in a V1 config to the flat V1 map,
21957
+ * the way OpenCode V1 itself does since v1.18.24 (`normalizeMcp`):
21958
+ *
21959
+ * - entries of the `mcp.servers` envelope are merged into the map, a flat entry
21960
+ * of the same name winning;
21961
+ * - V2-spelled server entries (`disabled`, an object `timeout`, snake_case
21962
+ * `oauth` keys) are lowered by {@link lowerOpencodeV2Server};
21963
+ * - the global `mcp.timeout` object is skipped. V1 moves it to
21964
+ * `experimental.mcp_timeout`, a default request timeout that has no
21965
+ * canonical counterpart, so it is not read as a server either.
21966
+ *
21967
+ * Unlike V1, an envelope entry that is not a valid server is not dropped: it
21968
+ * is passed on, so the schema rejects it loudly rather than it vanishing
21969
+ * without a word. Each value that is dropped — a non-empty global timeout, a
21970
+ * per-server timeout object or `oauth` key with no V1 equivalent, or an
21971
+ * envelope entry shadowed by a different flat entry — is reported through
21972
+ * `onDropped`.
21973
+ *
21974
+ * @see https://github.com/anomalyco/opencode/blob/v1.18.35/packages/opencode/src/config/v2-compat.ts
21975
+ * @see https://opencode.ai/v2/docs/mcp-servers/
21976
+ */
21977
+ function lowerOpencodeV2Mcp(mcp, onDropped) {
21978
+ if (!isRecord$1(mcp)) return mcp;
21979
+ const envelope = getOpencodeV2McpEnvelope(mcp);
21980
+ const globalTimeout = isOpencodeV2GlobalTimeout(mcp.timeout);
21981
+ if (findOpencodeV2McpContainers(mcp).includes("mcp.timeout")) onDropped?.("mcp.timeout", "unsupported");
21982
+ const servers = {};
21983
+ for (const [name, value] of Object.entries(mcp)) {
21984
+ if (name === "servers" && envelope !== null || name === "timeout" && globalTimeout) continue;
21985
+ setOwnEntry(servers, name, isRecord$1(value) && isOpencodeV2NativeServer(value) ? lowerOpencodeV2Server(value, `mcp.${name}`, onDropped) : value);
21986
+ }
21987
+ for (const [name, value] of Object.entries(envelope ?? {})) {
21988
+ const path = `mcp.servers.${name}`;
21989
+ if (Object.hasOwn(servers, name)) {
21990
+ if (!(0, node_util.isDeepStrictEqual)(servers[name], value)) onDropped?.(path, "conflict");
21991
+ continue;
21992
+ }
21993
+ setOwnEntry(servers, name, lowerOpencodeV2Server(value, path, onDropped));
21994
+ }
21995
+ return servers;
21996
+ }
21829
21997
  /** Matches a string whose entire value is an OpenCode `{file:...}` reference. */
21830
21998
  const OPENCODE_FILE_TEMPLATE_PATTERN = /^\s*\{file:(.+?)\}\s*$/;
21831
21999
  /**
@@ -32857,6 +33025,31 @@ const PI_PROMPT_BLOCKING_EVENT = "input";
32857
33025
  * @see https://github.com/earendil-works/pi/blob/v0.87.1/packages/coding-agent/src/core/extensions/types.ts
32858
33026
  */
32859
33027
  const PI_SETTLE_BLOCKING_EVENT = "agent_before_settle";
33028
+ /**
33029
+ * `session_stop` is oh-my-pi's stop surface (it has no `agent_before_settle`).
33030
+ * It fires when a main-session run is about to settle (never for subagents,
33031
+ * nor for a run the user aborted), and a handler returning
33032
+ * `{ continue: true, additionalContext }` queues one more turn with that text
33033
+ * as hidden model-visible context. As on Pi, only a stop command exiting with
33034
+ * code 2 asks for that, the feedback of every such command is combined, and
33035
+ * any other failure is rethrown for oh-my-pi to report. The generated handler
33036
+ * continues only when `event.stop_hook_active` is false, which oh-my-pi sets
33037
+ * after a continuation and clears once a stop pass lets the run settle, so the
33038
+ * agent is continued at most once per stop (the flag is shared by every
33039
+ * extension, so another extension's continuation also suppresses ours);
33040
+ * oh-my-pi additionally caps these
33041
+ * advisory continuations at 8 in a row. A run that ended on a provider error
33042
+ * (`stopReason: "error"`) is not continued, matching Pi's `completed`-only
33043
+ * gate. The advisory form is used rather than `{ decision: "block", reason }`,
33044
+ * which oh-my-pi keeps enforcing without that cap. oh-my-pi gives
33045
+ * `session_stop` handlers the 30-second extension handler budget: stop
33046
+ * commands running longer time the handler out, the run settles without a
33047
+ * continuation, and the command is not killed.
33048
+ *
33049
+ * @see https://github.com/can1357/oh-my-pi/blob/fa5ff4a3a977a28b93d025f4cf50f046e019f18c/docs/extensions.md
33050
+ * @see https://github.com/can1357/oh-my-pi/blob/fa5ff4a3a977a28b93d025f4cf50f046e019f18c/packages/coding-agent/src/session/agent-session.ts
33051
+ */
33052
+ const OMP_SESSION_STOP_BLOCKING_EVENT = "session_stop";
32860
33053
  const PI_EXTENSION_DIALECT = {
32861
33054
  overrideKey: "pi",
32862
33055
  typesPackage: "@earendil-works/pi-coding-agent",
@@ -32878,7 +33071,8 @@ const OMP_EXTENSION_DIALECT = {
32878
33071
  const PI_BLOCKING_MODE_BY_EVENT = {
32879
33072
  [PI_BLOCKING_EVENT]: "tool",
32880
33073
  [PI_PROMPT_BLOCKING_EVENT]: "prompt",
32881
- [PI_SETTLE_BLOCKING_EVENT]: "settle"
33074
+ [PI_SETTLE_BLOCKING_EVENT]: "settle",
33075
+ [OMP_SESSION_STOP_BLOCKING_EVENT]: "sessionStop"
32882
33076
  };
32883
33077
  /**
32884
33078
  * The body emitted inside a blocking handler's `catch`, per gate. `tool_call`
@@ -32891,7 +33085,8 @@ function failureLinesByMode(dialect) {
32891
33085
  none: [],
32892
33086
  tool: ["return { block: true, reason: toBlockReason(error) };"],
32893
33087
  prompt: ["reportPromptGateFailure(ctx, toBlockReason(error));", dialect.promptCancelLine],
32894
- settle: ["stopHookFailures.push(error);"]
33088
+ settle: ["stopHookFailures.push(error);"],
33089
+ sessionStop: ["stopHookFailures.push(error);"]
32895
33090
  };
32896
33091
  }
32897
33092
  /**
@@ -32983,6 +33178,12 @@ const PROMPT_GATE_HELPER_LINES = [
32983
33178
  " console.error(reason);",
32984
33179
  "}"
32985
33180
  ];
33181
+ /** Helper emitted alongside every stop gate (Pi's settle gate and oh-my-pi's `session_stop`). */
33182
+ const STOP_HOOK_BLOCK_HELPER_LINES = [
33183
+ "function isStopHookBlock(error: unknown): boolean {",
33184
+ " return (error as { code?: unknown } | null)?.code === 2;",
33185
+ "}"
33186
+ ];
32986
33187
  /**
32987
33188
  * Helper emitted alongside the stop hook's settle gate. `event.entries` holds
32988
33189
  * the drafts earlier extensions proposed at this boundary, so they are kept and
@@ -32993,10 +33194,6 @@ const PROMPT_GATE_HELPER_LINES = [
32993
33194
  * appended message is committed, and Pi re-validates the final context itself.
32994
33195
  */
32995
33196
  const STOP_HOOK_HELPER_LINES = [
32996
- "function isStopHookBlock(error: unknown): boolean {",
32997
- " return (error as { code?: unknown } | null)?.code === 2;",
32998
- "}",
32999
- "",
33000
33197
  "function continueAfterStopHook(",
33001
33198
  " event: AgentBeforeSettleEvent,",
33002
33199
  " reason: string,",
@@ -33079,14 +33276,16 @@ function buildSubscriptionLines(handlerGroups, dialect) {
33079
33276
  const blocking = PI_BLOCKING_MODE_BY_EVENT[piEvent] ?? "none";
33080
33277
  const isPromptGate = blocking === "prompt";
33081
33278
  const isSettleGate = blocking === "settle";
33279
+ const isSessionStopGate = blocking === "sessionStop";
33280
+ const collectsStopFailures = isSettleGate || isSessionStopGate;
33082
33281
  const usesToolName = PI_TOOL_EVENTS.has(piEvent) && handlers.some((h) => h.matcher);
33083
33282
  const usesErrorFlag = handlers.some((h) => h.onlyOnError);
33084
33283
  const gatesOnAssistant = PI_ASSISTANT_MESSAGE_EVENTS.has(piEvent);
33085
- const params = isPromptGate ? "event, ctx" : usesToolName || usesErrorFlag || gatesOnAssistant || isPromptGate || isSettleGate ? "event" : "";
33284
+ const params = isPromptGate ? "event, ctx" : usesToolName || usesErrorFlag || gatesOnAssistant || isPromptGate || collectsStopFailures ? "event" : "";
33086
33285
  lines.push(` pi.on(${JSON.stringify(piEvent)}, async (${params}) => {`);
33087
33286
  if (gatesOnAssistant) lines.push(` if (event.message.role !== "assistant") return;`);
33088
33287
  if (isPromptGate) lines.push(` if (event.source === "extension") ${dialect.promptPassLine}`);
33089
- if (isSettleGate) lines.push(" const stopHookFailures: unknown[] = [];");
33288
+ if (collectsStopFailures) lines.push(" const stopHookFailures: unknown[] = [];");
33090
33289
  for (const handler of handlers) lines.push(...buildCommandLines({
33091
33290
  handler,
33092
33291
  usesToolName,
@@ -33102,6 +33301,17 @@ function buildSubscriptionLines(handlerGroups, dialect) {
33102
33301
  lines.push(" }");
33103
33302
  lines.push(" if (stopHookFailures.length > 0) throw stopHookFailures[0];");
33104
33303
  }
33304
+ if (isSessionStopGate) {
33305
+ lines.push(" const blocks = stopHookFailures.filter(isStopHookBlock);");
33306
+ lines.push(" const endedOnError = (event.last_assistant_message as { stopReason?: unknown } | undefined)?.stopReason === \"error\";");
33307
+ lines.push(" if (blocks.length > 0 && !event.stop_hook_active && !endedOnError) {");
33308
+ lines.push(" return {");
33309
+ lines.push(" continue: true,");
33310
+ lines.push(" additionalContext: `Stop hook feedback:\\n${blocks.map(toBlockReason).join(\"\\n\\n\")}`,");
33311
+ lines.push(" };");
33312
+ lines.push(" }");
33313
+ lines.push(" if (stopHookFailures.length > 0) throw stopHookFailures[0];");
33314
+ }
33105
33315
  lines.push(" });");
33106
33316
  if (isSettleGate) {
33107
33317
  lines.push(` pi.on("agent_settled", async () => {`);
@@ -33120,8 +33330,9 @@ function buildSubscriptionLines(handlerGroups, dialect) {
33120
33330
  * `{ block: true, reason }`, and on `input` — Pi's prompt-submission gate —
33121
33331
  * where a non-zero exit cancels the prompt with the dialect's cancel result
33122
33332
  * (`{ 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.
33333
+ * on `agent_before_settle` (Pi) / `session_stop` (oh-my-pi), where a stop
33334
+ * command exiting with code 2 asks the agent to continue once with the
33335
+ * command's output as feedback.
33125
33336
  * `postToolUse` and `postToolUseFailure` share Pi's `tool_result` event; the
33126
33337
  * latter's commands run only when `event.isError` is set.
33127
33338
  *
@@ -33142,7 +33353,8 @@ function generatePiExtensionCode({ config, supportedEvents, eventMap, dialect =
33142
33353
  const subscriptionLines = buildSubscriptionLines(handlerGroups, dialect);
33143
33354
  const hasPromptGate = Boolean(handlerGroups[PI_PROMPT_BLOCKING_EVENT]);
33144
33355
  const hasSettleGate = Boolean(handlerGroups[PI_SETTLE_BLOCKING_EVENT]);
33145
- const needsBlockReasonHelper = Boolean(handlerGroups[PI_BLOCKING_EVENT]) || hasPromptGate || hasSettleGate;
33356
+ const hasSessionStopGate = Boolean(handlerGroups[OMP_SESSION_STOP_BLOCKING_EVENT]);
33357
+ const needsBlockReasonHelper = Boolean(handlerGroups[PI_BLOCKING_EVENT]) || hasPromptGate || hasSettleGate || hasSessionStopGate;
33146
33358
  const lines = ["// Generated by rulesync. Do not edit manually."];
33147
33359
  if (subscriptionLines.length === 0) {
33148
33360
  lines.push("export default function () {}");
@@ -33169,6 +33381,10 @@ function generatePiExtensionCode({ config, supportedEvents, eventMap, dialect =
33169
33381
  lines.push(...PROMPT_GATE_HELPER_LINES);
33170
33382
  lines.push("");
33171
33383
  }
33384
+ if (hasSettleGate || hasSessionStopGate) {
33385
+ lines.push(...STOP_HOOK_BLOCK_HELPER_LINES);
33386
+ lines.push("");
33387
+ }
33172
33388
  if (hasSettleGate) {
33173
33389
  lines.push(...STOP_HOOK_HELPER_LINES);
33174
33390
  lines.push("");
@@ -45526,18 +45742,21 @@ var KiroMcp = class KiroMcp extends ToolMcp {
45526
45742
  };
45527
45743
  //#endregion
45528
45744
  //#region src/features/mcp/opencode-mcp.ts
45745
+ const OpencodeMcpV2DisabledSchema = zod_mini.z.optional(zod_mini.z.boolean());
45529
45746
  const OpencodeMcpLocalServerSchema = zod_mini.z.looseObject({
45530
45747
  type: zod_mini.z.literal("local"),
45531
45748
  command: zod_mini.z.array(zod_mini.z.string()),
45532
45749
  environment: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), zod_mini.z.string())),
45533
45750
  enabled: zod_mini.z._default(zod_mini.z.boolean(), true),
45751
+ disabled: OpencodeMcpV2DisabledSchema,
45534
45752
  cwd: zod_mini.z.optional(zod_mini.z.string())
45535
45753
  });
45536
45754
  const OpencodeMcpRemoteServerSchema = zod_mini.z.looseObject({
45537
45755
  type: zod_mini.z.literal("remote"),
45538
45756
  url: zod_mini.z.string(),
45539
45757
  headers: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), zod_mini.z.string())),
45540
- enabled: zod_mini.z._default(zod_mini.z.boolean(), true)
45758
+ enabled: zod_mini.z._default(zod_mini.z.boolean(), true),
45759
+ disabled: OpencodeMcpV2DisabledSchema
45541
45760
  });
45542
45761
  const OPENCODE_PASSTHROUGH_SERVER_FIELDS = [
45543
45762
  "timeout",
@@ -45549,7 +45768,7 @@ const OPENCODE_MCP_TRANSPORT_KEYS = [
45549
45768
  ...Object.keys(OpencodeMcpLocalServerSchema.def.shape),
45550
45769
  ...Object.keys(OpencodeMcpRemoteServerSchema.def.shape),
45551
45770
  ...OPENCODE_PASSTHROUGH_SERVER_FIELDS
45552
- ].filter((key) => key !== "enabled");
45771
+ ].filter((key) => key !== "enabled" && key !== "disabled");
45553
45772
  /**
45554
45773
  * A bare toggle entry: `{"enabled": <bool>}` with no transport of its own,
45555
45774
  * disabling a server another config layer defines. It is the third member of
@@ -45677,7 +45896,7 @@ const OPENCODE_TOGGLE_KEPT_KEYS = /* @__PURE__ */ new Set([
45677
45896
  function warnAboutToggleDroppedKeys(serverName, serverConfig, logger) {
45678
45897
  const dropped = Object.keys(serverConfig).filter((key) => !OPENCODE_TOGGLE_KEPT_KEYS.has(key));
45679
45898
  if (dropped.length === 0) return;
45680
- logger?.warn(`OpenCode MCP: "${serverName}" declares no transport, so it is written as a toggle entry and ${dropped.toSorted().join(", ")} ${dropped.length === 1 ? "is" : "are"} dropped.`);
45899
+ logger?.warn(`OpenCode MCP: ${quoteValueForWarning(serverName)} declares no transport, so it is written as a toggle entry and ${dropped.toSorted().map(quoteValueForWarning).join(", ")} ${dropped.length === 1 ? "is" : "are"} dropped.`);
45681
45900
  }
45682
45901
  /**
45683
45902
  * Convert standard MCP format to OpenCode native format
@@ -45757,9 +45976,35 @@ var OpencodeMcp = class extends ToolMcp {
45757
45976
  /** Directory layout; OpenCode forks (MiMo Code) override it. */
45758
45977
  static layout = OPENCODE_LAYOUT;
45759
45978
  json;
45760
- constructor(params) {
45979
+ /**
45980
+ * With a `logger`, the V2 values that lowering the content drops are warned
45981
+ * about (see {@link OpencodeMcp.lowerConfig}); `fromFile` passes one so the
45982
+ * warnings come from the lowering that builds the parsed view rather than
45983
+ * from an extra pass of their own. (`validate()`, which the parent
45984
+ * constructor calls before this view exists, lowers separately and silently.)
45985
+ */
45986
+ constructor({ logger, ...params }) {
45761
45987
  super(params);
45762
- this.json = OpencodeConfigSchema.parse((0, jsonc_parser.parse)(this.fileContent || "{}"));
45988
+ this.json = OpencodeConfigSchema.parse(this.constructor.lowerConfig((0, jsonc_parser.parse)(this.fileContent || "{}"), logger));
45989
+ }
45990
+ /**
45991
+ * Lowers the OpenCode V2 `mcp` spellings of a parsed config for a layout
45992
+ * that reads them (see {@link lowerOpencodeV2Mcp}), so every reader — import,
45993
+ * the toggle lookup on generate, and the rules feature's `instructions`
45994
+ * write to the same file — accepts a V2-shaped `mcp`. Only the parsed view
45995
+ * is lowered; content written back keeps whatever `mcp` a feature that does
45996
+ * not own the key found there. With a `logger`, each V2 value the lowering
45997
+ * drops is warned about.
45998
+ */
45999
+ static lowerConfig(json, logger) {
46000
+ if (!this.layout.readsV2ConfigSpellings || !isRecord$1(json) || json.mcp === void 0) return json;
46001
+ return {
46002
+ ...json,
46003
+ mcp: lowerOpencodeV2Mcp(json.mcp, (path, reason) => {
46004
+ const quoted = quoteValueForWarning(path);
46005
+ logger?.warn(reason === "conflict" ? `OpenCode MCP: ${quoted} conflicts with a V1 value that takes precedence (the flat "mcp" entry of the same name, or "enabled"), so it is dropped on import.` : `OpenCode MCP: ${quoted} has no Rulesync equivalent, so it is dropped on import.`);
46006
+ })
46007
+ };
45763
46008
  }
45764
46009
  getJson() {
45765
46010
  return this.json;
@@ -45776,7 +46021,7 @@ var OpencodeMcp = class extends ToolMcp {
45776
46021
  relativeFilePath: this.layout.jsonFileName
45777
46022
  };
45778
46023
  }
45779
- static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
46024
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false, logger }) {
45780
46025
  const basePaths = this.getSettablePaths({ global });
45781
46026
  const jsonDir = (0, node_path.join)(outputRoot, basePaths.relativeDirPath);
45782
46027
  let fileContent = null;
@@ -45798,7 +46043,8 @@ var OpencodeMcp = class extends ToolMcp {
45798
46043
  relativeDirPath: basePaths.relativeDirPath,
45799
46044
  relativeFilePath,
45800
46045
  fileContent: JSON.stringify(newJson, null, 2),
45801
- validate
46046
+ validate,
46047
+ logger
45802
46048
  });
45803
46049
  }
45804
46050
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
@@ -45817,7 +46063,12 @@ var OpencodeMcp = class extends ToolMcp {
45817
46063
  mcpServers: rulesyncMcp.getMcpServers(),
45818
46064
  replacement: "{env:$1}"
45819
46065
  });
45820
- const existingMcp = OpencodeConfigSchema.safeParse((0, jsonc_parser.parse)(fileContent || "{}"));
46066
+ const existingJson = (0, jsonc_parser.parse)(fileContent || "{}");
46067
+ const existingMcp = OpencodeConfigSchema.safeParse(this.lowerConfig(existingJson));
46068
+ if (this.layout.readsV2ConfigSpellings && isRecord$1(existingJson)) {
46069
+ const containers = findOpencodeV2McpContainers(existingJson.mcp);
46070
+ if (containers.length > 0) logger?.warn(`OpenCode MCP: the whole "mcp" key is rewritten in the flat V1 shape, so ${containers.map(quoteValueForWarning).join(" and ")} ${containers.length === 1 ? "is" : "are"} not kept.`);
46071
+ }
45821
46072
  const { mcp: convertedMcp, tools: mcpTools } = convertToOpencodeFormat(transformedServers, (existingMcp.success ? existingMcp.data.mcp : void 0) ?? {}, logger);
45822
46073
  return new this({
45823
46074
  outputRoot,
@@ -45917,7 +46168,7 @@ var OpencodeMcp = class extends ToolMcp {
45917
46168
  }
45918
46169
  validate() {
45919
46170
  const json = parseJsonc$6(this.fileContent || "{}");
45920
- const result = OpencodeConfigSchema.safeParse(json);
46171
+ const result = OpencodeConfigSchema.safeParse(this.constructor.lowerConfig(json));
45921
46172
  if (!result.success) return {
45922
46173
  success: false,
45923
46174
  error: result.error
@@ -55965,6 +56216,124 @@ function parseCodebuddySettings({ fileContent, relativePath }) {
55965
56216
  }
55966
56217
  }
55967
56218
  //#endregion
56219
+ //#region src/features/permissions/codewhale-project-config.ts
56220
+ /**
56221
+ * The top-level key of the per-project `.codewhale/config.toml` overlay that
56222
+ * hides Codewhale's shell tools. The overlay only tightens: `false` disables
56223
+ * shell for the session, while `true` is ignored with a warning.
56224
+ * @see https://github.com/Hmbown/Codewhale/blob/fead51eeea0fb6038ea3e30923751b1a1c765b25/docs/CONFIGURATION.md#per-project-overlay-485
56225
+ * @see https://github.com/Hmbown/Codewhale/blob/fead51eeea0fb6038ea3e30923751b1a1c765b25/crates/tui/src/lib.rs
56226
+ */
56227
+ const CODEWHALE_ALLOW_SHELL_KEY = "allow_shell";
56228
+ const CODEWHALE_PROJECT_CONFIG_PATHS = {
56229
+ relativeDirPath: CODEWHALE_DIR,
56230
+ relativeFilePath: CODEWHALE_CONFIG_FILE_NAME
56231
+ };
56232
+ /** The project `.codewhale/config.toml` overlay, as written by the permissions feature. */
56233
+ var CodewhaleProjectConfigFile = class extends ToolFile {
56234
+ validate() {
56235
+ return {
56236
+ success: true,
56237
+ error: null
56238
+ };
56239
+ }
56240
+ };
56241
+ /**
56242
+ * Whether the canonical config denies every shell command: a `deny` on the
56243
+ * catch-all `*` (or `**`) pattern of `bash` or of the all-tools `*` category.
56244
+ * Codewhale decides by action before specificity, so such a deny already
56245
+ * blocks every command in global scope too, whatever `allow` rules sit beside
56246
+ * it.
56247
+ */
56248
+ function deniesEveryShellCommand(permission) {
56249
+ return collectShellCommandRules(permission).rules.some(({ pattern, action }) => action === "deny" && isShellCatchAll(pattern));
56250
+ }
56251
+ /** Whether a canonical shell pattern names every command. */
56252
+ function isShellCatchAll(pattern) {
56253
+ const trimmed = pattern.trim();
56254
+ return trimmed === "*" || trimmed === "**";
56255
+ }
56256
+ function projectConfigFilePath(outputRoot) {
56257
+ return (0, node_path.join)(outputRoot, CODEWHALE_PROJECT_CONFIG_PATHS.relativeDirPath, CODEWHALE_PROJECT_CONFIG_PATHS.relativeFilePath);
56258
+ }
56259
+ function parseProjectConfig({ fileContent, filePath }) {
56260
+ return parseSharedConfig({
56261
+ format: "toml",
56262
+ fileContent,
56263
+ filePath,
56264
+ invalidRootPolicy: "error"
56265
+ });
56266
+ }
56267
+ /**
56268
+ * Whether an overlay disables shell (`allow_shell = false`). A file that
56269
+ * cannot be read as TOML is reported and treated as not disabling shell.
56270
+ */
56271
+ function disablesShell({ fileContent, filePath, logger }) {
56272
+ try {
56273
+ return parseProjectConfig({
56274
+ fileContent,
56275
+ filePath
56276
+ })[CODEWHALE_ALLOW_SHELL_KEY] === false;
56277
+ } catch (error) {
56278
+ logger?.warn(`Codewhale permissions: not reading "allow_shell" from ${filePath}, so a shell deny set there is not taken into account: ${formatError(error)}`);
56279
+ return false;
56280
+ }
56281
+ }
56282
+ /**
56283
+ * Whether the project overlay at `outputRoot` disables shell
56284
+ * (`allow_shell = false`), for import.
56285
+ */
56286
+ async function readCodewhaleProjectShellDisabled({ outputRoot, logger }) {
56287
+ const filePath = projectConfigFilePath(outputRoot);
56288
+ const fileContent = await readFileContentOrNull(filePath);
56289
+ if (fileContent === null) return false;
56290
+ return disablesShell({
56291
+ fileContent,
56292
+ filePath,
56293
+ logger
56294
+ });
56295
+ }
56296
+ /**
56297
+ * The project `.codewhale/config.toml` overlay carrying `allow_shell = false`
56298
+ * when the canonical config denies every shell command, or `null` when the
56299
+ * file needs no change.
56300
+ *
56301
+ * Only `allow_shell` is written; every other overlay key (`model`,
56302
+ * `approval_policy`, ...) is kept, and an existing file that is not valid TOML
56303
+ * is an error only when it has to be written. An existing
56304
+ * `allow_shell = false` is never removed: the overlay cannot widen access, and
56305
+ * a hand-written value cannot be told apart from one rulesync wrote, so it is
56306
+ * reported instead.
56307
+ */
56308
+ async function createCodewhaleProjectConfigFile({ outputRoot = process.cwd(), config, logger }) {
56309
+ const filePath = projectConfigFilePath(outputRoot);
56310
+ const existingContent = await readFileContentOrNull(filePath);
56311
+ if (!deniesEveryShellCommand(config.permission)) {
56312
+ if (existingContent !== null && disablesShell({
56313
+ fileContent: existingContent,
56314
+ filePath,
56315
+ logger
56316
+ })) 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.`);
56317
+ return null;
56318
+ }
56319
+ if (parseProjectConfig({
56320
+ fileContent: existingContent ?? "",
56321
+ filePath
56322
+ })[CODEWHALE_ALLOW_SHELL_KEY] === false) return null;
56323
+ return new CodewhaleProjectConfigFile({
56324
+ outputRoot,
56325
+ ...CODEWHALE_PROJECT_CONFIG_PATHS,
56326
+ fileContent: applySharedConfigPatch({
56327
+ fileKey: sharedConfigFileKey(CODEWHALE_PROJECT_CONFIG_PATHS),
56328
+ feature: "permissions",
56329
+ existingContent: existingContent ?? "",
56330
+ patch: { [CODEWHALE_ALLOW_SHELL_KEY]: false },
56331
+ filePath,
56332
+ logger
56333
+ })
56334
+ });
56335
+ }
56336
+ //#endregion
55968
56337
  //#region src/features/permissions/codewhale-repo-law.ts
55969
56338
  /**
55970
56339
  * Project-scope Codewhale permissions: the enforced `protected_invariants` of
@@ -56115,7 +56484,8 @@ function collectRestrictions({ config, logger }) {
56115
56484
  let skippedAllowCount = 0;
56116
56485
  for (const [category, rules] of Object.entries(config.permission)) {
56117
56486
  if (!REPO_LAW_CATEGORIES.includes(category)) {
56118
- if (Object.keys(rules).length > 0) skippedCategories.push(category);
56487
+ const entries = Object.entries(rules);
56488
+ if ((category === "bash" && entries.some(([pattern, action]) => action === "deny" && isShellCatchAll(pattern)) ? entries.filter(([, action]) => action === "allow") : entries).length > 0) skippedCategories.push(category);
56119
56489
  continue;
56120
56490
  }
56121
56491
  for (const [pattern, action] of Object.entries(rules)) {
@@ -56841,17 +57211,32 @@ async function warnOnShadowedConstitution({ outputRoot, logger }) {
56841
57211
  * Project scope manages the enforced `protected_invariants` of the repo
56842
57212
  * constitution `.codewhale/constitution.json` (see `codewhale-repo-law.ts`):
56843
57213
  * the project `.codewhale/config.toml` overlay cannot carry rules, but repo
56844
- * law can hold writes to protected paths.
57214
+ * law can hold writes to protected paths. A deny on every shell command also
57215
+ * sets `allow_shell = false` in that overlay (see
57216
+ * `codewhale-project-config.ts`), which import reads back here.
56845
57217
  *
56846
57218
  * @see https://github.com/Hmbown/Codewhale/blob/main/docs/CONFIGURATION.md
56847
57219
  * @see https://github.com/Hmbown/Codewhale/blob/main/docs/AUTHORIZATION_ORDER.md
56848
57220
  */
56849
57221
  var CodewhalePermissions = class CodewhalePermissions extends ToolPermissions {
56850
- constructor(params) {
57222
+ /**
57223
+ * Whether the project `.codewhale/config.toml` overlay disables shell
57224
+ * (`allow_shell = false`); read on import in project scope only.
57225
+ */
57226
+ projectShellDisabled;
57227
+ constructor({ projectShellDisabled = false, ...params }) {
56851
57228
  super({
56852
57229
  ...params,
56853
57230
  fileContent: params.fileContent ?? ""
56854
57231
  });
57232
+ this.projectShellDisabled = projectShellDisabled;
57233
+ }
57234
+ /**
57235
+ * The project `.codewhale/config.toml` overlay, where a deny on every shell
57236
+ * command becomes `allow_shell = false` beside the user's other overlay keys.
57237
+ */
57238
+ static getExtraSharedWritePaths(options) {
57239
+ return options?.global ? [] : [{ ...CODEWHALE_PROJECT_CONFIG_PATHS }];
56855
57240
  }
56856
57241
  /**
56857
57242
  * Codewhale's approval card writes to the global file, and the constitution
@@ -56888,7 +57273,11 @@ var CodewhalePermissions = class CodewhalePermissions extends ToolPermissions {
56888
57273
  relativeFilePath: paths.relativeFilePath,
56889
57274
  fileContent,
56890
57275
  validate,
56891
- global
57276
+ global,
57277
+ projectShellDisabled: global ? false : await readCodewhaleProjectShellDisabled({
57278
+ outputRoot,
57279
+ logger
57280
+ })
56892
57281
  });
56893
57282
  }
56894
57283
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, logger, global = false }) {
@@ -56950,11 +57339,15 @@ var CodewhalePermissions = class CodewhalePermissions extends ToolPermissions {
56950
57339
  toRulesyncPermissions() {
56951
57340
  const filePath = (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath());
56952
57341
  if (!this.global) {
56953
- const document = parseCodewhaleConstitution({
57342
+ const permission = codewhaleConstitutionToCanonical(parseCodewhaleConstitution({
56954
57343
  fileContent: this.getFileContent(),
56955
57344
  filePath
56956
- });
56957
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission: codewhaleConstitutionToCanonical(document) }, null, 2) });
57345
+ }));
57346
+ if (this.projectShellDisabled) permission.bash = {
57347
+ ...permission.bash,
57348
+ "*": "deny"
57349
+ };
57350
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
56958
57351
  }
56959
57352
  const { permission, nativeRules } = codewhaleRulesToCanonical(partitionRuleRecords(parsePermissionsDocument({
56960
57353
  fileContent: this.getFileContent(),
@@ -71883,6 +72276,14 @@ var PermissionsProcessor = class extends FeatureProcessor {
71883
72276
  logger: this.logger,
71884
72277
  global: this.global
71885
72278
  });
72279
+ if (this.toolTarget === "codewhale" && !this.global) {
72280
+ const projectConfigFile = await createCodewhaleProjectConfigFile({
72281
+ outputRoot: this.outputRoot,
72282
+ config: effectivePermissions.getJson(),
72283
+ logger: this.logger
72284
+ });
72285
+ return projectConfigFile ? [toolPermissions, projectConfigFile] : [toolPermissions];
72286
+ }
71886
72287
  if (this.toolTarget !== "codexcli") return [toolPermissions];
71887
72288
  return [toolPermissions, createCodexcliBashRulesFile({
71888
72289
  outputRoot: this.outputRoot,