@principles/pd-cli 1.138.0 → 1.140.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,15 +34,17 @@ describe('deriveActivationStatusAndNextAction', () => {
34
34
  expect(nextAction).not.toContain('--confirm');
35
35
  });
36
36
 
37
- it('shadow mode: promote nextAction MUST contain --confirm', () => {
37
+ it('shadow mode: nextAction requires Owner review instead of advertising direct mutation', () => {
38
38
  const { status, nextAction } = deriveActivationStatusAndNextAction({
39
39
  ...base,
40
40
  mode: 'shadow',
41
41
  });
42
42
  expect(status).toBe('active');
43
43
  expect(nextAction).toBeDefined();
44
- expect(nextAction).toContain('pd activation promote');
45
- expect(nextAction).toContain('--confirm');
44
+ expect(nextAction).toBe(
45
+ 'Keep shadow; promotion requires an authenticated Owner decision, immutable evidence bindings, and a passing Promotion Readiness result.',
46
+ );
47
+ expect(nextAction).not.toContain('pd activation promote');
46
48
  });
47
49
 
48
50
  it('deactivated record: status deactivated, no nextAction', () => {
@@ -1,4 +1,6 @@
1
1
  import * as path from 'path';
2
+ import * as fs from 'node:fs';
3
+ import { createHash, randomUUID } from 'node:crypto';
2
4
  import type { Command } from 'commander';
3
5
  import {
4
6
  RuntimeStateManager,
@@ -15,6 +17,12 @@ import {
15
17
  isArtifactRevisionOf,
16
18
  extractEvidenceRefs,
17
19
  extractPrincipleId,
20
+ isFeatureEnabled,
21
+ RuleCodeOwnerDecisionService,
22
+ PromotionReadinessReader,
23
+ SqliteActivationSafetyStore,
24
+ collectOpenClawPromotionChecks,
25
+ summarizeRuleCodeShadowEvents,
18
26
  } from '@principles/core/runtime-v2';
19
27
  import type {
20
28
  ActivationDecision,
@@ -23,7 +31,8 @@ import type {
23
31
  ApprovalDecisionResult,
24
32
  ApprovalCompletionResult,
25
33
  } from '@principles/core/runtime-v2';
26
- import type { PIArtifactRecord, ActivationStatusRecord } from '@principles/core/runtime-v2';
34
+ import type { PIArtifactRecord, ActivationStatusRecord, PromotionEvidenceSnapshot } from '@principles/core/runtime-v2';
35
+ import { OPENCLAW_HOST_LIVENESS_CONTRACT } from '@principles/host-runtime';
27
36
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
28
37
  import { loadPdConfig, computeFlagsFromLoadResult } from '../services/pd-config-loader.js';
29
38
 
@@ -35,6 +44,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
35
44
  return value !== null && value !== undefined && typeof value === 'object' && !Array.isArray(value);
36
45
  }
37
46
 
47
+ function unavailableShadowSummary(): PromotionEvidenceSnapshot['shadowSummary'] {
48
+ return {
49
+ observed: null, matched: null, wouldBlock: null, wouldAllow: null,
50
+ requireApproval: null, autoCorrect: null, errors: null, neutralControl: null,
51
+ firstObservedAt: null, lastObservedAt: null,
52
+ };
53
+ }
54
+
55
+ function readShadowSummary(workspaceDir: string, activationId: string): PromotionEvidenceSnapshot['shadowSummary'] {
56
+ const logsDir = path.join(workspaceDir, '.pd', 'logs'); if (!fs.existsSync(logsDir)) return unavailableShadowSummary();
57
+ const entries: unknown[] = [];
58
+ try { for (const file of fs.readdirSync(logsDir).filter(name => /^events_.*\.jsonl$/.test(name)).sort().slice(-7)) for (const line of fs.readFileSync(path.join(logsDir, file), 'utf8').split('\n').filter(Boolean)) { try { entries.push(JSON.parse(line) as unknown); } catch { /* exclude malformed telemetry */ } } }
59
+ catch { return unavailableShadowSummary(); }
60
+ return summarizeRuleCodeShadowEvents(entries, activationId);
61
+ }
62
+
38
63
  interface ActivationDispatchOptions {
39
64
  workspace?: string;
40
65
  artifactId?: string;
@@ -345,6 +370,12 @@ export interface ActivationPromoteOptions {
345
370
  dryRun?: boolean;
346
371
  confirm?: boolean;
347
372
  json?: boolean;
373
+ artifactId?: string;
374
+ artifactDigest?: string;
375
+ controlVersion?: number;
376
+ idempotencyKey?: string;
377
+ reasonCode?: string;
378
+ note?: string;
348
379
  }
349
380
 
350
381
  export interface ActivationPromoteResult {
@@ -353,13 +384,19 @@ export interface ActivationPromoteResult {
353
384
  activationId: string;
354
385
  promotedAt?: string;
355
386
  reason?: string;
387
+ reasonCode?: string;
388
+ summary?: string;
389
+ failedChecks?: { checkId: string; reasonCode: string }[];
356
390
  nextAction?: string;
357
391
  }
358
392
 
359
393
  export async function handleRuntimeActivationPromote(opts: ActivationPromoteOptions): Promise<void> {
360
394
  const activationId = opts.activationId?.trim() ?? '';
361
- const refuse = (reason: string, nextAction: string): void => {
362
- const result: ActivationPromoteResult = { ok: false, decision: 'refused', activationId, reason, nextAction };
395
+ const refuse = (reason: string, nextAction: string, details?: { summary?: string; failedChecks?: { checkId: string; reasonCode: string }[] }): void => {
396
+ const result: ActivationPromoteResult = {
397
+ ok: false, decision: 'refused', activationId, reason, reasonCode: reason, nextAction,
398
+ summary: details?.summary, failedChecks: details?.failedChecks,
399
+ };
363
400
  if (opts.json) console.log(JSON.stringify(result));
364
401
  else {
365
402
  console.error(`Promotion refused: ${reason}`);
@@ -380,63 +417,116 @@ export async function handleRuntimeActivationPromote(opts: ActivationPromoteOpti
380
417
  let stateManager: RuntimeStateManager | undefined;
381
418
  try {
382
419
  const workspaceDir = opts.workspace ? path.resolve(opts.workspace) : resolveWorkspaceDir();
383
- stateManager = new RuntimeStateManager({ workspaceDir });
384
- await stateManager.initialize();
385
- const store = new SqliteActivationStateStore(stateManager.connection);
386
- const activeHooks = await store.listCodeToolHookActivations(false);
387
- // CodeRabbit PR2 Comment 3: dry-run must apply the same eligibility checks
388
- // as the real promote path. `promoteActivation` runs a COUNT guard inside a
389
- // BEGIN IMMEDIATE transaction and refuses when the count of matching shadow
390
- // rows is not exactly 1. The previous dry-run branch used `find()` (returns
391
- // the first match) and reported `would_promote` even when duplicates would
392
- // make the confirm path throw. Mirror the store's uniqueness check here so
393
- // dry-run and confirm agree (cli-5: failure paths must not mutate state;
394
- // cli-6: degraded/refused results carry a structured reason + nextAction).
395
- const matchingShadows = activeHooks.filter(
396
- (record) => record.activationId === activationId && record.action === 'code_tool_hook_shadow_activate',
397
- );
398
- if (matchingShadows.length === 0) {
399
- refuse(
400
- 'not_found_inactive_or_not_shadow',
401
- 'Refresh `pd activation list --channel code_tool_hook`; only active shadow activations can be promoted.',
402
- );
403
- return;
404
- }
405
- if (matchingShadows.length > 1) {
406
- refuse(
407
- 'duplicate_shadow_activations',
408
- `${matchingShadows.length} shadow activations share activation_id=${activationId}; resolve duplicates before promoting.`,
409
- );
410
- return;
411
- }
412
-
413
- if (opts.confirm !== true) {
414
- const result: ActivationPromoteResult = {
415
- ok: true,
416
- decision: 'would_promote',
417
- activationId,
418
- nextAction: `Run pd activation promote --activation-id ${activationId} --confirm to enable live blocking.`,
419
- };
420
- if (opts.json) console.log(JSON.stringify(result));
421
- else {
422
- console.log(`Would promote: ${activationId}`);
423
- console.log(` nextAction: ${result.nextAction}`);
420
+ const flags = computeFlagsFromLoadResult(loadPdConfig(workspaceDir));
421
+ const ownerId = process.env.PD_OWNER_ID?.trim();
422
+ const credentialId = process.env.PD_OWNER_CREDENTIAL_ID?.trim();
423
+ const consoleToken = process.env.PD_CONSOLE_TOKEN?.trim();
424
+ const operatorId = process.env.USERNAME?.trim() || process.env.USER?.trim();
425
+ const actor = ownerId && credentialId && consoleToken
426
+ ? {
427
+ principal: { kind: 'configured_owner' as const, ownerId },
428
+ authentication: { method: 'cli_owner_credential' as const, credentialId },
429
+ ...(operatorId ? { operator: { kind: 'local_user' as const, operatorId } } : {}),
430
+ }
431
+ : {
432
+ principal: { kind: 'break_glass' as const, reason: 'local_no_auth_emergency' as const },
433
+ authentication: { method: 'local_break_glass' as const },
434
+ };
435
+ const getStateManager = async (): Promise<RuntimeStateManager> => {
436
+ if (!stateManager) {
437
+ stateManager = new RuntimeStateManager({ workspaceDir, readonly: opts.dryRun === true });
438
+ await stateManager.initialize();
424
439
  }
440
+ return stateManager;
441
+ };
442
+ const service = new RuleCodeOwnerDecisionService({
443
+ ownerLiveDecisionEnabled: () => isFeatureEnabled(flags, 'rulecode_owner_live_decision'),
444
+ safetyControlsEnabled: () => isFeatureEnabled(flags, 'rulecode_safety_controls'),
445
+ evaluateReadiness: async request => {
446
+ const manager = await getStateManager();
447
+ const activationStore = new SqliteActivationStateStore(manager.connection);
448
+ const writer = new RuleHostWriter({
449
+ gateDeps: createProductionGateDeps(),
450
+ featureFlagProbe: flagId => isFeatureEnabled(flags, flagId),
451
+ });
452
+ const reader = new PromotionReadinessReader({
453
+ listCodeToolHookActivations: () => activationStore.listCodeToolHookActivations(false),
454
+ getArtifactById: artifactId => manager.piArtifactStore.getArtifactById(artifactId),
455
+ computeArtifactDigest: artifact => `sha256:${createHash('sha256').update(JSON.stringify(artifact), 'utf8').digest('hex')}`,
456
+ validateProductionArtifact: artifact => writer.canActivate(artifact),
457
+ collectHostChecks: async artifact => {
458
+ const liveArtifacts: PIArtifactSnapshot[] = [];
459
+ const activations = await activationStore.listCodeToolHookActivations(false);
460
+ for (const active of activations) {
461
+ if (active.action !== 'code_tool_hook_live_activate' || active.deactivatedAt !== null) continue;
462
+ const liveArtifact = await manager.piArtifactStore.getArtifactById(active.artifactId);
463
+ if (liveArtifact) liveArtifacts.push(liveArtifact);
464
+ }
465
+ return collectOpenClawPromotionChecks(artifact, {
466
+ ownerIdentityConfigured: actor.principal.kind === 'configured_owner'
467
+ && actor.authentication.method === 'cli_owner_credential',
468
+ safetyControlsEnabled: isFeatureEnabled(flags, 'rulecode_safety_controls'),
469
+ hostContract: OPENCLAW_HOST_LIVENESS_CONTRACT,
470
+ existingLiveArtifacts: liveArtifacts,
471
+ validateProductionArtifact: value => writer.canActivate(value),
472
+ });
473
+ },
474
+ buildEvidenceSnapshot: (checks, artifact) => {
475
+ const createdAt = new Date().toISOString();
476
+ const artifactDigest = artifact
477
+ ? `sha256:${createHash('sha256').update(JSON.stringify(artifact), 'utf8').digest('hex')}`
478
+ : request.expectedArtifactDigest;
479
+ const snapshotBody = JSON.stringify({ artifactDigest, checks, createdAt });
480
+ return {
481
+ snapshotId: `snapshot-${randomUUID()}`,
482
+ snapshotDigest: `sha256:${createHash('sha256').update(snapshotBody, 'utf8').digest('hex')}`,
483
+ artifactDigest,
484
+ lineageRefs: artifact ? [artifact.sourceTaskId, ...artifact.lineageArtifactIds] : [],
485
+ hostRuntimeVersion: 'openclaw-legacy@1', safetyGateResults: checks,
486
+ shadowSummary: readShadowSummary(workspaceDir, activationId),
487
+ configurationVersion: 'pd-config-current',
488
+ redaction: { version: 'v1', rawParametersStored: false }, createdAt,
489
+ };
490
+ },
491
+ newEvaluationId: () => `readiness-${randomUUID()}`,
492
+ });
493
+ return reader.evaluate(request);
494
+ },
495
+ commitPromotion: async input => {
496
+ const manager = await getStateManager();
497
+ return new SqliteActivationSafetyStore(manager.connection).commitPromotion(input);
498
+ },
499
+ newDecisionId: () => `decision-${randomUUID()}`,
500
+ now: () => new Date().toISOString(),
501
+ });
502
+ const result = await service.promote({
503
+ activationId,
504
+ expectedArtifactId: opts.artifactId?.trim() ?? '',
505
+ expectedArtifactDigest: opts.artifactDigest?.trim() ?? '',
506
+ expectedControlVersion: opts.controlVersion ?? 0,
507
+ idempotencyKey: opts.idempotencyKey?.trim() ?? '',
508
+ reasonCode: opts.reasonCode?.trim() ?? '',
509
+ note: opts.note,
510
+ confirmed: opts.confirm === true,
511
+ dryRun: opts.dryRun === true,
512
+ }, actor);
513
+ if (!result.ok) {
514
+ refuse(result.reasonCode, result.nextAction, { summary: result.summary, failedChecks: result.failedChecks });
425
515
  return;
426
516
  }
427
-
428
- const promotedAt = new Date().toISOString();
429
- const promoted = await store.promoteActivation(activationId, promotedAt);
430
- if (!promoted) {
431
- refuse('promotion_precondition_changed', 'Refresh the activation list; the activation changed before promotion.');
517
+ if (result.decision === 'would_promote') {
518
+ const output: ActivationPromoteResult = {
519
+ ok: true, decision: 'would_promote', activationId: result.activationId,
520
+ summary: `Readiness ${result.readinessEvaluationId} passed without mutation.`,
521
+ nextAction: `Re-run with --confirm using evidence snapshot ${result.evidenceSnapshotDigest}.`,
522
+ };
523
+ if (opts.json) console.log(JSON.stringify(output));
524
+ else console.log(`Would promote: ${result.activationId}`);
432
525
  return;
433
526
  }
434
- const result: ActivationPromoteResult = { ok: true, decision: 'promoted', activationId, promotedAt };
435
- if (opts.json) console.log(JSON.stringify(result));
436
- else {
437
- console.log(`Promoted live: ${activationId}`);
438
- console.log(` promotedAt: ${promotedAt}`);
439
- }
527
+ const output: ActivationPromoteResult = { ok: true, decision: 'promoted', activationId: result.activationId, promotedAt: result.promotedAt };
528
+ if (opts.json) console.log(JSON.stringify(output));
529
+ else console.log(`Promoted live: ${result.activationId}`);
440
530
  } catch (err: unknown) {
441
531
  refuse(
442
532
  `promotion_failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -472,9 +562,9 @@ export interface ActivationStatusDerivation {
472
562
  /**
473
563
  * Derive owner-facing status + nextAction for an activation record.
474
564
  *
475
- * Extract as a pure function so the CLI hints (promote/deactivate) can be
476
- * regression-tested. Rule: deactivate never takes --confirm (promote does);
477
- * keep the two templates distinct to avoid copy-paste regressions.
565
+ * Extract as a pure function so the CLI hints can be regression-tested.
566
+ * Shadow activations point at the Owner decision contract instead of
567
+ * advertising a raw lifecycle mutation that can bypass review evidence.
478
568
  */
479
569
  export function deriveActivationStatusAndNextAction(
480
570
  input: ActivationNextActionInput,
@@ -492,7 +582,7 @@ export function deriveActivationStatusAndNextAction(
492
582
  if (mode === 'shadow') {
493
583
  return {
494
584
  status: 'active',
495
- nextAction: `pd activation promote --activation-id ${activationId} --confirm`,
585
+ nextAction: 'Keep shadow; promotion requires an authenticated Owner decision, immutable evidence bindings, and a passing Promotion Readiness result.',
496
586
  };
497
587
  }
498
588
  if (mode === 'live') {
@@ -1257,6 +1347,12 @@ export function registerRuntimeActivationPromoteCommand(parent: Command): Comman
1257
1347
  .option('-w, --workspace <path>', 'Workspace directory')
1258
1348
  .option('--dry-run', 'Validate eligibility without changing activation state')
1259
1349
  .option('--confirm', 'Confirm promotion to live blocking')
1350
+ .option('--artifact-id <id>', 'Expected artifact ID from Owner review')
1351
+ .option('--artifact-digest <digest>', 'Expected artifact digest from Owner review')
1352
+ .option('--control-version <n>', 'Expected activation control version', (value) => Number.parseInt(value, 10))
1353
+ .option('--idempotency-key <key>', 'Idempotency key for the Owner decision')
1354
+ .option('--reason <code>', 'Owner decision reason code')
1355
+ .option('--note <text>', 'Required CLI Owner review note')
1260
1356
  .option('--json', 'Output raw JSON')
1261
1357
  .action(async (opts) => {
1262
1358
  await handleRuntimeActivationPromote({
@@ -1265,6 +1361,12 @@ export function registerRuntimeActivationPromoteCommand(parent: Command): Comman
1265
1361
  dryRun: opts.dryRun,
1266
1362
  confirm: opts.confirm,
1267
1363
  json: opts.json,
1364
+ artifactId: opts.artifactId,
1365
+ artifactDigest: opts.artifactDigest,
1366
+ controlVersion: opts.controlVersion,
1367
+ idempotencyKey: opts.idempotencyKey,
1368
+ reasonCode: opts.reason,
1369
+ note: opts.note,
1268
1370
  });
1269
1371
  });
1270
1372
  }