Skip to content

Commit eb50cef

Browse files
plossonclaude
andcommitted
feat: add read-only profile support
Add --read-only flag to profile creation that prevents write/destructive operations when the profile is used. This enables safer sharing of credentials for monitoring or read-only use cases. Changes: - Add ProfileEntry interface with optional readOnly field - Add enforceWriteAccess() utility for write operation enforcement - Add profile update command with --read-only/--no-read-only flags - Show [read-only] indicator in profile list and [RO] in status - Enforce read-only on all write operations across 14 services Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c8b360b commit eb50cef

File tree

21 files changed

+412
-115
lines changed

21 files changed

+412
-115
lines changed

src/commands/config.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { getAllCredentials, setAllCredentials } from '../auth/token-store';
88
import { CliError, handleError } from '../utils/errors';
99
import { confirm } from '../utils/stdin';
1010
import { isInteractive, interactiveCheckbox, interactiveSelect } from '../utils/interactive';
11-
import type { Config, ServiceName } from '../types/config';
11+
import type { Config, ServiceName, ProfileValue } from '../types/config';
1212
import type { StoredCredentials } from '../types/tokens';
1313

1414
interface ProfileSelection {
@@ -126,8 +126,9 @@ export function registerConfigCommands(program: Command): void {
126126
const allProfiles: ProfileSelection[] = [];
127127
for (const [service, profiles] of Object.entries(configData.profiles)) {
128128
if (profiles) {
129-
for (const profile of profiles) {
130-
allProfiles.push({ service: service as ServiceName, profile });
129+
for (const entry of profiles) {
130+
const profileName = typeof entry === 'string' ? entry : entry.name;
131+
allProfiles.push({ service: service as ServiceName, profile: profileName });
131132
}
132133
}
133134
}
@@ -309,11 +310,16 @@ export function registerConfigCommands(program: Command): void {
309310
for (const [service, profiles] of Object.entries(exportData.config.profiles)) {
310311
if (profiles) {
311312
if (!currentConfig.profiles[service as keyof typeof currentConfig.profiles]) {
312-
(currentConfig.profiles as Record<string, string[]>)[service] = [];
313+
(currentConfig.profiles as Record<string, ProfileValue[]>)[service] = [];
313314
}
314-
for (const profile of profiles) {
315-
if (!(currentConfig.profiles as Record<string, string[]>)[service].includes(profile)) {
316-
(currentConfig.profiles as Record<string, string[]>)[service].push(profile);
315+
for (const entry of profiles) {
316+
const profileName = typeof entry === 'string' ? entry : entry.name;
317+
const currentProfiles = (currentConfig.profiles as Record<string, ProfileValue[]>)[service];
318+
const exists = currentProfiles.some((p) =>
319+
(typeof p === 'string' ? p : p.name) === profileName
320+
);
321+
if (!exists) {
322+
currentProfiles.push(entry);
317323
}
318324
}
319325
}

src/commands/discourse.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export function registerDiscourseCommands(program: Command): void {
8888
.command('add')
8989
.description('Add a new Discourse profile')
9090
.option('--profile <name>', 'Profile name (auto-detected from username if not provided)')
91+
.option('--read-only', 'Create as read-only profile (blocks write operations)')
9192
.action(async (options) => {
9293
try {
9394
console.error('\nDiscourse Setup\n');
@@ -173,10 +174,13 @@ export function registerDiscourseCommands(program: Command): void {
173174
const profileName = options.profile || username.trim();
174175

175176
// Save credentials
176-
await setProfile('discourse', profileName);
177+
await setProfile('discourse', profileName, { readOnly: options.readOnly });
177178
await setCredentials('discourse', profileName, credentials);
178179

179180
console.log(`\nProfile "${profileName}" configured!`);
181+
if (options.readOnly) {
182+
console.log(` Access: read-only`);
183+
}
180184
console.log(` Test with: agentio discourse list --profile ${profileName}`);
181185
} catch (error) {
182186
handleError(error);

src/commands/gcal.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { GCalClient } from '../services/gcal/client';
99
import { printGCalCalendarList, printGCalEventList, printGCalEvent, printGCalEventCreated, printGCalEventDeleted, printGCalFreeBusy } from '../utils/output';
1010
import { CliError, handleError } from '../utils/errors';
1111
import { readStdin } from '../utils/stdin';
12+
import { enforceWriteAccess } from '../utils/read-only';
1213

1314
async function getGCalClient(profileName?: string): Promise<{ client: GCalClient; profile: string }> {
1415
const { tokens, profile } = await getValidTokens('gcal', profileName);
@@ -155,7 +156,8 @@ export function registerGCalCommands(program: Command): void {
155156
return { method: method as 'email' | 'popup', minutes: parseInt(minutes, 10) };
156157
});
157158

158-
const { client } = await getGCalClient(options.profile);
159+
const { client, profile } = await getGCalClient(options.profile);
160+
await enforceWriteAccess('gcal', profile, 'create event');
159161
const event = await client.createEvent({
160162
calendarId: calendarId || 'primary',
161163
summary: options.summary,
@@ -210,7 +212,8 @@ export function registerGCalCommands(program: Command): void {
210212
throw new CliError('INVALID_PARAMS', 'Cannot use both --attendee and --add-attendee');
211213
}
212214

213-
const { client } = await getGCalClient(options.profile);
215+
const { client, profile } = await getGCalClient(options.profile);
216+
await enforceWriteAccess('gcal', profile, 'update event');
214217
const event = await client.updateEvent({
215218
calendarId,
216219
eventId,
@@ -243,7 +246,8 @@ export function registerGCalCommands(program: Command): void {
243246
.option('--send-updates <mode>', 'Send notifications: all, externalOnly, none', 'all')
244247
.action(async (calendarId: string, eventId: string, options) => {
245248
try {
246-
const { client } = await getGCalClient(options.profile);
249+
const { client, profile } = await getGCalClient(options.profile);
250+
await enforceWriteAccess('gcal', profile, 'delete event');
247251
await client.deleteEvent(calendarId, eventId, options.sendUpdates);
248252
printGCalEventDeleted(calendarId, eventId);
249253
} catch (error) {
@@ -300,7 +304,8 @@ export function registerGCalCommands(program: Command): void {
300304
throw new CliError('INVALID_PARAMS', `Invalid status: ${options.status}`, 'Use: accepted, declined, or tentative');
301305
}
302306

303-
const { client } = await getGCalClient(options.profile);
307+
const { client, profile } = await getGCalClient(options.profile);
308+
await enforceWriteAccess('gcal', profile, 'respond to event');
304309
const event = await client.respond({
305310
calendarId,
306311
eventId,
@@ -353,6 +358,7 @@ export function registerGCalCommands(program: Command): void {
353358
.command('add')
354359
.description('Add a new Google Calendar profile')
355360
.option('--profile <name>', 'Profile name (auto-detected from email if not provided)')
361+
.option('--read-only', 'Create as read-only profile (blocks write operations)')
356362
.action(async (options) => {
357363
try {
358364
console.error('Starting OAuth flow for Google Calendar...\n');
@@ -371,11 +377,14 @@ export function registerGCalCommands(program: Command): void {
371377

372378
const profileName = options.profile || email;
373379

374-
await setProfile('gcal', profileName);
380+
await setProfile('gcal', profileName, { readOnly: options.readOnly });
375381
await setCredentials('gcal', profileName, { ...tokens, email });
376382

377383
console.log(`\nSuccess! Profile "${profileName}" configured.`);
378384
console.log(` Email: ${email}`);
385+
if (options.readOnly) {
386+
console.log(` Access: read-only`);
387+
}
379388
} catch (error) {
380389
handleError(error);
381390
}

src/commands/gchat.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { CliError, handleError } from '../utils/errors';
1212
import { readStdin, prompt } from '../utils/stdin';
1313
import { interactiveSelect } from '../utils/interactive';
1414
import { printGChatSendResult, printGChatMessageList, printGChatMessage, printGChatSpaceList } from '../utils/output';
15+
import { enforceWriteAccess } from '../utils/read-only';
1516
import type { GChatCredentials, GChatWebhookCredentials, GChatOAuthCredentials } from '../types/gchat';
1617

1718
const getGChatClient = createClientGetter<GChatCredentials, GChatClient>({
@@ -95,7 +96,8 @@ export function registerGChatCommands(program: Command): void {
9596
}
9697
}
9798

98-
const { client } = await getGChatClient(options.profile);
99+
const { client, profile } = await getGChatClient(options.profile);
100+
await enforceWriteAccess('gchat', profile, 'send message');
99101
const result = await client.send({
100102
text,
101103
payload,
@@ -185,6 +187,7 @@ export function registerGChatCommands(program: Command): void {
185187
.command('add')
186188
.description('Add a new Google Chat profile (webhook or OAuth)')
187189
.option('--profile <name>', 'Profile name (required for webhook, auto-detected for OAuth)')
190+
.option('--read-only', 'Create as read-only profile (blocks write operations)')
188191
.action(async (options) => {
189192
try {
190193
console.error('\nGoogle Chat Setup\n');
@@ -205,23 +208,26 @@ export function registerGChatCommands(program: Command): void {
205208
'Run: agentio gchat profile add --profile <name>'
206209
);
207210
}
208-
await setupWebhookProfile(options.profile);
211+
await setupWebhookProfile(options.profile, options.readOnly);
209212
} else {
210-
await setupOAuthProfile(options.profile);
213+
await setupOAuthProfile(options.profile, options.readOnly);
211214
}
212215
} catch (error) {
213216
handleError(error);
214217
}
215218
});
216219
}
217220

218-
function printProfileSetupSuccess(profileName: string, authType: 'webhook' | 'oauth'): void {
221+
function printProfileSetupSuccess(profileName: string, authType: 'webhook' | 'oauth', readOnly?: boolean): void {
219222
const typeLabel = authType.charAt(0).toUpperCase() + authType.slice(1);
220223
console.log(`\nSuccess! ${typeLabel} profile "${profileName}" configured.`);
224+
if (readOnly) {
225+
console.log(` Access: read-only`);
226+
}
221227
console.log(` Test with: agentio gchat send --profile ${profileName} "Hello from agentio"`);
222228
}
223229

224-
async function setupWebhookProfile(profileName: string): Promise<void> {
230+
async function setupWebhookProfile(profileName: string, readOnly?: boolean): Promise<void> {
225231
console.error('Webhook Setup\n');
226232
console.error('1. In Google Chat, find or create a space');
227233
console.error('2. Go to Space Settings → Webhooks');
@@ -264,13 +270,13 @@ async function setupWebhookProfile(profileName: string): Promise<void> {
264270
webhookUrl: webhookUrl,
265271
};
266272

267-
await setProfile('gchat', profileName);
273+
await setProfile('gchat', profileName, { readOnly });
268274
await setCredentials('gchat', profileName, credentials);
269275

270-
printProfileSetupSuccess(profileName, 'webhook');
276+
printProfileSetupSuccess(profileName, 'webhook', readOnly);
271277
}
272278

273-
async function setupOAuthProfile(profileNameOverride?: string): Promise<void> {
279+
async function setupOAuthProfile(profileNameOverride?: string, readOnly?: boolean): Promise<void> {
274280
console.error('OAuth Setup\n');
275281
console.error('Starting OAuth flow for Google Chat profile...\n');
276282

@@ -320,8 +326,8 @@ async function setupOAuthProfile(profileNameOverride?: string): Promise<void> {
320326
email: userEmail,
321327
};
322328

323-
await setProfile('gchat', profileName);
329+
await setProfile('gchat', profileName, { readOnly });
324330
await setCredentials('gchat', profileName, credentials);
325331

326-
printProfileSetupSuccess(profileName, 'oauth');
332+
printProfileSetupSuccess(profileName, 'oauth', readOnly);
327333
}

src/commands/gdocs.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { GDocsClient } from '../services/gdocs/client';
1111
import { printGDocsList, printGDocCreated, raw } from '../utils/output';
1212
import { CliError, handleError } from '../utils/errors';
1313
import { readStdin } from '../utils/stdin';
14+
import { enforceWriteAccess } from '../utils/read-only';
1415
import type { GDocsCredentials } from '../types/gdocs';
1516

1617
const getGDocsClient = createClientGetter<GDocsCredentials, GDocsClient>({
@@ -77,7 +78,8 @@ export function registerGDocsCommands(program: Command): void {
7778
throw new CliError('INVALID_PARAMS', 'No content provided', 'Provide --content or pipe markdown via stdin');
7879
}
7980

80-
const { client } = await getGDocsClient(options.profile);
81+
const { client, profile } = await getGDocsClient(options.profile);
82+
await enforceWriteAccess('gdocs', profile, 'create document');
8183
const result = await client.create(options.title, content, options.folder);
8284

8385
printGDocCreated(result);
@@ -138,6 +140,7 @@ Query Syntax Examples:
138140
.command('add')
139141
.description('Add a new Google Docs profile')
140142
.option('--profile <name>', 'Profile name (auto-detected from email if not provided)')
143+
.option('--read-only', 'Create as read-only profile (blocks write operations)')
141144
.action(async (options) => {
142145
try {
143146
console.error('Starting OAuth flow for Google Docs...\n');
@@ -174,11 +177,14 @@ Query Syntax Examples:
174177
email: userEmail,
175178
};
176179

177-
await setProfile('gdocs', profileName);
180+
await setProfile('gdocs', profileName, { readOnly: options.readOnly });
178181
await setCredentials('gdocs', profileName, credentials);
179182

180183
console.log(`\nSuccess! Profile "${profileName}" configured.`);
181184
console.log(` Email: ${userEmail}`);
185+
if (options.readOnly) {
186+
console.log(` Access: read-only`);
187+
}
182188
console.log(` Test with: agentio gdocs list --profile ${profileName}`);
183189
} catch (error) {
184190
handleError(error);

src/commands/gdrive.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { GDriveClient } from '../services/gdrive/client';
1010
import { printGDriveFileList, printGDriveFile, printGDriveDownloaded, printGDriveUploaded } from '../utils/output';
1111
import { CliError, handleError } from '../utils/errors';
1212
import { prompt } from '../utils/stdin';
13+
import { enforceWriteAccess } from '../utils/read-only';
1314
import type { GDriveCredentials, GDriveAccessLevel } from '../types/gdrive';
1415

1516
const getGDriveClient = createClientGetter<GDriveCredentials, GDriveClient>({
@@ -189,7 +190,8 @@ Examples:
189190
`)
190191
.action(async (filePath: string, options) => {
191192
try {
192-
const { client } = await getGDriveClient(options.profile);
193+
const { client, profile } = await getGDriveClient(options.profile);
194+
await enforceWriteAccess('gdrive', profile, 'upload file');
193195
const result = await client.upload({
194196
filePath,
195197
name: options.name,
@@ -220,6 +222,7 @@ Examples:
220222
.option('--profile <name>', 'Profile name (auto-detected from email if not provided)')
221223
.option('--readonly', 'Create a read-only profile (skip access level prompt)')
222224
.option('--full', 'Create a full access profile (skip access level prompt)')
225+
.option('--read-only', 'Create as read-only profile (blocks write operations)')
223226
.action(async (options) => {
224227
try {
225228
console.error('Google Drive Setup\n');
@@ -274,12 +277,15 @@ Examples:
274277
accessLevel,
275278
};
276279

277-
await setProfile('gdrive', profileName);
280+
await setProfile('gdrive', profileName, { readOnly: options.readOnly });
278281
await setCredentials('gdrive', profileName, credentials);
279282

280283
console.log(`\nSuccess! Profile "${profileName}" configured.`);
281284
console.log(` Email: ${userEmail}`);
282-
console.log(` Access: ${accessLevel === 'full' ? 'Full (read & write)' : 'Read-only'}`);
285+
console.log(` API Access: ${accessLevel === 'full' ? 'Full (read & write)' : 'Read-only'}`);
286+
if (options.readOnly) {
287+
console.log(` Profile Access: read-only`);
288+
}
283289
console.log(` Test with: agentio gdrive list --profile ${profileName}`);
284290
} catch (error) {
285291
handleError(error);

src/commands/github.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { GitHubClient } from '../services/github/client';
77
import { performGitHubOAuthFlow } from '../auth/github-oauth';
88
import { generateExportData } from './config';
99
import { CliError, handleError } from '../utils/errors';
10+
import { enforceWriteAccess } from '../utils/read-only';
1011
import type { GitHubCredentials } from '../types/github';
1112

1213
const getGitHubClient = createClientGetter<GitHubCredentials, GitHubClient>({
@@ -42,6 +43,7 @@ export function registerGitHubCommands(program: Command): void {
4243
parseRepo(repo);
4344

4445
const { client, profile } = await getGitHubClient(options.profile);
46+
await enforceWriteAccess('github', profile, 'install secrets');
4547

4648
console.error(`Using GitHub profile: ${profile}`);
4749
console.error(`Installing secrets to: ${repo}`);
@@ -77,6 +79,7 @@ export function registerGitHubCommands(program: Command): void {
7779
parseRepo(repo);
7880

7981
const { client, profile } = await getGitHubClient(options.profile);
82+
await enforceWriteAccess('github', profile, 'uninstall secrets');
8083

8184
console.error(`Using GitHub profile: ${profile}`);
8285
console.error(`Removing secrets from: ${repo}`);
@@ -105,6 +108,7 @@ export function registerGitHubCommands(program: Command): void {
105108
.command('add')
106109
.description('Add a new GitHub profile')
107110
.option('--profile <name>', 'Profile name (auto-detected from username if not provided)')
111+
.option('--read-only', 'Create as read-only profile (blocks write operations)')
108112
.action(async (options) => {
109113
try {
110114
console.error('\nGitHub Setup\n');
@@ -133,10 +137,13 @@ export function registerGitHubCommands(program: Command): void {
133137
console.error(`\nAuthenticated as: ${user.login}${user.email ? ` (${user.email})` : ''}`);
134138

135139
// Save credentials
136-
await setProfile('github', profileName);
140+
await setProfile('github', profileName, { readOnly: options.readOnly });
137141
await setCredentials('github', profileName, credentials);
138142

139143
console.log(`\nProfile "${profileName}" configured!`);
144+
if (options.readOnly) {
145+
console.log(` Access: read-only`);
146+
}
140147
console.log(` Install secrets: agentio github install owner/repo --profile ${profileName}`);
141148
} catch (error) {
142149
handleError(error);

0 commit comments

Comments
 (0)