@mortz.prk/opencode-auto-mode 1.0.1 → 1.1.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.
- package/CONTRIBUTING.md +234 -0
- package/README.llm.md +314 -0
- package/README.md +175 -166
- package/dist/gui-experimental.cjs +1 -0
- package/package.json +27 -16
- package/src/audit.ts +136 -38
- package/src/classifier.ts +45 -37
- package/src/code-mode.ts +55 -0
- package/src/config.ts +104 -98
- package/src/debug-events.ts +223 -0
- package/src/decision.ts +72 -0
- package/src/errors.ts +39 -0
- package/src/gates.ts +16 -0
- package/src/global-options.ts +85 -0
- package/src/gui-state.ts +269 -0
- package/src/gui.tsx +78 -0
- package/src/index.ts +510 -131
- package/src/mode.ts +237 -0
- package/src/reviewer.ts +71 -85
- package/src/rpc.ts +127 -0
- package/src/score/files.ts +49 -0
- package/src/score/history.ts +141 -0
- package/src/score/index.ts +37 -0
- package/src/score/limits.ts +24 -0
- package/src/score/shell-argv.ts +74 -0
- package/src/score/shell-baseline.ts +113 -0
- package/src/score/shell-safety.ts +54 -0
- package/src/score/shell.ts +30 -0
- package/src/score/types.ts +14 -0
- package/src/score.ts +9 -0
- package/src/shell-policy.ts +1979 -0
- package/src/shell.ts +508 -225
- package/src/status.ts +85 -0
- package/src/tiers.ts +286 -40
- package/src/timeout.ts +30 -0
- package/src/tui.tsx +44 -34
- package/src/user-files.ts +75 -0
- package/src/worktree.ts +30 -0
- package/src/decisions.ts +0 -213
- package/src/logger.ts +0 -26
- package/src/otlp.ts +0 -74
- package/src/policy.ts +0 -36
- package/src/state.ts +0 -135
- package/src/storage.ts +0 -26
- package/src/traces.ts +0 -84
- package/src/transcript.ts +0 -208
- package/src/tui-state.ts +0 -7
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { evaluateVcsGate, type VcsState } from "../gates.js";
|
|
2
|
+
import { isLongOption } from "../shell-policy.js";
|
|
3
|
+
import { ask, high, type RequestScore } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const HISTORY_SUBCOMMANDS = new Set(["rebase", "filter-branch", "filter-repo"]);
|
|
6
|
+
const FORCE_PUSH_OPTIONS = ["--force", "--force-with-lease", "--force-if-includes", "--delete", "--mirror", "--prune"];
|
|
7
|
+
|
|
8
|
+
/** GNU option parsing accepts unambiguous abbreviations, so long options are prefix-matched. */
|
|
9
|
+
function hasLong(args: readonly string[], name: string): boolean {
|
|
10
|
+
return args.some((arg) => isLongOption(arg, name));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function hasAnyLong(args: readonly string[], names: readonly string[]): boolean {
|
|
14
|
+
return names.some((name) => hasLong(args, name));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function pushRewritesHistory(args: readonly string[]): boolean {
|
|
18
|
+
if (hasAnyLong(args, FORCE_PUSH_OPTIONS)) return true;
|
|
19
|
+
return args.some((arg) => /^-[^-]*[fd]/.test(arg) || arg.startsWith("+") || /^:.+/.test(arg));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function deletesRef(args: readonly string[], shortFlag: RegExp): boolean {
|
|
23
|
+
return hasAnyLong(args, ["--delete", "--force"]) || args.some((arg) => shortFlag.test(arg));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True unless the reset only touches the index/working tree for given paths
|
|
28
|
+
* (`git reset [<commit>] -- <pathspec>...`), which leaves HEAD and the
|
|
29
|
+
* current branch untouched. Any other form (mode flags, or a bare commit
|
|
30
|
+
* argument with no pathspec) moves HEAD.
|
|
31
|
+
*/
|
|
32
|
+
function resetMovesHead(args: readonly string[]): boolean {
|
|
33
|
+
const rest = args.slice(1);
|
|
34
|
+
if (hasAnyLong(rest, ["--hard", "--soft", "--mixed", "--merge", "--keep"])) return true;
|
|
35
|
+
const dashIndex = rest.indexOf("--");
|
|
36
|
+
if (dashIndex !== -1) return dashIndex === rest.length - 1;
|
|
37
|
+
return rest.filter((arg) => !arg.startsWith("-")).length <= 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The force-create option of each branch-switching subcommand, with the short
|
|
42
|
+
* letters that take a value so a bundle like `-bB` is read as `-b B` rather
|
|
43
|
+
* than as a force create.
|
|
44
|
+
*/
|
|
45
|
+
const FORCE_CREATE: Record<string, { short: string; valueLetters: string; long?: string }> = {
|
|
46
|
+
checkout: { short: "B", valueLetters: "bB" },
|
|
47
|
+
switch: { short: "C", valueLetters: "cC", long: "--force-create" },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** The branch name a short bundle force-creates, or undefined when it creates no branch. */
|
|
51
|
+
function shortForceCreate(word: string, option: { short: string; valueLetters: string },
|
|
52
|
+
next: string | undefined): { branch?: string } | undefined {
|
|
53
|
+
const letters = word.slice(1);
|
|
54
|
+
for (let index = 0; index < letters.length; index++) {
|
|
55
|
+
if (!option.valueLetters.includes(letters[index])) continue;
|
|
56
|
+
if (letters[index] !== option.short) return undefined;
|
|
57
|
+
const attached = letters.slice(index + 1);
|
|
58
|
+
return { branch: attached.length ? attached : next };
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True for a short-option bundle (`-qB`), which is where the force-create letter can appear. */
|
|
64
|
+
function isShortBundle(word: string): boolean {
|
|
65
|
+
return word.startsWith("-") && !word.startsWith("--") && word.length > 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The branch name a long force-create option attaches (`=trunk`) or takes from the next word. */
|
|
69
|
+
function longForceCreate(word: string, next: string | undefined): string | undefined {
|
|
70
|
+
const separator = word.indexOf("=");
|
|
71
|
+
return separator === -1 ? next : word.slice(separator + 1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `checkout -B`/`switch -C`/`switch --force-create` reset an existing branch
|
|
76
|
+
* before switching to it. The branch name is the option's attached value
|
|
77
|
+
* (`-Btrunk`, `--force-create=trunk`) or the following word, so a start point
|
|
78
|
+
* (`HEAD~1`) is never mistaken for the branch; `--` ends option parsing.
|
|
79
|
+
*/
|
|
80
|
+
function forceCreate(args: readonly string[]): { branch?: string } | undefined {
|
|
81
|
+
const option = FORCE_CREATE[args[0] ?? ""];
|
|
82
|
+
if (option === undefined) return undefined;
|
|
83
|
+
const rest = args.slice(1);
|
|
84
|
+
for (let index = 0; index < rest.length; index++) {
|
|
85
|
+
const word = rest[index];
|
|
86
|
+
if (word === "--") return undefined;
|
|
87
|
+
if (option.long !== undefined && isLongOption(word, option.long))
|
|
88
|
+
return { branch: longForceCreate(word, rest[index + 1]) };
|
|
89
|
+
if (!isShortBundle(word)) continue;
|
|
90
|
+
const created = shortForceCreate(word, option, rest[index + 1]);
|
|
91
|
+
if (created !== undefined) return created;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function forceResetsBranch(args: readonly string[]): boolean {
|
|
97
|
+
return forceCreate(args) !== undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True when a parsed Git invocation rewrites or deletes history. */
|
|
101
|
+
export function historyChange(words: readonly string[]): boolean {
|
|
102
|
+
if (words[0] !== "git") return false;
|
|
103
|
+
const args = words.slice(1);
|
|
104
|
+
const first = args[0];
|
|
105
|
+
if (first === "push") return pushRewritesHistory(args);
|
|
106
|
+
if (first === "commit") return hasLong(args, "--amend");
|
|
107
|
+
if (first === "reset") return resetMovesHead(args);
|
|
108
|
+
if (first === "branch") return deletesRef(args, /^-[^-]*[dDf]/);
|
|
109
|
+
if (first === "tag") return deletesRef(args, /^-[^-]*[df]/);
|
|
110
|
+
if (first === "checkout" || first === "switch") return forceResetsBranch(args);
|
|
111
|
+
return HISTORY_SUBCOMMANDS.has(first ?? "");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Git global options alter cwd/config or helper execution, so the subcommand is not trustworthy. */
|
|
115
|
+
export function isGitGlobalOption(argv: readonly string[]): boolean {
|
|
116
|
+
return argv[0] === "git" && argv[1]?.startsWith("-") === true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pushesToDefaultBranch(argv: readonly string[], defaultBranch: string): boolean {
|
|
120
|
+
if (argv[0] !== "git" || argv[1] !== "push") return false;
|
|
121
|
+
return argv.slice(2).some((arg) =>
|
|
122
|
+
arg.replace(/^\+/, "").split(":").pop()?.replace(/^refs\/heads\//, "") === defaultBranch);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Name of the branch that a force-reset `checkout -B`/`switch -C` would overwrite, if any. */
|
|
126
|
+
function forceResetTarget(argv: readonly string[]): string | undefined {
|
|
127
|
+
if (argv[0] !== "git") return undefined;
|
|
128
|
+
return forceCreate(argv.slice(1))?.branch;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Scores one verified history-changing invocation, or the missing precondition that asks for review. */
|
|
132
|
+
export function historyScore(scanned: readonly (readonly string[])[], gitState?: VcsState): RequestScore {
|
|
133
|
+
const gate = evaluateVcsGate(gitState);
|
|
134
|
+
if (!gate.allowed) return ask(gate.reason);
|
|
135
|
+
const defaultBranch = gitState?.defaultBranch;
|
|
136
|
+
if (defaultBranch && scanned.some((argv) => pushesToDefaultBranch(argv, defaultBranch)))
|
|
137
|
+
return ask("Explicit push destination is the default branch");
|
|
138
|
+
if (defaultBranch && scanned.some((argv) => forceResetTarget(argv) === defaultBranch))
|
|
139
|
+
return ask("Force-reset target is the default branch");
|
|
140
|
+
return high("History changes satisfy VCS preconditions but still require review");
|
|
141
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AutoModeOptions } from "../config.js";
|
|
2
|
+
import type { PermissionRequest } from "../reviewer.js";
|
|
3
|
+
import { projectRoot } from "../tiers.js";
|
|
4
|
+
import { isFileAction, scoreFileAction } from "./files.js";
|
|
5
|
+
import { checkRequestLimits } from "./limits.js";
|
|
6
|
+
import { scoreShellRequest } from "./shell.js";
|
|
7
|
+
import { high, low, type RequestScore } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const NETWORK_ACTIONS = new Set(["webfetch", "websearch"]);
|
|
10
|
+
const INTERACTIVE_ACTIONS = new Set(["question", "todowrite"]);
|
|
11
|
+
|
|
12
|
+
/** The evaluated root comes from the caller when given, else from the location itself. */
|
|
13
|
+
function resolveWorktreeRoot(request: PermissionRequest): string | undefined {
|
|
14
|
+
return request.worktreeRoot ?? (request.projectDir ? projectRoot(request.projectDir) : undefined);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The caller decides which host effects enter review. Native deny is always the
|
|
18
|
+
* caller's responsibility; residual asks and native allows share these guards. */
|
|
19
|
+
export function scoreRequest(request: PermissionRequest & { worktreeRoot?: string }, options: AutoModeOptions): RequestScore {
|
|
20
|
+
const { action, resources, projectDir } = request;
|
|
21
|
+
// Reject malformed requests before root discovery touches the filesystem.
|
|
22
|
+
const rejected = checkRequestLimits(action, resources);
|
|
23
|
+
if (rejected) return rejected;
|
|
24
|
+
const worktreeRoot = resolveWorktreeRoot(request);
|
|
25
|
+
if (action === "shell") return scoreShellRequest(request, worktreeRoot);
|
|
26
|
+
if (isFileAction(action)) return scoreFileAction(action, resources, options, projectDir, worktreeRoot);
|
|
27
|
+
// No host allowlist exists, so a URL/query name cannot bound what is fetched.
|
|
28
|
+
if (NETWORK_ACTIONS.has(action)) return high("Network fetch/search has no verified host allowlist");
|
|
29
|
+
if (INTERACTIVE_ACTIONS.has(action)) return low("User interaction without host side effects");
|
|
30
|
+
// JavaScript can hide egress behind aliases, templates and computed URLs.
|
|
31
|
+
// Even literal allowlisted fetches do not prove the rest of the program safe.
|
|
32
|
+
if (action === "execute")
|
|
33
|
+
return high("Code Mode is dynamic execution; outbound hosts cannot be proven statically");
|
|
34
|
+
// MCP names are not a capability declaration. Unknown and MCP tools always
|
|
35
|
+
// stay HIGH: no tool name alone establishes a verified read-only capability.
|
|
36
|
+
return high("Unknown tool capability");
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ask, high, type RequestScore } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Bounds the scorer accepts before it parses or resolves any request text. */
|
|
4
|
+
const MAX_RESOURCES = 256;
|
|
5
|
+
const MAX_RESOURCE_CHARS = 65_536;
|
|
6
|
+
const MAX_RESOURCE_BYTES = 262_144;
|
|
7
|
+
/** User interaction is complete without any resource text. */
|
|
8
|
+
const INTERACTIVE_ACTIONS = new Set(["question", "todowrite"]);
|
|
9
|
+
/** The guard's existing reason for malformed shell requests; keep one manual floor. */
|
|
10
|
+
const SHELL_LIMIT_REASON = "Shell command exceeds parsing limits";
|
|
11
|
+
|
|
12
|
+
/** Malformed shell requests keep the guard's manual floor; other actions stay HIGH. */
|
|
13
|
+
function limitScore(action: string, reason: string): RequestScore {
|
|
14
|
+
return action === "shell" ? ask(SHELL_LIMIT_REASON) : high(reason);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Request-level limits shared by every action. */
|
|
18
|
+
export function checkRequestLimits(action: string, resources: readonly string[]): RequestScore | undefined {
|
|
19
|
+
if (!resources.length && !INTERACTIVE_ACTIONS.has(action)) return limitScore(action, "Missing request resources");
|
|
20
|
+
if (resources.length > MAX_RESOURCES || resources.some((resource) => resource.length > MAX_RESOURCE_CHARS) ||
|
|
21
|
+
resources.reduce((total, resource) => total + Buffer.byteLength(resource), 0) > MAX_RESOURCE_BYTES)
|
|
22
|
+
return limitScore(action, "Request exceeds scoring limits");
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { operandRoles, resolveExecutable } from "../shell-policy.js";
|
|
3
|
+
import { parseShellCommands, type ParsedShellCommands } from "../shell.js";
|
|
4
|
+
import { high, type RequestScore } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One shell request parsed once, so every deny, scope and baseline check reads
|
|
8
|
+
* the same argv representation.
|
|
9
|
+
*/
|
|
10
|
+
export interface ShellScan {
|
|
11
|
+
/** Parsed form of each resource; undefined when the parser refuses the text. */
|
|
12
|
+
commands: (ParsedShellCommands | undefined)[];
|
|
13
|
+
/** Every simple argv list, in resource order. */
|
|
14
|
+
parsedWords: string[][];
|
|
15
|
+
/** `parsedWords` after leading wrappers are removed. */
|
|
16
|
+
effective: string[][];
|
|
17
|
+
/** `effective` with the executable reduced to its basename, as deny checks read it. */
|
|
18
|
+
scanned: string[][];
|
|
19
|
+
/** File operands, excluding command names, options and redirect targets. */
|
|
20
|
+
operands: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function effectiveArgv(argv: readonly string[]): string[] {
|
|
24
|
+
const executable = resolveExecutable(argv);
|
|
25
|
+
return executable ? argv.slice(executable.index) : [...argv];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function scannedArgv(argv: readonly string[]): string[] {
|
|
29
|
+
return [basename(argv[0] ?? ""), ...argv.slice(1)];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function scanShell(resources: readonly string[]): ShellScan {
|
|
33
|
+
const commands = resources.map((resource) => parseShellCommands(resource));
|
|
34
|
+
const parsedWords = commands.flatMap((command) => command?.segments.map((segment) => segment.words) ?? [])
|
|
35
|
+
.filter((argv) => argv.length);
|
|
36
|
+
const effective = parsedWords.map(effectiveArgv);
|
|
37
|
+
return {
|
|
38
|
+
commands,
|
|
39
|
+
parsedWords,
|
|
40
|
+
effective,
|
|
41
|
+
scanned: effective.map(scannedArgv),
|
|
42
|
+
operands: commands.flatMap((command) => command?.operands ?? []),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** File targets are command-specific operands, attached path values, and redirects. */
|
|
47
|
+
export function pathTargets(scan: ShellScan): string[] {
|
|
48
|
+
return scan.commands.flatMap((command) => command === undefined ? [] : [
|
|
49
|
+
...command.redirects,
|
|
50
|
+
...command.segments.flatMap((segment) => {
|
|
51
|
+
const roles = operandRoles(segment.words);
|
|
52
|
+
return [
|
|
53
|
+
...roles.paths.map((index) => segment.words[index]!),
|
|
54
|
+
...roles.pathValues,
|
|
55
|
+
];
|
|
56
|
+
}),
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hasWrapperPrelude(argv: readonly string[]): boolean {
|
|
61
|
+
const executable = resolveExecutable(argv);
|
|
62
|
+
if (!executable) return false;
|
|
63
|
+
return argv.slice(0, executable.index).some((word) =>
|
|
64
|
+
word.includes("/") || word.startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(word));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function executableShapeScore(scan: ShellScan): RequestScore | undefined {
|
|
68
|
+
// Effective argv is sufficient for deny checks, not for bounded scope:
|
|
69
|
+
// a repo-controlled wrapper, wrapper options (cwd/output/config), or
|
|
70
|
+
// leading environment assignments can change where a build operates.
|
|
71
|
+
if (scan.parsedWords.some(hasWrapperPrelude)) return high("Wrapper executable or effects require review");
|
|
72
|
+
if (scan.effective.some((argv) => argv[0]?.includes("/"))) return high("Path-qualified executable");
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import type { ReviewResult } from "../reviewer.js";
|
|
3
|
+
import type { ParsedShellCommands } from "../shell.js";
|
|
4
|
+
import { isInProject, projectRoot } from "../tiers.js";
|
|
5
|
+
import { pathTargets, type ShellScan } from "./shell-argv.js";
|
|
6
|
+
import { high, low, medium, type RequestScore } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** Writes that never reach a regular file and cannot escape the project. */
|
|
9
|
+
const SAFE_DEVICES = new Set(["/dev/null", "/dev/stdout", "/dev/stderr"]);
|
|
10
|
+
/** Commands whose subcommands are only gray-band when they stay inside the project. */
|
|
11
|
+
const PACKAGE_MANAGERS = new Set(["npm", "pnpm"]);
|
|
12
|
+
const INSTALL_SUBCOMMANDS = new Set(["install", "add", "ci"]);
|
|
13
|
+
const BUILDERS = new Set(["cargo", "go"]);
|
|
14
|
+
const BUILD_SUBCOMMANDS = new Set(["test", "build", "check"]);
|
|
15
|
+
const FORGE_SUBCOMMANDS = new Set(["pr", "issue", "repo", "run"]);
|
|
16
|
+
const FORGE_READS = new Set(["view", "list", "diff", "checks"]);
|
|
17
|
+
const UNSCOPED_INSTALL_FLAGS = /^(?:-g|--global|--prefix|--cwd|--directory)/;
|
|
18
|
+
const GIT_GLOBAL_OVERRIDE = /^(?:-c|--git-dir|--work-tree|--exec-path)/;
|
|
19
|
+
|
|
20
|
+
function operandEscapes(candidate: string, locationDir: string, worktreeRoot: string): boolean {
|
|
21
|
+
if (SAFE_DEVICES.has(candidate)) return false;
|
|
22
|
+
const pathLike = candidate.startsWith("/") || candidate.startsWith("~") ||
|
|
23
|
+
/(?:^|[/\\])\.\.(?:[/\\]|$)/.test(candidate);
|
|
24
|
+
if (!pathLike) return false;
|
|
25
|
+
if (candidate.startsWith("~") || /[$`\0]/.test(candidate)) return true;
|
|
26
|
+
return !isInProject(resolve(locationDir, candidate), worktreeRoot);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True when a shell operand provably leaves the project scope. Tildes and
|
|
31
|
+
* parameter expansion cannot be resolved statically, so they are never low.
|
|
32
|
+
* Option values (`--output=/outside/file`) are judged as well as the whole word.
|
|
33
|
+
*/
|
|
34
|
+
function escapesProject(operand: string, locationDir: string, worktreeRoot: string): boolean {
|
|
35
|
+
const candidates = operand.includes("=") ? [operand, ...operand.split("=").slice(1)] : [operand];
|
|
36
|
+
return candidates.some((candidate) => operandEscapes(candidate, locationDir, worktreeRoot));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isInProjectWords(words: readonly string[], projectDir?: string, worktreeRoot?: string): boolean {
|
|
40
|
+
if (!projectDir || !worktreeRoot) return false;
|
|
41
|
+
return !words.some((word) => word.includes("/") && !word.startsWith("--") &&
|
|
42
|
+
!isInProject(resolve(projectDir, word), worktreeRoot));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isMediumGit(words: readonly string[]): boolean {
|
|
46
|
+
return words[1] === "commit" && !words.some((word) => GIT_GLOBAL_OVERRIDE.test(word));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isMediumInstall(words: readonly string[], subcommand?: string): boolean {
|
|
50
|
+
if (!INSTALL_SUBCOMMANDS.has(subcommand ?? "")) return false;
|
|
51
|
+
return words.includes("--ignore-scripts") && !words.some((word) => UNSCOPED_INSTALL_FLAGS.test(word));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isForgeRead(command?: string, words: readonly string[] = []): boolean {
|
|
55
|
+
return command === "gh" && FORGE_SUBCOMMANDS.has(words[1] ?? "") && FORGE_READS.has(words[2] ?? "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isMediumBun(words: readonly string[], subcommand?: string): boolean {
|
|
59
|
+
return subcommand === "test" || isMediumInstall(words, subcommand);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isMediumInvocation(words: readonly string[]): boolean {
|
|
63
|
+
const [command, subcommand] = words;
|
|
64
|
+
if (command === "git") return isMediumGit(words);
|
|
65
|
+
if (command === "bun") return isMediumBun(words, subcommand);
|
|
66
|
+
if (PACKAGE_MANAGERS.has(command ?? "")) return isMediumInstall(words, subcommand);
|
|
67
|
+
if (BUILDERS.has(command ?? "")) return BUILD_SUBCOMMANDS.has(subcommand ?? "");
|
|
68
|
+
return isForgeRead(command, words);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Direct in-project runners. Running arbitrary package scripts stays HIGH. */
|
|
72
|
+
function mediumShell(words: readonly string[], projectDir?: string, worktreeRoot = projectDir): boolean {
|
|
73
|
+
if (!isInProjectWords(words, projectDir, worktreeRoot)) return false;
|
|
74
|
+
return isMediumInvocation(words);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isBoundedCommand(command: ParsedShellCommands | undefined): boolean {
|
|
78
|
+
return !command?.compound && !command?.segments.some((segment) => segment.write);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function wordCount(parsedWords: readonly (readonly string[])[]): number {
|
|
82
|
+
return parsedWords.reduce((total, argv) => total + argv.length, 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isBoundedMedium(scan: ShellScan, projectDir?: string, worktreeRoot?: string): boolean {
|
|
86
|
+
return scan.commands.every(isBoundedCommand) &&
|
|
87
|
+
scan.operands.length === wordCount(scan.parsedWords) &&
|
|
88
|
+
scan.effective.every((argv) => mediumShell(argv, projectDir, worktreeRoot));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isReadOnlyEligible(guarded: ReviewResult | undefined, scan: ShellScan): boolean {
|
|
92
|
+
if (guarded?.decision !== "allow") return false;
|
|
93
|
+
return !scan.scanned.some((argv) => ["bun", "gh", "git"].includes(argv[0] ?? ""));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readOnlyScore(scan: ShellScan, projectDir?: string, worktreeRoot?: string): RequestScore {
|
|
97
|
+
// A read-only command still reads whatever its operands or attached option
|
|
98
|
+
// values name. Low risk requires every one to stay inside the evaluated project.
|
|
99
|
+
const locationDir = projectDir ?? process.cwd();
|
|
100
|
+
const scopeRoot = worktreeRoot ?? projectRoot(locationDir);
|
|
101
|
+
if (pathTargets(scan).some((operand) => escapesProject(operand, locationDir, scopeRoot)))
|
|
102
|
+
return high("Shell path operand resolves outside the project");
|
|
103
|
+
return low("Parsed read-only command with protected-path checks");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Picks the residual band after every deny and ask has been ruled out. */
|
|
107
|
+
export function scoreShellBaseline(scan: ShellScan, guarded: ReviewResult | undefined,
|
|
108
|
+
projectDir?: string, worktreeRoot?: string): RequestScore {
|
|
109
|
+
if (isReadOnlyEligible(guarded, scan)) return readOnlyScore(scan, projectDir, worktreeRoot);
|
|
110
|
+
if (isBoundedMedium(scan, projectDir, worktreeRoot))
|
|
111
|
+
return medium("In-project build, test, install without scripts, commit or forge read");
|
|
112
|
+
return high("Shell command has unbounded side effects");
|
|
113
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { PermissionRequest } from "../reviewer.js";
|
|
2
|
+
import { protectedSegment, recursiveExternalDelete, type ParsedShellCommands } from "../shell.js";
|
|
3
|
+
import { hasSymlinkTraversal, isProtected, isProtectedShellWord } from "../tiers.js";
|
|
4
|
+
import { historyChange, historyScore, isGitGlobalOption } from "./history.js";
|
|
5
|
+
import { executableShapeScore, pathTargets, type ShellScan } from "./shell-argv.js";
|
|
6
|
+
import { ask, deny, high, type RequestScore } from "./types.js";
|
|
7
|
+
|
|
8
|
+
function commandTouchesProtected(command: ParsedShellCommands | undefined, projectDir?: string): boolean {
|
|
9
|
+
if (!command) return false;
|
|
10
|
+
return command.segments.some((segment) => protectedSegment(segment, projectDir)) ||
|
|
11
|
+
command.leftovers.some((word) => isProtectedShellWord(word, projectDir));
|
|
12
|
+
}
|
|
13
|
+
|
|
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)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Denies and asks that depend only on the literal parsed command text. */
|
|
20
|
+
function literalShellScore(scan: ShellScan, request: PermissionRequest, worktreeRoot?: string): RequestScore | undefined {
|
|
21
|
+
if (scan.scanned.some((argv) => recursiveExternalDelete(argv, request.projectDir, worktreeRoot)))
|
|
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.
|
|
25
|
+
if (scan.scanned.some(isGitGlobalOption))
|
|
26
|
+
return ask("Git global options require manual approval");
|
|
27
|
+
// A history-rewriting invocation is gated once, against the request's VCS facts.
|
|
28
|
+
if (scan.scanned.some(historyChange)) return historyScore(scan.scanned, request.gitState);
|
|
29
|
+
// No regex fallback. Only the guard's literal, fully parsed representation
|
|
30
|
+
// can establish a low-risk compound; scripts and uncertain syntax stay HIGH.
|
|
31
|
+
if (scan.commands.some((command) => !command?.complete)) return high("Complex or dynamic shell syntax");
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Protected paths deny; a path that traverses a symlink asks for human review. */
|
|
36
|
+
function protectedPathScore(scan: ShellScan, projectDir?: string): RequestScore | undefined {
|
|
37
|
+
// Wrappers do not erase destructive semantics (env rm / command git push).
|
|
38
|
+
// Each simple command is judged by its own grammar, so a pattern/script/filter
|
|
39
|
+
// is data even when spelled like a path, while tokens outside any command keep
|
|
40
|
+
// the generic word check.
|
|
41
|
+
if (redirectsProtected(scan.commands, projectDir) ||
|
|
42
|
+
scan.commands.some((command) => commandTouchesProtected(command, projectDir)))
|
|
43
|
+
return deny("Protected path access");
|
|
44
|
+
if (pathTargets(scan).some((arg) => hasSymlinkTraversal(arg, projectDir)))
|
|
45
|
+
return ask("Symlink traversal requires human approval");
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Ordered shell safety pipeline: literal denies, then paths, then executable shape. */
|
|
50
|
+
export function shellSafetyScore(scan: ShellScan, request: PermissionRequest, worktreeRoot?: string): RequestScore | undefined {
|
|
51
|
+
const literal = literalShellScore(scan, request, worktreeRoot);
|
|
52
|
+
if (literal?.deny || literal?.ask) return literal;
|
|
53
|
+
return protectedPathScore(scan, request.projectDir) ?? literal ?? executableShapeScore(scan);
|
|
54
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PermissionRequest, ReviewResult } from "../reviewer.js";
|
|
2
|
+
import { guardShell, visibleShellDeny } from "../shell.js";
|
|
3
|
+
import { scanShell, type ShellScan } from "./shell-argv.js";
|
|
4
|
+
import { scoreShellBaseline } from "./shell-baseline.js";
|
|
5
|
+
import { shellSafetyScore } from "./shell-safety.js";
|
|
6
|
+
import { ask, deny, type RequestScore } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** Honours a guard deny/ask before the residual scorer runs. */
|
|
9
|
+
function guardScore(guarded: ReviewResult | undefined, resources: readonly string[],
|
|
10
|
+
projectDir?: string, worktreeRoot?: string): RequestScore | undefined {
|
|
11
|
+
if (guarded?.decision === "deny") return deny(guarded.reason);
|
|
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.
|
|
15
|
+
const blocked = visibleShellDeny(resources, projectDir, worktreeRoot);
|
|
16
|
+
if (blocked !== undefined) return deny(blocked);
|
|
17
|
+
return ask(guarded.reason);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Scores one shell request: guard floor, hard safety checks, then residual band. */
|
|
21
|
+
export function scoreShellRequest(request: PermissionRequest, worktreeRoot?: string): RequestScore {
|
|
22
|
+
const { resources, projectDir } = request;
|
|
23
|
+
const guarded = guardShell(resources, projectDir);
|
|
24
|
+
const rejected = guardScore(guarded, resources, projectDir, worktreeRoot);
|
|
25
|
+
if (rejected) return rejected;
|
|
26
|
+
const scan: ShellScan = scanShell(resources);
|
|
27
|
+
const unsafe = shellSafetyScore(scan, request, worktreeRoot);
|
|
28
|
+
if (unsafe) return unsafe;
|
|
29
|
+
return scoreShellBaseline(scan, guarded, projectDir, worktreeRoot);
|
|
30
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The risk band a permission request falls into, with the reason shown to the user. */
|
|
2
|
+
export interface RequestScore {
|
|
3
|
+
band: "low" | "medium" | "high";
|
|
4
|
+
reason: string;
|
|
5
|
+
deny?: boolean;
|
|
6
|
+
ask?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const low = (reason: string): RequestScore => ({ band: "low", reason });
|
|
10
|
+
export const medium = (reason: string): RequestScore => ({ band: "medium", reason });
|
|
11
|
+
export const high = (reason: string): RequestScore => ({ band: "high", reason });
|
|
12
|
+
export const deny = (reason: string): RequestScore => ({ band: "high", reason, deny: true });
|
|
13
|
+
/** Missing VCS preconditions gate history changes into manual review, not a hard deny. */
|
|
14
|
+
export const ask = (reason: string): RequestScore => ({ band: "high", reason, ask: true });
|
package/src/score.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compatibility entry point for `./score.js` imports.
|
|
3
|
+
*
|
|
4
|
+
* Scoring lives in the focused modules under `./score/`; this file deliberately
|
|
5
|
+
* re-exports the public surface instead of carrying implementation, so existing
|
|
6
|
+
* callers and the packaged plugin entry keep the same import path.
|
|
7
|
+
*/
|
|
8
|
+
export { scoreRequest } from "./score/index.js";
|
|
9
|
+
export type { RequestScore } from "./score/types.js";
|