Skip to content

Commit 446756b

Browse files
feat: add read-only OAuth mode for safe autonomous tool use (#205)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d14a8ff commit 446756b

17 files changed

Lines changed: 613 additions & 96 deletions

File tree

.agents/skills/add-command/SKILL.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@ Color convention:
2424
- `green` — create/join operations
2525
- `yellow` — update/delete/archive mutations
2626

27-
## 3. Command Implementation (`src/commands/<entity>/`)
27+
## 3. Read-Only Permissions (`src/lib/permissions.ts`)
28+
29+
If the new command uses a **read-only** SDK method (e.g., `getXxx`, `listXxx`), add it to the `KNOWN_SAFE_API_METHODS` set. This set uses a default-deny approach: any method **not** listed is treated as mutating and will be blocked when the CLI is authenticated with a read-only OAuth token (`td auth login --read-only`).
30+
31+
- **Read-only methods** (fetch/list/view): add to `KNOWN_SAFE_API_METHODS`
32+
- **Mutating methods** (add/update/delete/archive/move): do NOT add — they are blocked by default, which is the correct behavior
33+
34+
## 4. Command Implementation (`src/commands/<entity>/`)
2835

2936
Commands with multiple subcommands use a folder-based structure:
3037

@@ -83,7 +90,7 @@ const myCmd = parent
8390

8491
The variable assignment (`const myCmd = ...`) is needed so the `.action()` callback can call `myCmd.help()` when the argument is missing.
8592

86-
## 4. Accessibility (`src/lib/output.ts`)
93+
## 5. Accessibility (`src/lib/output.ts`)
8794

8895
The CLI supports accessible mode via `isAccessible()` (checks `TD_ACCESSIBLE=1` or `--accessible` flag). When adding output that uses color or visual elements, consider whether information is conveyed **only** by color or decoration.
8996

@@ -119,7 +126,7 @@ if (isAccessible()) {
119126

120127
If adding a new shared formatter to `output.ts`, use `Record<ExactType, ...>` rather than `Record<string, ...>` so the compiler catches missing variants.
121128

122-
## 5. Tests (`src/__tests__/<entity>.test.ts`)
129+
## 6. Tests (`src/__tests__/<entity>.test.ts`)
123130

124131
Follow the existing pattern: mock `getApi`, use `program.parseAsync()`.
125132

@@ -130,7 +137,7 @@ Always test:
130137
- `--dry-run` for mutating commands (API method should NOT be called, preview text shown)
131138
- `--json` output where applicable
132139

133-
## 6. Skill Content (`src/lib/skills/content.ts`)
140+
## 7. Skill Content (`src/lib/skills/content.ts`)
134141

135142
Update `SKILL_CONTENT` with examples for the new command. Update relevant sections:
136143

@@ -139,7 +146,7 @@ Update `SKILL_CONTENT` with examples for the new command. Update relevant sectio
139146
- Mutating `--json` list if the command returns an entity
140147
- `--dry-run` list if applicable
141148

142-
## 7. Sync Skill File
149+
## 8. Sync Skill File
143150

144151
After all code changes are complete:
145152

@@ -149,7 +156,7 @@ npm run sync:skill
149156

150157
This builds the project and regenerates `skills/todoist-cli/SKILL.md` from the compiled skill content. The regenerated file must be committed. CI will fail (`npm run check:skill-sync`) if it is out of sync.
151158

152-
## 8. Verify
159+
## 9. Verify
153160

154161
```bash
155162
npm run type-check

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ This opens your browser to authenticate with Todoist. Once approved, the token i
7272

7373
If secure storage is unavailable, the CLI warns and falls back to `~/.config/todoist-cli/config.json`. Existing plaintext tokens are migrated automatically the next time the CLI reads them successfully from the config file.
7474

75+
For a read-only OAuth token (scope `data:read`), run:
76+
77+
```bash
78+
td auth login --read-only
79+
```
80+
81+
In read-only mode, commands that change Todoist data (create/update/delete/complete/move/archive, etc.) are blocked by the CLI.
82+
7583
### Alternative methods
7684

7785
**Manual token:** Get your API token from [Todoist Settings > Integrations > Developer](https://todoist.com/app/settings/integrations/developer):
@@ -88,11 +96,19 @@ export TODOIST_API_TOKEN="your-token"
8896

8997
`TODOIST_API_TOKEN` always takes priority over the stored token.
9098

99+
Note: externally provided tokens (`TODOIST_API_TOKEN` or `td auth token`) are treated as unknown scope and assumed write-capable. The CLI cannot currently auto-detect OAuth scope for these tokens.
100+
91101
### Auth commands
92102

93103
```bash
94-
td auth status # check if authenticated
95-
td auth logout # remove saved token
104+
td auth status # check if authenticated + mode (read-only/read-write/unknown)
105+
td auth logout # remove saved token and auth metadata
106+
```
107+
108+
To switch back to normal write access, re-run:
109+
110+
```bash
111+
td auth login
96112
```
97113

98114
## Usage

skills/todoist-cli/SKILL.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Use this skill when the user wants to interact with their Todoist tasks.
2929
- `td reminder list` - List reminders (all or per task)
3030
- `td reminder add` - Task reminders
3131
- `td template create/export-file/export-url/import-file/import-id` - Project templates
32+
- `td auth login --read-only` - Authenticate with read-only OAuth scope
3233
- `td auth status` - Authentication status
3334
- `td stats` - Productivity stats
3435
- `td settings view` - User settings
@@ -394,11 +395,12 @@ td template import-id "My Project" --template-id product-launch --locale fr # W
394395

395396
### Auth
396397
```bash
397-
td auth status # Check authentication
398-
td auth status --json # JSON: { id, email, fullName }
399-
td auth login # OAuth login
400-
td auth token <token> # Save API token
401-
td auth logout # Remove saved token
398+
td auth login # OAuth login (read-write)
399+
td auth login --read-only # OAuth login with scope data:read
400+
td auth token "your-token" # Save manual token (scope unknown; assumed write-capable)
401+
td auth status # Show auth state + mode
402+
td auth status --json # JSON: { id, email, fullName, authMode, authScope }
403+
td auth logout # Remove token + auth metadata
402404
```
403405

404406
### Stats

src/__tests__/auth.test.ts

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ vi.mock('../lib/auth.js', async (importOriginal) => {
88
...actual,
99
saveApiToken: vi.fn(),
1010
clearApiToken: vi.fn(),
11+
getAuthMetadata: vi.fn(),
1112
}
1213
})
1314

@@ -33,10 +34,14 @@ vi.mock('../lib/oauth-server.js', () => ({
3334
}))
3435

3536
// Mock OAuth module
36-
vi.mock('../lib/oauth.js', () => ({
37-
buildAuthorizationUrl: vi.fn(() => 'https://todoist.com/oauth/authorize?test=1'),
38-
exchangeCodeForToken: vi.fn(),
39-
}))
37+
vi.mock('../lib/oauth.js', async (importOriginal) => {
38+
const actual = await importOriginal<typeof import('../lib/oauth.js')>()
39+
return {
40+
...actual,
41+
buildAuthorizationUrl: vi.fn(() => 'https://todoist.com/oauth/authorize?test=1'),
42+
exchangeCodeForToken: vi.fn(),
43+
}
44+
})
4045

4146
// Mock open module
4247
vi.mock('open', () => ({
@@ -58,17 +63,19 @@ import { createInterface, type Interface } from 'node:readline'
5863
import open from 'open'
5964
import { registerAuthCommand } from '../commands/auth/index.js'
6065
import { getApi } from '../lib/api/core.js'
61-
import { NoTokenError, clearApiToken, saveApiToken } from '../lib/auth.js'
66+
import { NoTokenError, clearApiToken, getAuthMetadata, saveApiToken } from '../lib/auth.js'
6267
import { startCallbackServer } from '../lib/oauth-server.js'
63-
import { exchangeCodeForToken } from '../lib/oauth.js'
68+
import { buildAuthorizationUrl, exchangeCodeForToken } from '../lib/oauth.js'
6469
import { createMockApi } from './helpers/mock-api.js'
6570

6671
const mockCreateInterface = vi.mocked(createInterface)
6772

6873
const mockSaveApiToken = vi.mocked(saveApiToken)
6974
const mockClearApiToken = vi.mocked(clearApiToken)
75+
const mockGetAuthMetadata = vi.mocked(getAuthMetadata)
7076
const mockGetApi = vi.mocked(getApi)
7177
const mockStartCallbackServer = vi.mocked(startCallbackServer)
78+
const mockBuildAuthorizationUrl = vi.mocked(buildAuthorizationUrl)
7279
const mockExchangeCodeForToken = vi.mocked(exchangeCodeForToken)
7380
const mockOpen = vi.mocked(open)
7481

@@ -104,7 +111,7 @@ describe('auth command', () => {
104111

105112
await program.parseAsync(['node', 'td', 'auth', 'token', token])
106113

107-
expect(mockSaveApiToken).toHaveBeenCalledWith(token)
114+
expect(mockSaveApiToken).toHaveBeenCalledWith(token, { authMode: 'unknown' })
108115
expect(consoleSpy).toHaveBeenCalledWith('✓', 'API token saved successfully!')
109116
expect(consoleSpy).toHaveBeenCalledWith(
110117
'Token stored securely in the system credential manager',
@@ -121,7 +128,7 @@ describe('auth command', () => {
121128
program.parseAsync(['node', 'td', 'auth', 'token', token]),
122129
).rejects.toThrow('Permission denied')
123130

124-
expect(mockSaveApiToken).toHaveBeenCalledWith(token)
131+
expect(mockSaveApiToken).toHaveBeenCalledWith(token, { authMode: 'unknown' })
125132
})
126133

127134
it('trims whitespace from token', async () => {
@@ -133,7 +140,7 @@ describe('auth command', () => {
133140

134141
await program.parseAsync(['node', 'td', 'auth', 'token', tokenWithWhitespace])
135142

136-
expect(mockSaveApiToken).toHaveBeenCalledWith(expectedToken)
143+
expect(mockSaveApiToken).toHaveBeenCalledWith(expectedToken, { authMode: 'unknown' })
137144
})
138145

139146
it('prompts interactively when no token argument given', async () => {
@@ -153,7 +160,9 @@ describe('auth command', () => {
153160

154161
expect(mockRl.question).toHaveBeenCalled()
155162
expect(mockRl.close).toHaveBeenCalled()
156-
expect(mockSaveApiToken).toHaveBeenCalledWith('interactive_token_456')
163+
expect(mockSaveApiToken).toHaveBeenCalledWith('interactive_token_456', {
164+
authMode: 'unknown',
165+
})
157166
writeSpy.mockRestore()
158167
})
159168

@@ -235,13 +244,42 @@ describe('auth command', () => {
235244
expect(mockOpen).toHaveBeenCalledWith('https://todoist.com/oauth/authorize?test=1')
236245
expect(mockStartCallbackServer).toHaveBeenCalledWith('test_state')
237246
expect(mockExchangeCodeForToken).toHaveBeenCalledWith(authCode, 'test_code_verifier')
238-
expect(mockSaveApiToken).toHaveBeenCalledWith(accessToken)
247+
expect(mockSaveApiToken).toHaveBeenCalledWith(accessToken, {
248+
authMode: 'read-write',
249+
authScope: 'data:read_write,data:delete,project:delete',
250+
})
239251
expect(consoleSpy).toHaveBeenCalledWith('✓', 'Successfully logged in!')
240252
expect(consoleSpy).toHaveBeenCalledWith(
241253
'Token stored securely in the system credential manager',
242254
)
243255
})
244256

257+
it('requests data:read scope when --read-only is set', async () => {
258+
const program = createProgram()
259+
const authCode = 'oauth_auth_code_123'
260+
const accessToken = 'oauth_access_token_456'
261+
262+
mockStartCallbackServer.mockReturnValue({
263+
promise: Promise.resolve(authCode),
264+
cleanup: vi.fn(),
265+
})
266+
mockExchangeCodeForToken.mockResolvedValue(accessToken)
267+
mockSaveApiToken.mockResolvedValue({ storage: 'secure-store' })
268+
mockOpen.mockResolvedValue({} as Awaited<ReturnType<typeof open>>)
269+
270+
await program.parseAsync(['node', 'td', 'auth', 'login', '--read-only'])
271+
272+
expect(mockBuildAuthorizationUrl).toHaveBeenCalledWith(
273+
'test_code_challenge',
274+
'test_state',
275+
{ readOnly: true },
276+
)
277+
expect(mockSaveApiToken).toHaveBeenCalledWith(accessToken, {
278+
authMode: 'read-only',
279+
authScope: 'data:read',
280+
})
281+
})
282+
245283
it('handles OAuth callback server error', async () => {
246284
const program = createProgram()
247285
const mockCleanup = vi.fn()
@@ -304,6 +342,11 @@ describe('auth command', () => {
304342
const mockUser = { email: 'test@example.com', fullName: 'Test User' }
305343
const mockApi = createMockApi({ getUser: vi.fn().mockResolvedValue(mockUser) })
306344
mockGetApi.mockResolvedValue(mockApi)
345+
mockGetAuthMetadata.mockResolvedValue({
346+
authMode: 'read-write',
347+
authScope: 'data:read_write,data:delete,project:delete',
348+
source: 'secure-store',
349+
})
307350

308351
await program.parseAsync(['node', 'td', 'auth', 'status'])
309352

@@ -312,19 +355,47 @@ describe('auth command', () => {
312355
expect(consoleSpy).toHaveBeenCalledWith('✓', 'Authenticated')
313356
expect(consoleSpy).toHaveBeenCalledWith(' Email: test@example.com')
314357
expect(consoleSpy).toHaveBeenCalledWith(' Name: Test User')
358+
expect(consoleSpy).toHaveBeenCalledWith(' Mode: read-write')
359+
})
360+
361+
it('shows read-only mode in status', async () => {
362+
const program = createProgram()
363+
const mockUser = { email: 'test@example.com', fullName: 'Test User' }
364+
const mockApi = createMockApi({ getUser: vi.fn().mockResolvedValue(mockUser) })
365+
mockGetApi.mockResolvedValue(mockApi)
366+
mockGetAuthMetadata.mockResolvedValue({
367+
authMode: 'read-only',
368+
authScope: 'data:read',
369+
source: 'secure-store',
370+
})
371+
372+
await program.parseAsync(['node', 'td', 'auth', 'status'])
373+
374+
expect(consoleSpy).toHaveBeenCalledWith(' Mode: read-only (OAuth scope data:read)')
315375
})
316376

317377
it('outputs JSON when --json flag is used', async () => {
318378
const program = createProgram()
319379
const mockUser = { id: '123', email: 'test@example.com', fullName: 'Test User' }
320380
const mockApi = createMockApi({ getUser: vi.fn().mockResolvedValue(mockUser) })
321381
mockGetApi.mockResolvedValue(mockApi)
382+
mockGetAuthMetadata.mockResolvedValue({
383+
authMode: 'read-write',
384+
authScope: 'data:read_write,data:delete,project:delete',
385+
source: 'secure-store',
386+
})
322387

323388
await program.parseAsync(['node', 'td', 'auth', 'status', '--json'])
324389

325390
expect(consoleSpy).toHaveBeenCalledWith(
326391
JSON.stringify(
327-
{ id: '123', email: 'test@example.com', fullName: 'Test User' },
392+
{
393+
id: '123',
394+
email: 'test@example.com',
395+
fullName: 'Test User',
396+
authMode: 'read-write',
397+
authScope: 'data:read_write,data:delete,project:delete',
398+
},
328399
null,
329400
2,
330401
),

0 commit comments

Comments
 (0)