@mortz.prk/opencode-auto-mode 1.1.1 → 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/README.llm.md CHANGED
@@ -178,9 +178,15 @@ are bounded by `reviewTimeoutMs`.
178
178
  Built-in guards are not a policy language and are not user-extensible. They cover
179
179
  protected repository/config paths, credential paths, risky shell structure,
180
180
  recursive external deletion, history changes, symlink traversal, project scope,
181
- and ambiguous syntax. Protected shell operands are a hard deny; protected
182
- `read`/`edit` targets and symlink traversal have a mandatory ask floor. `glob` and
183
- `grep` scope is not verified, so static review abstains. `webfetch`, `websearch`,
181
+ and ambiguous syntax. Credential/config shell operands are a hard deny; their
182
+ `read`/`edit` targets and symlink traversal have a mandatory ask floor. `.git`
183
+ metadata access, Git global options, and complex/dynamic POSIX shell syntax stay
184
+ HIGH and reach configured decision/LLM stages; static review abstains instead of
185
+ forcing a user prompt. Visible blocked operations still win, and malformed or
186
+ oversized requests remain bounded. Windows shell dialects retain a mandatory
187
+ ask floor. Loading a `skill` is LOW because it only loads instructions; subsequent
188
+ tool calls are checked independently. `glob` and `grep` scope is not verified,
189
+ so static review abstains. `webfetch`, `websearch`,
184
190
  `execute`, and unknown tools remain HIGH.
185
191
 
186
192
  Code Mode is bridged in `execute.before` through `ctx.permission.assert`.
package/README.md CHANGED
@@ -122,7 +122,13 @@ Stage shapes:
122
122
  are re-reviewed but never deleted or rewritten.
123
123
  3. Built-in guards run first: protected paths and credentials, risky shell
124
124
  structure, recursive external deletion, history changes, and ambiguous syntax.
125
- These guards are not user-extensible.
125
+ These guards are not user-extensible. Git metadata (`.git`), Git global
126
+ options, and complex POSIX shell syntax are left to the configured decision/LLM
127
+ stages, not automatically denied or forced to a user prompt. Credential
128
+ protections and visible blocked operations still take precedence. Unsupported
129
+ Windows shell dialects still require manual approval. Loading a skill is
130
+ low-risk: it loads instructions, not executable code; later tool calls are
131
+ checked separately.
126
132
  4. Configured classifiers run in array order. A static stage may resolve low-risk
127
133
  work; a decision stage uses OpenCode's native decision API; an LLM stage uses
128
134
  its explicit reviewer. The first decisive allow or deny stops the chain.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mortz.prk/opencode-auto-mode",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Configurable automatic permission review for OpenCode v2.",
5
5
  "author": {
6
6
  "name": "Andrew Mellen",
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@ const INTERRUPTED_REASON = "Auto-mode review was interrupted; please decide your
22
22
  const PAUSED_REASON = "Auto mode is paused; please decide yourself";
23
23
  const INVALID_CONFIG_ACTION = "Auto-mode review is disabled because its configuration is invalid";
24
24
  const NATIVE_DENY_REASON = "Native permission deny";
25
- const REVIEW_ACTIONS = /^(?:read|edit|write|glob|grep|shell|execute|webfetch|websearch|question|todowrite)$/;
25
+ const REVIEW_ACTIONS = /^(?:read|edit|write|glob|grep|shell|execute|webfetch|websearch|question|todowrite|skill)$/;
26
26
  const SAFER_ALTERNATIVE = "Continue with a safer alternative.";
27
27
  function denialMessage(reason: string): string {
28
28
  const trimmed = reason.trim().replace(/[\s.]+$/, "");
@@ -9,12 +9,14 @@ export function isFileAction(action: string): boolean {
9
9
  return FILE_ACTIONS.has(action);
10
10
  }
11
11
 
12
- /** Protected paths and symlink traversal need human approval, never a hard deny. */
12
+ /** Credentials/config and symlinks ask; Git metadata goes to model review. */
13
13
  function pathApproval(resources: readonly string[], projectDir?: string): RequestScore | undefined {
14
- if (resources.some((target) => isProtected(target, projectDir)))
14
+ if (resources.some((target) => isProtected(target, projectDir, false)))
15
15
  return ask("Protected path access requires human approval");
16
16
  if (resources.some((target) => hasSymlinkTraversal(target, projectDir)))
17
17
  return ask("Symlink traversal requires human approval");
18
+ if (resources.some((target) => isProtected(target, projectDir)))
19
+ return high("Git metadata access requires further review");
18
20
  return undefined;
19
21
  }
20
22
 
@@ -27,6 +27,7 @@ export function scoreRequest(request: PermissionRequest & { worktreeRoot?: strin
27
27
  // No host allowlist exists, so a URL/query name cannot bound what is fetched.
28
28
  if (NETWORK_ACTIONS.has(action)) return high("Network fetch/search has no verified host allowlist");
29
29
  if (INTERACTIVE_ACTIONS.has(action)) return low("User interaction without host side effects");
30
+ if (action === "skill") return low("Loading skill instructions without executing them");
30
31
  // JavaScript can hide egress behind aliases, templates and computed URLs.
31
32
  // Even literal allowlisted fetches do not prove the rest of the program safe.
32
33
  if (action === "execute")
@@ -5,25 +5,25 @@ import { historyChange, historyScore, isGitGlobalOption } from "./history.js";
5
5
  import { executableShapeScore, pathTargets, type ShellScan } from "./shell-argv.js";
6
6
  import { ask, deny, high, type RequestScore } from "./types.js";
7
7
 
8
- function commandTouchesProtected(command: ParsedShellCommands | undefined, projectDir?: string): boolean {
8
+ function commandTouchesProtected(command: ParsedShellCommands | undefined, projectDir?: string, includeGit = true): boolean {
9
9
  if (!command) return false;
10
- return command.segments.some((segment) => protectedSegment(segment, projectDir)) ||
11
- command.leftovers.some((word) => isProtectedShellWord(word, projectDir));
10
+ return command.segments.some((segment) => protectedSegment(segment, projectDir, includeGit)) ||
11
+ command.leftovers.some((word) => isProtectedShellWord(word, projectDir, includeGit));
12
12
  }
13
13
 
14
14
  /** A redirection always names a file, so its target takes the full path-aware check. */
15
- function redirectsProtected(commands: readonly (ParsedShellCommands | undefined)[], projectDir?: string): boolean {
16
- return commands.some((command) => command?.redirects.some((target) => isProtected(target, projectDir)));
15
+ function redirectsProtected(commands: readonly (ParsedShellCommands | undefined)[], projectDir?: string, includeGit = true): boolean {
16
+ return commands.some((command) => command?.redirects.some((target) => isProtected(target, projectDir, includeGit)));
17
17
  }
18
18
 
19
19
  /** Denies and asks that depend only on the literal parsed command text. */
20
20
  function literalShellScore(scan: ShellScan, request: PermissionRequest, worktreeRoot?: string): RequestScore | undefined {
21
21
  if (scan.scanned.some((argv) => recursiveExternalDelete(argv, request.projectDir, worktreeRoot)))
22
22
  return deny("Recursive/force deletion outside the project");
23
- // Git global options alter cwd/config or helper execution. Do not guess the
24
- // effective subcommand or let a reviewer lift this uncertainty.
23
+ // Global options can change cwd/config/helpers: not static clearance, but
24
+ // reviewers can evaluate the full invocation and its effective scope.
25
25
  if (scan.scanned.some(isGitGlobalOption))
26
- return ask("Git global options require manual approval");
26
+ return high("Git global options require further review");
27
27
  // A history-rewriting invocation is gated once, against the request's VCS facts.
28
28
  if (scan.scanned.some(historyChange)) return historyScore(scan.scanned, request.gitState);
29
29
  // No regex fallback. Only the guard's literal, fully parsed representation
@@ -32,17 +32,20 @@ function literalShellScore(scan: ShellScan, request: PermissionRequest, worktree
32
32
  return undefined;
33
33
  }
34
34
 
35
- /** Protected paths deny; a path that traverses a symlink asks for human review. */
35
+ /** Credential/config paths deny; Git metadata is left to configured reviewers. */
36
36
  function protectedPathScore(scan: ShellScan, projectDir?: string): RequestScore | undefined {
37
37
  // Wrappers do not erase destructive semantics (env rm / command git push).
38
38
  // Each simple command is judged by its own grammar, so a pattern/script/filter
39
39
  // is data even when spelled like a path, while tokens outside any command keep
40
40
  // the generic word check.
41
- if (redirectsProtected(scan.commands, projectDir) ||
42
- scan.commands.some((command) => commandTouchesProtected(command, projectDir)))
41
+ if (redirectsProtected(scan.commands, projectDir, false) ||
42
+ scan.commands.some((command) => commandTouchesProtected(command, projectDir, false)))
43
43
  return deny("Protected path access");
44
44
  if (pathTargets(scan).some((arg) => hasSymlinkTraversal(arg, projectDir)))
45
45
  return ask("Symlink traversal requires human approval");
46
+ if (redirectsProtected(scan.commands, projectDir) ||
47
+ scan.commands.some((command) => commandTouchesProtected(command, projectDir)))
48
+ return high("Git metadata access requires further review");
46
49
  return undefined;
47
50
  }
48
51
 
@@ -3,21 +3,22 @@ import { guardShell, visibleShellDeny } from "../shell.js";
3
3
  import { scanShell, type ShellScan } from "./shell-argv.js";
4
4
  import { scoreShellBaseline } from "./shell-baseline.js";
5
5
  import { shellSafetyScore } from "./shell-safety.js";
6
- import { ask, deny, type RequestScore } from "./types.js";
6
+ import { ask, deny, high, type RequestScore } from "./types.js";
7
7
 
8
- /** Honours a guard deny/ask before the residual scorer runs. */
8
+ /** Preserve visible denies; parser uncertainty must reach configured reviewers. */
9
9
  function guardScore(guarded: ReviewResult | undefined, resources: readonly string[],
10
10
  projectDir?: string, worktreeRoot?: string): RequestScore | undefined {
11
11
  if (guarded?.decision === "deny") return deny(guarded.reason);
12
12
  if (guarded?.decision !== "ask") return undefined;
13
- // An ask is a manual-approval floor, not a clearance: a deny already visible
14
- // in the literal prefix must still win before the ask is honored.
13
+ // The POSIX scanner cannot safely analyze other shell dialects.
14
+ if (process.platform === "win32") return ask(guarded.reason);
15
+ // The parser cannot clear uncertain syntax, but visible dangers still win.
15
16
  const blocked = visibleShellDeny(resources, projectDir, worktreeRoot);
16
17
  if (blocked !== undefined) return deny(blocked);
17
- return ask(guarded.reason);
18
+ return undefined;
18
19
  }
19
20
 
20
- /** Scores one shell request: guard floor, hard safety checks, then residual band. */
21
+ /** Scores one shell request: visible denies, safety checks, then residual band. */
21
22
  export function scoreShellRequest(request: PermissionRequest, worktreeRoot?: string): RequestScore {
22
23
  const { resources, projectDir } = request;
23
24
  const guarded = guardShell(resources, projectDir);
@@ -26,5 +27,6 @@ export function scoreShellRequest(request: PermissionRequest, worktreeRoot?: str
26
27
  const scan: ShellScan = scanShell(resources);
27
28
  const unsafe = shellSafetyScore(scan, request, worktreeRoot);
28
29
  if (unsafe) return unsafe;
30
+ if (guarded?.decision === "ask") return high(guarded.reason);
29
31
  return scoreShellBaseline(scan, guarded, projectDir, worktreeRoot);
30
32
  }
package/src/shell.ts CHANGED
@@ -277,17 +277,23 @@ export function parseShellCommands(command: string, limits: { maxChars?: number
277
277
  * path word is checked with `isProtectedPathOperand` and every other word keeps
278
278
  * the generic shell-word check.
279
279
  */
280
- export function protectedSegment(segment: { words: readonly string[] }, projectDir?: string): boolean {
280
+ export function protectedSegment(segment: { words: readonly string[] }, projectDir?: string, includeGit = true): boolean {
281
281
  const roles = operandRoles(segment.words);
282
282
  const data = new Set(roles.data);
283
283
  const paths = new Set(roles.paths);
284
- if (roles.pathValues.some((value) => isProtectedPathOperand(value, projectDir))) return true;
284
+ if (roles.pathValues.some((value) => isProtectedPathOperand(value, projectDir, includeGit))) return true;
285
285
  return segment.words.some((word, index) => {
286
286
  if (data.has(index)) return false;
287
- return paths.has(index) ? isProtectedPathOperand(word, projectDir) : isProtectedShellWord(word, projectDir);
287
+ return paths.has(index) ? isProtectedPathOperand(word, projectDir, includeGit) : isProtectedShellWord(word, projectDir, includeGit);
288
288
  });
289
289
  }
290
290
 
291
+ function protectedCommands(parsed: ParsedLine, projectDir?: string, includeGit = true): boolean {
292
+ return parsed.redirects.some((target) => isProtected(target, projectDir, includeGit)) ||
293
+ parsed.segments.some((segment) => protectedSegment(segment, projectDir, includeGit)) ||
294
+ parsed.leftovers.some((word) => isProtectedShellWord(word, projectDir, includeGit));
295
+ }
296
+
291
297
  function analyzeLine(command: string, projectDir?: string): Verdict {
292
298
  if (!command.trim()) return { decision: "allow" };
293
299
  if (!BALANCED_QUOTES.test(command)) return { decision: "ask" };
@@ -304,10 +310,8 @@ function analyzeLine(command: string, projectDir?: string): Verdict {
304
310
  // keeps the protected-path check. Tokens no command consumed (words inside a
305
311
  // substitution) keep the generic shell-word check. None of this depends on the
306
312
  // line parsing completely: a deny visible in the literal text must win over the
307
- // manual-approval floor.
308
- if (redirects.some((target) => isProtected(target, projectDir)) ||
309
- segments.some((segment) => protectedSegment(segment, projectDir)) ||
310
- leftovers.some((word) => isProtectedShellWord(word, projectDir)))
313
+ // parser uncertainty.
314
+ if (protectedCommands({ segments, redirects, leftovers, complete }, projectDir, false))
311
315
  return { decision: "deny", reason: "protected path access" };
312
316
  // Deny rules that are already visible must win even when later arguments are
313
317
  // dynamic (`sudo id $(...)`) and even when the line only parses partially.
@@ -326,7 +330,7 @@ function analyzeLine(command: string, projectDir?: string): Verdict {
326
330
  if (!complete || DYNAMIC_SYNTAX.test(command)) return { decision: "ask" };
327
331
 
328
332
  let ask = false;
329
- let review = false;
333
+ let review = protectedCommands({ segments, redirects, leftovers, complete }, projectDir);
330
334
  for (const segment of segments) {
331
335
  const verdict = analyzeSegment(segment);
332
336
  if (verdict.decision === "deny") return verdict;
@@ -518,9 +522,7 @@ export function visibleShellDeny(resources: readonly string[], projectDir?: stri
518
522
  if (FORK_BOMB.test(visible)) return "dangerous shell operation";
519
523
  const parsed = parseShellCommands(visible, { maxChars: MAX_COMMAND_BYTES });
520
524
  if (parsed === undefined) continue;
521
- if (parsed.redirects.some((target) => isProtected(target, projectDir)) ||
522
- parsed.segments.some((segment) => protectedSegment(segment, projectDir)) ||
523
- parsed.leftovers.some((word) => isProtectedShellWord(word, projectDir)))
525
+ if (protectedCommands(parsed, projectDir, false))
524
526
  return "protected path access";
525
527
  const commands = parsed.segments.map((segment) => effectiveInvocation(segment.words));
526
528
  for (const command of commands) {
@@ -609,7 +611,6 @@ export function guardShell(resources: readonly string[], projectDir?: string): R
609
611
  if (verdict.decision === "ask") ask = true;
610
612
  else if (verdict.decision === "review") review = true;
611
613
  }
612
- if (ask) return { decision: "ask", reason: "Shell syntax requires manual approval" };
614
+ if (ask) return { decision: "ask", reason: "Shell syntax requires further review" };
613
615
  return review ? undefined : { decision: "allow", reason: "read-only command" };
614
616
  }
615
-
package/src/tiers.ts CHANGED
@@ -83,6 +83,12 @@ function projectRootSpellings(projectDir?: string): string[] {
83
83
  return roots;
84
84
  }
85
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
+ }
91
+
86
92
  /**
87
93
  * Sensitive spelling of one candidate.
88
94
  *
@@ -92,14 +98,14 @@ function projectRootSpellings(projectDir?: string): string[] {
92
98
  * still applied to the whole candidate and, in `isProtected`, to every resolved
93
99
  * component: a real `.ssh` or `.config` directory stays protected.
94
100
  */
95
- function sensitiveSpelling(candidate: string, roots: readonly string[]): boolean {
96
- if (SENSITIVE_PATH.test(candidate)) return true;
101
+ function sensitiveSpelling(candidate: string, roots: readonly string[], includeGit?: boolean): boolean {
102
+ if (SENSITIVE_PATH.test(protectionSpelling(candidate, includeGit))) return true;
97
103
  if (path.isAbsolute(candidate)) {
98
104
  for (const root of roots) {
99
- if (isInProject(candidate, root)) return DOT_COMPONENT.test(path.relative(root, path.resolve(candidate)));
105
+ if (isInProject(candidate, root)) return DOT_COMPONENT.test(protectionSpelling(path.relative(root, path.resolve(candidate)), includeGit));
100
106
  }
101
107
  }
102
- return DOT_COMPONENT.test(candidate);
108
+ return DOT_COMPONENT.test(protectionSpelling(candidate, includeGit));
103
109
  }
104
110
 
105
111
  /** Inspect components before normalization can erase an alias followed by `..`.
@@ -123,16 +129,17 @@ export function hasSymlinkTraversal(target: string, locationDir = process.cwd())
123
129
  return false;
124
130
  }
125
131
 
126
- /** Check lexical spelling and each resolved component, preserving symlink/.. traversal. */
127
- export function isProtected(target: string, projectDir?: string): boolean {
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 {
128
135
  const roots = projectRootSpellings(projectDir);
129
136
  const candidates = [target, ...target.split("="), ...target.split(":")];
130
- if (candidates.some((candidate) => sensitiveSpelling(candidate, roots) ||
137
+ if (candidates.some((candidate) => sensitiveSpelling(candidate, roots, includeGit) ||
131
138
  /^\/proc\/(?:self|thread-self|\d+)\/(?:environ|mem)(?:$|\/)/.test(candidate))) return true;
132
139
  let current: string;
133
140
  try { current = path.isAbsolute(target) ? path.parse(target).root : realpathSync(projectDir ?? process.cwd()); }
134
141
  catch { return hasSymlinkTraversal(target, path.resolve(projectDir ?? process.cwd())); }
135
- const protectedPath = (candidate: string) => SENSITIVE_PATH.test(candidate) ||
142
+ const protectedPath = (candidate: string) => SENSITIVE_PATH.test(protectionSpelling(candidate, includeGit)) ||
136
143
  /^\/proc\/(?:self|thread-self|\d+)\/(?:environ|mem)(?:$|\/)/.test(candidate);
137
144
  if (protectedPath(current)) return true;
138
145
  for (const part of target.split(path.sep).filter(Boolean)) {
@@ -205,13 +212,13 @@ function operandValue(word: string): string {
205
212
  * reason to deny `.name`, `.gitignore` or `.cache`. Paths with a separator keep
206
213
  * the full check, including resolved components, symlinks and `file:` URLs.
207
214
  */
208
- export function isProtectedPathOperand(target: string, projectDir?: string): boolean {
215
+ export function isProtectedPathOperand(target: string, projectDir?: string, includeGit?: boolean): boolean {
209
216
  if (!target) return false;
210
217
  const word = operandValue(target);
211
218
  const local = fileUrlPath(word);
212
- if (local !== undefined) return isProtected(local, projectDir);
219
+ if (local !== undefined) return isProtected(local, projectDir, includeGit);
213
220
  if (SCHEME_URL.test(word) || SCP_URL.test(word)) return false;
214
- return /[/\\]/.test(word) ? isProtected(word, projectDir) : SENSITIVE_PATH.test(word);
221
+ return /[/\\]/.test(word) ? isProtected(word, projectDir, includeGit) : SENSITIVE_PATH.test(protectionSpelling(word, includeGit));
215
222
  }
216
223
 
217
224
  /**
@@ -227,33 +234,33 @@ export function isProtectedPathOperand(target: string, projectDir?: string): boo
227
234
  * (`signing.pem`) stay protected everywhere, and redirect targets are checked
228
235
  * with the full `isProtected` because a redirection always names a file.
229
236
  */
230
- export function isProtectedShellWord(target: string, projectDir?: string): boolean {
237
+ export function isProtectedShellWord(target: string, projectDir?: string, includeGit?: boolean): boolean {
231
238
  if (!target) return false;
232
239
  const separator = target.startsWith("-") ? target.indexOf("=") : -1;
233
240
  // `--grep=config/auth`, `--format=src/.env` and `--exclude=.env` carry
234
241
  // pattern/format data even when the value is spelled like a path.
235
242
  if (separator !== -1 && isDataOption(target.slice(0, separator))) return false;
236
243
  const local = fileUrlPath(target);
237
- if (local !== undefined) return isProtected(local, projectDir);
244
+ if (local !== undefined) return isProtected(local, projectDir, includeGit);
238
245
  if (SCHEME_URL.test(target) || SCP_URL.test(target)) return false;
239
246
  const candidates = [target, ...target.split("="), ...target.split(":")];
240
247
  // A `file:` value is a local path even when it arrives through an option.
241
248
  const files = candidates.map(fileUrlPath).filter((candidate): candidate is string => candidate !== undefined);
242
- if (files.length) return files.some((file) => isProtected(file, projectDir));
249
+ if (files.length) return files.some((file) => isProtected(file, projectDir, includeGit));
243
250
  // Any other scheme or an SCP-style location is remote, never a local path.
244
251
  if (candidates.some((candidate) => SCHEME_URL.test(candidate) || SCP_URL.test(candidate))) return false;
245
252
  if (target.startsWith("-")) {
246
253
  if (separator === -1) return false;
247
254
  // `--config=credentials.json` and `--file=.env` name files.
248
- return isProtectedPathOperand(target.slice(separator + 1), projectDir);
255
+ return isProtectedPathOperand(target.slice(separator + 1), projectDir, includeGit);
249
256
  }
250
257
  // `git show HEAD:.env` names a file after the colon; schemes and SCP
251
258
  // locations were already excluded above.
252
- if (!target.includes("=") && target.split(":").slice(1).some((part) => isProtectedPathOperand(part, projectDir)))
259
+ if (!target.includes("=") && target.split(":").slice(1).some((part) => isProtectedPathOperand(part, projectDir, includeGit)))
253
260
  return true;
254
261
  // A separator-less word is only a file reference when it carries a
255
262
  // filename-shaped name; a bare keyword such as `auth` stays data here.
256
- return /[/\\]/.test(target) ? isProtected(target, projectDir) : SENSITIVE_NAME.test(target);
263
+ return /[/\\]/.test(target) ? isProtected(target, projectDir, includeGit) : SENSITIVE_NAME.test(protectionSpelling(target, includeGit));
257
264
  }
258
265
 
259
266
  export function isInProject(target: string, projectDir: string): boolean {