@bubblebrain-ai/bubble 0.0.58 → 0.0.60

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 (177) hide show
  1. package/README.md +56 -6
  2. package/dist/agent/child-runner.js +5 -1
  3. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
  4. package/dist/agent/internal-reminder-sanitizer.js +7 -1
  5. package/dist/agent/profiles.js +4 -4
  6. package/dist/agent/routing-catalog.d.ts +19 -1
  7. package/dist/agent/routing-catalog.js +32 -13
  8. package/dist/agent/subagent/runtime.d.ts +28 -0
  9. package/dist/agent/subagent/runtime.js +34 -30
  10. package/dist/agent/subagent-control.d.ts +7 -0
  11. package/dist/agent/subagent-control.js +2 -0
  12. package/dist/agent/subagent-lifecycle-reminder.js +2 -1
  13. package/dist/agent/subagent-wait-guidance.d.ts +2 -0
  14. package/dist/agent/subagent-wait-guidance.js +8 -0
  15. package/dist/agent/subtask-policy.js +4 -4
  16. package/dist/agent.d.ts +20 -11
  17. package/dist/agent.js +200 -117
  18. package/dist/anthropic-thinking.d.ts +4 -0
  19. package/dist/anthropic-thinking.js +11 -0
  20. package/dist/approval/controller.d.ts +20 -6
  21. package/dist/approval/controller.js +67 -8
  22. package/dist/approval/protected-paths.d.ts +1 -0
  23. package/dist/approval/protected-paths.js +35 -0
  24. package/dist/approval/session-cache.d.ts +12 -26
  25. package/dist/approval/session-cache.js +20 -50
  26. package/dist/approval/types.d.ts +27 -1
  27. package/dist/cli.d.ts +5 -0
  28. package/dist/cli.js +25 -2
  29. package/dist/config.d.ts +3 -0
  30. package/dist/config.js +7 -0
  31. package/dist/context/checkpoint.d.ts +18 -0
  32. package/dist/context/checkpoint.js +117 -0
  33. package/dist/context/compact-llm.js +7 -0
  34. package/dist/context/compact.d.ts +8 -0
  35. package/dist/context/compact.js +40 -7
  36. package/dist/context/llm-compactor.d.ts +36 -2
  37. package/dist/context/llm-compactor.js +227 -44
  38. package/dist/context/session-write-lock.d.ts +20 -0
  39. package/dist/context/session-write-lock.js +162 -0
  40. package/dist/context/tool-images.d.ts +3 -0
  41. package/dist/context/tool-images.js +35 -0
  42. package/dist/context/usage.js +22 -5
  43. package/dist/feishu/agent-host/run-driver.js +56 -9
  44. package/dist/lsp/service.d.ts +13 -1
  45. package/dist/lsp/service.js +163 -43
  46. package/dist/main.js +82 -16
  47. package/dist/mcp/config.d.ts +4 -0
  48. package/dist/mcp/config.js +17 -0
  49. package/dist/mcp/manager.d.ts +13 -1
  50. package/dist/mcp/manager.js +32 -1
  51. package/dist/mcp/transports.js +11 -0
  52. package/dist/memory/db.d.ts +1 -0
  53. package/dist/memory/db.js +20 -4
  54. package/dist/memory/phase1.js +122 -16
  55. package/dist/memory/prompts.js +3 -0
  56. package/dist/model-catalog.js +19 -3
  57. package/dist/model-config.d.ts +1 -0
  58. package/dist/model-config.js +5 -5
  59. package/dist/model-pricing.js +26 -9
  60. package/dist/network/provider-transport-log.d.ts +12 -0
  61. package/dist/network/provider-transport-log.js +32 -0
  62. package/dist/network/provider-transport.js +6 -1
  63. package/dist/network/retry.d.ts +1 -1
  64. package/dist/network/retry.js +1 -1
  65. package/dist/oauth/grok.d.ts +1 -0
  66. package/dist/oauth/grok.js +20 -16
  67. package/dist/oauth/openai-codex.d.ts +1 -0
  68. package/dist/oauth/openai-codex.js +29 -25
  69. package/dist/oauth/refresh-control.d.ts +12 -0
  70. package/dist/oauth/refresh-control.js +42 -0
  71. package/dist/oauth/storage.d.ts +55 -1
  72. package/dist/oauth/storage.js +281 -16
  73. package/dist/permissions/rule.d.ts +6 -3
  74. package/dist/permissions/rule.js +71 -12
  75. package/dist/permissions/settings.d.ts +28 -1
  76. package/dist/permissions/settings.js +93 -3
  77. package/dist/permissions/shell-command.d.ts +36 -0
  78. package/dist/permissions/shell-command.js +226 -0
  79. package/dist/permissions/trust-prompt.d.ts +10 -0
  80. package/dist/permissions/trust-prompt.js +34 -0
  81. package/dist/permissions/trust.d.ts +26 -0
  82. package/dist/permissions/trust.js +113 -0
  83. package/dist/prompt/compose.d.ts +3 -0
  84. package/dist/prompt/compose.js +24 -7
  85. package/dist/prompt/environment.js +2 -2
  86. package/dist/prompt/reminders.js +1 -1
  87. package/dist/prompt/runtime.js +1 -1
  88. package/dist/provider-anthropic.d.ts +6 -2
  89. package/dist/provider-anthropic.js +98 -18
  90. package/dist/provider-error-record.d.ts +9 -0
  91. package/dist/provider-error-record.js +42 -14
  92. package/dist/provider-grok.js +5 -1
  93. package/dist/provider-openai-codex.d.ts +8 -0
  94. package/dist/provider-openai-codex.js +107 -24
  95. package/dist/provider-registry.d.ts +45 -2
  96. package/dist/provider-registry.js +300 -88
  97. package/dist/provider-transform.js +8 -0
  98. package/dist/provider-zen-errors.d.ts +9 -0
  99. package/dist/provider-zen-errors.js +36 -0
  100. package/dist/provider.js +36 -5
  101. package/dist/sdk/index.d.ts +69 -8
  102. package/dist/sdk/index.js +252 -39
  103. package/dist/sdk/replay-event-log.d.ts +7 -0
  104. package/dist/sdk/replay-event-log.js +66 -16
  105. package/dist/session-context-fence.d.ts +16 -0
  106. package/dist/session-context-fence.js +26 -0
  107. package/dist/session-log.d.ts +11 -2
  108. package/dist/session-log.js +187 -39
  109. package/dist/session-types.d.ts +5 -1
  110. package/dist/session.d.ts +39 -7
  111. package/dist/session.js +341 -93
  112. package/dist/skills/format.d.ts +31 -1
  113. package/dist/skills/format.js +45 -24
  114. package/dist/slash-commands/commands.d.ts +3 -0
  115. package/dist/slash-commands/commands.js +29 -14
  116. package/dist/system-prompt.d.ts +3 -1
  117. package/dist/tasks/manager.d.ts +7 -0
  118. package/dist/tasks/manager.js +97 -18
  119. package/dist/tools/agent-lifecycle.js +11 -7
  120. package/dist/tools/child-tools.d.ts +1 -1
  121. package/dist/tools/child-tools.js +4 -4
  122. package/dist/tools/edit-apply.d.ts +0 -3
  123. package/dist/tools/edit-apply.js +45 -249
  124. package/dist/tools/edit.js +3 -3
  125. package/dist/tools/exit-plan-mode.d.ts +5 -0
  126. package/dist/tools/exit-plan-mode.js +3 -2
  127. package/dist/tools/file-state.d.ts +4 -0
  128. package/dist/tools/file-state.js +12 -3
  129. package/dist/tools/grep.js +20 -1
  130. package/dist/tools/index.d.ts +1 -1
  131. package/dist/tools/index.js +3 -3
  132. package/dist/tools/ls.d.ts +3 -0
  133. package/dist/tools/ls.js +85 -0
  134. package/dist/tools/path-utils.d.ts +6 -0
  135. package/dist/tools/path-utils.js +38 -1
  136. package/dist/tools/sensitive-paths.d.ts +8 -0
  137. package/dist/tools/sensitive-paths.js +85 -3
  138. package/dist/tools/skill.d.ts +2 -0
  139. package/dist/tools/skill.js +34 -2
  140. package/dist/tools/task-tools.js +11 -7
  141. package/dist/tui/app.js +22 -12
  142. package/dist/tui/components/approval-dialog.d.ts +5 -5
  143. package/dist/tui/components/approval-dialog.js +16 -51
  144. package/dist/tui/components/skills-panel.d.ts +4 -0
  145. package/dist/tui/components/skills-panel.js +15 -1
  146. package/dist/tui/components/streaming-message.js +7 -2
  147. package/dist/tui/components/tasks-pane.d.ts +38 -0
  148. package/dist/tui/components/tasks-pane.js +182 -32
  149. package/dist/tui/components/transcript.d.ts +11 -4
  150. package/dist/tui/components/transcript.js +158 -71
  151. package/dist/tui/composer-autocomplete.d.ts +17 -2
  152. package/dist/tui/composer-autocomplete.js +79 -2
  153. package/dist/tui/controller/controller.d.ts +9 -4
  154. package/dist/tui/controller/controller.js +87 -95
  155. package/dist/tui/controller/goal-runtime-controller.js +1 -1
  156. package/dist/tui/controller/session-transition.js +1 -1
  157. package/dist/tui/model/subagent-view.d.ts +11 -19
  158. package/dist/tui/model/subagent-view.js +56 -85
  159. package/dist/tui/model/task-lifecycle.d.ts +57 -0
  160. package/dist/tui/model/task-lifecycle.js +124 -0
  161. package/dist/tui/model/trace-groups.js +10 -9
  162. package/dist/tui/trace-groups.js +10 -9
  163. package/dist/types.d.ts +20 -0
  164. package/dist/variant/variant-resolver.js +2 -0
  165. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
  166. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
  167. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
  168. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
  169. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
  170. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
  171. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
  172. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
  173. package/package.json +11 -3
  174. package/dist/prompt/skills.d.ts +0 -2
  175. package/dist/prompt/skills.js +0 -4
  176. package/dist/tools/glob.d.ts +0 -5
  177. package/dist/tools/glob.js +0 -199
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { isAbsolute, resolve } from "node:path";
3
3
  import picomatch from "picomatch";
4
+ import { analyzeShellCommand, effectiveCommandTokens, shellTokens } from "./shell-command.js";
4
5
  const KNOWN_TOOLS = new Set([
5
6
  "Bash",
6
7
  "Read",
@@ -80,11 +81,14 @@ export function parseRules(inputs) {
80
81
  /**
81
82
  * Does `rule` match `query`?
82
83
  *
83
- * Tool name must match (or rule.tool is "*"). If the rule has no pattern, it
84
- * matches any use of that tool. Otherwise the tool-specific matcher decides.
84
+ * Tool name must match (or rule.tool is "*"; `mcp__server` covers every
85
+ * `mcp__server__tool`). If the rule has no pattern, it matches any use of that
86
+ * tool. Otherwise the tool-specific matcher decides. `list` matters for Bash:
87
+ * an allow rule must cover every command a compound command runs, while a
88
+ * deny rule fires if it covers any of them.
85
89
  */
86
- export function matchRule(rule, query) {
87
- if (rule.tool !== "*" && rule.tool !== query.tool) {
90
+ export function matchRule(rule, query, list = "allow") {
91
+ if (rule.tool !== "*" && rule.tool !== query.tool && !isMcpServerRule(rule, query)) {
88
92
  return false;
89
93
  }
90
94
  if (rule.pattern === undefined) {
@@ -95,7 +99,9 @@ export function matchRule(rule, query) {
95
99
  case "Bash":
96
100
  if (!("command" in query) || typeof query.command !== "string")
97
101
  return false;
98
- return matchBash(rule.pattern, query.command);
102
+ return list === "deny"
103
+ ? bashDenyMatches(rule.pattern, query.command)
104
+ : bashAllowMatches(rule.pattern, query.command);
99
105
  case "Read":
100
106
  case "Write":
101
107
  case "Edit":
@@ -121,30 +127,83 @@ export function matchRule(rule, query) {
121
127
  */
122
128
  export function checkPermission(rules, query) {
123
129
  for (const rule of rules.deny) {
124
- if (matchRule(rule, query)) {
130
+ if (matchRule(rule, query, "deny")) {
125
131
  return { decision: "deny", rule };
126
132
  }
127
133
  }
134
+ if (query.tool === "Bash" && "command" in query && typeof query.command === "string") {
135
+ return checkBashAllow(rules.allow, query.command);
136
+ }
128
137
  for (const rule of rules.allow) {
129
- if (matchRule(rule, query)) {
138
+ if (matchRule(rule, query, "allow")) {
130
139
  return { decision: "allow", rule };
131
140
  }
132
141
  }
133
142
  return { decision: "ask" };
134
143
  }
144
+ /**
145
+ * A compound command is allowed when every simple command in it is covered
146
+ * by some allow rule (different rules may cover different parts), and it has
147
+ * nothing opaque — unless a rule grants any command (`Bash`, `*`, `Bash(:*)`).
148
+ */
149
+ function checkBashAllow(allowRules, command) {
150
+ const bashRules = allowRules.filter((rule) => rule.tool === "Bash" || rule.tool === "*");
151
+ const anyCommand = bashRules.find((rule) => rule.pattern === undefined || tokenize(rule.pattern).join(" ") === ":*");
152
+ if (anyCommand)
153
+ return { decision: "allow", rule: anyCommand };
154
+ const analysis = analyzeShellCommand(command);
155
+ if (analysis.opaque || analysis.segments.length === 0)
156
+ return { decision: "ask" };
157
+ let first;
158
+ for (const segment of analysis.segments) {
159
+ const tokens = shellTokens(segment);
160
+ const rule = bashRules.find((candidate) => matchBash(candidate.pattern, tokens));
161
+ if (!rule)
162
+ return { decision: "ask" };
163
+ first ??= rule;
164
+ }
165
+ return first ? { decision: "allow", rule: first } : { decision: "ask" };
166
+ }
135
167
  // --- per-tool matchers ---------------------------------------------------
168
+ /** `mcp__github` (no pattern) covers every tool of that MCP server. */
169
+ function isMcpServerRule(rule, query) {
170
+ return rule.pattern === undefined
171
+ && rule.tool.startsWith("mcp__")
172
+ && query.tool.startsWith(`${rule.tool}__`);
173
+ }
174
+ /**
175
+ * Allow: every simple command the line runs must match, and nothing opaque
176
+ * (substitution, file redirection, heredoc) may ride along.
177
+ */
178
+ function bashAllowMatches(pattern, command) {
179
+ // `Bash(:*)` is an explicit "any command" grant.
180
+ if (tokenize(pattern).join(" ") === ":*")
181
+ return true;
182
+ const analysis = analyzeShellCommand(command);
183
+ if (analysis.opaque || analysis.segments.length === 0)
184
+ return false;
185
+ return analysis.segments.every((segment) => matchBash(pattern, shellTokens(segment)));
186
+ }
187
+ /**
188
+ * Deny: any simple command matching — as written, or with leading
189
+ * assignments/wrappers (`sudo`, `env`, ...) stripped — blocks the line.
190
+ */
191
+ function bashDenyMatches(pattern, command) {
192
+ const analysis = analyzeShellCommand(command);
193
+ if (analysis.segments.length === 0)
194
+ return matchBash(pattern, shellTokens(command));
195
+ return analysis.segments.some((segment) => matchBash(pattern, shellTokens(segment)) || matchBash(pattern, effectiveCommandTokens(segment)));
196
+ }
136
197
  /**
137
198
  * Bash pattern matching.
138
199
  *
139
200
  * - `git status` → command tokens equal ["git","status"] exactly
140
201
  * - `git status:*` → command tokens start with ["git","status"]
141
202
  *
142
- * Tokenization splits on whitespace. Shell control tokens (`&&`, `|`, `;`) are
143
- * treated as plain tokens; a rule that includes them would have to match them
144
- * literally. v1 does not try to parse shell grammar.
203
+ * Matches one simple command's tokens; bashAllowMatches / bashDenyMatches
204
+ * split compound commands first (see shell-command.ts).
145
205
  */
146
- function matchBash(pattern, command) {
147
- const cmdTokens = tokenize(command);
206
+ function matchBash(pattern, cmdTokens) {
148
207
  let ruleTokens = tokenize(pattern);
149
208
  let prefixMatch = false;
150
209
  if (ruleTokens.length > 0) {
@@ -11,12 +11,19 @@
11
11
  * `allow` / `deny` arrays are concatenated across scopes (with the rule text
12
12
  * itself carrying provenance via `PermissionRule.source`).
13
13
  *
14
+ * Allow rules and LSP server definitions from the project and local scopes
15
+ * live inside the repository, so they only take effect once the user trusts
16
+ * this folder (asked at startup, see trust.ts); until then they are reported in
17
+ * `untrusted` (MCP servers from those files are gated the same way by
18
+ * loadMcpConfig).
19
+ *
14
20
  * Parse errors do not fail the load; they collect into `diagnostics` so callers
15
21
  * can surface them in /permissions or on startup without taking the agent down.
16
22
  */
17
23
  import type { PermissionMode } from "../types.js";
18
24
  import { type LspConfig } from "../lsp/config.js";
19
- import type { PermissionRuleSet } from "./types.js";
25
+ import { type RepoCapabilities } from "./trust.js";
26
+ import type { PermissionRule, PermissionRuleSet } from "./types.js";
20
27
  export type SettingsScope = "user" | "project" | "local";
21
28
  export type RuleList = "allow" | "deny";
22
29
  export interface RawSettings {
@@ -36,6 +43,10 @@ export interface MergedSettings {
36
43
  defaultMode?: PermissionMode;
37
44
  lsp?: LspConfig;
38
45
  ruleSet: PermissionRuleSet;
46
+ /** Project/local allow rules ignored until the user trusts them. */
47
+ untrustedAllow: PermissionRule[];
48
+ /** Everything in the repository settings that waits for trust (allow rules, MCP servers, LSP servers). */
49
+ untrusted: RepoCapabilities;
39
50
  diagnostics: SettingsDiagnostic[];
40
51
  }
41
52
  export interface SettingsManagerOptions {
@@ -46,9 +57,25 @@ export declare class SettingsManager {
46
57
  private readonly paths;
47
58
  private raw;
48
59
  private fileDiagnostics;
60
+ private readonly cwd;
61
+ private readonly bubbleHome?;
62
+ /** Cached per load: whether the repository's capability settings are trusted. */
63
+ private repoTrusted;
49
64
  constructor(cwd: string, options?: SettingsManagerOptions);
50
65
  /** Re-read all three files from disk. */
51
66
  reload(): void;
67
+ /**
68
+ * Trust the repository's current capability settings (allow rules, MCP
69
+ * servers, LSP servers from the project and local files).
70
+ */
71
+ trustRepoConfig(): void;
72
+ private repoRaw;
73
+ /**
74
+ * Bubble's own /permissions edits keep an existing trust: rules the user
75
+ * adds through Bubble are their decision. An untrusted rule set stays
76
+ * untrusted — editing it must not launder rules the user never reviewed.
77
+ */
78
+ private keepRepoTrustAfterWrite;
52
79
  getPath(scope: SettingsScope): string;
53
80
  /** Merged view for runtime consumption. Does not hit disk — call `reload()` first if stale. */
54
81
  getMerged(): MergedSettings;
@@ -11,6 +11,12 @@
11
11
  * `allow` / `deny` arrays are concatenated across scopes (with the rule text
12
12
  * itself carrying provenance via `PermissionRule.source`).
13
13
  *
14
+ * Allow rules and LSP server definitions from the project and local scopes
15
+ * live inside the repository, so they only take effect once the user trusts
16
+ * this folder (asked at startup, see trust.ts); until then they are reported in
17
+ * `untrusted` (MCP servers from those files are gated the same way by
18
+ * loadMcpConfig).
19
+ *
14
20
  * Parse errors do not fail the load; they collect into `diagnostics` so callers
15
21
  * can surface them in /permissions or on startup without taking the agent down.
16
22
  */
@@ -19,6 +25,7 @@ import { dirname, join } from "node:path";
19
25
  import { getBubbleHome } from "../bubble-home.js";
20
26
  import { normalizeLspConfig } from "../lsp/config.js";
21
27
  import { parseRules } from "./rule.js";
28
+ import { isRepoConfigTrusted, repoCapabilities, trustRepoConfig } from "./trust.js";
22
29
  const KNOWN_MODES = new Set([
23
30
  "default",
24
31
  "plan",
@@ -32,8 +39,14 @@ export class SettingsManager {
32
39
  local: null,
33
40
  };
34
41
  fileDiagnostics = [];
42
+ cwd;
43
+ bubbleHome;
44
+ /** Cached per load: whether the repository's capability settings are trusted. */
45
+ repoTrusted = false;
35
46
  constructor(cwd, options = {}) {
36
47
  const bubbleHome = options.bubbleHome ?? getBubbleHome();
48
+ this.cwd = cwd;
49
+ this.bubbleHome = bubbleHome;
37
50
  this.paths = {
38
51
  user: join(bubbleHome, "settings.json"),
39
52
  project: join(cwd, ".bubble", "settings.json"),
@@ -47,6 +60,31 @@ export class SettingsManager {
47
60
  for (const scope of ["user", "project", "local"]) {
48
61
  this.raw[scope] = this.readFile(scope);
49
62
  }
63
+ this.repoTrusted = isRepoConfigTrusted(this.cwd, this.repoRaw(), { bubbleHome: this.bubbleHome });
64
+ }
65
+ /**
66
+ * Trust the repository's current capability settings (allow rules, MCP
67
+ * servers, LSP servers from the project and local files).
68
+ */
69
+ trustRepoConfig() {
70
+ trustRepoConfig(this.cwd, this.repoRaw(), { bubbleHome: this.bubbleHome });
71
+ this.repoTrusted = true;
72
+ }
73
+ repoRaw() {
74
+ return { project: this.raw.project, local: this.raw.local };
75
+ }
76
+ /**
77
+ * Bubble's own /permissions edits keep an existing trust: rules the user
78
+ * adds through Bubble are their decision. An untrusted rule set stays
79
+ * untrusted — editing it must not launder rules the user never reviewed.
80
+ */
81
+ keepRepoTrustAfterWrite(scope, wasTrusted) {
82
+ if (scope === "user")
83
+ return;
84
+ if (wasTrusted)
85
+ this.trustRepoConfig();
86
+ else
87
+ this.repoTrusted = isRepoConfigTrusted(this.cwd, this.repoRaw(), { bubbleHome: this.bubbleHome });
50
88
  }
51
89
  getPath(scope) {
52
90
  return this.paths[scope];
@@ -58,12 +96,21 @@ export class SettingsManager {
58
96
  let lsp;
59
97
  const allow = [];
60
98
  const deny = [];
99
+ const untrustedAllow = [];
61
100
  for (const scope of ["user", "project", "local"]) {
62
101
  const data = this.raw[scope];
63
102
  if (!data)
64
103
  continue;
65
104
  if ("lsp" in data) {
66
- const parsed = normalizeLspConfig(data.lsp);
105
+ const trustedLsp = scope === "user" || this.repoTrusted;
106
+ const parsed = trustedLsp ? normalizeLspConfig(data.lsp) : untrustedLspSwitches(data.lsp);
107
+ if (!trustedLsp && repoCapabilities(data).lspServers.length > 0) {
108
+ diagnostics.push({
109
+ scope,
110
+ path: this.paths[scope],
111
+ message: `Ignored LSP server definitions from this repository (${repoCapabilities(data).lspServers.join(", ")}): the folder is not trusted (Bubble asks at startup).`,
112
+ });
113
+ }
67
114
  if (parsed === undefined) {
68
115
  diagnostics.push({
69
116
  scope,
@@ -71,7 +118,7 @@ export class SettingsManager {
71
118
  message: "Ignored lsp setting — expected boolean or object.",
72
119
  });
73
120
  }
74
- else {
121
+ else if (parsed !== null) {
75
122
  lsp = parsed;
76
123
  }
77
124
  }
@@ -93,7 +140,19 @@ export class SettingsManager {
93
140
  }
94
141
  if (Array.isArray(perms.allow)) {
95
142
  const parsed = parseRules(perms.allow);
96
- allow.push(...parsed.rules);
143
+ if (scope === "user" || this.repoTrusted) {
144
+ allow.push(...parsed.rules);
145
+ }
146
+ else {
147
+ untrustedAllow.push(...parsed.rules);
148
+ if (parsed.rules.length > 0) {
149
+ diagnostics.push({
150
+ scope,
151
+ path: this.paths[scope],
152
+ message: `Ignored ${parsed.rules.length} allow rule${parsed.rules.length === 1 ? "" : "s"} from this repository: the folder is not trusted (Bubble asks at startup).`,
153
+ });
154
+ }
155
+ }
97
156
  for (const err of parsed.errors) {
98
157
  diagnostics.push({
99
158
  scope,
@@ -118,6 +177,10 @@ export class SettingsManager {
118
177
  defaultMode,
119
178
  lsp,
120
179
  ruleSet: { allow, deny },
180
+ untrustedAllow,
181
+ untrusted: this.repoTrusted
182
+ ? { allow: [], mcpServers: [], lspServers: [] }
183
+ : mergeCapabilities(repoCapabilities(this.raw.project), repoCapabilities(this.raw.local)),
121
184
  diagnostics,
122
185
  };
123
186
  }
@@ -137,9 +200,11 @@ export class SettingsManager {
137
200
  }
138
201
  current.push(rule);
139
202
  permissions[list] = current;
203
+ const wasTrusted = this.repoTrusted;
140
204
  const next = { ...raw, permissions };
141
205
  this.writeFile(scope, next);
142
206
  this.raw[scope] = next;
207
+ this.keepRepoTrustAfterWrite(scope, wasTrusted);
143
208
  return true;
144
209
  }
145
210
  /**
@@ -159,9 +224,11 @@ export class SettingsManager {
159
224
  // Drop the key if empty, keep file readable
160
225
  if (current.length === 0)
161
226
  delete nextPermissions[list];
227
+ const wasTrusted = this.repoTrusted;
162
228
  const next = { ...raw, permissions: nextPermissions };
163
229
  this.writeFile(scope, next);
164
230
  this.raw[scope] = next;
231
+ this.keepRepoTrustAfterWrite(scope, wasTrusted);
165
232
  return true;
166
233
  }
167
234
  // -- internal --------------------------------------------------------
@@ -197,3 +264,26 @@ export class SettingsManager {
197
264
  writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
198
265
  }
199
266
  }
267
+ /**
268
+ * What an untrusted repository may still say about LSP: turning it off, or
269
+ * turning individual servers off. Commands, env and options need trust.
270
+ * Returns null when nothing usable is left (the scope then does not override).
271
+ */
272
+ function untrustedLspSwitches(value) {
273
+ if (typeof value === "boolean")
274
+ return value === false ? false : null;
275
+ const parsed = normalizeLspConfig(value);
276
+ if (parsed === undefined || typeof parsed === "boolean")
277
+ return parsed;
278
+ const switches = Object.fromEntries(Object.entries(parsed)
279
+ .filter(([, server]) => server.disabled === true)
280
+ .map(([id]) => [id, { disabled: true }]));
281
+ return Object.keys(switches).length > 0 ? switches : null;
282
+ }
283
+ function mergeCapabilities(a, b) {
284
+ return {
285
+ allow: [...a.allow, ...b.allow],
286
+ mcpServers: [...new Set([...a.mcpServers, ...b.mcpServers])],
287
+ lspServers: [...new Set([...a.lspServers, ...b.lspServers])],
288
+ };
289
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Just enough shell grammar for permission matching.
3
+ *
4
+ * Prefix rules ("git status", `Bash(npm run:*)`) used to compare against the
5
+ * raw command string, so an approved `git status` also approved
6
+ * `git status && rm -rf ~`, and a `Bash(rm:*)` deny rule missed
7
+ * `echo ok && rm -rf x`. This splits a command into the simple commands it
8
+ * runs and flags constructs whose effect cannot be judged from the visible
9
+ * command words. It is deliberately not a full parser: anything it does not
10
+ * understand is marked opaque, and opaque commands are never auto-allowed.
11
+ */
12
+ export interface ShellCommandAnalysis {
13
+ /**
14
+ * Simple commands in source order, including ones nested inside command
15
+ * substitution, backticks and process substitution. Leading control
16
+ * keywords (`then`, `do`, `!`, `{`, ...) are stripped.
17
+ */
18
+ segments: string[];
19
+ /**
20
+ * The command contains something an allow rule cannot vouch for: command
21
+ * or process substitution, backticks, a redirection to anything but
22
+ * /dev/null or another fd, a heredoc, a compound statement header
23
+ * (for/case/function), or unterminated quoting.
24
+ */
25
+ opaque: boolean;
26
+ }
27
+ export declare function analyzeShellCommand(command: string): ShellCommandAnalysis;
28
+ export declare function shellTokens(segment: string): string[];
29
+ /**
30
+ * The words of a segment with leading `VAR=value` assignments and command
31
+ * wrappers (`sudo -u x`, `env`, `nohup`, ...) removed. Deny rules match
32
+ * against this too, so `sudo rm -rf x` and `FOO=1 rm -rf x` still hit a
33
+ * `Bash(rm:*)` deny. Best effort — deny rules on shell commands can always be
34
+ * evaded by a determined command (e.g. `bash -c`), and are documented as such.
35
+ */
36
+ export declare function effectiveCommandTokens(segment: string): string[];
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Just enough shell grammar for permission matching.
3
+ *
4
+ * Prefix rules ("git status", `Bash(npm run:*)`) used to compare against the
5
+ * raw command string, so an approved `git status` also approved
6
+ * `git status && rm -rf ~`, and a `Bash(rm:*)` deny rule missed
7
+ * `echo ok && rm -rf x`. This splits a command into the simple commands it
8
+ * runs and flags constructs whose effect cannot be judged from the visible
9
+ * command words. It is deliberately not a full parser: anything it does not
10
+ * understand is marked opaque, and opaque commands are never auto-allowed.
11
+ */
12
+ /** Reserved words that may precede a command without changing what it runs. */
13
+ const LEADING_KEYWORDS = new Set([
14
+ "if", "then", "else", "elif", "fi", "do", "done", "while", "until", "!", "{", "}", "time",
15
+ ]);
16
+ /** Statement headers whose words are not a command at all. */
17
+ const OPAQUE_HEADERS = new Set(["for", "case", "select", "function", "esac"]);
18
+ const WORD_BREAK = /[\s;&|()<>]/;
19
+ export function analyzeShellCommand(command) {
20
+ const segments = [];
21
+ let opaque = false;
22
+ let current = "";
23
+ // Open contexts: a double quote, a `$(`/`<(`/`>(`/subshell closed by `)`,
24
+ // or a backtick substitution.
25
+ const stack = [];
26
+ const flush = () => {
27
+ const tokens = current.trim().split(/\s+/).filter(Boolean);
28
+ current = "";
29
+ while (tokens.length > 0 && LEADING_KEYWORDS.has(tokens[0]))
30
+ tokens.shift();
31
+ if (tokens.length === 0)
32
+ return;
33
+ if (OPAQUE_HEADERS.has(tokens[0]))
34
+ opaque = true;
35
+ segments.push(tokens.join(" "));
36
+ };
37
+ const n = command.length;
38
+ for (let i = 0; i < n; i++) {
39
+ const c = command[i];
40
+ const next = command[i + 1];
41
+ const top = stack[stack.length - 1];
42
+ if (top === '"') {
43
+ if (c === "\\") {
44
+ current += c + (next ?? "");
45
+ i++;
46
+ continue;
47
+ }
48
+ if (c === '"') {
49
+ stack.pop();
50
+ current += c;
51
+ continue;
52
+ }
53
+ if (c === "$" && next === "(") {
54
+ opaque = true;
55
+ flush();
56
+ stack.push(")");
57
+ i++;
58
+ continue;
59
+ }
60
+ if (c === "`") {
61
+ opaque = true;
62
+ flush();
63
+ stack.push("`");
64
+ continue;
65
+ }
66
+ current += c;
67
+ continue;
68
+ }
69
+ if (c === "'") {
70
+ const end = command.indexOf("'", i + 1);
71
+ if (end < 0) {
72
+ opaque = true;
73
+ current += command.slice(i);
74
+ break;
75
+ }
76
+ current += command.slice(i, end + 1);
77
+ i = end;
78
+ continue;
79
+ }
80
+ if (c === "\\") {
81
+ current += c + (next ?? "");
82
+ i++;
83
+ continue;
84
+ }
85
+ if (c === '"') {
86
+ stack.push('"');
87
+ current += c;
88
+ continue;
89
+ }
90
+ if (c === "`") {
91
+ opaque = true;
92
+ flush();
93
+ if (top === "`")
94
+ stack.pop();
95
+ else
96
+ stack.push("`");
97
+ continue;
98
+ }
99
+ if (c === "$" && next === "(") {
100
+ opaque = true;
101
+ flush();
102
+ stack.push(")");
103
+ i++;
104
+ continue;
105
+ }
106
+ if ((c === "<" || c === ">") && next === "(") {
107
+ opaque = true;
108
+ flush();
109
+ stack.push(")");
110
+ i++;
111
+ continue;
112
+ }
113
+ if (c === "<" || c === ">" || (c === "&" && next === ">")) {
114
+ // A redirection. Drop an fd number glued to the operator ("2>").
115
+ current = current.replace(/(^|\s)\d+$/, "$1");
116
+ let j = i + 1;
117
+ let operator = c;
118
+ while (j < n && /[<>&|]/.test(command[j])) {
119
+ operator += command[j];
120
+ j++;
121
+ }
122
+ if (operator.startsWith("<<"))
123
+ opaque = true; // heredoc / herestring
124
+ while (j < n && (command[j] === " " || command[j] === "\t"))
125
+ j++;
126
+ let k = j;
127
+ while (k < n && !WORD_BREAK.test(command[k]))
128
+ k++;
129
+ const target = command.slice(j, k);
130
+ const harmless = target === "/dev/null" || (operator.endsWith("&") && /^(\d+|-)$/.test(target));
131
+ if (!harmless)
132
+ opaque = true;
133
+ i = k - 1;
134
+ continue;
135
+ }
136
+ if (c === ")") {
137
+ flush();
138
+ if (top === ")")
139
+ stack.pop();
140
+ continue;
141
+ }
142
+ if (c === "(") {
143
+ flush();
144
+ stack.push(")");
145
+ continue;
146
+ }
147
+ if (c === ";" || c === "\n" || c === "&" || c === "|") {
148
+ flush();
149
+ continue;
150
+ }
151
+ current += c;
152
+ }
153
+ flush();
154
+ if (stack.length > 0)
155
+ opaque = true;
156
+ return { segments, opaque };
157
+ }
158
+ export function shellTokens(segment) {
159
+ return segment.trim().split(/\s+/).filter(Boolean);
160
+ }
161
+ /** Wrappers that run their argument as the real command. */
162
+ const COMMAND_WRAPPERS = new Set(["command", "exec", "nohup", "builtin", "env", "sudo", "doas", "nice", "xargs"]);
163
+ /**
164
+ * Wrapper options that take a separate argument (`sudo -u root`), so that
165
+ * argument is not mistaken for the command. `env -S` is deliberately absent:
166
+ * its argument is the command line itself.
167
+ */
168
+ const WRAPPER_OPTION_ARGS = {
169
+ sudo: { short: "CDghpRrTtUu", long: ["chdir", "chroot", "close-from", "command-timeout", "group", "host", "other-user", "prompt", "role", "type", "user"] },
170
+ doas: { short: "Cu", long: [] },
171
+ env: { short: "aCPu", long: ["argv0", "chdir", "unset"] },
172
+ nice: { short: "n", long: ["adjustment"] },
173
+ // GNU --eof/--replace/--max-lines take a value only as `--opt=value`.
174
+ xargs: { short: "adEIJLnPRSs", long: ["arg-file", "delimiter", "max-args", "max-chars", "max-procs", "process-slot-var"] },
175
+ exec: { short: "a", long: [] },
176
+ };
177
+ /** getopt rules: in `-Eu root` the last letter takes the next word; in `-uroot` it is attached. */
178
+ function optionTakesNextWord(wrapper, option) {
179
+ const spec = WRAPPER_OPTION_ARGS[wrapper];
180
+ if (!spec)
181
+ return false;
182
+ if (option.startsWith("--"))
183
+ return !option.includes("=") && spec.long.includes(option.slice(2));
184
+ for (let index = 1; index < option.length; index++) {
185
+ if (spec.short.includes(option[index]))
186
+ return index === option.length - 1;
187
+ }
188
+ return false;
189
+ }
190
+ /**
191
+ * The words of a segment with leading `VAR=value` assignments and command
192
+ * wrappers (`sudo -u x`, `env`, `nohup`, ...) removed. Deny rules match
193
+ * against this too, so `sudo rm -rf x` and `FOO=1 rm -rf x` still hit a
194
+ * `Bash(rm:*)` deny. Best effort — deny rules on shell commands can always be
195
+ * evaded by a determined command (e.g. `bash -c`), and are documented as such.
196
+ */
197
+ export function effectiveCommandTokens(segment) {
198
+ const tokens = shellTokens(segment).map(unquoteWord);
199
+ let changed = true;
200
+ while (changed && tokens.length > 0) {
201
+ changed = false;
202
+ while (tokens.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) {
203
+ tokens.shift();
204
+ changed = true;
205
+ }
206
+ if (tokens.length > 0 && COMMAND_WRAPPERS.has(tokens[0])) {
207
+ const wrapper = tokens.shift();
208
+ while (tokens.length > 0 && tokens[0].startsWith("-")) {
209
+ const option = tokens.shift();
210
+ if (option === "--")
211
+ break;
212
+ if (optionTakesNextWord(wrapper, option))
213
+ tokens.shift();
214
+ }
215
+ changed = true;
216
+ }
217
+ }
218
+ // `/bin/rm` and `./rm` run the same program a `Bash(rm:*)` deny names.
219
+ if (tokens.length > 0 && tokens[0].includes("/"))
220
+ tokens[0] = tokens[0].slice(tokens[0].lastIndexOf("/") + 1);
221
+ return tokens;
222
+ }
223
+ /** `'rm'`, `"rm"` and `r\m` are all the word rm to the shell. */
224
+ function unquoteWord(word) {
225
+ return word.replace(/\\(.)/g, "$1").replace(/['"]/g, "");
226
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Folder trust, the Kimi Code way: when the repository's `.bubble` settings
3
+ * would grant something (allow rules, MCP servers, LSP servers), ask once at
4
+ * startup before any of it loads. Trusting records the exact settings; any
5
+ * later change asks again. Declining keeps them off for this run only.
6
+ */
7
+ export declare function promptRepoTrust(cwd: string, io?: {
8
+ input: NodeJS.ReadableStream;
9
+ output: NodeJS.WritableStream;
10
+ }): Promise<boolean>;
@@ -0,0 +1,34 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import chalk from "chalk";
3
+ import { describeRepoCapabilities, isRepoConfigTrusted, mergedRepoCapabilities, readRepoSettings, trustRepoConfig, } from "./trust.js";
4
+ /**
5
+ * Folder trust, the Kimi Code way: when the repository's `.bubble` settings
6
+ * would grant something (allow rules, MCP servers, LSP servers), ask once at
7
+ * startup before any of it loads. Trusting records the exact settings; any
8
+ * later change asks again. Declining keeps them off for this run only.
9
+ */
10
+ export async function promptRepoTrust(cwd, io = { input: process.stdin, output: process.stdout }) {
11
+ // Read once: what gets trusted is exactly what was shown.
12
+ const raw = readRepoSettings(cwd);
13
+ if (isRepoConfigTrusted(cwd, raw))
14
+ return true;
15
+ const say = (line) => io.output.write(`${line}\n`);
16
+ say(chalk.bold(`\nThis folder's .bubble settings would enable:`));
17
+ for (const line of describeRepoCapabilities(mergedRepoCapabilities(raw)))
18
+ say(` ${line}`);
19
+ say(chalk.dim("Only trust folders whose settings you have reviewed; MCP servers run commands on your machine."));
20
+ const rl = createInterface({ input: io.input, output: io.output });
21
+ try {
22
+ const answer = (await rl.question("Trust this folder? [Y/n] ")).trim().toLowerCase();
23
+ const trusted = answer === "" || answer === "y" || answer === "yes";
24
+ if (trusted)
25
+ trustRepoConfig(cwd, raw);
26
+ say(chalk.dim(trusted
27
+ ? "Trusted. Any change to these settings will ask again.\n"
28
+ : "Not trusted: these settings stay off this session.\n"));
29
+ return trusted;
30
+ }
31
+ finally {
32
+ rl.close();
33
+ }
34
+ }