Repository navigation
fix(installer): isolate the agent, state its rules, and unbreak non-JS installs #266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
72f989e
26d91d8
ee39030
6840966
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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. | ||
| - 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. | ||
|
|
@@ -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) | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new Check the original command before stripping the prefix, or parse and rebuild a safe
Knowledge Base Used: Agent execution and assets Prompt To Fix With AIThis 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...] | ||
|
|
@@ -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)) | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -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)) { | ||
|
|
@@ -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 }; | ||
|
|
@@ -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 }; | ||
|
|
@@ -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.`, | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -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, | ||
|
|
@@ -704,6 +782,7 @@ export async function runAgent( | |
| } | ||
| try { | ||
| onMessage?.(message); | ||
| options.onAgentMessage?.(message); | ||
| } catch { | ||
| /* non-critical */ | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Every agent is now told that the installer will build the project and send errors back. Python, Ruby, and Elixir call
runAgentwithout aRetryConfigand return without that feedback. The new non-Next.js note inagent-runner.tsalso 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