@komarspn/pi-permission-system 16.0.2

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 (203) hide show
  1. package/CHANGELOG.md +2234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +158 -0
  4. package/config/config.example.json +39 -0
  5. package/package.json +82 -0
  6. package/schemas/permissions.schema.json +158 -0
  7. package/src/active-agent.ts +72 -0
  8. package/src/async-cache.ts +21 -0
  9. package/src/bash-arity.ts +210 -0
  10. package/src/builtin-tool-input-formatters.ts +82 -0
  11. package/src/canonicalize-path.ts +30 -0
  12. package/src/common.ts +121 -0
  13. package/src/config-loader.ts +432 -0
  14. package/src/config-modal.ts +259 -0
  15. package/src/config-paths.ts +47 -0
  16. package/src/config-reporter.ts +34 -0
  17. package/src/config-store.ts +222 -0
  18. package/src/decision-audit.ts +75 -0
  19. package/src/decision-reporter.ts +41 -0
  20. package/src/denial-messages.ts +232 -0
  21. package/src/expand-home.ts +28 -0
  22. package/src/extension-config.ts +79 -0
  23. package/src/extension-paths.ts +66 -0
  24. package/src/forwarded-permissions/io.ts +404 -0
  25. package/src/forwarded-permissions/permission-forwarder.ts +580 -0
  26. package/src/forwarding-manager.ts +74 -0
  27. package/src/gate-prompter.ts +12 -0
  28. package/src/handlers/before-agent-start.ts +94 -0
  29. package/src/handlers/gates/bash-command.ts +75 -0
  30. package/src/handlers/gates/bash-external-directory.ts +127 -0
  31. package/src/handlers/gates/bash-path-extractor.ts +15 -0
  32. package/src/handlers/gates/bash-path.ts +152 -0
  33. package/src/handlers/gates/bash-program.ts +1143 -0
  34. package/src/handlers/gates/bash-token-classification.ts +105 -0
  35. package/src/handlers/gates/candidate-check.ts +32 -0
  36. package/src/handlers/gates/descriptor.ts +81 -0
  37. package/src/handlers/gates/external-directory-messages.ts +20 -0
  38. package/src/handlers/gates/external-directory.ts +133 -0
  39. package/src/handlers/gates/helpers.ts +76 -0
  40. package/src/handlers/gates/path.ts +91 -0
  41. package/src/handlers/gates/runner.ts +186 -0
  42. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  43. package/src/handlers/gates/skill-input.ts +46 -0
  44. package/src/handlers/gates/skill-read.ts +87 -0
  45. package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
  46. package/src/handlers/gates/tool.ts +102 -0
  47. package/src/handlers/gates/types.ts +13 -0
  48. package/src/handlers/index.ts +3 -0
  49. package/src/handlers/lifecycle.ts +95 -0
  50. package/src/handlers/permission-gate-handler.ts +190 -0
  51. package/src/handlers/tool-call-boundary.ts +91 -0
  52. package/src/index.ts +225 -0
  53. package/src/input-normalizer.ts +157 -0
  54. package/src/logging.ts +113 -0
  55. package/src/mcp-targets.ts +170 -0
  56. package/src/node-modules-discovery.ts +76 -0
  57. package/src/normalize.ts +43 -0
  58. package/src/path-utils.ts +355 -0
  59. package/src/pattern-suggest.ts +132 -0
  60. package/src/permission-dialog.ts +138 -0
  61. package/src/permission-event-rpc.ts +223 -0
  62. package/src/permission-events.ts +266 -0
  63. package/src/permission-forwarding.ts +188 -0
  64. package/src/permission-gate.ts +94 -0
  65. package/src/permission-manager.ts +392 -0
  66. package/src/permission-merge.ts +32 -0
  67. package/src/permission-prompter.ts +142 -0
  68. package/src/permission-prompts.ts +93 -0
  69. package/src/permission-resolver.ts +109 -0
  70. package/src/permission-session.ts +189 -0
  71. package/src/permission-ui-prompt.ts +127 -0
  72. package/src/permissions-service.ts +63 -0
  73. package/src/persistent-approval-recorder.ts +139 -0
  74. package/src/policy-loader.ts +350 -0
  75. package/src/prompting-gateway.ts +104 -0
  76. package/src/rule.ts +188 -0
  77. package/src/scope-merge.ts +72 -0
  78. package/src/service-lifecycle.ts +49 -0
  79. package/src/service.ts +163 -0
  80. package/src/session-approval-recorder.ts +6 -0
  81. package/src/session-approval.ts +43 -0
  82. package/src/session-logger.ts +91 -0
  83. package/src/session-rules.ts +79 -0
  84. package/src/skill-prompt-sanitizer.ts +292 -0
  85. package/src/status.ts +35 -0
  86. package/src/subagent-context.ts +104 -0
  87. package/src/subagent-lifecycle-events.ts +72 -0
  88. package/src/subagent-registry.ts +105 -0
  89. package/src/synthesize.ts +92 -0
  90. package/src/system-prompt-sanitizer.ts +274 -0
  91. package/src/tool-access-extractor-registry.ts +68 -0
  92. package/src/tool-input-formatter-registry.ts +67 -0
  93. package/src/tool-input-preview.ts +34 -0
  94. package/src/tool-input-prompt-formatters.ts +63 -0
  95. package/src/tool-preview-formatter.ts +207 -0
  96. package/src/tool-registry.ts +148 -0
  97. package/src/types.ts +64 -0
  98. package/src/wildcard-matcher.ts +120 -0
  99. package/src/yolo-mode.ts +30 -0
  100. package/test/active-agent.test.ts +155 -0
  101. package/test/async-cache.test.ts +48 -0
  102. package/test/bash-arity.test.ts +144 -0
  103. package/test/bash-external-directory.test.ts +956 -0
  104. package/test/builtin-tool-input-formatters.test.ts +109 -0
  105. package/test/canonicalize-path.test.ts +93 -0
  106. package/test/common.test.ts +287 -0
  107. package/test/composition-root.test.ts +603 -0
  108. package/test/config-loader.test.ts +740 -0
  109. package/test/config-modal.test.ts +320 -0
  110. package/test/config-paths.test.ts +83 -0
  111. package/test/config-pipeline.test.ts +90 -0
  112. package/test/config-reporter.test.ts +147 -0
  113. package/test/config-store.test.ts +466 -0
  114. package/test/decision-audit.test.ts +72 -0
  115. package/test/decision-reporter.test.ts +112 -0
  116. package/test/denial-messages.test.ts +656 -0
  117. package/test/detect-permissive-bash-fallback.test.ts +56 -0
  118. package/test/expand-home.test.ts +93 -0
  119. package/test/extension-config.test.ts +129 -0
  120. package/test/extension-paths.test.ts +108 -0
  121. package/test/forwarded-permissions/io.test.ts +251 -0
  122. package/test/forwarding-manager.test.ts +194 -0
  123. package/test/handlers/before-agent-start.test.ts +317 -0
  124. package/test/handlers/external-directory-integration.test.ts +623 -0
  125. package/test/handlers/external-directory-session-dedup.test.ts +430 -0
  126. package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
  127. package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
  128. package/test/handlers/gates/bash-command.test.ts +191 -0
  129. package/test/handlers/gates/bash-external-directory.test.ts +269 -0
  130. package/test/handlers/gates/bash-path.test.ts +337 -0
  131. package/test/handlers/gates/bash-program.test.ts +410 -0
  132. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  133. package/test/handlers/gates/candidate-check.test.ts +52 -0
  134. package/test/handlers/gates/external-directory-messages.test.ts +61 -0
  135. package/test/handlers/gates/external-directory.test.ts +259 -0
  136. package/test/handlers/gates/helpers.test.ts +177 -0
  137. package/test/handlers/gates/path.test.ts +294 -0
  138. package/test/handlers/gates/runner.test.ts +447 -0
  139. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  140. package/test/handlers/gates/skill-input.test.ts +131 -0
  141. package/test/handlers/gates/skill-read.test.ts +158 -0
  142. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
  143. package/test/handlers/gates/tool.test.ts +223 -0
  144. package/test/handlers/input-events.test.ts +168 -0
  145. package/test/handlers/input.test.ts +199 -0
  146. package/test/handlers/lifecycle.test.ts +221 -0
  147. package/test/handlers/tool-call-boundary.test.ts +145 -0
  148. package/test/handlers/tool-call-events.test.ts +277 -0
  149. package/test/handlers/tool-call.test.ts +395 -0
  150. package/test/handlers/validate-requested-tool.test.ts +92 -0
  151. package/test/helpers/gate-fixtures.ts +323 -0
  152. package/test/helpers/handler-fixtures.ts +335 -0
  153. package/test/helpers/make-fake-pi.ts +100 -0
  154. package/test/helpers/manager-harness.ts +112 -0
  155. package/test/helpers/session-fixtures.ts +204 -0
  156. package/test/input-normalizer.test.ts +367 -0
  157. package/test/logging.test.ts +51 -0
  158. package/test/mcp-targets.test.ts +233 -0
  159. package/test/node-modules-discovery.test.ts +97 -0
  160. package/test/normalize.test.ts +247 -0
  161. package/test/path-utils.test.ts +650 -0
  162. package/test/pattern-suggest.test.ts +248 -0
  163. package/test/permission-dialog.test.ts +241 -0
  164. package/test/permission-event-rpc.test.ts +541 -0
  165. package/test/permission-events.test.ts +402 -0
  166. package/test/permission-forwarder.test.ts +369 -0
  167. package/test/permission-forwarding.test.ts +315 -0
  168. package/test/permission-gate.test.ts +305 -0
  169. package/test/permission-manager-unified.test.ts +3368 -0
  170. package/test/permission-merge.test.ts +61 -0
  171. package/test/permission-prompter.test.ts +518 -0
  172. package/test/permission-prompts.test.ts +363 -0
  173. package/test/permission-resolver.test.ts +265 -0
  174. package/test/permission-session.test.ts +363 -0
  175. package/test/permission-ui-prompt.test.ts +146 -0
  176. package/test/permissions-service.test.ts +177 -0
  177. package/test/persistent-approval-recorder.test.ts +133 -0
  178. package/test/pi-infrastructure-read.test.ts +369 -0
  179. package/test/policy-loader.test.ts +561 -0
  180. package/test/prompting-gateway.test.ts +230 -0
  181. package/test/rule.test.ts +604 -0
  182. package/test/scope-merge.test.ts +116 -0
  183. package/test/service-lifecycle.test.ts +163 -0
  184. package/test/service.test.ts +308 -0
  185. package/test/session-approval.test.ts +75 -0
  186. package/test/session-logger.test.ts +200 -0
  187. package/test/session-rules.test.ts +304 -0
  188. package/test/session-start.test.ts +112 -0
  189. package/test/skill-prompt-sanitizer.test.ts +374 -0
  190. package/test/status.test.ts +10 -0
  191. package/test/subagent-context.test.ts +326 -0
  192. package/test/subagent-lifecycle-events.test.ts +132 -0
  193. package/test/subagent-registry.test.ts +145 -0
  194. package/test/synthesize.test.ts +300 -0
  195. package/test/system-prompt-sanitizer.test.ts +382 -0
  196. package/test/tool-access-extractor-registry.test.ts +77 -0
  197. package/test/tool-input-formatter-registry.test.ts +75 -0
  198. package/test/tool-input-preview.test.ts +129 -0
  199. package/test/tool-input-prompt-formatters.test.ts +115 -0
  200. package/test/tool-preview-formatter.test.ts +458 -0
  201. package/test/tool-registry.test.ts +197 -0
  202. package/test/wildcard-matcher.test.ts +424 -0
  203. package/test/yolo-mode.test.ts +188 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Curated arity dictionary for common CLI commands.
3
+ *
4
+ * Keys are lowercase, space-joined command prefixes.
5
+ * Values are the total token count that defines the "human-understandable
6
+ * subcommand" — i.e. how many tokens to include in a session-approval pattern.
7
+ *
8
+ * Multi-level entries (e.g. "npm run": 3) take precedence over shorter entries
9
+ * ("npm": 2) because `prefix()` uses longest-match-wins.
10
+ *
11
+ * Exported for testability.
12
+ */
13
+ export const ARITY: Record<string, number> = {
14
+ // Version control
15
+ git: 2,
16
+ hg: 2,
17
+ svn: 2,
18
+
19
+ // Node.js package managers
20
+ npm: 2,
21
+ "npm run": 3,
22
+ "npm exec": 3,
23
+ npx: 2,
24
+ pnpm: 2,
25
+ "pnpm run": 3,
26
+ "pnpm exec": 3,
27
+ "pnpm dlx": 3,
28
+ yarn: 2,
29
+ "yarn run": 3,
30
+ bun: 2,
31
+ "bun run": 3,
32
+ "bun add": 2,
33
+ "bun x": 3,
34
+
35
+ // Runtimes
36
+ deno: 2,
37
+ "deno run": 3,
38
+ "deno task": 3,
39
+ "deno compile": 3,
40
+
41
+ // Python
42
+ pip: 2,
43
+ pip3: 2,
44
+ uv: 2,
45
+ "uv run": 3,
46
+ "uv pip": 3,
47
+
48
+ // Rust
49
+ cargo: 2,
50
+
51
+ // Go
52
+ go: 2,
53
+ "go run": 3,
54
+
55
+ // Ruby
56
+ bundle: 2,
57
+ "bundle exec": 3,
58
+
59
+ // Docker / container
60
+ docker: 2,
61
+ "docker compose": 3,
62
+ "docker container": 3,
63
+ "docker image": 3,
64
+ "docker network": 3,
65
+ "docker volume": 3,
66
+ podman: 2,
67
+ "podman compose": 3,
68
+
69
+ // Kubernetes
70
+ kubectl: 2,
71
+ helm: 2,
72
+
73
+ // Cloud CLIs
74
+ aws: 3,
75
+ az: 3,
76
+ gcloud: 3,
77
+ gh: 2,
78
+ "gh pr": 3,
79
+ "gh issue": 3,
80
+ "gh repo": 3,
81
+ fly: 2,
82
+ vercel: 2,
83
+ wrangler: 2,
84
+
85
+ // Build tools
86
+ make: 1,
87
+ bazel: 2,
88
+
89
+ // Infrastructure
90
+ terraform: 2,
91
+ tofu: 2,
92
+ pulumi: 2,
93
+
94
+ // System service management
95
+ systemctl: 2,
96
+ service: 2,
97
+
98
+ // Shell file-ops — args are paths/targets, not subcommands
99
+ ls: 1,
100
+ ll: 1,
101
+ la: 1,
102
+ cat: 1,
103
+ less: 1,
104
+ more: 1,
105
+ head: 1,
106
+ tail: 1,
107
+ grep: 1,
108
+ rg: 1,
109
+ ag: 1,
110
+ find: 1,
111
+ touch: 1,
112
+ mkdir: 1,
113
+ rm: 1,
114
+ cp: 1,
115
+ mv: 1,
116
+ ln: 1,
117
+ chmod: 1,
118
+ chown: 1,
119
+ du: 1,
120
+ df: 1,
121
+ echo: 1,
122
+ printf: 1,
123
+ diff: 1,
124
+ patch: 1,
125
+ wc: 1,
126
+ sort: 1,
127
+ uniq: 1,
128
+ awk: 1,
129
+ sed: 1,
130
+ tar: 1,
131
+ zip: 1,
132
+ unzip: 1,
133
+
134
+ // Network
135
+ curl: 1,
136
+ wget: 1,
137
+ ssh: 1,
138
+ scp: 1,
139
+ rsync: 1,
140
+ ping: 1,
141
+
142
+ // Process management
143
+ kill: 1,
144
+ killall: 1,
145
+ pkill: 1,
146
+
147
+ // Package managers (system)
148
+ brew: 2,
149
+ apt: 2,
150
+ "apt-get": 2,
151
+ yum: 2,
152
+ dnf: 2,
153
+ };
154
+
155
+ /**
156
+ * Return the semantically meaningful prefix tokens for a tokenized command.
157
+ *
158
+ * Performs a longest-match-wins lookup against the `ARITY` dictionary:
159
+ * iterates from the longest possible prefix down to a single token, returning
160
+ * the first (longest) match. Lookup is case-insensitive; the returned tokens
161
+ * preserve their original casing.
162
+ *
163
+ * When no entry matches, defaults to arity 1 (first token only).
164
+ * When the resolved arity exceeds the available tokens, it is clamped.
165
+ *
166
+ * @param tokens - The command split by whitespace (e.g. `["git", "checkout", "main"]`).
167
+ * @returns The prefix tokens defining the meaningful subcommand.
168
+ */
169
+ export function prefix(tokens: string[]): string[] {
170
+ if (tokens.length === 0) return [];
171
+
172
+ for (let n = tokens.length; n >= 1; n--) {
173
+ const key = tokens
174
+ .slice(0, n)
175
+ .map((t) => t.toLowerCase())
176
+ .join(" ");
177
+ const arity = ARITY[key];
178
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ARITY record type hides that a key may be absent at runtime
179
+ if (arity !== undefined) {
180
+ return tokens.slice(0, Math.min(arity, tokens.length));
181
+ }
182
+ }
183
+
184
+ // Unknown command — default arity 1.
185
+ return [tokens[0]];
186
+ }
187
+
188
+ /**
189
+ * Remove shell comment lines from a bash command string.
190
+ *
191
+ * A comment line is one whose first non-whitespace character is `#`. Agents
192
+ * frequently prepend descriptive comments before the real command
193
+ * (e.g. `"# Check debug logs\nfind ..."`); such prefixes defeat wildcard
194
+ * pattern matching and session-approval suggestions, which tokenize the
195
+ * leading text. Stripping comment lines lets matching operate on the actual
196
+ * command.
197
+ *
198
+ * The original command is never returned: when every line is a comment (or
199
+ * the input is blank) an empty string is returned, and each caller applies
200
+ * its own fallback.
201
+ *
202
+ * @param command - Raw bash command, possibly multi-line.
203
+ * @returns The command with comment lines removed and surrounding whitespace
204
+ * trimmed, or an empty string when nothing meaningful remains.
205
+ */
206
+ export function stripBashCommentLines(command: string): string {
207
+ const lines = command.split("\n");
208
+ const meaningful = lines.filter((line) => !/^\s*#/.test(line));
209
+ return meaningful.join("\n").trim();
210
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Built-in tool input formatters registered through the public seam at startup.
3
+ *
4
+ * Each formatter here dogfoods `ToolInputFormatterRegistry.register` — it goes
5
+ * through exactly the same path a third-party extension would use.
6
+ */
7
+
8
+ import { toRecord } from "./common";
9
+ import type {
10
+ ToolInputFormatter,
11
+ ToolInputFormatterRegistry,
12
+ } from "./tool-input-formatter-registry";
13
+ import { truncateInlineText } from "./tool-input-preview";
14
+
15
+ /** Maximum total length of the generated argument summary (before "with " prefix). */
16
+ const MCP_ARGS_SUMMARY_MAX_LENGTH = 160;
17
+
18
+ /** Maximum length of a single string argument value (before quoting). */
19
+ const MCP_ARG_VALUE_MAX_LENGTH = 60;
20
+
21
+ /**
22
+ * Render a single MCP argument value as a compact, readable fragment.
23
+ *
24
+ * - Strings: quoted and truncated.
25
+ * - Numbers / booleans: plain string conversion.
26
+ * - Arrays: `[N items]`.
27
+ * - Objects: `{…}`.
28
+ * - Everything else: plain string conversion.
29
+ */
30
+ function renderArgValue(value: unknown): string {
31
+ if (typeof value === "string") {
32
+ return `"${truncateInlineText(value, MCP_ARG_VALUE_MAX_LENGTH)}"`;
33
+ }
34
+ if (typeof value === "number" || typeof value === "boolean") {
35
+ return String(value);
36
+ }
37
+ if (Array.isArray(value)) {
38
+ return `[${value.length} items]`;
39
+ }
40
+ if (typeof value === "object" && value !== null) {
41
+ return "{…}";
42
+ }
43
+ return String(value);
44
+ }
45
+
46
+ /**
47
+ * Format an MCP tool call's `arguments` payload as a human-readable summary.
48
+ *
49
+ * Returns `undefined` when `arguments` is absent or empty — the MCP ask-prompt
50
+ * is then left unchanged (no suffix appended).
51
+ *
52
+ * Intended to be registered as the `"mcp"` formatter via
53
+ * `registerBuiltinToolInputFormatters`.
54
+ */
55
+ export const formatMcpInputForPrompt: ToolInputFormatter = (
56
+ input: Record<string, unknown>,
57
+ ): string | undefined => {
58
+ const args = toRecord(input.arguments);
59
+ const entries = Object.entries(args);
60
+ if (entries.length === 0) return undefined;
61
+
62
+ const parts = entries.map(
63
+ ([key, value]) => `${key}: ${renderArgValue(value)}`,
64
+ );
65
+ const summary = truncateInlineText(
66
+ parts.join(", "),
67
+ MCP_ARGS_SUMMARY_MAX_LENGTH,
68
+ );
69
+ return `with ${summary}`;
70
+ };
71
+
72
+ /**
73
+ * Register all built-in tool input formatters into `registry`.
74
+ *
75
+ * Called once from the extension factory (`index.ts`) immediately after the
76
+ * registry is constructed, before any third-party registration can occur.
77
+ */
78
+ export function registerBuiltinToolInputFormatters(
79
+ registry: ToolInputFormatterRegistry,
80
+ ): void {
81
+ registry.register("mcp", formatMcpInputForPrompt);
82
+ }
@@ -0,0 +1,30 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Resolve symlinks in an absolute path, best-effort.
6
+ *
7
+ * Splits the path into components and tries `realpathSync` from the full path
8
+ * down to `/`, re-appending the non-existent tail to the first ancestor that
9
+ * resolves. Returns the input unchanged when no ancestor resolves (unreachable
10
+ * in practice since `/` always exists) or when a non-ENOENT/ENOTDIR error is
11
+ * encountered (e.g. `EACCES`, `ELOOP`), so callers fall back to lexical
12
+ * containment for paths that cannot be resolved.
13
+ */
14
+ export function canonicalizePath(absolutePath: string): string {
15
+ if (!absolutePath) return absolutePath;
16
+
17
+ const parts = absolutePath.split("/").filter(Boolean);
18
+ for (let i = parts.length; i >= 0; i--) {
19
+ const candidate = "/" + parts.slice(0, i).join("/");
20
+ try {
21
+ const real = realpathSync(candidate);
22
+ const tail = parts.slice(i);
23
+ return tail.length === 0 ? real : join(real, ...tail);
24
+ } catch (error) {
25
+ const code = (error as NodeJS.ErrnoException).code;
26
+ if (code !== "ENOENT" && code !== "ENOTDIR") return absolutePath;
27
+ }
28
+ }
29
+ return absolutePath;
30
+ }
package/src/common.ts ADDED
@@ -0,0 +1,121 @@
1
+ import type { DenyWithReason, PermissionState } from "./types";
2
+
3
+ export function toRecord(value: unknown): Record<string, unknown> {
4
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
5
+ return {};
6
+ }
7
+
8
+ return value as Record<string, unknown>;
9
+ }
10
+
11
+ export function getNonEmptyString(value: unknown): string | null {
12
+ if (typeof value !== "string") {
13
+ return null;
14
+ }
15
+
16
+ const trimmed = value.trim();
17
+ return trimmed.length > 0 ? trimmed : null;
18
+ }
19
+
20
+ /** Returns `raw` if it is an array of strings; otherwise `undefined`. */
21
+ export function normalizeOptionalStringArray(
22
+ raw: unknown,
23
+ ): string[] | undefined {
24
+ return Array.isArray(raw) &&
25
+ raw.every((p): p is string => typeof p === "string")
26
+ ? raw
27
+ : undefined;
28
+ }
29
+
30
+ /** Returns `raw` if it is a positive integer; otherwise `undefined`. */
31
+ export function normalizeOptionalPositiveInt(raw: unknown): number | undefined {
32
+ return typeof raw === "number" && Number.isInteger(raw) && raw > 0
33
+ ? raw
34
+ : undefined;
35
+ }
36
+
37
+ export function isPermissionState(value: unknown): value is PermissionState {
38
+ return value === "allow" || value === "deny" || value === "ask";
39
+ }
40
+
41
+ /**
42
+ * Narrow type guard: a raw value representing a DenyWithReason object.
43
+ * Accepts `{ action: "deny" }` and `{ action: "deny", reason: "…" }`.
44
+ * Rejects a non-string `reason` to keep malformed config out of the rule set.
45
+ */
46
+ export function isDenyWithReason(value: unknown): value is DenyWithReason {
47
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
48
+ return false;
49
+ }
50
+ const record = value as Record<string, unknown>;
51
+ return (
52
+ record.action === "deny" &&
53
+ (record.reason === undefined || typeof record.reason === "string")
54
+ );
55
+ }
56
+
57
+ type StackNode = { indent: number; target: Record<string, unknown> };
58
+
59
+ export function parseSimpleYamlMap(input: string): Record<string, unknown> {
60
+ const root: Record<string, unknown> = {};
61
+ const stack: StackNode[] = [{ indent: -1, target: root }];
62
+
63
+ const lines = input.split(/\r?\n/);
64
+ for (const rawLine of lines) {
65
+ if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
66
+ continue;
67
+ }
68
+
69
+ const indent = rawLine.length - rawLine.trimStart().length;
70
+ const line = rawLine.trim();
71
+ const separatorIndex = line.indexOf(":");
72
+ if (separatorIndex <= 0) {
73
+ continue;
74
+ }
75
+
76
+ const key = line
77
+ .slice(0, separatorIndex)
78
+ .trim()
79
+ .replace(/^['"]|['"]$/g, "");
80
+ const rawValue = line.slice(separatorIndex + 1).trim();
81
+
82
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
83
+ stack.pop();
84
+ }
85
+
86
+ const current = stack[stack.length - 1].target;
87
+
88
+ if (!rawValue) {
89
+ const child: Record<string, unknown> = {};
90
+ current[key] = child;
91
+ stack.push({ indent, target: child });
92
+ continue;
93
+ }
94
+
95
+ let scalar = rawValue;
96
+ if (
97
+ (scalar.startsWith('"') && scalar.endsWith('"')) ||
98
+ (scalar.startsWith("'") && scalar.endsWith("'"))
99
+ ) {
100
+ scalar = scalar.slice(1, -1);
101
+ }
102
+
103
+ current[key] = scalar;
104
+ }
105
+
106
+ return root;
107
+ }
108
+
109
+ export function extractFrontmatter(markdown: string): string {
110
+ const normalized = markdown.replace(/\r\n/g, "\n");
111
+ if (!normalized.startsWith("---\n")) {
112
+ return "";
113
+ }
114
+
115
+ const end = normalized.indexOf("\n---", 4);
116
+ if (end === -1) {
117
+ return "";
118
+ }
119
+
120
+ return normalized.slice(4, end);
121
+ }