diff --git a/.env.example b/.env.example index ef5b103c..9a669016 100644 --- a/.env.example +++ b/.env.example @@ -11,10 +11,12 @@ SKETCHFAB_API_KEY= # OAuth credentials (register your own apps at github.com/settings/developers # and console.cloud.google.com to enable /oauth connect) -AXION_GITHUB_CLIENT_ID= -AXION_GITHUB_CLIENT_SECRET= -AXION_GOOGLE_CLIENT_ID= -AXION_GOOGLE_CLIENT_SECRET= +SENNORIC_GITHUB_CLIENT_ID= +SENNORIC_GITHUB_CLIENT_SECRET= +SENNORIC_GOOGLE_CLIENT_ID= +SENNORIC_GOOGLE_CLIENT_SECRET= +SENNORIC_NOTION_CLIENT_ID= +SENNORIC_NOTION_CLIENT_SECRET= # Overrides (optional) AXION_MODEL=claude # default model alias on startup diff --git a/README.md b/README.md index 0e633a23..7c731e75 100644 --- a/README.md +++ b/README.md @@ -371,10 +371,12 @@ Connect services to give the agent access to them: To enable OAuth, register your own apps and add credentials to `~/.axion/.env`: ``` -AXION_GITHUB_CLIENT_ID=... -AXION_GITHUB_CLIENT_SECRET=... -AXION_GOOGLE_CLIENT_ID=... -AXION_GOOGLE_CLIENT_SECRET=... +SENNORIC_GITHUB_CLIENT_ID=... +SENNORIC_GITHUB_CLIENT_SECRET=... +SENNORIC_GOOGLE_CLIENT_ID=... +SENNORIC_GOOGLE_CLIENT_SECRET=... +SENNORIC_NOTION_CLIENT_ID=... +SENNORIC_NOTION_CLIENT_SECRET=... ``` --- diff --git a/api-proxy-cf/package.json b/api-proxy-cf/package.json index fe65da45..e9fa03f9 100644 --- a/api-proxy-cf/package.json +++ b/api-proxy-cf/package.json @@ -4,7 +4,7 @@ "type": "module", "private": true, "engines": { - "node": ">=22.0.0" + "node": ">=22.13.0" }, "scripts": { "dev": "wrangler dev", diff --git a/api-proxy-cf/src/index.js b/api-proxy-cf/src/index.js index c2680cd9..4a924735 100644 --- a/api-proxy-cf/src/index.js +++ b/api-proxy-cf/src/index.js @@ -587,7 +587,7 @@ async function purgeExpiredDesktopAuthCodes(db) { // ── Desktop integration OAuth broker ───────────────────────────────────── // Native apps cannot keep an OAuth client secret. The Worker already owns -// the registered Google/GitHub credentials, so it performs the provider code +// the registered Google/GitHub/Notion credentials, so it performs the provider code // exchange and hands Desktop a short-lived, PKCE-bound one-time code. Provider // tokens are encrypted even during their brief stay in D1 and never travel in // a URL, renderer process, or log. @@ -604,6 +604,11 @@ const DESKTOP_INTEGRATION_PROVIDERS = { redirectUri: 'https://api.amplifiedsmp.org/auth/google/callback', scopes: 'https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events openid email profile', }, + notion: { + authURL: 'https://api.notion.com/v1/oauth/authorize', + redirectUri: 'https://api.amplifiedsmp.org/auth/notion/callback', + scopes: '', + }, } function bytesToBase64(bytes) { @@ -636,19 +641,22 @@ async function decryptDesktopIntegrationToken(value, secret) { function desktopIntegrationAuthUrl(provider, env, state) { const cfg = DESKTOP_INTEGRATION_PROVIDERS[provider] - const clientId = provider === 'github' ? env.GITHUB_CLIENT_ID : env.GOOGLE_CLIENT_ID + const clientId = provider === 'github' ? env.GITHUB_CLIENT_ID + : provider === 'notion' ? env.NOTION_CLIENT_ID + : env.GOOGLE_CLIENT_ID if (!cfg || !clientId) return null const params = new URLSearchParams({ client_id: clientId, redirect_uri: cfg.redirectUri, response_type: 'code', - scope: cfg.scopes, state, }) + if (cfg.scopes) params.set('scope', cfg.scopes) if (provider === 'google') { params.set('access_type', 'offline') params.set('prompt', 'consent select_account') } + if (provider === 'notion') params.set('owner', 'user') return `${cfg.authURL}?${params}` } @@ -669,7 +677,10 @@ app.post('/auth/desktop/integrations/:provider/start', async (c) => { exp: Date.now() + DESKTOP_INTEGRATION_CODE_TTL, }, c.env.TOKEN_SECRET) const authorizationUrl = desktopIntegrationAuthUrl(provider, c.env, state) - if (!authorizationUrl) return json({ error: `${provider === 'github' ? 'GitHub' : 'Google'} connections are temporarily unavailable.` }, 503) + if (!authorizationUrl) { + const label = provider === 'github' ? 'GitHub' : provider === 'notion' ? 'Notion' : 'Google' + return json({ error: `${label} connections are temporarily unavailable.` }, 503) + } return json({ authorization_url: authorizationUrl }) }) @@ -1004,17 +1015,25 @@ app.get('/auth/github/callback', async (c) => { return desktopFailure || new Response('Missing code', { status: 400 }) } const return_to = decodeState(c.req.query('state')) + const signedState = await parseToken(c.req.query('state'), c.env.TOKEN_SECRET) const tokenRes = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ client_id: c.env.GITHUB_CLIENT_ID, client_secret: c.env.GITHUB_CLIENT_SECRET, code }), + body: JSON.stringify({ + client_id: c.env.GITHUB_CLIENT_ID, + client_secret: c.env.GITHUB_CLIENT_SECRET, + code, + redirect_uri: 'https://api.amplifiedsmp.org/auth/github/callback', + }), }) const githubTokens = await tokenRes.json() const { access_token } = githubTokens - if (!access_token) return new Response('GitHub OAuth failed', { status: 400 }) + if (!access_token) { + const desktopFailure = failDesktopIntegration(signedState, 'github', 'provider_error') + return desktopFailure || new Response('GitHub could not authorize this connection. Try again.', { status: 400 }) + } - const signedState = await parseToken(c.req.query('state'), c.env.TOKEN_SECRET) const desktopIntegration = await finishDesktopIntegration(c, signedState, 'github', githubTokens) if (desktopIntegration) return desktopIntegration @@ -1034,6 +1053,37 @@ app.get('/auth/github/callback', async (c) => { return oauthFinish(c, { id_field: 'github_id', email, provider_id: String(profile.id), return_to }) }) +// ── Notion integration OAuth ───────────────────────────────────────────── + +app.get('/auth/notion/callback', async (c) => { + const signedState = await parseToken(c.req.query('state'), c.env.TOKEN_SECRET) + const code = c.req.query('code') + if (!code) { + const desktopFailure = failDesktopIntegration( + signedState, 'notion', c.req.query('error') || 'access_denied' + ) + return desktopFailure || new Response('Missing code', { status: 400 }) + } + + const basic = btoa(`${c.env.NOTION_CLIENT_ID}:${c.env.NOTION_CLIENT_SECRET}`) + const tokenRes = await fetch('https://api.notion.com/v1/oauth/token', { + method: 'POST', + headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + redirect_uri: 'https://api.amplifiedsmp.org/auth/notion/callback', + }), + }) + const tokens = await tokenRes.json() + if (!tokens.access_token) { + const desktopFailure = failDesktopIntegration(signedState, 'notion', 'provider_error') + return desktopFailure || new Response('Notion could not authorize this connection. Try again.', { status: 400 }) + } + return await finishDesktopIntegration(c, signedState, 'notion', tokens) + || new Response('This Notion connection request expired. Return to Sennoric and try again.', { status: 400 }) +}) + // ── Discord OAuth ────────────────────────────────────────────────────────── app.get('/auth/discord', (c) => { @@ -3945,10 +3995,25 @@ app.put('/dashboard/prefs', async (c) => { const user = await requireAuth(c) if (!user) return json({ error: 'Not authenticated' }, 401) const { notify_limit, notify_announcements, notify_scheduled, sandbox_mode } = await c.req.json().catch(() => ({})) + const preference = (value) => ( + typeof value === 'boolean' || value === 0 || value === 1 + ? (value ? 1 : 0) + : null + ) + const limit = preference(notify_limit) + const announcements = preference(notify_announcements) + const scheduled = preference(notify_scheduled) await c.env.DB.prepare( - `INSERT INTO email_prefs (user_id, notify_limit, notify_announcements, notify_scheduled) VALUES (?,?,?,?) - ON CONFLICT (user_id) DO UPDATE SET notify_limit=excluded.notify_limit, notify_announcements=excluded.notify_announcements, notify_scheduled=excluded.notify_scheduled` - ).bind(user.id, notify_limit ? 1 : 0, notify_announcements ? 1 : 0, notify_scheduled ? 1 : 0).run() + `INSERT INTO email_prefs (user_id, notify_limit, notify_announcements, notify_scheduled) + VALUES (?, COALESCE(?, 1), COALESCE(?, 1), COALESCE(?, 1)) + ON CONFLICT (user_id) DO UPDATE SET + notify_limit=COALESCE(?, email_prefs.notify_limit), + notify_announcements=COALESCE(?, email_prefs.notify_announcements), + notify_scheduled=COALESCE(?, email_prefs.notify_scheduled)` + ).bind( + user.id, limit, announcements, scheduled, + limit, announcements, scheduled, + ).run() if (sandbox_mode === 'ask' || sandbox_mode === 'auto') { await c.env.DB.prepare('UPDATE users SET sandbox_mode=? WHERE id=?').bind(sandbox_mode, user.id).run() } diff --git a/api-proxy-cf/test/desktop-integrations.test.mjs b/api-proxy-cf/test/desktop-integrations.test.mjs index cbb2592e..9bf5d3c5 100644 --- a/api-proxy-cf/test/desktop-integrations.test.mjs +++ b/api-proxy-cf/test/desktop-integrations.test.mjs @@ -41,6 +41,7 @@ function makeEnv() { DB: db, TOKEN_SECRET: SECRET, GITHUB_CLIENT_ID: 'github-client', GITHUB_CLIENT_SECRET: 'github-secret', GOOGLE_CLIENT_ID: 'google-client', GOOGLE_CLIENT_SECRET: 'google-secret', + NOTION_CLIENT_ID: 'notion-client', NOTION_CLIENT_SECRET: 'notion-secret', } } @@ -80,8 +81,9 @@ test('GitHub integration OAuth is brokered with PKCE and the provider token is s assert.equal(authorizationUrl.searchParams.get('client_id'), 'github-client') assert.match(authorizationUrl.searchParams.get('scope'), /\brepo\b/) - mock.method(globalThis, 'fetch', async (input) => { + mock.method(globalThis, 'fetch', async (input, init) => { assert.equal(String(input), 'https://github.com/login/oauth/access_token') + assert.equal(JSON.parse(init.body).redirect_uri, 'https://api.amplifiedsmp.org/auth/github/callback') return Response.json({ access_token: 'gho_secret', scope: 'repo read:user', token_type: 'bearer' }) }) const callback = await app.request(`/auth/github/callback?code=provider-code&state=${encodeURIComponent(authorizationUrl.searchParams.get('state'))}`, {}, env) @@ -128,3 +130,55 @@ test('a cancelled provider flow returns cleanly to Desktop instead of a raw erro assert.equal(callback.searchParams.get('error'), 'access_denied') assert.equal(callback.searchParams.get('state'), clientState) }) + +test('Notion integration OAuth is brokered without exposing its client secret', async () => { + const env = makeEnv() + const bearer = await sessionToken() + const { challenge } = await pkcePair() + const clientState = base64Url(crypto.getRandomValues(new Uint8Array(24))) + const started = await app.request('/auth/desktop/integrations/notion/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bearer}` }, + body: JSON.stringify({ code_challenge: challenge, state: clientState }), + }, env) + assert.equal(started.status, 200) + const authorizationUrl = new URL((await started.json()).authorization_url) + assert.equal(authorizationUrl.hostname, 'api.notion.com') + assert.equal(authorizationUrl.searchParams.get('client_id'), 'notion-client') + assert.equal(authorizationUrl.searchParams.get('owner'), 'user') + assert.equal(authorizationUrl.searchParams.has('client_secret'), false) + + mock.method(globalThis, 'fetch', async (input, init) => { + assert.equal(String(input), 'https://api.notion.com/v1/oauth/token') + assert.equal(init.headers.Authorization, `Basic ${btoa('notion-client:notion-secret')}`) + assert.equal(JSON.parse(init.body).redirect_uri, 'https://api.amplifiedsmp.org/auth/notion/callback') + return Response.json({ access_token: 'secret_notion', workspace_name: 'Sennoric' }) + }) + const callback = await app.request(`/auth/notion/callback?code=provider-code&state=${encodeURIComponent(authorizationUrl.searchParams.get('state'))}`, {}, env) + assert.equal(callback.status, 302) + const desktopUrl = new URL(callback.headers.get('Location')) + assert.equal(desktopUrl.protocol, 'sennoric:') + assert.equal(desktopUrl.searchParams.get('provider'), 'notion') + assert.equal(desktopUrl.searchParams.get('state'), clientState) +}) + +test('a failed GitHub token exchange returns to Desktop with a polished failure', async () => { + const env = makeEnv() + const bearer = await sessionToken() + const { challenge } = await pkcePair() + const clientState = base64Url(crypto.getRandomValues(new Uint8Array(24))) + const started = await app.request('/auth/desktop/integrations/github/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bearer}` }, + body: JSON.stringify({ code_challenge: challenge, state: clientState }), + }, env) + const authorizationUrl = new URL((await started.json()).authorization_url) + mock.method(globalThis, 'fetch', async () => Response.json({ error: 'bad_verification_code' }, { status: 200 })) + + const response = await app.request(`/auth/github/callback?code=expired&state=${encodeURIComponent(authorizationUrl.searchParams.get('state'))}`, {}, env) + assert.equal(response.status, 302) + const callback = new URL(response.headers.get('Location')) + assert.equal(callback.protocol, 'sennoric:') + assert.equal(callback.searchParams.get('error'), 'provider_error') + assert.equal(callback.searchParams.get('state'), clientState) +}) diff --git a/api-proxy-cf/test/preferences.test.mjs b/api-proxy-cf/test/preferences.test.mjs new file mode 100644 index 00000000..a5224637 --- /dev/null +++ b/api-proxy-cf/test/preferences.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { DatabaseSync } from 'node:sqlite' +import app from '../src/index.js' + +class Statement { + constructor(database, sql, values = []) { this.database = database; this.sql = sql; this.values = values } + bind(...values) { return new Statement(this.database, this.sql, values) } + first() { return this.database.prepare(this.sql).get(...this.values) || null } + all() { return { results: this.database.prepare(this.sql).all(...this.values) } } + run() { + const result = this.database.prepare(this.sql).run(...this.values) + return { meta: { changes: Number(result.changes) } } + } +} + +const SECRET = 'preferences-test-secret' + +function makeEnv() { + const database = new DatabaseSync(':memory:') + database.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, banned INTEGER NOT NULL DEFAULT 0, + token_version INTEGER NOT NULL DEFAULT 0, sandbox_mode TEXT NOT NULL DEFAULT 'ask' + ); + CREATE TABLE email_prefs ( + user_id TEXT PRIMARY KEY, notify_limit INTEGER DEFAULT 1, + notify_announcements INTEGER DEFAULT 1, notify_scheduled INTEGER DEFAULT 1 + ); + INSERT INTO users (id, email) VALUES ('u1', 'prefs@example.com'); + `) + return { DB: { prepare: (sql) => new Statement(database, sql) }, TOKEN_SECRET: SECRET } +} + +async function bearer() { + const payload = btoa(JSON.stringify({ uid: 'u1', v: 0, exp: Date.now() + 60_000 })) + const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)) + return `${payload}.${btoa(String.fromCharCode(...new Uint8Array(signature)))}` +} + +test('partial preference updates leave every unrelated switch unchanged', async () => { + const env = makeEnv() + const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${await bearer()}` } + const update = (body) => app.request('/dashboard/prefs', { method: 'PUT', headers, body: JSON.stringify(body) }, env) + const read = async () => (await app.request('/dashboard/prefs', { headers }, env)).json() + const values = async () => { + const { notify_limit, notify_announcements, notify_scheduled } = await read() + return { notify_limit, notify_announcements, notify_scheduled } + } + + for (const field of ['notify_limit', 'notify_announcements', 'notify_scheduled']) { + await update({ notify_limit: true, notify_announcements: true, notify_scheduled: true }) + assert.equal((await update({ [field]: 'invalid' })).status, 200) + assert.deepEqual(await values(), { notify_limit: 1, notify_announcements: 1, notify_scheduled: 1 }) + assert.equal((await update({ [field]: false })).status, 200) + assert.deepEqual(await values(), { + notify_limit: field === 'notify_limit' ? 0 : 1, + notify_announcements: field === 'notify_announcements' ? 0 : 1, + notify_scheduled: field === 'notify_scheduled' ? 0 : 1, + }) + } + + await update({ notify_limit: true, notify_announcements: true, notify_scheduled: true }) + await Promise.all([update({ notify_limit: false }), update({ notify_announcements: false })]) + assert.deepEqual(await values(), { notify_limit: 0, notify_announcements: 0, notify_scheduled: 1 }) +}) diff --git a/src/agent/commandPermissions.js b/src/agent/commandPermissions.js new file mode 100644 index 00000000..a1936267 --- /dev/null +++ b/src/agent/commandPermissions.js @@ -0,0 +1,86 @@ +// Cross-platform matching for user-granted command permissions. +// +// A rule can auto-approve only a single, statically tokenizable command. Shell +// operators, substitutions, environment expansion, and newlines deliberately +// fall back to the ordinary confirmation prompt so an allowed prefix cannot be +// extended into a second command. + +const UNSAFE_UNQUOTED = new Set(['&', '|', ';', '<', '>', '(', ')', '`', '$', '%', '!', '^', '\n', '\r']); + +export function tokenizeSimpleCommand(value) { + const source = String(value || '').trim(); + if (!source) return null; + const tokens = []; + let token = ''; + let quote = null; + let escaping = false; + let started = false; + + for (let index = 0; index < source.length; index++) { + const char = source[index]; + if (escaping) { + token += char; + escaping = false; + started = true; + continue; + } + if (char === '\\' && quote !== "'") { + const next = source[index + 1]; + if (next && UNSAFE_UNQUOTED.has(next)) return null; + if (next && /[\s"'\\]/.test(next)) escaping = true; + else token += char; + started = true; + continue; + } + if (quote) { + if (char === quote) quote = null; + else { + if (UNSAFE_UNQUOTED.has(char)) return null; + token += char; + } + started = true; + continue; + } + if (char === '"' || char === "'") { + quote = char; + started = true; + continue; + } + if (UNSAFE_UNQUOTED.has(char)) return null; + if (/\s/.test(char)) { + if (started) { + tokens.push(token); + token = ''; + started = false; + } + continue; + } + token += char; + started = true; + } + if (escaping || quote) return null; + if (started) tokens.push(token); + return tokens.length ? tokens : null; +} + +function executableName(value) { + const leaf = String(value || '').trim().split(/[\\/]/).pop()?.toLowerCase() || ''; + return leaf.endsWith('.exe') ? leaf.slice(0, -4) : leaf; +} + +export function commandMatchesPermissionRule(command, rule) { + const argv = tokenizeSimpleCommand(command); + if (!argv || executableName(argv[0]) !== executableName(rule?.executable)) return false; + const rawPrefix = String(rule?.argumentPrefix || '').trim(); + if (!rawPrefix) return true; + const prefix = tokenizeSimpleCommand(rawPrefix); + if (!prefix || argv.length < prefix.length + 1) return false; + for (let index = 0; index < prefix.length; index++) { + const actual = argv[index + 1]; + const expected = prefix[index]; + if (index === prefix.length - 1) { + if (!actual.startsWith(expected)) return false; + } else if (actual !== expected) return false; + } + return true; +} diff --git a/src/oauth/oauth.js b/src/oauth/oauth.js index cace7977..0ff95ee5 100644 --- a/src/oauth/oauth.js +++ b/src/oauth/oauth.js @@ -192,8 +192,7 @@ export async function connectOAuth(service, { onStatus, onToken, pastedToken } = // have the token exchange fail afterward). Paste-token flows (Slack) // don't need pre-registered apps. if (cfg.tokenFlow !== 'paste' && (!cfg.clientId || !cfg.clientSecret)) { - const U = service.toUpperCase(); - throw new Error(`${cfg.label} OAuth isn't configured on this build — no client ID. Register an OAuth app and set AXION_${U}_CLIENT_ID and AXION_${U}_CLIENT_SECRET, or use a paste-token integration (slack) instead.`); + throw new Error(`${cfg.label} connections are temporarily unavailable. Keep using Sennoric and try again shortly.`); } let tokenData; diff --git a/src/oauth/providers.js b/src/oauth/providers.js index 9a2097a0..d177628d 100644 --- a/src/oauth/providers.js +++ b/src/oauth/providers.js @@ -1,8 +1,8 @@ export const OAUTH_PROVIDERS = { github: { label: 'GitHub', - clientId: process.env.AXION_GITHUB_CLIENT_ID || '', - clientSecret: process.env.AXION_GITHUB_CLIENT_SECRET || '', + clientId: process.env.SENNORIC_GITHUB_CLIENT_ID || '', + clientSecret: process.env.SENNORIC_GITHUB_CLIENT_SECRET || '', // Redirect flow ("click Connect, approve on github.com, done") — the // same UX as Google. GitHub requires an exact redirect_uri match against // the app's registered callback URL, so this pins a fixed local port @@ -21,8 +21,8 @@ export const OAUTH_PROVIDERS = { }, google: { label: 'Google', - clientId: process.env.AXION_GOOGLE_CLIENT_ID || '', - clientSecret: process.env.AXION_GOOGLE_CLIENT_SECRET || '', + clientId: process.env.SENNORIC_GOOGLE_CLIENT_ID || '', + clientSecret: process.env.SENNORIC_GOOGLE_CLIENT_SECRET || '', tokenFlow: 'redirect', authURL: 'https://accounts.google.com/o/oauth2/v2/auth', tokenURL: 'https://oauth2.googleapis.com/token', @@ -31,8 +31,8 @@ export const OAUTH_PROVIDERS = { }, notion: { label: 'Notion', - clientId: process.env.AXION_NOTION_CLIENT_ID || '', - clientSecret: process.env.AXION_NOTION_CLIENT_SECRET || '', + clientId: process.env.SENNORIC_NOTION_CLIENT_ID || '', + clientSecret: process.env.SENNORIC_NOTION_CLIENT_SECRET || '', // Redirect flow — same "click Connect, approve on notion.so, done" UX as // Google/GitHub, via a public integration instead of pasting an internal // integration's token. Notion requires an exact redirect_uri match (pin diff --git a/test/commandPermissions.test.js b/test/commandPermissions.test.js new file mode 100644 index 00000000..43834d80 --- /dev/null +++ b/test/commandPermissions.test.js @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { commandMatchesPermissionRule, tokenizeSimpleCommand } from '../src/agent/commandPermissions.js'; + +test('matches executables and argument prefixes across POSIX and Windows spellings', () => { + assert.equal(commandMatchesPermissionRule('bash scripts/x-build.sh --fast', { + executable: 'bash', argumentPrefix: 'scripts/x', + }), true); + assert.equal(commandMatchesPermissionRule('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -File XDeploy.ps1', { + executable: 'powershell', argumentPrefix: '-File X', + }), true); + assert.equal(commandMatchesPermissionRule('pwsh -File YDeploy.ps1', { + executable: 'pwsh', argumentPrefix: '-File X', + }), false); + assert.equal(commandMatchesPermissionRule('powershell -Command XDeploy.ps1', { + executable: 'powershell', argumentPrefix: '-File X', + }), false); +}); + +test('supports quoted static arguments', () => { + assert.deepEqual(tokenizeSimpleCommand('npm test -- "settings suite"'), ['npm', 'test', '--', 'settings suite']); + assert.equal(commandMatchesPermissionRule('npm test -- "settings suite"', { + executable: 'npm', argumentPrefix: 'test', + }), true); +}); + +test('never auto-approves compound or dynamically expanded shell commands', () => { + const rule = { executable: 'npm', argumentPrefix: 'test' }; + for (const command of [ + 'npm test && rm -rf target', + 'npm test; curl example.com', + 'npm test | sh', + 'npm test\nwhoami', + 'npm test-$TASK', + 'npm "test-$(whoami)"', + 'npm test-%EXTRA%', + 'npm test \\& whoami', + "npm test '& whoami &'", + ]) assert.equal(commandMatchesPermissionRule(command, rule), false, command); +}); + +test('an executable-only grant is explicit and still limited to one static command', () => { + assert.equal(commandMatchesPermissionRule('bash script.sh', { executable: 'bash', argumentPrefix: '' }), true); + assert.equal(commandMatchesPermissionRule('bash script.sh && other', { executable: 'bash', argumentPrefix: '' }), false); +}); diff --git a/test/oauthConfiguration.test.js b/test/oauthConfiguration.test.js new file mode 100644 index 00000000..25f0b316 --- /dev/null +++ b/test/oauthConfiguration.test.js @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { connectOAuth } from '../src/oauth/oauth.js'; +import { OAUTH_PROVIDERS } from '../src/oauth/providers.js'; + +test('OAuth configuration uses Sennoric names and never exposes build instructions', () => { + const providers = readFileSync(new URL('../src/oauth/providers.js', import.meta.url), 'utf8'); + const oauth = readFileSync(new URL('../src/oauth/oauth.js', import.meta.url), 'utf8'); + + for (const provider of ['GITHUB', 'GOOGLE', 'NOTION']) { + assert.match(providers, new RegExp(`SENNORIC_${provider}_CLIENT_ID`)); + assert.match(providers, new RegExp(`SENNORIC_${provider}_CLIENT_SECRET`)); + const runtime = OAUTH_PROVIDERS[provider.toLowerCase()]; + assert.equal(runtime.clientId, process.env[`SENNORIC_${provider}_CLIENT_ID`] || ''); + assert.equal(runtime.clientSecret, process.env[`SENNORIC_${provider}_CLIENT_SECRET`] || ''); + } + assert.doesNotMatch(providers, /AXION_(?:GITHUB|GOOGLE|NOTION)_CLIENT/); + assert.match(oauth, /connections are temporarily unavailable/); + assert.doesNotMatch(oauth, /Register an OAuth app|paste-token integration|AXION_\$\{U\}/); +}); + +test('missing OAuth credentials produce only a polished runtime message', async () => { + if (OAUTH_PROVIDERS.notion.clientId || OAUTH_PROVIDERS.notion.clientSecret) return; + await assert.rejects( + connectOAuth('notion'), + (error) => /Notion connections are temporarily unavailable/.test(error.message) + && !/AXION_|Register an OAuth app|paste-token/.test(error.message), + ); +});