deepseek-coordination 0.1.7 → 0.1.8
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/package.json +3 -2
- package/src/host/bin/dsh-os.ps1 +105 -10
- package/src/host/bin/dsh-os7.ps1 +126 -20
- package/src/permission/index.js +99 -4
- package/src/plugin/index.js +111 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deepseek-coordination",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Cross-platform atomic OS-interaction surface for the DeepSeek Harness: one argument-stable verb set implemented natively on each OS, with byte-exact charset, BOM and line-ending handling.",
|
|
@@ -92,6 +92,7 @@
|
|
|
92
92
|
"test": "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests/golden.ps1",
|
|
93
93
|
"test:encoding": "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests/encoding.ps1",
|
|
94
94
|
"test:conformance": "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests/conformance.ps1",
|
|
95
|
+
"test:rules": "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests/rules.ps1",
|
|
95
96
|
"test:plugin": "node tests/plugin.test.mjs",
|
|
96
97
|
"test:activation": "node tests/activation.test.mjs",
|
|
97
98
|
"test:takeover": "node tests/takeover.test.mjs",
|
|
@@ -103,7 +104,7 @@
|
|
|
103
104
|
"test:load": "node tools/verify-plugin-load.mjs && node tools/verify-editions.mjs",
|
|
104
105
|
"test:pipeline": "node tools/probe-pipeline.mjs",
|
|
105
106
|
"measure:cache": "node tools/measure-cache.mjs",
|
|
106
|
-
"test:all": "npm run test:conformance && npm run test:encoding && npm run test && npm run test:plugin && npm run test:takeover && npm run test:permission && npm run test:workflow && npm run test:bundle && npm run test:package && npm run test:load && npm run test:desktop",
|
|
107
|
+
"test:all": "npm run test:conformance && npm run test:encoding && npm run test && npm run test:rules && npm run test:plugin && npm run test:activation && npm run test:takeover && npm run test:permission && npm run test:workflow && npm run test:bundle && npm run test:package && npm run test:load && npm run test:desktop",
|
|
107
108
|
"selfcheck": "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File src/host/bin/dsh-os.ps1 selfcheck"
|
|
108
109
|
},
|
|
109
110
|
"devDependencies": {
|
package/src/host/bin/dsh-os.ps1
CHANGED
|
@@ -498,6 +498,50 @@ function Assert-Allowed {
|
|
|
498
498
|
if ($Positionals -ge 0 -and $Parsed.positionals.Length -gt $Positionals) {
|
|
499
499
|
throw (New-OsError -Code 'E_USAGE' -Message ("unexpected extra argument <{0}>" -f $Parsed.positionals[$Positionals]))
|
|
500
500
|
}
|
|
501
|
+
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
# The durable rules are consulted HERE, at the one gate every mutating verb passes through.
|
|
504
|
+
#
|
|
505
|
+
# Before this existed, `permission.json` was written by the consent delegate and read by
|
|
506
|
+
# `permission.list` / `permission.query` for DISPLAY only: no verb ever looked at a rule, so a
|
|
507
|
+
# `deny-always` stopped nothing and an `allow-always` bought nothing. The file recorded decisions that
|
|
508
|
+
# had no effect, which is worse than having no file at all, because it reads as enforcement.
|
|
509
|
+
#
|
|
510
|
+
# A deny is checked FIRST and wins outright, so the answer never depends on the order rules were
|
|
511
|
+
# appended in. It is checked BEFORE the `--allow` flag, deliberately: `--allow` says a caller believes
|
|
512
|
+
# it is authorized, and a rule is the user having said otherwise. A deny that `--allow` could overrule
|
|
513
|
+
# would be a rule that does not deny.
|
|
514
|
+
#
|
|
515
|
+
# An allow rule still requires `--allow`, and that is not an oversight: the flag is the per-call
|
|
516
|
+
# statement that a caller has been authorized for THIS call, while the rule is the standing permission
|
|
517
|
+
# to be authorized at all. Requiring both keeps the flag meaningful for callers that never consult the
|
|
518
|
+
# rules, and stops a durable grant from silently authorizing every future call on its own.
|
|
519
|
+
# ---------------------------------------------------------------------------
|
|
520
|
+
$state = Read-PermissionFile
|
|
521
|
+
# An UNTRUSTED file is treated as having no rules at all, which is what the consent delegate does
|
|
522
|
+
# (`readDocument` returns an empty document when `updatedBy` is not its own). Only setting a `trusted`
|
|
523
|
+
# flag and then honouring the rules anyway would let anyone who can write the file both grant and deny
|
|
524
|
+
# by hand: the delegate is supposed to be its only writer, so a file it did not write is not evidence.
|
|
525
|
+
if ($null -ne $state.document -and $state.trusted) {
|
|
526
|
+
$target = Get-PermissionTarget -Parsed $Parsed -Verb $Verb
|
|
527
|
+
# THE TWO SPELLINGS ARE NOT THE SAME STRING, and that is the whole trap here.
|
|
528
|
+
#
|
|
529
|
+
# Every call site passes the verb in its HUMAN form, with spaces ("file delete"), because that is the
|
|
530
|
+
# text the error messages show. `permission.json` stores it in its TABLE form, with dots
|
|
531
|
+
# ("file.delete"), because that is what `selfcheck` and `permission.query` report and what a user
|
|
532
|
+
# writes into a rule. Comparing the two directly made every rule fail to match - silently, and only
|
|
533
|
+
# for the enforcement path, since the display path was always given the dotted form by its caller.
|
|
534
|
+
# A rule that never matches is indistinguishable from no rule at all, so this had to be normalized
|
|
535
|
+
# rather than documented.
|
|
536
|
+
$ruleVerb = $Verb -replace ' ', '.'
|
|
537
|
+
$rule = Get-PermissionDecision -Document $state.document -Verb $ruleVerb -Target $target
|
|
538
|
+
if ($null -ne $rule -and $rule.decision -eq 'deny-always') {
|
|
539
|
+
throw (New-OsError -Code 'E_PERMISSION_DENIED_BY_RULE' `
|
|
540
|
+
-Message ("{0} on '{1}' is denied by the stored rule {2}" -f $Verb, $target, $rule.id) `
|
|
541
|
+
-Hint 'the rule was written by the consent delegate; remove it with os_permission_revoke before retrying')
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
501
545
|
if (-not (Test-HasFlag -Parsed $Parsed -Name 'allow')) {
|
|
502
546
|
throw (New-OsError -Code 'E_PERMISSION' `
|
|
503
547
|
-Message ("{0} is a mutating operation and requires user authorization" -f $Verb) `
|
|
@@ -3198,16 +3242,7 @@ function Invoke-PermissionQuery {
|
|
|
3198
3242
|
$target = Get-Positional -Parsed $Parsed -Index 1 -Name 'target' -Optional -Default '*'
|
|
3199
3243
|
Assert-NoExtra -Parsed $Parsed -Expected 2
|
|
3200
3244
|
$state = Read-PermissionFile
|
|
3201
|
-
$matched = $
|
|
3202
|
-
if ($null -ne $state.document -and $null -ne $state.document.PSObject.Properties['rules']) {
|
|
3203
|
-
foreach ($r in @($state.document.rules)) {
|
|
3204
|
-
$verbMatch = ($r.verb -eq $verb) -or ($r.verb.EndsWith('.*') -and $verb.StartsWith($r.verb.Substring(0, $r.verb.Length - 1)))
|
|
3205
|
-
if (-not $verbMatch) { continue }
|
|
3206
|
-
$value = '*'
|
|
3207
|
-
if ($null -ne $r.target -and $null -ne $r.target.PSObject.Properties['value']) { $value = [string]$r.target.value }
|
|
3208
|
-
if ($value -eq '*' -or $value -eq $target) { $matched = $r; break }
|
|
3209
|
-
}
|
|
3210
|
-
}
|
|
3245
|
+
$matched = Get-PermissionDecision -Document $state.document -Verb $verb -Target $target
|
|
3211
3246
|
$decision = 'ask'
|
|
3212
3247
|
if ($null -ne $matched) { $decision = [string]$matched.decision }
|
|
3213
3248
|
elseif (-not $state.trusted) { $decision = 'ask' }
|
|
@@ -3223,6 +3258,66 @@ function Invoke-PermissionQuery {
|
|
|
3223
3258
|
}
|
|
3224
3259
|
}
|
|
3225
3260
|
|
|
3261
|
+
# ---------------------------------------------------------------------------
|
|
3262
|
+
# Rule evaluation for the verb layer
|
|
3263
|
+
# ---------------------------------------------------------------------------
|
|
3264
|
+
|
|
3265
|
+
# DENY FIRST, then allow. This is the deliberate departure from "first match wins" that the consent
|
|
3266
|
+
# delegate already implements, and it is what the two readers had disagreed about: the script used to
|
|
3267
|
+
# take the first matching rule in array order, so a deny and an allow that both matched resolved by
|
|
3268
|
+
# whichever happened to be appended first. The safe answer must not depend on append order.
|
|
3269
|
+
#
|
|
3270
|
+
# Returns the DECIDING rule, or $null when no rule matches (the caller then falls back to defaults).
|
|
3271
|
+
function Get-PermissionDecision {
|
|
3272
|
+
param($Document, [string]$Verb, [string]$Target)
|
|
3273
|
+
if ($null -eq $Document) { return $null }
|
|
3274
|
+
if ($null -eq $Document.PSObject.Properties['rules']) { return $null }
|
|
3275
|
+
$matching = @()
|
|
3276
|
+
foreach ($r in @($Document.rules)) {
|
|
3277
|
+
$pattern = [string]$r.verb
|
|
3278
|
+
$verbMatch = ($pattern -eq $Verb) -or ($pattern -eq '*')
|
|
3279
|
+
if (-not $verbMatch -and $pattern.EndsWith('.*')) {
|
|
3280
|
+
# The namespace prefix stops at the LAST dot, not one character before the asterisk. Taking the
|
|
3281
|
+
# string minus its final character turns "file.*" into "file." (correct) but also turns
|
|
3282
|
+
# "file.delete" into "file.d", which then prefix-matches "file.delete" AND "file.destroy" and every
|
|
3283
|
+
# other verb that happens to start with the same letters. A rule for one verb must not deny another.
|
|
3284
|
+
$prefix = $pattern.Substring(0, $pattern.Length - 2) + '.'
|
|
3285
|
+
$verbMatch = $Verb.StartsWith($prefix)
|
|
3286
|
+
}
|
|
3287
|
+
if (-not $verbMatch) { continue }
|
|
3288
|
+
$value = '*'
|
|
3289
|
+
if ($null -ne $r.target -and $null -ne $r.target.PSObject.Properties['value']) { $value = [string]$r.target.value }
|
|
3290
|
+
# A target that is not the wildcard only matches when the caller's own string matches it exactly.
|
|
3291
|
+
# The argument is used AS WRITTEN rather than resolved to an absolute path: a rule is granted on the
|
|
3292
|
+
# thing the user was shown, and rewriting it here would make matching depend on the working directory.
|
|
3293
|
+
if ($value -ne '*' -and $value -ne $Target) { continue }
|
|
3294
|
+
$matching += $r
|
|
3295
|
+
}
|
|
3296
|
+
foreach ($r in $matching) { if ($r.decision -eq 'deny-always') { return $r } }
|
|
3297
|
+
foreach ($r in $matching) { if ($r.decision -eq 'allow-always') { return $r } }
|
|
3298
|
+
return $null
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
# The value a rule is written against, derived from the verb and the caller's own argument.
|
|
3302
|
+
#
|
|
3303
|
+
# The verb layer has one thing the tool layer does not: the exact argv. So the target here is the
|
|
3304
|
+
# argument AS TYPED - a path, a service name, a variable name, a pid - which is the string a human would
|
|
3305
|
+
# have written into a rule. An unresolvable verb shape yields '*' (verb-wide only), which is the
|
|
3306
|
+
# conservative direction: a rule scoped to one path can only fail to match, never match too much.
|
|
3307
|
+
function Get-PermissionTarget {
|
|
3308
|
+
param($Parsed, [string]$Verb)
|
|
3309
|
+
$first = $null
|
|
3310
|
+
if ($Parsed.positionals.Length -ge 1) { $first = [string]$Parsed.positionals[0] }
|
|
3311
|
+
if ([string]::IsNullOrEmpty($first)) { return '*' }
|
|
3312
|
+
switch -Regex ($Verb) {
|
|
3313
|
+
'^file\.' { return $first }
|
|
3314
|
+
'^service\.' { return $first }
|
|
3315
|
+
'^task\.(launch|kill)$' { return $first }
|
|
3316
|
+
'^environment\.' { return $first }
|
|
3317
|
+
default { return '*' }
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3226
3321
|
# The well-known SID for "Everyone". Layer A denies write access to it, because denying Everyone
|
|
3227
3322
|
# covers every context that could open the file, including the automation one, without this process
|
|
3228
3323
|
# having to know which account the harness runs as.
|
package/src/host/bin/dsh-os7.ps1
CHANGED
|
@@ -589,6 +589,56 @@ function Assert-Allowed {
|
|
|
589
589
|
if ($Positionals -ge 0 -and $Parsed.positionals.Length -gt $Positionals) {
|
|
590
590
|
throw (New-OsError -Code 'E_USAGE' -Message ("unexpected extra argument <{0}>" -f $Parsed.positionals[$Positionals]))
|
|
591
591
|
}
|
|
592
|
+
|
|
593
|
+
# ---------------------------------------------------------------------------
|
|
594
|
+
# The durable rules are consulted HERE, at the one gate every mutating verb passes through.
|
|
595
|
+
#
|
|
596
|
+
# Before this existed, `permission.json` was written by the consent delegate and read by
|
|
597
|
+
# `permission.list` / `permission.query` for DISPLAY only: no verb ever looked at a rule, so a
|
|
598
|
+
# `deny-always` stopped nothing and an `allow-always` bought nothing. The file recorded decisions that
|
|
599
|
+
# had no effect, which is worse than having no file at all, because it reads as enforcement.
|
|
600
|
+
#
|
|
601
|
+
# A deny is checked FIRST and wins outright, so the answer never depends on the order rules were
|
|
602
|
+
# appended in. It is checked BEFORE the `--allow` flag, deliberately: `--allow` says a caller believes
|
|
603
|
+
# it is authorized, and a rule is the user having said otherwise. A deny that `--allow` could overrule
|
|
604
|
+
# would be a rule that does not deny.
|
|
605
|
+
#
|
|
606
|
+
# An allow rule still requires `--allow`, and that is not an oversight: the flag is the per-call
|
|
607
|
+
# statement that a caller has been authorized for THIS call, while the rule is the standing permission
|
|
608
|
+
# to be authorized at all. Requiring both keeps the flag meaningful for callers that never consult the
|
|
609
|
+
# rules, and stops a durable grant from silently authorizing every future call on its own.
|
|
610
|
+
#
|
|
611
|
+
# Both helpers live in permission.ps1, which is assembled AFTER this module. That is not a problem:
|
|
612
|
+
# PowerShell binds function calls at invocation time, not at definition time, and no verb runs until
|
|
613
|
+
# every module has been assembled into the one script. The golden suite exercises this path, so a
|
|
614
|
+
# load-order mistake cannot pass silently.
|
|
615
|
+
# ---------------------------------------------------------------------------
|
|
616
|
+
$state = Read-PermissionFile
|
|
617
|
+
# An UNTRUSTED file is treated as having no rules at all, which is what the consent delegate does
|
|
618
|
+
# (`readDocument` returns an empty document when `updatedBy` is not its own). Only setting a `trusted`
|
|
619
|
+
# flag and then honouring the rules anyway would let anyone who can write the file both grant and deny
|
|
620
|
+
# by hand: the delegate is supposed to be its only writer, so a file it did not write is not evidence.
|
|
621
|
+
if ($null -ne $state.document -and $state.trusted) {
|
|
622
|
+
$target = Get-PermissionTarget -Parsed $Parsed -Verb $Verb
|
|
623
|
+
# THE TWO SPELLINGS ARE NOT THE SAME STRING, and that is the whole trap here.
|
|
624
|
+
#
|
|
625
|
+
# Every call site passes the verb in its HUMAN form, with spaces ("file delete"), because that is the
|
|
626
|
+
# text the error messages show. `permission.json` stores it in its TABLE form, with dots
|
|
627
|
+
# ("file.delete"), because that is what `selfcheck` and `permission.query` report and what a user
|
|
628
|
+
# writes into a rule. Comparing the two directly made every rule fail to match - silently, and only
|
|
629
|
+
# for the enforcement path, since the display path was always given the dotted form by its caller.
|
|
630
|
+
# A rule that never matches is indistinguishable from no rule at all, so this had to be normalized
|
|
631
|
+
# rather than documented.
|
|
632
|
+
$ruleVerb = $Verb.Replace(' ', '.')
|
|
633
|
+
$rule = Get-PermissionDecision -Document $state.document -Verb $ruleVerb -Target $target
|
|
634
|
+
if ($null -ne $rule -and
|
|
635
|
+
[string]::Equals([string]$rule.decision, 'deny-always', [System.StringComparison]::Ordinal)) {
|
|
636
|
+
throw (New-OsError -Code 'E_PERMISSION_DENIED_BY_RULE' `
|
|
637
|
+
-Message ("{0} on '{1}' is denied by the stored rule {2}" -f $Verb, $target, $rule.id) `
|
|
638
|
+
-Hint 'the rule was written by the consent delegate; remove it with os_permission_revoke before retrying')
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
592
642
|
if (-not (Test-HasFlag -Parsed $Parsed -Name 'allow')) {
|
|
593
643
|
throw (New-OsError -Code 'E_PERMISSION' `
|
|
594
644
|
-Message ("{0} is a mutating operation and requires user authorization" -f $Verb) `
|
|
@@ -3557,26 +3607,9 @@ function Invoke-PermissionQuery {
|
|
|
3557
3607
|
$target = Get-Positional -Parsed $Parsed -Index 1 -Name 'target' -Optional -Default '*'
|
|
3558
3608
|
Assert-NoExtra -Parsed $Parsed -Expected 2
|
|
3559
3609
|
$state = Read-PermissionFile
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
# The rule text comes from permission.json and the verb comes from argv, so both comparisons are
|
|
3564
|
-
# named rather than linguistic. A rule written "file.*" matches every verb under "file." because
|
|
3565
|
-
# the trailing asterisk is dropped and what remains is used as a prefix.
|
|
3566
|
-
$verbMatch = [string]::Equals([string]$r.verb, $verb, [System.StringComparison]::OrdinalIgnoreCase)
|
|
3567
|
-
if (-not $verbMatch -and $r.verb.EndsWith('.*', [System.StringComparison]::Ordinal)) {
|
|
3568
|
-
$prefix = $r.verb.Substring(0, $r.verb.Length - 1)
|
|
3569
|
-
# The prefix test keeps the case-sensitivity the 5.1 code had; only its tolerance of control
|
|
3570
|
-
# characters is dropped.
|
|
3571
|
-
$verbMatch = $verb.StartsWith($prefix, [System.StringComparison]::Ordinal)
|
|
3572
|
-
}
|
|
3573
|
-
if (-not $verbMatch) { continue }
|
|
3574
|
-
$value = '*'
|
|
3575
|
-
if ($null -ne $r.target -and $null -ne $r.target.PSObject.Properties['value']) { $value = [string]$r.target.value }
|
|
3576
|
-
if ([string]::Equals($value, '*', [System.StringComparison]::OrdinalIgnoreCase) -or
|
|
3577
|
-
[string]::Equals($value, $target, [System.StringComparison]::OrdinalIgnoreCase)) { $matched = $r; break }
|
|
3578
|
-
}
|
|
3579
|
-
}
|
|
3610
|
+
# One evaluator, shared with the verb layer's authorization gate, so the answer this verb REPORTS and
|
|
3611
|
+
# the answer a mutating verb ACTS ON cannot drift apart.
|
|
3612
|
+
$matched = Get-PermissionDecision -Document $state.document -Verb $verb -Target $target
|
|
3580
3613
|
$decision = 'ask'
|
|
3581
3614
|
if ($null -ne $matched) { $decision = [string]$matched.decision }
|
|
3582
3615
|
elseif (-not $state.trusted) { $decision = 'ask' }
|
|
@@ -3592,6 +3625,79 @@ function Invoke-PermissionQuery {
|
|
|
3592
3625
|
}
|
|
3593
3626
|
}
|
|
3594
3627
|
|
|
3628
|
+
# ---------------------------------------------------------------------------
|
|
3629
|
+
# Rule evaluation for the verb layer
|
|
3630
|
+
# ---------------------------------------------------------------------------
|
|
3631
|
+
|
|
3632
|
+
# DENY FIRST, then allow. This is the deliberate departure from "first match wins" that the consent
|
|
3633
|
+
# delegate already implements, and it is what the two readers had disagreed about: this function used to
|
|
3634
|
+
# take the first matching rule in array order, so a deny and an allow that both matched resolved by
|
|
3635
|
+
# whichever happened to be appended first. The safe answer must not depend on append order.
|
|
3636
|
+
#
|
|
3637
|
+
# Every comparison below is named ORDINAL, for the reason this whole module names its comparisons: from
|
|
3638
|
+
# .NET 5 an ignorable character compares equal to almost anything, so a linguistic `-eq` would let a rule
|
|
3639
|
+
# whose verb carries a stray control byte match a verb it is not. The delegate's JS reader compares by
|
|
3640
|
+
# exact string equality, so ordinal is also what keeps the two readers agreeing.
|
|
3641
|
+
#
|
|
3642
|
+
# Returns the DECIDING rule, or $null when no rule matches (the caller then falls back to defaults).
|
|
3643
|
+
function Get-PermissionDecision {
|
|
3644
|
+
param($Document, [string]$Verb, [string]$Target)
|
|
3645
|
+
if ($null -eq $Document) { return $null }
|
|
3646
|
+
if ($null -eq $Document.PSObject.Properties['rules']) { return $null }
|
|
3647
|
+
$matching = @()
|
|
3648
|
+
foreach ($r in @($Document.rules)) {
|
|
3649
|
+
$pattern = [string]$r.verb
|
|
3650
|
+
$verbMatch = [string]::Equals($pattern, $Verb, [System.StringComparison]::OrdinalIgnoreCase)
|
|
3651
|
+
if (-not $verbMatch) {
|
|
3652
|
+
$verbMatch = [string]::Equals($pattern, '*', [System.StringComparison]::OrdinalIgnoreCase)
|
|
3653
|
+
}
|
|
3654
|
+
if (-not $verbMatch -and $pattern.EndsWith('.*', [System.StringComparison]::Ordinal)) {
|
|
3655
|
+
# The namespace prefix stops at the LAST dot, not one character before the asterisk. Taking the
|
|
3656
|
+
# string minus its final character turns "file.*" into "file." (correct) but also turns
|
|
3657
|
+
# "file.delete" into "file.d", which then prefix-matches "file.delete" AND "file.destroy" and every
|
|
3658
|
+
# other verb that happens to start with the same letters. A rule for one verb must not deny another.
|
|
3659
|
+
$prefix = $pattern.Substring(0, $pattern.Length - 2) + '.'
|
|
3660
|
+
$verbMatch = $Verb.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)
|
|
3661
|
+
}
|
|
3662
|
+
if (-not $verbMatch) { continue }
|
|
3663
|
+
$value = '*'
|
|
3664
|
+
if ($null -ne $r.target -and $null -ne $r.target.PSObject.Properties['value']) { $value = [string]$r.target.value }
|
|
3665
|
+
# A target that is not the wildcard only matches when the caller's own string matches it exactly.
|
|
3666
|
+
# The argument is used AS WRITTEN rather than resolved to an absolute path: a rule is granted on the
|
|
3667
|
+
# thing the user was shown, and rewriting it here would make matching depend on the working directory.
|
|
3668
|
+
if (-not [string]::Equals($value, '*', [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
3669
|
+
if (-not [string]::Equals($value, $Target, [System.StringComparison]::OrdinalIgnoreCase)) { continue }
|
|
3670
|
+
}
|
|
3671
|
+
$matching += $r
|
|
3672
|
+
}
|
|
3673
|
+
foreach ($r in $matching) {
|
|
3674
|
+
if ([string]::Equals([string]$r.decision, 'deny-always', [System.StringComparison]::Ordinal)) { return $r }
|
|
3675
|
+
}
|
|
3676
|
+
foreach ($r in $matching) {
|
|
3677
|
+
if ([string]::Equals([string]$r.decision, 'allow-always', [System.StringComparison]::Ordinal)) { return $r }
|
|
3678
|
+
}
|
|
3679
|
+
return $null
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
# The value a rule is written against, derived from the verb and the caller's own argument.
|
|
3683
|
+
#
|
|
3684
|
+
# The verb layer has one thing the tool layer does not: the exact argv. So the target here is the argument
|
|
3685
|
+
# AS TYPED - a path, a service name, a variable name, a pid - which is the string a human would have
|
|
3686
|
+
# written into a rule. An unrecognised verb shape yields '*' (verb-wide only), which is the conservative
|
|
3687
|
+
# direction: a path-scoped rule can then only fail to match, never match too much.
|
|
3688
|
+
function Get-PermissionTarget {
|
|
3689
|
+
param($Parsed, [string]$Verb)
|
|
3690
|
+
$first = $null
|
|
3691
|
+
if ($Parsed.positionals.Length -ge 1) { $first = [string]$Parsed.positionals[0] }
|
|
3692
|
+
if ([string]::IsNullOrEmpty($first)) { return '*' }
|
|
3693
|
+
if ($Verb.StartsWith('file.', [System.StringComparison]::Ordinal)) { return $first }
|
|
3694
|
+
if ($Verb.StartsWith('service.', [System.StringComparison]::Ordinal)) { return $first }
|
|
3695
|
+
if ($Verb.StartsWith('environment.', [System.StringComparison]::Ordinal)) { return $first }
|
|
3696
|
+
if ([string]::Equals($Verb, 'task.launch', [System.StringComparison]::Ordinal) -or
|
|
3697
|
+
[string]::Equals($Verb, 'task.kill', [System.StringComparison]::Ordinal)) { return $first }
|
|
3698
|
+
return '*'
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3595
3701
|
# The well-known SID for "Everyone". Layer A denies write access to it, because denying Everyone
|
|
3596
3702
|
# covers every context that could open the file, including the automation one, without this process
|
|
3597
3703
|
# having to know which account the harness runs as.
|
package/src/permission/index.js
CHANGED
|
@@ -344,6 +344,68 @@ export function apply(ctx, rawConfig) {
|
|
|
344
344
|
ctx.tools.register(spec)
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Ask the user before a DURABLE decision is written.
|
|
349
|
+
*
|
|
350
|
+
* The delegate's whole reason for existing is that the scripts cannot reach the approval UI: there is
|
|
351
|
+
* no write verb in the verb table precisely so that `permission.json` has exactly one writer, and that
|
|
352
|
+
* writer is supposed to be gated by a human. Until now the gate was only a sentence in this tool's
|
|
353
|
+
* description asking the model to behave — the enforcement was a request, not a check. This makes it a
|
|
354
|
+
* check.
|
|
355
|
+
*
|
|
356
|
+
* The Web composition already ships the answerer (`dsh-client-ui-approval`), so nothing here renders a
|
|
357
|
+
* prompt; it asks, and the client shows the banner. `allowed-once` is the seam's only grant, and for
|
|
358
|
+
* this purpose it means "yes, record this rule" — the rule's own persistence is what makes it durable,
|
|
359
|
+
* which is why a durable decision uses this seam while a one-shot verb call does not.
|
|
360
|
+
*
|
|
361
|
+
* Absence of the seam is reported rather than ignored: a rule that nobody approved must not be written
|
|
362
|
+
* quietly. A deployment that wants unattended grants has to say so by configuring `requireApproval:
|
|
363
|
+
* false`, which is a decision on the record instead of a default nobody chose.
|
|
364
|
+
*/
|
|
365
|
+
async function approvedForDurableWrite(exec, { verb, target, decision, revoke }) {
|
|
366
|
+
if (config.requireApproval === false) return { granted: true, outcome: 'not-required' }
|
|
367
|
+
const approval = typeof ctx.get === 'function' ? ctx.get('approval') : undefined
|
|
368
|
+
if (approval === undefined) {
|
|
369
|
+
return {
|
|
370
|
+
granted: false,
|
|
371
|
+
outcome: 'unavailable',
|
|
372
|
+
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',
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (exec?.agent === undefined || exec?.agent === null) {
|
|
376
|
+
return {
|
|
377
|
+
granted: false,
|
|
378
|
+
outcome: 'unavailable',
|
|
379
|
+
detail: 'this call carries no agent, so there is no session to show the request in; a durable decision needs an interactive turn',
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const verbLabel = revoke === true ? `revoke ${verb}` : `${decision} for ${verb}`
|
|
383
|
+
try {
|
|
384
|
+
const outcome = await approval.request({
|
|
385
|
+
agent: exec.agent,
|
|
386
|
+
toolName: revoke === true ? 'os_permission_revoke' : 'os_permission_grant',
|
|
387
|
+
...exec.callId === undefined ? {} : { callId: exec.callId },
|
|
388
|
+
reason: `dsh-os wants to ${verbLabel} on ${target}`,
|
|
389
|
+
displayReason: {
|
|
390
|
+
en: revoke === true
|
|
391
|
+
? `dsh-os wants to remove the stored rule for "${verb}".`
|
|
392
|
+
: `dsh-os wants to remember "${decision === 'allow-always' ? 'always allow' : 'always deny'}" for "${verb}" on ${target}.`,
|
|
393
|
+
},
|
|
394
|
+
...exec.signal === undefined ? {} : { signal: exec.signal },
|
|
395
|
+
})
|
|
396
|
+
if (outcome === 'allowed-once') return { granted: true, outcome }
|
|
397
|
+
return {
|
|
398
|
+
granted: false,
|
|
399
|
+
outcome,
|
|
400
|
+
detail: outcome === 'rejected'
|
|
401
|
+
? 'refused — note that a session policy of "never" refuses before any prompt is shown, so nothing was displayed to the user'
|
|
402
|
+
: 'no answerer accepted the request',
|
|
403
|
+
}
|
|
404
|
+
} catch (error) {
|
|
405
|
+
return { granted: false, outcome: 'unavailable', detail: error instanceof Error ? error.message : String(error) }
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
347
409
|
tool({
|
|
348
410
|
name: 'os_permission_query',
|
|
349
411
|
description:
|
|
@@ -389,8 +451,24 @@ export function apply(ctx, rawConfig) {
|
|
|
389
451
|
required: ['verb', 'target', 'decision'],
|
|
390
452
|
},
|
|
391
453
|
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
392
|
-
async execute(args) {
|
|
393
|
-
|
|
454
|
+
async execute(args, exec) {
|
|
455
|
+
// The user decides before anything is written. A refusal is reported as a result rather than an
|
|
456
|
+
// exception, so the model can tell "the user said no" from "the tool broke".
|
|
457
|
+
const permission = await approvedForDurableWrite(exec, {
|
|
458
|
+
verb: args.verb,
|
|
459
|
+
target: args.target ?? '*',
|
|
460
|
+
decision: args.decision,
|
|
461
|
+
})
|
|
462
|
+
if (!permission.granted) {
|
|
463
|
+
return JSON.stringify({
|
|
464
|
+
ok: false,
|
|
465
|
+
written: false,
|
|
466
|
+
authorization: permission.outcome,
|
|
467
|
+
...permission.detail === undefined ? {} : { reason: permission.detail },
|
|
468
|
+
note: 'permission.json was NOT modified.',
|
|
469
|
+
}, null, 2)
|
|
470
|
+
}
|
|
471
|
+
return JSON.stringify({ ...grant(args), authorizedBy: permission.outcome }, null, 2)
|
|
394
472
|
},
|
|
395
473
|
})
|
|
396
474
|
|
|
@@ -405,8 +483,25 @@ export function apply(ctx, rawConfig) {
|
|
|
405
483
|
required: ['ruleId'],
|
|
406
484
|
},
|
|
407
485
|
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
408
|
-
async execute(args) {
|
|
409
|
-
|
|
486
|
+
async execute(args, exec) {
|
|
487
|
+
// Revoking is a durable decision too — it removes a rule the user already agreed to, so it takes
|
|
488
|
+
// the same gate as writing one.
|
|
489
|
+
const permission = await approvedForDurableWrite(exec, {
|
|
490
|
+
verb: `rule ${args.ruleId}`,
|
|
491
|
+
target: args.ruleId,
|
|
492
|
+
decision: 'revoke',
|
|
493
|
+
revoke: true,
|
|
494
|
+
})
|
|
495
|
+
if (!permission.granted) {
|
|
496
|
+
return JSON.stringify({
|
|
497
|
+
ok: false,
|
|
498
|
+
written: false,
|
|
499
|
+
authorization: permission.outcome,
|
|
500
|
+
...permission.detail === undefined ? {} : { reason: permission.detail },
|
|
501
|
+
note: 'permission.json was NOT modified.',
|
|
502
|
+
}, null, 2)
|
|
503
|
+
}
|
|
504
|
+
return JSON.stringify({ ...revoke({ ruleId: args.ruleId }), authorizedBy: permission.outcome }, null, 2)
|
|
410
505
|
},
|
|
411
506
|
})
|
|
412
507
|
|
package/src/plugin/index.js
CHANGED
|
@@ -24,9 +24,62 @@ import { resolveInvocation, runScript, searchedDirectory } from './invoke.js'
|
|
|
24
24
|
|
|
25
25
|
export const name = 'dsh-os'
|
|
26
26
|
|
|
27
|
-
/** The plugin is inert without the tool registry. */
|
|
27
|
+
/** The plugin is inert without the tool registry; the approval seam is optional and read at use time. */
|
|
28
28
|
export const inject = ['tools']
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Parse an envelope, or undefined when the text is not one.
|
|
32
|
+
*
|
|
33
|
+
* Used only to READ the error code of a refusal. A caller that cannot parse it gets the text back
|
|
34
|
+
* unchanged, so a malformed envelope is never made worse by this.
|
|
35
|
+
*/
|
|
36
|
+
function parseEnvelope(text) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(text)
|
|
39
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : undefined
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Ask the DSH approval seam for a one-shot decision on one call.
|
|
47
|
+
*
|
|
48
|
+
* A host plugin does NOT register an answerer to reach the user — the opposite is true. The Web
|
|
49
|
+
* composition already ships `dsh-client-ui-approval`, which answers `approval/request` and renders the
|
|
50
|
+
* banner ("tool X requests elevated execution" with Reject / Allow once). A plugin only has to ASK, and
|
|
51
|
+
* the client renders it. That is why this file carries no UI code for the decision.
|
|
52
|
+
*
|
|
53
|
+
* Three facts about the seam shape the code below, all read from `@deepseek-ai/dsh-user-approval`:
|
|
54
|
+
*
|
|
55
|
+
* * `request()` reads `req.agent.session`, so a call with no agent THROWS rather than being refused
|
|
56
|
+
* politely. The caller must not invoke it in that case, which is why `agent` is passed in and
|
|
57
|
+
* checked here rather than being trusted.
|
|
58
|
+
* * the only grant in the outcome vocabulary is `'allowed-once'`; `'rejected'`, `'cancelled'` and
|
|
59
|
+
* `'unavailable'` all mean "no". They are reported distinctly so the caller can say which happened.
|
|
60
|
+
* * a session whose policy is `never` resolves to `'rejected'` BEFORE the waterfall, so no prompt is
|
|
61
|
+
* ever rendered. That is a deployment setting, not a fault in this code, and the message says so.
|
|
62
|
+
*
|
|
63
|
+
* The seam is consumed opportunistically with `ctx.get('approval')`, the same way `dsh-tools` does it: a
|
|
64
|
+
* deployment without the approval service keeps every verb that does not need a prompt.
|
|
65
|
+
*/
|
|
66
|
+
async function askApproval(ctx, { agent, callId, toolName, reason, displayReason, signal }) {
|
|
67
|
+
const approval = typeof ctx.get === 'function' ? ctx.get('approval') : undefined
|
|
68
|
+
if (approval === undefined) {
|
|
69
|
+
return { granted: false, outcome: 'unavailable', detail: 'no approval service is loaded in this deployment' }
|
|
70
|
+
}
|
|
71
|
+
if (agent === undefined || agent === null) {
|
|
72
|
+
return { granted: false, outcome: 'unavailable', detail: 'the call carries no agent, so there is no session to route an approval through' }
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const outcome = await approval.request({ agent, toolName, ...callId === undefined ? {} : { callId }, reason, displayReason, signal })
|
|
76
|
+
if (outcome === 'allowed-once') return { granted: true, outcome }
|
|
77
|
+
return { granted: false, outcome, detail: outcome === 'rejected' ? 'the request was refused (a session policy of "never" refuses before any prompt is shown)' : undefined }
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { granted: false, outcome: 'unavailable', detail: error instanceof Error ? error.message : String(error) }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
30
83
|
export function apply(ctx, rawConfig) {
|
|
31
84
|
// `?? {}` rather than a default parameter: Cordis passes a row's config as `null` when the row declares
|
|
32
85
|
// none, and a default parameter only fires for `undefined`. Every entry point in this package guards the
|
|
@@ -93,18 +146,24 @@ export function apply(ctx, rawConfig) {
|
|
|
93
146
|
|
|
94
147
|
// The verb may be given as several words; the script resolves multi-word verbs itself, so it is
|
|
95
148
|
// split here rather than guessed at.
|
|
96
|
-
const
|
|
97
|
-
for (const value of args.arguments ?? [])
|
|
98
|
-
for (const flag of args.flags ?? [])
|
|
99
|
-
if (args.allow === true) verbArgs.push('--allow')
|
|
149
|
+
const baseArgs = [...String(args.verb).trim().split(/\s+/)]
|
|
150
|
+
for (const value of args.arguments ?? []) baseArgs.push(String(value))
|
|
151
|
+
for (const flag of args.flags ?? []) baseArgs.push(String(flag))
|
|
100
152
|
|
|
101
|
-
const
|
|
153
|
+
const runOnce = (withAllow) => runScript(
|
|
154
|
+
invocation,
|
|
155
|
+
withAllow ? [...baseArgs, '--allow'] : baseArgs,
|
|
156
|
+
timeoutMs,
|
|
157
|
+
exec?.signal,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
let result = await runOnce(args.allow === true)
|
|
102
161
|
|
|
103
162
|
if (result.timedOut) {
|
|
104
163
|
throw new Error(`dsh-os verb "${args.verb}" timed out after ${timeoutMs}ms`)
|
|
105
164
|
}
|
|
106
165
|
|
|
107
|
-
|
|
166
|
+
let text = result.stdout.trim()
|
|
108
167
|
if (text === '') {
|
|
109
168
|
// An empty stdout would leave the caller with nothing to parse, so the exit code and the
|
|
110
169
|
// captured diagnostics are reported instead of an empty success.
|
|
@@ -114,6 +173,51 @@ export function apply(ctx, rawConfig) {
|
|
|
114
173
|
)
|
|
115
174
|
}
|
|
116
175
|
|
|
176
|
+
// A refusal that a user decision could actually resolve is turned into one, ONCE, in this same
|
|
177
|
+
// call: mutating verbs answer `E_PERMISSION` when they arrive without `--allow`, so the user is
|
|
178
|
+
// asked and the verb is retried with it. Asking is skipped when the request already carried
|
|
179
|
+
// `--allow`, because then the refusal came from somewhere a prompt cannot change.
|
|
180
|
+
if (args.allow !== true) {
|
|
181
|
+
const refusal = parseEnvelope(text)
|
|
182
|
+
if (refusal?.error?.code === 'E_PERMISSION') {
|
|
183
|
+
const decision = await askApproval(ctx, {
|
|
184
|
+
agent: exec?.agent,
|
|
185
|
+
callId: exec?.callId,
|
|
186
|
+
toolName: 'dsh_os',
|
|
187
|
+
reason: `dsh-os verb "${args.verb}" needs authorization`,
|
|
188
|
+
displayReason: {
|
|
189
|
+
en: `dsh-os wants to run "${args.verb}", which changes something on this machine.`,
|
|
190
|
+
},
|
|
191
|
+
signal: exec?.signal,
|
|
192
|
+
})
|
|
193
|
+
if (decision.granted) {
|
|
194
|
+
result = await runOnce(true)
|
|
195
|
+
if (result.timedOut) {
|
|
196
|
+
throw new Error(`dsh-os verb "${args.verb}" timed out after ${timeoutMs}ms after approval`)
|
|
197
|
+
}
|
|
198
|
+
text = result.stdout.trim()
|
|
199
|
+
} else if (decision.outcome !== 'unavailable' || decision.detail !== undefined) {
|
|
200
|
+
// Report the refusal as the typed envelope the caller already branches on, with the reason
|
|
201
|
+
// attached — a bare "no" from a prompt that was never shown would be indistinguishable
|
|
202
|
+
// from a verb that failed on its own.
|
|
203
|
+
return JSON.stringify({
|
|
204
|
+
ok: false,
|
|
205
|
+
verb: refusal.verb ?? String(args.verb).trim().replace(/\s+/g, '.'),
|
|
206
|
+
error: {
|
|
207
|
+
code: 'E_PERMISSION',
|
|
208
|
+
message: `"${args.verb}" was not authorized (approval: ${decision.outcome})`,
|
|
209
|
+
...decision.detail === undefined ? {} : { hint: decision.detail },
|
|
210
|
+
},
|
|
211
|
+
meta: { ...refusal.meta, approval: decision.outcome },
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (text === '') {
|
|
218
|
+
throw new Error(`dsh-os verb "${args.verb}" produced no envelope after approval (exit ${result.code})`)
|
|
219
|
+
}
|
|
220
|
+
|
|
117
221
|
// The envelope is returned verbatim, including when ok is false: a typed failure is a result the
|
|
118
222
|
// caller must be able to read (error.code is authoritative), not an exception that loses it.
|
|
119
223
|
return text
|