Skip to content

Commit 1aac00a

Browse files
committed
feat(local-notifications): add explicit local sudo notifications
1 parent 4443130 commit 1aac00a

37 files changed

Lines changed: 1515 additions & 42 deletions

docs/configuration/local-sudo.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ providers:
5353
local-presence:
5454
provider: local-presence
5555
enabled: true
56+
local-notification:
57+
provider: local-notification
58+
enabled: true
5659
```
5760

5861
For full local macOS integration testing, install the Apple Development-signed privilege-services bundle with:
@@ -81,6 +84,8 @@ On macOS v1, timed sudo is brokered through the native privilege-services app bu
8184

8285
If a workflow uses an approval notifier with `provider: local-presence`, the same signed helper can trigger a macOS device-owner authentication prompt on the routed device before `authorize` runs. The result is recorded through the normal approvals path, alongside Slack and email approval callbacks. This requires the target agent to be online in an interactive login session; CI and unit tests mock the LocalAuthentication result rather than invoking biometrics.
8386

87+
Workflows can also opt into Thand-managed macOS notifications with `provider: local-notification` in `thand: notify`, approval notifiers, `authorize.with.notifiers`, or `revoke.with.notifiers`. These notifications are routed to the target `device_id` and posted by the signed helper through `UNUserNotificationCenter`. Broker-triggered lease notifications remain enabled separately for now.
88+
8489
## Account Mapping
8590

8691
Per-device account mappings decide which local account receives sudo.

docs/configuration/workflows/tasks.md

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ notifiers:
191191
| `slack` | Slack notifications | Channel ID: `C0123456789` or User ID |
192192
| `email` | Email notifications | Email address |
193193
| `local-presence` | macOS device-owner approval prompt | `device_id` |
194+
| `local-notification` | macOS local user notification | `device_id` |
194195

195196
### Flow Control
196197

@@ -491,21 +492,21 @@ The `notify` task sends notifications to users, administrators, or external syst
491492
- notify:
492493
thand: notify
493494
with:
494-
approvals: number # Number of approvals needed
495-
notifiers: # Notification configuration
496-
key:
497-
provider: string
498-
to: string
499-
message: string
495+
provider: string
496+
to: string
497+
message: string
498+
device: string # Optional; used by local-notification
500499
then: next-step
501500
```
502501

503502
### Parameters
504503

505504
| Parameter | Type | Required | Description |
506505
|-----------|------|----------|-------------|
507-
| `approvals` | number | Yes | Number of approvals required (for approval notifications) |
508-
| `notifier` | object | Yes | Notification configuration |
506+
| `provider` | string | Yes | Notification provider |
507+
| `to` | string or array | Yes | Recipient identity, channel, or email address |
508+
| `message` | string | No | Notification body |
509+
| `device` | string | No | Target device for `local-notification` |
509510

510511
### Notification Process
511512

@@ -519,6 +520,9 @@ The notify task:
519520

520521
- **Slack**: Sends rich notifications with approval buttons
521522
- **Email**: Sends email notifications
523+
- **Local Notification**: Sends a macOS user notification on a routed agent device with `provider: local-notification`
524+
525+
`local-notification` requires a live macOS agent and a target device. Device resolution follows the explicit notifier `device` field first, then the request `device`, then request metadata `device_id`. The same routing applies when `local-notification` is used in `thand: notify`, `authorize.with.notifiers`, `revoke.with.notifiers`, or `approvals.with.notifiers` approval prompts. It uses the signed helper and `UNUserNotificationCenter`; it does not replace broker-triggered sudo lease notifications, which remain in place for now.
522526

523527
### Examples
524528

@@ -527,12 +531,20 @@ The notify task:
527531
- slack-notify:
528532
thand: notify
529533
with:
530-
approvals: 1
531-
notifiers:
532-
slack:
533-
provider: slack
534-
to: "C0123456789"
535-
message: "Access granted to user"
534+
provider: slack
535+
to: "C0123456789"
536+
message: "Access granted to user"
537+
```
538+
539+
**macOS Local Notification**
540+
```yaml
541+
- local-notify:
542+
thand: notify
543+
with:
544+
provider: local-notification
545+
to: ${ $context.user.email }
546+
device: "${ .device }"
547+
message: "Your local sudo request was approved"
536548
```
537549

538550
**Note**: The notify task is primarily used internally by the approvals task. For standalone notifications, consider using standard Serverless Workflow `call` tasks to external APIs.

internal/config/environment/local/providers.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,9 @@ providers:
1818
description: Local macOS device-owner approval prompts
1919
provider: local-presence
2020
enabled: true
21+
22+
local-notification:
23+
name: Local Notification
24+
description: Local macOS user notifications
25+
provider: local-notification
26+
enabled: true

internal/config/providers_local.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ package config
22

33
import (
44
_ "github.com/thand-io/agent/internal/providers/local"
5+
_ "github.com/thand-io/agent/internal/providers/localnotification"
56
_ "github.com/thand-io/agent/internal/providers/localpresence"
67
)

internal/localbroker/client.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const (
2525
OperationExecCommand Operation = "exec_command"
2626
OperationPTYSession Operation = "pty_session"
2727
OperationCheckLocalPresence Operation = "check_local_presence"
28+
OperationPostNotification Operation = "post_local_notification"
2829

2930
DefaultControlExecutable = "/Library/Application Support/Thand/PrivilegeBroker/bin/thand-macos-privilege-brokerctl"
3031
DefaultMachServiceLabel = "io.thand.agent.privilege-broker"
@@ -38,6 +39,7 @@ type Client interface {
3839
GrantTimedSudoers(ctx context.Context, req TimedSudoersGrantRequest) (*TimedSudoersGrantResponse, error)
3940
RevokeTimedGrant(ctx context.Context, handle string) (*RevokeTimedGrantResponse, error)
4041
CheckLocalPresence(ctx context.Context, req CheckLocalPresenceRequest) (*CheckLocalPresenceResponse, error)
42+
PostLocalNotification(ctx context.Context, req PostLocalNotificationRequest) (*PostLocalNotificationResponse, error)
4143
}
4244

4345
type TimedSudoersGrantRequest struct {
@@ -153,6 +155,18 @@ type CheckLocalPresenceResponse struct {
153155
FailureReason string `json:"failure_reason,omitempty"`
154156
}
155157

158+
type PostLocalNotificationRequest struct {
159+
NotificationID string `json:"notification_id,omitempty"`
160+
Title string `json:"title"`
161+
Subtitle string `json:"subtitle,omitempty"`
162+
Body string `json:"body"`
163+
ThreadID string `json:"thread_id,omitempty"`
164+
}
165+
166+
type PostLocalNotificationResponse struct {
167+
Posted bool `json:"posted"`
168+
}
169+
156170
type helperSession struct {
157171
dial func(context.Context, string) (net.Conn, error)
158172
wait func() error
@@ -260,6 +274,19 @@ func (c *CommandClient) CheckLocalPresence(ctx context.Context, req CheckLocalPr
260274
})
261275
}
262276

277+
func (c *CommandClient) PostLocalNotification(ctx context.Context, req PostLocalNotificationRequest) (*PostLocalNotificationResponse, error) {
278+
return invokeHelperRPC(ctx, c, OperationPostNotification, func(ctx context.Context, client localbrokerv1.LocalBrokerControlClient) (*PostLocalNotificationResponse, error) {
279+
response, err := client.PostLocalNotification(ctx, localNotificationProtoRequest(req))
280+
if err != nil {
281+
return nil, err
282+
}
283+
284+
return &PostLocalNotificationResponse{
285+
Posted: response.GetPosted(),
286+
}, nil
287+
})
288+
}
289+
263290
func IsRetryableError(err error) bool {
264291
switch status.Code(err) {
265292
case codes.Unavailable, codes.DeadlineExceeded, codes.Canceled:
@@ -374,6 +401,16 @@ func localPresenceProtoRequest(req CheckLocalPresenceRequest) *localbrokerv1.Che
374401
}
375402
}
376403

404+
func localNotificationProtoRequest(req PostLocalNotificationRequest) *localbrokerv1.PostLocalNotificationRequest {
405+
return &localbrokerv1.PostLocalNotificationRequest{
406+
NotificationId: req.NotificationID,
407+
Title: req.Title,
408+
Subtitle: req.Subtitle,
409+
Body: req.Body,
410+
ThreadId: req.ThreadID,
411+
}
412+
}
413+
377414
func (c *CommandClient) helperArguments() []string {
378415
args := []string{
379416
defaultHelperCommand,

internal/localbroker/client_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ type fakeLocalBrokerControlServer struct {
3131
presenceResponse *localbrokerv1.CheckLocalPresenceResponse
3232
presenceError error
3333
presenceRequest *localbrokerv1.CheckLocalPresenceRequest
34+
35+
notificationResponse *localbrokerv1.PostLocalNotificationResponse
36+
notificationError error
37+
notificationRequest *localbrokerv1.PostLocalNotificationRequest
3438
}
3539

3640
func (f *fakeLocalBrokerControlServer) GrantTimedSudoers(
@@ -84,6 +88,22 @@ func (f *fakeLocalBrokerControlServer) CheckLocalPresence(
8488
}, nil
8589
}
8690

91+
func (f *fakeLocalBrokerControlServer) PostLocalNotification(
92+
_ context.Context,
93+
req *localbrokerv1.PostLocalNotificationRequest,
94+
) (*localbrokerv1.PostLocalNotificationResponse, error) {
95+
f.notificationRequest = req
96+
if f.notificationError != nil {
97+
return nil, f.notificationError
98+
}
99+
if f.notificationResponse != nil {
100+
return f.notificationResponse, nil
101+
}
102+
return &localbrokerv1.PostLocalNotificationResponse{
103+
Posted: true,
104+
}, nil
105+
}
106+
87107
func grpcTestStarter(
88108
t *testing.T,
89109
server *fakeLocalBrokerControlServer,
@@ -427,6 +447,39 @@ func TestCheckLocalPresencePassesPromptContext(t *testing.T) {
427447
}
428448
}
429449

450+
func TestPostLocalNotificationPassesNotificationPayload(t *testing.T) {
451+
server := &fakeLocalBrokerControlServer{}
452+
client := &CommandClient{
453+
executable: "/test/brokerctl",
454+
serviceLabel: "io.thand.agent.privilege-broker",
455+
start: grpcTestStarter(t, server, "", nil),
456+
}
457+
458+
response, err := client.PostLocalNotification(context.Background(), PostLocalNotificationRequest{
459+
NotificationID: "notification-1",
460+
Title: "Access approved",
461+
Subtitle: "Local Sudo",
462+
Body: "Your sudo access is ready",
463+
ThreadID: "workflow-1",
464+
})
465+
if err != nil {
466+
t.Fatalf("PostLocalNotification returned error: %v", err)
467+
}
468+
469+
if got, want := server.notificationRequest.GetNotificationId(), "notification-1"; got != want {
470+
t.Fatalf("notification_id = %q, want %q", got, want)
471+
}
472+
if got, want := server.notificationRequest.GetTitle(), "Access approved"; got != want {
473+
t.Fatalf("title = %q, want %q", got, want)
474+
}
475+
if got, want := server.notificationRequest.GetBody(), "Your sudo access is ready"; got != want {
476+
t.Fatalf("body = %q, want %q", got, want)
477+
}
478+
if !response.Posted {
479+
t.Fatal("expected posted response")
480+
}
481+
}
482+
430483
func TestIsRetryableError(t *testing.T) {
431484
if !IsRetryableError(status.Error(codes.Unavailable, "transport interrupted")) {
432485
t.Fatal("expected unavailable to be retryable")
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package models
2+
3+
type LocalNotificationRequest struct {
4+
NotificationID string `json:"notification_id,omitempty"`
5+
DeviceID string `json:"device_id,omitempty"`
6+
Title string `json:"title"`
7+
Subtitle string `json:"subtitle,omitempty"`
8+
Body string `json:"body"`
9+
ThreadID string `json:"thread_id,omitempty"`
10+
}

internal/providers/local/main_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,10 @@ func (f *fakeBrokerClient) CheckLocalPresence(ctx context.Context, req localbrok
10221022
return f.presenceResponse, nil
10231023
}
10241024

1025+
func (f *fakeBrokerClient) PostLocalNotification(context.Context, localbroker.PostLocalNotificationRequest) (*localbroker.PostLocalNotificationResponse, error) {
1026+
return &localbroker.PostLocalNotificationResponse{Posted: true}, nil
1027+
}
1028+
10251029
type fakeEnforcer struct {
10261030
revokeFn func(meta models.LocalSudoAuthorizationMetadata) error
10271031
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package localnotification
2+
3+
import (
4+
"context"
5+
6+
"github.com/thand-io/agent/internal/models"
7+
)
8+
9+
type localNotificationProviderActivities struct {
10+
provider *localNotificationProvider
11+
}
12+
13+
func (a *localNotificationProviderActivities) SendNotificationActivity(
14+
ctx context.Context,
15+
notification models.NotificationRequest,
16+
) error {
17+
return a.provider.SendNotification(ctx, notification)
18+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package localnotification
2+
3+
import "github.com/thand-io/agent/internal/models"
4+
5+
var LocalNotificationCapabilities = models.NewProviderCapabilities().
6+
WithDefaultNotifierConfiguration()

0 commit comments

Comments
 (0)