] * : Output format. * --- * default: table * options: * - table * - json * --- * * ## EXAMPLES * * wp agent-firewall status * wp agent-firewall status --format=json * * @param array $args Positional arguments. * @param array $assoc_args Flags. */ public function status( array $args, array $assoc_args ): void { $format = isset( $assoc_args['format'] ) ? (string) $assoc_args['format'] : 'table'; if ( ! in_array( $format, self::FORMATS, true ) ) { WP_CLI::error( sprintf( /* translators: 1: the value supplied, 2: supported formats. */ __( 'Unknown --format "%1$s". Supported: %2$s.', 'agentic-daisy-ai-agent-firewall' ), $format, implode( ', ', self::FORMATS ) ) ); return; } $break = $this->writer->diagnose(); $broken = $break?->entry_id; $oldest = $this->ledger->oldest_timestamp(); $days = $this->retention->retention_days(); // An anchor exists only once a prune has happened, so it is the // signal that "1 entry" means a trimmed log rather than a quiet site. $trimmed = '' !== (string) get_option( LedgerWriter::OPTION_ANCHOR, '' ); $status = array( 'ledger_entries' => $this->ledger->count(), 'oldest_entry' => $oldest, 'chain_ok' => null === $broken, 'broken_at' => $broken, 'broken_reason' => $break?->reason, 'retention_days' => $days, 'history_trimmed' => $trimmed, 'pending_actions' => count( $this->pending->ledger_ids_awaiting_decision() ), ); if ( 'json' === $format ) { WP_CLI::line( (string) wp_json_encode( $status ) ); } else { foreach ( $this->rows( $status ) as $label => $value ) { WP_CLI::line( str_pad( $label, 18 ) . $value ); } } if ( null !== $break ) { WP_CLI::warning( sprintf( /* translators: 1: ledger entry id, 2: what verification found. */ __( 'Chain verification failed at entry %1$d. %2$s Run `wp agent-firewall verify` for a failing exit code.', 'agentic-daisy-ai-agent-firewall' ), $break->entry_id, $break->describe() ) ); } } /** * Human-readable rendering of the status fields. * * @param array $status Status fields. * @return array */ private function rows( array $status ): array { return array( __( 'Entries', 'agentic-daisy-ai-agent-firewall' ) => (string) $status['ledger_entries'], __( 'Oldest', 'agentic-daisy-ai-agent-firewall' ) => null === $status['oldest_entry'] ? __( 'none', 'agentic-daisy-ai-agent-firewall' ) : (string) $status['oldest_entry'], __( 'Chain', 'agentic-daisy-ai-agent-firewall' ) => true === $status['chain_ok'] ? __( 'ok', 'agentic-daisy-ai-agent-firewall' ) : sprintf( /* translators: %d: ledger entry id. */ __( 'BROKEN at entry %d', 'agentic-daisy-ai-agent-firewall' ), (int) $status['broken_at'] ), __( 'Retention', 'agentic-daisy-ai-agent-firewall' ) => 0 === $status['retention_days'] ? __( 'off (keeping everything)', 'agentic-daisy-ai-agent-firewall' ) : sprintf( /* translators: %d: retention window in days. */ _n( '%d day', '%d days', (int) $status['retention_days'], 'agentic-daisy-ai-agent-firewall' ), (int) $status['retention_days'] ), __( 'Trimmed', 'agentic-daisy-ai-agent-firewall' ) => $status['history_trimmed'] ? __( 'yes, older history has been pruned', 'agentic-daisy-ai-agent-firewall' ) : __( 'no', 'agentic-daisy-ai-agent-firewall' ), __( 'Awaiting', 'agentic-daisy-ai-agent-firewall' ) => (string) $status['pending_actions'], ); } /** * Verify the ledger's hash chain. * * Exits non-zero if any entry fails to verify, naming the first one, so * this can be run unattended. * * ## EXAMPLES * * wp agent-firewall verify * * @param array $args Positional arguments. * @param array $assoc_args Flags. */ public function verify( array $args, array $assoc_args ): void { $break = $this->writer->diagnose(); $total = $this->ledger->count(); if ( null !== $break ) { WP_CLI::error( sprintf( /* translators: 1: ledger entry id, 2: what verification found. */ __( 'Ledger chain broken at entry %1$d. %2$s', 'agentic-daisy-ai-agent-firewall' ), $break->entry_id, $break->describe() ) ); return; } WP_CLI::success( sprintf( /* translators: %d: number of entries verified. */ _n( '%d ledger entry verified.', '%d ledger entries verified.', $total, 'agentic-daisy-ai-agent-firewall' ), $total ) ); } /** * Apply the ledger retention window, removing the oldest entries. * * ## OPTIONS * * [--days=] * : Use this window for one run instead of the stored setting. Does not * change the setting. * * [--dry-run] * : Report what would be removed without removing it. * * [--yes] * : Skip the confirmation prompt. * * ## EXAMPLES * * wp agent-firewall prune --dry-run * wp agent-firewall prune --days=90 --yes * * @param array $args Positional arguments. * @param array $assoc_args Flags. */ public function prune( array $args, array $assoc_args ): void { $days = $this->window( $assoc_args ); if ( null === $days && isset( $assoc_args['days'] ) ) { return; } $effective = $days ?? $this->retention->retention_days(); if ( 0 === $effective ) { WP_CLI::warning( __( 'Ledger retention is off, so nothing was removed. Set a window in the admin screen, or pass --days for a one-off trim.', 'agentic-daisy-ai-agent-firewall' ) ); return; } $preview = $this->retention->preview( null, $days ); if ( 0 === $preview['count'] ) { WP_CLI::success( sprintf( /* translators: %s: cutoff timestamp. */ __( 'Nothing to remove: no entries older than %s, or the oldest are held for approval.', 'agentic-daisy-ai-agent-firewall' ), (string) $preview['cutoff'] ) ); return; } $summary = sprintf( /* translators: 1: number of entries, 2: cutoff timestamp. */ _n( '%1$d entry recorded before %2$s would be removed.', '%1$d entries recorded before %2$s would be removed.', $preview['count'], 'agentic-daisy-ai-agent-firewall' ), $preview['count'], (string) $preview['cutoff'] ); if ( isset( $assoc_args['dry-run'] ) ) { WP_CLI::line( $summary ); WP_CLI::line( __( 'Dry run: nothing was removed.', 'agentic-daisy-ai-agent-firewall' ) ); return; } WP_CLI::line( $summary ); WP_CLI::confirm( __( 'Audit records cannot be recovered once removed. Continue?', 'agentic-daisy-ai-agent-firewall' ), $assoc_args ); $removed = $this->retention->prune( null, $days ); WP_CLI::success( sprintf( /* translators: %d: number of entries removed. */ _n( '%d entry removed. The chain was re-anchored and the removal recorded in the ledger.', '%d entries removed. The chain was re-anchored and the removal recorded in the ledger.', $removed, 'agentic-daisy-ai-agent-firewall' ), $removed ) ); } /** * The one-off window from --days, or null when not supplied. * * A window that is not a plain positive integer is refused rather than * coerced: "soon" becoming 0 would silently mean "keep everything", and * "30 days" becoming 30 would only happen to be right. * * @param array $assoc_args Flags. */ private function window( array $assoc_args ): ?int { if ( ! isset( $assoc_args['days'] ) ) { return null; } $raw = (string) $assoc_args['days']; if ( 1 !== preg_match( '/^[0-9]+$/', $raw ) ) { WP_CLI::error( sprintf( /* translators: %s: the value supplied. */ __( '--days must be a whole number of days; got "%s".', 'agentic-daisy-ai-agent-firewall' ), $raw ) ); return null; } $days = (int) $raw; if ( $days < 1 || $days > RetentionService::MAX_DAYS ) { WP_CLI::error( sprintf( /* translators: %d: maximum window in days. */ __( '--days must be between 1 and %d.', 'agentic-daisy-ai-agent-firewall' ), RetentionService::MAX_DAYS ) ); return null; } return $days; } }