@mortz.prk/opencode-auto-mode 1.0.2 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +234 -0
- package/README.llm.md +320 -0
- package/README.md +180 -295
- package/dist/gui-experimental.cjs +1 -1
- package/package.json +9 -8
- package/src/audit.ts +130 -49
- package/src/classifier.ts +44 -47
- package/src/code-mode.ts +55 -0
- package/src/config.ts +102 -105
- package/src/debug-events.ts +223 -0
- package/src/decision.ts +72 -0
- package/src/gates.ts +16 -0
- package/src/global-options.ts +85 -0
- package/src/gui-state.ts +230 -121
- package/src/gui.tsx +56 -107
- package/src/index.ts +482 -373
- package/src/mode.ts +237 -0
- package/src/reviewer.ts +70 -94
- package/src/rpc.ts +48 -38
- package/src/score/files.ts +51 -0
- package/src/score/history.ts +141 -0
- package/src/score/index.ts +38 -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 +57 -0
- package/src/score/shell.ts +32 -0
- package/src/score/types.ts +14 -0
- package/src/score.ts +9 -0
- package/src/shell-policy.ts +672 -30
- package/src/shell.ts +276 -55
- package/src/status.ts +49 -36
- package/src/tiers.ts +292 -19
- package/src/tui.tsx +44 -34
- package/src/user-files.ts +75 -0
- package/src/worktree.ts +30 -0
- package/src/decisions.ts +0 -236
- package/src/logger.ts +0 -26
- package/src/otlp.ts +0 -77
- package/src/policy.ts +0 -36
- package/src/state.ts +0 -135
- package/src/storage.ts +0 -26
- package/src/traces.ts +0 -90
- package/src/transcript.ts +0 -204
- package/src/tui-state.ts +0 -13
package/src/audit.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
1
|
import * as os from "node:os";
|
|
3
2
|
import * as path from "node:path";
|
|
4
3
|
import type { ReviewErrorKind } from "./errors.js";
|
|
4
|
+
import { createJsonlLog, type DebugLogSetting } from "./debug-events.js";
|
|
5
|
+
import { parseShellCommands } from "./shell.js";
|
|
6
|
+
import { DYNAMIC, SCRIPT_HOSTS, SHELLS, STATEFUL, normalizeContinuations, resolveExecutable } from "./shell-policy.js";
|
|
5
7
|
|
|
8
|
+
/** Unsafe, opt-in debugging data. Every original field is retained, including secrets. */
|
|
6
9
|
export interface AuditRecord {
|
|
7
10
|
ts: string;
|
|
8
11
|
reviewID: string;
|
|
@@ -10,69 +13,147 @@ export interface AuditRecord {
|
|
|
10
13
|
agent?: string;
|
|
11
14
|
action: string;
|
|
12
15
|
resources: string[];
|
|
13
|
-
|
|
16
|
+
/** Actual host effect before arbitration; older raw records may omit it. */
|
|
17
|
+
hostEffect?: string;
|
|
14
18
|
decision: string;
|
|
19
|
+
proposedDecision?: "allow" | "ask" | "deny";
|
|
20
|
+
/** True when the evaluated host effect was a native/saved allow rather than an ask. */
|
|
21
|
+
reviewedNativeAllow?: boolean;
|
|
22
|
+
/** How the applied effect was chosen: enforced, off, paused or cancelled. */
|
|
23
|
+
disposition?: string;
|
|
24
|
+
/** Message left on the host permission after arbitration, including off restores. */
|
|
25
|
+
appliedMessage?: string;
|
|
15
26
|
stage: string;
|
|
16
27
|
reason: string;
|
|
17
28
|
durationMs: number;
|
|
18
|
-
|
|
29
|
+
score?: number | string;
|
|
30
|
+
breaker?: boolean;
|
|
19
31
|
errorKind?: ReviewErrorKind;
|
|
20
|
-
|
|
21
|
-
traceID?: string;
|
|
22
|
-
spanID?: string;
|
|
32
|
+
[key: string]: unknown;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
export interface AuditLog { write(record: AuditRecord): void }
|
|
26
36
|
|
|
27
|
-
|
|
28
|
-
const MAX_RESOURCES = 16;
|
|
29
|
-
const MAX_RESOURCE_CHARS = 512;
|
|
30
|
-
const MAX_REASON_CHARS = 512;
|
|
31
|
-
const MAX_ACTION_CHARS = 128;
|
|
32
|
-
const MAX_AGENT_CHARS = 128;
|
|
33
|
-
const MAX_STAGE_CHARS = 64;
|
|
34
|
-
const ERROR_KINDS: readonly ReviewErrorKind[] = ["deadline", "timeout", "cancelled", "provider", "invalid-output", "context"];
|
|
35
|
-
|
|
37
|
+
/** XDG state must be an absolute path; relative or empty values fall back to home. */
|
|
36
38
|
export function defaultAuditPath(): string {
|
|
37
|
-
const
|
|
39
|
+
const configured = process.env.XDG_STATE_HOME;
|
|
40
|
+
const base = configured !== undefined && configured !== "" && path.isAbsolute(configured)
|
|
41
|
+
? configured
|
|
42
|
+
: path.join(os.homedir(), ".local", "state");
|
|
38
43
|
return path.join(base, "opencode-auto-mode", "decisions.jsonl");
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
const REDACTED = "[REDACTED]";
|
|
47
|
+
const HTTP_BODY_OPTION = /^--(?:(?:expand-)?(?:data(?:-[a-z]+)?|json|form(?:-string)?)|post-data|post-file|body-data|body-file|upload-file|execute|config)(?:=|$)/;
|
|
48
|
+
const POSSIBLE_HTTP_BODY = /--(?:(?:expand-)?(?:data|json|form)|post-|body-|upload-file|execute|config)|-[^- \t\n]*[dFTKe]/;
|
|
49
|
+
const WGET_BODY_OPTIONS = ["--body-data", "--body-file", "--config", "--execute", "--post-data", "--post-file", "--upload-file"];
|
|
50
|
+
|
|
51
|
+
function isWgetBodyOption(word: string): boolean {
|
|
52
|
+
const option = word.split("=", 1)[0]!;
|
|
53
|
+
if (!option.startsWith("--") || option.length <= 2) return false;
|
|
54
|
+
return WGET_BODY_OPTIONS.filter((name) => name.startsWith(option)).length === 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function uncertainHttpBodyEvidence(value: string): boolean {
|
|
58
|
+
const literal = normalizeContinuations(value).replace(/['"\\]/g, "");
|
|
59
|
+
const abbreviatedWgetOption = /\bwget\b/.test(literal) &&
|
|
60
|
+
(literal.match(/--[A-Za-z0-9-]+/g) ?? []).some(isWgetBodyOption);
|
|
61
|
+
return /(?:curl|wget)/.test(literal) && (POSSIBLE_HTTP_BODY.test(literal) || abbreviatedWgetOption);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isHttpBodyOption(word: string, client: string): boolean {
|
|
65
|
+
return HTTP_BODY_OPTION.test(word) || (client === "wget" && isWgetBodyOption(word));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function wgetShortOptionBehavior(word: string): "body" | "value" | "other" {
|
|
69
|
+
// -e/--execute supplies wgetrc settings, including post_data; stop at value options.
|
|
70
|
+
for (let letter = 1; letter < word.length; letter++) {
|
|
71
|
+
if (word[letter] === "e") return "body";
|
|
72
|
+
if ("oaOUtTwQDAlIRX".includes(word[letter]!))
|
|
73
|
+
return letter === word.length - 1 ? "value" : "other";
|
|
74
|
+
}
|
|
75
|
+
return "other";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function curlShortOptionBehavior(word: string): "body" | "value" | "other" {
|
|
79
|
+
for (let letter = 1; letter < word.length; letter++) {
|
|
80
|
+
if ("dFT".includes(word[letter]!)) return "body";
|
|
81
|
+
if (word[letter] === "K") return "body"; // curl config is not bounded evidence.
|
|
82
|
+
if ("HAeuxowQCrmXbcztEyU".includes(word[letter]!))
|
|
83
|
+
return letter === word.length - 1 ? "value" : "other";
|
|
84
|
+
}
|
|
85
|
+
return "other";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasHttpBodyOptionEvidence(words: readonly string[], client: string, start: number): boolean {
|
|
89
|
+
const args = words.slice(start);
|
|
90
|
+
for (let index = 0; index < args.length; index++) {
|
|
91
|
+
const word = args[index]!;
|
|
92
|
+
if (word === "--") break;
|
|
93
|
+
if (isHttpBodyOption(word, client)) return true;
|
|
94
|
+
if (/^--(?:header|user-agent|referer|user|proxy|output|url|request|cookie|cookie-jar|config|directory-prefix)$/.test(word)) {
|
|
95
|
+
if (word === "--config") return true;
|
|
96
|
+
index++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!/^-[^-]/.test(word)) continue;
|
|
100
|
+
const behavior = client === "wget" ? wgetShortOptionBehavior(word) : curlShortOptionBehavior(word);
|
|
101
|
+
if (behavior === "body") return true;
|
|
102
|
+
if (behavior === "value") index++;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface JsonVisitState { nodes: number; changed: boolean }
|
|
108
|
+
|
|
109
|
+
function visitJson(entry: unknown, level: number, depth: number, state: JsonVisitState): unknown {
|
|
110
|
+
if (++state.nodes > 4096 || level > 32) { state.changed = true; return REDACTED; }
|
|
111
|
+
if (typeof entry === "string") {
|
|
112
|
+
const redacted = redactHttpBodies(entry, depth + 1);
|
|
113
|
+
if (redacted !== entry) state.changed = true;
|
|
114
|
+
return redacted;
|
|
115
|
+
}
|
|
116
|
+
if (Array.isArray(entry)) return entry.map((item) => visitJson(item, level + 1, depth, state));
|
|
117
|
+
if (entry !== null && typeof entry === "object")
|
|
118
|
+
return Object.fromEntries(Object.entries(entry).map(([key, item]) => [key, visitJson(item, level + 1, depth, state)]));
|
|
119
|
+
return entry;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Actual literal invocations, not strings merely mentioning an HTTP client. */
|
|
123
|
+
function segmentHasHttpBodyEvidence(words: readonly string[], globs: readonly boolean[], value: string): boolean {
|
|
124
|
+
if (!words.length) return false;
|
|
125
|
+
const executable = resolveExecutable(words);
|
|
126
|
+
if (!executable || SHELLS.has(executable.name) || SCRIPT_HOSTS.has(executable.name) ||
|
|
127
|
+
DYNAMIC.has(executable.name) || STATEFUL.has(executable.name) || globs.some(Boolean))
|
|
128
|
+
return uncertainHttpBodyEvidence(value);
|
|
129
|
+
return ["curl", "wget"].includes(executable.name) &&
|
|
130
|
+
hasHttpBodyOptionEvidence(words, executable.name, executable.index + 1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function hasHttpBodyEvidence(value: string): boolean {
|
|
134
|
+
const parsed = parseShellCommands(value);
|
|
135
|
+
// Unknown syntax may hide an invocation (substitution, interpreter, heredoc).
|
|
136
|
+
// With a client and body marker present, omit rather than leak into a model.
|
|
137
|
+
if (!parsed?.complete) return uncertainHttpBodyEvidence(value);
|
|
138
|
+
return parsed.segments.some((segment) => segmentHasHttpBodyEvidence(segment.words, segment.globs, value));
|
|
43
139
|
}
|
|
44
140
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
...(record.errorDetail === undefined ? {} : { errorDetail: clip(record.errorDetail, MAX_REASON_CHARS) }),
|
|
59
|
-
};
|
|
141
|
+
/** Evidence privacy helper, independent of the unredacted debugging audit sink. */
|
|
142
|
+
export function redactHttpBodies(value: string, depth = 0): string {
|
|
143
|
+
if (/^\s*(?:\[|\{|")/.test(value)) {
|
|
144
|
+
let parsed: unknown;
|
|
145
|
+
try { parsed = JSON.parse(value); } catch { /* Not a serialized JSON value. */ }
|
|
146
|
+
if (parsed !== undefined) {
|
|
147
|
+
if (depth >= 8) return REDACTED;
|
|
148
|
+
const state: JsonVisitState = { nodes: 0, changed: false };
|
|
149
|
+
const visited = visitJson(parsed, 0, depth, state);
|
|
150
|
+
return state.changed ? JSON.stringify(visited) : value;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return hasHttpBodyEvidence(value) ? "[REDACTED HTTP BODY AND COMMAND EVIDENCE]" : value;
|
|
60
154
|
}
|
|
61
155
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
write(record) {
|
|
67
|
-
let fd: number | undefined;
|
|
68
|
-
try {
|
|
69
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
70
|
-
try { if (fs.statSync(file).size > MAX_BYTES) fs.renameSync(file, `${file}.1`); } catch { /* no file yet */ }
|
|
71
|
-
fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW, 0o600);
|
|
72
|
-
fs.writeSync(fd, `${JSON.stringify(sanitizeRecord(record))}\n`);
|
|
73
|
-
} catch {
|
|
74
|
-
// Auditing is best effort and must not interrupt permission review.
|
|
75
|
-
} finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
76
|
-
},
|
|
77
|
-
};
|
|
156
|
+
/** OFF unless explicitly enabled. Records are neither redacted nor truncated. */
|
|
157
|
+
export function createAuditLog(setting: DebugLogSetting): AuditLog | undefined {
|
|
158
|
+
return createJsonlLog(setting);
|
|
78
159
|
}
|
package/src/classifier.ts
CHANGED
|
@@ -1,53 +1,50 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import type { ClassifierOptions } from "./config.js";
|
|
2
|
+
import { errorKindOf } from "./errors.js";
|
|
3
3
|
import type { PermissionRequest, ReviewResult, StageResult } from "./reviewer.js";
|
|
4
|
-
import {
|
|
5
|
-
import type { ReviewErrorKind } from "./errors.js";
|
|
4
|
+
import type { RequestScore } from "./score.js";
|
|
6
5
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
): Promise<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
let
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
continue;
|
|
6
|
+
export interface ClassifierCallbacks {
|
|
7
|
+
/** Whole-pipeline cancellation/deadline, distinct from each stage's timeout. */
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
decision(stage: Extract<ClassifierOptions, { type: "decision" }>, request: PermissionRequest): Promise<StageResult>;
|
|
10
|
+
llm(stage: Extract<ClassifierOptions, { type: "llm" }>, request: PermissionRequest): Promise<StageResult>;
|
|
11
|
+
onResult?(stage: ClassifierOptions, result: StageResult, index: number): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Configured order is authoritative. The caller applies mandatory deny/ask guards first. */
|
|
15
|
+
export async function runClassifiers(stages: readonly ClassifierOptions[], request: PermissionRequest, score: RequestScore, callbacks: ClassifierCallbacks): Promise<ReviewResult> {
|
|
16
|
+
const interrupted = (stage = "classifiers"): ReviewResult => ({
|
|
17
|
+
decision: "ask", reason: "Classifier pipeline interrupted; human review required", stage,
|
|
18
|
+
errorKind: callbacks.signal?.reason instanceof DOMException && callbacks.signal.reason.name === "TimeoutError" ? "deadline" : "cancelled",
|
|
19
|
+
});
|
|
20
|
+
if (callbacks.signal?.aborted) return interrupted();
|
|
21
|
+
// Defensive guard: no configured stage may override mandatory scoring effects.
|
|
22
|
+
if (score.deny || score.ask)
|
|
23
|
+
return { decision: score.deny ? "deny" : "ask", reason: score.reason, stage: "guard" };
|
|
24
|
+
let last: StageResult | undefined;
|
|
25
|
+
for (const [index, stage] of stages.entries()) {
|
|
26
|
+
if (callbacks.signal?.aborted) return interrupted(stage.type);
|
|
27
|
+
let result: StageResult;
|
|
28
|
+
try {
|
|
29
|
+
if (stage.type === "static") {
|
|
30
|
+
const editsDisabled = stage.allowInProjectEdits === false && ["edit", "write"].includes(request.action);
|
|
31
|
+
result = score.band === "low" && !editsDisabled
|
|
32
|
+
? { decision: "allow", reason: score.reason, stage: "static" }
|
|
33
|
+
: { decision: "abstain", reason: "Static classifier requires further review", stage: "static" };
|
|
34
|
+
} else {
|
|
35
|
+
// Decisions and LLMs see any unresolved band; never silently reorder or insert stages.
|
|
36
|
+
result = stage.type === "decision" ? await callbacks.decision(stage, request) : await callbacks.llm(stage, request);
|
|
39
37
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
...(result.errorKind === undefined ? {} : { errorKind: result.errorKind }),
|
|
43
|
-
};
|
|
44
|
-
return terminal;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
result = { decision: "abstain", reason: "Classifier unavailable", stage: stage.type, errorKind: errorKindOf(error) };
|
|
45
40
|
}
|
|
41
|
+
if (callbacks.signal?.aborted) result = interrupted(stage.type);
|
|
42
|
+
try { callbacks.onResult?.(stage, result, index); } catch { /* Observability must not alter a verdict. */ }
|
|
43
|
+
// Observers can synchronously trigger cancellation too; never return a stale allow.
|
|
44
|
+
if (callbacks.signal?.aborted) return interrupted(stage.type);
|
|
45
|
+
if (result.errorKind === "cancelled" || result.errorKind === "deadline") return { ...result, decision: "ask" };
|
|
46
|
+
if (result.decision === "allow" || result.decision === "deny") return result as ReviewResult;
|
|
47
|
+
last = result;
|
|
46
48
|
}
|
|
47
|
-
return {
|
|
48
|
-
decision: "ask",
|
|
49
|
-
reason: abstained ? `No classifier made a decision (${abstained})` : "No classifier made a decision",
|
|
50
|
-
stage: "none",
|
|
51
|
-
...(abstainedKind === undefined ? {} : { errorKind: abstainedKind }),
|
|
52
|
-
};
|
|
49
|
+
return { ...last, decision: "ask", reason: last?.reason ?? "No classifier resolved the request", stage: last?.stage ?? "classifiers" };
|
|
53
50
|
}
|
package/src/code-mode.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const MAX_CODE_BYTES = 65_536;
|
|
4
|
+
const IDENTITY_PREFIX = "execute:sha256:";
|
|
5
|
+
const METADATA_KEY = "opencodeAutoModeCode";
|
|
6
|
+
|
|
7
|
+
/** Hash the exact UTF-8 program, without normalizing whitespace or punctuation.
|
|
8
|
+
* Native save entries are wildcard patterns: raw JavaScript is never a safe save entry.
|
|
9
|
+
* This identity format intentionally contains neither '*' nor '?'. */
|
|
10
|
+
export function codeModeIdentity(code: string): string {
|
|
11
|
+
return `${IDENTITY_PREFIX}${createHash("sha256").update(code, "utf8").digest("hex")}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function validCode(value: unknown): value is string {
|
|
15
|
+
return typeof value === "string" && value.trim().length > 0 && Buffer.byteLength(value) <= MAX_CODE_BYTES;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Build only the Code Mode-specific fields of permission.assert. The caller owns
|
|
19
|
+
* session, agent and source, and must not mutate the executable input afterwards.
|
|
20
|
+
* No native rules or saved approvals are read, migrated, removed, or overwritten.
|
|
21
|
+
* Legacy raw-code rules generally do not match these identities; broad native
|
|
22
|
+
* execute policies (including '*') still do. Raw-code-specific configured policies
|
|
23
|
+
* require a separate native evaluation by the caller; this helper cannot preserve
|
|
24
|
+
* their semantics by passing raw code as a second resource (that breaks Always).
|
|
25
|
+
*/
|
|
26
|
+
export function codeModePermission(input: unknown) {
|
|
27
|
+
const code = typeof input === "string" ? input : input && typeof input === "object" && !Array.isArray(input)
|
|
28
|
+
? (input as { code?: unknown }).code : undefined;
|
|
29
|
+
if (!validCode(code)) throw new Error("Code Mode input is missing, unsupported or too large for permission review");
|
|
30
|
+
const identity = codeModeIdentity(code);
|
|
31
|
+
return {
|
|
32
|
+
action: "execute" as const,
|
|
33
|
+
resources: [identity],
|
|
34
|
+
save: [identity],
|
|
35
|
+
metadata: { [METADATA_KEY]: { version: 1, identity, code } },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Recover reviewer evidence only when metadata is bound to the sole hash resource.
|
|
40
|
+
* Native deny remains final. Native allows are reviewed whenever auto mode is on;
|
|
41
|
+
* there is no reviewNativeAllows option and no legacy bypass. Returned resources
|
|
42
|
+
* are reviewer-local, not host edits.
|
|
43
|
+
* Undefined means an ordinary non-identity execute request; malformed identity
|
|
44
|
+
* requests throw rather than allowing review of an opaque digest or mismatched code.
|
|
45
|
+
*/
|
|
46
|
+
export function codeModeReviewResources(event: { action: string; resources: readonly string[]; metadata?: Record<string, unknown> }): string[] | undefined {
|
|
47
|
+
if (event.action !== "execute" || !event.resources.some(resource => resource.startsWith(IDENTITY_PREFIX))) return undefined;
|
|
48
|
+
const evidence = event.metadata?.[METADATA_KEY];
|
|
49
|
+
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence))
|
|
50
|
+
throw new Error("Code Mode program evidence is unavailable");
|
|
51
|
+
const { version, identity, code } = evidence as { version?: unknown; identity?: unknown; code?: unknown };
|
|
52
|
+
if (version !== 1 || !validCode(code) || event.resources.length !== 1 || identity !== event.resources[0] || identity !== codeModeIdentity(code))
|
|
53
|
+
throw new Error("Code Mode program evidence does not match its permission identity");
|
|
54
|
+
return [code];
|
|
55
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,137 +1,134 @@
|
|
|
1
1
|
import { Model } from "@opencode/schema/model";
|
|
2
|
-
import type { ReviewPolicy } from "./policy.js";
|
|
3
|
-
import { logsUrl, type OtlpOptions } from "./otlp.js";
|
|
4
2
|
|
|
5
|
-
export type ReviewerModel = string;
|
|
6
3
|
export type ReviewerModelRef = { id: string; providerID: string; variant?: string };
|
|
7
|
-
export type
|
|
8
|
-
apiKeyEnv?: string;
|
|
9
|
-
minConfidence?: number;
|
|
10
|
-
timeoutMs?: number;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export type DecisionStage = {
|
|
14
|
-
type: "decision";
|
|
15
|
-
model: string;
|
|
16
|
-
endpoint?: string;
|
|
17
|
-
policy?: Partial<ReviewPolicy>;
|
|
18
|
-
} & DecisionProvider;
|
|
19
|
-
|
|
20
|
-
export type ClassifierStage =
|
|
4
|
+
export type ClassifierOptions =
|
|
21
5
|
| { type: "static"; allowInProjectEdits?: boolean }
|
|
22
|
-
|
|
|
23
|
-
| { type: "llm"; model
|
|
24
|
-
|
|
6
|
+
| { type: "decision"; model: string; timeoutMs?: number; minConfidence?: number }
|
|
7
|
+
| { type: "llm"; model: string; timeoutMs?: number };
|
|
8
|
+
export interface BreakerOptions { consecutiveDenies: number; windowDenies: number; windowSize: number }
|
|
9
|
+
export interface AuditOptions { enabled: boolean; maxBytes: number }
|
|
25
10
|
export interface AutoModeOptions {
|
|
26
|
-
classifiers:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
reviewTimeoutMs
|
|
11
|
+
classifiers: ClassifierOptions[];
|
|
12
|
+
/** Resolved scoring compatibility field, derived from static stages; not a public option. */
|
|
13
|
+
allowInProjectEdits: boolean;
|
|
14
|
+
tui: boolean;
|
|
15
|
+
reviewTimeoutMs: number;
|
|
16
|
+
breaker: BreakerOptions;
|
|
17
|
+
audit: AuditOptions;
|
|
31
18
|
}
|
|
32
19
|
|
|
33
|
-
|
|
34
|
-
export const DEFAULT_REVIEW_TIMEOUT_MS = 60_000;
|
|
20
|
+
export const DEFAULT_REVIEW_TIMEOUT_MS = 15_000;
|
|
35
21
|
export const MAX_REVIEW_TIMEOUT_MS = 120_000;
|
|
22
|
+
/** Audited JSONL rotation limit; the debug event sink uses the same 10 MiB cap. */
|
|
23
|
+
export const DEFAULT_AUDIT_MAX_BYTES = 10_485_760;
|
|
24
|
+
const DEFAULT_BREAKER: BreakerOptions = { consecutiveDenies: 3, windowDenies: 10, windowSize: 50 };
|
|
36
25
|
|
|
37
26
|
export function object(value: unknown): value is Record<string, unknown> {
|
|
38
27
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
28
|
}
|
|
40
|
-
|
|
41
29
|
function text(value: unknown): value is string {
|
|
42
30
|
return typeof value === "string" && value.trim().length > 0 && value.length <= 4096;
|
|
43
31
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
32
|
+
function only(value: Record<string, unknown>, keys: readonly string[]): void {
|
|
33
|
+
const unknown = Object.keys(value).find((key) => !keys.includes(key));
|
|
34
|
+
if (unknown) throw new Error(`Unknown auto-mode option '${unknown}'`);
|
|
35
|
+
}
|
|
36
|
+
function invalid(name: string): never { throw new Error(`Invalid auto-mode ${name}`); }
|
|
37
|
+
function integer(value: unknown, max: number): value is number {
|
|
38
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= max;
|
|
47
39
|
}
|
|
48
|
-
|
|
49
40
|
export function parseReviewerModel(value: string): ReviewerModelRef {
|
|
50
41
|
const ref = Model.Ref.parse(value);
|
|
51
|
-
return {
|
|
52
|
-
id: ref.id,
|
|
53
|
-
providerID: ref.providerID,
|
|
54
|
-
...(ref.variant === undefined ? {} : { variant: ref.variant }),
|
|
55
|
-
};
|
|
42
|
+
return { id: ref.id, providerID: ref.providerID, ...(ref.variant === undefined ? {} : { variant: ref.variant }) };
|
|
56
43
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (typeof value !== "string" || !text(value)) return false;
|
|
44
|
+
function model(value: unknown): value is string {
|
|
45
|
+
if (!text(value)) return false;
|
|
60
46
|
try { parseReviewerModel(value); return true; } catch { return false; }
|
|
61
47
|
}
|
|
62
48
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
!['127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.search || url.hash ||
|
|
72
|
-
url.pathname !== '/v1/systemone') throw new Error("Local System One requires a loopback /v1/systemone endpoint");
|
|
73
|
-
return url.href;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function otlp(value: unknown): value is OtlpOptions {
|
|
77
|
-
if (!object(value) || !only(value, ["endpoint", "headers", "timeoutMs"])) return false;
|
|
78
|
-
if (value.endpoint !== undefined) {
|
|
79
|
-
if (!text(value.endpoint)) return false;
|
|
80
|
-
try { logsUrl(value.endpoint); } catch { return false; }
|
|
49
|
+
/** One configured pipeline stage. Unknown keys are rejected per stage type. */
|
|
50
|
+
function parseClassifier(stage: unknown): ClassifierOptions {
|
|
51
|
+
if (!object(stage)) invalid("classifier");
|
|
52
|
+
if (stage.type === "static") {
|
|
53
|
+
only(stage, ["type", "allowInProjectEdits"]);
|
|
54
|
+
if (stage.allowInProjectEdits !== undefined && typeof stage.allowInProjectEdits !== "boolean")
|
|
55
|
+
invalid("static allowInProjectEdits");
|
|
56
|
+
return { type: "static", ...(stage.allowInProjectEdits === undefined ? {} : { allowInProjectEdits: stage.allowInProjectEdits }) };
|
|
81
57
|
}
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
58
|
+
if (stage.type !== "decision" && stage.type !== "llm") invalid("classifier type");
|
|
59
|
+
only(stage, stage.type === "decision" ? ["type", "model", "timeoutMs", "minConfidence"] : ["type", "model", "timeoutMs"]);
|
|
60
|
+
if (!model(stage.model)) invalid("classifier model");
|
|
61
|
+
if (stage.timeoutMs !== undefined && !integer(stage.timeoutMs, MAX_REVIEW_TIMEOUT_MS)) invalid("classifier timeoutMs");
|
|
62
|
+
if (stage.type === "decision" && stage.minConfidence !== undefined &&
|
|
63
|
+
(typeof stage.minConfidence !== "number" || !Number.isFinite(stage.minConfidence) ||
|
|
64
|
+
stage.minConfidence < 0 || stage.minConfidence > 1))
|
|
65
|
+
invalid("decision minConfidence");
|
|
66
|
+
return { type: stage.type, model: stage.model,
|
|
67
|
+
...(stage.timeoutMs === undefined ? {} : { timeoutMs: stage.timeoutMs as number }),
|
|
68
|
+
...(stage.type === "decision" && stage.minConfidence !== undefined ? { minConfidence: stage.minConfidence as number } : {}) };
|
|
86
69
|
}
|
|
87
70
|
|
|
88
|
-
|
|
71
|
+
/** Required, non-empty, and at most one static stage. Exactly one static stage may
|
|
72
|
+
* exist: a second is redundant at best and makes the effective project-edit policy
|
|
73
|
+
* ambiguous. */
|
|
74
|
+
function parseClassifiers(value: unknown): ClassifierOptions[] {
|
|
75
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 64) invalid("classifiers");
|
|
76
|
+
const classifiers = value.map(parseClassifier);
|
|
77
|
+
if (classifiers.filter((stage) => stage.type === "static").length > 1) invalid("classifiers");
|
|
78
|
+
return classifiers;
|
|
79
|
+
}
|
|
89
80
|
|
|
90
|
-
function
|
|
91
|
-
if (!
|
|
92
|
-
|
|
93
|
-
if (!
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (value.timeoutMs !== undefined &&
|
|
102
|
-
(typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) ||
|
|
103
|
-
value.timeoutMs < 1 || value.timeoutMs > 30_000)) return false;
|
|
104
|
-
if (value.endpoint !== undefined) {
|
|
105
|
-
if (ref.providerID !== "laya" || !text(value.endpoint)) return false;
|
|
106
|
-
try { localEndpoint(value.endpoint); } catch { return false; }
|
|
107
|
-
}
|
|
108
|
-
return true;
|
|
81
|
+
function parseBreaker(value: unknown): BreakerOptions {
|
|
82
|
+
if (!object(value)) invalid("breaker");
|
|
83
|
+
only(value, ["consecutiveDenies", "windowDenies", "windowSize"]);
|
|
84
|
+
if (!integer(value.consecutiveDenies, 10_000) || !integer(value.windowDenies, 10_000) ||
|
|
85
|
+
!integer(value.windowSize, 10_000) || value.windowDenies > value.windowSize)
|
|
86
|
+
invalid("breaker thresholds");
|
|
87
|
+
return {
|
|
88
|
+
consecutiveDenies: value.consecutiveDenies,
|
|
89
|
+
windowDenies: value.windowDenies,
|
|
90
|
+
windowSize: value.windowSize,
|
|
91
|
+
};
|
|
109
92
|
}
|
|
110
93
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (value
|
|
114
|
-
|
|
115
|
-
if (value.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return
|
|
94
|
+
/** Audit settings are an object only; a bare path or boolean was a removed shape. */
|
|
95
|
+
function parseAudit(value: unknown): AuditOptions {
|
|
96
|
+
if (!object(value)) invalid("audit");
|
|
97
|
+
only(value, ["enabled", "maxBytes"]);
|
|
98
|
+
if (value.enabled !== undefined && typeof value.enabled !== "boolean") invalid("audit enabled");
|
|
99
|
+
if (value.maxBytes !== undefined && (typeof value.maxBytes !== "number" || !Number.isSafeInteger(value.maxBytes) ||
|
|
100
|
+
value.maxBytes < 0)) invalid("audit maxBytes");
|
|
101
|
+
return {
|
|
102
|
+
enabled: value.enabled === undefined ? false : value.enabled,
|
|
103
|
+
maxBytes: value.maxBytes === undefined ? DEFAULT_AUDIT_MAX_BYTES : value.maxBytes,
|
|
104
|
+
};
|
|
119
105
|
}
|
|
120
106
|
|
|
107
|
+
/** Strict validation only; callers must obtain provenance via loadGlobalOptions,
|
|
108
|
+
* never from the SDK's project-merged ctx.options. */
|
|
121
109
|
export function parseOptions(value: unknown): AutoModeOptions {
|
|
122
|
-
if (!object(value))
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
110
|
+
if (!object(value)) invalid("options");
|
|
111
|
+
only(value, ["classifiers", "reviewTimeoutMs", "breaker", "tui", "audit"]);
|
|
112
|
+
const classifiers = parseClassifiers(value.classifiers);
|
|
113
|
+
const result: AutoModeOptions = {
|
|
114
|
+
classifiers,
|
|
115
|
+
// In-project edit auto-allow remains the default (owner-approved); a static
|
|
116
|
+
// stage opts out by setting allowInProjectEdits: false.
|
|
117
|
+
allowInProjectEdits: classifiers.some((stage) => stage.type === "static" && stage.allowInProjectEdits !== false),
|
|
118
|
+
tui: true,
|
|
119
|
+
reviewTimeoutMs: DEFAULT_REVIEW_TIMEOUT_MS,
|
|
120
|
+
breaker: { ...DEFAULT_BREAKER },
|
|
121
|
+
audit: { enabled: false, maxBytes: DEFAULT_AUDIT_MAX_BYTES },
|
|
122
|
+
};
|
|
123
|
+
if (value.reviewTimeoutMs !== undefined) {
|
|
124
|
+
if (!integer(value.reviewTimeoutMs, MAX_REVIEW_TIMEOUT_MS)) invalid("reviewTimeoutMs");
|
|
125
|
+
result.reviewTimeoutMs = value.reviewTimeoutMs;
|
|
126
|
+
}
|
|
127
|
+
if (value.tui !== undefined) {
|
|
128
|
+
if (typeof value.tui !== "boolean") invalid("tui");
|
|
129
|
+
result.tui = value.tui;
|
|
130
|
+
}
|
|
131
|
+
if (value.breaker !== undefined) result.breaker = parseBreaker(value.breaker);
|
|
132
|
+
if (value.audit !== undefined) result.audit = parseAudit(value.audit);
|
|
133
|
+
return result;
|
|
137
134
|
}
|