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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepseek-coordination",
3
- "version": "0.1.7",
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": {
@@ -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.
@@ -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
- 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)
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
- 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)
410
505
  },
411
506
  })
412
507
 
@@ -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 verbArgs = [...String(args.verb).trim().split(/\s+/)]
97
- for (const value of args.arguments ?? []) verbArgs.push(String(value))
98
- for (const flag of args.flags ?? []) verbArgs.push(String(flag))
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 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)
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
- const text = result.stdout.trim()
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