@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
package/src/status.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_STATUS_ACTION_CHARS,
|
|
3
|
+
MAX_STATUS_REASON_CHARS,
|
|
4
|
+
MAX_STATUS_REVIEWING,
|
|
5
|
+
MAX_STATUS_STAGE_CHARS,
|
|
6
|
+
type AutoModeCounts,
|
|
7
|
+
type AutoModeDecision,
|
|
8
|
+
type AutoModeErrorKind,
|
|
9
|
+
type AutoModeMode,
|
|
10
|
+
type AutoModeStatus,
|
|
11
|
+
} from "./rpc.js";
|
|
12
|
+
|
|
13
|
+
function clip(value: string, max = MAX_STATUS_REASON_CHARS): string {
|
|
14
|
+
// Count code points so clipping cannot split a surrogate pair.
|
|
15
|
+
const characters = Array.from(value);
|
|
16
|
+
return characters.length > max ? `${characters.slice(0, max - 1).join("")}…` : value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function counts(value: AutoModeCounts | undefined): AutoModeCounts {
|
|
20
|
+
// Rebuild exactly three counters so arbitrary stored/transport keys never leak.
|
|
21
|
+
return { allow: value?.allow ?? 0, ask: value?.ask ?? 0, deny: value?.deny ?? 0 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StatusSnapshotInput {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
mode: AutoModeMode;
|
|
27
|
+
configError?: string;
|
|
28
|
+
errorKind?: AutoModeErrorKind;
|
|
29
|
+
reviewTimeoutMs: number;
|
|
30
|
+
sessionID?: string;
|
|
31
|
+
/** Today's audit counts, supplied by the server rather than accumulated in UI memory. */
|
|
32
|
+
counts?: AutoModeCounts;
|
|
33
|
+
breaker?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface StatusTracker {
|
|
37
|
+
start(sessionID: string, reviewID: string, at: number): void;
|
|
38
|
+
finish(decision: AutoModeDecision): AutoModeDecision;
|
|
39
|
+
snapshot(input: StatusSnapshotInput): AutoModeStatus;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Bounded active-review tracking. Finished decisions are not retained. */
|
|
43
|
+
export function createStatusTracker(): StatusTracker {
|
|
44
|
+
const reviewing = new Map<string, { sessionID: string; startedAt: number }>();
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
start(sessionID, reviewID, at) {
|
|
48
|
+
if (reviewing.size >= MAX_STATUS_REVIEWING && !reviewing.has(reviewID)) {
|
|
49
|
+
const oldest = reviewing.keys().next().value;
|
|
50
|
+
if (oldest !== undefined) reviewing.delete(oldest);
|
|
51
|
+
}
|
|
52
|
+
reviewing.set(reviewID, { sessionID, startedAt: at });
|
|
53
|
+
},
|
|
54
|
+
finish(decision) {
|
|
55
|
+
reviewing.delete(decision.reviewID);
|
|
56
|
+
// Return only the bounded live-decision surface; never retain arbitrary
|
|
57
|
+
// record fields such as removed rule or shadow metadata.
|
|
58
|
+
return {
|
|
59
|
+
reviewID: decision.reviewID,
|
|
60
|
+
sessionID: decision.sessionID,
|
|
61
|
+
action: clip(decision.action, MAX_STATUS_ACTION_CHARS),
|
|
62
|
+
decision: clip(decision.decision, MAX_STATUS_STAGE_CHARS),
|
|
63
|
+
stage: clip(decision.stage, MAX_STATUS_STAGE_CHARS),
|
|
64
|
+
reason: clip(decision.reason),
|
|
65
|
+
durationMs: decision.durationMs,
|
|
66
|
+
at: decision.at,
|
|
67
|
+
...(decision.errorKind === undefined ? {} : { errorKind: clip(decision.errorKind, MAX_STATUS_STAGE_CHARS) }),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
snapshot(input) {
|
|
71
|
+
return {
|
|
72
|
+
enabled: input.enabled,
|
|
73
|
+
mode: input.mode,
|
|
74
|
+
...(input.configError === undefined ? {} : { configError: clip(input.configError) }),
|
|
75
|
+
...(input.errorKind === undefined ? {} : { errorKind: input.errorKind }),
|
|
76
|
+
reviewTimeoutMs: input.reviewTimeoutMs,
|
|
77
|
+
reviewing: [...reviewing.entries()]
|
|
78
|
+
.filter(([, entry]) => input.sessionID === undefined || entry.sessionID === input.sessionID)
|
|
79
|
+
.map(([reviewID, entry]) => ({ sessionID: entry.sessionID, reviewID, startedAt: entry.startedAt })),
|
|
80
|
+
counts: counts(input.counts),
|
|
81
|
+
...(input.breaker === undefined ? {} : { breaker: input.breaker }),
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
package/src/tiers.ts
CHANGED
|
@@ -1,23 +1,260 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { lstatSync, realpathSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { isDataOption } from "./shell-policy.js";
|
|
5
|
+
import { getWorktreeScope } from "./worktree.js";
|
|
3
6
|
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
/**
|
|
87
|
+
* Sensitive spelling of one candidate.
|
|
88
|
+
*
|
|
89
|
+
* DOT_COMPONENT is applied to the path relative to the project root for an
|
|
90
|
+
* absolute target inside it, so an unrelated dotted ancestor such as
|
|
91
|
+
* `~/.local/share/project` does not mark every file below it. SENSITIVE_PATH is
|
|
92
|
+
* still applied to the whole candidate and, in `isProtected`, to every resolved
|
|
93
|
+
* component: a real `.ssh` or `.config` directory stays protected.
|
|
94
|
+
*/
|
|
95
|
+
function sensitiveSpelling(candidate: string, roots: readonly string[]): boolean {
|
|
96
|
+
if (SENSITIVE_PATH.test(candidate)) return true;
|
|
97
|
+
if (path.isAbsolute(candidate)) {
|
|
98
|
+
for (const root of roots) {
|
|
99
|
+
if (isInProject(candidate, root)) return DOT_COMPONENT.test(path.relative(root, path.resolve(candidate)));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return DOT_COMPONENT.test(candidate);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Inspect components before normalization can erase an alias followed by `..`.
|
|
106
|
+
* Absolute paths also start at their filesystem root, not at the location base.
|
|
107
|
+
*/
|
|
108
|
+
export function hasSymlinkTraversal(target: string, locationDir = process.cwd()): boolean {
|
|
109
|
+
let current: string;
|
|
110
|
+
try { current = path.isAbsolute(target) ? path.parse(target).root : realpathSync(locationDir); }
|
|
111
|
+
catch { current = path.resolve(locationDir); }
|
|
112
|
+
for (const part of target.split(path.sep).filter(Boolean)) {
|
|
113
|
+
if (part === ".") continue;
|
|
114
|
+
if (part === "..") { current = path.dirname(current); continue; }
|
|
115
|
+
current = path.join(current, part);
|
|
116
|
+
try {
|
|
117
|
+
if (lstatSync(current).isSymbolicLink()) return true;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
// ENAMETOOLONG means the name cannot exist at all, so it hides no alias.
|
|
120
|
+
if (!ABSENT_COMPONENT.includes((error as NodeJS.ErrnoException).code ?? "")) return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Check lexical spelling and each resolved component, preserving symlink/.. traversal. */
|
|
127
|
+
export function isProtected(target: string, projectDir?: string): boolean {
|
|
128
|
+
const roots = projectRootSpellings(projectDir);
|
|
129
|
+
const candidates = [target, ...target.split("="), ...target.split(":")];
|
|
130
|
+
if (candidates.some((candidate) => sensitiveSpelling(candidate, roots) ||
|
|
131
|
+
/^\/proc\/(?:self|thread-self|\d+)\/(?:environ|mem)(?:$|\/)/.test(candidate))) return true;
|
|
132
|
+
let current: string;
|
|
133
|
+
try { current = path.isAbsolute(target) ? path.parse(target).root : realpathSync(projectDir ?? process.cwd()); }
|
|
134
|
+
catch { return hasSymlinkTraversal(target, path.resolve(projectDir ?? process.cwd())); }
|
|
135
|
+
const protectedPath = (candidate: string) => SENSITIVE_PATH.test(candidate) ||
|
|
136
|
+
/^\/proc\/(?:self|thread-self|\d+)\/(?:environ|mem)(?:$|\/)/.test(candidate);
|
|
137
|
+
if (protectedPath(current)) return true;
|
|
138
|
+
for (const part of target.split(path.sep).filter(Boolean)) {
|
|
139
|
+
if (part === ".") continue;
|
|
140
|
+
current = part === ".." ? path.dirname(current) : path.join(current, part);
|
|
141
|
+
try { current = realpathSync(current); }
|
|
142
|
+
catch (error) {
|
|
143
|
+
// Missing new files are expected; broken aliases and other ambiguous
|
|
144
|
+
// filesystem errors cannot establish that a protected target is absent.
|
|
145
|
+
// ENAMETOOLONG is not ambiguous: no such name can exist.
|
|
146
|
+
try { if (lstatSync(current).isSymbolicLink()) return true; } catch { /* Missing component. */ }
|
|
147
|
+
if (!ABSENT_COMPONENT.includes((error as NodeJS.ErrnoException).code ?? "")) return true;
|
|
148
|
+
}
|
|
149
|
+
if (protectedPath(current)) return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A scheme-qualified URL names a remote resource, never a local path. */
|
|
155
|
+
const SCHEME_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
156
|
+
/** `user@host:path` is a remote Git/SCP location rather than a local path. */
|
|
157
|
+
const SCP_URL = /^[^/\\@:]+@[^/\\:]+:/;
|
|
158
|
+
/** `file:` URLs are local reads; every other scheme is remote. */
|
|
159
|
+
const FILE_URL = /^file:(?:\/\/)?([\s\S]*)$/i;
|
|
160
|
+
|
|
161
|
+
/** Expands the leading home spellings this module can decode without a shell. */
|
|
162
|
+
function expandHome(word: string): string {
|
|
163
|
+
if (word === "~") return homedir();
|
|
164
|
+
if (word.startsWith("~/")) return `${homedir()}${word.slice(1)}`;
|
|
165
|
+
return word.replace(/^\$(?:HOME(?![A-Za-z0-9_])|\{HOME\})/, homedir());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The local path a `file:` URL names, or undefined for any other spelling.
|
|
170
|
+
*
|
|
171
|
+
* A URL is not a shell word, so the shell never expands `~`, `$HOME` or
|
|
172
|
+
* `${HOME}` inside it; they are expanded lexically here. A URL authority that
|
|
173
|
+
* is a host (`localhost`, an address, a dotted name) is not part of the path,
|
|
174
|
+
* while a non-host first segment stays in it. Query and fragment are dropped.
|
|
175
|
+
*/
|
|
176
|
+
function fileUrlPath(word: string): string | undefined {
|
|
177
|
+
const match = FILE_URL.exec(word);
|
|
178
|
+
if (match === null) return undefined;
|
|
179
|
+
const rest = expandHome(match[1].split(/[?#]/, 1)[0]);
|
|
180
|
+
if (rest.startsWith("/")) return rest;
|
|
181
|
+
const slash = rest.indexOf("/");
|
|
182
|
+
const authority = slash === -1 ? rest : rest.slice(0, slash);
|
|
183
|
+
if (slash !== -1 && /^(?:localhost|\[[0-9a-fA-F:.]+\]|[^/]*\.[^/]*)$/.test(authority)) return rest.slice(slash);
|
|
184
|
+
return `/${rest}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The value an option-shaped path operand carries (`-fFILE`, `--file=FILE`).
|
|
189
|
+
* A plain word is returned unchanged, including a bare `-` or `--`.
|
|
190
|
+
*/
|
|
191
|
+
function operandValue(word: string): string {
|
|
192
|
+
if (word.length <= 1 || !word.startsWith("-")) return word;
|
|
193
|
+
const separator = word.indexOf("=");
|
|
194
|
+
if (separator !== -1) return word.slice(separator + 1);
|
|
195
|
+
return word.startsWith("--") ? word : word.slice(2);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Protected-path check for an operand a command consumes as a file.
|
|
200
|
+
*
|
|
201
|
+
* The command's own grammar has already established that the word names a file
|
|
202
|
+
* (`cat credentials`), so the bare sensitive names count here even though they
|
|
203
|
+
* are only data in a pattern position. A word with no separator cannot be more
|
|
204
|
+
* than one component, so only the explicit names apply: a leading dot is not a
|
|
205
|
+
* reason to deny `.name`, `.gitignore` or `.cache`. Paths with a separator keep
|
|
206
|
+
* the full check, including resolved components, symlinks and `file:` URLs.
|
|
207
|
+
*/
|
|
208
|
+
export function isProtectedPathOperand(target: string, projectDir?: string): boolean {
|
|
209
|
+
if (!target) return false;
|
|
210
|
+
const word = operandValue(target);
|
|
211
|
+
const local = fileUrlPath(word);
|
|
212
|
+
if (local !== undefined) return isProtected(local, projectDir);
|
|
213
|
+
if (SCHEME_URL.test(word) || SCP_URL.test(word)) return false;
|
|
214
|
+
return /[/\\]/.test(word) ? isProtected(word, projectDir) : SENSITIVE_PATH.test(word);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Protected-path check for one parsed shell word that the command grammar did
|
|
219
|
+
* not place in a path or data slot.
|
|
220
|
+
*
|
|
221
|
+
* A shell word is often data — a search pattern (`grep -r auth`), a package name
|
|
222
|
+
* (`npm view auth`), a commit message, or a URL — so bare keywords only count
|
|
223
|
+
* when the word can name a path: it contains a separator or carries a value in
|
|
224
|
+
* an option. A separator-less word is matched against the explicit names only,
|
|
225
|
+
* so `jq .name`, `git add .gitignore` and `echo .foo` stay ordinary words while
|
|
226
|
+
* `.env`, `.npmrc` and `auth.json` stay protected. Filename-shaped names
|
|
227
|
+
* (`signing.pem`) stay protected everywhere, and redirect targets are checked
|
|
228
|
+
* with the full `isProtected` because a redirection always names a file.
|
|
229
|
+
*/
|
|
230
|
+
export function isProtectedShellWord(target: string, projectDir?: string): boolean {
|
|
231
|
+
if (!target) return false;
|
|
232
|
+
const separator = target.startsWith("-") ? target.indexOf("=") : -1;
|
|
233
|
+
// `--grep=config/auth`, `--format=src/.env` and `--exclude=.env` carry
|
|
234
|
+
// pattern/format data even when the value is spelled like a path.
|
|
235
|
+
if (separator !== -1 && isDataOption(target.slice(0, separator))) return false;
|
|
236
|
+
const local = fileUrlPath(target);
|
|
237
|
+
if (local !== undefined) return isProtected(local, projectDir);
|
|
238
|
+
if (SCHEME_URL.test(target) || SCP_URL.test(target)) return false;
|
|
239
|
+
const candidates = [target, ...target.split("="), ...target.split(":")];
|
|
240
|
+
// A `file:` value is a local path even when it arrives through an option.
|
|
241
|
+
const files = candidates.map(fileUrlPath).filter((candidate): candidate is string => candidate !== undefined);
|
|
242
|
+
if (files.length) return files.some((file) => isProtected(file, projectDir));
|
|
243
|
+
// Any other scheme or an SCP-style location is remote, never a local path.
|
|
244
|
+
if (candidates.some((candidate) => SCHEME_URL.test(candidate) || SCP_URL.test(candidate))) return false;
|
|
245
|
+
if (target.startsWith("-")) {
|
|
246
|
+
if (separator === -1) return false;
|
|
247
|
+
// `--config=credentials.json` and `--file=.env` name files.
|
|
248
|
+
return isProtectedPathOperand(target.slice(separator + 1), projectDir);
|
|
249
|
+
}
|
|
250
|
+
// `git show HEAD:.env` names a file after the colon; schemes and SCP
|
|
251
|
+
// locations were already excluded above.
|
|
252
|
+
if (!target.includes("=") && target.split(":").slice(1).some((part) => isProtectedPathOperand(part, projectDir)))
|
|
253
|
+
return true;
|
|
254
|
+
// A separator-less word is only a file reference when it carries a
|
|
255
|
+
// filename-shaped name; a bare keyword such as `auth` stays data here.
|
|
256
|
+
return /[/\\]/.test(target) ? isProtected(target, projectDir) : SENSITIVE_NAME.test(target);
|
|
257
|
+
}
|
|
21
258
|
|
|
22
259
|
export function isInProject(target: string, projectDir: string): boolean {
|
|
23
260
|
const resolved = path.resolve(projectDir, target);
|
|
@@ -25,11 +262,39 @@ export function isInProject(target: string, projectDir: string): boolean {
|
|
|
25
262
|
return resolved === root || resolved.startsWith(`${root}${path.sep}`);
|
|
26
263
|
}
|
|
27
264
|
|
|
28
|
-
|
|
265
|
+
/**
|
|
266
|
+
* Lexical containment for low-band read operands. Tildes, parameter expansion
|
|
267
|
+
* and NUL cannot be resolved to a project path here, so they are never low;
|
|
268
|
+
* callers keep the protected-path and symlink checks separate. Both the spelling
|
|
269
|
+
* supplied by the caller and the canonical path are accepted, because a scope
|
|
270
|
+
* discovered with `realpath` may differ from a caller's `/var` vs `/private/var`
|
|
271
|
+
* spelling on macOS.
|
|
272
|
+
*/
|
|
273
|
+
export function isScopedPath(target: string, locationDir: string, worktreeRoot: string): boolean {
|
|
274
|
+
if (!target || target.startsWith("~") || /[$`\0]/.test(target)) return false;
|
|
275
|
+
const directory = path.resolve(locationDir);
|
|
276
|
+
const root = path.resolve(worktreeRoot);
|
|
277
|
+
if (isInProject(directory, root) && isInProject(path.resolve(directory, target), root)) return true;
|
|
278
|
+
try {
|
|
279
|
+
const canonicalDirectory = realpathSync(directory);
|
|
280
|
+
const canonicalRoot = realpathSync(root);
|
|
281
|
+
if (!isInProject(canonicalDirectory, canonicalRoot)) return false;
|
|
282
|
+
const resolved = path.resolve(canonicalDirectory, target);
|
|
283
|
+
let canonical = resolved;
|
|
284
|
+
try { canonical = realpathSync(resolved); } catch { /* New files keep the resolved spelling. */ }
|
|
285
|
+
return isInProject(resolved, canonicalRoot) || isInProject(canonical, canonicalRoot);
|
|
286
|
+
} catch { return false; }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function safeTarget(target: string, locationDir: string, worktreeRoot = projectRoot(locationDir)): boolean {
|
|
290
|
+
if (target.startsWith("~") || /[$`\0]/.test(target)) return false;
|
|
29
291
|
try {
|
|
30
|
-
const root = realpathSync(
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
292
|
+
const root = realpathSync(worktreeRoot);
|
|
293
|
+
const directory = realpathSync(locationDir);
|
|
294
|
+
if (!lstatSync(root).isDirectory() || !lstatSync(directory).isDirectory() || !isInProject(directory, root)) return false;
|
|
295
|
+
if (hasSymlinkTraversal(target, directory)) return false;
|
|
296
|
+
const resolved = path.resolve(directory, target);
|
|
297
|
+
if (!isInProject(resolved, root) || isProtected(target, directory)) return false;
|
|
33
298
|
const parts = path.relative(root, resolved).split(path.sep);
|
|
34
299
|
let current = root;
|
|
35
300
|
for (const part of parts) {
|
|
@@ -44,22 +309,3 @@ function safeTarget(target: string, projectDir: string): boolean {
|
|
|
44
309
|
return true;
|
|
45
310
|
} catch { return false; }
|
|
46
311
|
}
|
|
47
|
-
|
|
48
|
-
export function classify(action: string, resources: readonly string[], options?: ClassifyOptions): Classification {
|
|
49
|
-
if (action === "shell") {
|
|
50
|
-
for (const command of resources) {
|
|
51
|
-
for (const pattern of BLOCKED_SHELL) {
|
|
52
|
-
if (pattern.test(command)) return { tier: "auto-deny", reason: "dangerous shell operation" };
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
if (resources.length > 0 && resources.every((command) => READ_ONLY_COMMANDS.has(command)))
|
|
56
|
-
return { tier: "auto-allow", reason: "read-only command" };
|
|
57
|
-
return { tier: "review", reason: "evaluate the entire command as one action" };
|
|
58
|
-
}
|
|
59
|
-
if (action === "read" || ((action === "edit" || action === "write") && options?.allowInProjectEdits)) {
|
|
60
|
-
if (options?.projectDir && resources.length > 0 && resources.every((target) =>
|
|
61
|
-
target.length > 0 && safeTarget(target, options.projectDir!)))
|
|
62
|
-
return { tier: "auto-allow", reason: "non-sensitive in-project file operation" };
|
|
63
|
-
}
|
|
64
|
-
return { tier: "review", reason: `action '${action}' needs review` };
|
|
65
|
-
}
|
package/src/timeout.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ReviewFailure } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bounds a provider promise and rejects it as soon as `signal` aborts.
|
|
5
|
+
*
|
|
6
|
+
* A provider may ignore the signal, so this wrapper settles on its own and
|
|
7
|
+
* clears both its timer and abort listener on every path. Adding a listener to
|
|
8
|
+
* an already-aborted signal never replays the abort, so check `aborted` after
|
|
9
|
+
* subscribing or a cancelled fallback stage would wait for the full timeout.
|
|
10
|
+
*/
|
|
11
|
+
export function withTimeout<T>(promise: Promise<T>, ms: number, signal?: AbortSignal): Promise<T> {
|
|
12
|
+
return new Promise<T>((resolve, reject) => {
|
|
13
|
+
let settled = false;
|
|
14
|
+
const finish = (action: () => void) => {
|
|
15
|
+
if (settled) return;
|
|
16
|
+
settled = true;
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
signal?.removeEventListener("abort", onAbort);
|
|
19
|
+
action();
|
|
20
|
+
};
|
|
21
|
+
const onAbort = () => finish(() => reject(new ReviewFailure("cancelled", "Review cancelled")));
|
|
22
|
+
const timer = setTimeout(() => finish(() => reject(new ReviewFailure("timeout", "Review timed out"))), ms);
|
|
23
|
+
// Observe the provider promise before any early return: a promise that is
|
|
24
|
+
// already rejected must not become an unhandled rejection just because the
|
|
25
|
+
// signal was aborted before this wrapper was created.
|
|
26
|
+
promise.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
|
|
27
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
28
|
+
if (signal?.aborted) onAbort();
|
|
29
|
+
});
|
|
30
|
+
}
|
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 {
|
|
5
|
-
import {
|
|
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
|
|
7
|
+
const POLL_MS = 1_000;
|
|
8
8
|
|
|
9
9
|
export default Plugin.define({
|
|
10
10
|
id: "opencode-auto-mode",
|
|
11
|
-
setup:
|
|
11
|
+
setup: (ctx) => {
|
|
12
12
|
if (ctx.options.tui === false) return;
|
|
13
|
-
const [
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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: "
|
|
34
|
-
render: () =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/worktree.ts
ADDED
|
@@ -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
|
+
}
|