Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .deadcode-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ internal/googleauth/scopes.go:34:6: unreachable func: ScopesForCommands
internal/googleauth/scopes.go:72:6: unreachable func: AllScopes
internal/googleauth/scopes.go:96:6: unreachable func: knownCommandNames
internal/msauth/scopes.go:17:6: unreachable func: canonicalPilotScope
internal/msauth/scopes.go:29:6: unreachable func: PilotAllowedScopes
internal/msauth/scopes.go:40:6: unreachable func: GuardPilotScopes
internal/officetext/extract.go:46:6: unreachable func: ExtractTextByMIME
internal/secrets/keychain_other.go:11:6: unreachable func: CheckKeychainLocked
Expand Down
5 changes: 3 additions & 2 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/automagik-dev/workit/internal/authclient"
"github.com/automagik-dev/workit/internal/config"
"github.com/automagik-dev/workit/internal/googleauth"
"github.com/automagik-dev/workit/internal/msauth"
"github.com/automagik-dev/workit/internal/outfmt"
"github.com/automagik-dev/workit/internal/secrets"
"github.com/automagik-dev/workit/internal/setup"
Expand Down Expand Up @@ -1371,12 +1372,12 @@ type AuthServicesCmd struct {
}

func (c *AuthServicesCmd) Run(ctx context.Context, _ *RootFlags) error {
infos := googleauth.ServicesInfo()
infos := appendAuthServiceInfos(googleauth.ServicesInfo(), msauth.ServicesInfo())
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"services": infos})
}
if c.Markdown {
_, err := io.WriteString(os.Stdout, googleauth.ServicesMarkdown(infos))
_, err := io.WriteString(os.Stdout, authServicesMarkdown(infos))
return err
}

Expand Down
82 changes: 82 additions & 0 deletions internal/cmd/auth_services_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package cmd

import (
"strings"

"github.com/automagik-dev/workit/internal/googleauth"
"github.com/automagik-dev/workit/internal/msauth"
)

type authServiceInfo struct {
Service string `json:"service"`
User bool `json:"user"`
Scopes []string `json:"scopes"`
APIs []string `json:"apis,omitempty"`
Note string `json:"note,omitempty"`
}

func appendAuthServiceInfos(googleInfos []googleauth.ServiceInfo, m365Infos []msauth.ServiceInfo) []authServiceInfo {
out := make([]authServiceInfo, 0, len(googleInfos)+len(m365Infos))
for _, info := range googleInfos {
out = append(out, authServiceInfo{
Service: string(info.Service),
User: info.User,
Scopes: append([]string(nil), info.Scopes...),
APIs: append([]string(nil), info.APIs...),
Note: info.Note,
})
}
for _, info := range m365Infos {
out = append(out, authServiceInfo{
Service: info.Service,
User: info.User,
Scopes: append([]string(nil), info.Scopes...),
APIs: append([]string(nil), info.APIs...),
Note: info.Note,
})
}

return out
}

func authServicesMarkdown(infos []authServiceInfo) string {
if len(infos) == 0 {
return ""
}

var b strings.Builder
b.WriteString("| Service | User | APIs | Scopes | Notes |\n")
b.WriteString("| --- | --- | --- | --- | --- |\n")
for _, info := range infos {
userLabel := "no"
if info.User {
userLabel = "yes"
}
b.WriteString("| ")
b.WriteString(info.Service)
b.WriteString(" | ")
b.WriteString(userLabel)
b.WriteString(" | ")
b.WriteString(strings.Join(info.APIs, ", "))
b.WriteString(" | ")
b.WriteString(markdownAuthScopes(info.Scopes))
b.WriteString(" | ")
b.WriteString(info.Note)
b.WriteString(" |\n")
}

return b.String()
}

func markdownAuthScopes(scopes []string) string {
if len(scopes) == 0 {
return ""
}

parts := make([]string, 0, len(scopes))
for _, scope := range scopes {
parts = append(parts, "`"+scope+"`")
}

return strings.Join(parts, "<br>")
}
97 changes: 97 additions & 0 deletions internal/cmd/m365.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package cmd

import (
"context"
"strings"

"github.com/automagik-dev/workit/internal/ui"
)

type M365Cmd struct {
Outlook M365OutlookCmd `cmd:"" help:"Microsoft Outlook read-only pilot commands"`
Calendar M365CalendarCmd `cmd:"" help:"Microsoft Calendar read-only pilot commands"`
}

type M365OutlookCmd struct {
Search M365OutlookSearchCmd `cmd:"" help:"Search Outlook messages (read-only pilot)"`
Message M365OutlookMessageCmd `cmd:"" help:"Read Outlook messages (read-only pilot)"`
}

type M365OutlookSearchCmd struct {
Query string `name:"query" help:"Microsoft Graph message search query"`
Top int `name:"top" help:"Maximum messages to return" default:"10"`
}

type M365OutlookMessageCmd struct {
Get M365OutlookMessageGetCmd `cmd:"" help:"Get an Outlook message by id (read-only pilot)"`
}

type M365OutlookMessageGetCmd struct {
ID string `arg:"" name:"id" help:"Microsoft Graph message id"`
}

type M365CalendarCmd struct {
Events M365CalendarEventsCmd `cmd:"" help:"List calendar events (read-only pilot)"`
FreeBusy M365CalendarFreeBusyCmd `cmd:"" name:"freebusy" help:"Check free/busy availability (read-only pilot)"`
}

type M365CalendarEventsCmd struct {
From string `name:"from" help:"Start time (RFC3339)"`
To string `name:"to" help:"End time (RFC3339)"`
Top int `name:"top" help:"Maximum events to return" default:"10"`
}

type M365CalendarFreeBusyCmd struct {
Users string `name:"users" help:"Comma-separated email addresses/resources"`
From string `name:"from" help:"Start time (RFC3339)"`
To string `name:"to" help:"End time (RFC3339)"`
}

func (c *M365OutlookSearchCmd) Run(ctx context.Context, flags *RootFlags) error {
return writeM365PilotResult(ctx, flags, "m365.outlook.search", map[string]any{
"query": strings.TrimSpace(c.Query),
"top": c.Top,
})
}

func (c *M365OutlookMessageGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return writeM365PilotResult(ctx, flags, "m365.outlook.message.get", map[string]any{
"id": strings.TrimSpace(c.ID),
})
}

func (c *M365CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error {
return writeM365PilotResult(ctx, flags, "m365.calendar.events", map[string]any{
"from": strings.TrimSpace(c.From),
"to": strings.TrimSpace(c.To),
"top": c.Top,
})
}

func (c *M365CalendarFreeBusyCmd) Run(ctx context.Context, flags *RootFlags) error {
users := splitCommaList(c.Users)
if users == nil {
users = []string{}
}

return writeM365PilotResult(ctx, flags, "m365.calendar.freebusy", map[string]any{
"users": users,
"from": strings.TrimSpace(c.From),
"to": strings.TrimSpace(c.To),
})
}
Comment on lines +71 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If c.Users is empty, splitCommaList returns nil, which marshals to null in the JSON output. It is more idiomatic for JSON arrays to be represented as an empty array [] rather than null when empty. We can ensure a non-nil slice is returned.

func (c *M365CalendarFreeBusyCmd) Run(ctx context.Context, flags *RootFlags) error {\n\tusers := splitCommaList(c.Users)\n\tif users == nil {\n\t\tusers = []string{}\n\t}\n\treturn writeM365PilotResult(ctx, flags, \"m365.calendar.freebusy\", map[string]any{\n\t\t\"users\": users,\n\t\t\"from\":  strings.TrimSpace(c.From),\n\t\t\"to\":    strings.TrimSpace(c.To),\n\t})\n}


func writeM365PilotResult(ctx context.Context, flags *RootFlags, operation string, request map[string]any) error {
if flags == nil || !flags.ReadOnly {
return usage("m365 pilot commands require explicit --read-only")
}
Comment on lines +85 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject --force for M365 read-only pilot commands

When --force is supplied with an allowlisted M365 read command, this only checks --read-only, so wk --read-only --force m365 calendar events ... is still emitted as a safe read_only_pilot operation. The M365 approval contract in docs/plans/2026-05-30-workit-m365-write-approval-contract.md classifies any command using --force as approval-required, so the CLI should fail closed here (or otherwise refuse flags.Force) instead of presenting the operation as approval-free read-only.

Useful? React with 👍 / 👎.


u := ui.FromContext(ctx)
return writeResult(ctx, u,
kv("operation", operation),
kv("provider", "microsoft_graph"),
kv("mode", "read_only_pilot"),
kv("status", "ready_for_m365_auth"),
kv("request", request),
)
}
134 changes: 134 additions & 0 deletions internal/cmd/m365_pilot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package cmd

import (
"encoding/json"
"slices"
"strings"
"testing"
)
Comment on lines +3 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To use the standard library's slices.Contains function instead of a custom helper, we should add "slices" to the imports.

Suggested change
import (
"encoding/json"
"strings"
"testing"
)
import (\n\t\"encoding/json\"\n\t\"slices\"\n\t\"strings\"\n\t\"testing\"\n)


func TestM365ReadOnlyPilotCommandsAreExposed(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{
name: "outlook search",
args: []string{"--json", "--read-only", "m365", "outlook", "search", "--query", "from:felipe"},
want: "m365.outlook.search",
},
{
name: "outlook message get",
args: []string{"--json", "--read-only", "m365", "outlook", "message", "get", "AAMk-message-id"},
want: "m365.outlook.message.get",
},
{
name: "calendar events",
args: []string{"--json", "--read-only", "m365", "calendar", "events", "--from", "2026-05-31T00:00:00Z", "--to", "2026-06-01T00:00:00Z"},
want: "m365.calendar.events",
},
{
name: "calendar freebusy",
args: []string{"--json", "--read-only", "m365", "calendar", "freebusy", "--users", "bernardo@example.com,felipe@example.com"},
want: "m365.calendar.freebusy",
},
{
name: "calendar freebusy without users",
args: []string{"--json", "--read-only", "m365", "calendar", "freebusy"},
want: "m365.calendar.freebusy",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute(tt.args); err != nil {
t.Fatalf("Execute(%v): %v", tt.args, err)
}
})
})

var got map[string]any
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("json output: %v\n%s", err, out)
}
if got["operation"] != tt.want {
t.Fatalf("operation = %v, want %s; output=%s", got["operation"], tt.want, out)
}
if got["provider"] != "microsoft_graph" {
t.Fatalf("provider = %v, want microsoft_graph; output=%s", got["provider"], out)
}
if got["mode"] != "read_only_pilot" {
t.Fatalf("mode = %v, want read_only_pilot; output=%s", got["mode"], out)
}
if tt.name == "calendar freebusy without users" {
request, ok := got["request"].(map[string]any)
if !ok {
t.Fatalf("request has type %T, want object; output=%s", got["request"], out)
}
users, ok := request["users"].([]any)
if !ok {
t.Fatalf("request.users has type %T, want empty array; output=%s", request["users"], out)
}
if len(users) != 0 {
t.Fatalf("request.users = %#v, want empty array", users)
}
}
})
}
}

func TestM365PilotCommandsRequireExplicitReadOnlyFlag(t *testing.T) {
_ = captureStderr(t, func() {
err := Execute([]string{"--json", "m365", "outlook", "search", "--query", "from:felipe"})
if err == nil {
t.Fatal("expected m365 pilot command without --read-only to fail closed")
}
if !strings.Contains(err.Error(), "--read-only") {
t.Fatalf("expected --read-only error, got: %v", err)
}
})
}

func TestAuthServicesJSONIncludesM365PilotReadOnlyScopes(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "auth", "services"}); err != nil {
t.Fatalf("auth services: %v", err)
}
})
})

var payload struct {
Services []struct {
Service string `json:"service"`
Scopes []string `json:"scopes"`
} `json:"services"`
}
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("json output: %v\n%s", err, out)
}

var scopes []string
for _, service := range payload.Services {
if service.Service == "m365" {
scopes = service.Scopes
break
}
}
if len(scopes) == 0 {
t.Fatalf("auth services missing m365 service: %s", out)
}
for _, scope := range []string{"User.Read", "Mail.Read", "Calendars.Read"} {
if !slices.Contains(scopes, scope) {
t.Fatalf("m365 auth services missing %s: %#v", scope, scopes)
}
}
for _, forbidden := range []string{"Mail.Send", "Calendars.ReadWrite"} {
if slices.Contains(scopes, forbidden) {
t.Fatalf("m365 auth services exposed write scope %s: %#v", forbidden, scopes)
}
}
Comment on lines +124 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We can use the standard library's slices.Contains function here for better readability and to align with modern Go practices.

\tfor _, scope := range []string{\"User.Read\", \"Mail.Read\", \"Calendars.Read\"} {\n\t\tif !slices.Contains(scopes, scope) {\n\t\t\tt.Fatalf(\"m365 auth services missing %s: %#v\", scope, scopes)\n\t\t}\n\t}\n\tfor _, forbidden := range []string{\"Mail.Send\", \"Calendars.ReadWrite\"} {\n\t\tif slices.Contains(scopes, forbidden) {\n\t\t\tt.Fatalf(\"m365 auth services exposed write scope %s: %#v\", forbidden, scopes)\n\t\t}\n\t}

}
1 change: 1 addition & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ type CLI struct {
Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"`
Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"`
AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"`
M365 M365Cmd `cmd:"" name:"m365" aliases:"microsoft,graph" help:"Microsoft 365 read-only pilot"`
Sync SyncCmd `cmd:"" help:"Google Drive sync"`
Templates TemplatesCmd `cmd:"" help:"Manage document templates"`
Setup SetupCmd `cmd:"" help:"Validate environment dependencies"`
Expand Down
21 changes: 21 additions & 0 deletions internal/msauth/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package msauth

type ServiceInfo struct {
Service string `json:"service"`
User bool `json:"user"`
Scopes []string `json:"scopes"`
APIs []string `json:"apis,omitempty"`
Note string `json:"note,omitempty"`
}

func ServicesInfo() []ServiceInfo {
return []ServiceInfo{
{
Service: "m365",
User: true,
Scopes: PilotAllowedScopes(),
APIs: []string{"Microsoft Graph"},
Note: "Read-only Microsoft 365 pilot; writes remain KHAW-gated/disabled",
},
}
}
Loading