deepseek-coordination 0.1.9 → 0.10.0
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/LICENSE +21 -21
- package/README.md +349 -1083
- package/README.zh-CN.md +349 -697
- package/cordis.patch.yml +5 -74
- package/lib/client.js +33772 -0
- package/lib/index.d.mts +252 -0
- package/lib/index.mjs +27374 -0
- package/package.json +98 -114
- package/client.js +0 -338
- package/src/host/bin/dsh-os.bat +0 -19
- package/src/host/bin/dsh-os.ps1 +0 -5039
- package/src/host/bin/dsh-os.sh +0 -3385
- package/src/host/bin/dsh-os7.ps1 +0 -5518
- package/src/host/command.txt +0 -136
- package/src/host/docs/command-original.txt +0 -26
- package/src/host/docs/os-spec.md +0 -528
- package/src/host/docs/permission-model.md +0 -206
- package/src/host/spec/charsets.tsv +0 -28
- package/src/host/spec/verbs.tsv +0 -120
- package/src/permission/index.js +0 -546
- package/src/plugin/index.js +0 -226
- package/src/plugin/invoke.js +0 -306
- package/src/plugin/settings.js +0 -349
- package/src/plugin/takeover.js +0 -436
- package/src/plugin/workflow.js +0 -1494
- package/tools/measure-cache.mjs +0 -223
package/src/permission/index.js
DELETED
|
@@ -1,546 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-os permission consent delegate.
|
|
3
|
-
*
|
|
4
|
-
* The ONLY writer of permission.json.
|
|
5
|
-
*
|
|
6
|
-
* Why this is a plugin and not a script subcommand: DSH's approval UI lives in the client, so only a
|
|
7
|
-
* plugin can register an answerer on the approval waterfall and reach the user. The scripts
|
|
8
|
-
* (dsh-os.ps1 / dsh-os.sh) therefore only ever READ the file - there is no write verb in the verb
|
|
9
|
-
* table at all - and this delegate is the single component holding the write path.
|
|
10
|
-
*
|
|
11
|
-
* The four decisions the user can make map onto the two mechanisms available:
|
|
12
|
-
*
|
|
13
|
-
* allow-once -> the DSH approval seam. Its only grant is "allowed-once", so a one-shot decision
|
|
14
|
-
* deny-once needs no persistence and must not create one.
|
|
15
|
-
* allow-always -> a rule in permission.json, written here. The approval seam deliberately cannot
|
|
16
|
-
* deny-always express a durable grant, which is exactly why this delegate exists.
|
|
17
|
-
*
|
|
18
|
-
* Matching is deny-first, and that is a deliberate departure from "first match wins" for allow rules:
|
|
19
|
-
* if a deny rule and an allow rule both match, the deny wins regardless of array order, because the
|
|
20
|
-
* safe answer must not depend on the order somebody happened to append rules in.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
24
|
-
import { dirname, join } from 'node:path'
|
|
25
|
-
import { fileURLToPath } from 'node:url'
|
|
26
|
-
|
|
27
|
-
export const name = 'dsh-os-permission'
|
|
28
|
-
|
|
29
|
-
/** A service, so consumers can inject it and the tool layer can be swapped out. */
|
|
30
|
-
export const inject = ['tools']
|
|
31
|
-
|
|
32
|
-
// <pkg>/src/permission/index.js -> the package root is three levels up from the file.
|
|
33
|
-
const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
|
|
34
|
-
|
|
35
|
-
/** The five scopes the schema allows. */
|
|
36
|
-
const SCOPES = ['user', 'machine', 'process', 'session', 'any']
|
|
37
|
-
|
|
38
|
-
/** Audit is append-only and bounded; older entries are trimmed by the delegate, never edited. */
|
|
39
|
-
const AUDIT_LIMIT = 200
|
|
40
|
-
|
|
41
|
-
/** Written into every file this delegate produces, so a reader can tell who last wrote it. */
|
|
42
|
-
const WRITER = 'consent-delegate'
|
|
43
|
-
|
|
44
|
-
// NOTE: there is deliberately NO `Config` export here, and adding one back needs care.
|
|
45
|
-
//
|
|
46
|
-
// Cordis applies a plugin's `Config` export as a Standard Schema (a schemastery `z.object({...})`):
|
|
47
|
-
// resolveConfig: if (!runtime.Config) return config
|
|
48
|
-
// const result = runtime.Config['~standard'].validate(config)
|
|
49
|
-
// A plain JavaScript object carrying JSON-Schema-shaped keys therefore fails at activation with
|
|
50
|
-
// TypeError: Cannot read properties of undefined (reading 'validate')
|
|
51
|
-
// because `Config['~standard']` is undefined. Declaring the shape in JSON Schema does not make it a
|
|
52
|
-
// validator, so the object that used to live here was worse than nothing: it described the config
|
|
53
|
-
// truthfully while breaking every mount.
|
|
54
|
-
//
|
|
55
|
-
// Omitting `Config` is the correct choice for this package. The row's config is passed through
|
|
56
|
-
// unvalidated, and every field is optional with a default applied in `permissionPathOf`/`apply`, so
|
|
57
|
-
// there is nothing to validate. The alternative - exporting a schemastery schema - would add a runtime
|
|
58
|
-
// dependency to a package whose whole premise is that it has none.
|
|
59
|
-
//
|
|
60
|
-
// The config keys a deployment may set are:
|
|
61
|
-
// permissionPath absolute path to permission.json (default: <package root>/permission.json)
|
|
62
|
-
// auditLimit audit entries to keep (default: 200)
|
|
63
|
-
|
|
64
|
-
function permissionPathOf(config) {
|
|
65
|
-
return config?.permissionPath ?? join(PKG_ROOT, 'permission.json')
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** An empty but valid document: the documented initial state is a 0-byte file, not a broken one. */
|
|
69
|
-
function emptyDocument() {
|
|
70
|
-
return {
|
|
71
|
-
version: 1,
|
|
72
|
-
updatedAt: new Date().toISOString(),
|
|
73
|
-
updatedBy: WRITER,
|
|
74
|
-
defaults: { readOnly: 'allow', mutating: 'ask' },
|
|
75
|
-
rules: [],
|
|
76
|
-
audit: [],
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Read the document.
|
|
82
|
-
*
|
|
83
|
-
* A 0-byte file is the documented initial state and is treated as empty, not as corruption: the
|
|
84
|
-
* repository ships one so that the file's presence is not itself a signal.
|
|
85
|
-
*
|
|
86
|
-
* A file whose updatedBy is not this delegate is treated as untrusted. The reader then falls back to
|
|
87
|
-
* defaults AND says so, because silently honouring rules somebody else wrote would defeat the whole
|
|
88
|
-
* arrangement - the delegate is supposed to be the only writer.
|
|
89
|
-
*/
|
|
90
|
-
function readDocument(path) {
|
|
91
|
-
if (!existsSync(path)) {
|
|
92
|
-
return { document: emptyDocument(), trusted: true, exists: false, untrustedReason: null }
|
|
93
|
-
}
|
|
94
|
-
const raw = readFileSync(path, 'utf8')
|
|
95
|
-
if (raw.trim() === '') {
|
|
96
|
-
return { document: emptyDocument(), trusted: true, exists: true, untrustedReason: null }
|
|
97
|
-
}
|
|
98
|
-
let parsed
|
|
99
|
-
try {
|
|
100
|
-
parsed = JSON.parse(raw)
|
|
101
|
-
} catch (error) {
|
|
102
|
-
return {
|
|
103
|
-
document: emptyDocument(),
|
|
104
|
-
trusted: false,
|
|
105
|
-
exists: true,
|
|
106
|
-
untrustedReason: `file is not valid JSON: ${error.message}`,
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
if (parsed.updatedBy !== WRITER) {
|
|
110
|
-
// Fail closed: evaluate everything as defaults, and report the reason rather than the rules.
|
|
111
|
-
return {
|
|
112
|
-
document: emptyDocument(),
|
|
113
|
-
trusted: false,
|
|
114
|
-
exists: true,
|
|
115
|
-
untrustedReason: `updatedBy is '${parsed.updatedBy}', expected '${WRITER}'`,
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (!Array.isArray(parsed.rules)) parsed.rules = []
|
|
119
|
-
if (!Array.isArray(parsed.audit)) parsed.audit = []
|
|
120
|
-
return { document: parsed, trusted: true, exists: true, untrustedReason: null }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Write the document atomically.
|
|
125
|
-
*
|
|
126
|
-
* Through a temporary file in the same directory plus a rename, so a crash cannot leave a half-written
|
|
127
|
-
* permission file. A half-written file would read as untrusted and silently reset every durable
|
|
128
|
-
* decision to defaults, which is the failure this avoids.
|
|
129
|
-
*/
|
|
130
|
-
function writeDocument(path, document) {
|
|
131
|
-
document.version = 1
|
|
132
|
-
document.updatedAt = new Date().toISOString()
|
|
133
|
-
document.updatedBy = WRITER
|
|
134
|
-
const dir = dirname(path)
|
|
135
|
-
const tmp = join(dir, `.permission.json.${process.pid}.tmp`)
|
|
136
|
-
writeFileSync(tmp, JSON.stringify(document, null, 2) + '\n', 'utf8')
|
|
137
|
-
renameSync(tmp, path)
|
|
138
|
-
return document
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Does a rule's verb pattern match? A trailing `.*` matches the whole namespace. */
|
|
142
|
-
function verbMatches(pattern, verb) {
|
|
143
|
-
if (pattern === '*' || pattern === verb) return true
|
|
144
|
-
if (pattern.endsWith('.*')) return verb.startsWith(pattern.slice(0, -1))
|
|
145
|
-
return false
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Does a rule's target match? `*` in a value matches any target. */
|
|
149
|
-
function targetMatches(ruleTarget, target) {
|
|
150
|
-
const value = ruleTarget?.value ?? '*'
|
|
151
|
-
if (value === '*') return true
|
|
152
|
-
if (!target) return false
|
|
153
|
-
return value === target
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Decide what the stored rules say about a call.
|
|
158
|
-
*
|
|
159
|
-
* Deny is checked across every matching rule before any allow is honoured, so the answer never depends
|
|
160
|
-
* on the order rules were appended in.
|
|
161
|
-
*/
|
|
162
|
-
function evaluate(document, verb, target) {
|
|
163
|
-
const matching = document.rules.filter(
|
|
164
|
-
(r) => verbMatches(r.verb, verb) && targetMatches(r.target, target),
|
|
165
|
-
)
|
|
166
|
-
const denied = matching.find((r) => r.decision === 'deny-always')
|
|
167
|
-
if (denied) {
|
|
168
|
-
return { decision: 'deny-always', ruleId: denied.id, source: 'rule' }
|
|
169
|
-
}
|
|
170
|
-
const allowed = matching.find((r) => r.decision === 'allow-always')
|
|
171
|
-
if (allowed) {
|
|
172
|
-
return { decision: 'allow-always', ruleId: allowed.id, source: 'rule' }
|
|
173
|
-
}
|
|
174
|
-
return { decision: document.defaults.mutating, ruleId: null, source: 'defaults' }
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function appendAudit(document, entry, limit) {
|
|
178
|
-
document.audit.push(entry)
|
|
179
|
-
// Trim from the front, never edit: the tail is the recent history and the earlier entries are the
|
|
180
|
-
// ones dropped.
|
|
181
|
-
if (document.audit.length > limit) {
|
|
182
|
-
document.audit = document.audit.slice(document.audit.length - limit)
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function nextRuleId(document) {
|
|
187
|
-
let max = 0
|
|
188
|
-
for (const r of document.rules) {
|
|
189
|
-
const m = /^r-(\d+)$/.exec(r.id ?? '')
|
|
190
|
-
if (m) max = Math.max(max, Number(m[1]))
|
|
191
|
-
}
|
|
192
|
-
return `r-${String(max + 1).padStart(4, '0')}`
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function apply(ctx, rawConfig) {
|
|
196
|
-
// `?? {}` rather than a default parameter: Cordis passes a row's config as `null` when the row declares
|
|
197
|
-
// none, and a default parameter only fires for `undefined`. This row happens to ship with a config, so
|
|
198
|
-
// it never hit the fault — but the fix belongs here too, or a deployment that trims the row's config
|
|
199
|
-
// takes the consent delegate down with it.
|
|
200
|
-
const config = rawConfig ?? {}
|
|
201
|
-
const path = permissionPathOf(config)
|
|
202
|
-
const auditLimit = config.auditLimit ?? AUDIT_LIMIT
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Grant a durable decision.
|
|
206
|
-
*
|
|
207
|
-
* A `*` target is refused without an explicit acknowledgement. The model forbids "permanent consent
|
|
208
|
-
* to everything" as a side effect of a click, so a caller has to say so deliberately and the result
|
|
209
|
-
* records that it was broad.
|
|
210
|
-
*/
|
|
211
|
-
function grant({ verb, target, scope, decision, note, acknowledgeBroad }) {
|
|
212
|
-
if (!verb) throw new Error('grant requires a verb')
|
|
213
|
-
if (decision !== 'allow-always' && decision !== 'deny-always') {
|
|
214
|
-
throw new Error(`decision must be allow-always or deny-always, got '${decision}'`)
|
|
215
|
-
}
|
|
216
|
-
if (scope && !SCOPES.includes(scope)) {
|
|
217
|
-
throw new Error(`scope must be one of ${SCOPES.join(', ')}, got '${scope}'`)
|
|
218
|
-
}
|
|
219
|
-
// THE TARGET ARRIVES IN TWO SHAPES, and this function has to accept both.
|
|
220
|
-
//
|
|
221
|
-
// The model-facing tool declares `target` as a STRING ("Target value; * means every target") and hands
|
|
222
|
-
// it straight through, while this function reads `target.value` because the STORED rule is an object.
|
|
223
|
-
// Reading `.value` off a string yields `undefined`, so the value became '*' and `broad` became true,
|
|
224
|
-
// and the broad-target guard below then refused EVERY grant. The effect was that a rule scoped to one
|
|
225
|
-
// path could not be created at all: the documented "deny this one file" was unreachable through the
|
|
226
|
-
// only writer that exists, and the refusal blamed the caller for asking for too much.
|
|
227
|
-
//
|
|
228
|
-
// `query` already normalised both shapes (it reads `target?.value ?? target`); this is that treatment.
|
|
229
|
-
const shape = typeof target === 'string' ? { kind: 'path', value: target } : target
|
|
230
|
-
const value = shape?.value ?? '*'
|
|
231
|
-
const broad = value === '*'
|
|
232
|
-
if (broad && acknowledgeBroad !== true) {
|
|
233
|
-
throw new Error(
|
|
234
|
-
'refusing to persist a rule that matches every target without acknowledgeBroad: ' +
|
|
235
|
-
'permanent consent to everything must be an explicit, separate decision',
|
|
236
|
-
)
|
|
237
|
-
}
|
|
238
|
-
const state = readDocument(path)
|
|
239
|
-
const document = state.document
|
|
240
|
-
const rule = {
|
|
241
|
-
id: nextRuleId(document),
|
|
242
|
-
verb,
|
|
243
|
-
target: { kind: shape?.kind ?? 'any', value },
|
|
244
|
-
scope: { kind: scope ?? 'user' },
|
|
245
|
-
decision,
|
|
246
|
-
grantedBy: WRITER,
|
|
247
|
-
grantedAt: new Date().toISOString(),
|
|
248
|
-
expiresAt: null,
|
|
249
|
-
note: note ?? null,
|
|
250
|
-
broad,
|
|
251
|
-
}
|
|
252
|
-
document.rules.push(rule)
|
|
253
|
-
appendAudit(
|
|
254
|
-
document,
|
|
255
|
-
{
|
|
256
|
-
at: new Date().toISOString(),
|
|
257
|
-
verb,
|
|
258
|
-
target: rule.target,
|
|
259
|
-
decision,
|
|
260
|
-
decidedBy: WRITER,
|
|
261
|
-
ruleId: rule.id,
|
|
262
|
-
broad,
|
|
263
|
-
},
|
|
264
|
-
auditLimit,
|
|
265
|
-
)
|
|
266
|
-
writeDocument(path, document)
|
|
267
|
-
return { rule, fileTrusted: state.trusted }
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** Revoke a rule by id. Revoking is a durable decision too, so it takes the same path. */
|
|
271
|
-
function revoke({ ruleId }) {
|
|
272
|
-
if (!ruleId) throw new Error('revoke requires a ruleId')
|
|
273
|
-
const state = readDocument(path)
|
|
274
|
-
const document = state.document
|
|
275
|
-
const index = document.rules.findIndex((r) => r.id === ruleId)
|
|
276
|
-
if (index < 0) {
|
|
277
|
-
throw new Error(`no rule with id '${ruleId}'`)
|
|
278
|
-
}
|
|
279
|
-
const [removed] = document.rules.splice(index, 1)
|
|
280
|
-
appendAudit(
|
|
281
|
-
document,
|
|
282
|
-
{
|
|
283
|
-
at: new Date().toISOString(),
|
|
284
|
-
verb: removed.verb,
|
|
285
|
-
target: removed.target,
|
|
286
|
-
decision: 'revoked',
|
|
287
|
-
decidedBy: WRITER,
|
|
288
|
-
ruleId: removed.id,
|
|
289
|
-
},
|
|
290
|
-
auditLimit,
|
|
291
|
-
)
|
|
292
|
-
writeDocument(path, document)
|
|
293
|
-
return { removed }
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function query({ verb, target }) {
|
|
297
|
-
const state = readDocument(path)
|
|
298
|
-
const verdict = evaluate(state.document, verb, target?.value ?? target)
|
|
299
|
-
return {
|
|
300
|
-
verb,
|
|
301
|
-
target: target ?? null,
|
|
302
|
-
decision: verdict.decision,
|
|
303
|
-
ruleId: verdict.ruleId,
|
|
304
|
-
source: verdict.source,
|
|
305
|
-
fileExists: state.exists,
|
|
306
|
-
trusted: state.trusted,
|
|
307
|
-
untrustedReason: state.untrustedReason,
|
|
308
|
-
// Reported so a caller can see which strength it is actually operating at.
|
|
309
|
-
aclEnforced: false,
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function list() {
|
|
314
|
-
const state = readDocument(path)
|
|
315
|
-
return {
|
|
316
|
-
path,
|
|
317
|
-
fileExists: state.exists,
|
|
318
|
-
trusted: state.trusted,
|
|
319
|
-
untrustedReason: state.untrustedReason,
|
|
320
|
-
defaults: state.document.defaults,
|
|
321
|
-
count: state.document.rules.length,
|
|
322
|
-
rules: state.document.rules,
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function audit({ limit }) {
|
|
327
|
-
const state = readDocument(path)
|
|
328
|
-
const entries = state.document.audit
|
|
329
|
-
const n = Math.max(1, Math.min(limit ?? 20, entries.length || 1))
|
|
330
|
-
return {
|
|
331
|
-
path,
|
|
332
|
-
total: entries.length,
|
|
333
|
-
returned: Math.min(n, entries.length),
|
|
334
|
-
trusted: state.trusted,
|
|
335
|
-
entries: entries.slice(Math.max(0, entries.length - n)),
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// The service other plugins and the tool layer consume.
|
|
340
|
-
//
|
|
341
|
-
// `ctx.provide` REGISTERS it; `ctx.set` only OVERWRITES the value of a service that was already
|
|
342
|
-
// provided. Calling `set` first - as this used to - throws
|
|
343
|
-
// Error: cannot set property "dsh-os-permission" without provide
|
|
344
|
-
// because the name is not yet in the fiber's property table, and that aborted the whole entry.
|
|
345
|
-
// `provide` is also the form that returns a disposer, so the registration unwinds with the fiber.
|
|
346
|
-
const service = { path, grant, revoke, query, list, audit, readDocument, evaluate }
|
|
347
|
-
const disposeService = ctx.provide?.('dsh-os-permission', service)
|
|
348
|
-
// `provide` runs inside a fiber effect, which is the documented place to register a resource. A
|
|
349
|
-
// context that offers no `provide` (the unit tests' stand-in) is tolerated, but the service is then
|
|
350
|
-
// simply not published, which is honest rather than silently half-registered.
|
|
351
|
-
void disposeService
|
|
352
|
-
|
|
353
|
-
const tool = (spec) => {
|
|
354
|
-
if (typeof ctx.tools?.register !== 'function') return
|
|
355
|
-
ctx.tools.register(spec)
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Ask the user before a DURABLE decision is written.
|
|
360
|
-
*
|
|
361
|
-
* The delegate's whole reason for existing is that the scripts cannot reach the approval UI: there is
|
|
362
|
-
* no write verb in the verb table precisely so that `permission.json` has exactly one writer, and that
|
|
363
|
-
* writer is supposed to be gated by a human. Until now the gate was only a sentence in this tool's
|
|
364
|
-
* description asking the model to behave — the enforcement was a request, not a check. This makes it a
|
|
365
|
-
* check.
|
|
366
|
-
*
|
|
367
|
-
* The Web composition already ships the answerer (`dsh-client-ui-approval`), so nothing here renders a
|
|
368
|
-
* prompt; it asks, and the client shows the banner. `allowed-once` is the seam's only grant, and for
|
|
369
|
-
* this purpose it means "yes, record this rule" — the rule's own persistence is what makes it durable,
|
|
370
|
-
* which is why a durable decision uses this seam while a one-shot verb call does not.
|
|
371
|
-
*
|
|
372
|
-
* Absence of the seam is reported rather than ignored: a rule that nobody approved must not be written
|
|
373
|
-
* quietly. A deployment that wants unattended grants has to say so by configuring `requireApproval:
|
|
374
|
-
* false`, which is a decision on the record instead of a default nobody chose.
|
|
375
|
-
*/
|
|
376
|
-
async function approvedForDurableWrite(exec, { verb, target, decision, revoke }) {
|
|
377
|
-
if (config.requireApproval === false) return { granted: true, outcome: 'not-required' }
|
|
378
|
-
const approval = typeof ctx.get === 'function' ? ctx.get('approval') : undefined
|
|
379
|
-
if (approval === undefined) {
|
|
380
|
-
return {
|
|
381
|
-
granted: false,
|
|
382
|
-
outcome: 'unavailable',
|
|
383
|
-
detail: 'no approval service is loaded, so a durable decision cannot be confirmed by the user; set requireApproval: false in this row\'s config to grant unattended',
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
if (exec?.agent === undefined || exec?.agent === null) {
|
|
387
|
-
return {
|
|
388
|
-
granted: false,
|
|
389
|
-
outcome: 'unavailable',
|
|
390
|
-
detail: 'this call carries no agent, so there is no session to show the request in; a durable decision needs an interactive turn',
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
const verbLabel = revoke === true ? `revoke ${verb}` : `${decision} for ${verb}`
|
|
394
|
-
try {
|
|
395
|
-
const outcome = await approval.request({
|
|
396
|
-
agent: exec.agent,
|
|
397
|
-
toolName: revoke === true ? 'os_permission_revoke' : 'os_permission_grant',
|
|
398
|
-
...exec.callId === undefined ? {} : { callId: exec.callId },
|
|
399
|
-
reason: `dsh-os wants to ${verbLabel} on ${target}`,
|
|
400
|
-
displayReason: {
|
|
401
|
-
en: revoke === true
|
|
402
|
-
? `dsh-os wants to remove the stored rule for "${verb}".`
|
|
403
|
-
: `dsh-os wants to remember "${decision === 'allow-always' ? 'always allow' : 'always deny'}" for "${verb}" on ${target}.`,
|
|
404
|
-
},
|
|
405
|
-
...exec.signal === undefined ? {} : { signal: exec.signal },
|
|
406
|
-
})
|
|
407
|
-
if (outcome === 'allowed-once') return { granted: true, outcome }
|
|
408
|
-
return {
|
|
409
|
-
granted: false,
|
|
410
|
-
outcome,
|
|
411
|
-
detail: outcome === 'rejected'
|
|
412
|
-
? 'refused — note that a session policy of "never" refuses before any prompt is shown, so nothing was displayed to the user'
|
|
413
|
-
: 'no answerer accepted the request',
|
|
414
|
-
}
|
|
415
|
-
} catch (error) {
|
|
416
|
-
return { granted: false, outcome: 'unavailable', detail: error instanceof Error ? error.message : String(error) }
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
tool({
|
|
421
|
-
name: 'os_permission_query',
|
|
422
|
-
description:
|
|
423
|
-
'Ask what the stored permission rules say about one verb and target. Read-only: never writes.',
|
|
424
|
-
parameters: {
|
|
425
|
-
// A complete JSON Schema: `register()` does not compile the { name: {...} } property map that the
|
|
426
|
-
// internal `defineTool` accepts, so the object root must be stated here explicitly.
|
|
427
|
-
type: 'object',
|
|
428
|
-
properties: {
|
|
429
|
-
verb: { type: 'string', description: 'Verb to ask about, e.g. file.delete.' },
|
|
430
|
-
target: { type: 'string', description: 'Target value, e.g. an absolute path.' },
|
|
431
|
-
},
|
|
432
|
-
required: ['verb'],
|
|
433
|
-
},
|
|
434
|
-
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
435
|
-
async execute(args) {
|
|
436
|
-
return JSON.stringify(query({ verb: args.verb, target: { value: args.target } }), null, 2)
|
|
437
|
-
},
|
|
438
|
-
})
|
|
439
|
-
|
|
440
|
-
tool({
|
|
441
|
-
name: 'os_permission_grant',
|
|
442
|
-
description:
|
|
443
|
-
'Persist a durable authorization decision. This is the only path that writes permission.json, ' +
|
|
444
|
-
'and it must only be called after the user has chosen "allow always" or "deny always".',
|
|
445
|
-
parameters: {
|
|
446
|
-
type: 'object',
|
|
447
|
-
properties: {
|
|
448
|
-
verb: { type: 'string', description: 'Verb, or a namespace like file.*' },
|
|
449
|
-
target: { type: 'string', description: 'Target value; * means every target.' },
|
|
450
|
-
decision: {
|
|
451
|
-
type: 'string',
|
|
452
|
-
description: 'allow-always or deny-always.',
|
|
453
|
-
enum: ['allow-always', 'deny-always'],
|
|
454
|
-
},
|
|
455
|
-
scope: { type: 'string', description: 'user, machine, process, session or any.' },
|
|
456
|
-
note: { type: 'string', description: 'Optional human note.' },
|
|
457
|
-
acknowledgeBroad: {
|
|
458
|
-
type: 'boolean',
|
|
459
|
-
description: 'Required when target is *, because consent to everything must be explicit.',
|
|
460
|
-
},
|
|
461
|
-
},
|
|
462
|
-
required: ['verb', 'target', 'decision'],
|
|
463
|
-
},
|
|
464
|
-
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
465
|
-
async execute(args, exec) {
|
|
466
|
-
// The user decides before anything is written. A refusal is reported as a result rather than an
|
|
467
|
-
// exception, so the model can tell "the user said no" from "the tool broke".
|
|
468
|
-
const permission = await approvedForDurableWrite(exec, {
|
|
469
|
-
verb: args.verb,
|
|
470
|
-
target: args.target ?? '*',
|
|
471
|
-
decision: args.decision,
|
|
472
|
-
})
|
|
473
|
-
if (!permission.granted) {
|
|
474
|
-
return JSON.stringify({
|
|
475
|
-
ok: false,
|
|
476
|
-
written: false,
|
|
477
|
-
authorization: permission.outcome,
|
|
478
|
-
...permission.detail === undefined ? {} : { reason: permission.detail },
|
|
479
|
-
note: 'permission.json was NOT modified.',
|
|
480
|
-
}, null, 2)
|
|
481
|
-
}
|
|
482
|
-
return JSON.stringify({ ...grant(args), authorizedBy: permission.outcome }, null, 2)
|
|
483
|
-
},
|
|
484
|
-
})
|
|
485
|
-
|
|
486
|
-
tool({
|
|
487
|
-
name: 'os_permission_revoke',
|
|
488
|
-
description: 'Remove a stored rule by id. Revoking is itself a durable decision.',
|
|
489
|
-
parameters: {
|
|
490
|
-
type: 'object',
|
|
491
|
-
properties: {
|
|
492
|
-
ruleId: { type: 'string', description: 'Rule id, e.g. r-0001.' },
|
|
493
|
-
},
|
|
494
|
-
required: ['ruleId'],
|
|
495
|
-
},
|
|
496
|
-
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
497
|
-
async execute(args, exec) {
|
|
498
|
-
// Revoking is a durable decision too — it removes a rule the user already agreed to, so it takes
|
|
499
|
-
// the same gate as writing one.
|
|
500
|
-
const permission = await approvedForDurableWrite(exec, {
|
|
501
|
-
verb: `rule ${args.ruleId}`,
|
|
502
|
-
target: args.ruleId,
|
|
503
|
-
decision: 'revoke',
|
|
504
|
-
revoke: true,
|
|
505
|
-
})
|
|
506
|
-
if (!permission.granted) {
|
|
507
|
-
return JSON.stringify({
|
|
508
|
-
ok: false,
|
|
509
|
-
written: false,
|
|
510
|
-
authorization: permission.outcome,
|
|
511
|
-
...permission.detail === undefined ? {} : { reason: permission.detail },
|
|
512
|
-
note: 'permission.json was NOT modified.',
|
|
513
|
-
}, null, 2)
|
|
514
|
-
}
|
|
515
|
-
return JSON.stringify({ ...revoke({ ruleId: args.ruleId }), authorizedBy: permission.outcome }, null, 2)
|
|
516
|
-
},
|
|
517
|
-
})
|
|
518
|
-
|
|
519
|
-
tool({
|
|
520
|
-
name: 'os_permission_list',
|
|
521
|
-
description: 'List the stored rules, the defaults, and whether the file is trusted.',
|
|
522
|
-
parameters: { type: 'object', properties: {} },
|
|
523
|
-
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
524
|
-
async execute() {
|
|
525
|
-
return JSON.stringify(list(), null, 2)
|
|
526
|
-
},
|
|
527
|
-
})
|
|
528
|
-
|
|
529
|
-
tool({
|
|
530
|
-
name: 'os_permission_audit',
|
|
531
|
-
description: 'Read the tail of the append-only authorization audit.',
|
|
532
|
-
parameters: {
|
|
533
|
-
type: 'object',
|
|
534
|
-
properties: {
|
|
535
|
-
limit: { type: 'number', description: 'How many recent entries (default 20).' },
|
|
536
|
-
},
|
|
537
|
-
},
|
|
538
|
-
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
539
|
-
async execute(args) {
|
|
540
|
-
return JSON.stringify(audit({ limit: args.limit }), null, 2)
|
|
541
|
-
},
|
|
542
|
-
})
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// Exported for tests, so the matching and trust rules can be exercised without a live plugin context.
|
|
546
|
-
export const __internals = { evaluate, readDocument, verbMatches, targetMatches, emptyDocument }
|