feat(cli): §3 M3 — per-env approval gating ([project.environments])

A `[project.environments]` block maps an env name to `{ confirm = bool }`
(`ManifestProject.environments`, client-side only — `skip_serializing`).
`pic apply --env <e>` is refused before any request when `<e>` is
confirm-required unless it is explicitly `--approve <e>`d; a blanket `--yes`
does NOT cover a gated env (§4.2 "CI must opt in per environment"); an unlisted
or `confirm = false` env applies freely. `require_env_approval` gates both the
single (`run`) and tree (`run_tree`) apply paths; `--approve <env>` is a
repeatable flag. Per-env value overlays (`picloud.<env>.toml`) already merge
client-side, so this is the confirm-policy gate only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 20:28:28 +02:00
parent b8eced9d91
commit 62710b5d81
3 changed files with 53 additions and 1 deletions

View File

@@ -11,9 +11,10 @@ use anyhow::{Context, Result};
use crate::client::{Client, NodeKind};
use crate::cmds::plan::build_bundle;
use crate::config;
use crate::manifest::Manifest;
use crate::manifest::{Manifest, ManifestProject};
use crate::output::{KvBlock, OutputMode};
#[allow(clippy::too_many_arguments)]
pub async fn run(
manifest_path: &Path,
env: Option<&str>,
@@ -21,12 +22,14 @@ pub async fn run(
yes: bool,
force: bool,
takeover: bool,
approve: &[String],
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let manifest = Manifest::load_with_env(manifest_path, env)?;
require_env_approval(manifest.project.as_ref(), env, approve)?;
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
let kind = if manifest.is_group() {
@@ -132,12 +135,14 @@ pub async fn run_tree(
force: bool,
takeover: bool,
structure_mode: &str,
approve: &[String],
env: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let (bundle, node_count, project) = crate::discover::build_tree(dir, env)?;
require_env_approval(project.as_ref(), env, approve)?;
if prune && !yes {
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
@@ -224,6 +229,31 @@ pub async fn run_tree(
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
/// rather than silently deleting (review the deletions first with `pic plan`).
/// §3 M3: refuse `pic apply --env <e>` to a confirm-required env
/// (`[project.environments]` with `confirm = true`) unless it was explicitly
/// `--approve <e>`d. A blanket `--yes` does NOT cover a gated env (§4.2 "CI must
/// opt in per environment"). An unlisted or `confirm = false` env applies freely.
fn require_env_approval(
project: Option<&ManifestProject>,
env: Option<&str>,
approve: &[String],
) -> Result<()> {
let (Some(env), Some(project)) = (env, project) else {
return Ok(());
};
let Some(policy) = project.environments.get(env) else {
return Ok(());
};
if policy.confirm && !approve.iter().any(|a| a == env) {
anyhow::bail!(
"environment `{env}` is confirm-required (`[project.environments]`); \
re-run with `--approve {env}` to apply to it — a blanket `--yes` does \
not cover a gated environment"
);
}
Ok(())
}
fn confirm_prune(slug: &str) -> Result<()> {
if !std::io::stdin().is_terminal() {
anyhow::bail!(

View File

@@ -269,6 +269,10 @@ struct ApplyArgs {
/// divergence and reconcile content in place (no reparent).
#[arg(long)]
adopt_server_structure: bool,
/// §3 M3: approve applying to a confirm-required environment
/// (`[project.environments]`). Repeatable; each must match the `--env`.
#[arg(long = "approve")]
approve: Vec<String>,
}
#[derive(Args)]
@@ -1455,6 +1459,7 @@ async fn main() -> ExitCode {
args.force,
args.takeover,
structure_mode,
&args.approve,
args.env.as_deref(),
mode,
)
@@ -1468,6 +1473,7 @@ async fn main() -> ExitCode {
args.yes,
args.force,
args.takeover,
&args.approve,
mode,
)
.await

View File

@@ -283,6 +283,22 @@ pub struct ManifestProject {
/// that subtree. Absent = instance root = no ceiling (the default).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_group: Option<String>,
/// §3 M3 per-env approval policy: `[project.environments]` maps an env name
/// to its policy. A `confirm = true` env requires an explicit `--approve
/// <env>` on `pic apply --env <env>` (a blanket `--yes` does NOT cover it —
/// §4.2 "CI must opt in per environment"). Client-side gating only; never
/// sent to the server.
#[serde(default, skip_serializing)]
pub environments: std::collections::BTreeMap<String, EnvPolicy>,
}
/// §3 M3: the apply-gating policy for one environment.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnvPolicy {
/// Require an explicit `--approve <env>` to apply to this env.
#[serde(default)]
pub confirm: bool,
}
/// The store kind of a shared collection (§11.6).