Skip to content
Merged
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
10 changes: 10 additions & 0 deletions rust/crates/sift_cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

### What's New

- Added an `--allow-create` MCP access tier alongside `--allow-destructive`.
The server now recognizes three tiers: read-only (default), create (enables
`create_*`, `upload_dataset`, `append_test_measurements`), and destructive
(implies create; enables `update_*`, `archive_*`, `unarchive_*`). The flag is
available on `sift-cli mcp`, `sift-cli agent install`, and
`sift-cli agent update`; blocked calls now name the exact remediation command
for the tier they need.

## [v0.4.0] - August 5, 2026

### What's New
Expand Down
34 changes: 29 additions & 5 deletions rust/crates/sift_cli/assets/skills/sift/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ exists.
chart and numbers, do both and give the user both.
- **Answer a question about how Sift works.** Call `search_docs`. Do not answer
from memory, and cite the page you used.
- **Create an asset.** There is no `create_asset` MCP tool because Sift creates
assets implicitly on ingest. The MCP path is `upload_dataset` with the target
asset name; the asset is registered as a side effect. `upload_dataset` is a
create tool, so it is gated by `--allow-create`. If the server is read-only,
DO NOT reach for the REST `CreateAsset` RPC, `sift-cli import`, `sift_stream`,
or a gRPC client to make the asset another way. Surface the block: name the
gated tool and the exact `sift-cli agent update --allow-create` command, and
wait for the user to widen access.
- **Evaluate rules against a run.** Find rules with `list_rules` and author rules
with `create_rule`. To reuse the same rule set across many runs, bundle
standard rules (`is_external: false`) into a template with `create_report_template`,
Expand All @@ -98,11 +106,27 @@ exists.
URL that a tool did not return.
- **Confirm every write before you run it.** Show the user the proposed change
and its target, then wait for approval.
- **Destructive tools are off by default.** `update_*`, `archive_*`, and
`unarchive_*` need `--allow-destructive`. If one is blocked, tell the user
that this access is disabled by default and ask for explicit approval. Never
enable it silently. The procedure is in
[references/agent-setup.md](references/agent-setup.md).
- **Write tools are off by default.** Read-only is the default access mode.
`create_*`, `upload_dataset`, and `append_test_measurements` need
`--allow-create`. `update_*`, `archive_*`, and `unarchive_*` need
`--allow-destructive` (which implies create). If a call is blocked, tell the
user that this access is disabled by default and ask for explicit approval.
Never widen access silently.
- **A blocked MCP tool is a user policy signal, not a transport error.** If
the MCP gate blocks a write, do NOT route around it — do not shell to
`sift-cli import`, `curl` against the REST API, `sift_client` Python, or
another MCP server that happens to be in destructive mode. Surface the
block and the exact remediation command; wait for the user to widen access.
The procedure is in [references/agent-setup.md](references/agent-setup.md).
- **"No dedicated MCP tool" is not the same as "can't do this in MCP".**
Several creates happen as side effects of other MCP tools — an asset is
created when `upload_dataset` names one that doesn't exist, a run is
created when a `create_report`/`create_test_report`/`upload_dataset` names
one. When the user asks for a create with no matching `create_*` tool,
look for the tool that creates it as a side effect before falling out of
MCP. If that side-effect tool is gated and blocked, that IS the block —
surface it, do not treat "no `create_asset` tool" as license to shell out
to REST/gRPC/`sift-cli import`.
- **Choose one profile for the session and keep it.** Never switch profiles to
recover from a failure. Surface the failure and ask the user.

Expand Down
57 changes: 45 additions & 12 deletions rust/crates/sift_cli/assets/skills/sift/references/agent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,29 +23,62 @@ skill:
pass it as `sift-cli agent install --profile <name>`. Run the command when the
user asks you to install or approves the change.
- Run `sift-cli agent update` to refresh every detected client together. It
preserves the existing profile and read-only or destructive access mode.
Switch every client to another named profile with
`sift-cli agent update --profile <name>`, or return them to the default with
`sift-cli agent update --default-profile`.
preserves the existing profile and access mode. Switch every client to another
named profile with `sift-cli agent update --profile <name>`, or return them to
the default with `sift-cli agent update --default-profile`.
- If the CLI is outdated, relay the exact curl or PowerShell installer printed
by `agent doctor` or `agent update`. After the user updates `sift-cli`, rerun
`sift-cli agent update`.
- Never repair or update only one detected client. If doctor reports mixed
access modes, ask the user to choose `sift-cli agent update --read-only` or
`sift-cli agent update --allow-destructive`. If it reports mixed profiles,
access modes, ask the user to choose `sift-cli agent update --read-only`,
`--allow-create`, or `--allow-destructive`. If it reports mixed profiles,
ask for the intended profile and use `sift-cli agent update --profile <name>`
or `sift-cli agent update --default-profile`.

## Enabling destructive tools
## Access modes

Destructive tools (`update_*`, `archive_*`, `unarchive_*`) are gated on
`--allow-destructive` and disabled by default. When a call is blocked:
Three tiers, each a superset of the last. All are opt-in per detected client
via `sift-cli agent update`:

1. Explain to the user that this access is disabled by default.
- **Read-only** (default) — no writes. Any create or destructive tool call
returns a structured error naming the remediation command.
- **Create** — enables `create_*`, `upload_dataset`, and `append_test_measurements`
(additive writes that don't modify existing data). Enable with
`sift-cli agent update --allow-create`.
- **Destructive** — enables everything, including `update_*`, `archive_*`, and
`unarchive_*`. Enable with `sift-cli agent update --allow-destructive`.
Implies create.

When a call is blocked:

1. Explain to the user which tier is required and that it's disabled by default.
2. Ask for explicit approval to enable it across every detected client.
3. Only after approval, run `sift-cli agent update --allow-destructive`.
3. Only after approval, run the exact command in the tool's error message
(`sift-cli agent update --allow-create` or `--allow-destructive`).
4. Ask the user to reload or restart the MCP client.
5. Wait for the user to confirm the restart before you retry the call.

Never enable destructive access silently. Restore safe mode with
Never widen access silently. Restore safe mode with
`sift-cli agent update --read-only`.

## Access modes are the ceiling for the whole session

The MCP access tier is a policy signal for what the user has authorized, not
just an MCP-transport gate. If the tier blocks a write, DO NOT accomplish the
same write another way:

- Do not shell to `sift-cli import`, `curl` against the Sift REST API, or
`sift_client` Python to route around a blocked MCP tool.
- Do not use one MCP server to make a write another MCP server would block
(e.g. `sift-dev` in destructive mode is not a bypass for `sift` in read-only).
- Do not compose reads and side-effects to imitate a write (creating an
annotation with a "create" verb is a create, whichever path runs it).

Treat a blocked MCP tool the same way you would treat any other explicit user
denial: surface the block, name the exact remediation command from the error
payload, and wait for the user to widen access before retrying. The whole
point of read-only and create tiers is to let a user register the agent with
narrow authority; a fallback path that ignores the tier is a foot-gun that
defeats the setting.

Reads work everywhere at every tier. This rule is only about writes.
6 changes: 6 additions & 0 deletions rust/crates/sift_cli/assets/skills/sift/references/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ per session. The rest apply to each subcommand invocation.
(imports, config changes), surface the final proposed command and the
target (asset, run, profile) to the user and wait for approval before
running.
**Never use the CLI to route around a blocked MCP write.** If an MCP
tool returned a "disabled by default" error naming an
`sift-cli agent update --allow-...` command, honor that block. Do not
shell to `sift-cli import`, `curl`, or a Python client to accomplish the
same operation. Surface the block per [references/agent-setup.md](agent-setup.md)
and wait for the user to widen access first.
5. **Use absolute paths.** Pass absolute paths for any file argument so
the command does not depend on the shell's current directory.
6. **For imports, pass `--wait`.** With `--wait` the CLI blocks until the
Expand Down
26 changes: 21 additions & 5 deletions rust/crates/sift_cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,15 @@ pub enum Cmd {

#[derive(clap::Args)]
pub struct McpArgs {
/// Expose create tools (creates, ingest, appends). When omitted, create
/// tool calls return an error instructing the caller to relaunch the
/// server with this flag. Implied by `--allow-destructive`.
#[arg(long)]
pub allow_create: bool,

/// Expose destructive tools (updates, archives, restores). When omitted,
/// destructive tool calls return an error instructing the caller to
/// relaunch the server with this flag.
/// relaunch the server with this flag. Also enables create tools.
#[arg(long)]
pub allow_destructive: bool,
}
Expand Down Expand Up @@ -115,19 +121,29 @@ pub enum AgentCmd {

#[derive(clap::Args)]
pub struct AgentInstallArgs {
/// Enable tools that modify or archive resources for every detected MCP client
/// Enable tools that create new resources for every detected MCP client
#[arg(long)]
pub allow_create: bool,

/// Enable tools that modify or archive resources for every detected MCP client.
/// Also enables create tools.
#[arg(long)]
pub allow_destructive: bool,
}

#[derive(clap::Args)]
pub struct AgentUpdateArgs {
/// Enable tools that modify or archive resources for every detected MCP client
/// Enable tools that create new resources for every detected MCP client
#[arg(long, conflicts_with = "read_only")]
pub allow_create: bool,

/// Enable tools that modify or archive resources for every detected MCP client.
/// Also enables create tools.
#[arg(long, conflicts_with = "read_only")]
pub allow_destructive: bool,

/// Disable destructive tools for every detected MCP client
#[arg(long, conflicts_with = "allow_destructive")]
/// Disable create and destructive tools for every detected MCP client
#[arg(long, conflicts_with_all = ["allow_create", "allow_destructive"])]
pub read_only: bool,

/// Switch every detected MCP client back to the default profile
Expand Down
36 changes: 23 additions & 13 deletions rust/crates/sift_cli/src/cmd/agent/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,8 +418,10 @@ fn mcp_args(registration: &Registration) -> Vec<String> {
args.push("--profile".to_string());
args.push(profile.clone());
}
if registration.access == AccessMode::Destructive {
args.push("--allow-destructive".to_string());
match registration.access {
AccessMode::ReadOnly => {}
AccessMode::Create => args.push("--allow-create".to_string()),
AccessMode::Destructive => args.push("--allow-destructive".to_string()),
}
args
}
Expand Down Expand Up @@ -582,8 +584,8 @@ fn classify_command(command: &str, args: &[String], environment: &Environment) -
fn registration_from_args(args: &[String]) -> Option<Registration> {
match args {
[mcp] if mcp == "mcp" => Some(Registration::new(AccessMode::ReadOnly, Profile::Default)),
[mcp, destructive] if mcp == "mcp" && destructive == "--allow-destructive" => {
Some(Registration::new(AccessMode::Destructive, Profile::Default))
[mcp, access] if mcp == "mcp" => {
access_from_flag(access).map(|a| Registration::new(a, Profile::Default))
}
[mcp, profile_flag, profile]
if mcp == "mcp" && profile_flag == "--profile" && valid_profile(profile) =>
Expand All @@ -593,21 +595,23 @@ fn registration_from_args(args: &[String]) -> Option<Registration> {
Profile::Named(profile.clone()),
))
}
[mcp, profile_flag, profile, destructive]
if mcp == "mcp"
&& profile_flag == "--profile"
&& valid_profile(profile)
&& destructive == "--allow-destructive" =>
[mcp, profile_flag, profile, access]
if mcp == "mcp" && profile_flag == "--profile" && valid_profile(profile) =>
{
Some(Registration::new(
AccessMode::Destructive,
Profile::Named(profile.clone()),
))
access_from_flag(access).map(|a| Registration::new(a, Profile::Named(profile.clone())))
}
_ => None,
}
}

fn access_from_flag(flag: &str) -> Option<AccessMode> {
match flag {
"--allow-create" => Some(AccessMode::Create),
"--allow-destructive" => Some(AccessMode::Destructive),
_ => None,
}
}

fn valid_profile(profile: &str) -> bool {
!profile.is_empty() && !profile.starts_with('-')
}
Expand Down Expand Up @@ -940,8 +944,13 @@ mod tests {
#[test]
fn only_exact_sift_mcp_argument_shapes_are_managed() {
assert!(registration_from_args(&args(&["mcp"])).is_some());
assert!(registration_from_args(&args(&["mcp", "--allow-create"])).is_some());
assert!(registration_from_args(&args(&["mcp", "--allow-destructive"])).is_some());
assert!(registration_from_args(&args(&["mcp", "--profile", "localdev"])).is_some());
assert!(
registration_from_args(&args(&["mcp", "--profile", "localdev", "--allow-create",]))
.is_some()
);
assert!(
registration_from_args(&args(&[
"mcp",
Expand All @@ -957,6 +966,7 @@ mod tests {
args(&["mcp", "--profile"]),
args(&["mcp", "--profile", "--allow-destructive"]),
args(&["mcp", "--allow-destructive", "--profile", "localdev"]),
args(&["mcp", "--allow-create", "--profile", "localdev"]),
args(&["mcp", "--profile", "localdev", "--extra"]),
] {
assert_eq!(registration_from_args(&custom), None);
Expand Down
10 changes: 9 additions & 1 deletion rust/crates/sift_cli/src/cmd/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ fn warning_status() -> StyledContent<&'static str> {
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(super) enum AccessMode {
ReadOnly,
Create,
Destructive,
}

impl AccessMode {
fn label(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::Create => "create tools enabled",
Self::Destructive => "destructive tools enabled",
}
}
Expand Down Expand Up @@ -201,6 +203,8 @@ impl Environment {
pub fn install(profile: Option<String>, args: AgentInstallArgs) -> Result<ExitCode> {
let access = if args.allow_destructive {
AccessMode::Destructive
} else if args.allow_create {
AccessMode::Create
} else {
AccessMode::ReadOnly
};
Expand Down Expand Up @@ -231,6 +235,8 @@ pub async fn update(profile: Option<String>, args: AgentUpdateArgs) -> Result<Ex
let inference = infer_registration(&environment)?;
let requested_access = if args.allow_destructive {
Some(AccessMode::Destructive)
} else if args.allow_create {
Some(AccessMode::Create)
} else if args.read_only {
Some(AccessMode::ReadOnly)
} else {
Expand All @@ -249,7 +255,9 @@ pub async fn update(profile: Option<String>, args: AgentUpdateArgs) -> Result<Ex
if unresolved_access || unresolved_profile {
println!("No changes were made because detected MCP clients are not configured uniformly.");
if unresolved_access {
println!("- Access modes are mixed. Choose `--allow-destructive` or `--read-only`.");
println!(
"- Access modes are mixed. Choose `--allow-destructive`, `--allow-create`, or `--read-only`."
);
}
if unresolved_profile {
println!("- Profiles are mixed. Choose `--profile <name>` or `--default-profile`.");
Expand Down
10 changes: 10 additions & 0 deletions rust/crates/sift_cli/src/cmd/agent/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,16 @@ fn update_access_flags_are_mutually_exclusive() {
])
.is_err()
);
assert!(
crate::cli::Args::try_parse_from([
"sift-cli",
"agent",
"update",
"--allow-create",
"--read-only",
])
.is_err()
);
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions rust/crates/sift_cli/src/cmd/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub async fn run(ctx: Context, args: McpArgs, app_uri: String) -> Result<ExitCod
credentials,
!ctx.disable_tls,
app_uri,
args.allow_create,
args.allow_destructive,
)
.await
Expand Down
Loading
Loading