@gotgenes/pi-permission-system 16.2.0 → 16.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/access-intent/bash/command-enumeration.ts +79 -2
  5. package/src/access-intent/bash/parser.ts +2 -0
  6. package/src/access-intent/bash/program.ts +3 -0
  7. package/src/builtin-tool-input-formatters.ts +1 -1
  8. package/src/config-loader.ts +7 -7
  9. package/src/forwarded-permissions/permission-forwarder.ts +1 -1
  10. package/src/handlers/gates/bash-command.ts +15 -1
  11. package/src/handlers/gates/bash-external-directory.ts +1 -1
  12. package/src/handlers/gates/bash-path.ts +1 -1
  13. package/src/handlers/gates/skill-read.ts +1 -1
  14. package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -1
  15. package/src/handlers/permission-gate-handler.ts +1 -2
  16. package/src/handlers/tool-call-boundary.ts +1 -2
  17. package/src/input-normalizer.ts +1 -1
  18. package/src/mcp-targets.ts +1 -1
  19. package/src/normalize.ts +1 -1
  20. package/src/path-utils.ts +1 -1
  21. package/src/permission-manager.ts +1 -1
  22. package/src/permission-prompts.ts +1 -1
  23. package/src/policy-loader.ts +2 -2
  24. package/src/tool-input-prompt-formatters.ts +1 -1
  25. package/src/tool-preview-formatter.ts +1 -1
  26. package/src/tool-registry.ts +1 -1
  27. package/src/{common.ts → value-guards.ts} +0 -66
  28. package/src/yaml-frontmatter.ts +65 -0
  29. package/test/access-intent/bash/node-text.test.ts +1 -0
  30. package/test/access-intent/bash/program.test.ts +67 -0
  31. package/test/handlers/external-directory-integration.test.ts +40 -153
  32. package/test/handlers/external-directory-session-dedup.test.ts +38 -262
  33. package/test/handlers/gates/bash-command.test.ts +63 -0
  34. package/test/handlers/gates/bash-external-directory.test.ts +1 -1
  35. package/test/handlers/gates/bash-path.test.ts +1 -1
  36. package/test/helpers/external-directory-fixtures.ts +269 -0
  37. package/test/{common.test.ts → value-guards.test.ts} +2 -96
  38. package/test/yaml-frontmatter.test.ts +91 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [16.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.2.0...pi-permission-system-v16.2.1) (2026-06-27)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** floor opaque bash -c/eval wrappers to ask ([#481](https://github.com/gotgenes/pi-packages/issues/481)) ([e69493c](https://github.com/gotgenes/pi-packages/commit/e69493c00a04fdcdc1a972ef04ffee5839c93d2a))
14
+ * **pi-permission-system:** strip env-var assignment prefix from bash command units ([#481](https://github.com/gotgenes/pi-packages/issues/481)) ([1c99fb3](https://github.com/gotgenes/pi-packages/commit/1c99fb3835442dce3cb01b89f26db0a39084b7af))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** document env-prefix stripping and opaque bash-wrapper floor ([#481](https://github.com/gotgenes/pi-packages/issues/481)) ([40c0012](https://github.com/gotgenes/pi-packages/commit/40c001270249ace6a9d471c92063f9f09aa67238))
20
+ * **pi-permission-system:** note opaque-wrapper sentinel in README and skill ([#481](https://github.com/gotgenes/pi-packages/issues/481)) ([e226fe7](https://github.com/gotgenes/pi-packages/commit/e226fe7a49218fac73f2dcb8c84cb13608d48fa0))
21
+ * **pi-permission-system:** note prefix strip and opaque flag in commands() JSDoc ([#481](https://github.com/gotgenes/pi-packages/issues/481)) ([1892098](https://github.com/gotgenes/pi-packages/commit/18920980e0b17b6dd5e51a75fd103663f9313b32))
22
+
8
23
  ## [16.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.1.0...pi-permission-system-v16.2.0) (2026-06-26)
9
24
 
10
25
 
package/README.md CHANGED
@@ -19,7 +19,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
19
19
  - **Gates MCP and skill access** at server, tool, and skill-name granularity
20
20
  - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once
21
21
  - **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
22
- - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command prompts (`ask`) rather than passing silently
22
+ - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an opaque `bash -c`/`eval` wrapper — prompts (`ask`) rather than passing silently
23
23
  - **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
24
24
  - **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI
25
25
  - **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "16.2.0",
3
+ "version": "16.2.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -18,6 +18,12 @@ export interface BashCommand {
18
18
  * for a current-shell (top-level) command.
19
19
  */
20
20
  readonly context?: BashCommandContext;
21
+ /**
22
+ * True when this is an opaque-payload wrapper (`bash -c`/`eval`) whose inner
23
+ * program is not re-parsed; its decision is floored to at least `ask` so it
24
+ * cannot ride a permissive `allow`.
25
+ */
26
+ readonly opaque?: boolean;
21
27
  }
22
28
 
23
29
  // ── Command enumeration ──────────────────────────────────────────────────────
@@ -75,6 +81,9 @@ const NESTED_EXECUTION_CONTEXTS = new Map<string, BashCommandContext>([
75
81
  *
76
82
  * The enclosing command/subshell is always still emitted whole, so adding the
77
83
  * nested units can only ever produce a more-restrictive decision, never weaker.
84
+ *
85
+ * Each emitted command unit has any leading `variable_assignment` prefix
86
+ * stripped (so an env-var prefix cannot defeat a command-pattern rule).
78
87
  */
79
88
  export function collectCommands(node: TSNode): BashCommand[] {
80
89
  const out: BashCommand[] = [];
@@ -93,7 +102,9 @@ function collectCommandsInto(
93
102
  if (COMMAND_ENUM_SKIP.has(node.type)) return;
94
103
 
95
104
  if (node.type === "command") {
96
- out.push(makeUnit(node.text, context));
105
+ out.push(
106
+ makeUnit(commandUnitText(node), context, isOpaqueWrapperCommand(node)),
107
+ );
97
108
  // A command's text already contains any substitution; descend its subtree
98
109
  // to ALSO emit the inner commands of command/process substitutions.
99
110
  collectSubstitutionCommands(node, out);
@@ -119,8 +130,74 @@ function collectCommandsInto(
119
130
  function makeUnit(
120
131
  text: string,
121
132
  context: BashCommandContext | undefined,
133
+ opaque = false,
122
134
  ): BashCommand {
123
- return context ? { text, context } : { text };
135
+ const unit: BashCommand = context ? { text, context } : { text };
136
+ return opaque ? { ...unit, opaque } : unit;
137
+ }
138
+
139
+ /**
140
+ * Shell command names whose `-c` flag introduces an opaque inline program.
141
+ */
142
+ const SHELL_WRAPPER_NAMES = new Set(["bash", "sh", "dash", "zsh", "ksh"]);
143
+
144
+ /**
145
+ * True when a `command` node is an opaque-payload wrapper: a shell
146
+ * (`bash`/`sh`/`dash`/`zsh`/`ksh`) invoked with a `-c` flag, or `eval`. Its
147
+ * inner program is a quoted argument the enumerator does not re-parse, so the
148
+ * wrapper's decision is later floored to at least `ask`.
149
+ *
150
+ * The command name is matched on its basename (so `/bin/bash -c …` counts), and
151
+ * a `-c` flag is recognized within a short-flag cluster (`-c`, `-ec`, `-xc`).
152
+ * A leading `variable_assignment` prefix is skipped, matching `commandUnitText`.
153
+ */
154
+ function isOpaqueWrapperCommand(node: TSNode): boolean {
155
+ let commandName: string | undefined;
156
+ let sawShortFlagC = false;
157
+ for (let i = 0; i < node.childCount; i++) {
158
+ const child = node.child(i);
159
+ if (!child?.isNamed) continue;
160
+ if (child.type === "variable_assignment") continue;
161
+ if (commandName === undefined) {
162
+ commandName = basename(child.text);
163
+ continue;
164
+ }
165
+ const text = child.text;
166
+ if (text === "--") break;
167
+ if (text.startsWith("-") && !text.startsWith("--") && text.includes("c")) {
168
+ sawShortFlagC = true;
169
+ }
170
+ }
171
+ if (commandName === undefined) return false;
172
+ if (commandName === "eval") return true;
173
+ return SHELL_WRAPPER_NAMES.has(commandName) && sawShortFlagC;
174
+ }
175
+
176
+ /** The final path segment of a command name (`/bin/bash` → `bash`). */
177
+ function basename(name: string): string {
178
+ const slash = name.lastIndexOf("/");
179
+ return slash === -1 ? name : name.slice(slash + 1);
180
+ }
181
+
182
+ /**
183
+ * The command-pattern text of a `command` node, with any leading
184
+ * `variable_assignment` prefix stripped.
185
+ *
186
+ * An env-var prefix (`AWS_PROFILE=prod aws …`, `PGPASSWORD=…`) is part of the
187
+ * `command` node's text but must not defeat a rule that gates the underlying
188
+ * command, so matching targets the text from the first non-assignment child
189
+ * (the `command_name`) onward, sliced verbatim to preserve spacing. A pure
190
+ * assignment (`FOO=bar`, no `command_name`) runs no command and is returned
191
+ * unchanged.
192
+ */
193
+ function commandUnitText(node: TSNode): string {
194
+ for (let i = 0; i < node.childCount; i++) {
195
+ const child = node.child(i);
196
+ if (child?.isNamed && child.type !== "variable_assignment") {
197
+ return node.text.slice(child.startIndex - node.startIndex);
198
+ }
199
+ }
200
+ return node.text;
124
201
  }
125
202
 
126
203
  function descendCommandChildren(
@@ -8,6 +8,8 @@ import { memoizeAsyncWithRetry } from "#src/async-cache";
8
8
  export interface TSNode {
9
9
  readonly type: string;
10
10
  readonly text: string;
11
+ /** Absolute byte offset of this node's start in the parsed source. */
12
+ readonly startIndex: number;
11
13
  readonly childCount: number;
12
14
  /** False for anonymous tokens (operators, delimiters); true for named nodes. */
13
15
  readonly isNamed: boolean;
@@ -61,6 +61,9 @@ export class BashProgram {
61
61
  * Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
62
62
  * quotes, command substitution, and subshells are respected by the parser and
63
63
  * are NOT split — a subshell or other compound statement is emitted whole.
64
+ * Each unit has any leading `variable_assignment` prefix stripped, and an
65
+ * opaque-payload wrapper (`bash -c`/`eval`) is flagged `opaque` so its decision
66
+ * is floored to `ask`.
64
67
  * May be empty (e.g. an empty command or a comment-only line); callers fall
65
68
  * back to the whole command so the surface is never evaluated weaker than
66
69
  * before.
@@ -5,12 +5,12 @@
5
5
  * through exactly the same path a third-party extension would use.
6
6
  */
7
7
 
8
- import { toRecord } from "./common";
9
8
  import type {
10
9
  ToolInputFormatter,
11
10
  ToolInputFormatterRegistry,
12
11
  } from "./tool-input-formatter-registry";
13
12
  import { truncateInlineText } from "./tool-input-preview";
13
+ import { toRecord } from "./value-guards";
14
14
 
15
15
  /** Maximum total length of the generated argument summary (before "with " prefix). */
16
16
  const MCP_ARGS_SUMMARY_MAX_LENGTH = 160;
@@ -1,12 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { normalize } from "node:path";
3
- import {
4
- isDenyWithReason,
5
- isPermissionState,
6
- normalizeOptionalPositiveInt,
7
- normalizeOptionalStringArray,
8
- toRecord,
9
- } from "./common";
10
3
  import {
11
4
  getGlobalConfigPath,
12
5
  getLegacyExtensionConfigPath,
@@ -16,6 +9,13 @@ import {
16
9
  } from "./config-paths";
17
10
  import { mergeFlatPermissions } from "./permission-merge";
18
11
  import type { FlatPermissionConfig, PatternValue } from "./types";
12
+ import {
13
+ isDenyWithReason,
14
+ isPermissionState,
15
+ normalizeOptionalPositiveInt,
16
+ normalizeOptionalStringArray,
17
+ toRecord,
18
+ } from "./value-guards";
19
19
 
20
20
  /**
21
21
  * Unified config shape combining runtime knobs and flat permission policy.
@@ -5,7 +5,6 @@ import {
5
5
  getActiveAgentNameFromSystemPrompt,
6
6
  type SessionEntryView,
7
7
  } from "#src/active-agent";
8
- import { toRecord } from "#src/common";
9
8
  import type { ConfigReader } from "#src/config-store";
10
9
  import type {
11
10
  PermissionDecisionUi,
@@ -31,6 +30,7 @@ import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
31
30
  import type { DebugReviewLogger } from "#src/session-logger";
32
31
  import { isSubagentExecutionContext } from "#src/subagent-context";
33
32
  import type { SubagentSessionRegistry } from "#src/subagent-registry";
33
+ import { toRecord } from "#src/value-guards";
34
34
  import { shouldAutoApprovePermissionState } from "#src/yolo-mode";
35
35
 
36
36
  import {
@@ -19,6 +19,12 @@ import type { PermissionCheckResult } from "#src/types";
19
19
  * `commandContext` (set only for a nested command), so the prompt,
20
20
  * session-approval suggestion, and decision event scope to that command.
21
21
  *
22
+ * An opaque-payload wrapper unit (`bash -c`/`eval`, flagged `opaque` by the
23
+ * enumerator) has its inner program hidden behind a quoted argument, so an
24
+ * `allow` is floored up to a synthetic `ask` (the `<opaque-bash-wrapper>`
25
+ * pattern) to keep it from riding a permissive rule; an explicit `deny`/`ask`
26
+ * on the wrapper is left untouched (`deny > ask > allow`).
27
+ *
22
28
  * When `commands` is empty there are two cases. A trivially-empty command (an
23
29
  * empty, whitespace-only, or comment-only line) has genuinely nothing to gate,
24
30
  * so the whole `command` is resolved as before. A non-empty command that parsed
@@ -56,12 +62,20 @@ export function resolveBashCommandCheck(
56
62
  }
57
63
 
58
64
  const results = commands.map((cmd) => {
59
- const result = resolver.resolve({
65
+ const base = resolver.resolve({
60
66
  kind: "tool",
61
67
  surface: "bash",
62
68
  input: { command: cmd.text },
63
69
  agentName,
64
70
  });
71
+ const result =
72
+ cmd.opaque && base.state === "allow"
73
+ ? {
74
+ ...base,
75
+ state: "ask" as const,
76
+ matchedPattern: "<opaque-bash-wrapper>",
77
+ }
78
+ : base;
65
79
  return cmd.context ? { ...result, commandContext: cmd.context } : result;
66
80
  });
67
81
  return (
@@ -1,8 +1,8 @@
1
1
  import type { BashProgram } from "#src/access-intent/bash/program";
2
- import { getNonEmptyString, toRecord } from "#src/common";
3
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
3
  import { SessionApproval } from "#src/session-approval";
5
4
  import { deriveApprovalPattern } from "#src/session-rules";
5
+ import { getNonEmptyString, toRecord } from "#src/value-guards";
6
6
  import type { GateResult } from "./descriptor";
7
7
  import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
8
8
  import { selectUncoveredExternalPaths } from "./external-directory-policy";
@@ -1,9 +1,9 @@
1
1
  import type { BashProgram } from "#src/access-intent/bash/program";
2
- import { getNonEmptyString, toRecord } from "#src/common";
3
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
3
  import { SessionApproval } from "#src/session-approval";
5
4
  import { deriveApprovalPattern } from "#src/session-rules";
6
5
  import type { PermissionCheckResult } from "#src/types";
6
+ import { getNonEmptyString, toRecord } from "#src/value-guards";
7
7
  import { pickMostRestrictive } from "./candidate-check";
8
8
  import type { GateResult } from "./descriptor";
9
9
  import { formatPathAskPrompt } from "./path";
@@ -1,8 +1,8 @@
1
- import { toRecord } from "#src/common";
2
1
  import { normalizePathForComparison } from "#src/path-utils";
3
2
  import { formatSkillPathAskPrompt } from "#src/permission-prompts";
4
3
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
5
4
  import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
5
+ import { toRecord } from "#src/value-guards";
6
6
  import type { GateDescriptor } from "./descriptor";
7
7
  import type { ToolCallContext } from "./types";
8
8
 
@@ -1,5 +1,4 @@
1
1
  import { BashProgram } from "#src/access-intent/bash/program";
2
- import { getNonEmptyString, toRecord } from "#src/common";
3
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
3
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
5
4
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
@@ -8,6 +7,7 @@ import {
8
7
  ToolPreviewFormatter,
9
8
  type ToolPreviewFormatterOptions,
10
9
  } from "#src/tool-preview-formatter";
10
+ import { getNonEmptyString, toRecord } from "#src/value-guards";
11
11
  import { resolveBashCommandCheck } from "./bash-command";
12
12
  import { describeBashExternalDirectoryGate } from "./bash-external-directory";
13
13
  import { describeBashPathGate } from "./bash-path";
@@ -2,8 +2,6 @@ import type {
2
2
  ExtensionContext,
3
3
  InputEventResult,
4
4
  } from "@earendil-works/pi-coding-agent";
5
-
6
- import { toRecord } from "#src/common";
7
5
  import {
8
6
  formatMissingToolNameReason,
9
7
  formatUnknownToolReason,
@@ -14,6 +12,7 @@ import {
14
12
  getToolNameFromValue,
15
13
  type ToolRegistry,
16
14
  } from "#src/tool-registry";
15
+ import { toRecord } from "#src/value-guards";
17
16
  import type { GateRunner } from "./gates/runner";
18
17
  import type {
19
18
  GateNotifier,
@@ -1,8 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
3
- import { toRecord } from "#src/common";
4
2
  import type { DecisionRecorder } from "#src/decision-audit";
5
3
  import type { DecisionReporter } from "#src/decision-reporter";
4
+ import { toRecord } from "#src/value-guards";
6
5
  import type { GateOutcome } from "./gates/types";
7
6
 
8
7
  /** The SDK-facing result shape for a `tool_call` handler. */
@@ -1,7 +1,7 @@
1
1
  import { stripBashCommentLines } from "./bash-arity";
2
- import { getNonEmptyString, toRecord } from "./common";
3
2
  import { createMcpPermissionTargets } from "./mcp-targets";
4
3
  import { getPathPolicyValues, PATH_BEARING_TOOLS } from "./path-utils";
4
+ import { getNonEmptyString, toRecord } from "./value-guards";
5
5
 
6
6
  /**
7
7
  * Construct a surface-appropriate input object from a raw value string.
@@ -1,4 +1,4 @@
1
- import { getNonEmptyString, toRecord } from "./common";
1
+ import { getNonEmptyString, toRecord } from "./value-guards";
2
2
 
3
3
  /**
4
4
  * An ordered accumulator that owns the uniqueness invariant.
package/src/normalize.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { isDenyWithReason, isPermissionState } from "./common";
2
1
  import type { Rule, Ruleset } from "./rule";
3
2
  import type { FlatPermissionConfig } from "./types";
3
+ import { isDenyWithReason, isPermissionState } from "./value-guards";
4
4
 
5
5
  /**
6
6
  * Convert a flat permission config into a Ruleset.
package/src/path-utils.ts CHANGED
@@ -8,9 +8,9 @@ import {
8
8
  } from "node:path";
9
9
 
10
10
  import { canonicalizePath } from "./canonicalize-path";
11
- import { getNonEmptyString, toRecord } from "./common";
12
11
  import { expandHomePath } from "./expand-home";
13
12
  import type { ToolAccessExtractorLookup } from "./tool-access-extractor-registry";
13
+ import { getNonEmptyString, toRecord } from "./value-guards";
14
14
  import { wildcardMatch } from "./wildcard-matcher";
15
15
 
16
16
  export function normalizePathForComparison(
@@ -1,6 +1,5 @@
1
1
  import { join } from "node:path";
2
2
  import type { ResolvedAccessIntent } from "./access-intent/access-intent";
3
- import { isPermissionState } from "./common";
4
3
  import {
5
4
  getGlobalConfigPath,
6
5
  getProjectAgentsDir,
@@ -28,6 +27,7 @@ import type {
28
27
  PermissionCheckResult,
29
28
  PermissionState,
30
29
  } from "./types";
30
+ import { isPermissionState } from "./value-guards";
31
31
 
32
32
  const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
33
33
  "bash",
@@ -1,8 +1,8 @@
1
- import { getNonEmptyString, toRecord } from "./common";
2
1
  import { matchQualifier } from "./denial-messages";
3
2
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
4
3
  import type { ToolPreviewFormatter } from "./tool-preview-formatter";
5
4
  import type { PermissionCheckResult } from "./types";
5
+ import { getNonEmptyString, toRecord } from "./value-guards";
6
6
 
7
7
  // NOTE: formatDenyReason, formatUserDeniedReason, and
8
8
  // formatPermissionHardStopHint have been moved to denial-messages.ts.
@@ -1,8 +1,6 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
-
5
- import { extractFrontmatter, parseSimpleYamlMap, toRecord } from "./common";
6
4
  import {
7
5
  loadUnifiedConfig,
8
6
  normalizeUnifiedConfig,
@@ -10,6 +8,8 @@ import {
10
8
  } from "./config-loader";
11
9
  import { getGlobalConfigPath } from "./config-paths";
12
10
  import type { ScopeConfig } from "./types";
11
+ import { toRecord } from "./value-guards";
12
+ import { extractFrontmatter, parseSimpleYamlMap } from "./yaml-frontmatter";
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // File-stamp helper
@@ -1,5 +1,5 @@
1
- import { getNonEmptyString, toRecord } from "./common";
2
1
  import { countTextLines, formatCount } from "./tool-input-preview";
2
+ import { getNonEmptyString, toRecord } from "./value-guards";
3
3
 
4
4
  export function getPromptPath(input: Record<string, unknown>): string | null {
5
5
  return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
@@ -1,4 +1,3 @@
1
- import { getNonEmptyString, toRecord } from "./common";
2
1
  import type { PermissionSystemExtensionConfig } from "./extension-config";
3
2
  import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
4
3
  import {
@@ -15,6 +14,7 @@ import {
15
14
  getPromptPath,
16
15
  } from "./tool-input-prompt-formatters";
17
16
  import type { PermissionCheckResult } from "./types";
17
+ import { getNonEmptyString, toRecord } from "./value-guards";
18
18
 
19
19
  export interface ToolPreviewFormatterOptions {
20
20
  toolInputPreviewMaxLength: number;
@@ -1,4 +1,4 @@
1
- import { getNonEmptyString, toRecord } from "./common";
1
+ import { getNonEmptyString, toRecord } from "./value-guards";
2
2
 
3
3
  /** Narrow interface for the Pi tool API subset used by handler classes. */
4
4
  export interface ToolRegistry {
@@ -53,69 +53,3 @@ export function isDenyWithReason(value: unknown): value is DenyWithReason {
53
53
  (record.reason === undefined || typeof record.reason === "string")
54
54
  );
55
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
- }
@@ -0,0 +1,65 @@
1
+ type StackNode = { indent: number; target: Record<string, unknown> };
2
+
3
+ export function parseSimpleYamlMap(input: string): Record<string, unknown> {
4
+ const root: Record<string, unknown> = {};
5
+ const stack: StackNode[] = [{ indent: -1, target: root }];
6
+
7
+ const lines = input.split(/\r?\n/);
8
+ for (const rawLine of lines) {
9
+ if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
10
+ continue;
11
+ }
12
+
13
+ const indent = rawLine.length - rawLine.trimStart().length;
14
+ const line = rawLine.trim();
15
+ const separatorIndex = line.indexOf(":");
16
+ if (separatorIndex <= 0) {
17
+ continue;
18
+ }
19
+
20
+ const key = line
21
+ .slice(0, separatorIndex)
22
+ .trim()
23
+ .replace(/^['"]|['"]$/g, "");
24
+ const rawValue = line.slice(separatorIndex + 1).trim();
25
+
26
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
27
+ stack.pop();
28
+ }
29
+
30
+ const current = stack[stack.length - 1].target;
31
+
32
+ if (!rawValue) {
33
+ const child: Record<string, unknown> = {};
34
+ current[key] = child;
35
+ stack.push({ indent, target: child });
36
+ continue;
37
+ }
38
+
39
+ let scalar = rawValue;
40
+ if (
41
+ (scalar.startsWith('"') && scalar.endsWith('"')) ||
42
+ (scalar.startsWith("'") && scalar.endsWith("'"))
43
+ ) {
44
+ scalar = scalar.slice(1, -1);
45
+ }
46
+
47
+ current[key] = scalar;
48
+ }
49
+
50
+ return root;
51
+ }
52
+
53
+ export function extractFrontmatter(markdown: string): string {
54
+ const normalized = markdown.replace(/\r\n/g, "\n");
55
+ if (!normalized.startsWith("---\n")) {
56
+ return "";
57
+ }
58
+
59
+ const end = normalized.indexOf("\n---", 4);
60
+ if (end === -1) {
61
+ return "";
62
+ }
63
+
64
+ return normalized.slice(4, end);
65
+ }
@@ -10,6 +10,7 @@ function makeNode(type: string, text: string, children: TSNode[] = []): TSNode {
10
10
  return {
11
11
  type,
12
12
  text,
13
+ startIndex: 0,
13
14
  childCount: children.length,
14
15
  isNamed: true,
15
16
  child: (i) => children[i] ?? null,