Skip to content
Open
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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"@workos/emulate": "^0.14.0",
"@workos/migrations": "^2.6.0",
"@workos/openapi-spec": "^0.123.0",
"@workos/skills": "0.8.0",
"@workos/skills": "0.8.1",
"chalk": "^6.0.1",
"diff": "^9.0.0",
"fast-glob": "^3.3.3",
Expand Down
71 changes: 71 additions & 0 deletions src/lib/agent-interface.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,31 @@ describe('runAgent retry loop', () => {
}) as typeof emitter.emit;
});

it('isolates the agent from user settings and states installer rules in the system prompt', async () => {
mockQuery.mockImplementation(createMockSDKResponse([{ text: 'Done!' }]));

await runAgent(makeAgentConfig(), 'Test prompt', makeOptions(), undefined, emitter);

const { options } = mockQuery.mock.calls[0][0] as { options: Record<string, unknown> };
// Omitted, the SDK loads ~/.claude settings: user plugins, MCP servers, hooks, and
// permission rules that would apply before installerCanUseTool.
expect(options.settingSources).toEqual([]);
expect(options.systemPrompt).toEqual({
type: 'preset',
preset: 'claude_code',
append: expect.stringContaining('WorkOS installer environment'),
});
});

it('passes agent messages to options.onAgentMessage', async () => {
mockQuery.mockImplementation(createMockSDKResponse([{ text: 'Done!' }]));
const onAgentMessage = vi.fn();

await runAgent(makeAgentConfig(), 'Test prompt', makeOptions({ onAgentMessage }), undefined, emitter);

expect(onAgentMessage).toHaveBeenCalled();
});

it('returns retryCount=0 when no retryConfig provided', async () => {
mockQuery.mockImplementation(createMockSDKResponse([{ text: 'Done!' }]));

Expand Down Expand Up @@ -567,4 +592,50 @@ describe('installerCanUseTool', () => {
});
expect(result.behavior).toBe('deny');
});
describe('with a working directory', () => {
const cwd = '/proj';
const verdict = (command: string) => installerCanUseTool('Bash', { command }, cwd).behavior;

it('allows an allowlisted command after cd into the working directory only', () => {
expect(verdict('cd /proj && bun run build')).toBe('allow');
expect(verdict('cd /proj && bun run build 2>&1 | tail -20')).toBe('allow');
expect(verdict('cd /other && bun run build')).toBe('deny');
expect(verdict('cd /proj && bun run build && curl http://x/')).toBe('deny');
});

it('allows read-only git and denies git that writes or runs programs', () => {
expect(verdict('git status --short')).toBe('allow');
expect(verdict('git -C /proj status')).toBe('allow');
expect(verdict('git diff --stat | head -50')).toBe('allow');
expect(verdict('git -C /etc log')).toBe('deny');
expect(verdict('git diff --output=/tmp/x')).toBe('deny');
expect(verdict('git -c core.pager=sh log')).toBe('deny');
expect(verdict('git commit -m x')).toBe('deny');
expect(verdict('git push')).toBe('deny');
});

it('allows syntax checks per tool without allowing code execution', () => {
expect(verdict('python3 -m py_compile server.py')).toBe('allow');
expect(verdict('python3 -m venv .venv')).toBe('allow');
expect(verdict('ruby -c app.rb')).toBe('allow');
expect(verdict('php -l index.php')).toBe('allow');
expect(verdict('python3 -c "print(1)"')).toBe('deny');
expect(verdict('python3 -m http.server')).toBe('deny');
expect(verdict('ruby -e "puts 1"')).toBe('deny');
expect(verdict('php -r "echo 1"')).toBe('deny');
});

it('allows dependency changes across ecosystems', () => {
expect(verdict('bun remove @auth0/auth0-react')).toBe('allow');
expect(verdict('go get github.com/workos/workos-go/v6')).toBe('allow');
expect(verdict('go mod tidy')).toBe('allow');
expect(verdict('composer require workos/workos-php')).toBe('allow');
});

it('still denies network, the workos CLI, and plain inspection commands', () => {
expect(verdict('curl -sL https://example.com')).toBe('deny');
expect(verdict('npx workos whoami')).toBe('deny');
expect(verdict('ls -la')).toBe('deny');
});
});
});
97 changes: 88 additions & 9 deletions src/lib/agent-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Uses Claude Agent SDK directly with WorkOS MCP server
*/

import { dirname } from 'path';
import { dirname, resolve } from 'path';
import { getSkillsDir as getSkillsPackageDir } from './skills-assets.js';
import { ensureClaudeCodeExecutable } from './agent-sdk-assets.js';
import { debug, logInfo, logWarn, logError, initLogFile, getLogFilePath } from '../utils/debug.js';
Expand Down Expand Up @@ -102,6 +102,23 @@ export type AgentRunConfig = {
claudeExecutablePath: string;
};

/**
* Appended to Claude Code's system prompt for every installer run. It states up front
* what installerCanUseTool enforces, so the agent doesn't discover the rules one
* denied command at a time. Keep it in sync with the allowlist below.
*/
export const INSTALLER_SYSTEM_PROMPT_APPEND = `# WorkOS installer environment

You are running inside the WorkOS CLI installer, not an interactive session, and your working directory is the user's project.

- Bash runs only: package-manager install/add/remove commands; the project's build, typecheck, lint, format, and test scripts; language checks such as \`go build\`, \`go vet\`, \`mix compile\`, \`python3 -m py_compile\`, \`ruby -c\`, \`php -l\`, \`dotnet build\`, and \`./gradlew build\`; and read-only git (\`status\`, \`diff\`, \`log\`, \`show\`, \`ls-files\`).
- Run one command per Bash call from the working directory. Don't use \`cd\`, \`&&\`, \`;\`, \`$(...)\`, or pipes other than \`| head\` or \`| tail\`.
- Inspect files with Read, Grep, and Glob, not Bash.
- Bash has no network access: no curl, \`npm view\`, or \`git clone\`. Use WebFetch to read documentation. The \`workos\` CLI is not available to you.
- Don't start dev servers or open a browser to test sign-in. After you finish, the installer typechecks and builds the project and sends you any errors to fix.

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 Promised checks never run

Every agent is now told that the installer will build the project and send errors back. Python, Ruby, and Elixir call runAgent without a RetryConfig and return without that feedback. The new non-Next.js note in agent-runner.ts also says the installer handles browser checks.

State only the checks each runner actually performs. Otherwise, agents may defer work to a follow-up that never happens.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/agent-interface.ts
Line: 118

Comment:
**Promised checks never run**

Every agent is now told that the installer will build the project and send errors back. Python, Ruby, and Elixir call `runAgent` without a `RetryConfig` and return without that feedback. The new non-Next.js note in `agent-runner.ts` also says the installer handles browser checks.

State only the checks each runner actually performs. Otherwise, agents may defer work to a follow-up that never happens.

**Knowledge Base Used:**
- [Framework integration agents](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/cli/-/docs/framework-integration-agents.md)
- [Agent execution and assets](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/cli/-/docs/agent-execution.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

- Don't change WorkOS dashboard settings (redirect URIs, Sign-out URI, Initiate login URI, CORS). The installer configures supported settings after your code passes validation. List anything you could not verify as unverified.
- Leave changes unstaged: don't stage, commit, or create branches.`;

/**
* Package managers that can be used to run commands.
* Includes JS and non-JS ecosystem package managers for multi-SDK support.
Expand Down Expand Up @@ -153,10 +170,16 @@ const PACKAGE_MANAGERS = [
* Note: Linting tools are in LINTING_TOOLS and checked separately.
*/
const SAFE_SCRIPTS = [
// Package installation
// Package installation and removal
'install',
'add',
'ci',
'remove',
'uninstall',
'get', // go get
'require', // composer require
'mod tidy',
'mod download',
// Build
'build',
// Type checking (various naming conventions)
Expand Down Expand Up @@ -204,6 +227,46 @@ const SAFE_SCRIPTS = [
*/
const DANGEROUS_OPERATORS = /[;`$()\n\r]/;

/**
* Per-tool arguments that only check syntax or create a local environment.
* Kept per tool because the same flag can execute code elsewhere
* (`ruby -c` checks syntax; `python -c` runs code).
*/
const TOOL_SAFE_ARGS: Record<string, string[]> = {
python: ['-m py_compile', '-m compileall', '-m venv', '-m pip install'],
python3: ['-m py_compile', '-m compileall', '-m venv', '-m pip install'],
uv: ['venv', 'pip install', 'sync'],
ruby: ['-c '],
php: ['-l '],
};

/** Read-only git subcommands the agent may use to see what changed. */
const READ_ONLY_GIT_SUBCOMMANDS = ['status', 'diff', 'log', 'show', 'ls-files', 'rev-parse'];
/** git arguments that write files or run external programs. */
const UNSAFE_GIT_ARG = /^(-c|-o|--output.*|--ext-diff|--textconv|--exec.*|--upload-pack.*|--open-files-in-pager.*)$/;

function isReadOnlyGit(command: string, cwd?: string): boolean {
const parts = command.split(/\s+/);
if (parts[0] !== 'git') return false;
let i = 1;
if (parts[i] === '--no-pager') i++;
if (parts[i] === '-C') {
if (!cwd || !parts[i + 1] || resolve(cwd, parts[i + 1]) !== resolve(cwd)) return false;
i += 2;
}
if (!READ_ONLY_GIT_SUBCOMMANDS.includes(parts[i])) return false;
return !parts.slice(i + 1).some((arg) => UNSAFE_GIT_ARG.test(arg));
}

/** Strip `cd <working directory> && ` so the rest is judged on its own. */
function stripCdIntoWorkingDirectory(command: string, cwd?: string): string {
if (!cwd) return command;
const match = command.match(/^cd\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s*&&\s*(\S.*)$/s);
if (!match) return command;
const dir = match[1] ?? match[2] ?? match[3];
return resolve(cwd, dir) === resolve(cwd) ? match[4] : command;
Comment on lines +264 to +267

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Directory prefixes hide shell code

The new cd check can hide command substitution even when the project path contains no special characters. With workingDirectory set to /proj, cd "/proj/$(id)/.." && git status passes because resolve() removes the $(id)/.. segment. The dangerous-operator check then sees only git status, but the returned original input makes Bash execute id.

Check the original command before stripping the prefix, or parse and rebuild a safe cd command.

How this was verified: The path check removes the substitution-bearing segment before checking operators, while the allow result returns the original command for execution.

Knowledge Base Used: Agent execution and assets

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/agent-interface.ts
Line: 264-267

Comment:
**Directory prefixes hide shell code**

The new `cd` check can hide command substitution even when the project path contains no special characters. With `workingDirectory` set to `/proj`, `cd "/proj/$(id)/.." && git status` passes because `resolve()` removes the `$(id)/..` segment. The dangerous-operator check then sees only `git status`, but the returned original input makes Bash execute `id`.

Check the original command before stripping the prefix, or parse and rebuild a safe `cd` command.

> **How this was verified:** The path check removes the substitution-bearing segment before checking operators, while the allow result returns the original command for execution.

**Knowledge Base Used:** [Agent execution and assets](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/cli/-/docs/agent-execution.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}

/**
* Check if command is an allowed package manager command.
* Matches: <pkg-manager> [run|exec] <safe-script> [args...]
Expand All @@ -226,7 +289,8 @@ function matchesAllowedPrefix(command: string): boolean {
// Check if script starts with any safe script name or linting tool
return (
SAFE_SCRIPTS.some((safe) => scriptPart.startsWith(safe)) ||
LINTING_TOOLS.some((tool) => scriptPart.startsWith(tool))
LINTING_TOOLS.some((tool) => scriptPart.startsWith(tool)) ||
(TOOL_SAFE_ARGS[parts[0]] ?? []).some((arg) => parts.slice(1).join(' ').startsWith(arg))
);
}

Expand All @@ -237,13 +301,20 @@ function matchesAllowedPrefix(command: string): boolean {
* - Piping to tail/head for output limiting is allowed
* - Stderr redirection (2>&1) is allowed
*/
export function installerCanUseTool(toolName: string, input: Record<string, unknown>): PermissionResult {
export function installerCanUseTool(
toolName: string,
input: Record<string, unknown>,
workingDirectory?: string,
): PermissionResult {
// Allow all non-Bash tools
if (toolName !== 'Bash') {
return { behavior: 'allow', updatedInput: input };
}

const command = (typeof input.command === 'string' ? input.command : '').trim();
const command = stripCdIntoWorkingDirectory(
(typeof input.command === 'string' ? input.command : '').trim(),
workingDirectory,
);

// Block definitely dangerous operators: ; ` $ ( )
if (DANGEROUS_OPERATORS.test(command)) {
Expand Down Expand Up @@ -283,7 +354,7 @@ export function installerCanUseTool(toolName: string, input: Record<string, unkn
};
}

if (matchesAllowedPrefix(baseCommand)) {
if (matchesAllowedPrefix(baseCommand) || isReadOnlyGit(baseCommand, workingDirectory)) {
logInfo(`Allowing bash command with output limiter: ${command}`);
debug(`Allowing bash command with output limiter: ${command}`);
return { behavior: 'allow', updatedInput: input };
Expand All @@ -306,7 +377,7 @@ export function installerCanUseTool(toolName: string, input: Record<string, unkn
}

// Check if command starts with any allowed prefix
if (matchesAllowedPrefix(normalized)) {
if (matchesAllowedPrefix(normalized) || isReadOnlyGit(normalized, workingDirectory)) {
logInfo(`Allowing bash command: ${command}`);
debug(`Allowing bash command: ${command}`);
return { behavior: 'allow', updatedInput: input };
Expand All @@ -321,7 +392,7 @@ export function installerCanUseTool(toolName: string, input: Record<string, unkn
});
return {
behavior: 'deny',
message: `Bash command not allowed. Only install, build, typecheck, lint, and formatting commands are permitted.`,
message: `Bash command not allowed. Only install, build, typecheck, lint, format, test, syntax-check, and read-only git commands are permitted. Use Read, Grep, and Glob to inspect files.`,
};
}

Expand Down Expand Up @@ -672,11 +743,18 @@ export async function runAgent(
env: agentConfig.sdkEnv,
canUseTool: (toolName, input) => {
logInfo('canUseTool called:', { toolName, input });
const result = installerCanUseTool(toolName, input);
const result = installerCanUseTool(toolName, input, agentConfig.workingDirectory);
logInfo('canUseTool result:', result);
return Promise.resolve(result);
},
tools: { type: 'preset', preset: 'claude_code' },
// Without this the SDK sends an empty system prompt, so the agent never gets
// Claude Code's tool guidance (Read/Grep over cat/grep) or our installer rules.
systemPrompt: { type: 'preset', preset: 'claude_code', append: INSTALLER_SYSTEM_PROMPT_APPEND },
// Load no ~/.claude or project settings. Omitted, the SDK loads them all: the
// user's plugins, MCP servers, hooks, and permission rules, which apply before
// installerCanUseTool and would bypass the Bash gate.
settingSources: [],
allowedTools: agentConfig.allowedTools,
plugins: [{ type: 'local', path: pluginPath }],
pathToClaudeCodeExecutable: agentConfig.claudeExecutablePath,
Expand Down Expand Up @@ -704,6 +782,7 @@ export async function runAgent(
}
try {
onMessage?.(message);
options.onAgentMessage?.(message);
} catch {
/* non-critical */
}
Expand Down
37 changes: 36 additions & 1 deletion src/lib/agent-runner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { runAgentInstaller } from './agent-runner.js';
import { validateInstallation, quickCheckValidateAndFormat } from './validation/index.js';
import { autoConfigureWorkOSEnvironment } from './workos-management.js';
import { writeEnvLocal } from './env-writer.js';
import { getOrAskForWorkOSCredentials } from '../utils/ui-utils.js';
import { ensurePackageIsInstalled, getOrAskForWorkOSCredentials, getPackageDotJson } from '../utils/ui-utils.js';

const options: InstallerOptions = {
debug: false,
Expand Down Expand Up @@ -244,3 +244,38 @@ describe('installer prompt', () => {
expect(runAgent).not.toHaveBeenCalled();
});
});

describe('project manifest checks', () => {
it.each([
['kotlin', 'kotlin', 'workos-kotlin'],
['php', 'php', 'workos-php'],
['php-laravel', 'php', 'workos-php-laravel'],
] as const)('does not read package.json for %s projects', async (integration, language, skillName) => {
await runAgentInstaller({ ...config, metadata: { ...config.metadata, integration, language, skillName } }, options);

expect(getPackageDotJson).not.toHaveBeenCalled();
expect(ensurePackageIsInstalled).not.toHaveBeenCalled();
expect(runAgent).toHaveBeenCalledTimes(1);
});

it('does not require a framework package for vanilla JS', async () => {
await runAgentInstaller(
{
...config,
metadata: { ...config.metadata, integration: 'vanilla-js', skillName: 'workos-authkit-vanilla-js' },
detection: { packageName: 'workos', packageDisplayName: 'Vanilla JavaScript', getVersion: () => undefined },
},
options,
);

expect(getPackageDotJson).toHaveBeenCalled();
expect(ensurePackageIsInstalled).not.toHaveBeenCalled();
expect(runAgent).toHaveBeenCalledTimes(1);
});

it('still requires the framework package for other JS integrations', async () => {
await runAgentInstaller(config, options);

expect(ensurePackageIsInstalled).toHaveBeenCalledWith(expect.anything(), 'next', 'Next.js');
});
});
26 changes: 19 additions & 7 deletions src/lib/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,14 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal
// Git check is now handled by the state machine - no need to check here

// Framework detection and version
const packageJson = await getPackageDotJson(options);
await ensurePackageIsInstalled(packageJson, config.detection.packageName, config.detection.packageDisplayName);
// Only JS integrations have a package.json; reading it elsewhere aborts the install
// (Kotlin, PHP, Laravel). Vanilla JS is the fallback for any JS project, so it has
// no framework package to require.
const isJavaScriptProject = config.metadata.language === 'javascript';
const packageJson = isJavaScriptProject ? await getPackageDotJson(options) : {};
if (isJavaScriptProject && config.metadata.integration !== 'vanilla-js') {
await ensurePackageIsInstalled(packageJson, config.detection.packageName, config.detection.packageDisplayName);
}

const frameworkVersion = config.detection.getVersion(packageJson);

Expand Down Expand Up @@ -345,21 +351,27 @@ ${baseContent ? `## General Guidelines\n\n${baseContent}\n\n` : ''}## Integratio
${refContent}

${
setupContent
? `## Required Application Setup and Verification
baseContent && !setupContent
? `The references above link to workos-authkit-setup.md. It covers dashboard configuration and browser checks that this installer handles itself, so you don't need to read it.

`
: ''
}${
setupContent
? `## Required Application Setup and Verification

${setupContent}

## Installer execution boundary

The setup reference above is already included in this prompt. Do not read a relative workos-authkit-setup.md from the app directory.
The agent's shell permissions do not allow WorkOS management commands. Do not run workos, install another CLI, use curl or SDK scripts to bypass that boundary, or attempt dashboard authentication. Implement and validate the app code only. The installer handles supported dashboard configuration outside the agent after code validation; unavailable configuration must remain explicitly unverified.
Don't install another CLI or use SDK scripts to change WorkOS settings or authenticate to the dashboard. Implement and validate the app code only.

Create a dedicated /sign-in GET route in the App Router using getSignInUrl() from @workos-inc/authkit-nextjs and redirect(await getSignInUrl()) from next/navigation. Keep the OAuth callback using handleAuth() separate. The Initiate login URI is the app origin plus /sign-in, NEVER the callback URI. Read existing files before editing; do not replace an unrelated existing sign-in flow. If a page already serves /sign-in (including inside route groups), stop: this installer cannot place a route handler alongside that page. Never delete the page or create a conflicting route. Keep /sign-in public and follow the SDK README for PKCE cookie handling.
Do not claim the full integration or browser flows are verified. Report code implementation separately from application configuration and browser testing.
`
: ''
}
: ''
}

${signInSection}Report your progress using [STATUS] prefixes.

Expand Down
Loading
Loading