@code-yeongyu/senpi 2026.10.5 → 2026.10.6

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 (115) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/bundle/chunks/{anthropic-messages-G5236QNK.js → anthropic-messages-4MNFPQ66.js} +1 -1
  3. package/dist/bundle/chunks/{app-server-command-3Q5G4CAF.js → app-server-command-GNTX4MZT.js} +1 -1
  4. package/dist/bundle/chunks/{azure-openai-responses-NTA2HGG4.js → azure-openai-responses-A5OEHSJM.js} +1 -1
  5. package/dist/bundle/chunks/bedrock-converse-stream.js +1 -1
  6. package/dist/bundle/chunks/{chunk-EEOKVSNF.js → chunk-3EE6G4G7.js} +180 -176
  7. package/dist/bundle/chunks/{chunk-2WV426OJ.js → chunk-4MJKBXI3.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-ECDLITEY.js → chunk-6EH7QXOG.js} +2 -2
  9. package/dist/bundle/chunks/chunk-6KQGOVQG.js +2 -0
  10. package/dist/bundle/chunks/{chunk-JVSOW4QU.js → chunk-7A3WRAKP.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-WTJU5B2C.js → chunk-BE66QJ3Z.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-UHRXXTFZ.js → chunk-D2W3MXRF.js} +2 -2
  13. package/dist/bundle/chunks/{chunk-U7CI2JGX.js → chunk-DDEAZX22.js} +1 -1
  14. package/dist/bundle/chunks/{chunk-UXIQK5NQ.js → chunk-ESSQUSRC.js} +1 -1
  15. package/dist/bundle/chunks/{chunk-KLBKUBDV.js → chunk-GQ3YLXVZ.js} +1 -1
  16. package/dist/bundle/chunks/{chunk-CTQU2XNX.js → chunk-MLNJNJFE.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-BPX6QWPD.js → chunk-PELNNWGO.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-UUAKEDVS.js → chunk-QLIW3YMI.js} +1 -1
  19. package/dist/bundle/chunks/{chunk-BBK6SSYT.js → chunk-TAV6QZFM.js} +1 -1
  20. package/dist/bundle/chunks/{chunk-QUROHJLP.js → chunk-TFO5JGLR.js} +1 -1
  21. package/dist/bundle/chunks/{cli-main-VZWSUHFB.js → cli-main-2C3YPHRE.js} +1 -1
  22. package/dist/bundle/chunks/{cloudflare-workers-ai-system-one-5ZUHUKMU.js → cloudflare-workers-ai-system-one-TE3OEJEL.js} +1 -1
  23. package/dist/bundle/chunks/{google-generative-ai-OTMG6ZXI.js → google-generative-ai-JBC53AMK.js} +1 -1
  24. package/dist/bundle/chunks/{google-vertex-2LXS3ICC.js → google-vertex-46ODNPM2.js} +1 -1
  25. package/dist/bundle/chunks/{help-fast-path-SHZJ3QWQ.js → help-fast-path-FUMH5NSA.js} +1 -1
  26. package/dist/bundle/chunks/{host-command-7JIGGOWR.js → host-command-N6XS5O67.js} +1 -1
  27. package/dist/bundle/chunks/{interactive-mode-JA7AHYYL.js → interactive-mode-CM4C4ZFY.js} +1 -1
  28. package/dist/bundle/chunks/{llama-cpp-classify-U5XAMKZE.js → llama-cpp-classify-AD6TTKNL.js} +1 -1
  29. package/dist/bundle/chunks/{multi-session-host-34WOINP4.js → multi-session-host-QLLTPWIM.js} +5 -5
  30. package/dist/bundle/chunks/{openai-codex-responses-WNTYIPFZ.js → openai-codex-responses-Q4OWIK7Y.js} +1 -1
  31. package/dist/bundle/chunks/{openai-completions-VSH22FCN.js → openai-completions-6HU7GIGD.js} +1 -1
  32. package/dist/bundle/chunks/{openai-images-XBTUJXPR.js → openai-images-M73YANTR.js} +1 -1
  33. package/dist/bundle/chunks/{openai-responses-WLBHCFYL.js → openai-responses-S4QYZXF3.js} +1 -1
  34. package/dist/bundle/chunks/{openrouter-images-3LVJZY3G.js → openrouter-images-PCJFX2KO.js} +1 -1
  35. package/dist/bundle/chunks/{package-manager-cli-2QLVGQSC.js → package-manager-cli-VRCAO7RV.js} +1 -1
  36. package/dist/bundle/chunks/{rotation-stream-JUCEXHWU.js → rotation-stream-7E37KOCX.js} +1 -1
  37. package/dist/bundle/chunks/{rpc-mode-QEBSN2OH.js → rpc-mode-2OPE6J7P.js} +1 -1
  38. package/dist/bundle/chunks/{session-control-endpoint-UGQIP6ZN.js → session-control-endpoint-SOG4XFF7.js} +1 -1
  39. package/dist/bundle/chunks/{session-picker-HX4LOJZ7.js → session-picker-VTTRFGE7.js} +1 -1
  40. package/dist/bundle/chunks/session-worker.js +370 -366
  41. package/dist/bundle/chunks/{typesafe-system-one-FNI3UASE.js → typesafe-system-one-TF23RUVG.js} +1 -1
  42. package/dist/bundle/cli.js +1 -1
  43. package/dist/bundle/index.js +1 -1
  44. package/dist/bundle/rpc-entry.js +1 -1
  45. package/dist/bundle/runtime-manifest.json +1 -1
  46. package/dist/core/agent-session-runtime.d.ts +12 -0
  47. package/dist/core/agent-session.d.ts +18 -0
  48. package/dist/core/agent-session.js +55 -19
  49. package/dist/core/dynamic-prompt/handoff.d.ts +4 -2
  50. package/dist/core/dynamic-prompt/handoff.js +1 -1
  51. package/dist/core/extensions/builtin/diff.js +14 -9
  52. package/dist/core/extensions/builtin/files.js +6 -2
  53. package/dist/core/extensions/builtin/gpt-apply-patch/parser.js +5 -1
  54. package/dist/core/extensions/builtin/gpt-apply-patch/streaming-parser.d.ts +8 -0
  55. package/dist/core/extensions/builtin/gpt-apply-patch/streaming-parser.js +26 -2
  56. package/dist/core/extensions/builtin/gpt-apply-patch/streaming-render.js +82 -11
  57. package/dist/core/extensions/builtin/gpt-apply-patch/types.d.ts +9 -1
  58. package/dist/core/extensions/builtin/permission-system/auto-credentials.d.ts +2 -0
  59. package/dist/core/extensions/builtin/permission-system/auto-credentials.js +17 -0
  60. package/dist/core/extensions/builtin/permission-system/auto-paths.d.ts +14 -0
  61. package/dist/core/extensions/builtin/permission-system/auto-paths.js +93 -0
  62. package/dist/core/extensions/builtin/permission-system/auto-policy.d.ts +20 -0
  63. package/dist/core/extensions/builtin/permission-system/auto-policy.js +126 -0
  64. package/dist/core/extensions/builtin/permission-system/auto-program-rules.d.ts +6 -0
  65. package/dist/core/extensions/builtin/permission-system/auto-program-rules.js +139 -0
  66. package/dist/core/extensions/builtin/permission-system/auto-shell-grammar.d.ts +29 -0
  67. package/dist/core/extensions/builtin/permission-system/auto-shell-grammar.js +74 -0
  68. package/dist/core/extensions/builtin/permission-system/auto-shell-segments.d.ts +13 -0
  69. package/dist/core/extensions/builtin/permission-system/auto-shell-segments.js +23 -0
  70. package/dist/core/extensions/builtin/permission-system/cli.d.ts +1 -0
  71. package/dist/core/extensions/builtin/permission-system/cli.js +9 -10
  72. package/dist/core/extensions/builtin/permission-system/config.d.ts +4 -1
  73. package/dist/core/extensions/builtin/permission-system/config.js +13 -1
  74. package/dist/core/extensions/builtin/permission-system/index.js +20 -12
  75. package/dist/core/extensions/builtin/permission-system/non-interactive.d.ts +11 -6
  76. package/dist/core/extensions/builtin/permission-system/non-interactive.js +8 -33
  77. package/dist/core/extensions/builtin/permission-system/service.d.ts +20 -1
  78. package/dist/core/extensions/builtin/permission-system/service.js +43 -7
  79. package/dist/core/extensions/builtin/permission-system/settings.d.ts +1 -0
  80. package/dist/core/extensions/builtin/permission-system/settings.js +4 -3
  81. package/dist/core/extensions/builtin/permission-system/types.d.ts +1 -1
  82. package/dist/core/extensions/builtin/prompt-preset/claude-fable-5-1.js +16 -3
  83. package/dist/core/retry-fallback/usage-limit.js +5 -1
  84. package/dist/core/tools/diff-render.d.ts +4 -0
  85. package/dist/core/tools/diff-render.js +13 -0
  86. package/dist/core/tools/renderers/edit.js +6 -4
  87. package/dist/main.js +12 -0
  88. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  89. package/dist/modes/interactive/interactive-mode.js +55 -11
  90. package/dist/modes/interactive/provider-error-presentation.d.ts +25 -0
  91. package/dist/modes/interactive/provider-error-presentation.js +42 -0
  92. package/dist/modes/interactive/theme/dark.json +5 -5
  93. package/dist/modes/interactive/theme/light.json +5 -5
  94. package/dist/modes/rpc/custom-capability.d.ts +6 -0
  95. package/dist/modes/rpc/custom-capability.js +6 -0
  96. package/dist/modes/rpc/rpc-client.d.ts +3 -0
  97. package/dist/modes/rpc/rpc-input-validation.d.ts +7 -0
  98. package/dist/modes/rpc/rpc-input-validation.js +37 -0
  99. package/dist/modes/rpc/rpc-types.d.ts +9 -0
  100. package/dist/modes/rpc/session-command-activity.d.ts +2 -0
  101. package/dist/modes/rpc/session-command-activity.js +25 -0
  102. package/dist/modes/rpc/session-command-router.js +11 -4
  103. package/dist/modes/rpc/session-registry-attach.js +1 -0
  104. package/dist/modes/rpc/session-registry-types.d.ts +7 -0
  105. package/dist/modes/rpc/session-registry-types.js +11 -0
  106. package/dist/modes/rpc/session-registry.js +6 -3
  107. package/dist/modes/rpc/session-sweep.d.ts +12 -2
  108. package/dist/modes/rpc/session-sweep.js +36 -3
  109. package/dist/modes/rpc/session-teardown.d.ts +2 -0
  110. package/dist/modes/rpc/session-teardown.js +1 -0
  111. package/dist/modes/rpc/worker-session-registry.js +5 -1
  112. package/docs/rpc.md +39 -5
  113. package/docs/settings.md +3 -1
  114. package/package.json +6 -6
  115. package/dist/bundle/chunks/chunk-K3BXFPWZ.js +0 -2
@@ -3,6 +3,9 @@ import { normalizeApplyPatchArguments } from "./params.js";
3
3
  import { renderPatchLine } from "./preview-format.js";
4
4
  import { StreamingPatchParser } from "./streaming-parser.js";
5
5
  import { extractPatchedPaths } from "./text.js";
6
+ // Tail-window the streaming body so a long patch never takes over the viewport: a sticky
7
+ // per-file header plus the last few lines of each file, with a "+N lines above" indicator.
8
+ const STREAMING_TAIL_LINES_PER_FILE = 12;
6
9
  function hunkOperation(hunk) {
7
10
  if (hunk.type === "add")
8
11
  return "Added";
@@ -27,8 +30,44 @@ function hunkDiffLines(hunk) {
27
30
  ...chunk.newLines.map((line) => ` + ${line}`),
28
31
  ]);
29
32
  }
30
- function formatStreamingHunks(hunks) {
31
- return hunks.flatMap((hunk) => [`• ${hunkOperation(hunk)} ${hunkPath(hunk)}`, ...hunkDiffLines(hunk)]).join("\n");
33
+ function hunkChangeCounts(hunk) {
34
+ if (hunk.type === "add")
35
+ return { added: hunk.content.split("\n").filter(Boolean).length, removed: 0 };
36
+ if (hunk.type === "delete")
37
+ return { added: 0, removed: 0 };
38
+ // addedCount/removedCount are tracked by prefix at parse time, so context lines are excluded
39
+ // and a + line whose text equals a context line still counts as an addition (no set-membership
40
+ // or positional undercount).
41
+ let added = 0;
42
+ let removed = 0;
43
+ for (const chunk of hunk.chunks) {
44
+ added += chunk.addedCount;
45
+ removed += chunk.removedCount;
46
+ }
47
+ return { added, removed };
48
+ }
49
+ // One bounded block per file: sticky header with net counts, then the last N diff lines
50
+ // with a "+N lines above" marker when the file outgrew the window.
51
+ function formatStreamingHunks(hunks, partialLine) {
52
+ const blocks = [];
53
+ for (const hunk of hunks) {
54
+ const { added, removed } = hunkChangeCounts(hunk);
55
+ const counts = added + removed > 0 ? ` (+${added} -${removed})` : "";
56
+ const header = `• ${hunkOperation(hunk)} ${hunkPath(hunk)}${counts}`;
57
+ const lines = hunkDiffLines(hunk);
58
+ if (lines.length <= STREAMING_TAIL_LINES_PER_FILE) {
59
+ blocks.push([header, ...lines].join("\n"));
60
+ continue;
61
+ }
62
+ const hidden = lines.length - STREAMING_TAIL_LINES_PER_FILE;
63
+ const tail = lines.slice(-STREAMING_TAIL_LINES_PER_FILE);
64
+ blocks.push([header, ` … (+${hidden} lines above)`, ...tail].join("\n"));
65
+ }
66
+ // The in-flight, not-yet-newline-terminated line renders dimmed as the last row.
67
+ const partial = partialLine.trim();
68
+ if (partial.length > 0)
69
+ blocks.push(` ${partial}`);
70
+ return blocks.join("\n");
32
71
  }
33
72
  function updateStreamingState(input, state) {
34
73
  if (!state.streamingParser || !input.startsWith(state.streamingInput ?? "")) {
@@ -39,8 +78,14 @@ function updateStreamingState(input, state) {
39
78
  }
40
79
  const previousInput = state.streamingInput ?? "";
41
80
  const delta = input.slice(previousInput.length);
81
+ // A zero-length delta (same render pass re-run with no new text) must not re-parse or re-render.
82
+ if (delta.length === 0)
83
+ return state.streamingHunks ?? [];
42
84
  try {
43
- state.streamingHunks = state.streamingParser.pushDelta(delta);
85
+ // Parse the delta, then render the parser's live hunk list rather than the deep clone
86
+ // pushDelta returns; the per-delta structuredClone over every hunk is the O(n^2) cost.
87
+ state.streamingParser.pushDelta(delta);
88
+ state.streamingHunks = state.streamingParser.getLiveHunks?.() ?? state.streamingHunks;
44
89
  state.streamingInput = input;
45
90
  state.streamingError = undefined;
46
91
  }
@@ -49,30 +94,56 @@ function updateStreamingState(input, state) {
49
94
  }
50
95
  return state.streamingHunks ?? [];
51
96
  }
52
- function renderBox(title, body, theme) {
97
+ function renderBox(title, body, theme, dimLastLine) {
53
98
  const component = new Container();
54
99
  const box = new Box(1, 1, (text) => theme.bg("toolPendingBg", text));
55
100
  box.addChild(new Text(theme.fg("toolTitle", theme.bold(title)), 0, 0));
56
101
  box.addChild(new Spacer(1));
57
- box.addChild(new Text(body
58
- .split("\n")
59
- .map((line) => renderPatchLine(line, theme))
102
+ const lines = body.split("\n");
103
+ box.addChild(new Text(lines
104
+ .map((line, index) => dimLastLine && index === lines.length - 1
105
+ ? theme.fg("toolDiffContext", line)
106
+ : renderPatchLine(line, theme))
60
107
  .join("\n"), 0, 0));
61
108
  component.addChild(box);
62
109
  return component;
63
110
  }
111
+ // A no-change delta keeps the already-rendered body; the component renders it on demand so the
112
+ // box persists across a redraw without a rebuild.
113
+ class MemoizedStreamingBox extends Container {
114
+ constructor(body, hasPartial, theme) {
115
+ super();
116
+ this.body = body;
117
+ this.hasPartial = hasPartial;
118
+ this.theme = theme;
119
+ }
120
+ render(width) {
121
+ this.built ??= renderBox("Applying patch", this.body, this.theme, this.hasPartial);
122
+ return this.built.render(width);
123
+ }
124
+ }
64
125
  export function renderStreamingPatchCall(args, theme, state) {
65
126
  const input = normalizeApplyPatchArguments(args).input;
66
127
  if (!input)
67
128
  return undefined;
68
129
  const hunks = updateStreamingState(input, state);
69
130
  if (state.streamingError)
70
- return renderBox("Invalid patch stream", state.streamingError, theme);
71
- if (hunks.length > 0)
72
- return renderBox("Applying patch", formatStreamingHunks(hunks), theme);
131
+ return renderBox("Invalid patch stream", state.streamingError, theme, false);
132
+ if (hunks.length > 0) {
133
+ const partialLine = state.streamingParser?.getPartialLine?.() ?? "";
134
+ const body = formatStreamingHunks(hunks, partialLine);
135
+ const hasPartial = partialLine.trim().length > 0;
136
+ // A no-change delta keeps the already-rendered box instead of rebuilding it; the box must
137
+ // still render (returning undefined here would blank the preview on any redraw).
138
+ if (body === state.streamingLastRenderKey && state.streamingLastRenderKey !== undefined) {
139
+ return new MemoizedStreamingBox(body, hasPartial, theme);
140
+ }
141
+ state.streamingLastRenderKey = body;
142
+ return renderBox("Applying patch", body, theme, hasPartial);
143
+ }
73
144
  const paths = extractPatchedPaths(input);
74
145
  if (paths.length === 0)
75
146
  return undefined;
76
- return renderBox("Applying patch", paths.map((filePath) => `• ${filePath}`).join("\n"), theme);
147
+ return renderBox("Applying patch", paths.map((filePath) => `• ${filePath}`).join("\n"), theme, false);
77
148
  }
78
149
  //# sourceMappingURL=streaming-render.js.map
@@ -20,6 +20,10 @@ export type PatchChunk = {
20
20
  newLines: string[];
21
21
  contextLineIndices: ContextLineIndex[];
22
22
  isEndOfFile: boolean;
23
+ /** Real added/removed line counts by prefix, tracked at parse time (context lines excluded,
24
+ * so a `+` line whose text equals a context line still counts as an addition). */
25
+ addedCount: number;
26
+ removedCount: number;
23
27
  };
24
28
  export type ApplyPatchWireMode = "freeform" | "json" | "none";
25
29
  export type ApplyPatchToolVariant = Exclude<ApplyPatchWireMode, "none">;
@@ -111,9 +115,13 @@ export type ApplyPatchRenderState = {
111
115
  streamingInput?: string;
112
116
  streamingParser?: {
113
117
  pushDelta: (delta: string) => ParsedPatch[];
118
+ getLiveHunks?: () => readonly ParsedPatch[];
119
+ getPartialLine?: () => string;
114
120
  };
115
- streamingHunks?: ParsedPatch[];
121
+ streamingHunks?: readonly ParsedPatch[];
116
122
  streamingError?: string;
123
+ /** Last rendered body fingerprint, so a delta that changes nothing skips the rebuild. */
124
+ streamingLastRenderKey?: string;
117
125
  };
118
126
  export type ApplyPatchToolDefinition = ToolDefinition<typeof APPLY_PATCH_PARAMS, ApplyPatchToolDetails | undefined, ApplyPatchRenderState>;
119
127
  export type ApplyPatchExtensionAPI = Pick<ExtensionAPI, "on" | "getActiveTools" | "getAllTools" | "setActiveTools"> & {
@@ -0,0 +1,2 @@
1
+ export declare function isCredentialPath(value: string): boolean;
2
+ //# sourceMappingURL=auto-credentials.d.ts.map
@@ -0,0 +1,17 @@
1
+ const CREDENTIAL_PATH_PATTERNS = [
2
+ /(^|[/\\=])\.(ssh|aws|gnupg|kube|docker|azure|gcloud)([/\\]|$)/i,
3
+ /(^|[/\\=])\.config[/\\](gh|gcloud|hub|op)([/\\]|$)/i,
4
+ /(^|[/\\=])\.(netrc|npmrc|pypirc|pgpass|git-credentials|htpasswd|vault-token|envrc|terraformrc)$/i,
5
+ /(^|[/\\=])\.env(\.[^/\\]*)?$/i,
6
+ /(^|[/\\=])id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
7
+ /\.(pem|key|p12|pfx|jks|keystore|kdbx|ppk|asc|gpg)$/i,
8
+ /(^|[/\\=])\.?credentials(\.json|\.toml|\.tfrc\.json)?$/i,
9
+ /(^|[/\\=])auth\.json$/i,
10
+ /(^|[/\\=])(\.[a-z]+_history|fish_history)$/i,
11
+ /(^|[/\\=])\.m2[/\\]settings\.xml$/i,
12
+ /(^|[/\\])Library[/\\]Keychains([/\\]|$)/i,
13
+ ];
14
+ export function isCredentialPath(value) {
15
+ return CREDENTIAL_PATH_PATTERNS.some((pattern) => pattern.test(value));
16
+ }
17
+ //# sourceMappingURL=auto-credentials.js.map
@@ -0,0 +1,14 @@
1
+ export type TargetKind = "file" | "directory" | "missing" | "other";
2
+ /** Whether something, even a dangling symlink, exists at `target` itself; git looks at the name. */
3
+ export declare function existsAtName(target: string): boolean;
4
+ /** Whether `cwd` is a root `auto` approves anything in (see `projectRoot`). */
5
+ export declare function isProjectSession(cwd: string): boolean;
6
+ /** What is at `target` after following every symlink, without opening anything. */
7
+ export declare function targetKind(target: string): TargetKind;
8
+ /**
9
+ * Whether `auto` may touch the absolute path `target` that a tool resolved: after following every
10
+ * symlink it is inside the project root, no component below the root is hidden (other than a
11
+ * short list of repository and formatter files), and nothing along it is credential-shaped.
12
+ */
13
+ export declare function isApprovableTarget(target: string, cwd: string): boolean;
14
+ //# sourceMappingURL=auto-paths.d.ts.map
@@ -0,0 +1,93 @@
1
+ import { lstatSync } from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { realpathWithoutOpenStrict } from "../../../../utils/paths.js";
5
+ import { isCredentialPath } from "./auto-credentials.js";
6
+ /** Hidden project entries `auto` may touch: repository metadata and formatter/linter configs. */
7
+ const SAFE_HIDDEN_NAMES = new Set([
8
+ ".github",
9
+ ".gitignore",
10
+ ".gitattributes",
11
+ ".editorconfig",
12
+ ".nvmrc",
13
+ ".node-version",
14
+ ".prettierrc",
15
+ ".prettierignore",
16
+ ".eslintrc",
17
+ ".eslintignore",
18
+ ".stylelintrc",
19
+ ".markdownlint.json",
20
+ ]);
21
+ const physicalPath = (target) => {
22
+ try {
23
+ const resolved = realpathWithoutOpenStrict(target);
24
+ return resolved.split(path.sep).includes("..") ? undefined : resolved;
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ };
30
+ /**
31
+ * The project root `auto` works in, or undefined when the session root is `/`, the home directory, any
32
+ * ancestor of it (`/Users`, `/home`), or inside a hidden directory (`~/.config`): from there login
33
+ * items, app stores and tool configs would count as project files.
34
+ */
35
+ function projectRoot(cwd) {
36
+ const root = physicalPath(path.resolve(cwd));
37
+ const home = physicalPath(os.homedir());
38
+ if (root === undefined || home === undefined)
39
+ return undefined;
40
+ const homeFromRoot = path.relative(root, home);
41
+ const rootContainsHome = homeFromRoot === "" || (!homeFromRoot.startsWith("..") && !path.isAbsolute(homeFromRoot));
42
+ const hiddenRoot = root.split(path.sep).some((segment) => segment.startsWith("."));
43
+ return root === path.parse(root).root || rootContainsHome || hiddenRoot ? undefined : root;
44
+ }
45
+ /** Whether something, even a dangling symlink, exists at `target` itself; git looks at the name. */
46
+ export function existsAtName(target) {
47
+ try {
48
+ return lstatSync(target, { throwIfNoEntry: false }) !== undefined;
49
+ }
50
+ catch {
51
+ // A name the filesystem refuses (too long, a loop, no permission) is not a plain ref either:
52
+ // report it as existing so the caller checks it as a path, and that check asks.
53
+ return true;
54
+ }
55
+ }
56
+ /** Whether `cwd` is a root `auto` approves anything in (see `projectRoot`). */
57
+ export function isProjectSession(cwd) {
58
+ return projectRoot(cwd) !== undefined;
59
+ }
60
+ /** What is at `target` after following every symlink, without opening anything. */
61
+ export function targetKind(target) {
62
+ const physical = physicalPath(target);
63
+ if (physical === undefined)
64
+ return "other";
65
+ try {
66
+ const stats = lstatSync(physical);
67
+ return stats.isFile() ? "file" : stats.isDirectory() ? "directory" : "other";
68
+ }
69
+ catch (error) {
70
+ return error.code === "ENOENT" ? "missing" : "other";
71
+ }
72
+ }
73
+ /**
74
+ * Whether `auto` may touch the absolute path `target` that a tool resolved: after following every
75
+ * symlink it is inside the project root, no component below the root is hidden (other than a
76
+ * short list of repository and formatter files), and nothing along it is credential-shaped.
77
+ */
78
+ export function isApprovableTarget(target, cwd) {
79
+ const root = projectRoot(cwd);
80
+ const physical = physicalPath(target);
81
+ if (root === undefined || physical === undefined)
82
+ return false;
83
+ const below = path.relative(root, physical);
84
+ if (below === "")
85
+ return true;
86
+ if (below.startsWith("..") || path.isAbsolute(below))
87
+ return false;
88
+ if (below.split(path.sep).some((segment) => segment.startsWith(".") && !SAFE_HIDDEN_NAMES.has(segment))) {
89
+ return false;
90
+ }
91
+ return !isCredentialPath(physical) && !isCredentialPath(target);
92
+ }
93
+ //# sourceMappingURL=auto-paths.js.map
@@ -0,0 +1,20 @@
1
+ import type { PermissionRequest } from "./parsers.ts";
2
+ export type AutoCommandVerdict = "allow" | "ask";
3
+ export interface AutoDecision {
4
+ /** Approve an ask that comes only from the preset's own rule (a user's rules still win). */
5
+ readonly approveBlanketAsk: boolean;
6
+ }
7
+ /**
8
+ * Judges a shell command for the `auto` preset: every simple command must be a listed read-only
9
+ * program run in the project root, whose every word the program grammar classifies, and every
10
+ * path word must name an approvable project file or directory. Anything else asks.
11
+ */
12
+ export declare function judgeAutoCommand(command: string, cwd: string): AutoCommandVerdict;
13
+ /**
14
+ * The `auto` preset's allowlist (decision table on senpi#2614). Each decision is made on the exact
15
+ * target the tool will open, obtained from the tool's own resolver (`resolveReadPathAsync` for `read`,
16
+ * `resolveToCwd` for `write`, `edit`, `ls`, `find` and `grep`, `parsePatch` + `resolvePatchPath` for `apply_patch`).
17
+ * Every other call keeps the preset's ask.
18
+ */
19
+ export declare function decideAuto(toolName: string, input: Record<string, unknown>, request: PermissionRequest, cwd: string): Promise<AutoDecision>;
20
+ //# sourceMappingURL=auto-policy.d.ts.map
@@ -0,0 +1,126 @@
1
+ import * as path from "node:path";
2
+ import { resolveReadPathAsync, resolveToCwd } from "../../../tools/path-utils.js";
3
+ import { normalizeApplyPatchArguments } from "../gpt-apply-patch/params.js";
4
+ import { parsePatch } from "../gpt-apply-patch/parser.js";
5
+ import { resolvePatchPath } from "../gpt-apply-patch/workspace.js";
6
+ import { isCredentialPath } from "./auto-credentials.js";
7
+ import { existsAtName, isApprovableTarget, isProjectSession, targetKind } from "./auto-paths.js";
8
+ import { PROGRAM_RULES } from "./auto-program-rules.js";
9
+ import { splitShellSegments } from "./auto-shell-segments.js";
10
+ const WRITE_TOOLS = new Set(["write", "edit"]);
11
+ const LIST_TOOLS = new Set(["ls", "find"]);
12
+ const NO = { approveBlanketAsk: false };
13
+ const YES = { approveBlanketAsk: true };
14
+ const approvedTarget = (target, cwd, kinds) => isApprovableTarget(target, cwd) && kinds.includes(targetKind(target));
15
+ function shellWordAllowed(entry, cwd) {
16
+ if (entry.role === "text")
17
+ return true;
18
+ const text = entry.word.text;
19
+ if (text === "" || text === "-" || text.split("/").includes(".."))
20
+ return false;
21
+ const target = path.resolve(cwd, text);
22
+ if (entry.role === "read-file")
23
+ return approvedTarget(target, cwd, ["file"]);
24
+ if (entry.role === "list")
25
+ return approvedTarget(target, cwd, ["file", "directory"]);
26
+ if (entry.role === "ref-or-path") {
27
+ if (isCredentialPath(target))
28
+ return false;
29
+ return !existsAtName(target) || approvedTarget(target, cwd, ["file", "directory"]);
30
+ }
31
+ return false;
32
+ }
33
+ /**
34
+ * Judges a shell command for the `auto` preset: every simple command must be a listed read-only
35
+ * program run in the project root, whose every word the program grammar classifies, and every
36
+ * path word must name an approvable project file or directory. Anything else asks.
37
+ */
38
+ export function judgeAutoCommand(command, cwd) {
39
+ const segments = splitShellSegments(command);
40
+ if (!segments || segments.length === 0)
41
+ return "ask";
42
+ for (const segment of segments) {
43
+ const [program, ...args] = segment;
44
+ if (!program || program.text.includes("/") || program.text.includes("="))
45
+ return "ask";
46
+ const classified = PROGRAM_RULES.get(program.text)?.(args);
47
+ if (classified === undefined || !classified.every((entry) => shellWordAllowed(entry, cwd)))
48
+ return "ask";
49
+ }
50
+ return "allow";
51
+ }
52
+ const stringPaths = (value) => {
53
+ if (value === undefined)
54
+ return ["."];
55
+ if (typeof value === "string")
56
+ return [value];
57
+ if (Array.isArray(value) && value.every((item) => typeof item === "string"))
58
+ return value;
59
+ return undefined;
60
+ };
61
+ /** The paths an `apply_patch` call will write, resolved by the patch tool's own parser and resolver. */
62
+ function patchTargets(input, cwd) {
63
+ const { input: patchText } = normalizeApplyPatchArguments(input);
64
+ if (!patchText)
65
+ return undefined;
66
+ try {
67
+ const hunks = parsePatch(patchText);
68
+ // A delete asks, the same as `rm` in the shell.
69
+ if (hunks.length === 0 || hunks.some((hunk) => hunk.type === "delete"))
70
+ return undefined;
71
+ return hunks.flatMap((hunk) => {
72
+ const targets = [resolvePatchPath(cwd, hunk.filePath)];
73
+ if (hunk.type === "update" && hunk.movePath)
74
+ targets.push(resolvePatchPath(cwd, hunk.movePath));
75
+ return targets;
76
+ });
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ /**
83
+ * The `auto` preset's allowlist (decision table on senpi#2614). Each decision is made on the exact
84
+ * target the tool will open, obtained from the tool's own resolver (`resolveReadPathAsync` for `read`,
85
+ * `resolveToCwd` for `write`, `edit`, `ls`, `find` and `grep`, `parsePatch` + `resolvePatchPath` for `apply_patch`).
86
+ * Every other call keeps the preset's ask.
87
+ */
88
+ export async function decideAuto(toolName, input, request, cwd) {
89
+ if (!isProjectSession(cwd))
90
+ return NO;
91
+ if (toolName === "bash" || (toolName === "monitor" && typeof input.command === "string")) {
92
+ if (request.permission !== "bash" || typeof input.command !== "string")
93
+ return NO;
94
+ return judgeAutoCommand(input.command, cwd) === "allow" ? YES : NO;
95
+ }
96
+ if (toolName === "read") {
97
+ const raw = input.path ?? input.file_path;
98
+ if (typeof raw !== "string")
99
+ return NO;
100
+ return approvedTarget(await resolveReadPathAsync(raw, cwd), cwd, ["file", "missing"]) ? YES : NO;
101
+ }
102
+ if (toolName === "grep") {
103
+ const raws = stringPaths(input.path);
104
+ if (raws?.length === 0)
105
+ return NO;
106
+ return raws?.every((raw) => approvedTarget(resolveToCwd(raw, cwd), cwd, ["file"])) ? YES : NO;
107
+ }
108
+ if (LIST_TOOLS.has(toolName)) {
109
+ const raw = input.path ?? ".";
110
+ if (typeof raw !== "string")
111
+ return NO;
112
+ return approvedTarget(resolveToCwd(raw || ".", cwd), cwd, ["directory"]) ? YES : NO;
113
+ }
114
+ if (WRITE_TOOLS.has(toolName)) {
115
+ const raw = input.path ?? input.file_path;
116
+ if (typeof raw !== "string")
117
+ return NO;
118
+ return approvedTarget(resolveToCwd(raw, cwd), cwd, ["file", "missing"]) ? YES : NO;
119
+ }
120
+ if (toolName === "apply_patch") {
121
+ const targets = patchTargets(input, cwd);
122
+ return targets?.every((target) => approvedTarget(target, cwd, ["file", "missing"])) ? YES : NO;
123
+ }
124
+ return NO;
125
+ }
126
+ //# sourceMappingURL=auto-policy.js.map
@@ -0,0 +1,6 @@
1
+ import { type ClassifiedWord } from "./auto-shell-grammar.ts";
2
+ import type { ShellWord } from "./auto-shell-segments.ts";
3
+ /** Classifies a command's words, or undefined when the program or any word is not understood. */
4
+ export type ProgramRule = (args: readonly ShellWord[]) => ClassifiedWord[] | undefined;
5
+ export declare const PROGRAM_RULES: ReadonlyMap<string, ProgramRule>;
6
+ //# sourceMappingURL=auto-program-rules.d.ts.map
@@ -0,0 +1,139 @@
1
+ import { classifyWords } from "./auto-shell-grammar.js";
2
+ const all = (role) => () => role;
3
+ const firstIs = (first, rest) => (index) => index === 0 ? first : rest;
4
+ const flags = (names, role = true) => Object.fromEntries(names.split(" ").map((name) => [name, role]));
5
+ const spec = (value) => (args) => classifyWords(args, value);
6
+ /**
7
+ * grep and rg: the first operand is the pattern unless a pattern came from `-e`/`--regexp`, in
8
+ * which case every operand is a file to read.
9
+ */
10
+ const searchSpec = (value) => (args) => {
11
+ const patternFromFlag = args.some((word) => word.text === "--regexp" || word.text.startsWith("--regexp=") || /^-[^-]*e/.test(word.text));
12
+ return classifyWords(args, patternFromFlag ? { ...value, operand: all("read-file"), minOperands: 1 } : value);
13
+ };
14
+ const READ_FILES = { flags: {}, operand: all("read-file"), minOperands: 1 };
15
+ const SPECS = [
16
+ ["pwd", spec({ flags: {}, operand: all("text"), maxOperands: 0 })],
17
+ ["true", spec({ flags: {}, operand: all("text"), maxOperands: 0 })],
18
+ ["echo", spec({ flags: flags("-n -e -E"), operand: all("text") })],
19
+ ["which", spec({ flags: flags("-a"), operand: all("text"), minOperands: 1 })],
20
+ [
21
+ "ls",
22
+ spec({
23
+ flags: flags("-a -A -l -h -1 -R -t -S -r -d -F -p --all --almost-all --human-readable --recursive"),
24
+ operand: all("list"),
25
+ }),
26
+ ],
27
+ ["cat", spec({ ...READ_FILES, flags: flags("-n -b -s -A -e -t -v") })],
28
+ ["head", spec({ ...READ_FILES, flags: { ...flags("-n -c --lines --bytes", "text"), ...flags("-q -v --quiet") } })],
29
+ ["tail", spec({ ...READ_FILES, flags: { ...flags("-n -c --lines --bytes", "text"), ...flags("-q -v --quiet") } })],
30
+ ["wc", spec({ ...READ_FILES, flags: flags("-l -w -c -m -L --lines --words --bytes --chars") })],
31
+ [
32
+ "diff",
33
+ spec({
34
+ ...READ_FILES,
35
+ flags: { ...flags("-u -q -s -w -b -B -i --brief"), ...flags("-U --unified", "text") },
36
+ minOperands: 2,
37
+ maxOperands: 2,
38
+ }),
39
+ ],
40
+ ["stat", spec({ flags: flags("-L"), operand: all("list"), minOperands: 1 })],
41
+ ["file", spec({ flags: flags("-b -i -L --brief --mime"), operand: all("read-file"), minOperands: 1 })],
42
+ [
43
+ "cut",
44
+ spec({
45
+ ...READ_FILES,
46
+ flags: {
47
+ ...flags("-d -f -c -b --delimiter --fields --characters --bytes", "text"),
48
+ ...flags("-s --only-delimited"),
49
+ },
50
+ }),
51
+ ],
52
+ [
53
+ "grep",
54
+ searchSpec({
55
+ flags: {
56
+ ...flags("-i -n -c -l -L -v -w -x -o -q -s -H -h -F -E --ignore-case --line-number --count --fixed-strings --extended-regexp --files-with-matches"),
57
+ ...flags("-e -m -A -B -C --regexp --max-count --after-context --before-context --context", "text"),
58
+ },
59
+ operand: firstIs("text", "read-file"),
60
+ minOperands: 2,
61
+ }),
62
+ ],
63
+ [
64
+ "rg",
65
+ searchSpec({
66
+ flags: {
67
+ ...flags("-i -n -c -l -v -w -x -o -F -S -N --ignore-case --line-number --count --fixed-strings --smart-case --files-with-matches --no-heading"),
68
+ ...flags("-e -m -A -B -C --regexp --max-count --after-context --before-context --context", "text"),
69
+ },
70
+ operand: firstIs("text", "read-file"),
71
+ minOperands: 2,
72
+ }),
73
+ ],
74
+ ];
75
+ const GIT_REF = /^[A-Za-z0-9_][A-Za-z0-9_.@-]*$/;
76
+ /**
77
+ * A git operand that is not shaped like a ref is a path; a ref-shaped one (`HEAD`, `main`, `notes`)
78
+ * is still a path when something exists at that name, which the judge checks.
79
+ */
80
+ const gitOperand = (_index, _count, text) => GIT_REF.test(text) && !text.includes("..") ? "ref-or-path" : "list";
81
+ const GIT_READ_SUBCOMMANDS = {
82
+ status: { flags: flags("-s -b -u --short --branch --porcelain --untracked-files"), operand: all("list") },
83
+ diff: {
84
+ flags: flags("--stat --cached --staged --name-only --name-status --numstat --no-color --color -w"),
85
+ operand: gitOperand,
86
+ },
87
+ log: {
88
+ flags: {
89
+ ...flags("--oneline --graph --decorate --stat --no-color --all --reverse --name-only --name-status"),
90
+ ...flags("-n --max-count --since --until --author --format --pretty", "text"),
91
+ },
92
+ operand: gitOperand,
93
+ },
94
+ "rev-parse": { flags: flags("--abbrev-ref --short --show-toplevel --verify"), operand: gitOperand },
95
+ "ls-files": {
96
+ flags: flags("-m -o -d -s --modified --others --deleted --stage --exclude-standard"),
97
+ operand: all("list"),
98
+ },
99
+ blame: {
100
+ flags: { ...flags("-w -s --porcelain"), ...flags("-L", "text") },
101
+ operand: all("read-file"),
102
+ minOperands: 1,
103
+ },
104
+ branch: {
105
+ flags: flags("-a -r -v -vv --all --remotes --verbose --list --show-current"),
106
+ operand: all("text"),
107
+ maxOperands: 0,
108
+ },
109
+ };
110
+ const SUMMARY_ONLY = new Set(["--stat", "--name-only", "--name-status", "--numstat"]);
111
+ /**
112
+ * Read-only git subcommands only; any global option (`-c`, `-C`, `--git-dir`, a pager) asks.
113
+ * `diff` prints file contents, which can include a tracked secret, so it passes only in a summary
114
+ * form; `show` is not listed (`git show <blob id>` prints any tracked file), no operand may name an
115
+ * object path or a full object id, and an operand that is not a plain ref is checked as a path.
116
+ */
117
+ const gitRule = (args) => {
118
+ const [sub, ...rest] = args;
119
+ if (sub === undefined)
120
+ return undefined;
121
+ const subSpec = GIT_READ_SUBCOMMANDS[sub.text];
122
+ // A flag's value (`-n 1000`) is not an object id; every other word is checked.
123
+ const isFlagValue = (index) => {
124
+ const previous = rest[index - 1]?.text;
125
+ return previous !== undefined && typeof subSpec?.flags[previous] === "string";
126
+ };
127
+ if (subSpec === undefined ||
128
+ rest.some((word, index) => word.text.includes(":") || (!isFlagValue(index) && /^[0-9a-f]{4,64}$/i.test(word.text)))) {
129
+ return undefined;
130
+ }
131
+ if (sub.text === "diff" && !rest.some((word) => SUMMARY_ONLY.has(word.text)))
132
+ return undefined;
133
+ return classifyWords(rest, subSpec);
134
+ };
135
+ export const PROGRAM_RULES = new Map([
136
+ ...SPECS,
137
+ ["git", gitRule],
138
+ ]);
139
+ //# sourceMappingURL=auto-program-rules.js.map
@@ -0,0 +1,29 @@
1
+ import type { ShellWord } from "./auto-shell-segments.ts";
2
+ /**
3
+ * What a word means to the program, so the judge knows how to check it: `read-file` is read from
4
+ * (must be a regular project file), `list` only has its name shown, `text` is never used as a path
5
+ * (a pattern, a count, a format), and `ref-or-path` is a git revision unless something exists at
6
+ * that name, in which case it is checked like `list` (git reads it as a path).
7
+ */
8
+ export type WordRole = "read-file" | "list" | "text" | "ref-or-path";
9
+ export interface ClassifiedWord {
10
+ readonly role: WordRole;
11
+ readonly word: ShellWord;
12
+ }
13
+ export interface ProgramSpec {
14
+ /** Every flag the program may take; `true` = no value, a role = takes one value. */
15
+ readonly flags: Readonly<Record<string, true | WordRole>>;
16
+ /** The role of operand `index` out of `count` operands, given its text. */
17
+ readonly operand: (index: number, count: number, text: string) => WordRole;
18
+ readonly minOperands?: number;
19
+ readonly maxOperands?: number;
20
+ }
21
+ /**
22
+ * Classifies every word of one simple command against `spec`, or returns undefined when any word
23
+ * is not understood: an unknown flag (short or long), a flag in a form the spec does not list, a
24
+ * value-taking flag with no value, or an operand count out of range. Short flags may be
25
+ * clustered (`-la`), and a value-taking short flag takes the rest of its word as the value
26
+ * (`-o/x`, `-ro/x`) or else the next word; a long flag takes `--name=value` or `--name value`.
27
+ */
28
+ export declare function classifyWords(words: readonly ShellWord[], spec: ProgramSpec): ClassifiedWord[] | undefined;
29
+ //# sourceMappingURL=auto-shell-grammar.d.ts.map