@mortz.prk/opencode-auto-mode 1.0.2 → 1.1.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.
package/src/tiers.ts CHANGED
@@ -1,12 +1,267 @@
1
1
  import * as path from "node:path";
2
2
  import { lstatSync, realpathSync } from "node:fs";
3
- import { classifyShell } from "./shell.js";
3
+ import { homedir } from "node:os";
4
+ import { isDataOption } from "./shell-policy.js";
5
+ import { getWorktreeScope } from "./worktree.js";
4
6
 
5
- export type Tier = "auto-allow" | "auto-deny" | "review" | "unknown";
6
- export interface Classification { tier: Tier; reason: string }
7
- export interface ClassifyOptions { projectDir?: string; allowInProjectEdits?: boolean }
7
+ /** Compatibility helper; invalid locations cannot establish a verified file scope. */
8
+ export function projectRoot(location: string): string {
9
+ try { return getWorktreeScope(location).worktreeRoot; }
10
+ catch { return path.resolve(location); }
11
+ }
12
+
13
+ /**
14
+ * File extensions that name source or documentation rather than a credential
15
+ * store. A module such as `src/tokens.ts` is ordinary source, and treating a
16
+ * keyword plus one of these extensions as a secret name turns every source
17
+ * search or file read into a hard deny.
18
+ */
19
+ const SOURCE_EXTENSION = String.raw`(?:[cm]?[jt]sx?|py|pyi|rb|go|rs|java|kt|kts|cs|php|swift|scala|sh|bash|zsh|fish|lua|pl|pm|vue|svelte|astro|html?|css|scss|sass|less|sql|mdx?)`;
20
+
21
+ /**
22
+ * Filename-shaped sensitive names.
23
+ *
24
+ * These spellings are unambiguous file references, so they apply to a bare shell
25
+ * word as well as to a path: a dotfile, a key file, or a name that carries a
26
+ * credential keyword and an extension. A credential keyword followed by a source
27
+ * extension is excluded so `tokens.ts` stays a module name.
28
+ */
29
+ const SENSITIVE_NAME = new RegExp([
30
+ // Repository, tool, editor, CI and agent configuration directories.
31
+ String.raw`(?:^|[/\\])\.(?:git|opencode|ssh|aws|gnupg|kube|docker|config|github|vscode|husky|devcontainer|idea|gitlab-ci\.yml|git-credentials|password-store|netrc|npmrc|pypirc|env[^/\\]*)(?:[/\\]|$)`,
32
+ // Credential stores and key material that are not hidden directories.
33
+ String.raw`(?:^|[/\\])(?:credentials?\.[^/\\]+|opencode\.json[^/\\]*|auth\.(?:json|ya?ml))(?:[/\\]|$)`,
34
+ String.raw`(?:^|[/\\])id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?(?:[/\\]|$)`,
35
+ String.raw`(?:^|[/\\])[^/\\]+\.(?:pem|key|p12|pfx|jks|keystore)(?:[/\\]|$)`,
36
+ // Files whose own name declares a credential, token, secret or password
37
+ // payload. The keyword must start the component or follow a separator, so a
38
+ // source file such as `tokenizer.ts` is not swept in, and a source extension
39
+ // disqualifies the name.
40
+ String.raw`(?:^|[/\\])(?![^/\\]*\.` + SOURCE_EXTENSION + String.raw`(?:[/\\]|$))(?:[^/\\]*[._-])?(?:credential|token|secret|password|passwd|private[_ -]?key)s?(?![a-z])[^/\\]*\.[^/\\]+(?:[/\\]|$)`,
41
+ // Infrastructure state and cloud service-account key files.
42
+ String.raw`(?:^|[/\\])(?:[^/\\]*[._-])?terraform\.tfstate[^/\\]*(?:[/\\]|$)`,
43
+ String.raw`(?:^|[/\\])(?:[^/\\]*[._-])?service[-_ ]?accounts?\.json(?:[/\\]|$)`,
44
+ ].join("|"), "i");
45
+
46
+ /**
47
+ * Bare credential keywords. A word that is only a keyword is as likely to be a
48
+ * search pattern, a package name or a commit message as a file, so shell callers
49
+ * only apply these to a word that can name a path or to a redirect target.
50
+ */
51
+ const SENSITIVE_KEYWORD = new RegExp([
52
+ String.raw`(?:^|[/\\])(?:credentials?|kubeconfig|auth)(?:[/\\]|$)`,
53
+ String.raw`(?:^|[/\\])(?:tokens?|secrets?|passwords?|passwd|private[ _-]?keys?|[^/\\]*[._-](?:credential|token|secret|password|passwd|private[_ -]?key)s?)(?:[/\\]|$)`,
54
+ ].join("|"), "i");
55
+
56
+ /**
57
+ * Explicit sensitive names, checked both on the spelling supplied by the caller
58
+ * and on each component of the resolved path. This is the narrow set that also
59
+ * applies after resolution, so a project checked out below an unrelated dotted
60
+ * ancestor (for example `~/.local/share/project`) stays usable.
61
+ */
62
+ export const SENSITIVE_PATH = new RegExp([SENSITIVE_NAME.source, SENSITIVE_KEYWORD.source].join("|"), "i");
63
+
64
+ /**
65
+ * Broad lexical protection: any dot-prefixed path component supplied by the
66
+ * caller is sensitive, including dotfiles and dot-directories this module does
67
+ * not name explicitly. `.` and `..` are traversal, not names, and are left to
68
+ * the containment checks.
69
+ */
70
+ const DOT_COMPONENT = /(?:^|[/\\])\.(?!\.?(?:[/\\]|$))[^/\\]+(?:[/\\]|$)/;
71
+
72
+ /** Filesystem errors that mean a component cannot exist, so nothing is hidden there. */
73
+ const ABSENT_COMPONENT = ["ENOENT", "ENOTDIR", "ENAMETOOLONG"];
74
+
75
+ /** Lexical and canonical spellings of the project root a target is compared to. */
76
+ function projectRootSpellings(projectDir?: string): string[] {
77
+ const directory = path.resolve(projectDir ?? process.cwd());
78
+ const roots = [directory];
79
+ try {
80
+ const real = realpathSync(directory);
81
+ if (!roots.includes(real)) roots.push(real);
82
+ } catch { /* Unverifiable root keeps the lexical spelling only. */ }
83
+ return roots;
84
+ }
85
+
86
+ function protectionSpelling(candidate: string, includeGit?: boolean): string {
87
+ // Metadata needs model review, not a credential deny. Do not normalize here:
88
+ // sensitive descendants and resolved symlink destinations must still count.
89
+ return includeGit === false ? candidate.replace(/(^|[/\\])\.git(?=[/\\]|$)/gi, "$1") : candidate;
90
+ }
8
91
 
9
- const SENSITIVE_PATH = /(?:^|[/\\])(?:\.[^/\\]+|opencode\.jsonc?|id_(?:rsa|dsa|ecdsa|ed25519)|[^/\\]+\.(?:pem|key|p12|pfx)|[^/\\]*(?:credentials|token|secret|password|private.key)[^/\\]*)(?:[/\\]|$)/i;
92
+ /**
93
+ * Sensitive spelling of one candidate.
94
+ *
95
+ * DOT_COMPONENT is applied to the path relative to the project root for an
96
+ * absolute target inside it, so an unrelated dotted ancestor such as
97
+ * `~/.local/share/project` does not mark every file below it. SENSITIVE_PATH is
98
+ * still applied to the whole candidate and, in `isProtected`, to every resolved
99
+ * component: a real `.ssh` or `.config` directory stays protected.
100
+ */
101
+ function sensitiveSpelling(candidate: string, roots: readonly string[], includeGit?: boolean): boolean {
102
+ if (SENSITIVE_PATH.test(protectionSpelling(candidate, includeGit))) return true;
103
+ if (path.isAbsolute(candidate)) {
104
+ for (const root of roots) {
105
+ if (isInProject(candidate, root)) return DOT_COMPONENT.test(protectionSpelling(path.relative(root, path.resolve(candidate)), includeGit));
106
+ }
107
+ }
108
+ return DOT_COMPONENT.test(protectionSpelling(candidate, includeGit));
109
+ }
110
+
111
+ /** Inspect components before normalization can erase an alias followed by `..`.
112
+ * Absolute paths also start at their filesystem root, not at the location base.
113
+ */
114
+ export function hasSymlinkTraversal(target: string, locationDir = process.cwd()): boolean {
115
+ let current: string;
116
+ try { current = path.isAbsolute(target) ? path.parse(target).root : realpathSync(locationDir); }
117
+ catch { current = path.resolve(locationDir); }
118
+ for (const part of target.split(path.sep).filter(Boolean)) {
119
+ if (part === ".") continue;
120
+ if (part === "..") { current = path.dirname(current); continue; }
121
+ current = path.join(current, part);
122
+ try {
123
+ if (lstatSync(current).isSymbolicLink()) return true;
124
+ } catch (error) {
125
+ // ENAMETOOLONG means the name cannot exist at all, so it hides no alias.
126
+ if (!ABSENT_COMPONENT.includes((error as NodeJS.ErrnoException).code ?? "")) return true;
127
+ }
128
+ }
129
+ return false;
130
+ }
131
+
132
+ /** Check lexical and resolved components. Excluding Git only removes the metadata
133
+ * marker; credentials inside it and symlink/.. traversal remain protected. */
134
+ export function isProtected(target: string, projectDir?: string, includeGit?: boolean): boolean {
135
+ const roots = projectRootSpellings(projectDir);
136
+ const candidates = [target, ...target.split("="), ...target.split(":")];
137
+ if (candidates.some((candidate) => sensitiveSpelling(candidate, roots, includeGit) ||
138
+ /^\/proc\/(?:self|thread-self|\d+)\/(?:environ|mem)(?:$|\/)/.test(candidate))) return true;
139
+ let current: string;
140
+ try { current = path.isAbsolute(target) ? path.parse(target).root : realpathSync(projectDir ?? process.cwd()); }
141
+ catch { return hasSymlinkTraversal(target, path.resolve(projectDir ?? process.cwd())); }
142
+ const protectedPath = (candidate: string) => SENSITIVE_PATH.test(protectionSpelling(candidate, includeGit)) ||
143
+ /^\/proc\/(?:self|thread-self|\d+)\/(?:environ|mem)(?:$|\/)/.test(candidate);
144
+ if (protectedPath(current)) return true;
145
+ for (const part of target.split(path.sep).filter(Boolean)) {
146
+ if (part === ".") continue;
147
+ current = part === ".." ? path.dirname(current) : path.join(current, part);
148
+ try { current = realpathSync(current); }
149
+ catch (error) {
150
+ // Missing new files are expected; broken aliases and other ambiguous
151
+ // filesystem errors cannot establish that a protected target is absent.
152
+ // ENAMETOOLONG is not ambiguous: no such name can exist.
153
+ try { if (lstatSync(current).isSymbolicLink()) return true; } catch { /* Missing component. */ }
154
+ if (!ABSENT_COMPONENT.includes((error as NodeJS.ErrnoException).code ?? "")) return true;
155
+ }
156
+ if (protectedPath(current)) return true;
157
+ }
158
+ return false;
159
+ }
160
+
161
+ /** A scheme-qualified URL names a remote resource, never a local path. */
162
+ const SCHEME_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
163
+ /** `user@host:path` is a remote Git/SCP location rather than a local path. */
164
+ const SCP_URL = /^[^/\\@:]+@[^/\\:]+:/;
165
+ /** `file:` URLs are local reads; every other scheme is remote. */
166
+ const FILE_URL = /^file:(?:\/\/)?([\s\S]*)$/i;
167
+
168
+ /** Expands the leading home spellings this module can decode without a shell. */
169
+ function expandHome(word: string): string {
170
+ if (word === "~") return homedir();
171
+ if (word.startsWith("~/")) return `${homedir()}${word.slice(1)}`;
172
+ return word.replace(/^\$(?:HOME(?![A-Za-z0-9_])|\{HOME\})/, homedir());
173
+ }
174
+
175
+ /**
176
+ * The local path a `file:` URL names, or undefined for any other spelling.
177
+ *
178
+ * A URL is not a shell word, so the shell never expands `~`, `$HOME` or
179
+ * `${HOME}` inside it; they are expanded lexically here. A URL authority that
180
+ * is a host (`localhost`, an address, a dotted name) is not part of the path,
181
+ * while a non-host first segment stays in it. Query and fragment are dropped.
182
+ */
183
+ function fileUrlPath(word: string): string | undefined {
184
+ const match = FILE_URL.exec(word);
185
+ if (match === null) return undefined;
186
+ const rest = expandHome(match[1].split(/[?#]/, 1)[0]);
187
+ if (rest.startsWith("/")) return rest;
188
+ const slash = rest.indexOf("/");
189
+ const authority = slash === -1 ? rest : rest.slice(0, slash);
190
+ if (slash !== -1 && /^(?:localhost|\[[0-9a-fA-F:.]+\]|[^/]*\.[^/]*)$/.test(authority)) return rest.slice(slash);
191
+ return `/${rest}`;
192
+ }
193
+
194
+ /**
195
+ * The value an option-shaped path operand carries (`-fFILE`, `--file=FILE`).
196
+ * A plain word is returned unchanged, including a bare `-` or `--`.
197
+ */
198
+ function operandValue(word: string): string {
199
+ if (word.length <= 1 || !word.startsWith("-")) return word;
200
+ const separator = word.indexOf("=");
201
+ if (separator !== -1) return word.slice(separator + 1);
202
+ return word.startsWith("--") ? word : word.slice(2);
203
+ }
204
+
205
+ /**
206
+ * Protected-path check for an operand a command consumes as a file.
207
+ *
208
+ * The command's own grammar has already established that the word names a file
209
+ * (`cat credentials`), so the bare sensitive names count here even though they
210
+ * are only data in a pattern position. A word with no separator cannot be more
211
+ * than one component, so only the explicit names apply: a leading dot is not a
212
+ * reason to deny `.name`, `.gitignore` or `.cache`. Paths with a separator keep
213
+ * the full check, including resolved components, symlinks and `file:` URLs.
214
+ */
215
+ export function isProtectedPathOperand(target: string, projectDir?: string, includeGit?: boolean): boolean {
216
+ if (!target) return false;
217
+ const word = operandValue(target);
218
+ const local = fileUrlPath(word);
219
+ if (local !== undefined) return isProtected(local, projectDir, includeGit);
220
+ if (SCHEME_URL.test(word) || SCP_URL.test(word)) return false;
221
+ return /[/\\]/.test(word) ? isProtected(word, projectDir, includeGit) : SENSITIVE_PATH.test(protectionSpelling(word, includeGit));
222
+ }
223
+
224
+ /**
225
+ * Protected-path check for one parsed shell word that the command grammar did
226
+ * not place in a path or data slot.
227
+ *
228
+ * A shell word is often data — a search pattern (`grep -r auth`), a package name
229
+ * (`npm view auth`), a commit message, or a URL — so bare keywords only count
230
+ * when the word can name a path: it contains a separator or carries a value in
231
+ * an option. A separator-less word is matched against the explicit names only,
232
+ * so `jq .name`, `git add .gitignore` and `echo .foo` stay ordinary words while
233
+ * `.env`, `.npmrc` and `auth.json` stay protected. Filename-shaped names
234
+ * (`signing.pem`) stay protected everywhere, and redirect targets are checked
235
+ * with the full `isProtected` because a redirection always names a file.
236
+ */
237
+ export function isProtectedShellWord(target: string, projectDir?: string, includeGit?: boolean): boolean {
238
+ if (!target) return false;
239
+ const separator = target.startsWith("-") ? target.indexOf("=") : -1;
240
+ // `--grep=config/auth`, `--format=src/.env` and `--exclude=.env` carry
241
+ // pattern/format data even when the value is spelled like a path.
242
+ if (separator !== -1 && isDataOption(target.slice(0, separator))) return false;
243
+ const local = fileUrlPath(target);
244
+ if (local !== undefined) return isProtected(local, projectDir, includeGit);
245
+ if (SCHEME_URL.test(target) || SCP_URL.test(target)) return false;
246
+ const candidates = [target, ...target.split("="), ...target.split(":")];
247
+ // A `file:` value is a local path even when it arrives through an option.
248
+ const files = candidates.map(fileUrlPath).filter((candidate): candidate is string => candidate !== undefined);
249
+ if (files.length) return files.some((file) => isProtected(file, projectDir, includeGit));
250
+ // Any other scheme or an SCP-style location is remote, never a local path.
251
+ if (candidates.some((candidate) => SCHEME_URL.test(candidate) || SCP_URL.test(candidate))) return false;
252
+ if (target.startsWith("-")) {
253
+ if (separator === -1) return false;
254
+ // `--config=credentials.json` and `--file=.env` name files.
255
+ return isProtectedPathOperand(target.slice(separator + 1), projectDir, includeGit);
256
+ }
257
+ // `git show HEAD:.env` names a file after the colon; schemes and SCP
258
+ // locations were already excluded above.
259
+ if (!target.includes("=") && target.split(":").slice(1).some((part) => isProtectedPathOperand(part, projectDir, includeGit)))
260
+ return true;
261
+ // A separator-less word is only a file reference when it carries a
262
+ // filename-shaped name; a bare keyword such as `auth` stays data here.
263
+ return /[/\\]/.test(target) ? isProtected(target, projectDir, includeGit) : SENSITIVE_NAME.test(protectionSpelling(target, includeGit));
264
+ }
10
265
 
11
266
  export function isInProject(target: string, projectDir: string): boolean {
12
267
  const resolved = path.resolve(projectDir, target);
@@ -14,11 +269,39 @@ export function isInProject(target: string, projectDir: string): boolean {
14
269
  return resolved === root || resolved.startsWith(`${root}${path.sep}`);
15
270
  }
16
271
 
17
- function safeTarget(target: string, projectDir: string): boolean {
272
+ /**
273
+ * Lexical containment for low-band read operands. Tildes, parameter expansion
274
+ * and NUL cannot be resolved to a project path here, so they are never low;
275
+ * callers keep the protected-path and symlink checks separate. Both the spelling
276
+ * supplied by the caller and the canonical path are accepted, because a scope
277
+ * discovered with `realpath` may differ from a caller's `/var` vs `/private/var`
278
+ * spelling on macOS.
279
+ */
280
+ export function isScopedPath(target: string, locationDir: string, worktreeRoot: string): boolean {
281
+ if (!target || target.startsWith("~") || /[$`\0]/.test(target)) return false;
282
+ const directory = path.resolve(locationDir);
283
+ const root = path.resolve(worktreeRoot);
284
+ if (isInProject(directory, root) && isInProject(path.resolve(directory, target), root)) return true;
285
+ try {
286
+ const canonicalDirectory = realpathSync(directory);
287
+ const canonicalRoot = realpathSync(root);
288
+ if (!isInProject(canonicalDirectory, canonicalRoot)) return false;
289
+ const resolved = path.resolve(canonicalDirectory, target);
290
+ let canonical = resolved;
291
+ try { canonical = realpathSync(resolved); } catch { /* New files keep the resolved spelling. */ }
292
+ return isInProject(resolved, canonicalRoot) || isInProject(canonical, canonicalRoot);
293
+ } catch { return false; }
294
+ }
295
+
296
+ export function safeTarget(target: string, locationDir: string, worktreeRoot = projectRoot(locationDir)): boolean {
297
+ if (target.startsWith("~") || /[$`\0]/.test(target)) return false;
18
298
  try {
19
- const root = realpathSync(projectDir);
20
- const resolved = path.resolve(root, target);
21
- if (!isInProject(resolved, root) || SENSITIVE_PATH.test(resolved)) return false;
299
+ const root = realpathSync(worktreeRoot);
300
+ const directory = realpathSync(locationDir);
301
+ if (!lstatSync(root).isDirectory() || !lstatSync(directory).isDirectory() || !isInProject(directory, root)) return false;
302
+ if (hasSymlinkTraversal(target, directory)) return false;
303
+ const resolved = path.resolve(directory, target);
304
+ if (!isInProject(resolved, root) || isProtected(target, directory)) return false;
22
305
  const parts = path.relative(root, resolved).split(path.sep);
23
306
  let current = root;
24
307
  for (const part of parts) {
@@ -33,13 +316,3 @@ function safeTarget(target: string, projectDir: string): boolean {
33
316
  return true;
34
317
  } catch { return false; }
35
318
  }
36
-
37
- export function classify(action: string, resources: readonly string[], options?: ClassifyOptions): Classification {
38
- if (action === "shell") return classifyShell(resources);
39
- if (action === "read" || ((action === "edit" || action === "write") && options?.allowInProjectEdits)) {
40
- if (options?.projectDir && resources.length > 0 && resources.every((target) =>
41
- target.length > 0 && safeTarget(target, options.projectDir!)))
42
- return { tier: "auto-allow", reason: "non-sensitive in-project file operation" };
43
- }
44
- return { tier: "review", reason: `action '${action}' needs review` };
45
- }
package/src/tui.tsx CHANGED
@@ -1,48 +1,58 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { createSignal, Show } from "solid-js";
2
+ import { createEffect, createSignal, Show } from "solid-js";
3
3
  import { Plugin } from "@opencode/plugin/tui";
4
- import { readConfigError, readDecisions } from "./state.js";
5
- import { isReviewing } from "./tui-state.js";
4
+ import { AutoMode, type AutoModeStatus } from "./rpc.js";
5
+ import { createDecisionNotifier, createDecisionSubscription, createStatusLoader, pillDescription, pillLabel, pillTone, statusLocationKey, type StatusTarget } from "./gui-state.js";
6
6
 
7
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ const POLL_MS = 1_000;
8
8
 
9
9
  export default Plugin.define({
10
10
  id: "opencode-auto-mode",
11
- setup: async (ctx) => {
11
+ setup: (ctx) => {
12
12
  if (ctx.options.tui === false) return;
13
- const [reviewing, setReviewing] = createSignal(false);
14
- const [configError, setConfigError] = createSignal(false);
15
- let announced = false;
16
- const update = () => {
13
+ const [status, setStatus] = createSignal<AutoModeStatus | undefined>(undefined);
14
+ const refresh = createStatusLoader(
15
+ (target, signal) => ctx.client.rpc(AutoMode).status({ sessionID: target.sessionID }, {
16
+ location: target.location, signal,
17
+ }) as Promise<AutoModeStatus | undefined>,
18
+ setStatus,
19
+ );
20
+ const target = (): StatusTarget | undefined => {
17
21
  const route = ctx.ui.router.current();
18
- setReviewing(isReviewing(readDecisions(), route.type === "session" ? route.sessionID : undefined));
19
- const error = readConfigError(ctx.location?.directory);
20
- setConfigError(error !== undefined);
21
- if (error === undefined) announced = false;
22
- else if (!announced) {
23
- announced = true;
24
- ctx.ui.toast.show({
25
- variant: "error", title: "auto-mode", duration: 10_000,
26
- message: "Invalid configuration; automatic approval is disabled.",
27
- });
28
- }
22
+ const location = ctx.location ?? ctx.data.location.default();
23
+ return route.type === "session" && location !== undefined
24
+ ? { key: statusLocationKey(route.sessionID, location), sessionID: route.sessionID, ownServer: true, location }
25
+ : undefined;
29
26
  };
30
- update();
31
- const poll = setInterval(update, 250);
27
+ const receive = createDecisionNotifier(target, (notice) => {
28
+ ctx.ui.toast.show({ title: "Auto-mode", message: notice.message,
29
+ variant: notice.kind === "deny" ? "error" : "warning", duration: 5_000 });
30
+ });
31
+ const subscription = createDecisionSubscription(receive);
32
+ const update = () => {
33
+ const client = ctx.client;
34
+ subscription.update(client, (handler, signal) => client.rpc(AutoMode).events.on("decision", handler, { signal }));
35
+ void refresh(target());
36
+ };
37
+ const poll = setInterval(update, POLL_MS);
32
38
  const dispose = ctx.ui.slot({
33
- append: "app",
34
- render: () => (
35
- <box position="absolute" top={0} right={0} zIndex={10_000} flexDirection="row" gap={1}>
36
- <Show when={configError()}>
37
- <text fg={ctx.theme.text.feedback.error.base}>auto-mode: invalid config</text>
38
- </Show>
39
- <Show when={reviewing()}>
40
- <spinner frames={SPINNER_FRAMES} interval={120} color={ctx.theme.text.feedback.warning.base} />
41
- <text fg={ctx.theme.text.feedback.warning.base}>auto-mode reviewing</text>
39
+ append: "prompt.footer.status",
40
+ render: () => {
41
+ // Router changes refresh immediately, independently of the timer.
42
+ createEffect(update);
43
+ const color = () => {
44
+ const tone = pillTone(status());
45
+ if (tone === "danger") return ctx.theme.text.feedback.error.base;
46
+ if (tone === "warning") return ctx.theme.text.feedback.warning.base;
47
+ return ctx.theme.text.base;
48
+ };
49
+ return (
50
+ <Show when={pillLabel(status())}>
51
+ <text fg={color()}>auto-mode: {pillDescription(status()) ?? pillLabel(status())}</text>
42
52
  </Show>
43
- </box>
44
- ),
53
+ );
54
+ },
45
55
  });
46
- return () => { clearInterval(poll); dispose(); };
56
+ return () => { clearInterval(poll); refresh.dispose(); subscription.dispose(); dispose(); };
47
57
  },
48
58
  });
@@ -0,0 +1,75 @@
1
+ import { readFileSync, realpathSync, lstatSync, openSync, fstatSync, closeSync, constants } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import { isInProject, projectRoot } from "./tiers.js";
4
+
5
+ /** Strip JSONC comments and trailing commas without changing quoted strings. */
6
+ export function parseJSONC(input: string): unknown {
7
+ if (Buffer.byteLength(input) > 1_048_576) throw new Error("JSONC exceeds size limit");
8
+ let output = "";
9
+ let quoted = false;
10
+ for (let i = 0; i < input.length; i++) {
11
+ const c = input[i];
12
+ if (quoted) {
13
+ output += c;
14
+ if (c === "\\") output += input[++i] ?? "";
15
+ else if (c === '"') quoted = false;
16
+ } else if (c === '"') { quoted = true; output += c; }
17
+ else if (c === "/" && input[i + 1] === "/") {
18
+ while (i < input.length && input[i] !== "\n") i++;
19
+ output += "\n";
20
+ } else if (c === "/" && input[i + 1] === "*") {
21
+ const end = input.indexOf("*/", i + 2);
22
+ if (end === -1) throw new Error("Unclosed block comment");
23
+ output += " "; i = end + 1;
24
+ } else output += c;
25
+ }
26
+ let clean = "";
27
+ quoted = false;
28
+ for (let i = 0; i < output.length; i++) {
29
+ const c = output[i];
30
+ if (quoted) {
31
+ clean += c;
32
+ if (c === "\\") clean += output[++i] ?? "";
33
+ else if (c === '"') quoted = false;
34
+ } else {
35
+ if (c === '"') quoted = true;
36
+ if (c === "," && /^[\s]*[}\]]/.test(output.slice(i + 1))) continue;
37
+ clean += c;
38
+ }
39
+ }
40
+ return JSON.parse(clean.replace(/^\uFEFF/, "")) as unknown;
41
+ }
42
+ /** Read policy data without following symlinks or trusting writable parents. */
43
+ export function readUserOwnedFile(filename: string, projectDir?: string, maxBytes = 1_048_576): string {
44
+ if (!isAbsolute(filename)) throw new Error("Policy file path must be absolute");
45
+ const lexical = resolve(filename);
46
+ const canonical = realpathSync(lexical);
47
+ const root = projectRoot(projectDir ?? process.cwd());
48
+ let canonicalRoot = root;
49
+ try { canonicalRoot = realpathSync(root); } catch { /* still enforce lexical root */ }
50
+ if (isInProject(lexical, root) || isInProject(canonical, canonicalRoot))
51
+ throw new Error("Policy files must be user-owned outside the repository");
52
+ const uid = process.getuid?.();
53
+ let current = lexical;
54
+ while (true) {
55
+ const stat = lstatSync(current);
56
+ if (stat.isSymbolicLink()) throw new Error("Policy paths must not contain symlinks");
57
+ if ((stat.mode & 0o022) !== 0 && !(stat.isDirectory() && (stat.mode & 0o1000) !== 0))
58
+ throw new Error("Policy paths must not be writable by other users");
59
+ if (uid !== undefined && stat.uid !== uid && stat.uid !== 0)
60
+ throw new Error("Policy paths must be user-owned or system-owned parents");
61
+ const parent = resolve(current, "..");
62
+ if (parent === current) break;
63
+ current = parent;
64
+ }
65
+ const descriptor = openSync(lexical, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
66
+ try {
67
+ const stat = fstatSync(descriptor);
68
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > maxBytes ||
69
+ (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o022) !== 0)
70
+ throw new Error("Policy file must be user-owned, single-linked and not writable by other users");
71
+ const input = readFileSync(descriptor, "utf8");
72
+ if (Buffer.byteLength(input) > maxBytes) throw new Error("Policy file exceeds size limit");
73
+ return input;
74
+ } finally { closeSync(descriptor); }
75
+ }
@@ -0,0 +1,30 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ export interface WorktreeScope {
5
+ /** Canonical location directory: the base for relative request resources. */
6
+ directory: string;
7
+ /** Nearest Git working tree, not the shared repository of a linked worktree. */
8
+ worktreeRoot: string;
9
+ }
10
+
11
+ /** Stateless discovery: a location move needs no permission mutation or cache invalidation.
12
+ * Relative native grants follow the location; persisted user grants remain untouched.
13
+ * Throws for a missing or non-directory location rather than inventing a scope.
14
+ */
15
+ export function getWorktreeScope(location: string): WorktreeScope {
16
+ const directory = realpathSync(resolve(location));
17
+ if (!statSync(directory).isDirectory()) throw new Error("Worktree location must be a directory");
18
+ let current = directory;
19
+ while (true) {
20
+ try {
21
+ const marker = statSync(join(current, ".git"));
22
+ if (marker.isFile() || marker.isDirectory()) return { directory, worktreeRoot: realpathSync(current) };
23
+ } catch (error) {
24
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
25
+ }
26
+ const parent = dirname(current);
27
+ if (parent === current) return { directory, worktreeRoot: directory };
28
+ current = parent;
29
+ }
30
+ }