indusagi-coding-agent 0.2.5 → 0.2.9

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 (224) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +9 -5
  3. package/dist/entry.js +14267 -24282
  4. package/dist/guardrails.js +924 -7867
  5. package/dist/index.js +13706 -23775
  6. package/package.json +3 -2
  7. package/src/_decls/entry.ts +18 -0
  8. package/src/_decls/guardrails.ts +35 -0
  9. package/src/_decls/index.ts +26 -0
  10. package/src/addons/contract.ts +236 -0
  11. package/src/addons/dispatch/event-dispatcher.ts +164 -0
  12. package/src/addons/dispatch/index.ts +25 -0
  13. package/src/addons/dispatch/tool-interceptor.ts +208 -0
  14. package/src/addons/host.ts +225 -0
  15. package/src/addons/index.ts +112 -0
  16. package/src/addons/manifest.ts +158 -0
  17. package/src/addons/sandbox.ts +170 -0
  18. package/src/addons/surface.ts +78 -0
  19. package/src/boot/auth-vault.ts +195 -0
  20. package/src/boot/boot.ts +138 -0
  21. package/src/boot/contract.ts +238 -0
  22. package/src/boot/heap.ts +59 -0
  23. package/src/boot/index.ts +28 -0
  24. package/src/boot/invocation.ts +93 -0
  25. package/src/boot/runners/addon-wiring.ts +153 -0
  26. package/src/boot/runners/checkpoint.ts +169 -0
  27. package/src/boot/runners/delegate-runner.ts +294 -0
  28. package/src/boot/runners/index.ts +13 -0
  29. package/src/boot/runners/link-runner.ts +45 -0
  30. package/src/boot/runners/memdir.ts +168 -0
  31. package/src/boot/runners/oneshot-runner.ts +58 -0
  32. package/src/boot/runners/read-state.ts +90 -0
  33. package/src/boot/runners/registry.ts +42 -0
  34. package/src/boot/runners/repl-runner.ts +143 -0
  35. package/src/boot/runners/server-mode.ts +121 -0
  36. package/src/boot/runners/session.ts +641 -0
  37. package/src/boot/server-token.ts +148 -0
  38. package/src/boot/stages.ts +167 -0
  39. package/src/boot/upgrade/apply.ts +94 -0
  40. package/src/boot/upgrade/index.ts +13 -0
  41. package/src/boot/upgrade/upgrades.ts +289 -0
  42. package/src/briefing/compose.ts +150 -0
  43. package/src/briefing/context-docs.ts +19 -0
  44. package/src/briefing/contract.ts +717 -0
  45. package/src/briefing/index.ts +31 -0
  46. package/src/briefing/macros.ts +97 -0
  47. package/src/briefing/skills.ts +47 -0
  48. package/src/capability-deck/bridge-ledger/index.ts +27 -0
  49. package/src/capability-deck/bridge-ledger/key.ts +67 -0
  50. package/src/capability-deck/bridge-ledger/ledger.ts +131 -0
  51. package/src/capability-deck/bridge-ledger/network.ts +117 -0
  52. package/src/capability-deck/builtin-bridge.ts +312 -0
  53. package/src/capability-deck/cards/bg-process-card.ts +335 -0
  54. package/src/capability-deck/cards/index.ts +115 -0
  55. package/src/capability-deck/cards/memory-card.ts +146 -0
  56. package/src/capability-deck/cards/plan-file.ts +97 -0
  57. package/src/capability-deck/cards/plan-tools.ts +185 -0
  58. package/src/capability-deck/cards/saas-card.ts +183 -0
  59. package/src/capability-deck/cards/task-card.ts +207 -0
  60. package/src/capability-deck/cards/todo-card.ts +168 -0
  61. package/src/capability-deck/cards/workflow-card.ts +247 -0
  62. package/src/capability-deck/contract.ts +388 -0
  63. package/src/capability-deck/index.ts +48 -0
  64. package/src/capability-deck/manifest.ts +109 -0
  65. package/src/capability-deck/provision.ts +169 -0
  66. package/src/channels/contract.ts +191 -0
  67. package/src/channels/framer.ts +50 -0
  68. package/src/channels/index.ts +101 -0
  69. package/src/channels/link/dialog.ts +129 -0
  70. package/src/channels/link/driver.ts +190 -0
  71. package/src/channels/link/index.ts +34 -0
  72. package/src/channels/link/server.ts +134 -0
  73. package/src/channels/oneshot.ts +90 -0
  74. package/src/channels/ops.ts +65 -0
  75. package/src/channels/session-ops.ts +81 -0
  76. package/src/conductor/bash-guard.ts +599 -0
  77. package/src/conductor/catalog/catalog.ts +116 -0
  78. package/src/conductor/catalog/index.ts +8 -0
  79. package/src/conductor/catalog/matcher.ts +134 -0
  80. package/src/conductor/conductor.ts +234 -0
  81. package/src/conductor/contract.ts +842 -0
  82. package/src/conductor/diagnostics.ts +227 -0
  83. package/src/conductor/index.ts +33 -0
  84. package/src/conductor/permissions.ts +588 -0
  85. package/src/conductor/quota-error.ts +49 -0
  86. package/src/conductor/signal-hub/hub.ts +46 -0
  87. package/src/conductor/signal-hub/index.ts +2 -0
  88. package/src/conductor/signal-hub/translate.test.ts +81 -0
  89. package/src/conductor/signal-hub/translate.ts +74 -0
  90. package/src/conductor/skill-parse/index.ts +2 -0
  91. package/src/conductor/skill-parse/parse.ts +108 -0
  92. package/src/conductor/transcript-store/index.ts +22 -0
  93. package/src/conductor/transcript-store/serialize.ts +116 -0
  94. package/src/conductor/transcript-store/store.ts +205 -0
  95. package/src/console/auth-status.ts +56 -0
  96. package/src/console/components/AgentsView.ts +165 -0
  97. package/src/console/components/BackgroundAgents.ts +155 -0
  98. package/src/console/components/Banner.ts +334 -0
  99. package/src/console/components/Composer.ts +94 -0
  100. package/src/console/components/StatusBar.ts +49 -0
  101. package/src/console/components/TerminalConsole.ts +1090 -0
  102. package/src/console/components/WorkingIndicator.ts +98 -0
  103. package/src/console/components/banner-sweep.ts +24 -0
  104. package/src/console/components/welcome.ts +74 -0
  105. package/src/console/contract.ts +630 -0
  106. package/src/console/index.ts +34 -0
  107. package/src/console/input/complete.ts +127 -0
  108. package/src/console/input/dir-reader.ts +34 -0
  109. package/src/console/input/index.ts +23 -0
  110. package/src/console/input/keymap.ts +159 -0
  111. package/src/console/input/paste.ts +104 -0
  112. package/src/console/mount.ts +56 -0
  113. package/src/console/overlays/approval-queue.ts +57 -0
  114. package/src/console/overlays/approval.ts +130 -0
  115. package/src/console/overlays/auth.ts +342 -0
  116. package/src/console/overlays/boards.ts +308 -0
  117. package/src/console/overlays/host.ts +36 -0
  118. package/src/console/overlays/index.ts +26 -0
  119. package/src/console/overlays/pickers.ts +258 -0
  120. package/src/console/overlays/sessions.ts +190 -0
  121. package/src/console/reducer.ts +182 -0
  122. package/src/console/slash/builtins.ts +81 -0
  123. package/src/console/slash/commands/dynamic.ts +83 -0
  124. package/src/console/slash/commands/integrations.ts +695 -0
  125. package/src/console/slash/commands/shared.ts +75 -0
  126. package/src/console/slash/commands/transcript.ts +263 -0
  127. package/src/console/slash/commands/workbench.ts +246 -0
  128. package/src/console/slash/index.ts +15 -0
  129. package/src/console/slash/registry.ts +70 -0
  130. package/src/console/slash/resolve.ts +63 -0
  131. package/src/console/startup.ts +209 -0
  132. package/src/console/theme/adapter.ts +45 -0
  133. package/src/console/theme/index.ts +7 -0
  134. package/src/console/theme/palette.ts +68 -0
  135. package/src/console/theme/resolve.ts +39 -0
  136. package/src/console/theme/tokens.ts +71 -0
  137. package/src/entry.ts +55 -0
  138. package/src/guardrails.ts +37 -0
  139. package/src/index.ts +18 -0
  140. package/src/insight/channel.ts +88 -0
  141. package/src/insight/contract.ts +185 -0
  142. package/src/insight/index.ts +110 -0
  143. package/src/insight/recorder.ts +213 -0
  144. package/src/insight/redaction.ts +157 -0
  145. package/src/insight/replay.ts +158 -0
  146. package/src/insight/sampling.ts +70 -0
  147. package/src/insight/serialize.ts +50 -0
  148. package/src/insight/sinks/console.ts +64 -0
  149. package/src/insight/sinks/file.ts +40 -0
  150. package/src/insight/sinks/index.ts +24 -0
  151. package/src/insight/sinks/stream.ts +54 -0
  152. package/src/integrations/sarvam/attach.ts +239 -0
  153. package/src/integrations/sarvam/config.ts +156 -0
  154. package/src/integrations/sarvam/index.ts +25 -0
  155. package/src/integrations/sarvam/sarvam.test.ts +60 -0
  156. package/src/integrations/sarvam/types.ts +27 -0
  157. package/src/integrations/zoho/attach.ts +342 -0
  158. package/src/integrations/zoho/config.ts +125 -0
  159. package/src/integrations/zoho/index.ts +27 -0
  160. package/src/integrations/zoho/types.ts +21 -0
  161. package/src/integrations/zoho/zoho.test.ts +50 -0
  162. package/src/kit/clipboard-image.ts +107 -0
  163. package/src/kit/external-editor.ts +48 -0
  164. package/src/kit/image.ts +59 -0
  165. package/src/kit/index.ts +51 -0
  166. package/src/kit/shell.ts +19 -0
  167. package/src/kit/tool-fetch.ts +85 -0
  168. package/src/launch/catalog.ts +148 -0
  169. package/src/launch/contract.ts +187 -0
  170. package/src/launch/credentials.ts +625 -0
  171. package/src/launch/index.ts +98 -0
  172. package/src/launch/invocation/attachments.ts +179 -0
  173. package/src/launch/invocation/flags.ts +196 -0
  174. package/src/launch/invocation/index.ts +25 -0
  175. package/src/launch/invocation/read.ts +260 -0
  176. package/src/launch/invocation/usage.ts +67 -0
  177. package/src/launch/login.ts +324 -0
  178. package/src/launch/oauth.test.ts +18 -0
  179. package/src/launch/oauth.ts +203 -0
  180. package/src/launch/packages.ts +194 -0
  181. package/src/launch/pickers.ts +189 -0
  182. package/src/runtime-bridge/bridges/_drive.ts +96 -0
  183. package/src/runtime-bridge/bridges/builtins.ts +68 -0
  184. package/src/runtime-bridge/bridges/claude-cli.ts +123 -0
  185. package/src/runtime-bridge/bridges/codex-cli.ts +142 -0
  186. package/src/runtime-bridge/bridges/index.ts +33 -0
  187. package/src/runtime-bridge/bridges/indusagi-cli.ts +155 -0
  188. package/src/runtime-bridge/broker.ts +227 -0
  189. package/src/runtime-bridge/contract.ts +122 -0
  190. package/src/runtime-bridge/index.ts +79 -0
  191. package/src/runtime-bridge/sink.ts +180 -0
  192. package/src/sessions/contract.ts +81 -0
  193. package/src/sessions/index.ts +13 -0
  194. package/src/sessions/library.ts +229 -0
  195. package/src/settings/contract.ts +114 -0
  196. package/src/settings/index.ts +32 -0
  197. package/src/settings/manager.ts +117 -0
  198. package/src/transcript-export/index.ts +45 -0
  199. package/src/transcript-export/publish.ts +260 -0
  200. package/src/transcript-export/sgr.ts +315 -0
  201. package/src/transcript-export/template.ts +272 -0
  202. package/src/transcript-export/theme-bridge.ts +150 -0
  203. package/src/window-budget/budget/estimate.ts +135 -0
  204. package/src/window-budget/budget/gate.ts +33 -0
  205. package/src/window-budget/budget/index.ts +16 -0
  206. package/src/window-budget/budget/slice.ts +56 -0
  207. package/src/window-budget/condenser.ts +58 -0
  208. package/src/window-budget/contract.ts +184 -0
  209. package/src/window-budget/index.ts +19 -0
  210. package/src/window-budget/microcompact.ts +95 -0
  211. package/src/window-budget/rehydrate.ts +136 -0
  212. package/src/window-budget/summarize/condense.ts +103 -0
  213. package/src/window-budget/summarize/index.ts +14 -0
  214. package/src/window-budget/summarize/prompt.ts +149 -0
  215. package/src/workflow-engine/agent-runner.ts +181 -0
  216. package/src/workflow-engine/display.ts +224 -0
  217. package/src/workflow-engine/engine.ts +294 -0
  218. package/src/workflow-engine/index.ts +23 -0
  219. package/src/workflow-engine/parse.ts +172 -0
  220. package/src/workflow-engine/structured-output.ts +35 -0
  221. package/src/workspace/brand.ts +29 -0
  222. package/src/workspace/index.ts +18 -0
  223. package/src/workspace/locator.ts +103 -0
  224. package/src/workspace/runtime-detect.ts +64 -0
@@ -0,0 +1,588 @@
1
+ /**
2
+ * Permission rule engine + the `canUseTool` gate seam.
3
+ *
4
+ * This module is the product-side brain of the permission stack. It is split in
5
+ * two layers:
6
+ *
7
+ * 1. **A pure rule engine** — {@link resolveRuleDecision} maps a single tool
8
+ * call `(toolName, input, mode, rules)` to a {@link PermissionBehavior}
9
+ * (`allow` / `ask` / `deny`). It is total, synchronous, and side-effect free
10
+ * so the precedence (deny > ask > mode auto-allow > allow) is exhaustively
11
+ * unit-testable. Rule strings are induscode-style: a bare tool name
12
+ * (`"Bash"`) or a tool name with an argument specifier
13
+ * (`"Bash(npm run test:*)"`).
14
+ *
15
+ * 2. **A gate factory** — {@link createPermissionGate} turns that engine into a
16
+ * framework {@link CanUseToolFn}: the hard async hook the framework agent
17
+ * awaits immediately before every tool runs. An `ask` decision is routed to
18
+ * an OPTIONAL host-supplied approval resolver; when the resolver is absent
19
+ * (a non-interactive boot) `ask` denies with a clear message. An
20
+ * `allow-always` approval appends a session-scoped allow rule so later
21
+ * identical calls auto-allow.
22
+ *
23
+ * Backwards-compatibility: the framework `canUseTool` hook is itself optional, so
24
+ * a session that never builds a gate keeps today's allow-all behavior. A gate
25
+ * built from an empty rule set in `default` mode allows read-only tools and asks
26
+ * for everything else — and, with no resolver, the `ask` denials are explicit
27
+ * rather than silent.
28
+ */
29
+
30
+ import type { PermissionMode } from "../settings.js"
31
+ import {
32
+ bashSubcommandSubjects,
33
+ evaluateCatastrophic,
34
+ } from "./bash-guard.js"
35
+
36
+ /** Re-exported so consumers of the engine get the mode vocabulary in one import. */
37
+ export type { PermissionMode }
38
+
39
+ /** The verdict the rule engine renders for a single tool call. */
40
+ export type PermissionBehavior = "allow" | "ask" | "deny"
41
+
42
+ /**
43
+ * One parsed permission rule: a behavior plus the tool it targets, with an
44
+ * optional argument specifier (`ruleContent`) that further narrows the match.
45
+ */
46
+ export interface PermissionRule {
47
+ /** What happens when this rule matches a tool call. */
48
+ readonly ruleBehavior: PermissionBehavior
49
+ /** The tool (and optional argument specifier) this rule selects. */
50
+ readonly ruleValue: {
51
+ /** The canonical tool name, e.g. `"Bash"` / `"bash"` (matched case-insensitively). */
52
+ readonly toolName: string
53
+ /** An optional argument specifier, e.g. `"npm run test:*"`. Absent = bare match. */
54
+ readonly ruleContent?: string
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The framework's tool-permission verdict. Mirrors `indusagi/agent`'s
60
+ * `PermissionDecision`; re-declared here so the product engine never imports the
61
+ * framework just for a structural type (the gate is assignable to the
62
+ * framework's `CanUseToolFn` because the shapes coincide).
63
+ */
64
+ export type PermissionDecision =
65
+ | {
66
+ behavior: "allow"
67
+ updatedInput?: unknown
68
+ }
69
+ | {
70
+ behavior: "deny"
71
+ message: string
72
+ }
73
+
74
+ /**
75
+ * The hard permission gate the framework agent awaits before each tool executes.
76
+ * Structurally identical to `indusagi/agent`'s `CanUseToolFn`, so a value of this
77
+ * type is accepted by `new Agent({ canUseTool })` with no cast.
78
+ */
79
+ export type CanUseToolFn = (
80
+ toolName: string,
81
+ input: unknown,
82
+ opts: { signal?: AbortSignal },
83
+ ) => Promise<PermissionDecision>
84
+
85
+ /**
86
+ * The outcome of the host approval prompt raised for an `ask` decision:
87
+ * - `allow-once` — run this single call, do not remember.
88
+ * - `allow-always` — run it and append a session allow rule for the tool.
89
+ * - `deny` — block this call.
90
+ */
91
+ export type ApprovalChoice = "allow-once" | "allow-always" | "deny"
92
+
93
+ /**
94
+ * What an {@link ApprovalResolver} may resolve to: a user's {@link ApprovalChoice},
95
+ * or `"unavailable"` — the conductor's stable delegate answers that when NO
96
+ * interactive resolver is installed behind it (a headless boot, or the console not
97
+ * yet mounted). The gate maps `"unavailable"` to the actionable "requires
98
+ * approval" deny (pointing at `--permission-mode` / settings allow rules) rather
99
+ * than the "denied by the user" message, which would be false when no user ever
100
+ * saw a prompt.
101
+ */
102
+ export type ApprovalOutcome = ApprovalChoice | "unavailable"
103
+
104
+ /**
105
+ * The OPTIONAL host approval resolver. When present, an `ask` decision awaits it;
106
+ * the host (an interactive overlay) returns the user's choice. It MUST resolve to
107
+ * `"deny"` on abort so a cancelled turn never hangs on a pending prompt. When the
108
+ * resolver is absent (non-interactive boot / oneshot / link), an `ask` decision
109
+ * deterministically denies. A stable pass-through delegate (the conductor's)
110
+ * resolves `"unavailable"` while no real resolver is installed behind it, so the
111
+ * gate can surface the actionable non-interactive deny message instead of a
112
+ * fictitious user denial.
113
+ */
114
+ export type ApprovalResolver = (
115
+ toolName: string,
116
+ input: unknown,
117
+ opts: { signal?: AbortSignal },
118
+ ) => Promise<ApprovalOutcome>
119
+
120
+ /**
121
+ * Tool names known to only inspect state (never mutate). Mirrors the framework's
122
+ * `READ_ONLY_TOOL_NAMES`; matched case-insensitively. A tool the framework marks
123
+ * `readOnly: true` is also auto-allowed via {@link createPermissionGate}'s
124
+ * `readOnlyToolNames` option, so this set is the static fallback for callers that
125
+ * only know names.
126
+ */
127
+ export const READ_ONLY_TOOL_NAMES: ReadonlySet<string> = new Set([
128
+ "read",
129
+ "ls",
130
+ "grep",
131
+ "find",
132
+ "glob",
133
+ "websearch",
134
+ "webfetch",
135
+ "todoread",
136
+ ])
137
+
138
+ /**
139
+ * Tool names that mutate the workspace by editing or writing files. Auto-allowed
140
+ * under `acceptEdits`, and (together with every other non-read-only tool) denied
141
+ * under `plan`.
142
+ */
143
+ export const EDIT_TOOL_NAMES: ReadonlySet<string> = new Set([
144
+ "edit",
145
+ "write",
146
+ "multiedit",
147
+ ])
148
+
149
+ function canon(toolName: string): string {
150
+ return toolName.toLowerCase()
151
+ }
152
+
153
+ /** Whether `toolName` is a statically-known read-only tool. */
154
+ export function isReadOnlyToolName(
155
+ toolName: string,
156
+ extra?: ReadonlySet<string>,
157
+ ): boolean {
158
+ const name = canon(toolName)
159
+ if (READ_ONLY_TOOL_NAMES.has(name)) return true
160
+ if (extra) {
161
+ for (const candidate of extra) {
162
+ if (canon(candidate) === name) return true
163
+ }
164
+ }
165
+ return false
166
+ }
167
+
168
+ /** Whether `toolName` is an edit/write tool (auto-allowed under `acceptEdits`). */
169
+ export function isEditToolName(toolName: string): boolean {
170
+ return EDIT_TOOL_NAMES.has(canon(toolName))
171
+ }
172
+
173
+ function normalizeMode(mode: PermissionMode): PermissionMode {
174
+ return mode === "bypassPermissions" ? "bypass" : mode
175
+ }
176
+
177
+ /**
178
+ * Parse an induscode-style rule string into its tool name and optional argument
179
+ * specifier.
180
+ *
181
+ * - `"Bash"` → `{ toolName: "Bash" }`
182
+ * - `"Bash(npm run test:*)"` → `{ toolName: "Bash", ruleContent: "npm run test:*" }`
183
+ * - `"mcp__server"` → `{ toolName: "mcp__server" }`
184
+ *
185
+ * Whitespace around the tool name is trimmed; an empty argument specifier
186
+ * (`"Bash()"`) is treated as a bare rule.
187
+ */
188
+ export function parseRule(raw: string): {
189
+ toolName: string
190
+ ruleContent?: string
191
+ } {
192
+ const trimmed = raw.trim()
193
+ const open = trimmed.indexOf("(")
194
+ if (open === -1 || !trimmed.endsWith(")")) {
195
+ return { toolName: trimmed }
196
+ }
197
+ const toolName = trimmed.slice(0, open).trim()
198
+ const ruleContent = trimmed.slice(open + 1, trimmed.length - 1).trim()
199
+ return ruleContent.length > 0 ? { toolName, ruleContent } : { toolName }
200
+ }
201
+
202
+ /**
203
+ * Build a {@link PermissionRule} from a behavior and an induscode-style rule
204
+ * string. The single place a raw settings list entry becomes a typed rule.
205
+ */
206
+ export function makeRule(
207
+ ruleBehavior: PermissionBehavior,
208
+ raw: string,
209
+ ): PermissionRule {
210
+ return { ruleBehavior, ruleValue: parseRule(raw) }
211
+ }
212
+
213
+ function ruleSubject(toolName: string, input: unknown): string {
214
+ if (input === null || typeof input !== "object") {
215
+ return typeof input === "string" ? input : ""
216
+ }
217
+ const bag = input as Record<string, unknown>
218
+ const name = canon(toolName)
219
+ if ((name === "bash" || name === "shell") && typeof bag.command === "string") {
220
+ return bag.command
221
+ }
222
+ if (typeof bag.command === "string") return bag.command
223
+ try {
224
+ return JSON.stringify(bag)
225
+ } catch {
226
+ return ""
227
+ }
228
+ }
229
+
230
+ function specifierMatches(specifier: string, subject: string): boolean {
231
+ if (specifier.endsWith(":*")) {
232
+ return subject.startsWith(specifier.slice(0, -2))
233
+ }
234
+ if (specifier.endsWith("*") && !specifier.slice(0, -1).includes("*")) {
235
+ return subject.startsWith(specifier.slice(0, -1))
236
+ }
237
+ if (specifier.includes("*")) {
238
+ const escaped = specifier
239
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
240
+ .replace(/\*/g, ".*")
241
+ return new RegExp(`^${escaped}$`).test(subject)
242
+ }
243
+ return specifier === subject
244
+ }
245
+
246
+ function isBashTool(toolName: string): boolean {
247
+ const name = canon(toolName)
248
+ return name === "bash" || name === "shell"
249
+ }
250
+
251
+ function bashSubjects(toolName: string, input: unknown): string[] {
252
+ const command = ruleSubject(toolName, input)
253
+ if (command.length === 0) return []
254
+ return bashSubcommandSubjects(command)
255
+ }
256
+
257
+ /**
258
+ * Whether a tool call matches a rule (the ANY-match semantics used for deny/ask).
259
+ *
260
+ * The tool name must match (case-insensitive), with an `mcp__server` rule also
261
+ * matching every `mcp__server__tool` under it (the induscode MCP-wildcard
262
+ * convention). A bare rule (no specifier) matches any arguments.
263
+ *
264
+ * When the rule carries an argument specifier:
265
+ * - for the **shell** tool the command is split into its constituent
266
+ * sub-commands and the specifier is tested against EACH — a match on ANY
267
+ * sub-command counts (so `Bash(rm:*)` denies `git status && rm x`);
268
+ * - for any other tool the specifier is tested against the single rendered
269
+ * subject (the command field, or a JSON encoding).
270
+ *
271
+ * The any-match semantics are correct for `deny` and `ask` (a single offending
272
+ * sub-command should trigger them). The `allow` branch needs ALL sub-commands
273
+ * covered instead — see {@link bashAllowCoversAll}.
274
+ */
275
+ export function toolMatchesRule(
276
+ toolName: string,
277
+ input: unknown,
278
+ rule: PermissionRule,
279
+ ): boolean {
280
+ const ruleName = canon(rule.ruleValue.toolName)
281
+ const callName = canon(toolName)
282
+ const nameMatches =
283
+ ruleName === callName ||
284
+ (ruleName.startsWith("mcp__") && callName.startsWith(`${ruleName}__`))
285
+ if (!nameMatches) return false
286
+ const specifier = rule.ruleValue.ruleContent
287
+ if (specifier === void 0) return true
288
+ if (isBashTool(toolName)) {
289
+ const subjects = bashSubjects(toolName, input)
290
+ if (subjects.length === 0)
291
+ return specifierMatches(specifier, ruleSubject(toolName, input))
292
+ return subjects.some((subject) => specifierMatches(specifier, subject))
293
+ }
294
+ return specifierMatches(specifier, ruleSubject(toolName, input))
295
+ }
296
+
297
+ function bashAllowSetCoversAll(
298
+ toolName: string,
299
+ input: unknown,
300
+ allowRules: readonly PermissionRule[],
301
+ ): boolean {
302
+ const callName = canon(toolName)
303
+ const subjects = bashSubjects(toolName, input)
304
+ if (subjects.length === 0) return false
305
+ return subjects.every((subject) =>
306
+ allowRules.some((rule) => {
307
+ if (canon(rule.ruleValue.toolName) !== callName) return false
308
+ const specifier = rule.ruleValue.ruleContent
309
+ if (specifier === void 0) return true
310
+ return specifierMatches(specifier, subject)
311
+ }),
312
+ )
313
+ }
314
+
315
+ /**
316
+ * Resolve the behavior for a single tool call against the rule set and mode.
317
+ *
318
+ * Precedence, highest first:
319
+ * 1. **deny rules** — any matching `deny` rule blocks, regardless of mode. Deny
320
+ * wins across every tier (the caller concatenates the tiers before passing
321
+ * the list in, and this scans all deny rules first).
322
+ * 2. **plan mode** — denies any mutating (non-read-only) tool outright.
323
+ * 3. **ask rules** — a matching `ask` rule forces a prompt (unless a deny
324
+ * already fired). Mode auto-allow does NOT override an explicit ask.
325
+ * 4. **bypass mode** — allows everything not already denied/asked.
326
+ * 5. **read-only auto-allow** — a known read-only tool allows in any mode.
327
+ * 6. **acceptEdits mode** — auto-allows edit/write tools.
328
+ * 7. **allow rules** — a matching `allow` rule (or, for the shell tool, the
329
+ * allow rule SET) allows.
330
+ * 8. **fallthrough** — `ask` (the safe default: prompt, then deny when no
331
+ * resolver is wired).
332
+ *
333
+ * The catastrophic-command blocklist sits ABOVE all of this (step 0): a shell
334
+ * command on the built-in blocklist (`rm -rf /`, fork bombs, `curl | sh`, …) is
335
+ * denied regardless of rules or mode — even `bypass` cannot run it. See
336
+ * {@link evaluateCatastrophic}.
337
+ *
338
+ * Shell handling threads through the per-sub-command matching: a compound
339
+ * command (`a && b; c | d`) is split into its constituent commands and each rule
340
+ * is evaluated against each — a deny/ask on ANY sub-command fires, while
341
+ * auto-allow requires the allow rule set to cover EVERY sub-command.
342
+ *
343
+ * Pure and total: no I/O, no async, deterministic for a given input.
344
+ */
345
+ export function resolveRuleDecision(
346
+ toolName: string,
347
+ input: unknown,
348
+ rules: readonly PermissionRule[],
349
+ mode: PermissionMode,
350
+ readOnlyExtra?: ReadonlySet<string>,
351
+ ): PermissionBehavior {
352
+ const m = normalizeMode(mode)
353
+ if (isBashTool(toolName)) {
354
+ const command = ruleSubject(toolName, input)
355
+ if (command.length > 0 && evaluateCatastrophic(command) !== void 0) {
356
+ return "deny"
357
+ }
358
+ }
359
+ for (const rule of rules) {
360
+ if (rule.ruleBehavior === "deny" && toolMatchesRule(toolName, input, rule)) {
361
+ return "deny"
362
+ }
363
+ }
364
+ const readOnly = isReadOnlyToolName(toolName, readOnlyExtra)
365
+ if (m === "plan" && !readOnly) {
366
+ return "deny"
367
+ }
368
+ for (const rule of rules) {
369
+ if (rule.ruleBehavior === "ask" && toolMatchesRule(toolName, input, rule)) {
370
+ return "ask"
371
+ }
372
+ }
373
+ if (m === "bypass") {
374
+ return "allow"
375
+ }
376
+ if (readOnly) {
377
+ return "allow"
378
+ }
379
+ if (m === "acceptEdits" && isEditToolName(toolName)) {
380
+ return "allow"
381
+ }
382
+ if (isBashTool(toolName)) {
383
+ const allowRules = rules.filter((r) => r.ruleBehavior === "allow")
384
+ if (
385
+ allowRules.length > 0 &&
386
+ bashAllowSetCoversAll(toolName, input, allowRules)
387
+ ) {
388
+ return "allow"
389
+ }
390
+ } else {
391
+ for (const rule of rules) {
392
+ if (rule.ruleBehavior === "allow" && toolMatchesRule(toolName, input, rule)) {
393
+ return "allow"
394
+ }
395
+ }
396
+ }
397
+ return "ask"
398
+ }
399
+
400
+ /**
401
+ * The rule strings an `allow-always` approval mints — i.e. what the session will
402
+ * REMEMBER for this tool call.
403
+ *
404
+ * For the shell tool the remembered rules are scoped to the command: one
405
+ * `Bash(<sub-command>)` rule per constituent sub-command (so approving
406
+ * `git status && npm test` remembers both halves, and re-running either — or the
407
+ * same compound — auto-allows), but approving one command never whitelists the
408
+ * whole shell. Every other tool remembers the bare tool name (`Edit`), matching
409
+ * the prompt's "remember the tool for this session" copy.
410
+ *
411
+ * Exported so the approval overlay can show the user exactly what "Allow always"
412
+ * will remember (the `suggestions` line), and so the gate and the UI can never
413
+ * disagree about it.
414
+ */
415
+ export function allowAlwaysRuleStrings(
416
+ toolName: string,
417
+ input: unknown,
418
+ ): string[] {
419
+ if (isBashTool(toolName)) {
420
+ const bag = input as Record<string, unknown> | null
421
+ const command =
422
+ typeof input === "string"
423
+ ? input
424
+ : bag !== null &&
425
+ typeof bag === "object" &&
426
+ typeof bag.command === "string"
427
+ ? bag.command
428
+ : ""
429
+ if (command.length > 0) {
430
+ return bashSubcommandSubjects(command).map(
431
+ (subject: string) => `${toolName}(${subject})`,
432
+ )
433
+ }
434
+ }
435
+ return [toolName]
436
+ }
437
+
438
+ function noResolverMessage(toolName: string): string {
439
+ return `Tool "${toolName}" requires approval, but no interactive approval prompt is available here (a non-interactive run, or a sub-agent). Denying. Add an allow rule under permissions.allow in settings, or run with --permission-mode acceptEdits/bypass, to permit it.`
440
+ }
441
+
442
+ function declinedMessage(toolName: string): string {
443
+ return `Tool "${toolName}" was denied by the user.`
444
+ }
445
+
446
+ function blockedMessage(toolName: string): string {
447
+ return `Tool "${toolName}" is blocked by the active permission policy.`
448
+ }
449
+
450
+ /** Configuration for {@link createPermissionGate}. */
451
+ export interface PermissionGateConfig {
452
+ /**
453
+ * The ordered rule set, already concatenated across the project/global tiers.
454
+ * Deny rules are scanned first so a deny anywhere in the list wins.
455
+ */
456
+ readonly rules: readonly PermissionRule[]
457
+ /** A live getter for the current permission mode (so a mode switch is honored). */
458
+ readonly mode: () => PermissionMode
459
+ /**
460
+ * The OPTIONAL host approval resolver consulted on an `ask` decision. Absent on
461
+ * a non-interactive boot — then `ask` deterministically denies.
462
+ */
463
+ readonly requestApproval?: ApprovalResolver
464
+ /**
465
+ * Append a session-scoped allow rule when the host returns `allow-always`. The
466
+ * boot layer owns the mutable rule list (the SAME array instance `rules` refers
467
+ * to) and passes a push here, so the appended rule is visible to every
468
+ * subsequent gate consultation within the session — and to every other gate
469
+ * built over the same list (sub-agent gates included). Session-scoped only:
470
+ * the rule is never persisted to settings.
471
+ */
472
+ readonly appendAllowRule?: (rule: PermissionRule) => void
473
+ /**
474
+ * Tool names (beyond {@link READ_ONLY_TOOL_NAMES}) to treat as read-only —
475
+ * derived from the deck's `readOnly: true` tool flags so MCP/custom read-only
476
+ * tools also auto-allow.
477
+ */
478
+ readonly readOnlyToolNames?: ReadonlySet<string>
479
+ }
480
+
481
+ /**
482
+ * Build a framework {@link CanUseToolFn} from the rule engine + the current mode.
483
+ *
484
+ * The returned gate:
485
+ * - resolves the behavior via {@link resolveRuleDecision},
486
+ * - on `allow` proceeds (the framework keeps the validated args),
487
+ * - on `deny` short-circuits with a clear message (the framework turns it into
488
+ * an `isError` tool result so the model sees why),
489
+ * - on `ask` awaits {@link PermissionGateConfig.requestApproval} when present —
490
+ * `allow-once` proceeds, `allow-always` proceeds and appends a session allow
491
+ * rule, `deny` blocks — and DENIES when no resolver is wired (the safe
492
+ * non-interactive default).
493
+ *
494
+ * The result is assignable to `indusagi/agent`'s `CanUseToolFn`.
495
+ */
496
+ export function createPermissionGate(
497
+ config: PermissionGateConfig,
498
+ ): CanUseToolFn {
499
+ return async (toolName, input, opts) => {
500
+ if (isBashTool(toolName)) {
501
+ const command = ruleSubject(toolName, input)
502
+ if (command.length > 0) {
503
+ const reason = evaluateCatastrophic(command)
504
+ if (reason !== void 0) {
505
+ return { behavior: "deny", message: reason }
506
+ }
507
+ }
508
+ }
509
+ const behavior = resolveRuleDecision(
510
+ toolName,
511
+ input,
512
+ config.rules,
513
+ config.mode(),
514
+ config.readOnlyToolNames,
515
+ )
516
+ if (behavior === "allow") {
517
+ return { behavior: "allow" }
518
+ }
519
+ if (behavior === "deny") {
520
+ return { behavior: "deny", message: blockedMessage(toolName) }
521
+ }
522
+ const resolver = config.requestApproval
523
+ if (resolver === void 0) {
524
+ return { behavior: "deny", message: noResolverMessage(toolName) }
525
+ }
526
+ let choice: ApprovalOutcome
527
+ try {
528
+ choice = await resolver(toolName, input, opts)
529
+ } catch {
530
+ choice = "deny"
531
+ }
532
+ if (choice === "allow-once" || choice === "allow-always") {
533
+ if (choice === "allow-always" && config.appendAllowRule) {
534
+ for (const raw of allowAlwaysRuleStrings(toolName, input)) {
535
+ config.appendAllowRule(makeRule("allow", raw))
536
+ }
537
+ }
538
+ return { behavior: "allow" }
539
+ }
540
+ if (choice === "unavailable") {
541
+ return { behavior: "deny", message: noResolverMessage(toolName) }
542
+ }
543
+ return { behavior: "deny", message: declinedMessage(toolName) }
544
+ }
545
+ }
546
+
547
+ /**
548
+ * The policy bundle a parent session hands to a sub-agent runner. The sub-agent
549
+ * gate is constructed from these fields; see {@link createSubagentPermissionGate}.
550
+ */
551
+ export interface SessionPermissionPolicy {
552
+ readonly rules: readonly PermissionRule[]
553
+ readonly mode: () => PermissionMode
554
+ readonly requestApproval?: ApprovalResolver
555
+ }
556
+
557
+ /**
558
+ * Build a sub-agent-scoped permission gate from the parent's policy bundle.
559
+ *
560
+ * The returned {@link CanUseToolFn} delegates to {@link createPermissionGate}
561
+ * using the parent's rules and live mode getter. Tool names not present in
562
+ * `tools` are denied — a sub-agent can only act with the tool surface it was
563
+ * given. This prevents a delegate from invoking capabilities outside its
564
+ * intended scope.
565
+ *
566
+ * @param policy the parent's permission policy (rules + mode getter)
567
+ * @param tools the tools the sub-agent has been granted access to
568
+ */
569
+ export function createSubagentPermissionGate(
570
+ policy: SessionPermissionPolicy,
571
+ tools: readonly { name: string }[],
572
+ ): CanUseToolFn {
573
+ const allowed = new Set<string>(tools.map((t) => t.name))
574
+ const gate = createPermissionGate({
575
+ rules: policy.rules,
576
+ mode: policy.mode,
577
+ ...policy.requestApproval !== undefined
578
+ ? { requestApproval: policy.requestApproval }
579
+ : {},
580
+ readOnlyToolNames: READ_ONLY_TOOL_NAMES,
581
+ })
582
+ return async (toolName, input, opts) => {
583
+ if (!allowed.has(toolName)) {
584
+ return { behavior: "deny", message: `Sub-agent is not allowed to use tool "${toolName}".` }
585
+ }
586
+ return gate(toolName, input, opts)
587
+ }
588
+ }
@@ -0,0 +1,49 @@
1
+ const QUOTA_MARKER = "quota_exceeded";
2
+
3
+ function collectErrorSignals(error: unknown): { statuses: number[]; text: string } {
4
+ const statuses: number[] = [];
5
+ const parts: string[] = [];
6
+ const seen = new Set<object>();
7
+ const visit = (value: unknown, depth: number): void => {
8
+ if (value == null || depth > 6) return;
9
+ if (typeof value === "string") { parts.push(value); return; }
10
+ if (typeof value === "number") { statuses.push(value); return; }
11
+ if (typeof value !== "object" || seen.has(value)) return;
12
+ seen.add(value);
13
+ const object = value as Record<string, unknown>;
14
+ for (const key of ["status", "statusCode", "code"]) {
15
+ const raw = object[key];
16
+ if (typeof raw === "number") statuses.push(raw);
17
+ else if (typeof raw === "string" && /^\d+$/.test(raw)) statuses.push(Number(raw));
18
+ }
19
+ if (typeof object.message === "string") parts.push(object.message);
20
+ visit(object.cause, depth + 1);
21
+ visit(object.error, depth + 1);
22
+ visit(object.response, depth + 1);
23
+ visit(object.body, depth + 1);
24
+ visit(object.data, depth + 1);
25
+ };
26
+ visit(error, 0);
27
+ try { parts.push(JSON.stringify(error)); } catch { /* ignore circular data */ }
28
+ if (error instanceof Error && typeof error.stack === "string") parts.push(error.stack);
29
+ return { statuses, text: parts.join(" \u0001 ").toLowerCase() };
30
+ }
31
+
32
+ export function isQuotaFault(error: unknown): boolean {
33
+ const { statuses, text } = collectErrorSignals(error);
34
+ if (!text.includes(QUOTA_MARKER)) return false;
35
+ return statuses.includes(429) || text.includes(QUOTA_MARKER);
36
+ }
37
+
38
+ function parseUsedLimit(text: string): { used: number; limit: number } | undefined {
39
+ const used = text.match(/"used"\s*:\s*(\d+)/);
40
+ const limit = text.match(/"limit"\s*:\s*(\d+)/);
41
+ return used && limit ? { used: Number(used[1]), limit: Number(limit[1]) } : undefined;
42
+ }
43
+
44
+ export function quotaErrorMessage(error: unknown): string | undefined {
45
+ if (!isQuotaFault(error)) return undefined;
46
+ const numbers = parseUsedLimit(collectErrorSignals(error).text);
47
+ const usage = numbers ? ` (${numbers.used}/${numbers.limit} tokens this month)` : "";
48
+ return `You've hit your indus usage limit${usage}. Add your own API key with \`indus signin <provider>\`, or upgrade your plan.`;
49
+ }
@@ -0,0 +1,46 @@
1
+ import type { SessionSignal, SignalHandler } from "../contract.js";
2
+
3
+ export interface SignalHubOptions {
4
+ readonly onHandlerError?: (error: unknown, signal: SessionSignal) => void;
5
+ }
6
+
7
+ export class SignalHub {
8
+ private readonly handlers = new Set<SignalHandler>();
9
+ private readonly onHandlerError?: SignalHubOptions["onHandlerError"];
10
+
11
+ constructor(options: SignalHubOptions = {}) {
12
+ this.onHandlerError = options.onHandlerError;
13
+ }
14
+
15
+ subscribe(handler: SignalHandler): () => void {
16
+ this.handlers.add(handler);
17
+ let active = true;
18
+ return () => {
19
+ if (!active) return;
20
+ active = false;
21
+ this.handlers.delete(handler);
22
+ };
23
+ }
24
+
25
+ emit(signal: SessionSignal): void {
26
+ for (const handler of [...this.handlers]) {
27
+ try {
28
+ handler(signal);
29
+ } catch (error) {
30
+ this.report(error, signal);
31
+ }
32
+ }
33
+ }
34
+
35
+ get size(): number { return this.handlers.size; }
36
+ clear(): void { this.handlers.clear(); }
37
+
38
+ private report(error: unknown, signal: SessionSignal): void {
39
+ if (!this.onHandlerError) return;
40
+ try {
41
+ this.onHandlerError(error, signal);
42
+ } catch {
43
+ // The error sink is isolated like ordinary subscribers.
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,2 @@
1
+ export { SignalHub, type SignalHubOptions } from "./hub.js";
2
+ export { translateAgentEvent, TRANSLATOR_TABLE } from "./translate.js";