@cortexkit/opencode-magic-context 0.33.1 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/README.md +20 -3
  2. package/dist/config/agent-disable.d.ts +22 -0
  3. package/dist/config/agent-disable.d.ts.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/migrate-experimental.d.ts +2 -1
  6. package/dist/config/migrate-experimental.d.ts.map +1 -1
  7. package/dist/config/project-security.d.ts +9 -0
  8. package/dist/config/project-security.d.ts.map +1 -1
  9. package/dist/config/schema/magic-context.d.ts +90 -17
  10. package/dist/config/schema/magic-context.d.ts.map +1 -1
  11. package/dist/config/transform-mode.d.ts +2 -0
  12. package/dist/config/transform-mode.d.ts.map +1 -1
  13. package/dist/{conflict-warning-hook-n48c783v.js → conflict-warning-hook-fvpfvt0w.js} +1 -1
  14. package/dist/{derive-budgets-e9j09sjr.js → derive-budgets-9crp0cvd.js} +1 -1
  15. package/dist/features/builtin-commands/commands.d.ts +1 -1
  16. package/dist/features/builtin-commands/commands.d.ts.map +1 -1
  17. package/dist/features/magic-context/compaction-marker.d.ts +57 -0
  18. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  19. package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/classify.d.ts +1 -0
  21. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/map-memories.d.ts +1 -0
  24. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +1 -0
  26. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts +2 -0
  28. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts.map +1 -1
  29. package/dist/features/magic-context/dreamer/task-executor.d.ts +4 -1
  30. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  31. package/dist/features/magic-context/dreamer/task-gates.d.ts +19 -1
  32. package/dist/features/magic-context/dreamer/task-gates.d.ts.map +1 -1
  33. package/dist/features/magic-context/dreamer/task-registry.d.ts +28 -0
  34. package/dist/features/magic-context/dreamer/task-registry.d.ts.map +1 -1
  35. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +7 -1
  36. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  37. package/dist/features/magic-context/dreamer/verify.d.ts +1 -0
  38. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  39. package/dist/features/magic-context/fail-closed-block.d.ts +10 -0
  40. package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
  41. package/dist/features/magic-context/git-commits/storage-git-commit-embeddings.d.ts.map +1 -1
  42. package/dist/features/magic-context/git-commits/storage-git-commits.d.ts.map +1 -1
  43. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -1
  44. package/dist/features/magic-context/memory/embedding-local.d.ts +7 -1
  45. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  46. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  47. package/dist/features/magic-context/memory/memory-migration.d.ts.map +1 -1
  48. package/dist/features/magic-context/memory/project-identity.d.ts +2 -6
  49. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  50. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts.map +1 -1
  51. package/dist/features/magic-context/migrations.d.ts +23 -1
  52. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  53. package/dist/features/magic-context/mural/compress-cues.d.ts +1 -0
  54. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  55. package/dist/features/magic-context/mural/render-mural.d.ts.map +1 -1
  56. package/dist/features/magic-context/overflow-detection.d.ts +8 -1
  57. package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
  58. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  59. package/dist/features/magic-context/scheduler.d.ts.map +1 -1
  60. package/dist/features/magic-context/schema-fence-probe.d.ts +28 -0
  61. package/dist/features/magic-context/schema-fence-probe.d.ts.map +1 -0
  62. package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
  63. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  64. package/dist/features/magic-context/storage-db.d.ts +37 -2
  65. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  66. package/dist/features/magic-context/storage-meta-persisted.d.ts +38 -4
  67. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  68. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  69. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  70. package/dist/features/magic-context/storage.d.ts +2 -2
  71. package/dist/features/magic-context/storage.d.ts.map +1 -1
  72. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/boundary-execution.d.ts +1 -1
  74. package/dist/hooks/magic-context/boundary-execution.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/cache-busting-signals.d.ts +1 -0
  76. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/child-session-spawn.d.ts +26 -0
  78. package/dist/hooks/magic-context/child-session-spawn.d.ts.map +1 -0
  79. package/dist/hooks/magic-context/command-handler.d.ts +3 -0
  80. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/compaction-off-transition.d.ts +96 -0
  82. package/dist/hooks/magic-context/compaction-off-transition.d.ts.map +1 -0
  83. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +2 -0
  84. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  85. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  86. package/dist/hooks/magic-context/compartment-runner-mapping.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  89. package/dist/hooks/magic-context/compartment-trigger.d.ts +12 -5
  90. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  91. package/dist/hooks/magic-context/ctx-reduce-availability.d.ts +47 -1
  92. package/dist/hooks/magic-context/ctx-reduce-availability.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/emergency-drop.d.ts +2 -2
  94. package/dist/hooks/magic-context/event-handler.d.ts +7 -0
  95. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/event-resolvers.d.ts +13 -21
  97. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/execute-status.d.ts +5 -1
  99. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  100. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +2 -0
  101. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -1
  102. package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -1
  103. package/dist/hooks/magic-context/hook-handlers.d.ts +1 -0
  104. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  105. package/dist/hooks/magic-context/hook.d.ts +14 -6
  106. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  107. package/dist/hooks/magic-context/inject-compartments.d.ts +10 -1
  108. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/live-session-state.d.ts +3 -0
  110. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  111. package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -1
  112. package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -1
  114. package/dist/hooks/magic-context/rust-mode-transform.d.ts +3 -0
  115. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  116. package/dist/hooks/magic-context/send-session-notification.d.ts +20 -1
  117. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  118. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +3 -0
  119. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  120. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +31 -1
  121. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  122. package/dist/hooks/magic-context/transform.d.ts +29 -2
  123. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  124. package/dist/{index-56yjaeb2.js → index-2y0ybv5x.js} +11 -1
  125. package/dist/{index-ey1bc14z.js → index-b563sga5.js} +7 -264
  126. package/dist/{index-y1c9tcm3.js → index-cn960ffd.js} +101 -20
  127. package/dist/{index-xyaekybw.js → index-dtas91s5.js} +6 -4
  128. package/dist/index-fcyvznch.js +279 -0
  129. package/dist/{index-63dq2g4d.js → index-ry8rh8cv.js} +138 -24
  130. package/dist/{index-c2gahrks.js → index-x8spt5wy.js} +1682 -893
  131. package/dist/index.d.ts.map +1 -1
  132. package/dist/index.js +1677 -723
  133. package/dist/plugin/dream-timer.d.ts +4 -2
  134. package/dist/plugin/dream-timer.d.ts.map +1 -1
  135. package/dist/plugin/embedding-bootstrap.d.ts.map +1 -1
  136. package/dist/plugin/hooks/create-session-hooks.d.ts +12 -1
  137. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  138. package/dist/plugin/messages-transform.d.ts +10 -0
  139. package/dist/plugin/messages-transform.d.ts.map +1 -1
  140. package/dist/plugin/rpc-handlers.d.ts +3 -7
  141. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  142. package/dist/plugin/tool-registry.d.ts +8 -0
  143. package/dist/plugin/tool-registry.d.ts.map +1 -1
  144. package/dist/{recomp-orchestrator-2yb2hfh2.js → recomp-orchestrator-k72ejkkh.js} +6 -4
  145. package/dist/send-session-notification-6njqewzk.js +20 -0
  146. package/dist/shared/conflict-detector.d.ts +38 -1
  147. package/dist/shared/conflict-detector.d.ts.map +1 -1
  148. package/dist/shared/conflict-fixer.d.ts +18 -1
  149. package/dist/shared/conflict-fixer.d.ts.map +1 -1
  150. package/dist/shared/context-limit-provenance.d.ts +3 -0
  151. package/dist/shared/context-limit-provenance.d.ts.map +1 -0
  152. package/dist/shared/escalation-bands.d.ts +9 -0
  153. package/dist/shared/escalation-bands.d.ts.map +1 -0
  154. package/dist/shared/format-threshold.d.ts +1 -1
  155. package/dist/shared/models-dev-cache.d.ts +29 -3
  156. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  157. package/dist/shared/rpc-server.d.ts.map +1 -1
  158. package/dist/shared/rpc-types.d.ts +51 -1
  159. package/dist/shared/rpc-types.d.ts.map +1 -1
  160. package/dist/shared/rpc-utils.d.ts +19 -0
  161. package/dist/shared/rpc-utils.d.ts.map +1 -1
  162. package/dist/shared/storage-permissions.d.ts +5 -0
  163. package/dist/shared/storage-permissions.d.ts.map +1 -0
  164. package/dist/shared/tui-runtime-specifiers.d.ts +20 -0
  165. package/dist/shared/tui-runtime-specifiers.d.ts.map +1 -0
  166. package/dist/{storage-meta-session-td6bwtky.js → storage-meta-session-xt41zbw4.js} +2 -1
  167. package/dist/{task-config-5nx3qfz8.js → task-config-gbp7r23g.js} +1 -1
  168. package/dist/tui/compaction-off.d.ts +13 -0
  169. package/dist/tui/compaction-off.d.ts.map +1 -0
  170. package/dist/tui/data/context-db.d.ts.map +1 -1
  171. package/dist/tui-compiled/compaction-off.d.ts +13 -0
  172. package/dist/tui-compiled/compaction-off.d.ts.map +1 -0
  173. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  174. package/package.json +6 -1
  175. package/src/agents/dreamer.ts +68 -0
  176. package/src/agents/hidden-agent-registrations.ts +331 -0
  177. package/src/agents/historian.ts +3 -0
  178. package/src/agents/language-directive.test.ts +138 -0
  179. package/src/agents/language-directive.ts +121 -0
  180. package/src/agents/magic-context-prompt.language.test.ts +35 -0
  181. package/src/agents/magic-context-prompt.test.ts +292 -0
  182. package/src/agents/magic-context-prompt.ts +182 -0
  183. package/src/agents/permissions.test.ts +252 -0
  184. package/src/agents/permissions.ts +186 -0
  185. package/src/agents/sidekick.ts +1 -0
  186. package/src/agents/smart-note-compiler.ts +1 -0
  187. package/src/config/agent-disable.ts +106 -0
  188. package/src/config/compaction-accessor-guard.test.ts +82 -0
  189. package/src/config/compaction-backcompat.test.ts +52 -0
  190. package/src/config/index.test.ts +936 -0
  191. package/src/config/index.ts +601 -0
  192. package/src/config/migrate-config-location.test.ts +332 -0
  193. package/src/config/migrate-config-location.ts +635 -0
  194. package/src/config/migrate-dreamer-v2.test.ts +287 -0
  195. package/src/config/migrate-dreamer-v2.ts +336 -0
  196. package/src/config/migrate-experimental.test.ts +41 -0
  197. package/src/config/migrate-experimental.ts +185 -0
  198. package/src/config/project-security.test.ts +388 -0
  199. package/src/config/project-security.ts +611 -0
  200. package/src/config/prune-config-leaf.test.ts +78 -0
  201. package/src/config/prune-config-leaf.ts +68 -0
  202. package/src/config/removed-config-keys.test.ts +116 -0
  203. package/src/config/schema/agent-overrides.ts +45 -0
  204. package/src/config/schema/magic-context.test.ts +359 -0
  205. package/src/config/schema/magic-context.ts +991 -0
  206. package/src/config/transform-mode.test.ts +62 -0
  207. package/src/config/transform-mode.ts +34 -0
  208. package/src/config/variable.test.ts +323 -0
  209. package/src/config/variable.ts +192 -0
  210. package/src/features/magic-context/defaults.ts +1 -0
  211. package/src/features/magic-context/dreamer/cron.ts +243 -0
  212. package/src/features/magic-context/dreamer/task-registry.ts +151 -0
  213. package/src/shared/conflict-detector.test.ts +121 -0
  214. package/src/shared/conflict-detector.ts +50 -3
  215. package/src/shared/conflict-fixer.test.ts +309 -0
  216. package/src/shared/conflict-fixer.ts +82 -5
  217. package/src/shared/context-limit-provenance.ts +6 -0
  218. package/src/shared/escalation-bands.test.ts +23 -0
  219. package/src/shared/escalation-bands.ts +18 -0
  220. package/src/shared/format-threshold.ts +1 -1
  221. package/src/shared/models-dev-cache.test.ts +171 -0
  222. package/src/shared/models-dev-cache.ts +195 -38
  223. package/src/shared/rpc-server.ts +27 -18
  224. package/src/shared/rpc-types.ts +55 -1
  225. package/src/shared/rpc-utils.test.ts +157 -0
  226. package/src/shared/rpc-utils.ts +137 -0
  227. package/src/shared/storage-permissions.ts +20 -0
  228. package/src/shared/tui-runtime-specifiers.ts +32 -0
  229. package/src/tui/compaction-off.test.ts +56 -0
  230. package/src/tui/compaction-off.ts +29 -0
  231. package/src/tui/data/context-db.ts +6 -0
  232. package/src/tui/index.tsx +113 -65
  233. package/src/tui/slots/sidebar-content.tsx +189 -91
  234. package/src/tui/tui-compiled-runtime-imports.test.ts +151 -0
  235. package/src/tui-compiled/compaction-off.ts +29 -0
  236. package/src/tui-compiled/data/context-db.ts +6 -0
  237. package/src/tui-compiled/index.tsx +558 -455
  238. package/src/tui-compiled/slots/sidebar-content.tsx +334 -169
  239. package/dist/send-session-notification-tghq9gnh.js +0 -11
  240. /package/dist/{index-v7fmc1s2.js → index-ygcng9t8.js} +0 -0
@@ -0,0 +1,611 @@
1
+ import { DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE } from "./schema/magic-context";
2
+
3
+ /**
4
+ * Security hardening for PROJECT-level (repo-supplied, untrusted) config.
5
+ *
6
+ * A project config lives inside a repository the user cloned. Opening a repo
7
+ * must never let that repo's config escalate privilege or exfiltrate secrets.
8
+ * These helpers run on the raw project config BEFORE/AFTER it is merged over the
9
+ * trusted user config, mutating the relevant object in place and returning
10
+ * human-readable warnings.
11
+ *
12
+ * Shared by both harnesses (OpenCode `config/index.ts` and Pi
13
+ * `config/index.ts`) so the trust boundary is identical cross-harness.
14
+ */
15
+
16
+ /** Hidden agents that run with elevated/autonomous capability. */
17
+ const HIDDEN_AGENT_KEYS = ["historian", "dreamer", "sidekick"] as const;
18
+ const HISTORIAN_USER_ONLY_FIELDS = ["model", "fallback_models"] as const;
19
+
20
+ /**
21
+ * Fields on a hidden-agent block that constitute a privilege-escalation /
22
+ * code-execution vector when set from an untrusted repo:
23
+ *
24
+ * - `prompt` — reprograms the agent's instructions. The dreamer runs
25
+ * AUTONOMOUSLY in the background with `bash`/`edit`/`webfetch`,
26
+ * so a repo-supplied prompt is an unattended exfil/RCE path.
27
+ * - `permission` — broadens the agent's per-tool permissions.
28
+ * - `tools` — enable/disable map; could flip a denied tool (e.g. `bash`)
29
+ * on for an agent whose allow-list intentionally excludes it.
30
+ * - `system_prompt` — sidekick's custom system prompt. It takes precedence over
31
+ * the built-in prompt (sidekick/agent.ts reads
32
+ * `config.system_prompt` before `config.prompt`), so leaving it
33
+ * unstripped reopens the exact reprogramming vector `prompt`
34
+ * closes — a cloned repo could rewrite sidekick's instructions
35
+ * via `/ctx-aug`.
36
+ *
37
+ * Dreamer model/cadence fields are deliberately NOT stripped: a repo may tune
38
+ * its own dreamer overlays and schedules through the user's provider auth.
39
+ * Historian model selection stays USER-tier only, and compaction thresholds are
40
+ * project raise-only, so a cloned repo cannot force earlier compaction or extra
41
+ * historian spend on the user's dime.
42
+ */
43
+ const AGENT_ESCALATION_FIELDS = ["prompt", "permission", "tools", "system_prompt"] as const;
44
+ const EMBEDDING_DESTINATION_FIELDS = ["endpoint", "provider", "fallback_provider"] as const;
45
+ const PERCENTAGE_THRESHOLD_REASON =
46
+ "security: a repository may only raise compaction thresholds above the user's effective value; it cannot force earlier historian work or cloned-repo cost escalation.";
47
+ const TOKEN_THRESHOLD_REASON =
48
+ "security: a repository may only raise execute_threshold_tokens above the user's trusted token threshold; it cannot force earlier historian work or cloned-repo cost escalation.";
49
+ const TOKEN_THRESHOLD_INTRODUCTION_REASON =
50
+ "security: a repository cannot introduce a new execute_threshold_tokens override when the user has no trusted token threshold for that key; that could force earlier historian work or cloned-repo cost escalation.";
51
+
52
+ interface PercentageThresholdConfig {
53
+ defaultValue: number;
54
+ overrides: Map<string, number>;
55
+ }
56
+
57
+ interface TokenThresholdConfig {
58
+ defaultValue: number | undefined;
59
+ overrides: Map<string, number>;
60
+ }
61
+
62
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ function isValidPercentageThreshold(value: unknown): value is number {
67
+ return typeof value === "number" && Number.isFinite(value) && value >= 20 && value <= 80;
68
+ }
69
+
70
+ function isValidTokenThreshold(value: unknown): value is number {
71
+ return (
72
+ typeof value === "number" && Number.isFinite(value) && value >= 5_000 && value <= 2_000_000
73
+ );
74
+ }
75
+
76
+ function normalizeTrustedPercentageThresholds(value: unknown): PercentageThresholdConfig {
77
+ if (typeof value === "number" && Number.isFinite(value)) {
78
+ return { defaultValue: value, overrides: new Map() };
79
+ }
80
+
81
+ if (
82
+ isPlainObject(value) &&
83
+ typeof value.default === "number" &&
84
+ Number.isFinite(value.default)
85
+ ) {
86
+ const overrides = new Map<string, number>();
87
+ for (const [key, child] of Object.entries(value)) {
88
+ if (key === "default") continue;
89
+ if (typeof child === "number" && Number.isFinite(child)) {
90
+ overrides.set(key, child);
91
+ }
92
+ }
93
+ return { defaultValue: value.default, overrides };
94
+ }
95
+
96
+ return { defaultValue: DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE, overrides: new Map() };
97
+ }
98
+
99
+ function normalizeTrustedTokenThresholds(value: unknown): TokenThresholdConfig {
100
+ if (!isPlainObject(value)) {
101
+ return { defaultValue: undefined, overrides: new Map() };
102
+ }
103
+
104
+ const overrides = new Map<string, number>();
105
+ for (const [key, child] of Object.entries(value)) {
106
+ if (key === "default") continue;
107
+ if (typeof child === "number" && Number.isFinite(child)) {
108
+ overrides.set(key, child);
109
+ }
110
+ }
111
+
112
+ return {
113
+ defaultValue:
114
+ typeof value.default === "number" && Number.isFinite(value.default)
115
+ ? value.default
116
+ : undefined,
117
+ overrides,
118
+ };
119
+ }
120
+
121
+ function clonePercentageThresholds(value: PercentageThresholdConfig): PercentageThresholdConfig {
122
+ return {
123
+ defaultValue: value.defaultValue,
124
+ overrides: new Map(value.overrides),
125
+ };
126
+ }
127
+
128
+ function cloneTokenThresholds(value: TokenThresholdConfig): TokenThresholdConfig {
129
+ return {
130
+ defaultValue: value.defaultValue,
131
+ overrides: new Map(value.overrides),
132
+ };
133
+ }
134
+
135
+ function percentageThresholdsEqual(
136
+ left: PercentageThresholdConfig,
137
+ right: PercentageThresholdConfig,
138
+ ): boolean {
139
+ if (left.defaultValue !== right.defaultValue) return false;
140
+ if (left.overrides.size !== right.overrides.size) return false;
141
+ for (const [key, value] of left.overrides) {
142
+ if (right.overrides.get(key) !== value) return false;
143
+ }
144
+ return true;
145
+ }
146
+
147
+ function setMergedPercentageThreshold(
148
+ mergedRaw: Record<string, unknown>,
149
+ value: PercentageThresholdConfig,
150
+ ): void {
151
+ if (value.overrides.size === 0) {
152
+ mergedRaw.execute_threshold_percentage = value.defaultValue;
153
+ return;
154
+ }
155
+
156
+ const serialized: Record<string, number> = { default: value.defaultValue };
157
+ for (const [key, threshold] of value.overrides) {
158
+ serialized[key] = threshold;
159
+ }
160
+ mergedRaw.execute_threshold_percentage = serialized;
161
+ }
162
+
163
+ function setMergedTokenThreshold(
164
+ mergedRaw: Record<string, unknown>,
165
+ value: TokenThresholdConfig,
166
+ ): void {
167
+ if (value.defaultValue === undefined && value.overrides.size === 0) {
168
+ delete mergedRaw.execute_threshold_tokens;
169
+ return;
170
+ }
171
+
172
+ const serialized: Record<string, number> = {};
173
+ if (value.defaultValue !== undefined) {
174
+ serialized.default = value.defaultValue;
175
+ }
176
+ for (const [key, threshold] of value.overrides) {
177
+ serialized[key] = threshold;
178
+ }
179
+ mergedRaw.execute_threshold_tokens = serialized;
180
+ }
181
+
182
+ function makeProjectThresholdWarning(field: string, reason: string): string {
183
+ return `Ignoring ${field} from project config (${reason})`;
184
+ }
185
+
186
+ /**
187
+ * Strip unsafe fields from a raw PROJECT config IN PLACE, before it is merged
188
+ * over the user config. Returns warnings describing what was ignored.
189
+ *
190
+ * Closes:
191
+ * - `auto_update` — a repo must not suppress plugin self-updates (which can
192
+ * carry security fixes).
193
+ * - `fail_closed_blocking` — a repo must not un-block (or force-block) the
194
+ * loud inoperability gate; only the user may restore silent degrade.
195
+ * - `allow_home_project` — only the user may opt a home-directory session
196
+ * into a durable project identity.
197
+ * - `output_reserve` — only the user may change the process-wide safe input budget.
198
+ * - `language`: a repo must not inject prompt text through a user preference.
199
+ * - `sqlite` — `sqlite.cache_size_mb` / `mmap_size_mb` become PRAGMAs on the
200
+ * process-global shared DB handle (one connection across every project in the
201
+ * process). A cloned repo could set a huge value to exhaust host memory /
202
+ * address space — a resource-exhaustion vector with no legitimate per-repo
203
+ * use. Honor user-level config only.
204
+ * - `storage.enforce_private_permissions` — changing a shared store from
205
+ * owner-private to group-readable changes every session and memory's local
206
+ * confidentiality. Only the machine operator's user config may opt into an
207
+ * externally managed trusted-group deployment.
208
+ * - `embedding.endpoint` / `embedding.provider` — a repo must not choose
209
+ * where private memory/search/commit text is embedded. User-level config is
210
+ * the trust boundary for embedding destinations.
211
+ * - `transform_mode` is intentionally allowed at project tier so a repository
212
+ * can opt its own runtime into the experimental Rust pipeline. The resolver
213
+ * requires trusted user-level `subc` configuration before Rust can activate.
214
+ * - `historian.model` / `historian.fallback_models` — historian model spend is
215
+ * user-level only; a cloned repo cannot force extra compaction cost.
216
+ * - `mural.model` — mural cue-compressor model selection is user-level only;
217
+ * a cloned repo cannot choose a model that sends project memory to a provider.
218
+ * - `pi.subagent_extensions` — a cloned repo must not choose which extensions
219
+ * the user's Pi child processes load.
220
+ * - hidden-agent `prompt`/`permission`/`tools` — a repo must not reprogram or
221
+ * re-permission the historian/dreamer/sidekick.
222
+ */
223
+ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknown>): string[] {
224
+ const warnings: string[] = [];
225
+
226
+ if ("auto_update" in projectRaw) {
227
+ delete projectRaw.auto_update;
228
+ warnings.push(
229
+ "Ignoring auto_update from project config (security: this setting only honors user-level config).",
230
+ );
231
+ }
232
+
233
+ if ("fail_closed_blocking" in projectRaw) {
234
+ delete projectRaw.fail_closed_blocking;
235
+ warnings.push(
236
+ "Ignoring fail_closed_blocking from project config (security: only user-level config may disable or force the loud inoperability gate).",
237
+ );
238
+ }
239
+
240
+ if ("allow_home_project" in projectRaw) {
241
+ delete projectRaw.allow_home_project;
242
+ warnings.push(
243
+ "Ignoring allow_home_project from project config (security: only user-level config may opt the user's home directory into Magic Context).",
244
+ );
245
+ }
246
+
247
+ // compaction.enabled is USER-tier only: a cloned repo must never silently
248
+ // disable the user's context management (it changes how the window is
249
+ // owned and interacts with native compaction state). Field-scoped, not
250
+ // block-scoped: a sibling key in a project `compaction` block survives the
251
+ // strip, so a future project-tier compaction knob can still land.
252
+ const compaction = projectRaw.compaction;
253
+ if (isPlainObject(compaction) && "enabled" in compaction) {
254
+ delete compaction.enabled;
255
+ warnings.push(
256
+ "Ignoring compaction.enabled from project config (security: only user-level config may disable Magic Context's context-window management; a cloned repo cannot change how the user's window is owned).",
257
+ );
258
+ }
259
+
260
+ if ("output_reserve" in projectRaw) {
261
+ delete projectRaw.output_reserve;
262
+ warnings.push(
263
+ "Ignoring output_reserve from project config (security: output-token reservation only honors user-level config).",
264
+ );
265
+ }
266
+
267
+ if ("language" in projectRaw) {
268
+ delete projectRaw.language;
269
+ warnings.push(
270
+ "Ignoring language from project config (security: output language is a user-level setting).",
271
+ );
272
+ }
273
+
274
+ if ("sqlite" in projectRaw) {
275
+ delete projectRaw.sqlite;
276
+ warnings.push(
277
+ "Ignoring sqlite.* from project config (security: SQLite cache/mmap PRAGMAs apply to the " +
278
+ "process-global shared database handle; only user-level config may set them).",
279
+ );
280
+ }
281
+
282
+ // storage.enforce_private_permissions is USER-tier only because disabling it
283
+ // changes the confidentiality of the process-global shared store. Field-scoped
284
+ // stripping preserves future project-tier storage settings in the same block.
285
+ const storage = projectRaw.storage;
286
+ if (isPlainObject(storage) && "enforce_private_permissions" in storage) {
287
+ delete storage.enforce_private_permissions;
288
+ warnings.push(
289
+ "Ignoring storage.enforce_private_permissions from project config (security: only user-level config may opt into externally managed shared storage permissions).",
290
+ );
291
+ }
292
+
293
+ const pi = projectRaw.pi;
294
+ if (isPlainObject(pi) && "subagent_extensions" in pi) {
295
+ delete pi.subagent_extensions;
296
+ warnings.push(
297
+ "Ignoring pi.subagent_extensions from project config (security: only user-level config may choose extensions loaded by Pi subagent children).",
298
+ );
299
+ }
300
+
301
+ for (const field of ["subc", "shadow_embedding"] as const) {
302
+ if (field in projectRaw) {
303
+ delete projectRaw[field];
304
+ warnings.push(
305
+ `Ignoring ${field} from project config (security: daemon routing and developer-only embedding traffic are user-level settings).`,
306
+ );
307
+ }
308
+ }
309
+
310
+ const embedding = projectRaw.embedding;
311
+ if (isPlainObject(embedding)) {
312
+ const removed: string[] = [];
313
+ for (const field of EMBEDDING_DESTINATION_FIELDS) {
314
+ if (field in embedding) {
315
+ delete embedding[field];
316
+ removed.push(field);
317
+ }
318
+ }
319
+ if (removed.length > 0) {
320
+ warnings.push(
321
+ `Ignoring embedding.${removed.join("/")} from project config ` +
322
+ "(security: a repository cannot choose where private text is embedded).",
323
+ );
324
+ }
325
+ }
326
+
327
+ const historian = projectRaw.historian;
328
+ if (isPlainObject(historian)) {
329
+ const removed: string[] = [];
330
+ for (const field of HISTORIAN_USER_ONLY_FIELDS) {
331
+ if (field in historian) {
332
+ delete historian[field];
333
+ removed.push(field);
334
+ }
335
+ }
336
+ if (removed.length > 0) {
337
+ warnings.push(
338
+ `Ignoring historian.${removed.join("/")} from project config ` +
339
+ "(security: historian model selection is user-level only; a repository cannot force extra compaction cost).",
340
+ );
341
+ }
342
+ }
343
+
344
+ const mural = projectRaw.mural;
345
+ if (isPlainObject(mural) && "model" in mural) {
346
+ delete mural.model;
347
+ warnings.push(
348
+ "Ignoring mural.model from project config (security: the mural cue-compressor model is a user-level setting; a repository cannot choose where project memory is sent).",
349
+ );
350
+ }
351
+
352
+ // Keep the same trust boundary while accepting the pre-graduation spelling.
353
+ // This must run before the in-memory migration moves experimental.mural.model
354
+ // to mural.model, otherwise an untrusted project could bypass the user-only
355
+ // model check.
356
+ const experimental = projectRaw.experimental;
357
+ const legacyMural = isPlainObject(experimental) ? experimental.mural : undefined;
358
+ if (isPlainObject(legacyMural) && "model" in legacyMural) {
359
+ delete legacyMural.model;
360
+ warnings.push(
361
+ "Ignoring experimental.mural.model from project config (security: the mural cue-compressor model is a user-level setting; use user-level mural.model).",
362
+ );
363
+ }
364
+
365
+ for (const agentKey of HIDDEN_AGENT_KEYS) {
366
+ const block = projectRaw[agentKey];
367
+ if (!isPlainObject(block)) continue;
368
+ const removed: string[] = [];
369
+ for (const field of AGENT_ESCALATION_FIELDS) {
370
+ if (field in block) {
371
+ delete block[field];
372
+ removed.push(field);
373
+ }
374
+ }
375
+ if (removed.length > 0) {
376
+ warnings.push(
377
+ `Ignoring ${agentKey}.${removed.join("/")} from project config ` +
378
+ "(security: a repository cannot reprogram or re-permission hidden agents).",
379
+ );
380
+ }
381
+ }
382
+
383
+ return warnings;
384
+ }
385
+
386
+ /**
387
+ * Clamp project-tier compaction thresholds after merge so a cloned repository
388
+ * may only DELAY compaction relative to the trusted user/default settings. A
389
+ * repo may never lower thresholds in a way that forces earlier historian work
390
+ * or cloned-repo cost escalation on the user's account.
391
+ */
392
+ export function constrainProjectThresholdOverrides(args: {
393
+ mergedRaw: Record<string, unknown>;
394
+ projectRaw: Record<string, unknown>;
395
+ trustedBaseConfig: {
396
+ execute_threshold_percentage?: unknown;
397
+ execute_threshold_tokens?: unknown;
398
+ };
399
+ }): string[] {
400
+ const warnings: string[] = [];
401
+ const basePercentage = normalizeTrustedPercentageThresholds(
402
+ args.trustedBaseConfig.execute_threshold_percentage,
403
+ );
404
+ const baseTokens = normalizeTrustedTokenThresholds(
405
+ args.trustedBaseConfig.execute_threshold_tokens,
406
+ );
407
+
408
+ if ("execute_threshold_percentage" in args.projectRaw) {
409
+ const projectValue = args.projectRaw.execute_threshold_percentage;
410
+
411
+ if (isValidPercentageThreshold(projectValue)) {
412
+ const constrained = clonePercentageThresholds(basePercentage);
413
+ constrained.defaultValue = Math.max(basePercentage.defaultValue, projectValue);
414
+ for (const [modelKey, threshold] of basePercentage.overrides) {
415
+ const raisedThreshold = Math.max(threshold, projectValue);
416
+ if (raisedThreshold === constrained.defaultValue) {
417
+ constrained.overrides.delete(modelKey);
418
+ } else {
419
+ constrained.overrides.set(modelKey, raisedThreshold);
420
+ }
421
+ }
422
+ setMergedPercentageThreshold(args.mergedRaw, constrained);
423
+ if (percentageThresholdsEqual(constrained, basePercentage)) {
424
+ warnings.push(
425
+ makeProjectThresholdWarning(
426
+ "execute_threshold_percentage",
427
+ PERCENTAGE_THRESHOLD_REASON,
428
+ ),
429
+ );
430
+ }
431
+ } else if (isPlainObject(projectValue)) {
432
+ const constrained = clonePercentageThresholds(basePercentage);
433
+ let touchedValidEntry = false;
434
+
435
+ if (isValidPercentageThreshold(projectValue.default)) {
436
+ touchedValidEntry = true;
437
+ if (projectValue.default > basePercentage.defaultValue) {
438
+ constrained.defaultValue = projectValue.default;
439
+ } else {
440
+ warnings.push(
441
+ makeProjectThresholdWarning(
442
+ "execute_threshold_percentage.default",
443
+ PERCENTAGE_THRESHOLD_REASON,
444
+ ),
445
+ );
446
+ }
447
+ }
448
+
449
+ for (const [modelKey, rawValue] of Object.entries(projectValue)) {
450
+ if (modelKey === "default") continue;
451
+ if (!isValidPercentageThreshold(rawValue)) continue;
452
+ touchedValidEntry = true;
453
+ const baseValue =
454
+ basePercentage.overrides.get(modelKey) ?? basePercentage.defaultValue;
455
+ if (rawValue > baseValue) {
456
+ if (rawValue === constrained.defaultValue) {
457
+ constrained.overrides.delete(modelKey);
458
+ } else {
459
+ constrained.overrides.set(modelKey, rawValue);
460
+ }
461
+ } else {
462
+ warnings.push(
463
+ makeProjectThresholdWarning(
464
+ `execute_threshold_percentage.${modelKey}`,
465
+ PERCENTAGE_THRESHOLD_REASON,
466
+ ),
467
+ );
468
+ }
469
+ }
470
+
471
+ if (touchedValidEntry) {
472
+ setMergedPercentageThreshold(args.mergedRaw, constrained);
473
+ }
474
+ }
475
+ }
476
+
477
+ if (
478
+ "execute_threshold_tokens" in args.projectRaw &&
479
+ isPlainObject(args.projectRaw.execute_threshold_tokens)
480
+ ) {
481
+ const projectValue = args.projectRaw.execute_threshold_tokens;
482
+ const constrained = cloneTokenThresholds(baseTokens);
483
+ let touchedValidEntry = false;
484
+
485
+ if (isValidTokenThreshold(projectValue.default)) {
486
+ touchedValidEntry = true;
487
+ if (baseTokens.defaultValue === undefined) {
488
+ warnings.push(
489
+ makeProjectThresholdWarning(
490
+ "execute_threshold_tokens.default",
491
+ TOKEN_THRESHOLD_INTRODUCTION_REASON,
492
+ ),
493
+ );
494
+ } else if (projectValue.default > baseTokens.defaultValue) {
495
+ constrained.defaultValue = projectValue.default;
496
+ } else {
497
+ warnings.push(
498
+ makeProjectThresholdWarning(
499
+ "execute_threshold_tokens.default",
500
+ TOKEN_THRESHOLD_REASON,
501
+ ),
502
+ );
503
+ }
504
+ }
505
+
506
+ for (const [modelKey, rawValue] of Object.entries(projectValue)) {
507
+ if (modelKey === "default") continue;
508
+ if (!isValidTokenThreshold(rawValue)) continue;
509
+ touchedValidEntry = true;
510
+ const baseValue = baseTokens.overrides.get(modelKey) ?? baseTokens.defaultValue;
511
+ if (baseValue === undefined) {
512
+ warnings.push(
513
+ makeProjectThresholdWarning(
514
+ `execute_threshold_tokens.${modelKey}`,
515
+ TOKEN_THRESHOLD_INTRODUCTION_REASON,
516
+ ),
517
+ );
518
+ continue;
519
+ }
520
+ if (rawValue > baseValue) {
521
+ if (rawValue === constrained.defaultValue) {
522
+ constrained.overrides.delete(modelKey);
523
+ } else {
524
+ constrained.overrides.set(modelKey, rawValue);
525
+ }
526
+ } else {
527
+ warnings.push(
528
+ makeProjectThresholdWarning(
529
+ `execute_threshold_tokens.${modelKey}`,
530
+ TOKEN_THRESHOLD_REASON,
531
+ ),
532
+ );
533
+ }
534
+ }
535
+
536
+ if (touchedValidEntry) {
537
+ setMergedTokenThreshold(args.mergedRaw, constrained);
538
+ }
539
+ }
540
+
541
+ return warnings;
542
+ }
543
+
544
+ /**
545
+ * After the project config has been merged over the user config, drop the
546
+ * user's inherited `embedding.api_key` when the project redirected the embedding
547
+ * `endpoint` without supplying its own key.
548
+ *
549
+ * Threat: a malicious repo overrides only `embedding.endpoint` → an attacker
550
+ * server, inheriting the user's `embedding.api_key`, which is then sent as
551
+ * `Authorization: Bearer …` to that server. The victim did nothing but clone the
552
+ * repo. Dropping the inherited key means a redirected endpoint never receives
553
+ * the user's secret; a project that genuinely points at a different endpoint
554
+ * must supply its own key.
555
+ *
556
+ * `projectRaw` is the raw project config (pre-merge, so we can see what the
557
+ * project itself declared). `mergedRaw` is the post-merge result, mutated in
558
+ * place. `userRaw` is the trusted user config; when supplied, the key is dropped
559
+ * only when the project endpoint ACTUALLY differs from the user's endpoint — a
560
+ * project repeating the user's own endpoint (e.g. only to change `model`) is not
561
+ * a redirect and must keep the inherited key. Returns warnings.
562
+ */
563
+ function normalizeEndpoint(value: unknown): string | undefined {
564
+ if (typeof value !== "string") return undefined;
565
+ const trimmed = value.trim().replace(/\/+$/, "");
566
+ return trimmed.length > 0 ? trimmed.toLowerCase() : undefined;
567
+ }
568
+
569
+ export function dropInheritedEmbeddingKeyOnRedirect(
570
+ projectRaw: Record<string, unknown>,
571
+ mergedRaw: Record<string, unknown>,
572
+ userRaw?: Record<string, unknown>,
573
+ ): string[] {
574
+ const projectEmbedding = projectRaw.embedding;
575
+ if (!isPlainObject(projectEmbedding)) return [];
576
+
577
+ // Only an ENDPOINT redirect changes WHERE the bytes (and the Authorization
578
+ // header) are sent. A provider-only change keeps the user's endpoint, so it
579
+ // is not an exfiltration vector.
580
+ const redirectsEndpoint = "endpoint" in projectEmbedding;
581
+ if (!redirectsEndpoint) return [];
582
+
583
+ // A project that merely repeats the user's OWN endpoint (e.g. to override
584
+ // `model` while keeping the same server) is not a redirect — the key was
585
+ // always destined for that endpoint. Only drop when the destination
586
+ // actually changed. When userRaw is absent we cannot tell, so fall back to
587
+ // the conservative presence-based drop.
588
+ const userEmbedding = userRaw?.embedding;
589
+ if (isPlainObject(userEmbedding)) {
590
+ const projectEndpoint = normalizeEndpoint(projectEmbedding.endpoint);
591
+ const userEndpoint = normalizeEndpoint(userEmbedding.endpoint);
592
+ if (projectEndpoint !== undefined && projectEndpoint === userEndpoint) {
593
+ return [];
594
+ }
595
+ }
596
+
597
+ const providesOwnKey =
598
+ typeof projectEmbedding.api_key === "string" && projectEmbedding.api_key.length > 0;
599
+ if (providesOwnKey) return [];
600
+
601
+ const mergedEmbedding = mergedRaw.embedding;
602
+ if (!isPlainObject(mergedEmbedding)) return [];
603
+ if (!("api_key" in mergedEmbedding)) return [];
604
+
605
+ delete mergedEmbedding.api_key;
606
+ return [
607
+ "Dropped inherited user embedding api_key because project config redirected " +
608
+ "embedding.endpoint without supplying its own key (security: prevents key " +
609
+ "exfiltration to a repository-chosen endpoint).",
610
+ ];
611
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { pruneNestedConfigLeaf } from "./prune-config-leaf";
4
+
5
+ describe("pruneNestedConfigLeaf", () => {
6
+ it("prunes a 2-level leaf and keeps siblings", () => {
7
+ const block = { enabled: false, injection_budget_tokens: 4000 };
8
+ const result = pruneNestedConfigLeaf(block, ["injection_budget_tokens"]);
9
+ expect(result).not.toBeNull();
10
+ expect(result?.block).toEqual({ enabled: false });
11
+ expect(result?.removed).toBe("injection_budget_tokens");
12
+ // original untouched
13
+ expect(block).toEqual({ enabled: false, injection_budget_tokens: 4000 });
14
+ });
15
+
16
+ it("prunes the DEEPEST leaf of a 3-level path, preserving a disabled sibling", () => {
17
+ // The regression: an invalid memory.git_commit_indexing.since_days must
18
+ // drop only `since_days`, NOT the whole git_commit_indexing block (which
19
+ // would lose `enabled: false` and let the default restore enabled: true).
20
+ const block = {
21
+ enabled: true,
22
+ git_commit_indexing: { enabled: false, since_days: 99999 },
23
+ };
24
+ const result = pruneNestedConfigLeaf(block, ["git_commit_indexing", "since_days"]);
25
+ expect(result).not.toBeNull();
26
+ expect(result?.block).toEqual({
27
+ enabled: true,
28
+ git_commit_indexing: { enabled: false },
29
+ });
30
+ expect(result?.removed).toBe("git_commit_indexing.since_days");
31
+ // original deeply untouched (no mutation of nested object)
32
+ expect(block.git_commit_indexing).toEqual({ enabled: false, since_days: 99999 });
33
+ });
34
+
35
+ it("prunes the owning field when an intermediate segment is not an object", () => {
36
+ // The path descends into a non-object (here `git_commit_indexing` is a
37
+ // bare bool, not a block). We can't reach `since_days`, but we prune the
38
+ // owning field so it falls back to its default — NOT collapse to all
39
+ // defaults (which dropping to null upstream would cause).
40
+ const block = { git_commit_indexing: true, keep_me: { enabled: false } };
41
+ const result = pruneNestedConfigLeaf(block, ["git_commit_indexing", "since_days"]);
42
+ expect(result).not.toBeNull();
43
+ expect(result?.removed).toBe("git_commit_indexing");
44
+ expect(result?.block).toEqual({ keep_me: { enabled: false } });
45
+ });
46
+
47
+ it("prunes an invalid array-element leaf to its owning field", () => {
48
+ // system_prompt_injection.skip_signatures[0] invalid → Zod path descends
49
+ // into the array. Prune skip_signatures (→ default), keep siblings.
50
+ const block = {
51
+ system_prompt_injection: { enabled: true, skip_signatures: [123] },
52
+ };
53
+ const result = pruneNestedConfigLeaf(block, [
54
+ "system_prompt_injection",
55
+ "skip_signatures",
56
+ 0,
57
+ ]);
58
+ expect(result).not.toBeNull();
59
+ expect(result?.removed).toBe("system_prompt_injection.skip_signatures");
60
+ expect(result?.block).toEqual({ system_prompt_injection: { enabled: true } });
61
+ });
62
+
63
+ it("returns null when the leaf is absent", () => {
64
+ const block = { auto_search: { enabled: false } };
65
+ expect(pruneNestedConfigLeaf(block, ["auto_search", "missing"])).toBeNull();
66
+ });
67
+
68
+ it("returns null for an empty path", () => {
69
+ expect(pruneNestedConfigLeaf({ a: 1 }, [])).toBeNull();
70
+ });
71
+
72
+ it("deep-clones intermediate objects (no shared references with input)", () => {
73
+ const inner = { enabled: false, since_days: 1 };
74
+ const block = { git_commit_indexing: inner };
75
+ const result = pruneNestedConfigLeaf(block, ["git_commit_indexing", "since_days"]);
76
+ expect(result?.block.git_commit_indexing).not.toBe(inner);
77
+ });
78
+ });