deepseek-coordination 0.1.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepseek-coordination",
3
- "version": "0.1.6",
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,7 +92,9 @@
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",
97
+ "test:activation": "node tests/activation.test.mjs",
96
98
  "test:takeover": "node tests/takeover.test.mjs",
97
99
  "test:permission": "node tests/permission.test.mjs",
98
100
  "test:workflow": "node tests/workflow.test.mjs",
@@ -102,7 +104,7 @@
102
104
  "test:load": "node tools/verify-plugin-load.mjs && node tools/verify-editions.mjs",
103
105
  "test:pipeline": "node tools/probe-pipeline.mjs",
104
106
  "measure:cache": "node tools/measure-cache.mjs",
105
- "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",
106
108
  "selfcheck": "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File src/host/bin/dsh-os.ps1 selfcheck"
107
109
  },
108
110
  "devDependencies": {
@@ -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 = $null
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.
@@ -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
- $matched = $null
3561
- if ($null -ne $state.document -and $null -ne $state.document.PSObject.Properties['rules']) {
3562
- foreach ($r in @($state.document.rules)) {
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.
@@ -192,7 +192,12 @@ function nextRuleId(document) {
192
192
  return `r-${String(max + 1).padStart(4, '0')}`
193
193
  }
194
194
 
195
- export function apply(ctx, config = {}) {
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 ?? {}
196
201
  const path = permissionPathOf(config)
197
202
  const auditLimit = config.auditLimit ?? AUDIT_LIMIT
198
203
 
@@ -339,6 +344,68 @@ export function apply(ctx, config = {}) {
339
344
  ctx.tools.register(spec)
340
345
  }
341
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
+
342
409
  tool({
343
410
  name: 'os_permission_query',
344
411
  description:
@@ -384,8 +451,24 @@ export function apply(ctx, config = {}) {
384
451
  required: ['verb', 'target', 'decision'],
385
452
  },
386
453
  output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
387
- async execute(args) {
388
- return JSON.stringify(grant(args), null, 2)
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)
389
472
  },
390
473
  })
391
474
 
@@ -400,8 +483,25 @@ export function apply(ctx, config = {}) {
400
483
  required: ['ruleId'],
401
484
  },
402
485
  output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
403
- async execute(args) {
404
- return JSON.stringify(revoke({ ruleId: args.ruleId }), null, 2)
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)
405
505
  },
406
506
  })
407
507
 
@@ -24,10 +24,67 @@ 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
- export function apply(ctx, config = {}) {
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
+
83
+ export function apply(ctx, rawConfig) {
84
+ // `?? {}` rather than a default parameter: Cordis passes a row's config as `null` when the row declares
85
+ // none, and a default parameter only fires for `undefined`. Every entry point in this package guards the
86
+ // same way, so a row whose config is trimmed degrades to defaults instead of failing to activate.
87
+ const config = rawConfig ?? {}
31
88
  const timeoutMs = config.timeoutMs ?? 120000
32
89
 
33
90
  ctx.tools.register({
@@ -89,18 +146,24 @@ export function apply(ctx, config = {}) {
89
146
 
90
147
  // The verb may be given as several words; the script resolves multi-word verbs itself, so it is
91
148
  // split here rather than guessed at.
92
- const verbArgs = [...String(args.verb).trim().split(/\s+/)]
93
- for (const value of args.arguments ?? []) verbArgs.push(String(value))
94
- for (const flag of args.flags ?? []) verbArgs.push(String(flag))
95
- 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))
96
152
 
97
- const result = await runScript(invocation, verbArgs, timeoutMs, exec?.signal)
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)
98
161
 
99
162
  if (result.timedOut) {
100
163
  throw new Error(`dsh-os verb "${args.verb}" timed out after ${timeoutMs}ms`)
101
164
  }
102
165
 
103
- const text = result.stdout.trim()
166
+ let text = result.stdout.trim()
104
167
  if (text === '') {
105
168
  // An empty stdout would leave the caller with nothing to parse, so the exit code and the
106
169
  // captured diagnostics are reported instead of an empty success.
@@ -110,6 +173,51 @@ export function apply(ctx, config = {}) {
110
173
  )
111
174
  }
112
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
+
113
221
  // The envelope is returned verbatim, including when ok is false: a typed failure is a result the
114
222
  // caller must be able to read (error.code is authoritative), not an exception that loses it.
115
223
  return text
@@ -113,7 +113,11 @@ export const DEFAULT_SCRIPT_DIR = 'src/host/bin'
113
113
  * DSH_OS_POWERSHELL still overrides the executable. `'auto'` uses 7.6 only when both the artefact and
114
114
  * a pwsh executable are present, and falls back to 5.1 otherwise.
115
115
  */
116
- export function resolveInvocation(config = {}) {
116
+ export function resolveInvocation(rawConfig) {
117
+ // `?? {}` rather than a default parameter. Cordis passes a row's config through as `null` when the row
118
+ // declares none, and a default parameter only fires for `undefined` — so `config.scriptPath` on that
119
+ // null threw and the plugin row failed to activate. Every caller may pass null.
120
+ const config = rawConfig ?? {}
117
121
  const explicit = config.scriptPath ? [config.scriptPath] : []
118
122
  const dir = config.scriptPath
119
123
  ? dirname(config.scriptPath)
@@ -192,7 +196,8 @@ export function resolveInvocation(config = {}) {
192
196
  }
193
197
 
194
198
  /** The directory an invocation would search, for error messages that name a real path. */
195
- export function searchedDirectory(config = {}) {
199
+ export function searchedDirectory(rawConfig) {
200
+ const config = rawConfig ?? {}
196
201
  return config.scriptPath ?? join(PKG_ROOT, config.scriptDir ?? DEFAULT_SCRIPT_DIR)
197
202
  }
198
203
 
@@ -110,9 +110,15 @@ export function writeSettings(file, settings) {
110
110
  * second one is evidence that the feature works.
111
111
  */
112
112
  export class SettingsService {
113
- constructor(ctx, config = {}) {
113
+ constructor(ctx, config) {
114
114
  this.ctx = ctx
115
- this.file = config.settingsPath ?? settingsPath()
115
+ // `config ?? {}` rather than a default parameter: Cordis passes the row's config as `null` when the
116
+ // row declares none, and a default parameter does not fire for null. This is exactly what made the
117
+ // shipped 0.1.5 fail to activate with "Cannot read properties of null (reading 'settingsPath')".
118
+ const resolved = config ?? {}
119
+ this.file = typeof resolved.settingsPath === 'string' && resolved.settingsPath.trim() !== ''
120
+ ? resolved.settingsPath
121
+ : settingsPath()
116
122
  this.settings = readSettings(this.file)
117
123
  this.activity = []
118
124
  this.listeners = new Set()
@@ -312,8 +318,11 @@ export function makeSettingsRoutes(service) {
312
318
  */
313
319
  export const name = 'dsh-os-settings'
314
320
 
315
- export function apply(ctx, config = {}) {
316
- const service = new SettingsService(ctx, config)
321
+ export function apply(ctx, rawConfig) {
322
+ // `?? {}` rather than a default parameter: Cordis passes a row's config as `null` when the row declares
323
+ // none, and a default parameter only fires for `undefined`. This row ships WITHOUT a config, which is
324
+ // what made the shipped 0.1.5 fail with "Cannot read properties of null (reading 'settingsPath')".
325
+ const service = new SettingsService(ctx, rawConfig ?? {})
317
326
 
318
327
  // `ctx.provide` REGISTERS the service and returns a disposer; `ctx.set` only overwrites the value of a
319
328
  // service that was already provided, and calling it first throws
@@ -388,7 +388,11 @@ function eligible(tool, args) {
388
388
  }
389
389
  }
390
390
 
391
- export function apply(ctx, config = {}) {
391
+ export function apply(ctx, rawConfig) {
392
+ // `?? {}` rather than a default parameter: Cordis passes a row's config as `null` when the row declares
393
+ // none, and a default parameter only fires for `undefined`, so the first property read threw and the
394
+ // row never activated. This row is shipped WITHOUT a config, so it hit exactly that.
395
+ const config = rawConfig ?? {}
392
396
  const toolTimeoutMs = config.timeoutMs ?? 120000
393
397
  // Mirrors the `tool-fs` row's `readLimit` (2000 by default): the interception must not quietly apply
394
398
  // a different window size than the built-in it replaced.
@@ -1405,12 +1405,17 @@ export function workflowToolDefinition(handlers) {
1405
1405
  }
1406
1406
  }
1407
1407
 
1408
- export function apply(ctx, config = {}) {
1409
- const root = typeof config.workflowRoot === 'string' && config.workflowRoot.trim() !== ''
1410
- ? config.workflowRoot
1408
+ export function apply(ctx, config) {
1409
+ // `config ?? {}` rather than a `config = {}` default parameter, because the two are NOT equivalent:
1410
+ // a default parameter only fires for `undefined`, and Cordis hands the row's config through as `null`
1411
+ // when the row declares none. The shipped 0.1.5 read `config.workflowRoot` off that null and the row
1412
+ // failed to activate with "Cannot read properties of null".
1413
+ const resolved = config ?? {}
1414
+ const root = typeof resolved.workflowRoot === 'string' && resolved.workflowRoot.trim() !== ''
1415
+ ? resolved.workflowRoot
1411
1416
  : workflowRoot()
1412
- const autoCompact = config.autoCompact !== false
1413
- const maxCheckpoints = Number.isInteger(config.maxCheckpoints) && config.maxCheckpoints > 0 ? config.maxCheckpoints : 20
1417
+ const autoCompact = resolved.autoCompact !== false
1418
+ const maxCheckpoints = Number.isInteger(resolved.maxCheckpoints) && resolved.maxCheckpoints > 0 ? resolved.maxCheckpoints : 20
1414
1419
 
1415
1420
  // The engine's own automatic compaction is named in the checkpoint result rather than invoked: this
1416
1421
  // plugin holds no session handle, and a call into a service whose context contract is not published
@@ -1446,14 +1451,28 @@ export function apply(ctx, config = {}) {
1446
1451
 
1447
1452
  if (typeof ctx.tools?.register === 'function') ctx.tools.register(workflowToolDefinition(handlers))
1448
1453
 
1449
- // The prompt half is registered only when the registry is present, so a deployment without it loses
1450
- // the section rather than the feature. The text is a constant: no state is read, nothing is looked up,
1451
- // and the model's cached prefix survives every phase of every workflow. See SECTION_TEXT.
1452
- if (typeof ctx.systemPrompt?.section === 'function') {
1453
- ctx.systemPrompt.section({
1454
- name: 'dsh-os:workflow',
1455
- order: sectionOrder(ctx.systemPrompt),
1456
- text: SECTION_TEXT,
1454
+ // The prompt half is registered through `ctx.inject`, NOT by reading `ctx.systemPrompt` directly.
1455
+ //
1456
+ // Reading it directly is what broke the shipped 0.1.5: Cordis resolves a service property through an
1457
+ // enforcing proxy, so an undeclared service does not read as `undefined` — it THROWS
1458
+ // Error: cannot get property "systemPrompt" without inject
1459
+ // and a guard of the form `typeof ctx.systemPrompt?.section === 'function'` never gets to run, because
1460
+ // the property access itself is the throw. The row then failed to activate entirely, losing the tool
1461
+ // as well as the section.
1462
+ //
1463
+ // `ctx.inject` is the first-party pattern for an optional service (`dsh-tool-fs` registers its image
1464
+ // tool the same way): the callback runs when the service is present and unwinds when it goes away, and
1465
+ // a deployment without a system prompt keeps the tool instead of losing everything.
1466
+ //
1467
+ // The text is a constant: no state is read, nothing is looked up, and the model's cached prefix
1468
+ // survives every phase of every workflow. See SECTION_TEXT.
1469
+ if (typeof ctx.inject === 'function') {
1470
+ ctx.inject(['systemPrompt'], (scoped) => {
1471
+ scoped.systemPrompt.section({
1472
+ name: 'dsh-os:workflow',
1473
+ order: sectionOrder(scoped.systemPrompt),
1474
+ text: SECTION_TEXT,
1475
+ })
1457
1476
  })
1458
1477
  }
1459
1478