diff --git a/.changeset/safety-rules-and-confirm.md b/.changeset/safety-rules-and-confirm.md new file mode 100644 index 00000000..a79ce8ef --- /dev/null +++ b/.changeset/safety-rules-and-confirm.md @@ -0,0 +1,7 @@ +--- +'@salesforce/b2c-tooling-sdk': minor +'@salesforce/b2c-cli': minor +'@salesforce/b2c-dx-docs': patch +--- + +Added per-instance safety configuration with rule-based actions (allow/block/confirm) and interactive confirmation mode. Safety can now be configured in `dw.json` with granular rules for HTTP paths, job IDs, and CLI commands. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 8d046cd6..73cd3afe 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -49,6 +49,7 @@ const guidesSidebar = [ {text: 'Analytics Reports (CIP/CCAC)', link: '/guide/analytics-reports-cip-ccac'}, {text: 'IDE Integration', link: '/guide/ide-integration'}, {text: 'Scaffolding', link: '/guide/scaffolding'}, + {text: 'Safety Mode', link: '/guide/safety'}, {text: 'Security', link: '/guide/security'}, {text: 'Storefront Next', link: '/guide/storefront-next'}, {text: 'MRT Utilities', link: '/guide/mrt-utilities'}, diff --git a/docs/cli/index.md b/docs/cli/index.md index e720e5f2..229ab0fc 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -51,7 +51,7 @@ b2c sandbox list # ✅ Allowed b2c sandbox create --realm test # ❌ Blocked ``` -Safety Mode operates at the HTTP layer and cannot be bypassed by command-line flags. See the [Security Guide](/guide/security#operational-security-safety-mode) for detailed information. +Safety Mode operates at the HTTP layer and cannot be bypassed by command-line flags. See the [Safety Mode](/guide/safety) guide for detailed information. ### Other Environment Variables diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 5c7ddee2..c4c4fc9d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -84,7 +84,9 @@ You can configure the CLI using environment variables: | `MRT_PROJECT` | MRT project slug (`SFCC_MRT_PROJECT` also supported) | | `MRT_ENVIRONMENT` | MRT environment name (`SFCC_MRT_ENVIRONMENT`, `MRT_TARGET` also supported) | | `MRT_CLOUD_ORIGIN` | MRT API origin URL override (`SFCC_MRT_CLOUD_ORIGIN` also supported) | -| `SFCC_SAFETY_LEVEL` | Safety mode: `NONE`, `NO_DELETE`, `NO_UPDATE`, `READ_ONLY` (see [Safety Mode](/guide/security#operational-security-safety-mode)) | +| `SFCC_SAFETY_LEVEL` | Safety mode: `NONE`, `NO_DELETE`, `NO_UPDATE`, `READ_ONLY` (see [Safety Mode](/guide/safety)) | +| `SFCC_SAFETY_CONFIRM` | Enable confirmation mode for safety: `true` or `1` (see [Safety Mode](/guide/safety#confirmation-mode)) | +| `SFCC_SAFETY_CONFIG` | Path to global safety config file (see [Safety Mode](/guide/safety#global-safety-config)) | ## .env File @@ -150,6 +152,8 @@ For projects that work with multiple instances, use the `configs` array: } ``` +Each instance can have its own `safety` configuration for per-instance operational safety. See [Safety Mode](/guide/safety#per-instance-configuration) for details. + Use the `-i` or `--instance` flag to select a specific configuration: ```bash diff --git a/docs/guide/safety.md b/docs/guide/safety.md new file mode 100644 index 00000000..397720f0 --- /dev/null +++ b/docs/guide/safety.md @@ -0,0 +1,305 @@ +--- +description: Configure Safety Mode to prevent accidental destructive operations with safety levels, per-instance rules, confirmation mode, and global policies. +--- + +# Safety Mode + +The CLI and SDK include a **Safety Mode** feature that prevents accidental or unwanted destructive operations via HTTP middleware and command-level checks. This is particularly important when: + +- Providing the CLI as a tool to AI agents/LLMs +- Working in production environments +- Training new team members +- Running commands from untrusted scripts + +## Quick Start + +Set an environment variable to enable safety for all operations: + +```bash +export SFCC_SAFETY_LEVEL=NO_DELETE +``` + +Or configure per-instance in `dw.json`: + +```json +{ + "hostname": "prod.example.com", + "safety": { + "level": "NO_UPDATE", + "confirm": true + } +} +``` + +## Safety Levels + +Safety levels provide broad protection by category: + +| Level | Description | Blocks | +|-------|-------------|--------| +| `NONE` | No restrictions (default) | Nothing | +| `NO_DELETE` | Prevent deletions | DELETE operations | +| `NO_UPDATE` | Prevent deletions and destructive updates | DELETE + reset/stop/restart | +| `READ_ONLY` | Read-only mode | All writes (POST/PUT/PATCH/DELETE) | + +Levels apply to all HTTP requests made through the SDK. They are enforced by middleware, so they work regardless of which SDK method or CLI command initiates the request. + +### Setting a Level + +**Environment variable** (recommended for AI agent environments): + +```bash +export SFCC_SAFETY_LEVEL=NO_UPDATE +``` + +Environment variables are preferred over command-line flags because LLMs control commands and flags, but not the environment. + +**Per-instance in `dw.json`**: + +```json +{ + "hostname": "prod.example.com", + "safety": { + "level": "READ_ONLY" + } +} +``` + +**Global config** (see [Global Safety Config](#global-safety-config)): + +```json +{ + "level": "NO_DELETE" +} +``` + +When multiple sources set a level, the **most restrictive** wins. + +## Safety Rules + +Rules provide granular control over specific operations. Each rule matches an operation and specifies an action (`allow`, `block`, or `confirm`). Rules are evaluated in order -- the first matching rule wins. + +### Rule Actions + +| Action | Behavior | +|--------|----------| +| `allow` | Operation is permitted -- overrides level restrictions | +| `block` | Operation is refused | +| `confirm` | Operation requires interactive confirmation before proceeding | + +### Rule Matchers + +Rules support three matcher types. All patterns use glob syntax (via [minimatch](https://github.com/isaacs/minimatch)). + +#### HTTP Method + Path + +Matches HTTP requests by method and URL path. Use this for fine-grained control over API endpoints: + +```json +{ "method": "DELETE", "path": "/code_versions/*", "action": "block" } +``` + +`method` and `path` can be used independently or together. When both are specified, both must match. + +#### Job ID + +Matches OCAPI job execution by job ID. This catches both direct job commands and the underlying HTTP requests: + +```json +{ "job": "sfcc-site-archive-import", "action": "block" } +{ "job": "sfcc-site-archive-*", "action": "confirm" } +``` + +#### CLI Command ID + +Matches CLI commands by their oclif command ID. Command rules are enforced automatically for **every** command before `run()` executes -- no per-command opt-in is needed: + +```json +{ "command": "sandbox:delete", "action": "confirm" } +{ "command": "sandbox:*", "action": "block" } +{ "command": "code:deploy", "action": "block" } +``` + +### Evaluation Order + +1. **Rules** are checked in order. The first matching rule's action wins. + - An `allow` rule overrides even the strictest safety level -- it represents a deliberate user choice. + - A `block` rule blocks even if the level would allow. + - A `confirm` rule requires interactive confirmation. +2. If no rule matches, the **level** determines the outcome: + - If the level blocks the operation and `confirm: true` is set, confirmation is required instead of a hard block. + - If the level blocks, the operation is blocked. + - Otherwise, the operation is allowed. + +## Confirmation Mode + +When `confirm: true` is set, operations that would be blocked by the safety level are softened to require interactive confirmation instead of being refused outright: + +```json +{ + "hostname": "staging.example.com", + "safety": { + "level": "NO_DELETE", + "confirm": true + } +} +``` + +You can also enable confirmation mode via the `SFCC_SAFETY_CONFIRM` environment variable: + +```bash +export SFCC_SAFETY_CONFIRM=true +``` + +::: warning Non-Interactive Environments +In non-interactive environments (MCP server, piped stdin, CI/CD), confirmation is not possible. Operations that require confirmation will be **blocked** instead. +::: + +## Per-Instance Configuration + +Configure safety per instance in `dw.json` using the `safety` object. This is especially useful in multi-instance configurations where different instances have different risk profiles: + +```json +{ + "configs": [ + { + "name": "dev", + "hostname": "dev.example.com", + "safety": { "level": "NONE" } + }, + { + "name": "staging", + "hostname": "staging.example.com", + "safety": { + "level": "NO_DELETE", + "confirm": true, + "rules": [ + { "job": "sfcc-site-archive-export", "action": "allow" } + ] + } + }, + { + "name": "production", + "hostname": "prod.example.com", + "safety": { "level": "READ_ONLY" } + } + ] +} +``` + +## Global Safety Config + +Safety can be configured globally (across all projects and instances) using a `safety.json` file in the CLI's config directory. + +| Platform | Default Location | +|----------|-----------------| +| macOS | `~/Library/Application Support/@salesforce/b2c-cli/safety.json` | +| Linux | `~/.config/b2c/safety.json` (or `$XDG_CONFIG_HOME`) | +| Windows | `%LOCALAPPDATA%\@salesforce\b2c-cli\safety.json` | + +Override the file location with the `SFCC_SAFETY_CONFIG` environment variable: + +```bash +export SFCC_SAFETY_CONFIG=/path/to/safety.json +``` + +The file has the same shape as the `safety` object in `dw.json`: + +```json +{ + "level": "NO_DELETE", + "confirm": true, + "rules": [ + { "job": "sfcc-site-archive-import", "action": "confirm" }, + { "command": "sandbox:delete", "action": "block" } + ] +} +``` + +This is useful for enforcing baseline safety policies -- for example, when providing the CLI as a tool to AI agents. + +## Configuration Merge + +Safety configuration is merged from three sources (all optional): + +| Source | Sets | +|--------|------| +| Environment variables (`SFCC_SAFETY_LEVEL`, `SFCC_SAFETY_CONFIRM`) | Level, confirm | +| Per-instance `dw.json` `safety` object | Level, confirm, rules | +| Global `safety.json` | Level, confirm, rules | + +The merge strategy: + +- **Level**: most restrictive wins across all sources +- **Confirm**: enabled if **any** source enables it +- **Rules**: instance rules are checked first, then global rules. Since evaluation is first-match-wins, instance rules can override global policy. +- **Explicit `allow` rules always win.** They represent a deliberate user choice and override any level restriction. + +### Example + +Given this global config: + +```json +{ + "level": "NO_UPDATE", + "rules": [ + { "job": "sfcc-site-archive-*", "action": "block" } + ] +} +``` + +And this instance config: + +```json +{ + "safety": { + "rules": [ + { "job": "sfcc-site-archive-export", "action": "allow" } + ] + } +} +``` + +The result: +- Level is `NO_UPDATE` (from global) +- Export jobs are **allowed** (instance rule matches first, overriding the global block) +- Import jobs are **blocked** (falls through to the global rule) +- DELETE requests are **blocked** (level `NO_UPDATE` blocks destructive operations) + +## Environment Variables Reference + +| Variable | Description | +|----------|-------------| +| `SFCC_SAFETY_LEVEL` | Safety level: `NONE`, `NO_DELETE`, `NO_UPDATE`, `READ_ONLY` | +| `SFCC_SAFETY_CONFIRM` | Enable confirmation mode: `true` or `1` | +| `SFCC_SAFETY_CONFIG` | Path to global safety config file | + +## SDK Usage + +The safety system is available to SDK consumers via the `SafetyGuard` class: + +```typescript +import { SafetyGuard, resolveEffectiveSafetyConfig, withSafetyConfirmation } from '@salesforce/b2c-tooling-sdk'; + +// Create a guard from config +const guard = new SafetyGuard({ + level: 'NO_UPDATE', + rules: [{ job: 'sfcc-site-archive-export', action: 'allow' }], +}); + +// Evaluate an operation +const evaluation = guard.evaluate({ type: 'job', jobId: 'sfcc-site-archive-export' }); +// evaluation.action === 'allow' + +// Assert (throws SafetyBlockedError or SafetyConfirmationRequired) +guard.assert({ type: 'http', method: 'DELETE', path: '/items/1' }); + +// Confirmation flow with retry +const result = await withSafetyConfirmation( + guard, + () => doSomethingDangerous(), + async (eval) => promptUser(`Safety: ${eval.reason}. Proceed?`), +); +``` + +The HTTP middleware uses `SafetyGuard` internally, so all HTTP requests through SDK clients are evaluated automatically. CLI commands and other consumers can use the guard directly for richer safety interaction. diff --git a/docs/guide/security.md b/docs/guide/security.md index 38233927..2c2c0ef2 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -66,41 +66,9 @@ This project uses [NPM trusted publishers](https://docs.npmjs.com/trusted-publis ## Operational Security: Safety Mode -The CLI includes a **Safety Mode** feature via CLI checks and HTTP middleware that prevents accidental or unwanted destructive operations. This is particularly important when: +The CLI includes a **Safety Mode** feature that prevents accidental or unwanted destructive operations via HTTP middleware and command-level checks. Safety mode supports configurable levels, per-instance and global rules, and interactive confirmation. -- Providing the CLI as a tool to AI agents/LLMs -- Working in production environments -- Training new team members -- Running commands from untrusted scripts - -### Safety Levels - -Configure via the `SFCC_SAFETY_LEVEL` environment variable: - -| Level | Description | Blocks | -|-------|-------------|--------| -| `NONE` | No restrictions (default) | Nothing | -| `NO_DELETE` | Prevent deletions | DELETE operations | -| `NO_UPDATE` | Prevent deletions and destructive updates | DELETE + reset/stop/restart | -| `READ_ONLY` | Read-only mode | All writes (POST/PUT/PATCH/DELETE) | - -### Usage - -```bash -# Default - no restrictions -export SFCC_SAFETY_LEVEL=NONE - -# Prevent deletions -export SFCC_SAFETY_LEVEL=NO_DELETE - -# Prevent deletions and destructive updates -export SFCC_SAFETY_LEVEL=NO_UPDATE - -# Read-only mode -export SFCC_SAFETY_LEVEL=READ_ONLY -``` - -Environment variables are used instead of command-line flags because LLMs control commands and flags, but not the environment. +See the **[Safety Mode](/guide/safety)** guide for full documentation. ## Best Practices diff --git a/packages/b2c-cli/src/commands/code/deploy.ts b/packages/b2c-cli/src/commands/code/deploy.ts index 13f99a42..1254df7b 100644 --- a/packages/b2c-cli/src/commands/code/deploy.ts +++ b/packages/b2c-cli/src/commands/code/deploy.ts @@ -161,6 +161,14 @@ export default class CodeDeploy extends CartridgeCommand { this.log(` ${c.name} (${c.src})`); } + // After safety evaluation passes, temporarily allow WebDAV DELETE operations + // that are part of the deploy flow (cleanup of temp zip, --delete cartridge removal). + const cleanupSafetyRule = this.safetyGuard.temporarilyAddRule({ + method: 'DELETE', + path: '**/Cartridges/**', + action: 'allow', + }); + try { // Optionally delete existing cartridges first if (this.flags.delete) { @@ -240,6 +248,8 @@ export default class CodeDeploy extends CartridgeCommand { this.error(t('commands.code.deploy.failed', 'Deployment failed: {{message}}', {message: error.message})); } throw error; + } finally { + cleanupSafetyRule(); } } } diff --git a/packages/b2c-cli/src/commands/code/watch.ts b/packages/b2c-cli/src/commands/code/watch.ts index d5876053..e95ba527 100644 --- a/packages/b2c-cli/src/commands/code/watch.ts +++ b/packages/b2c-cli/src/commands/code/watch.ts @@ -56,6 +56,14 @@ export default class CodeWatch extends CartridgeCommand { this.log(t('commands.code.watch.codeVersion', 'Code Version: {{version}}', {version})); } + // Temporarily allow WebDAV DELETE on Cartridges paths for the watch lifecycle. + // The watcher DELETEs temp zip files after upload and syncs local file deletions. + const cleanupSafetyRule = this.safetyGuard.temporarilyAddRule({ + method: 'DELETE', + path: '**/Cartridges/**', + action: 'allow', + }); + try { const result = await this.operations.watchCartridges(this.instance, this.cartridgePath, { ...this.cartridgeOptions, @@ -92,6 +100,8 @@ export default class CodeWatch extends CartridgeCommand { this.error(t('commands.code.watch.failed', 'Watch failed: {{message}}', {message: error.message})); } throw error; + } finally { + cleanupSafetyRule(); } } } diff --git a/packages/b2c-cli/src/commands/job/export.ts b/packages/b2c-cli/src/commands/job/export.ts index 40f70a0e..958fa329 100644 --- a/packages/b2c-cli/src/commands/job/export.ts +++ b/packages/b2c-cli/src/commands/job/export.ts @@ -129,6 +129,16 @@ export default class JobExport extends JobCommand { const hostname = this.resolvedConfig.values.hostname!; + // Safety evaluation — check rules for export job before executing. + // Command-level rules are already evaluated generically in BaseCommand.init(). + const jobEvaluation = this.safetyGuard.evaluate({type: 'job', jobId: 'sfcc-site-archive-export'}); + if (jobEvaluation.action === 'block') { + this.error(jobEvaluation.reason, {exit: 1}); + } + if (jobEvaluation.action === 'confirm') { + await this.confirmOrBlock(jobEvaluation); + } + // Build data units configuration const dataUnits = this.buildDataUnits({ dataUnitsJson, @@ -173,6 +183,15 @@ export default class JobExport extends JobCommand { } as unknown as SiteArchiveExportResult & {localPath?: string; archiveKept?: boolean}; } + // After safety evaluation passes, temporarily allow WebDAV operations + // that are part of the export flow (download GET, cleanup DELETE on Impex paths). + // Without this, the HTTP middleware would independently block the cleanup DELETE. + const cleanupSafetyRule = this.safetyGuard.temporarilyAddRule({ + method: 'DELETE', + path: '**/Impex/**', + action: 'allow', + }); + this.log( t('commands.job.export.exporting', 'Exporting data from {{hostname}}...', { hostname, @@ -263,6 +282,8 @@ export default class JobExport extends JobCommand { ); } throw error; + } finally { + cleanupSafetyRule(); } } diff --git a/packages/b2c-cli/src/commands/job/import.ts b/packages/b2c-cli/src/commands/job/import.ts index ea01f484..92450726 100644 --- a/packages/b2c-cli/src/commands/job/import.ts +++ b/packages/b2c-cli/src/commands/job/import.ts @@ -72,6 +72,16 @@ export default class JobImport extends JobCommand { const hostname = this.resolvedConfig.values.hostname!; + // Safety evaluation — check rules for import job before executing. + // Command-level rules are already evaluated generically in BaseCommand.init(). + const jobEvaluation = this.safetyGuard.evaluate({type: 'job', jobId: 'sfcc-site-archive-import'}); + if (jobEvaluation.action === 'block') { + this.error(jobEvaluation.reason, {exit: 1}); + } + if (jobEvaluation.action === 'confirm') { + await this.confirmOrBlock(jobEvaluation); + } + // Create lifecycle context const context = this.createContext('job:import', { target, @@ -95,6 +105,14 @@ export default class JobImport extends JobCommand { } as unknown as SiteArchiveImportResult; } + // After safety evaluation passes, temporarily allow WebDAV operations + // that are part of the import flow (upload PUT, cleanup DELETE on Impex paths). + const cleanupSafetyRule = this.safetyGuard.temporarilyAddRule({ + method: 'DELETE', + path: '**/Impex/**', + action: 'allow', + }); + if (remote) { this.log( t('commands.job.import.importingRemote', 'Importing {{target}} from {{hostname}}...', { @@ -182,6 +200,8 @@ export default class JobImport extends JobCommand { ); } throw error; + } finally { + cleanupSafetyRule(); } } } diff --git a/packages/b2c-cli/src/commands/job/run.ts b/packages/b2c-cli/src/commands/job/run.ts index 09ca7b52..961d7ffc 100644 --- a/packages/b2c-cli/src/commands/job/run.ts +++ b/packages/b2c-cli/src/commands/job/run.ts @@ -95,6 +95,16 @@ export default class JobRun extends JobCommand { 'show-log': showLog, } = this.flags; + // Safety evaluation — check rules for this job before executing. + // Command-level rules are already evaluated generically in BaseCommand.init(). + const jobEvaluation = this.safetyGuard.evaluate({type: 'job', jobId}); + if (jobEvaluation.action === 'block') { + this.error(jobEvaluation.reason, {exit: 1}); + } + if (jobEvaluation.action === 'confirm') { + await this.confirmOrBlock(jobEvaluation); + } + // Parse parameters or body const parameters = this.parseParameters(param || []); const rawBody = body ? this.parseBody(body) : undefined; diff --git a/packages/b2c-tooling-sdk/src/cli/base-command.ts b/packages/b2c-tooling-sdk/src/cli/base-command.ts index f86d778b..b9bb3587 100644 --- a/packages/b2c-tooling-sdk/src/cli/base-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/base-command.ts @@ -19,7 +19,15 @@ import type { import {setLanguage, t} from '../i18n/index.js'; import {configureLogger, getLogger, type LogLevel, type Logger} from '../logging/index.js'; import {createExtraParamsMiddleware, createSafetyMiddleware, type ExtraParamsConfig} from '../clients/middleware.js'; -import {getSafetyLevel, describeSafetyLevel} from '../safety/index.js'; +import { + getSafetyLevel, + describeSafetyLevel, + resolveEffectiveSafetyConfig, + loadGlobalSafetyConfig, +} from '../safety/index.js'; +import {SafetyGuard} from '../safety/safety-guard.js'; +import type {SafetyEvaluation} from '../safety/types.js'; +import {confirm as safetyConfirm} from '../ux/confirm.js'; import {globalConfigSourceRegistry} from '../config/config-source-registry.js'; import {globalMiddlewareRegistry} from '../clients/middleware-registry.js'; import {globalAuthMiddlewareRegistry} from '../auth/middleware.js'; @@ -129,6 +137,9 @@ export abstract class BaseCommand extends Command { protected resolvedConfig!: ResolvedB2CConfig; protected logger!: Logger; + /** Safety guard for evaluating operations against safety rules and levels. */ + protected safetyGuard: SafetyGuard = new SafetyGuard({level: 'NONE'}); + /** Telemetry instance for tracking command events */ protected telemetry?: Telemetry; @@ -166,8 +177,13 @@ export abstract class BaseCommand extends Command { // This must happen before any API clients are created this.registerExtraParamsMiddleware(); + // Initialize safety guard with env-var-only config (before config resolution). + // The guard is updated with the full config after loadConfiguration(). + this.safetyGuard = new SafetyGuard({level: getSafetyLevel('NONE')}); + // Register safety middleware FIRST (before any other middleware) - // This provides unbypassable protection at the HTTP layer + // The middleware reads this.safetyGuard lazily via closure, so it picks up + // the full config after initializeSafetyGuard() runs. this.registerSafetyMiddleware(); // Collect middleware from plugins before any API clients are created @@ -184,6 +200,13 @@ export abstract class BaseCommand extends Command { this.resolvedConfig = await this.loadConfiguration(); + // Update safety guard with config-provided safety settings (merges env + config) + this.initializeSafetyGuard(); + + // Evaluate command-level safety rules for every command. + // This enforces rules like { command: "code:deploy", action: "block" } generically. + await this.evaluateCommandSafety(); + this.addTelemetryContext(); } @@ -624,36 +647,32 @@ export abstract class BaseCommand extends Command { * ``` */ protected assertDestructiveOperationAllowed(operationDescription?: string): void { - const safetyLevel = getSafetyLevel('NONE'); + const evaluation = this.safetyGuard.evaluate({ + type: 'command', + commandId: this.id, + }); - if (safetyLevel === 'NONE') { - return; // No restrictions + if (evaluation.action === 'allow') { + return; } const operation = operationDescription || t('base.destructiveOperation', 'this destructive operation'); - // Determine if this operation should be blocked - // We assume all calls to this method are for destructive operations - // The safety level determines blocking: - // - READ_ONLY: blocks all writes - // - NO_DELETE: blocks deletes (we assume this method is called for deletes/destructive ops) - // - NO_UPDATE: blocks deletes and resets - const shouldBlock = safetyLevel === 'READ_ONLY' || safetyLevel === 'NO_DELETE' || safetyLevel === 'NO_UPDATE'; - - if (shouldBlock) { - return this.error( - t( - 'base.safetyModeBlocked', - 'Cannot {{operation}}: blocked by safety level {{safetyLevel}}.\n\n{{description}}\n\nTo allow this operation, unset or change the SFCC_SAFETY_LEVEL environment variable.\nSee: https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/security#operational-security-safety-mode', - { - operation, - safetyLevel, - description: describeSafetyLevel(safetyLevel), - }, - ), - {exit: 1}, - ); - } + // For both 'block' and 'confirm' at the assertion level, we block. + // Commands that want to support confirmation should call confirmOrBlock() separately. + const safetyLevel = this.safetyGuard.config.level; + return this.error( + t( + 'base.safetyModeBlocked', + 'Cannot {{operation}}: blocked by your safety configuration (level: {{safetyLevel}}).\n\n{{description}}\n\nTo change this, update the "safety" section in your dw.json or the SFCC_SAFETY_LEVEL environment variable.\nSee: https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/safety', + { + operation, + safetyLevel, + description: describeSafetyLevel(safetyLevel), + }, + ), + {exit: 1}, + ); } /** @@ -701,33 +720,92 @@ export abstract class BaseCommand extends Command { } /** - * Register safety middleware to block destructive operations. - * This provides unbypassable protection at the HTTP layer - cannot be circumvented - * by command-line flags since it operates on all HTTP requests. + * Register safety middleware that evaluates all HTTP requests against the SafetyGuard. * - * Safety level is determined by the SFCC_SAFETY_LEVEL environment variable: - * - NONE: No restrictions (default) - * - NO_DELETE: Block DELETE operations - * - NO_UPDATE: Block DELETE + destructive operations (reset/stop/restart) - * - READ_ONLY: Block all writes (GET only) + * The middleware reads `this.safetyGuard` lazily (via arrow function closure), so it + * picks up the full config after `initializeSafetyGuard()` runs. This allows the + * middleware to be registered early in init() before config resolution completes. */ private registerSafetyMiddleware(): void { - const safetyLevel = getSafetyLevel('NONE'); - - // Only register if safety is enabled - if (safetyLevel === 'NONE') return; - - // Safety mode is silent until it blocks something - // Error messages will be shown when operations are actually blocked - globalMiddlewareRegistry.register({ name: 'cli-safety-guard', - getMiddleware() { - return createSafetyMiddleware({level: safetyLevel}); + getMiddleware: () => { + // Skip if no safety restrictions are configured + if (this.safetyGuard.config.level === 'NONE' && !this.safetyGuard.config.rules?.length) { + return undefined; + } + return createSafetyMiddleware(this.safetyGuard); }, }); } + /** + * Update the safety guard with config-provided safety settings. + * Called after loadConfiguration() to merge env vars, global safety config, + * and per-instance dw.json config. + */ + private initializeSafetyGuard(): void { + const globalSafety = loadGlobalSafetyConfig(this.config.configDir); + const config = resolveEffectiveSafetyConfig(this.resolvedConfig.values.safety, globalSafety); + this.safetyGuard = new SafetyGuard(config); + + if (config.level !== 'NONE' || config.rules?.length || config.confirm) { + this.logger.debug( + {level: config.level, confirm: config.confirm, ruleCount: config.rules?.length ?? 0}, + 'Safety mode active', + ); + } + } + + /** + * Evaluate command-level safety rules for the current command. + * + * This runs at the end of init() so every command is evaluated against + * command rules (e.g., `{ command: "code:deploy", action: "block" }`). + * If no command rule matches, this is a no-op — level-based blocking + * is handled by the HTTP middleware and assertDestructiveOperationAllowed(). + */ + private async evaluateCommandSafety(): Promise { + const evaluation = this.safetyGuard.evaluate({ + type: 'command', + commandId: this.id, + }); + + if (evaluation.action === 'block' && evaluation.rule) { + this.error(evaluation.reason, {exit: 1}); + } + if (evaluation.action === 'confirm' && evaluation.rule) { + await this.confirmOrBlock(evaluation); + } + } + + /** + * Require interactive confirmation for a safety-guarded operation. + * + * If stdin is a TTY, prompts the user. Otherwise, blocks with an error message. + * The error message clearly indicates the block is from the user's own safety configuration. + * + * @param evaluation - The safety evaluation that triggered confirmation + * @throws Error if confirmation is denied or not possible + */ + protected async confirmOrBlock(evaluation: SafetyEvaluation): Promise { + if (!process.stdin.isTTY) { + this.error( + `Your safety configuration requires confirmation for this operation, ` + + `but no interactive session is available.\n\n ${evaluation.reason}\n\n` + + `To change this, update the "safety" section in your dw.json or the SFCC_SAFETY_CONFIRM environment variable.`, + {exit: 1}, + ); + } + + const confirmed = await safetyConfirm( + `Your safety configuration requires confirmation for this operation:\n ${evaluation.reason}\n Proceed?`, + ); + if (!confirmed) { + this.error('Operation cancelled.', {exit: 1}); + } + } + /** * Register extra params (query, body, headers) as global middleware. * This applies to ALL HTTP clients created during command execution. diff --git a/packages/b2c-tooling-sdk/src/clients/middleware.ts b/packages/b2c-tooling-sdk/src/clients/middleware.ts index df894aca..b19db9c4 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware.ts @@ -534,39 +534,43 @@ export function createLoggingMiddleware(config?: string | LoggingMiddlewareConfi * ``` */ /** - * Configuration for safety middleware. + * Safety middleware using SafetyGuard. */ -import type {SafetyConfig} from '../safety/safety-middleware.js'; -import {checkSafetyViolation, SafetyBlockedError} from '../safety/safety-middleware.js'; +import type {SafetyGuard} from '../safety/safety-guard.js'; +import {extractJobIdFromPath} from '../safety/safety-guard.js'; /** - * Creates safety middleware that blocks destructive operations. + * Creates safety middleware that evaluates HTTP requests against a SafetyGuard. * - * This middleware intercepts HTTP requests BEFORE they are sent and blocks - * destructive operations based on the configured safety level. It cannot be - * bypassed by command-line flags since it operates at the HTTP layer. + * This middleware intercepts HTTP requests BEFORE they are sent. It evaluates + * each request against the guard's rules and level, throwing: + * - {@link SafetyBlockedError} for blocked operations + * - {@link SafetyConfirmationRequired} for operations needing confirmation * - * @param config - Safety configuration - * @returns Middleware that blocks destructive operations + * Callers that want confirmation support should wrap their SDK calls with + * {@link withSafetyConfirmation}. Otherwise, both error types propagate as errors. + * + * @param guard - The SafetyGuard instance + * @returns Middleware that evaluates operations against safety rules * * @example * ```typescript + * const guard = new SafetyGuard({ level: 'NO_DELETE' }); * const client = createOdsClient(config, auth); - * client.use(createSafetyMiddleware({ level: 'NO_DELETE' })); + * client.use(createSafetyMiddleware(guard)); * ``` */ -export function createSafetyMiddleware(config: SafetyConfig): Middleware { - const logger = getLogger(); - +export function createSafetyMiddleware(guard: SafetyGuard): Middleware { return { async onRequest({request}) { - const errorMessage = checkSafetyViolation(request.method, request.url, config); - - if (errorMessage) { - logger.warn({method: request.method, url: request.url, safetyLevel: config.level}, `[SAFETY] ${errorMessage}`); - throw new SafetyBlockedError(errorMessage, request.method, request.url, config.level); - } - + const path = new URL(request.url, 'http://dummy').pathname; + guard.assert({ + type: 'http', + method: request.method, + url: request.url, + path, + jobId: extractJobIdFromPath(path), + }); return request; }, }; diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index 98835f77..f5614918 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -87,6 +87,34 @@ export interface DwJsonConfig { certificatePassphrase?: string; /** Whether to skip SSL/TLS certificate verification (self-signed certs) */ selfSigned?: boolean; + /** + * Safety configuration for this instance. + * + * @example + * ```json + * { + * "safety": { + * "level": "NO_UPDATE", + * "confirm": true, + * "rules": [ + * { "job": "sfcc-site-archive-export", "action": "allow" }, + * { "command": "sandbox:*", "action": "confirm" } + * ] + * } + * } + * ``` + */ + safety?: { + level?: string; + confirm?: boolean; + rules?: Array<{ + method?: string; + path?: string; + job?: string; + command?: string; + action: string; + }>; + }; } /** diff --git a/packages/b2c-tooling-sdk/src/config/mapping.ts b/packages/b2c-tooling-sdk/src/config/mapping.ts index c4209540..7963c200 100644 --- a/packages/b2c-tooling-sdk/src/config/mapping.ts +++ b/packages/b2c-tooling-sdk/src/config/mapping.ts @@ -13,6 +13,9 @@ */ import type {AuthConfig} from '../auth/types.js'; import {B2CInstance, type InstanceConfig} from '../instance/index.js'; +import {parseSafetyLevelString} from '../safety/safety-middleware.js'; +import {isValidSafetyAction} from '../safety/types.js'; +import type {SafetyRule} from '../safety/types.js'; import type {DwJsonConfig} from './dw-json.js'; import type {NormalizedConfig, ConfigWarning} from './types.js'; @@ -167,6 +170,37 @@ export function mapDwJsonToNormalizedConfig(json: DwJsonConfig): NormalizedConfi certificate: json.certificate, certificatePassphrase: json.certificatePassphrase, selfSigned: json.selfSigned, + // Safety + safety: mapDwJsonSafety(json.safety), + }; +} + +/** + * Maps and validates safety config from dw.json to normalized format. + */ +function mapDwJsonSafety(safety: DwJsonConfig['safety']): NormalizedConfig['safety'] { + if (!safety) return undefined; + + const level = parseSafetyLevelString(safety.level); + const rules: SafetyRule[] | undefined = safety.rules + ?.filter((r) => isValidSafetyAction(r.action)) + .map((r) => ({ + method: r.method, + path: r.path, + job: r.job, + command: r.command, + action: r.action as SafetyRule['action'], + })); + + // Only return if there's at least one meaningful field + if (level === undefined && safety.confirm === undefined && (!rules || rules.length === 0)) { + return undefined; + } + + return { + level, + confirm: safety.confirm, + rules: rules && rules.length > 0 ? rules : undefined, }; } @@ -262,6 +296,19 @@ export function mapNormalizedConfigToDwJson(config: Partial, n if (config.selfSigned !== undefined) { result.selfSigned = config.selfSigned; } + if (config.safety !== undefined) { + result.safety = { + level: config.safety.level, + confirm: config.safety.confirm, + rules: config.safety.rules?.map((r) => ({ + method: r.method, + path: r.path, + job: r.job, + command: r.command, + action: r.action, + })), + }; + } return result; } @@ -381,6 +428,8 @@ export function mergeConfigsWithProtection( certificate: overrides.certificate ?? base.certificate, certificatePassphrase: overrides.certificatePassphrase ?? base.certificatePassphrase, selfSigned: overrides.selfSigned ?? base.selfSigned, + // Safety + safety: overrides.safety ?? base.safety, }, warnings, hostnameMismatch: false, diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index 2d3c3a33..6e53d381 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -13,6 +13,8 @@ */ import type {AuthMethod, AuthStrategy} from '../auth/types.js'; import type {B2CInstance} from '../instance/index.js'; +import type {SafetyLevel} from '../safety/safety-middleware.js'; +import type {SafetyRule} from '../safety/types.js'; /** * A value that may be synchronous or a Promise. @@ -114,6 +116,17 @@ export interface NormalizedConfig { certificatePassphrase?: string; /** Whether to skip SSL/TLS certificate verification (self-signed certs) */ selfSigned?: boolean; + + // Safety + /** Safety configuration for this instance */ + safety?: { + /** Safety level */ + level?: SafetyLevel; + /** When true, level-blocked operations require confirmation instead of hard-blocking */ + confirm?: boolean; + /** Ordered safety rules. First matching rule wins. */ + rules?: SafetyRule[]; + }; } /** diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index f14a9723..323d5140 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -325,8 +325,26 @@ export {getRole, listRoles} from './operations/roles/index.js'; export {getOrg, getOrgByName, listOrgs} from './operations/orgs/index.js'; // Safety - Protection against destructive operations -export {getSafetyLevel, describeSafetyLevel, checkSafetyViolation, SafetyBlockedError} from './safety/index.js'; -export type {SafetyLevel, SafetyConfig} from './safety/index.js'; +export { + getSafetyLevel, + describeSafetyLevel, + checkSafetyViolation, + checkLevelViolation, + SafetyBlockedError, + SafetyConfirmationRequired, + SafetyGuard, + extractJobIdFromPath, + maxSafetyLevel, + isValidSafetyLevel, + parseSafetyLevelString, + resolveEffectiveSafetyConfig, + loadGlobalSafetyConfig, + isValidSafetyAction, + VALID_SAFETY_ACTIONS, + withSafetyConfirmation, +} from './safety/index.js'; +export type {SafetyLevel, SafetyConfig, SafetyConfigFragment} from './safety/index.js'; +export type {SafetyAction, SafetyRule, SafetyOperation, SafetyEvaluation, ConfirmHandler} from './safety/index.js'; // Defaults export { diff --git a/packages/b2c-tooling-sdk/src/safety/index.ts b/packages/b2c-tooling-sdk/src/safety/index.ts index 2df7ea76..231574cf 100644 --- a/packages/b2c-tooling-sdk/src/safety/index.ts +++ b/packages/b2c-tooling-sdk/src/safety/index.ts @@ -10,5 +10,29 @@ * @module safety */ -export type {SafetyLevel, SafetyConfig} from './safety-middleware.js'; -export {SafetyBlockedError, checkSafetyViolation, getSafetyLevel, describeSafetyLevel} from './safety-middleware.js'; +// Types +export type {SafetyAction, SafetyRule, SafetyOperation, SafetyEvaluation} from './types.js'; +export {isValidSafetyAction, VALID_SAFETY_ACTIONS} from './types.js'; + +// Core safety config and levels +export type {SafetyLevel, SafetyConfig, SafetyConfigFragment} from './safety-middleware.js'; +export { + SafetyBlockedError, + SafetyConfirmationRequired, + checkSafetyViolation, + checkLevelViolation, + getSafetyLevel, + describeSafetyLevel, + maxSafetyLevel, + isValidSafetyLevel, + parseSafetyLevelString, + resolveEffectiveSafetyConfig, + loadGlobalSafetyConfig, +} from './safety-middleware.js'; + +// SafetyGuard +export {SafetyGuard, extractJobIdFromPath} from './safety-guard.js'; + +// Confirmation utility +export type {ConfirmHandler} from './with-confirmation.js'; +export {withSafetyConfirmation} from './with-confirmation.js'; diff --git a/packages/b2c-tooling-sdk/src/safety/safety-guard.ts b/packages/b2c-tooling-sdk/src/safety/safety-guard.ts new file mode 100644 index 00000000..d828f5da --- /dev/null +++ b/packages/b2c-tooling-sdk/src/safety/safety-guard.ts @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * SafetyGuard: SDK-level safety evaluation engine. + * + * Evaluates operations against safety rules and levels, producing typed + * evaluations (allow/block/confirm). Used by both the HTTP middleware + * (automatic) and command-level checks (opt-in). + * + * @module safety/safety-guard + */ + +import {Minimatch} from 'minimatch'; +import {getLogger, type Logger} from '../logging/index.js'; +import type {SafetyConfig} from './safety-middleware.js'; +import { + SafetyBlockedError, + SafetyConfirmationRequired, + checkLevelViolation, + describeSafetyLevel, +} from './safety-middleware.js'; +import type {SafetyAction, SafetyEvaluation, SafetyOperation, SafetyRule} from './types.js'; + +/** Regex to extract job ID from OCAPI job execution URLs. */ +const JOB_EXECUTION_PATTERN = /\/jobs\/([^/]+)\/executions/; + +/** + * Extract a job ID from a URL path if it's a job execution endpoint. + * + * Matches patterns like: + * - `/s/-/dw/data/v24_5/jobs/sfcc-site-archive-import/executions` + * - `/jobs/sfcc-site-archive-export/executions` + */ +export function extractJobIdFromPath(path: string): string | undefined { + const match = JOB_EXECUTION_PATTERN.exec(path); + return match?.[1]; +} + +/** + * Test whether a string matches a glob pattern. + * Uses minimatch with dot matching enabled. + */ +function matchGlob(value: string, pattern: string): boolean { + const matcher = new Minimatch(pattern, {dot: true, nocase: true}); + return matcher.match(value); +} + +/** + * Check if a rule matches an operation. + */ +function ruleMatchesOperation(rule: SafetyRule, operation: SafetyOperation): boolean { + // Command matcher + if (rule.command !== undefined) { + if (!operation.commandId) return false; + return matchGlob(operation.commandId, rule.command); + } + + // Job matcher + if (rule.job !== undefined) { + const jobId = operation.jobId ?? (operation.path ? extractJobIdFromPath(operation.path) : undefined); + if (!jobId) return false; + return matchGlob(jobId, rule.job); + } + + // HTTP method + path matcher + if (rule.path !== undefined) { + if (!operation.path) return false; + if (!matchGlob(operation.path, rule.path)) return false; + // If method is specified, it must also match + if (rule.method !== undefined) { + if (!operation.method) return false; + return matchGlob(operation.method.toUpperCase(), rule.method.toUpperCase()); + } + return true; + } + + // Method-only matcher (no path) + if (rule.method !== undefined) { + if (!operation.method) return false; + return matchGlob(operation.method.toUpperCase(), rule.method.toUpperCase()); + } + + // Rule has no matchers — does not match anything + return false; +} + +/** + * SafetyGuard evaluates operations against safety rules and levels. + * + * The guard provides three levels of API: + * - {@link evaluate} — returns a {@link SafetyEvaluation} describing what should happen + * - {@link assert} — throws {@link SafetyBlockedError} or {@link SafetyConfirmationRequired} + * - {@link temporarilyAllow} — creates a scoped exemption for confirmed operations + * + * The HTTP middleware uses the guard internally so all HTTP requests are + * evaluated automatically. CLI commands and other consumers can use the + * guard directly for richer safety interaction (command-level checks, + * confirmation flows). + * + * @example + * ```typescript + * const guard = new SafetyGuard({ + * level: 'NO_UPDATE', + * confirm: true, + * rules: [{ job: 'sfcc-site-archive-export', action: 'allow' }], + * }); + * + * const evaluation = guard.evaluate({ type: 'job', jobId: 'sfcc-site-archive-export' }); + * // evaluation.action === 'allow' + * ``` + */ +export class SafetyGuard { + private temporaryAllows: SafetyRule[] = []; + private readonly logger: Logger; + + constructor(public readonly config: SafetyConfig) { + this.logger = getLogger(); + } + + /** + * Evaluate an operation against safety rules and level. + * + * Evaluation order: + * 1. Temporary allows (from confirmed retries) — if matched, allow + * 2. Config rules in order — first matching rule's action wins + * 3. Level-based default — confirm if `confirm: true`, otherwise block/allow + * + * All evaluations are trace-logged for diagnostics. + */ + evaluate(operation: SafetyOperation): SafetyEvaluation { + // 1. Check temporary allows (confirmed operations) + for (const rule of this.temporaryAllows) { + if (ruleMatchesOperation(rule, operation)) { + const evaluation: SafetyEvaluation = { + action: 'allow', + reason: 'Temporarily allowed after confirmation', + operation, + rule, + }; + this.logger.trace({operation, evaluation}, '[SafetyGuard] Allowed by temporary exemption'); + return evaluation; + } + } + + // 2. Check config rules (first match wins) + if (this.config.rules) { + for (const rule of this.config.rules) { + if (ruleMatchesOperation(rule, operation)) { + const evaluation: SafetyEvaluation = { + action: rule.action, + reason: this.describeRuleMatch(rule, operation), + operation, + rule, + }; + this.logger.trace({operation, rule, action: rule.action}, '[SafetyGuard] Matched rule'); + return evaluation; + } + } + } + + // 3. Fall back to level-based evaluation + return this.evaluateByLevel(operation); + } + + /** + * Assert that an operation is allowed. + * + * @throws {SafetyBlockedError} if the operation is blocked + * @throws {SafetyConfirmationRequired} if the operation needs confirmation + */ + assert(operation: SafetyOperation): void { + const evaluation = this.evaluate(operation); + switch (evaluation.action) { + case 'allow': + return; + case 'block': + throw new SafetyBlockedError(evaluation.reason, operation.method ?? '', operation.url ?? '', this.config.level); + case 'confirm': + throw new SafetyConfirmationRequired(evaluation); + } + } + + /** + * Create a temporary exemption for a confirmed operation. + * + * Returns a cleanup function that removes the exemption. Use this + * to retry an operation after the user has confirmed. + */ + temporarilyAllow(operation: SafetyOperation): () => void { + const rule = this.operationToRule(operation); + return this.temporarilyAddRule(rule); + } + + /** + * Add a temporary safety rule for a scoped exemption. + * + * Unlike {@link temporarilyAllow} which derives a rule from an operation, + * this accepts an arbitrary rule — useful for granting broad temporary + * access (e.g., allowing WebDAV DELETE on Impex paths during a job export). + * + * Returns a cleanup function that removes the rule. + */ + temporarilyAddRule(rule: SafetyRule): () => void { + this.temporaryAllows.push(rule); + this.logger.trace({rule}, '[SafetyGuard] Added temporary rule'); + + return () => { + const idx = this.temporaryAllows.indexOf(rule); + if (idx >= 0) { + this.temporaryAllows.splice(idx, 1); + this.logger.trace({rule}, '[SafetyGuard] Removed temporary rule'); + } + }; + } + + /** + * Evaluate an operation using only the safety level (no rules). + */ + private evaluateByLevel(operation: SafetyOperation): SafetyEvaluation { + // For HTTP operations, check the level + if (operation.method && operation.path) { + const violation = checkLevelViolation(operation.method, operation.path, this.config.level); + if (violation) { + const action: SafetyAction = this.config.confirm ? 'confirm' : 'block'; + const evaluation: SafetyEvaluation = { + action, + reason: this.describeLevelBlock(operation), + operation, + }; + this.logger.trace({operation, action, level: this.config.level}, '[SafetyGuard] Level evaluation'); + return evaluation; + } + } + + // For command operations, no level-based blocking (levels are HTTP-level) + // Commands opt into safety via rules or assertDestructiveOperationAllowed() + const evaluation: SafetyEvaluation = { + action: 'allow', + reason: 'No matching rule and level allows this operation', + operation, + }; + this.logger.trace({operation, level: this.config.level}, '[SafetyGuard] Allowed by level'); + return evaluation; + } + + /** + * Convert an operation to a temporary allow rule for retry. + */ + private operationToRule(operation: SafetyOperation): SafetyRule { + if (operation.commandId) { + return {command: operation.commandId, action: 'allow'}; + } + if (operation.jobId) { + return {job: operation.jobId, action: 'allow'}; + } + // HTTP operation — match exact method + path + return { + method: operation.method, + path: operation.path, + action: 'allow', + }; + } + + /** + * Describe why a rule matched, for user-facing messages. + */ + private describeRuleMatch(rule: SafetyRule, operation: SafetyOperation): string { + if (rule.command) { + return `Command "${operation.commandId}" matched safety rule (command: "${rule.command}", action: ${rule.action})`; + } + if (rule.job) { + return `Job "${operation.jobId}" matched safety rule (job: "${rule.job}", action: ${rule.action})`; + } + const method = operation.method ?? 'unknown'; + const path = operation.path ?? 'unknown'; + return `${method} ${path} matched safety rule (action: ${rule.action})`; + } + + /** + * Describe why the level blocked an operation, for user-facing messages. + */ + private describeLevelBlock(operation: SafetyOperation): string { + const method = operation.method ?? 'unknown'; + const path = operation.path ?? 'unknown'; + const levelDesc = describeSafetyLevel(this.config.level); + return `${method} ${path} blocked by safety level ${this.config.level} — ${levelDesc}`; + } +} diff --git a/packages/b2c-tooling-sdk/src/safety/safety-middleware.ts b/packages/b2c-tooling-sdk/src/safety/safety-middleware.ts index 1d5fd6dd..d32eb3cb 100644 --- a/packages/b2c-tooling-sdk/src/safety/safety-middleware.ts +++ b/packages/b2c-tooling-sdk/src/safety/safety-middleware.ts @@ -4,7 +4,12 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ +import {existsSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; import {getLogger} from '../logging/logger.js'; +import type {SafetyRule} from './types.js'; +import {isValidSafetyAction} from './types.js'; +import type {SafetyEvaluation} from './types.js'; /** * Safety levels for preventing destructive operations. @@ -16,12 +21,61 @@ import {getLogger} from '../logging/logger.js'; */ export type SafetyLevel = 'NONE' | 'NO_DELETE' | 'NO_UPDATE' | 'READ_ONLY'; +/** + * Safety configuration. + * + * Supports both simple level-based blocking and granular per-rule actions. + */ export interface SafetyConfig { + /** The base safety level. */ level: SafetyLevel; + /** When true, operations that the level would block require confirmation instead of hard-blocking. */ + confirm?: boolean; + /** Ordered list of rules. First matching rule wins. */ + rules?: SafetyRule[]; +} + +/** + * Ordering of safety levels from least to most restrictive. + */ +const LEVEL_ORDER: Record = { + NONE: 0, + NO_DELETE: 1, + NO_UPDATE: 2, + READ_ONLY: 3, +}; + +/** + * Valid safety level strings. + */ +const VALID_LEVELS: readonly SafetyLevel[] = ['NONE', 'NO_DELETE', 'NO_UPDATE', 'READ_ONLY'] as const; + +/** + * Returns the more restrictive of two safety levels. + */ +export function maxSafetyLevel(a: SafetyLevel, b: SafetyLevel): SafetyLevel { + return LEVEL_ORDER[a] >= LEVEL_ORDER[b] ? a : b; +} + +/** + * Check if a string is a valid SafetyLevel. + */ +export function isValidSafetyLevel(value: string): value is SafetyLevel { + return VALID_LEVELS.includes(value as SafetyLevel); +} + +/** + * Parse a string to a SafetyLevel, returning undefined for invalid values. + * Accepts case-insensitive input and converts dashes to underscores. + */ +export function parseSafetyLevelString(value: string | undefined): SafetyLevel | undefined { + if (!value) return undefined; + const normalized = value.toUpperCase().replace(/-/g, '_'); + return isValidSafetyLevel(normalized) ? normalized : undefined; } /** - * Safety error thrown when an operation is blocked by safety middleware. + * Safety error thrown when an operation is blocked by safety configuration. */ export class SafetyBlockedError extends Error { constructor( @@ -36,20 +90,35 @@ export class SafetyBlockedError extends Error { } /** - * Checks if an HTTP operation should be blocked based on safety configuration. + * Error thrown when an operation requires interactive confirmation. + * + * Callers can catch this error, prompt the user, and retry the operation + * using {@link withSafetyConfirmation}. + */ +export class SafetyConfirmationRequired extends Error { + constructor(public readonly evaluation: SafetyEvaluation) { + super(`Confirmation required: ${evaluation.reason}`); + this.name = 'SafetyConfirmationRequired'; + } +} + +/** + * Checks if an HTTP operation should be blocked based on a safety level. + * + * This is the low-level level check. For full rule-based evaluation, + * use {@link SafetyGuard.evaluate}. * * @param method - HTTP method (GET, POST, PUT, PATCH, DELETE) - * @param url - Request URL - * @param config - Safety configuration + * @param path - URL pathname + * @param level - Safety level to check against * @returns Error message if blocked, undefined if allowed */ -export function checkSafetyViolation(method: string, url: string, config: SafetyConfig): string | undefined { +export function checkLevelViolation(method: string, path: string, level: SafetyLevel): string | undefined { const upperMethod = method.toUpperCase(); - const path = new URL(url, 'http://dummy').pathname; - switch (config.level) { + switch (level) { case 'NONE': - return undefined; // No restrictions + return undefined; case 'NO_DELETE': if (upperMethod === 'DELETE') { @@ -57,17 +126,16 @@ export function checkSafetyViolation(method: string, url: string, config: Safety } return undefined; - case 'NO_UPDATE': - // Block DELETE operations + case 'NO_UPDATE': { if (upperMethod === 'DELETE') { return `Delete operation blocked: DELETE ${path} (NO_UPDATE mode prevents deletions)`; } - // Block operations that contain reset, stop, restart in path or might be destructive const destructivePatterns = ['/reset', '/stop', '/restart', '/operations']; if (destructivePatterns.some((pattern) => path.includes(pattern)) && upperMethod === 'POST') { return `Destructive operation blocked: POST ${path} (NO_UPDATE mode prevents reset/stop/restart)`; } return undefined; + } case 'READ_ONLY': if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(upperMethod)) { @@ -80,6 +148,20 @@ export function checkSafetyViolation(method: string, url: string, config: Safety } } +/** + * Checks if an HTTP operation should be blocked based on safety configuration. + * + * @param method - HTTP method (GET, POST, PUT, PATCH, DELETE) + * @param url - Request URL + * @param config - Safety configuration + * @returns Error message if blocked, undefined if allowed + * @deprecated Use {@link SafetyGuard.evaluate} for full rule-based evaluation. + */ +export function checkSafetyViolation(method: string, url: string, config: SafetyConfig): string | undefined { + const path = new URL(url, 'http://dummy').pathname; + return checkLevelViolation(method, path, config.level); +} + /** * Parse safety level from environment variable. * @@ -93,12 +175,11 @@ export function checkSafetyViolation(method: string, url: string, config: Safety export function getSafetyLevel(defaultLevel: SafetyLevel = 'NONE'): SafetyLevel { const safetyLevelEnv = process.env['SFCC_SAFETY_LEVEL']; if (safetyLevelEnv) { - const upper = safetyLevelEnv.toUpperCase().replace(/-/g, '_'); - if (['NONE', 'NO_DELETE', 'NO_UPDATE', 'READ_ONLY'].includes(upper)) { - return upper as SafetyLevel; - } + const parsed = parseSafetyLevelString(safetyLevelEnv); + if (parsed) return parsed; + getLogger().warn( - {envValue: safetyLevelEnv, validValues: ['NONE', 'NO_DELETE', 'NO_UPDATE', 'READ_ONLY']}, + {envValue: safetyLevelEnv, validValues: VALID_LEVELS}, 'SFCC_SAFETY_LEVEL has an invalid value; using default safety level', ); } @@ -123,3 +204,114 @@ export function describeSafetyLevel(level: SafetyLevel): string { return 'Unknown safety level'; } } + +/** Shape of the global safety.json file (unvalidated strings, same as dw.json safety). */ +interface RawSafetyConfig { + level?: string; + confirm?: boolean; + rules?: Array<{method?: string; path?: string; job?: string; command?: string; action: string}>; +} + +/** Validated safety config fragment (shared by global and per-instance). */ +export interface SafetyConfigFragment { + level?: SafetyLevel; + confirm?: boolean; + rules?: SafetyRule[]; +} + +/** + * Load global safety configuration from a JSON file. + * + * Resolution order: + * 1. `SFCC_SAFETY_CONFIG` env var — explicit path to a safety config file + * 2. `{configDir}/safety.json` — oclif config directory (e.g., `~/.config/b2c/safety.json`) + * + * The file has the same shape as the `safety` object in dw.json: + * ```json + * { "level": "NO_DELETE", "confirm": true, "rules": [...] } + * ``` + * + * @param configDir - oclif config directory path (e.g., `this.config.configDir`) + * @returns Validated safety config fragment, or undefined if no file found + */ +export function loadGlobalSafetyConfig(configDir?: string): SafetyConfigFragment | undefined { + const logger = getLogger(); + + // 1. Check SFCC_SAFETY_CONFIG env var + const envPath = process.env['SFCC_SAFETY_CONFIG']; + const filePath = envPath || (configDir ? join(configDir, 'safety.json') : undefined); + + if (!filePath || !existsSync(filePath)) { + return undefined; + } + + try { + const raw = JSON.parse(readFileSync(filePath, 'utf-8')) as RawSafetyConfig; + const result: SafetyConfigFragment = {}; + + if (raw.level) { + const parsed = parseSafetyLevelString(raw.level); + if (parsed) { + result.level = parsed; + } else { + logger.warn({level: raw.level, file: filePath}, 'Invalid safety level in global safety config; ignoring'); + } + } + + if (raw.confirm !== undefined) { + result.confirm = raw.confirm === true; + } + + if (raw.rules && Array.isArray(raw.rules)) { + result.rules = raw.rules.filter((r) => { + if (!isValidSafetyAction(r.action)) { + logger.warn({rule: r, file: filePath}, 'Invalid safety rule action in global safety config; skipping rule'); + return false; + } + return true; + }) as SafetyRule[]; + } + + logger.trace({filePath, level: result.level, ruleCount: result.rules?.length}, 'Loaded global safety config'); + return result; + } catch (error) { + logger.warn({error, file: filePath}, 'Failed to load global safety config; ignoring'); + return undefined; + } +} + +/** + * Compute effective safety config by merging environment variables, global + * safety config, and per-instance config. + * + * Merge strategy: + * - **Level**: `max(env, global, instance)` — most restrictive wins + * - **Confirm**: OR across all sources + * - **Rules**: instance rules first, then global rules (first-match-wins, + * so instance rules can override global policy) + * + * @param instanceSafety - Per-instance safety config from dw.json + * @param globalSafety - Global safety config from safety.json + * @returns Merged SafetyConfig + */ +export function resolveEffectiveSafetyConfig( + instanceSafety?: SafetyConfigFragment, + globalSafety?: SafetyConfigFragment, +): SafetyConfig { + const envLevel = getSafetyLevel('NONE'); + const envConfirm = process.env['SFCC_SAFETY_CONFIRM'] === 'true' || process.env['SFCC_SAFETY_CONFIRM'] === '1'; + + const instanceLevel = instanceSafety?.level ?? 'NONE'; + const globalLevel = globalSafety?.level ?? 'NONE'; + + // Merge rules: instance first, then global (first-match-wins) + const instanceRules = instanceSafety?.rules ?? []; + const globalRules = globalSafety?.rules ?? []; + const mergedRules = [...instanceRules, ...globalRules]; + + return { + level: maxSafetyLevel(envLevel, maxSafetyLevel(globalLevel, instanceLevel)), + confirm: envConfirm || instanceSafety?.confirm === true || globalSafety?.confirm === true, + rules: mergedRules.length > 0 ? mergedRules : undefined, + }; +} diff --git a/packages/b2c-tooling-sdk/src/safety/types.ts b/packages/b2c-tooling-sdk/src/safety/types.ts new file mode 100644 index 00000000..f5b4a5cd --- /dev/null +++ b/packages/b2c-tooling-sdk/src/safety/types.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Types for the safety evaluation system. + * + * @module safety/types + */ + +/** + * Action that a safety evaluation can produce. + * + * - `allow`: Operation is permitted + * - `block`: Operation is refused + * - `confirm`: Operation requires interactive confirmation before proceeding + */ +export type SafetyAction = 'allow' | 'block' | 'confirm'; + +/** + * A safety rule that matches operations and specifies an action. + * + * Rules support three matcher types, all using glob patterns (via minimatch): + * - `method` + `path`: Matches HTTP requests by method and URL path + * - `job`: Matches job execution by job ID (extracted from OCAPI URLs) + * - `command`: Matches CLI commands by oclif command ID (e.g., "sandbox:delete") + * + * @example + * ```json + * { "job": "sfcc-site-archive-export", "action": "allow" } + * { "command": "ecdn:cache:purge", "action": "confirm" } + * { "method": "DELETE", "path": "/code_versions/*", "action": "block" } + * ``` + */ +export interface SafetyRule { + /** HTTP method pattern (e.g., "POST", "DELETE"). Omit to match any method. */ + method?: string; + /** URL path glob pattern (e.g., "/jobs/*/executions"). Matched with minimatch. */ + path?: string; + /** Job ID glob pattern. Matches OCAPI job execution URLs by job ID. */ + job?: string; + /** CLI command ID glob pattern (e.g., "sandbox:*", "ecdn:cache:purge"). */ + command?: string; + /** Action to take when this rule matches. */ + action: SafetyAction; +} + +/** + * An operation being evaluated for safety. + * + * Constructed by the caller (HTTP middleware, CLI command, etc.) and passed + * to {@link SafetyGuard.evaluate} for rule matching. + */ +export interface SafetyOperation { + /** The type of operation being evaluated. */ + type: 'http' | 'job' | 'command'; + /** HTTP method (for http operations). */ + method?: string; + /** Full request URL (for http operations). */ + url?: string; + /** Parsed URL pathname (for http operations). */ + path?: string; + /** Job identifier (for job operations, or extracted from http operation URLs). */ + jobId?: string; + /** CLI command ID, e.g., "sandbox:delete" (for command operations). */ + commandId?: string; +} + +/** + * Result of evaluating an operation against safety rules. + */ +export interface SafetyEvaluation { + /** The action determined by evaluation. */ + action: SafetyAction; + /** Human-readable explanation of why this action was chosen. */ + reason: string; + /** The operation that was evaluated. */ + operation: SafetyOperation; + /** The rule that matched, or undefined if the level default was used. */ + rule?: SafetyRule; +} + +/** + * Validated safety actions for use in rule parsing. + */ +export const VALID_SAFETY_ACTIONS: readonly SafetyAction[] = ['allow', 'block', 'confirm'] as const; + +/** + * Check if a string is a valid SafetyAction. + */ +export function isValidSafetyAction(value: string): value is SafetyAction { + return VALID_SAFETY_ACTIONS.includes(value as SafetyAction); +} diff --git a/packages/b2c-tooling-sdk/src/safety/with-confirmation.ts b/packages/b2c-tooling-sdk/src/safety/with-confirmation.ts new file mode 100644 index 00000000..ebce0d8f --- /dev/null +++ b/packages/b2c-tooling-sdk/src/safety/with-confirmation.ts @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Confirmation flow utility for safety-guarded operations. + * + * @module safety/with-confirmation + */ + +import type {SafetyGuard} from './safety-guard.js'; +import type {SafetyEvaluation} from './types.js'; +import {SafetyBlockedError, SafetyConfirmationRequired} from './safety-middleware.js'; + +/** + * Handler that prompts a user for confirmation. + * + * Implementations vary by context: + * - CLI: readline-based prompt + * - VS Code: `vscode.window.showWarningMessage({ modal: true })` + * - MCP/non-interactive: always returns `false` + * + * @param evaluation - The safety evaluation that triggered confirmation + * @returns true if the user confirmed, false to cancel + */ +export type ConfirmHandler = (evaluation: SafetyEvaluation) => Promise; + +/** + * Execute an operation with safety confirmation support. + * + * If the operation throws {@link SafetyConfirmationRequired}, the + * `confirmHandler` is called. If the user confirms, the operation + * is retried with a temporary exemption. If the user cancels (or + * the handler returns false), a {@link SafetyBlockedError} is thrown. + * + * Non-confirmation errors are re-thrown as-is. + * + * @param guard - The SafetyGuard instance + * @param operation - The operation to execute + * @param confirmHandler - Context-specific confirmation handler + * @returns The operation's return value + * + * @example + * ```typescript + * // CLI usage + * const result = await withSafetyConfirmation( + * guard, + * () => instance.ocapi.POST('/jobs/import/executions', ...), + * async (eval) => { + * if (!process.stdin.isTTY) return false; + * return confirm(`Safety: ${eval.reason}. Proceed?`); + * }, + * ); + * + * // VS Code usage + * const result = await withSafetyConfirmation( + * guard, + * () => runJobImport(), + * async (eval) => { + * const choice = await vscode.window.showWarningMessage(eval.reason, { modal: true }, 'Proceed'); + * return choice === 'Proceed'; + * }, + * ); + * ``` + */ +export async function withSafetyConfirmation( + guard: SafetyGuard, + operation: () => Promise, + confirmHandler: ConfirmHandler, +): Promise { + try { + return await operation(); + } catch (err) { + if (err instanceof SafetyConfirmationRequired) { + const confirmed = await confirmHandler(err.evaluation); + if (!confirmed) { + throw new SafetyBlockedError( + `Operation cancelled: ${err.evaluation.reason}`, + err.evaluation.operation.method ?? '', + err.evaluation.operation.url ?? '', + guard.config.level, + ); + } + + // Temporarily allow this specific operation and retry + const cleanup = guard.temporarilyAllow(err.evaluation.operation); + try { + return await operation(); + } finally { + cleanup(); + } + } + throw err; + } +} diff --git a/packages/b2c-tooling-sdk/src/ux/confirm.ts b/packages/b2c-tooling-sdk/src/ux/confirm.ts new file mode 100644 index 00000000..164ab368 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/ux/confirm.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as readline from 'node:readline'; + +export interface ConfirmOptions { + /** Default to yes when the user presses Enter without typing. Defaults to false (no). */ + defaultYes?: boolean; +} + +/** + * Simple yes/no confirmation prompt. + * + * Output goes to stderr so it doesn't interfere with structured stdout output. + * + * @param message - Prompt message (the hint is appended automatically) + * @param options - Options to control default behavior + * @returns true if user confirmed, false otherwise + */ +export async function confirm(message: string, options?: ConfirmOptions): Promise { + const defaultYes = options?.defaultYes ?? false; + const hint = defaultYes ? '(Y/n)' : '(y/N)'; + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + + return new Promise((resolve) => { + rl.question(`${message} ${hint} `, (answer) => { + rl.close(); + const normalized = answer.trim().toLowerCase(); + if (normalized === '') { + resolve(defaultYes); + } else { + resolve(normalized === 'y' || normalized === 'yes'); + } + }); + }); +} diff --git a/packages/b2c-tooling-sdk/test/clients/safety-middleware.test.ts b/packages/b2c-tooling-sdk/test/clients/safety-middleware.test.ts index 9d46d365..889fcf6e 100644 --- a/packages/b2c-tooling-sdk/test/clients/safety-middleware.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/safety-middleware.test.ts @@ -6,12 +6,12 @@ import {expect} from 'chai'; import {createSafetyMiddleware} from '@salesforce/b2c-tooling-sdk/clients'; -import {SafetyBlockedError} from '@salesforce/b2c-tooling-sdk'; +import {SafetyBlockedError, SafetyGuard} from '@salesforce/b2c-tooling-sdk'; describe('clients/middleware - createSafetyMiddleware', () => { describe('HTTP request interception', () => { it('allows safe operations to pass through', async () => { - const middleware = createSafetyMiddleware({level: 'NO_DELETE'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'NO_DELETE'})); type OnRequestParams = Parameters>[0]; const request = new Request('https://api.example.com/items', {method: 'GET'}); @@ -21,7 +21,7 @@ describe('clients/middleware - createSafetyMiddleware', () => { }); it('throws SafetyBlockedError for blocked operations', async () => { - const middleware = createSafetyMiddleware({level: 'NO_DELETE'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'NO_DELETE'})); type OnRequestParams = Parameters>[0]; const request = new Request('https://api.example.com/items/1', {method: 'DELETE'}); @@ -34,14 +34,14 @@ describe('clients/middleware - createSafetyMiddleware', () => { expect((error as SafetyBlockedError).method).to.equal('DELETE'); expect((error as SafetyBlockedError).url).to.equal('https://api.example.com/items/1'); expect((error as SafetyBlockedError).safetyLevel).to.equal('NO_DELETE'); - expect((error as SafetyBlockedError).message).to.include('Delete operation blocked'); + expect((error as SafetyBlockedError).message).to.include('blocked by safety level'); } }); }); describe('NO_DELETE level middleware', () => { it('blocks DELETE requests', async () => { - const middleware = createSafetyMiddleware({level: 'NO_DELETE'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'NO_DELETE'})); type OnRequestParams = Parameters>[0]; const request = new Request('https://api.example.com/sandboxes/123', {method: 'DELETE'}); @@ -51,12 +51,12 @@ describe('clients/middleware - createSafetyMiddleware', () => { throw new Error('Expected SafetyBlockedError'); } catch (error) { expect(error).to.be.instanceOf(SafetyBlockedError); - expect((error as SafetyBlockedError).message).to.include('Delete operation blocked'); + expect((error as SafetyBlockedError).message).to.include('blocked by safety level'); } }); it('allows GET, POST, PUT, PATCH requests', async () => { - const middleware = createSafetyMiddleware({level: 'NO_DELETE'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'NO_DELETE'})); type OnRequestParams = Parameters>[0]; const methods = ['GET', 'POST', 'PUT', 'PATCH']; @@ -71,7 +71,7 @@ describe('clients/middleware - createSafetyMiddleware', () => { describe('NO_UPDATE level middleware', () => { it('blocks DELETE requests', async () => { - const middleware = createSafetyMiddleware({level: 'NO_UPDATE'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'NO_UPDATE'})); type OnRequestParams = Parameters>[0]; const request = new Request('https://api.example.com/items/1', {method: 'DELETE'}); @@ -85,7 +85,7 @@ describe('clients/middleware - createSafetyMiddleware', () => { }); it('blocks destructive POST operations', async () => { - const middleware = createSafetyMiddleware({level: 'NO_UPDATE'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'NO_UPDATE'})); type OnRequestParams = Parameters>[0]; const destructivePaths = [ @@ -103,13 +103,13 @@ describe('clients/middleware - createSafetyMiddleware', () => { throw new Error(`Expected SafetyBlockedError for ${url}`); } catch (error) { expect(error).to.be.instanceOf(SafetyBlockedError); - expect((error as SafetyBlockedError).message).to.include('Destructive operation blocked'); + expect((error as SafetyBlockedError).message).to.include('blocked by safety level'); } } }); it('allows normal POST operations', async () => { - const middleware = createSafetyMiddleware({level: 'NO_UPDATE'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'NO_UPDATE'})); type OnRequestParams = Parameters>[0]; const safePaths = [ @@ -128,7 +128,7 @@ describe('clients/middleware - createSafetyMiddleware', () => { describe('READ_ONLY level middleware', () => { it('blocks all write operations', async () => { - const middleware = createSafetyMiddleware({level: 'READ_ONLY'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'READ_ONLY'})); type OnRequestParams = Parameters>[0]; const writeMethods = ['POST', 'PUT', 'PATCH', 'DELETE']; @@ -141,14 +141,13 @@ describe('clients/middleware - createSafetyMiddleware', () => { throw new Error(`Expected SafetyBlockedError for ${method}`); } catch (error) { expect(error).to.be.instanceOf(SafetyBlockedError); - expect((error as SafetyBlockedError).message).to.include('Write operation blocked'); - expect((error as SafetyBlockedError).message).to.include('READ_ONLY mode'); + expect((error as SafetyBlockedError).message).to.include('blocked by safety level READ_ONLY'); } } }); it('allows GET operations', async () => { - const middleware = createSafetyMiddleware({level: 'READ_ONLY'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'READ_ONLY'})); type OnRequestParams = Parameters>[0]; const request = new Request('https://api.example.com/items', {method: 'GET'}); @@ -160,7 +159,7 @@ describe('clients/middleware - createSafetyMiddleware', () => { describe('NONE level middleware', () => { it('allows all operations', async () => { - const middleware = createSafetyMiddleware({level: 'NONE'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'NONE'})); type OnRequestParams = Parameters>[0]; const allMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; @@ -175,7 +174,7 @@ describe('clients/middleware - createSafetyMiddleware', () => { describe('real-world scenarios', () => { it('protects against accidental sandbox deletion', async () => { - const middleware = createSafetyMiddleware({level: 'NO_DELETE'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'NO_DELETE'})); type OnRequestParams = Parameters>[0]; const request = new Request('https://api.example.com/sandboxes/prod-123', {method: 'DELETE'}); @@ -191,7 +190,7 @@ describe('clients/middleware - createSafetyMiddleware', () => { }); it('protects against destructive operations on production', async () => { - const middleware = createSafetyMiddleware({level: 'NO_UPDATE'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'NO_UPDATE'})); type OnRequestParams = Parameters>[0]; const resetRequest = new Request('https://api.example.com/sandboxes/prod-123/reset', {method: 'POST'}); @@ -201,12 +200,12 @@ describe('clients/middleware - createSafetyMiddleware', () => { throw new Error('Expected SafetyBlockedError'); } catch (error) { expect(error).to.be.instanceOf(SafetyBlockedError); - expect((error as SafetyBlockedError).message).to.include('Destructive operation blocked'); + expect((error as SafetyBlockedError).message).to.include('blocked by safety level'); } }); it('enforces read-only access for audit/investigation scenarios', async () => { - const middleware = createSafetyMiddleware({level: 'READ_ONLY'}); + const middleware = createSafetyMiddleware(new SafetyGuard({level: 'READ_ONLY'})); type OnRequestParams = Parameters>[0]; // Reading should work diff --git a/packages/b2c-tooling-sdk/test/safety/safety-guard.test.ts b/packages/b2c-tooling-sdk/test/safety/safety-guard.test.ts new file mode 100644 index 00000000..66cbb427 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/safety/safety-guard.test.ts @@ -0,0 +1,554 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import { + SafetyGuard, + extractJobIdFromPath, + SafetyBlockedError, + SafetyConfirmationRequired, + type SafetyOperation, +} from '@salesforce/b2c-tooling-sdk'; + +describe('safety/safety-guard', () => { + describe('extractJobIdFromPath', () => { + it('extracts job ID from OCAPI job execution URL', () => { + expect(extractJobIdFromPath('/s/-/dw/data/v24_5/jobs/sfcc-site-archive-import/executions')).to.equal( + 'sfcc-site-archive-import', + ); + }); + + it('extracts job ID from short path', () => { + expect(extractJobIdFromPath('/jobs/my-custom-job/executions')).to.equal('my-custom-job'); + }); + + it('returns undefined for non-job paths', () => { + expect(extractJobIdFromPath('/items/123')).to.be.undefined; + expect(extractJobIdFromPath('/jobs')).to.be.undefined; + expect(extractJobIdFromPath('/jobs/my-job')).to.be.undefined; + }); + }); + + describe('SafetyGuard.evaluate', () => { + describe('rule matching — command rules', () => { + it('matches exact command ID', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{command: 'sandbox:delete', action: 'block'}], + }); + + const result = guard.evaluate({type: 'command', commandId: 'sandbox:delete'}); + expect(result.action).to.equal('block'); + expect(result.rule).to.deep.include({command: 'sandbox:delete'}); + }); + + it('matches command ID with glob pattern', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{command: 'sandbox:*', action: 'confirm'}], + }); + + expect(guard.evaluate({type: 'command', commandId: 'sandbox:delete'}).action).to.equal('confirm'); + expect(guard.evaluate({type: 'command', commandId: 'sandbox:create'}).action).to.equal('confirm'); + }); + + it('does not match unrelated command', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{command: 'sandbox:*', action: 'block'}], + }); + + const result = guard.evaluate({type: 'command', commandId: 'job:run'}); + expect(result.action).to.equal('allow'); + }); + + it('does not match command rule when no commandId on operation', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{command: 'sandbox:delete', action: 'block'}], + }); + + const result = guard.evaluate({type: 'http', method: 'DELETE', path: '/items/1'}); + expect(result.action).to.equal('allow'); + }); + }); + + describe('rule matching — job rules', () => { + it('matches exact job ID', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{job: 'sfcc-site-archive-import', action: 'block'}], + }); + + const result = guard.evaluate({type: 'job', jobId: 'sfcc-site-archive-import'}); + expect(result.action).to.equal('block'); + }); + + it('matches job ID with glob', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{job: 'sfcc-site-archive-*', action: 'confirm'}], + }); + + expect(guard.evaluate({type: 'job', jobId: 'sfcc-site-archive-import'}).action).to.equal('confirm'); + expect(guard.evaluate({type: 'job', jobId: 'sfcc-site-archive-export'}).action).to.equal('confirm'); + }); + + it('extracts job ID from HTTP operation path', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{job: 'sfcc-site-archive-import', action: 'block'}], + }); + + const result = guard.evaluate({ + type: 'http', + method: 'POST', + path: '/s/-/dw/data/v24_5/jobs/sfcc-site-archive-import/executions', + }); + expect(result.action).to.equal('block'); + }); + + it('does not match job rule when no jobId available', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{job: 'sfcc-site-archive-import', action: 'block'}], + }); + + const result = guard.evaluate({type: 'http', method: 'GET', path: '/items'}); + expect(result.action).to.equal('allow'); + }); + }); + + describe('rule matching — HTTP method+path rules', () => { + it('matches path with glob', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{path: '/code_versions/*', action: 'block'}], + }); + + const result = guard.evaluate({type: 'http', method: 'DELETE', path: '/code_versions/v1'}); + expect(result.action).to.equal('block'); + }); + + it('matches method and path together', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{method: 'DELETE', path: '/code_versions/*', action: 'block'}], + }); + + // DELETE matches + expect(guard.evaluate({type: 'http', method: 'DELETE', path: '/code_versions/v1'}).action).to.equal('block'); + // GET does not match — method doesn't match + expect(guard.evaluate({type: 'http', method: 'GET', path: '/code_versions/v1'}).action).to.equal('allow'); + }); + + it('matches method-only rule', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{method: 'DELETE', action: 'block'}], + }); + + expect(guard.evaluate({type: 'http', method: 'DELETE', path: '/anything'}).action).to.equal('block'); + expect(guard.evaluate({type: 'http', method: 'GET', path: '/anything'}).action).to.equal('allow'); + }); + + it('method matching is case-insensitive', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{method: 'delete', path: '/items/*', action: 'block'}], + }); + + expect(guard.evaluate({type: 'http', method: 'DELETE', path: '/items/1'}).action).to.equal('block'); + }); + }); + + describe('first-match wins', () => { + it('uses the first matching rule', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [ + {job: 'sfcc-site-archive-export', action: 'allow'}, + {job: 'sfcc-site-archive-*', action: 'block'}, + ], + }); + + // Export matches first rule (allow) + expect(guard.evaluate({type: 'job', jobId: 'sfcc-site-archive-export'}).action).to.equal('allow'); + // Import matches second rule (block) + expect(guard.evaluate({type: 'job', jobId: 'sfcc-site-archive-import'}).action).to.equal('block'); + }); + }); + + describe('allow rules override level', () => { + it('allows explicitly allowed operation even when level would block', () => { + const guard = new SafetyGuard({ + level: 'READ_ONLY', + rules: [{job: 'sfcc-site-archive-export', action: 'allow'}], + }); + + const result = guard.evaluate({type: 'job', jobId: 'sfcc-site-archive-export'}); + expect(result.action).to.equal('allow'); + }); + + it('allows explicitly allowed HTTP path even when level would block', () => { + const guard = new SafetyGuard({ + level: 'NO_DELETE', + rules: [{method: 'DELETE', path: '/code_versions/*', action: 'allow'}], + }); + + const result = guard.evaluate({type: 'http', method: 'DELETE', path: '/code_versions/v1'}); + expect(result.action).to.equal('allow'); + }); + }); + + describe('level-based evaluation (no rules)', () => { + it('allows GET at NO_DELETE level', () => { + const guard = new SafetyGuard({level: 'NO_DELETE'}); + const result = guard.evaluate({type: 'http', method: 'GET', path: '/items'}); + expect(result.action).to.equal('allow'); + }); + + it('blocks DELETE at NO_DELETE level', () => { + const guard = new SafetyGuard({level: 'NO_DELETE'}); + const result = guard.evaluate({type: 'http', method: 'DELETE', path: '/items/1'}); + expect(result.action).to.equal('block'); + }); + + it('blocks write operations at READ_ONLY level', () => { + const guard = new SafetyGuard({level: 'READ_ONLY'}); + + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + const result = guard.evaluate({type: 'http', method, path: '/items'}); + expect(result.action).to.equal('block', `Expected ${method} to be blocked`); + } + }); + + it('allows all operations at NONE level', () => { + const guard = new SafetyGuard({level: 'NONE'}); + + for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) { + const result = guard.evaluate({type: 'http', method, path: '/items'}); + expect(result.action).to.equal('allow', `Expected ${method} to be allowed`); + } + }); + + it('allows command operations (levels are HTTP-level only)', () => { + const guard = new SafetyGuard({level: 'READ_ONLY'}); + const result = guard.evaluate({type: 'command', commandId: 'sandbox:delete'}); + expect(result.action).to.equal('allow'); + }); + }); + + describe('confirm mode', () => { + it('softens level blocks into confirm when confirm is true', () => { + const guard = new SafetyGuard({level: 'NO_DELETE', confirm: true}); + const result = guard.evaluate({type: 'http', method: 'DELETE', path: '/items/1'}); + expect(result.action).to.equal('confirm'); + }); + + it('does not affect operations the level would allow', () => { + const guard = new SafetyGuard({level: 'NO_DELETE', confirm: true}); + const result = guard.evaluate({type: 'http', method: 'GET', path: '/items'}); + expect(result.action).to.equal('allow'); + }); + + it('does not affect rule-based blocks (only level-based)', () => { + const guard = new SafetyGuard({ + level: 'NONE', + confirm: true, + rules: [{command: 'sandbox:delete', action: 'block'}], + }); + + const result = guard.evaluate({type: 'command', commandId: 'sandbox:delete'}); + expect(result.action).to.equal('block'); + }); + + it('returns confirm for rule-based confirm action', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{command: 'sandbox:delete', action: 'confirm'}], + }); + + const result = guard.evaluate({type: 'command', commandId: 'sandbox:delete'}); + expect(result.action).to.equal('confirm'); + }); + }); + }); + + describe('SafetyGuard.assert', () => { + it('does not throw for allowed operations', () => { + const guard = new SafetyGuard({level: 'NONE'}); + expect(() => guard.assert({type: 'http', method: 'DELETE', path: '/items/1'})).to.not.throw(); + }); + + it('throws SafetyBlockedError for blocked operations', () => { + const guard = new SafetyGuard({level: 'NO_DELETE'}); + + try { + guard.assert({type: 'http', method: 'DELETE', url: 'https://example.com/items/1', path: '/items/1'}); + throw new Error('Expected SafetyBlockedError'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + } + }); + + it('throws SafetyConfirmationRequired for confirm operations', () => { + const guard = new SafetyGuard({level: 'NO_DELETE', confirm: true}); + + try { + guard.assert({type: 'http', method: 'DELETE', path: '/items/1'}); + throw new Error('Expected SafetyConfirmationRequired'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyConfirmationRequired); + expect((error as SafetyConfirmationRequired).evaluation.action).to.equal('confirm'); + } + }); + }); + + describe('SafetyGuard.temporarilyAllow', () => { + it('allows previously blocked operation after temporary allow', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{command: 'sandbox:delete', action: 'block'}], + }); + + // Blocked before temporary allow + expect(guard.evaluate({type: 'command', commandId: 'sandbox:delete'}).action).to.equal('block'); + + // Temporarily allow + const cleanup = guard.temporarilyAllow({type: 'command', commandId: 'sandbox:delete'}); + + // Now allowed + expect(guard.evaluate({type: 'command', commandId: 'sandbox:delete'}).action).to.equal('allow'); + + // Clean up + cleanup(); + + // Blocked again + expect(guard.evaluate({type: 'command', commandId: 'sandbox:delete'}).action).to.equal('block'); + }); + + it('creates temporary allow for HTTP operations', () => { + const guard = new SafetyGuard({level: 'NO_DELETE'}); + const operation: SafetyOperation = {type: 'http', method: 'DELETE', path: '/items/1'}; + + expect(guard.evaluate(operation).action).to.equal('block'); + + const cleanup = guard.temporarilyAllow(operation); + expect(guard.evaluate(operation).action).to.equal('allow'); + + cleanup(); + expect(guard.evaluate(operation).action).to.equal('block'); + }); + + it('creates temporary allow for job operations', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{job: 'sfcc-site-archive-import', action: 'block'}], + }); + const operation: SafetyOperation = {type: 'job', jobId: 'sfcc-site-archive-import'}; + + expect(guard.evaluate(operation).action).to.equal('block'); + + const cleanup = guard.temporarilyAllow(operation); + expect(guard.evaluate(operation).action).to.equal('allow'); + + cleanup(); + expect(guard.evaluate(operation).action).to.equal('block'); + }); + }); + + describe('SafetyGuard.evaluate — evaluation reasons', () => { + it('includes rule info in reason for rule matches', () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{command: 'sandbox:delete', action: 'block'}], + }); + + const result = guard.evaluate({type: 'command', commandId: 'sandbox:delete'}); + expect(result.reason).to.include('sandbox:delete'); + expect(result.reason).to.include('block'); + }); + + it('includes level info in reason for level-based blocks', () => { + const guard = new SafetyGuard({level: 'NO_DELETE'}); + + const result = guard.evaluate({type: 'http', method: 'DELETE', path: '/items/1'}); + expect(result.reason).to.include('NO_DELETE'); + }); + + it('returns operation in evaluation', () => { + const guard = new SafetyGuard({level: 'NONE'}); + const operation: SafetyOperation = {type: 'http', method: 'GET', path: '/items'}; + + const result = guard.evaluate(operation); + expect(result.operation).to.equal(operation); + }); + }); + + describe('SafetyConfirmationRequired', () => { + it('stores evaluation and has correct name', () => { + const evaluation = { + action: 'confirm' as const, + reason: 'Test reason', + operation: {type: 'command' as const, commandId: 'sandbox:delete'}, + }; + + const error = new SafetyConfirmationRequired(evaluation); + expect(error.name).to.equal('SafetyConfirmationRequired'); + expect(error.evaluation).to.equal(evaluation); + expect(error.message).to.include('Test reason'); + }); + }); + + describe('resolveEffectiveSafetyConfig', () => { + it('merges global and instance configs — level uses max', async () => { + const {resolveEffectiveSafetyConfig} = await import('@salesforce/b2c-tooling-sdk'); + + const result = resolveEffectiveSafetyConfig({level: 'NO_DELETE'}, {level: 'NO_UPDATE'}); + expect(result.level).to.equal('NO_UPDATE'); + }); + + it('merges confirm with OR', async () => { + const {resolveEffectiveSafetyConfig} = await import('@salesforce/b2c-tooling-sdk'); + + expect(resolveEffectiveSafetyConfig({confirm: false}, {confirm: true}).confirm).to.be.true; + expect(resolveEffectiveSafetyConfig({confirm: true}, {confirm: false}).confirm).to.be.true; + expect(resolveEffectiveSafetyConfig({confirm: false}, {confirm: false}).confirm).to.be.false; + }); + + it('concatenates rules — instance first, then global', async () => { + const {resolveEffectiveSafetyConfig} = await import('@salesforce/b2c-tooling-sdk'); + + const instanceRule = {command: 'sandbox:delete', action: 'allow' as const}; + const globalRule = {command: 'sandbox:*', action: 'block' as const}; + + const result = resolveEffectiveSafetyConfig({rules: [instanceRule]}, {rules: [globalRule]}); + expect(result.rules).to.have.length(2); + expect(result.rules![0]).to.deep.equal(instanceRule); + expect(result.rules![1]).to.deep.equal(globalRule); + }); + + it('works with only global config', async () => { + const {resolveEffectiveSafetyConfig} = await import('@salesforce/b2c-tooling-sdk'); + + const result = resolveEffectiveSafetyConfig(undefined, {level: 'NO_DELETE', confirm: true}); + expect(result.level).to.equal('NO_DELETE'); + expect(result.confirm).to.be.true; + }); + + it('works with no configs', async () => { + const {resolveEffectiveSafetyConfig} = await import('@salesforce/b2c-tooling-sdk'); + + const result = resolveEffectiveSafetyConfig(); + expect(result.level).to.equal('NONE'); + expect(result.confirm).to.be.false; + expect(result.rules).to.be.undefined; + }); + }); + + describe('loadGlobalSafetyConfig', () => { + it('returns undefined when no config dir provided', async () => { + const {loadGlobalSafetyConfig} = await import('@salesforce/b2c-tooling-sdk'); + expect(loadGlobalSafetyConfig()).to.be.undefined; + }); + + it('returns undefined when config dir does not contain safety.json', async () => { + const {loadGlobalSafetyConfig} = await import('@salesforce/b2c-tooling-sdk'); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'safety-test-')); + expect(loadGlobalSafetyConfig(tmpDir)).to.be.undefined; + fs.rmSync(tmpDir, {recursive: true}); + }); + + it('loads safety.json from config dir', async () => { + const {loadGlobalSafetyConfig} = await import('@salesforce/b2c-tooling-sdk'); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'safety-test-')); + fs.writeFileSync( + path.join(tmpDir, 'safety.json'), + JSON.stringify({ + level: 'NO_DELETE', + confirm: true, + rules: [{job: 'sfcc-site-archive-import', action: 'block'}], + }), + ); + + const result = loadGlobalSafetyConfig(tmpDir); + expect(result).to.deep.equal({ + level: 'NO_DELETE', + confirm: true, + rules: [{job: 'sfcc-site-archive-import', action: 'block'}], + }); + + fs.rmSync(tmpDir, {recursive: true}); + }); + + it('loads from SFCC_SAFETY_CONFIG env var', async () => { + const {loadGlobalSafetyConfig} = await import('@salesforce/b2c-tooling-sdk'); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'safety-test-')); + const configPath = path.join(tmpDir, 'custom-safety.json'); + fs.writeFileSync(configPath, JSON.stringify({level: 'READ_ONLY'})); + + const originalEnv = process.env['SFCC_SAFETY_CONFIG']; + try { + process.env['SFCC_SAFETY_CONFIG'] = configPath; + const result = loadGlobalSafetyConfig(); + expect(result?.level).to.equal('READ_ONLY'); + } finally { + if (originalEnv !== undefined) { + process.env['SFCC_SAFETY_CONFIG'] = originalEnv; + } else { + delete process.env['SFCC_SAFETY_CONFIG']; + } + fs.rmSync(tmpDir, {recursive: true}); + } + }); + + it('skips rules with invalid actions', async () => { + const {loadGlobalSafetyConfig} = await import('@salesforce/b2c-tooling-sdk'); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'safety-test-')); + fs.writeFileSync( + path.join(tmpDir, 'safety.json'), + JSON.stringify({ + rules: [ + {job: 'valid-job', action: 'block'}, + {job: 'invalid-job', action: 'invalid_action'}, + ], + }), + ); + + const result = loadGlobalSafetyConfig(tmpDir); + expect(result?.rules).to.have.length(1); + expect(result?.rules![0].job).to.equal('valid-job'); + + fs.rmSync(tmpDir, {recursive: true}); + }); + }); + + describe('maxSafetyLevel', () => { + it('is accessible from the main export', async () => { + const {maxSafetyLevel} = await import('@salesforce/b2c-tooling-sdk'); + expect(maxSafetyLevel('NONE', 'NO_DELETE')).to.equal('NO_DELETE'); + expect(maxSafetyLevel('READ_ONLY', 'NO_DELETE')).to.equal('READ_ONLY'); + expect(maxSafetyLevel('NONE', 'NONE')).to.equal('NONE'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/safety/with-confirmation.test.ts b/packages/b2c-tooling-sdk/test/safety/with-confirmation.test.ts new file mode 100644 index 00000000..bc11b1ee --- /dev/null +++ b/packages/b2c-tooling-sdk/test/safety/with-confirmation.test.ts @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {SafetyGuard, SafetyBlockedError, withSafetyConfirmation} from '@salesforce/b2c-tooling-sdk'; + +describe('safety/with-confirmation', () => { + describe('withSafetyConfirmation', () => { + it('returns result when operation succeeds without confirmation', async () => { + const guard = new SafetyGuard({level: 'NONE'}); + + const result = await withSafetyConfirmation( + guard, + async () => 'success', + async () => true, + ); + + expect(result).to.equal('success'); + }); + + it('prompts and retries when SafetyConfirmationRequired is thrown', async () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{command: 'sandbox:delete', action: 'confirm'}], + }); + + let callCount = 0; + let confirmCalled = false; + + const result = await withSafetyConfirmation( + guard, + async () => { + callCount++; + if (callCount === 1) { + // First call: guard evaluates and finds confirm rule + guard.assert({type: 'command', commandId: 'sandbox:delete'}); + } + return 'success'; + }, + async (evaluation) => { + confirmCalled = true; + expect(evaluation.action).to.equal('confirm'); + return true; + }, + ); + + expect(result).to.equal('success'); + expect(confirmCalled).to.be.true; + expect(callCount).to.equal(2); + }); + + it('throws SafetyBlockedError when user declines confirmation', async () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{command: 'sandbox:delete', action: 'confirm'}], + }); + + try { + await withSafetyConfirmation( + guard, + async () => { + guard.assert({type: 'command', commandId: 'sandbox:delete'}); + return 'success'; + }, + async () => false, // User declines + ); + throw new Error('Expected SafetyBlockedError'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + expect((error as SafetyBlockedError).message).to.include('Operation cancelled'); + } + }); + + it('re-throws non-confirmation errors', async () => { + const guard = new SafetyGuard({level: 'NONE'}); + + try { + await withSafetyConfirmation( + guard, + async () => { + throw new Error('Network error'); + }, + async () => true, + ); + throw new Error('Expected error'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + expect((error as Error).message).to.equal('Network error'); + } + }); + + it('re-throws SafetyBlockedError without confirmation prompt', async () => { + const guard = new SafetyGuard({level: 'NO_DELETE'}); + let confirmCalled = false; + + try { + await withSafetyConfirmation( + guard, + async () => { + guard.assert({type: 'http', method: 'DELETE', url: 'https://example.com/items/1', path: '/items/1'}); + return 'success'; + }, + async () => { + confirmCalled = true; + return true; + }, + ); + throw new Error('Expected SafetyBlockedError'); + } catch (error) { + expect(error).to.be.instanceOf(SafetyBlockedError); + expect(confirmCalled).to.be.false; + } + }); + + it('cleans up temporary allow after retry completes', async () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{command: 'sandbox:delete', action: 'confirm'}], + }); + + let callCount = 0; + await withSafetyConfirmation( + guard, + async () => { + callCount++; + if (callCount === 1) { + guard.assert({type: 'command', commandId: 'sandbox:delete'}); + } + return 'done'; + }, + async () => true, + ); + + // After withSafetyConfirmation returns, the temporary allow should be cleaned up + // So evaluating the same operation should get the confirm action again + const evaluation = guard.evaluate({type: 'command', commandId: 'sandbox:delete'}); + expect(evaluation.action).to.equal('confirm'); + }); + + it('cleans up temporary allow even if retry fails', async () => { + const guard = new SafetyGuard({ + level: 'NONE', + rules: [{command: 'sandbox:delete', action: 'confirm'}], + }); + + let callCount = 0; + try { + await withSafetyConfirmation( + guard, + async () => { + callCount++; + if (callCount === 1) { + guard.assert({type: 'command', commandId: 'sandbox:delete'}); + } + // Retry also fails with a different error + throw new Error('Operation failed after confirmation'); + }, + async () => true, + ); + } catch { + // expected + } + + // Temporary allow should be cleaned up + const evaluation = guard.evaluate({type: 'command', commandId: 'sandbox:delete'}); + expect(evaluation.action).to.equal('confirm'); + }); + }); +});