Skip to content

feat: AIGRO-4919 - Invoking allowed commands from outside the cli#1598

Open
mwritter wants to merge 11 commits into
mainfrom
feat/aigro-4919
Open

feat: AIGRO-4919 - Invoking allowed commands from outside the cli#1598
mwritter wants to merge 11 commits into
mainfrom
feat/aigro-4919

Conversation

@mwritter

@mwritter mwritter commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Screenshots - Locally tested linking cli changes to mellon and running pnpm inspect from mellon
image

Screenshot 2026-07-24 at 3 43 28 PM image

Note

Medium Risk
Expands the attack surface for remote callers (many destructive API commands are allowed under MCP policy), though mitigated by explicit policies, token isolation, and disabled local project/config discovery; regressions in policy exhaustiveness or context routing could leak tokens or host filesystem behavior.

Overview
Adds in-process CLI invocation for embedding hosts (e.g. MCP): @sanity/cli/invokeSanityCli runs allowed oclif commands, returns {exitCode, output}, and never shells out.

@sanity/cli-core/executionContext uses AsyncLocalStorage so each call can supply a per-invocation token (bypasses env/config/cache), stdout/stderr sinks, forced non-interactive behavior, and no filesystem project-root discovery (callers must pass flags like --project-id). SanityCommand logging/warnings and getCliToken honor this context; the normal sanity binary is unchanged.

MCP command policy is an exhaustive allow / deny / conditional table (mcpPolicy) synced to the oclif manifest; denied commands look like unknown commands. Help is filtered to the invokable surface (plain text, no probing denied topics). Includes shell-style tokenizeCliArgs (no shell) and a small guard on cors add so programmatic runs do not stat the host cwd.

Reviewed by Cursor Bugbot for commit ab297aa. Configure here.

@mwritter
mwritter requested a review from jonahsnider July 24, 2026 19:14
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @sanity/cli

Compared against main (7332e1f6)

@sanity/cli

Metric Value vs main (7332e1f)
Internal (raw) 2.2 KB -
Internal (gzip) 838 B -
Bundled (raw) 11.20 MB +1.1 KB, +0.0%
Bundled (gzip) 2.11 MB +405 B, +0.0%
Import time 758ms -3ms, -0.4%

bin:sanity

Metric Value vs main (7332e1f)
Internal (raw) 782 B -
Internal (gzip) 423 B -
Bundled (raw) 9.90 MB -
Bundled (gzip) 1.78 MB -
Import time 1.79s -15ms, -0.8%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @sanity/cli-core

Compared against main (7332e1f6)

Metric Value vs main (7332e1f)
Internal (raw) 118.7 KB +4.6 KB, +4.0%
Internal (gzip) 30.5 KB +1.3 KB, +4.4%
Bundled (raw) 21.77 MB +3.8 KB, +0.0%
Bundled (gzip) 3.47 MB +793 B, +0.0%
Import time 664ms -5ms, -0.7%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @sanity/cli-build

Compared against main (7332e1f6)

@sanity/cli-build/_internal/build

Metric Value vs main (7332e1f)
Internal (raw) 113.2 KB -
Internal (gzip) 28.7 KB -
Bundled (raw) 17.77 MB +1.4 KB, +0.0%
Bundled (gzip) 3.56 MB +611 B, +0.0%
Import time 979ms -3ms, -0.3%

@sanity/cli-build/_internal/env

Metric Value vs main (7332e1f)
Internal (raw) 1.8 KB -
Internal (gzip) 644 B -
Bundled (raw) 1.31 MB -
Bundled (gzip) 333.8 KB -
Import time 121ms -1ms, -0.9%

@sanity/cli-build/_internal/extract

Metric Value vs main (7332e1f)
Internal (raw) 8.6 KB -
Internal (gzip) 2.7 KB -
Bundled (raw) 155.0 KB -
Bundled (gzip) 39.5 KB -
Import time 208ms -3ms, -1.2%

🗺️ ./_internal/env · ./_internal/extract · @sanity/cli-build:./_internal/build treemap too large to embed · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — create-sanity

Compared against main (7332e1f6)

Metric Value vs main (7332e1f)
Internal (raw) 908 B -
Internal (gzip) 483 B -
Bundled (raw) 931 B -
Bundled (gzip) 491 B -
Import time ❌ ChildProcess denied: node -
Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Coverage Delta

File Statements
packages/@sanity/cli-core/src/SanityCommand.ts 86.3% (+ 2.3%)
packages/@sanity/cli-core/src/config/cli/cliUserConfig.ts 100.0% (±0%)
packages/@sanity/cli-core/src/config/findProjectRoot.ts 100.0% (±0%)
packages/@sanity/cli-core/src/config/findProjectRootSync.ts 100.0% (±0%)
packages/@sanity/cli-core/src/executionContext.ts 100.0% (new)
packages/@sanity/cli-core/src/util/isInteractive.ts 100.0% (±0%)
packages/@sanity/cli/src/commands/cors/add.ts 100.0% (±0%)
packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/index.ts 100.0% (new)
packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts 60.0% (new)
packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts 80.0% (new)
packages/@sanity/cli/src/exports/invokeSanityCli/help.ts 97.6% (new)
packages/@sanity/cli/src/exports/invokeSanityCli/index.ts 94.2% (new)
packages/@sanity/cli/src/util/tokenizeCliArgs.ts 100.0% (new)

Comparing 13 changed files against main @ 7332e1f699aa4c98f8787cb6e4c3d879d75cd7c1

Overall Coverage

Metric Coverage
Statements 78.7% (+ 0.2%)
Branches 70.3% (+ 0.3%)
Functions 73.9% (+ 0.2%)
Lines 79.1% (+ 0.2%)

* @throws When the input contains an unterminated quote.
* @internal
*/
export function tokenizeCliArgs(input: string): string[] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

IMO, rather than keeping this in the CLI project, the MCP server should handle parsing the args. this keeps the bundle size smaller for the CLI

Comment on lines +16 to +19
['cors add', CorsAdd],
['cors delete', CorsDelete],
['cors list', CorsList],
['projects list', ProjectsList],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is there a way we can run commands programmatically without needing to define them all here? we want to specify the allowed commands in the MCP server, not the CLI.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤔 These are 'invoke-able' commands meant to be called from outside the cli so they are run programmatically. The agent would call the tool with an 'args' string that mimics the actual cli command.

runSanityCli({
    args: "cors list --projectId asdfjkl",
    token: "mcp-user-token" // extra.authInfo.token
})

IMO I actually think it would be the clis responsability to tell the MCP what it should or shouldn't be able to call 🤷‍♂️ - The cli and anyone working it would know when a breaking change is introduced (ie add a file system call to an already allowed command) and it would be their responsibility to make it work with mcp commands.

So maybe we need to distinguish mcp commands better? Right now we export invokeSanityCli which could technically be used by any project that wants to piggy back off of the cli functionality but maybe we can pass a source and the mcp could have its own dedicated command list (policy)?

This is also why the 'tokenizer' I think should live in the cli as well, because if another source wants to invoke a command it will have to create its own 'tokenizer' as well 🤷‍♂️

@mwritter
mwritter requested a review from jonahsnider July 27, 2026 14:25
@mwritter
mwritter marked this pull request as ready for review July 27, 2026 14:43
@mwritter
mwritter requested a review from a team as a code owner July 27, 2026 14:43

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ab297aa. Configure here.

function loadCliCommandConfig(): Promise<Config> {
// Resolves to the package root from both src/exports (dev) and dist/exports (built)
return Config.load(fileURLToPath(new URL('../..', import.meta.url)))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Wrong package root for Config.load

High Severity

loadCliCommandConfig resolves ../.. from invokeSanityCli/index, which lands on src/ or dist/ rather than the package root. The comment assumes a file under exports/, but this module sits one level deeper, so default invocations (no config override) point oclif at a directory without package.json / oclif.config.js.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ab297aa. Configure here.

}
} finally {
process.exitCode = previousExitCode
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Exit code race under concurrency

Medium Severity

Saving and restoring process.exitCode around each run is not safe under concurrent invocations. One call can observe another’s temporary exit code and later “restore” that non-zero value onto the host process after the other call has already cleaned up.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ab297aa. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants