opencode-agent-skill 15.1.0 → 15.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/docs/V15-MANAGED-RUNTIME.md +18 -0
- package/global-config/agents/verifier.md +6 -2
- package/lib/affected-tests.mjs +28 -7
- package/lib/capability-fabric.mjs +107 -48
- package/lib/code-intelligence/index.mjs +50 -24
- package/lib/code-intelligence/lsp-pool.mjs +831 -0
- package/lib/code-intelligence/lsp-provider.mjs +197 -20
- package/lib/compaction-resume-guard.mjs +76 -4
- package/lib/completion-auditor.mjs +20 -0
- package/lib/context-manifest.mjs +88 -36
- package/lib/decision-policy.mjs +40 -2
- package/lib/evidence-store.mjs +121 -31
- package/lib/executable-probe.mjs +69 -0
- package/lib/execution-contract.mjs +2 -2
- package/lib/hierarchical-context.mjs +31 -3
- package/lib/learning-engine.mjs +164 -65
- package/lib/memory-engine.mjs +177 -95
- package/lib/model-config.mjs +86 -7
- package/lib/performance-fabric.mjs +92 -0
- package/lib/permission-policy.mjs +232 -0
- package/lib/pi-rpc-pool.mjs +220 -24
- package/lib/provider-recovery.mjs +51 -0
- package/lib/repo-graph.mjs +40 -7
- package/lib/repo-inspect.mjs +3 -2
- package/lib/runtime-events.mjs +31 -10
- package/lib/semantic-index.mjs +69 -21
- package/lib/skill-compiler.mjs +84 -18
- package/lib/task-engine.mjs +28 -9
- package/lib/task-policy.mjs +80 -7
- package/lib/trajectory.mjs +25 -1
- package/lib/untrusted-output.mjs +106 -0
- package/lib/workspace-fingerprint.mjs +17 -9
- package/lib/workspace-hygiene.mjs +2 -2
- package/lib/worktree-sandbox.mjs +14 -8
- package/package.json +8 -6
- package/pi/extensions/ues-child-runtime.ts +116 -5
- package/pi/extensions/ues.ts +780 -81
- package/scripts/benchmark-lsp-pool.mjs +128 -0
- package/scripts/check-release-consistency.mjs +16 -4
- package/scripts/check-source-integrity.mjs +118 -6
- package/scripts/run-test-suite.mjs +143 -0
- package/scripts/smoke-package-closure.mjs +4 -0
package/lib/model-config.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs"
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
2
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import { defaultModelPolicy, resolveModel } from "./model-policy.mjs"
|
|
5
5
|
import { normalizeCapabilityProfile } from "./capability-registry.mjs"
|
|
@@ -7,6 +7,70 @@ import { normalizePerformanceHistory, recordPerformanceOutcome } from "./model-p
|
|
|
7
7
|
|
|
8
8
|
const TIERS = new Set(["light", "standard", "heavy"])
|
|
9
9
|
|
|
10
|
+
const MODEL_POLICY_WRITE_TAILS = new Map()
|
|
11
|
+
const MODEL_POLICY_LOCK_STALE_MS = 15_000
|
|
12
|
+
const MODEL_POLICY_LOCK_WAIT_MS = 20_000
|
|
13
|
+
|
|
14
|
+
function sleep(ms) {
|
|
15
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function modelPolicyLockDir(configDir) {
|
|
19
|
+
return modelPolicyFile(configDir) + ".lock"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function withCrossProcessModelPolicyLock(configDir, fn) {
|
|
23
|
+
const lockDir = modelPolicyLockDir(configDir)
|
|
24
|
+
await mkdir(path.dirname(lockDir), { recursive: true })
|
|
25
|
+
const deadline = Date.now() + MODEL_POLICY_LOCK_WAIT_MS
|
|
26
|
+
let delayMs = 8
|
|
27
|
+
while (true) {
|
|
28
|
+
try {
|
|
29
|
+
await mkdir(lockDir)
|
|
30
|
+
break
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error?.code !== "EEXIST") throw error
|
|
33
|
+
const info = await stat(lockDir).catch(() => null)
|
|
34
|
+
if (info && Date.now() - info.mtimeMs > MODEL_POLICY_LOCK_STALE_MS) {
|
|
35
|
+
const confirmed = await stat(lockDir).catch(() => null)
|
|
36
|
+
if (
|
|
37
|
+
confirmed &&
|
|
38
|
+
confirmed.ino === info.ino &&
|
|
39
|
+
confirmed.mtimeMs === info.mtimeMs
|
|
40
|
+
) {
|
|
41
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => {})
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (Date.now() >= deadline) throw new Error("Timed out waiting for model-policy write lock")
|
|
46
|
+
await sleep(delayMs)
|
|
47
|
+
delayMs = Math.min(100, delayMs * 2)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return await fn()
|
|
52
|
+
} finally {
|
|
53
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => {})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function withModelPolicyWriteLock(configDir, fn) {
|
|
58
|
+
const key = path.resolve(configDir)
|
|
59
|
+
const previous = MODEL_POLICY_WRITE_TAILS.get(key) || Promise.resolve()
|
|
60
|
+
let release
|
|
61
|
+
const barrier = new Promise((resolve) => { release = resolve })
|
|
62
|
+
const tail = previous.catch(() => {}).then(() => barrier)
|
|
63
|
+
MODEL_POLICY_WRITE_TAILS.set(key, tail)
|
|
64
|
+
|
|
65
|
+
await previous.catch(() => {})
|
|
66
|
+
try {
|
|
67
|
+
return await withCrossProcessModelPolicyLock(configDir, fn)
|
|
68
|
+
} finally {
|
|
69
|
+
release()
|
|
70
|
+
if (MODEL_POLICY_WRITE_TAILS.get(key) === tail) MODEL_POLICY_WRITE_TAILS.delete(key)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
10
74
|
function validModelID(value) {
|
|
11
75
|
return typeof value === "string" && /^[^\s/]+\/[^\s#]+(?:#[^\s]+)?$/.test(value)
|
|
12
76
|
}
|
|
@@ -61,8 +125,7 @@ export async function readModelPolicy(configDir) {
|
|
|
61
125
|
}
|
|
62
126
|
}
|
|
63
127
|
|
|
64
|
-
|
|
65
|
-
const current = await readModelPolicy(configDir)
|
|
128
|
+
async function persistModelPolicy(configDir, current, patch = {}) {
|
|
66
129
|
const merged = normalize({
|
|
67
130
|
...current,
|
|
68
131
|
...patch,
|
|
@@ -73,10 +136,24 @@ export async function writeModelPolicy(configDir, patch = {}) {
|
|
|
73
136
|
})
|
|
74
137
|
const file = modelPolicyFile(configDir)
|
|
75
138
|
await mkdir(path.dirname(file), { recursive: true })
|
|
76
|
-
|
|
139
|
+
const temp = file + "." + process.pid + "." + Date.now() + ".tmp"
|
|
140
|
+
await writeFile(temp, JSON.stringify(merged, null, 2) + "\n", "utf8")
|
|
141
|
+
try {
|
|
142
|
+
await rename(temp, file)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
await rm(temp, { force: true }).catch(() => {})
|
|
145
|
+
throw error
|
|
146
|
+
}
|
|
77
147
|
return { ...merged, file }
|
|
78
148
|
}
|
|
79
149
|
|
|
150
|
+
export async function writeModelPolicy(configDir, patch = {}) {
|
|
151
|
+
return withModelPolicyWriteLock(configDir, async () => {
|
|
152
|
+
const current = await readModelPolicy(configDir)
|
|
153
|
+
return persistModelPolicy(configDir, current, patch)
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
80
157
|
export function validateModelID(value) {
|
|
81
158
|
return validModelID(value)
|
|
82
159
|
}
|
|
@@ -102,7 +179,9 @@ export function applyConfiguredModel(source, role, policy) {
|
|
|
102
179
|
}
|
|
103
180
|
|
|
104
181
|
export async function recordModelPerformance(configDir, outcome = {}) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
182
|
+
return withModelPolicyWriteLock(configDir, async () => {
|
|
183
|
+
const current = await readModelPolicy(configDir)
|
|
184
|
+
const performance = recordPerformanceOutcome(current.performance || {}, outcome)
|
|
185
|
+
return persistModelPolicy(configDir, current, { performance })
|
|
186
|
+
})
|
|
108
187
|
}
|
|
@@ -91,6 +91,73 @@ function gitDiffRows(source, limit = 140) {
|
|
|
91
91
|
], limit)
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
function gitStatusRows(source, limit = 120) {
|
|
95
|
+
return matchingLines(source, [
|
|
96
|
+
/^##\s+/,
|
|
97
|
+
/^On branch\s+/i,
|
|
98
|
+
/^Your branch\s+/i,
|
|
99
|
+
/^Changes to be committed:/i,
|
|
100
|
+
/^Changes not staged for commit:/i,
|
|
101
|
+
/^Untracked files:/i,
|
|
102
|
+
/^Unmerged paths:/i,
|
|
103
|
+
/^\s*(?:nothing to commit|working tree clean)/i,
|
|
104
|
+
/^[ MARCUD?!]{1,2}\s+\S+/,
|
|
105
|
+
], limit)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function gitLogRows(source, limit = 100) {
|
|
109
|
+
return matchingLines(source, [
|
|
110
|
+
/^commit\s+[0-9a-f]{7,40}\b/i,
|
|
111
|
+
/^Author:\s+/i,
|
|
112
|
+
/^Date:\s+/i,
|
|
113
|
+
/^Merge:\s+/i,
|
|
114
|
+
/^\s{4}\S+/,
|
|
115
|
+
/^fatal:/i,
|
|
116
|
+
/^error:/i,
|
|
117
|
+
], limit)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function verificationTestRows(source, limit = 120) {
|
|
121
|
+
return matchingLines(source, [
|
|
122
|
+
/^(?:ok|FAIL)\s+\S+/,
|
|
123
|
+
/^---\s+(?:PASS|FAIL):\s+/,
|
|
124
|
+
/^test result:\s+(?:ok|FAILED)\b/i,
|
|
125
|
+
/^failures?:/i,
|
|
126
|
+
/^running\s+\d+\s+tests?/i,
|
|
127
|
+
/^Finished\s+test\b/i,
|
|
128
|
+
/^Total tests:\s*\d+/i,
|
|
129
|
+
/^\s*(?:Passed|Failed|Skipped):\s*\d+/i,
|
|
130
|
+
/\b(?:panic|assertion failed|FAILED|FAILURE|ERROR)\b/i,
|
|
131
|
+
/\berror\[E\d+\]/,
|
|
132
|
+
/\bBUILD\s+(?:SUCCESS|FAILURE)\b/i,
|
|
133
|
+
/\bTests run:\s*\d+/i,
|
|
134
|
+
], limit)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function pythonLintRows(source, limit = 120) {
|
|
138
|
+
return matchingLines(source, [
|
|
139
|
+
/^.+:\d+:\d+:\s+[A-Z]+\d+\s+/,
|
|
140
|
+
/^.+:\d+:\d+:\s+(?:error|warning)\b/i,
|
|
141
|
+
/^Found\s+\d+\s+errors?/i,
|
|
142
|
+
/^All checks passed!?$/i,
|
|
143
|
+
/^\d+\s+files?\s+(?:reformatted|left unchanged)/i,
|
|
144
|
+
/\b(?:error|warning|failed)\b/i,
|
|
145
|
+
], limit)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function packageInstallRows(source, limit = 120) {
|
|
149
|
+
return matchingLines(source, [
|
|
150
|
+
/\b(?:added|removed|changed)\s+\d+\s+packages?\b/i,
|
|
151
|
+
/\baudited\s+\d+\s+packages?\b/i,
|
|
152
|
+
/\b\d+\s+packages?\s+(?:are\s+)?looking for funding\b/i,
|
|
153
|
+
/\b\d+\s+vulnerabilit(?:y|ies)\b/i,
|
|
154
|
+
/\b(?:deprecated|warning|warn|error|ERR!|ELIFECYCLE|ERR_)\b/i,
|
|
155
|
+
/^Packages:\s+[+-]?\d+/i,
|
|
156
|
+
/^Progress:\s+/i,
|
|
157
|
+
/^Done in\s+/i,
|
|
158
|
+
], limit)
|
|
159
|
+
}
|
|
160
|
+
|
|
94
161
|
function rgRows(source, limit = 100) {
|
|
95
162
|
const lines = String(source || "").split(/\r?\n/).filter(Boolean)
|
|
96
163
|
const matched = []
|
|
@@ -213,6 +280,16 @@ const REDUCERS = Object.freeze([
|
|
|
213
280
|
match: (hint) => /\b(?:pytest|py\.test)\b/.test(hint),
|
|
214
281
|
rows: pytestRows,
|
|
215
282
|
},
|
|
283
|
+
{
|
|
284
|
+
family: "git-status",
|
|
285
|
+
match: (hint) => /\bgit\s+status\b/.test(hint),
|
|
286
|
+
rows: gitStatusRows,
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
family: "git-log",
|
|
290
|
+
match: (hint) => /\bgit\s+log\b/.test(hint),
|
|
291
|
+
rows: gitLogRows,
|
|
292
|
+
},
|
|
216
293
|
{
|
|
217
294
|
family: "git-diff",
|
|
218
295
|
match: (hint) => /\bgit\s+(?:diff|show)\b/.test(hint),
|
|
@@ -248,6 +325,21 @@ const REDUCERS = Object.freeze([
|
|
|
248
325
|
match: (hint) => /\bprisma\b|schema\.prisma/.test(hint),
|
|
249
326
|
rows: prismaRows,
|
|
250
327
|
},
|
|
328
|
+
{
|
|
329
|
+
family: "package-install",
|
|
330
|
+
match: (hint) => /(?:^|\s)(?:npm\s+(?:i|install|ci)|pnpm\s+(?:i|install|add)|yarn\s+(?:install|add))(?:\s|$)/.test(hint),
|
|
331
|
+
rows: packageInstallRows,
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
family: "verification-test",
|
|
335
|
+
match: (hint) => /\b(?:go\s+test|cargo\s+test|dotnet\s+test|mvn\s+test|gradle\s+test)\b/.test(hint),
|
|
336
|
+
rows: verificationTestRows,
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
family: "python-lint",
|
|
340
|
+
match: (hint) => /\b(?:ruff|flake8|pylint)\b/.test(hint),
|
|
341
|
+
rows: pythonLintRows,
|
|
342
|
+
},
|
|
251
343
|
{
|
|
252
344
|
family: "build",
|
|
253
345
|
match: (hint) => /\b(?:compile|build|gradle|maven|mvn|cargo\s+check|dotnet\s+build)\b/.test(hint),
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises"
|
|
2
|
+
|
|
3
|
+
const EFFECTS = new Set(["allow", "deny", "ask"])
|
|
4
|
+
|
|
5
|
+
function normalizeSlashes(value) {
|
|
6
|
+
return String(value || "").replaceAll("\\", "/")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function escapeRegex(value) {
|
|
10
|
+
return value.replace(/[|\\{}()[\]^$+.\-]/g, "\\$&")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function wildcardRegex(pattern, { caseInsensitive = false, shell = false } = {}) {
|
|
14
|
+
let value = normalizeSlashes(pattern)
|
|
15
|
+
const shellBare = shell && value.endsWith(" *")
|
|
16
|
+
? value.slice(0, -2)
|
|
17
|
+
: null
|
|
18
|
+
const compile = (input) => {
|
|
19
|
+
let source = ""
|
|
20
|
+
for (const char of input) {
|
|
21
|
+
if (char === "*") source += ".*"
|
|
22
|
+
else if (char === "?") source += "."
|
|
23
|
+
else source += escapeRegex(char)
|
|
24
|
+
}
|
|
25
|
+
return source
|
|
26
|
+
}
|
|
27
|
+
const alternatives = shellBare === null
|
|
28
|
+
? [compile(value)]
|
|
29
|
+
: [compile(value), compile(shellBare)]
|
|
30
|
+
return new RegExp(`^(?:${alternatives.join("|")})$`, caseInsensitive ? "i" : "")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function permissionPatternMatches(pattern, value, options = {}) {
|
|
34
|
+
return wildcardRegex(String(pattern || ""), options).test(normalizeSlashes(value))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizePermissionRules(value) {
|
|
38
|
+
const rules = Array.isArray(value) ? value : []
|
|
39
|
+
return rules.map((rule, index) => {
|
|
40
|
+
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
|
|
41
|
+
throw new Error(`permission rule ${index} must be an object`)
|
|
42
|
+
}
|
|
43
|
+
const action = String(rule.action || "").trim()
|
|
44
|
+
const resource = String(rule.resource || "").trim()
|
|
45
|
+
const effect = String(rule.effect || "").trim().toLowerCase()
|
|
46
|
+
if (!action || !resource || !EFFECTS.has(effect)) {
|
|
47
|
+
throw new Error(`permission rule ${index} requires action, resource and effect=allow|deny|ask`)
|
|
48
|
+
}
|
|
49
|
+
return { action, resource, effect }
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resourceValue(action, value) {
|
|
54
|
+
if (["read", "edit", "external_directory"].includes(String(action || ""))) {
|
|
55
|
+
return normalizeSlashes(value)
|
|
56
|
+
}
|
|
57
|
+
return String(value || "")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function matchRule(rule, request, platform) {
|
|
61
|
+
const caseInsensitive = platform === "win32"
|
|
62
|
+
const actionMatches = permissionPatternMatches(rule.action, request.action, { caseInsensitive })
|
|
63
|
+
if (!actionMatches) return false
|
|
64
|
+
return permissionPatternMatches(
|
|
65
|
+
rule.resource,
|
|
66
|
+
resourceValue(request.action, request.resource),
|
|
67
|
+
{ caseInsensitive, shell: request.action === "shell" },
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function evaluatePermissionRules(rules, request = {}, options = {}) {
|
|
72
|
+
const normalized = normalizePermissionRules(rules)
|
|
73
|
+
const action = String(request.action || "").trim()
|
|
74
|
+
const resources = (Array.isArray(request.resources) ? request.resources : [request.resource])
|
|
75
|
+
.filter((value) => value !== undefined && value !== null)
|
|
76
|
+
.map((value) => String(value))
|
|
77
|
+
const platform = String(options.platform || process.platform)
|
|
78
|
+
const defaultEffect = EFFECTS.has(options.defaultEffect) ? options.defaultEffect : "allow"
|
|
79
|
+
if (!action) throw new Error("permission request requires action")
|
|
80
|
+
if (!resources.length) resources.push("*")
|
|
81
|
+
|
|
82
|
+
const decisions = resources.map((resource) => {
|
|
83
|
+
let matchedRule = null
|
|
84
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
85
|
+
const rule = normalized[index]
|
|
86
|
+
if (matchRule(rule, { action, resource }, platform)) {
|
|
87
|
+
matchedRule = { ...rule, index }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
resource,
|
|
92
|
+
effect: matchedRule?.effect || defaultEffect,
|
|
93
|
+
matchedRule,
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const effect = decisions.some((item) => item.effect === "deny")
|
|
98
|
+
? "deny"
|
|
99
|
+
: decisions.some((item) => item.effect === "ask")
|
|
100
|
+
? "ask"
|
|
101
|
+
: "allow"
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
schemaVersion: 1,
|
|
105
|
+
action,
|
|
106
|
+
effect,
|
|
107
|
+
defaultEffect,
|
|
108
|
+
decisions,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function toolPermissionRequest(toolName, input = {}) {
|
|
113
|
+
const name = String(toolName || "").trim()
|
|
114
|
+
const lower = name.toLowerCase()
|
|
115
|
+
if (lower === "bash" || lower === "powershell") {
|
|
116
|
+
return { action: "shell", resources: [String(input.command || "")] }
|
|
117
|
+
}
|
|
118
|
+
if (lower === "read") {
|
|
119
|
+
return { action: "read", resources: [String(input.path || input.file || input.filePath || "*")] }
|
|
120
|
+
}
|
|
121
|
+
if (["edit", "write", "write_file", "apply_patch", "ues_code_edit"].includes(lower)) {
|
|
122
|
+
return { action: "edit", resources: [String(input.path || input.file || input.filePath || input.target || "*")] }
|
|
123
|
+
}
|
|
124
|
+
if (lower === "grep") {
|
|
125
|
+
return { action: "grep", resources: [String(input.pattern || input.query || "*")] }
|
|
126
|
+
}
|
|
127
|
+
if (lower === "find" || lower === "glob") {
|
|
128
|
+
return { action: "glob", resources: [String(input.pattern || input.glob || input.query || "*")] }
|
|
129
|
+
}
|
|
130
|
+
if (lower === "ues_dispatch") {
|
|
131
|
+
return { action: "subagent", resources: [String(input.agent || input.target || "*")] }
|
|
132
|
+
}
|
|
133
|
+
if (lower === "ues_session") {
|
|
134
|
+
return { action: "session", resources: [String(input.action || "*")] }
|
|
135
|
+
}
|
|
136
|
+
const action = lower.replace(/[^a-z0-9_]+/g, "_") || "tool"
|
|
137
|
+
return { action, resources: ["*"] }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function permissionRecoveryHint(request = {}, decision = null, options = {}) {
|
|
141
|
+
const action = String(request.action || decision?.action || "tool").trim() || "tool"
|
|
142
|
+
const resources = (
|
|
143
|
+
Array.isArray(request.resources)
|
|
144
|
+
? request.resources
|
|
145
|
+
: request.resource !== undefined
|
|
146
|
+
? [request.resource]
|
|
147
|
+
: []
|
|
148
|
+
).map((value) => String(value)).filter(Boolean)
|
|
149
|
+
const denied = Array.isArray(decision?.decisions)
|
|
150
|
+
? decision.decisions.filter((item) => item?.effect === "deny" || item?.effect === "ask")
|
|
151
|
+
: []
|
|
152
|
+
const resource = String(denied[0]?.resource || resources[0] || "*")
|
|
153
|
+
const effect = String(options.effect || decision?.effect || "deny")
|
|
154
|
+
const prefix = effect === "ask"
|
|
155
|
+
? "UES approval was not granted for this action."
|
|
156
|
+
: "UES policy denied this action."
|
|
157
|
+
|
|
158
|
+
return [
|
|
159
|
+
prefix,
|
|
160
|
+
`Do not repeat the same blocked ${action} request for ${resource}.`,
|
|
161
|
+
"Continue by choosing the narrowest allowed, reversible alternative that still advances the task.",
|
|
162
|
+
"Prefer read-only inspection or a less-privileged resource when possible.",
|
|
163
|
+
"If no allowed path can satisfy the task, report the blocker with evidence instead of bypassing the guard.",
|
|
164
|
+
].join(" ")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizedConfig(parsed) {
|
|
168
|
+
if (Array.isArray(parsed)) {
|
|
169
|
+
return { permissions: normalizePermissionRules(parsed), agents: {}, defaultEffect: "allow" }
|
|
170
|
+
}
|
|
171
|
+
if (!parsed || typeof parsed !== "object") {
|
|
172
|
+
throw new Error("permission config must be an array or object")
|
|
173
|
+
}
|
|
174
|
+
const permissions = normalizePermissionRules(parsed.permissions || [])
|
|
175
|
+
const agents = {}
|
|
176
|
+
for (const [agent, value] of Object.entries(parsed.agents || {})) {
|
|
177
|
+
const rules = Array.isArray(value) ? value : value?.permissions
|
|
178
|
+
agents[String(agent)] = normalizePermissionRules(rules || [])
|
|
179
|
+
}
|
|
180
|
+
const defaultEffect = EFFECTS.has(parsed.defaultEffect) ? parsed.defaultEffect : "allow"
|
|
181
|
+
return { permissions, agents, defaultEffect }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export class PermissionPolicyStore {
|
|
185
|
+
constructor(file) {
|
|
186
|
+
this.file = String(file || "")
|
|
187
|
+
this.cacheKey = null
|
|
188
|
+
this.cached = null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async load() {
|
|
192
|
+
if (!this.file) return { configured: false, config: null }
|
|
193
|
+
const info = await stat(this.file).catch((error) => {
|
|
194
|
+
if (error?.code === "ENOENT") return null
|
|
195
|
+
throw error
|
|
196
|
+
})
|
|
197
|
+
if (!info?.isFile()) {
|
|
198
|
+
this.cacheKey = null
|
|
199
|
+
this.cached = null
|
|
200
|
+
return { configured: false, config: null }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const cacheKey = `${info.mtimeMs}:${info.size}`
|
|
204
|
+
if (this.cached && this.cacheKey === cacheKey) {
|
|
205
|
+
return { configured: true, config: this.cached, cached: true }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const parsed = JSON.parse(await readFile(this.file, "utf8"))
|
|
209
|
+
const config = normalizedConfig(parsed)
|
|
210
|
+
this.cacheKey = cacheKey
|
|
211
|
+
this.cached = config
|
|
212
|
+
return { configured: true, config, cached: false }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async evaluate(request, options = {}) {
|
|
216
|
+
const loaded = await this.load()
|
|
217
|
+
if (!loaded.configured) return { configured: false, decision: null }
|
|
218
|
+
const agent = String(options.agent || "")
|
|
219
|
+
const agentRules = agent ? (loaded.config.agents[agent] || []) : []
|
|
220
|
+
const rules = [...loaded.config.permissions, ...agentRules]
|
|
221
|
+
const decision = evaluatePermissionRules(rules, request, {
|
|
222
|
+
platform: options.platform,
|
|
223
|
+
defaultEffect: loaded.config.defaultEffect,
|
|
224
|
+
})
|
|
225
|
+
return {
|
|
226
|
+
configured: true,
|
|
227
|
+
decision,
|
|
228
|
+
agent: agent || null,
|
|
229
|
+
cached: loaded.cached === true,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|